From a5130aaf531ab30cf2c5ee0291c054625f462596 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:02:19 +0100 Subject: [PATCH 01/15] Timetable for Courses completed --- src/extensions/timetable.py | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/extensions/timetable.py diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py new file mode 100644 index 0000000..881c340 --- /dev/null +++ b/src/extensions/timetable.py @@ -0,0 +1,84 @@ +import collections +import itertools +import aiohttp +import arc +import hikari + +from src.config import Colour +from src.models import Blockbot, BlockbotContext, BlockbotPlugin + +plugin = BlockbotPlugin(name="Get Timetable Command") + +timetables = plugin.include_slash_group("timetables", "Get Timetable's ics files and urls.") + +TIMETABLE_CACHE = {} + +TIMETABLE_ENDPOINTS = { + "course": "https://timetable.redbrick.dcu.ie/api/all/course", + "module": "https://timetable.redbrick.dcu.ie/api/all/module", + "location": "https://timetable.redbrick.dcu.ie/api/all/location", + "club": "https://timetable.redbrick.dcu.ie/api/all/club", + "society": "https://timetable.redbrick.dcu.ie/api/all/society", +} + +async def fetch_and_cache_timetable_data(): + async with aiohttp.ClientSession() as session: + for key, url in TIMETABLE_ENDPOINTS.items(): + async with session.get(url) as resp: + if resp.status == 200: + TIMETABLE_CACHE[key] = await resp.json() + else: + TIMETABLE_CACHE[key] = [] + +@timetables.include +@arc.slash_subcommand("course", "Allows you to get the timetable for a subject using the course code.") +async def timetable_command( + ctx: BlockbotContext, + course_id: arc.Option[ + str, arc.StrParams(description="The course code. E.g. 'COMSCI1'.", min_length=3, max_length=12) + ], + ) -> None: + # Ensure cache is populated + if not TIMETABLE_CACHE: + await fetch_and_cache_timetable_data() + + matching_courses = [ + item for item in TIMETABLE_CACHE.get("course", []) + if course_id.lower() in item.get("name", "").lower() + ] + + if len(matching_courses) > 1: + choices = "\n".join( + f"- {item.get('name', '')} (ID: {item.get('identity', '')})" + for item in matching_courses + ) + + embed = hikari.Embed( + title="Multiple Matches Found", + description=f"Multiple courses matched your query. Please be more specific or use the ID:\n{choices}", + color=Colour.GERRY_YELLOW, + ) + await ctx.respond(embed=embed) + return + elif len(matching_courses) == 1: + course = matching_courses[0] + ics_url = f"https://timetable.redbrick.dcu.ie/api?courses={course.get('identity', '')}" + embed = hikari.Embed( + title=f"Timetable for {course.get('name', '')}", + description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", + color=Colour.BRICKIE_BLUE, + ).set_footer(text="Powered by TimetableSync") + await ctx.respond(embed=embed) + return + + embed = hikari.Embed( + title="Course Not Found", + description=f"No course found matching '{course_id}'. Please check the course code and try again", + color=Colour.REDBRICK_RED, + ) + await ctx.respond(embed=embed) + +@arc.loader +def loader(client: Blockbot) -> None: + client.add_plugin(plugin) + From b62f2b7cebcf6ad043f444d21dfaf11ddc211fe5 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:59:20 +0100 Subject: [PATCH 02/15] Added module, course, location, club and societies --- src/extensions/timetable.py | 102 ++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 881c340..263cd86 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -9,7 +9,7 @@ plugin = BlockbotPlugin(name="Get Timetable Command") -timetables = plugin.include_slash_group("timetables", "Get Timetable's ics files and urls.") +timetable = plugin.include_slash_group("timetable", "Get Timetable's ics files and urls.") TIMETABLE_CACHE = {} @@ -30,41 +30,47 @@ async def fetch_and_cache_timetable_data(): else: TIMETABLE_CACHE[key] = [] -@timetables.include -@arc.slash_subcommand("course", "Allows you to get the timetable for a subject using the course code.") -async def timetable_command( - ctx: BlockbotContext, - course_id: arc.Option[ - str, arc.StrParams(description="The course code. E.g. 'COMSCI1'.", min_length=3, max_length=12) - ], - ) -> None: +async def send_timetable_info(ctx, timetable_type, user_data): # Ensure cache is populated if not TIMETABLE_CACHE: await fetch_and_cache_timetable_data() - matching_courses = [ - item for item in TIMETABLE_CACHE.get("course", []) - if course_id.lower() in item.get("name", "").lower() + matching_fields = [ + item for item in TIMETABLE_CACHE.get(timetable_type, []) + if user_data.lower() in item.get("name", "").lower() ] - if len(matching_courses) > 1: - choices = "\n".join( + if len(matching_fields) > 1: + max_length = 4096 + base_text = f"Multiple {str(timetable_type)}s matched your query. Please be more specific:\n" + choices_lines = [ f"- {item.get('name', '')} (ID: {item.get('identity', '')})" - for item in matching_courses - ) + for item in matching_fields + ] + choices_str = "" + for line in choices_lines: + if len(base_text) + len(choices_str) + len(line) + 4 > max_length: + choices_str += "\n..." + break + choices_str += line + "\n" embed = hikari.Embed( title="Multiple Matches Found", - description=f"Multiple courses matched your query. Please be more specific or use the ID:\n{choices}", + description=base_text + choices_str, color=Colour.GERRY_YELLOW, ) await ctx.respond(embed=embed) return - elif len(matching_courses) == 1: - course = matching_courses[0] - ics_url = f"https://timetable.redbrick.dcu.ie/api?courses={course.get('identity', '')}" + elif len(matching_fields) == 1: + match = matching_fields[0] + if (timetable_type != "club") and (timetable_type != "society"): + ics_url = f"https://timetable.redbrick.dcu.ie/api?{str(timetable_type)}s={match.get('identity', '')}" + else: + if timetable_type == "society": + timetable_type = "societie" + ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{str(timetable_type)}s={match.get('identity', '')}" embed = hikari.Embed( - title=f"Timetable for {course.get('name', '')}", + title=f"Timetable for {match.get('name', '')}", description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", color=Colour.BRICKIE_BLUE, ).set_footer(text="Powered by TimetableSync") @@ -72,12 +78,62 @@ async def timetable_command( return embed = hikari.Embed( - title="Course Not Found", - description=f"No course found matching '{course_id}'. Please check the course code and try again", + title=f"{str(timetable_type).capitalize()} Not Found", + description=f"No {str(timetable_type)} found matching '{user_data}'. Please check the {str(timetable_type)} code/name and try again", color=Colour.REDBRICK_RED, ) await ctx.respond(embed=embed) +@timetable.include +@arc.slash_subcommand("course", "Allows you to get the timetable for a course using the course code.") +async def timetable_command( + ctx: BlockbotContext, + course_id: arc.Option[ + str, arc.StrParams(description="The course code. E.g. 'COMSCI1'.", min_length=3, max_length=12) + ], + ) -> None: + await send_timetable_info(ctx, "course", course_id) + +@timetable.include +@arc.slash_subcommand("module", "Allows you to get the timetable for a module using the module code.") +async def timetable_command( + ctx: BlockbotContext, + module_id: arc.Option[ + str, arc.StrParams(description="The module code. E.g. 'ACC1005'.", min_length=3, max_length=12) + ], + ) -> None: + await send_timetable_info(ctx, "module", module_id) + +@timetable.include +@arc.slash_subcommand("location", "Allows you to get the timetable for a location using its location code.") +async def timetable_command( + ctx: BlockbotContext, + location_id: arc.Option[ + str, arc.StrParams(description="The location code. E.g. 'AHC.CG01'.", min_length=3, max_length=12) + ], + ) -> None: + await send_timetable_info(ctx, "location", location_id) + +@timetable.include +@arc.slash_subcommand("club", "Allows you to get the timetable for a Specific club using its name.") +async def timetable_command( + ctx: BlockbotContext, + club_name: arc.Option[ + str, arc.StrParams(description="The club name. E.g. 'Archery Club'.", min_length=3, max_length=12) + ], + ) -> None: + await send_timetable_info(ctx, "club", club_name) + +@timetable.include +@arc.slash_subcommand("society", "Allows you to get the timetable for a specific society using its name.") +async def timetable_command( + ctx: BlockbotContext, + society_name: arc.Option[ + str, arc.StrParams(description="The society name. E.g. 'Redbrick'.", min_length=3, max_length=12) + ], + ) -> None: + await send_timetable_info(ctx, "society", society_name) + @arc.loader def loader(client: Blockbot) -> None: client.add_plugin(plugin) From e989cf3226ab8f0552e011468fdf9cdd5971e8b9 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:00:53 +0100 Subject: [PATCH 03/15] Formatting! --- src/extensions/timetable.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 263cd86..f0948f0 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -31,7 +31,6 @@ async def fetch_and_cache_timetable_data(): TIMETABLE_CACHE[key] = [] async def send_timetable_info(ctx, timetable_type, user_data): - # Ensure cache is populated if not TIMETABLE_CACHE: await fetch_and_cache_timetable_data() @@ -69,11 +68,13 @@ async def send_timetable_info(ctx, timetable_type, user_data): if timetable_type == "society": timetable_type = "societie" ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{str(timetable_type)}s={match.get('identity', '')}" + embed = hikari.Embed( title=f"Timetable for {match.get('name', '')}", description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", color=Colour.BRICKIE_BLUE, ).set_footer(text="Powered by TimetableSync") + await ctx.respond(embed=embed) return From 60c9bad03a96578f4f361f3b2d0aeaa2a9d0fffb Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Fri, 26 Sep 2025 18:21:43 +0100 Subject: [PATCH 04/15] Formatting with ruff --- src/extensions/timetable.py | 110 +++++++++++++++++++++++++----------- 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index f0948f0..a730345 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -1,5 +1,3 @@ -import collections -import itertools import aiohttp import arc import hikari @@ -9,7 +7,9 @@ plugin = BlockbotPlugin(name="Get Timetable Command") -timetable = plugin.include_slash_group("timetable", "Get Timetable's ics files and urls.") +timetable = plugin.include_slash_group( + "timetable", "Get Timetable's ics files and urls." +) TIMETABLE_CACHE = {} @@ -21,7 +21,8 @@ "society": "https://timetable.redbrick.dcu.ie/api/all/society", } -async def fetch_and_cache_timetable_data(): + +async def fetch_and_cache_timetable_data() -> None: async with aiohttp.ClientSession() as session: for key, url in TIMETABLE_ENDPOINTS.items(): async with session.get(url) as resp: @@ -30,18 +31,22 @@ async def fetch_and_cache_timetable_data(): else: TIMETABLE_CACHE[key] = [] -async def send_timetable_info(ctx, timetable_type, user_data): + +async def send_timetable_info( + ctx: BlockbotContext, timetable_type: str, user_data: str +) -> None: if not TIMETABLE_CACHE: await fetch_and_cache_timetable_data() matching_fields = [ - item for item in TIMETABLE_CACHE.get(timetable_type, []) + item + for item in TIMETABLE_CACHE.get(timetable_type, []) if user_data.lower() in item.get("name", "").lower() ] if len(matching_fields) > 1: max_length = 4096 - base_text = f"Multiple {str(timetable_type)}s matched your query. Please be more specific:\n" + base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" choices_lines = [ f"- {item.get('name', '')} (ID: {item.get('identity', '')})" for item in matching_fields @@ -60,15 +65,15 @@ async def send_timetable_info(ctx, timetable_type, user_data): ) await ctx.respond(embed=embed) return - elif len(matching_fields) == 1: + if len(matching_fields) == 1: match = matching_fields[0] - if (timetable_type != "club") and (timetable_type != "society"): - ics_url = f"https://timetable.redbrick.dcu.ie/api?{str(timetable_type)}s={match.get('identity', '')}" + if timetable_type not in {"club", "society"}: + ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match.get('identity', '')}" else: if timetable_type == "society": timetable_type = "societie" - ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{str(timetable_type)}s={match.get('identity', '')}" - + ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{timetable_type!s}s={match.get('identity', '')}" + embed = hikari.Embed( title=f"Timetable for {match.get('name', '')}", description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", @@ -80,62 +85,99 @@ async def send_timetable_info(ctx, timetable_type, user_data): embed = hikari.Embed( title=f"{str(timetable_type).capitalize()} Not Found", - description=f"No {str(timetable_type)} found matching '{user_data}'. Please check the {str(timetable_type)} code/name and try again", + description=f"No {timetable_type!s} found matching '{user_data}'. Please check the {timetable_type!s} code/name and try again", color=Colour.REDBRICK_RED, ) await ctx.respond(embed=embed) + @timetable.include -@arc.slash_subcommand("course", "Allows you to get the timetable for a course using the course code.") -async def timetable_command( +@arc.slash_subcommand( + "course", "Allows you to get the timetable for a course using the course code." +) +async def course_command( ctx: BlockbotContext, course_id: arc.Option[ - str, arc.StrParams(description="The course code. E.g. 'COMSCI1'.", min_length=3, max_length=12) + str, + arc.StrParams( + description="The course code. E.g. 'COMSCI1'.", min_length=3, max_length=12 + ), ], - ) -> None: +) -> None: await send_timetable_info(ctx, "course", course_id) + @timetable.include -@arc.slash_subcommand("module", "Allows you to get the timetable for a module using the module code.") -async def timetable_command( +@arc.slash_subcommand( + "module", "Allows you to get the timetable for a module using the module code." +) +async def module_command( ctx: BlockbotContext, module_id: arc.Option[ - str, arc.StrParams(description="The module code. E.g. 'ACC1005'.", min_length=3, max_length=12) + str, + arc.StrParams( + description="The module code. E.g. 'ACC1005'.", min_length=3, max_length=12 + ), ], - ) -> None: +) -> None: await send_timetable_info(ctx, "module", module_id) + @timetable.include -@arc.slash_subcommand("location", "Allows you to get the timetable for a location using its location code.") -async def timetable_command( +@arc.slash_subcommand( + "location", + "Allows you to get the timetable for a location using its location code.", +) +async def location_command( ctx: BlockbotContext, location_id: arc.Option[ - str, arc.StrParams(description="The location code. E.g. 'AHC.CG01'.", min_length=3, max_length=12) + str, + arc.StrParams( + description="The location code. E.g. 'AHC.CG01'.", + min_length=3, + max_length=12, + ), ], - ) -> None: +) -> None: await send_timetable_info(ctx, "location", location_id) + @timetable.include -@arc.slash_subcommand("club", "Allows you to get the timetable for a Specific club using its name.") -async def timetable_command( +@arc.slash_subcommand( + "club", "Allows you to get the timetable for a Specific club using its name." +) +async def club_command( ctx: BlockbotContext, club_name: arc.Option[ - str, arc.StrParams(description="The club name. E.g. 'Archery Club'.", min_length=3, max_length=12) + str, + arc.StrParams( + description="The club name. E.g. 'Archery Club'.", + min_length=3, + max_length=12, + ), ], - ) -> None: +) -> None: await send_timetable_info(ctx, "club", club_name) + @timetable.include -@arc.slash_subcommand("society", "Allows you to get the timetable for a specific society using its name.") -async def timetable_command( +@arc.slash_subcommand( + "society", "Allows you to get the timetable for a specific society using its name." +) +async def society_command( ctx: BlockbotContext, society_name: arc.Option[ - str, arc.StrParams(description="The society name. E.g. 'Redbrick'.", min_length=3, max_length=12) + str, + arc.StrParams( + description="The society name. E.g. 'Redbrick'.", + min_length=3, + max_length=12, + ), ], - ) -> None: +) -> None: await send_timetable_info(ctx, "society", society_name) + @arc.loader def loader(client: Blockbot) -> None: client.add_plugin(plugin) - From 7d283a207f20d4f6fd341dce6997a0f8629254fc Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:13:51 +0100 Subject: [PATCH 05/15] Being more strict with types --- src/extensions/timetable.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index a730345..3572479 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -11,7 +11,7 @@ "timetable", "Get Timetable's ics files and urls." ) -TIMETABLE_CACHE = {} +TIMETABLE_CACHE: dict[str, list[dict]] = {} TIMETABLE_ENDPOINTS = { "course": "https://timetable.redbrick.dcu.ie/api/all/course", @@ -38,17 +38,17 @@ async def send_timetable_info( if not TIMETABLE_CACHE: await fetch_and_cache_timetable_data() - matching_fields = [ + matching_fields: list[dict] = [ item for item in TIMETABLE_CACHE.get(timetable_type, []) - if user_data.lower() in item.get("name", "").lower() + if user_data.lower() in str(item.get("name", "")).lower() ] if len(matching_fields) > 1: max_length = 4096 base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" choices_lines = [ - f"- {item.get('name', '')} (ID: {item.get('identity', '')})" + f"- {dict(item).get('name', '')} (ID: {dict(item).get('identity', '')})" for item in matching_fields ] choices_str = "" @@ -66,7 +66,7 @@ async def send_timetable_info( await ctx.respond(embed=embed) return if len(matching_fields) == 1: - match = matching_fields[0] + match: dict = matching_fields[0] if timetable_type not in {"club", "society"}: ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match.get('identity', '')}" else: From 91d6156953b70b7a89514dd6240992d022962fb3 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:37:18 +0100 Subject: [PATCH 06/15] More formatting (pyright) --- src/extensions/timetable.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 3572479..0719379 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -11,7 +11,7 @@ "timetable", "Get Timetable's ics files and urls." ) -TIMETABLE_CACHE: dict[str, list[dict]] = {} +TIMETABLE_CACHE: dict[str, list[dict[str, str]]] = {} TIMETABLE_ENDPOINTS = { "course": "https://timetable.redbrick.dcu.ie/api/all/course", @@ -38,7 +38,7 @@ async def send_timetable_info( if not TIMETABLE_CACHE: await fetch_and_cache_timetable_data() - matching_fields: list[dict] = [ + matching_fields: list[dict[str, str]] = [ item for item in TIMETABLE_CACHE.get(timetable_type, []) if user_data.lower() in str(item.get("name", "")).lower() @@ -48,7 +48,7 @@ async def send_timetable_info( max_length = 4096 base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" choices_lines = [ - f"- {dict(item).get('name', '')} (ID: {dict(item).get('identity', '')})" + f"- {item.get('name', '')} (ID: {item.get('identity', '')})" for item in matching_fields ] choices_str = "" @@ -66,7 +66,7 @@ async def send_timetable_info( await ctx.respond(embed=embed) return if len(matching_fields) == 1: - match: dict = matching_fields[0] + match: dict[str, str] = matching_fields[0] if timetable_type not in {"club", "society"}: ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match.get('identity', '')}" else: From a0bbd02b56fe0b28bd1fd99b1e85973f7b2b965e Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:45:23 +0000 Subject: [PATCH 07/15] Rebase #2 From 9bb07f999dbe48fdaac63eeca80195be627c9571 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:59:55 +0000 Subject: [PATCH 08/15] Migrated to new query endpoint + dropdown select --- src/extensions/timetable.py | 169 +++++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 39 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 0719379..d80a9c6 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -1,6 +1,7 @@ import aiohttp import arc import hikari +import miru from src.config import Colour from src.models import Blockbot, BlockbotContext, BlockbotPlugin @@ -11,40 +12,43 @@ "timetable", "Get Timetable's ics files and urls." ) -TIMETABLE_CACHE: dict[str, list[dict[str, str]]] = {} - -TIMETABLE_ENDPOINTS = { - "course": "https://timetable.redbrick.dcu.ie/api/all/course", - "module": "https://timetable.redbrick.dcu.ie/api/all/module", - "location": "https://timetable.redbrick.dcu.ie/api/all/location", - "club": "https://timetable.redbrick.dcu.ie/api/all/club", - "society": "https://timetable.redbrick.dcu.ie/api/all/society", -} +async def _get_matching_fields( + timetable_type: str, user_data: str +) -> list[dict[str, str]]: + matching_fields: list[dict[str, str]] = [] + async with ( + aiohttp.ClientSession() as session, + session.get( + f"https://timetable.redbrick.dcu.ie/api/all/{timetable_type}?query={user_data}" + ) as resp, + ): + if resp.status == 200: + matching_fields = await resp.json() + else: + matching_fields = [] + return matching_fields -async def fetch_and_cache_timetable_data() -> None: - async with aiohttp.ClientSession() as session: - for key, url in TIMETABLE_ENDPOINTS.items(): - async with session.get(url) as resp: - if resp.status == 200: - TIMETABLE_CACHE[key] = await resp.json() - else: - TIMETABLE_CACHE[key] = [] +async def _get_ics_link(timetable_type: str, match: dict[str, str]) -> str: + if timetable_type not in {"club", "society"}: + ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match.get('identity', '')}" + else: + if timetable_type == "society": + timetable_type = "societie" + ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{timetable_type!s}s={match.get('identity', '')}" -async def send_timetable_info( - ctx: BlockbotContext, timetable_type: str, user_data: str -) -> None: - if not TIMETABLE_CACHE: - await fetch_and_cache_timetable_data() + return ics_url - matching_fields: list[dict[str, str]] = [ - item - for item in TIMETABLE_CACHE.get(timetable_type, []) - if user_data.lower() in str(item.get("name", "")).lower() - ] - if len(matching_fields) > 1: +async def _timetable_response( + ctx: BlockbotContext, + timetable_type: str, + user_data: str, + matching_fields: list[dict[str, str]], + miru_client: miru.Client, +) -> None: + if len(matching_fields) > 25: max_length = 4096 base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" choices_lines = [ @@ -65,14 +69,87 @@ async def send_timetable_info( ) await ctx.respond(embed=embed) return + + if 1 < len(matching_fields) <= 25: + + class TimetableSelectView(miru.View): + def __init__(self, user_id: int) -> None: + self.user_id = user_id + super().__init__(timeout=60) + + @miru.text_select( + custom_id="timetable_select", + placeholder="Choose an option..", + min_values=1, + max_values=1, + options=[ + miru.SelectOption( + label=item.get("name", ""), + value=item.get("identity", ""), + description=f"ID: {item.get('identity', '')}", + ) + for item in matching_fields + ], + ) + async def on_select( + self, ctx_sub: miru.ViewContext, select: miru.TextSelect + ) -> None: + # Restruct matching_fields and recall _timetable_response + selected_id = select.values[0] + # Delete original message + await ctx_sub.message.delete() + await _timetable_response( + ctx, + timetable_type, + user_data, + [ + { + "name": next( + item.get("name", "") + for item in matching_fields + if item.get("identity", "") == selected_id + ), + "identity": select.values[0], + } + ], + miru_client, + ) + + async def view_check(self, ctx_sub: miru.ViewContext) -> bool: + # This view will only handle interactions that belong to the + # user who originally ran the command. + # For every other user they will receive an error message. + if ctx_sub.user.id != self.user_id: + await ctx_sub.respond( + "You can't press this!", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return False + + return True + + async def on_timeout(self) -> None: + message = self.message + + # Since the view is bound to a message, we can assert it's not None + assert message + + await message.edit("Interaction Timed Out", components=[], embed=None) + self.stop() + + embed = hikari.Embed( + title="Multiple Matches Found", + description="Please select the correct option from the dropdown below.", + color=Colour.GERRY_YELLOW, + ) + view = TimetableSelectView(ctx.user.id) + response = await ctx.respond(embed=embed, components=view) + miru_client.start_view(view, bind_to=await response.retrieve_message()) + return + if len(matching_fields) == 1: match: dict[str, str] = matching_fields[0] - if timetable_type not in {"club", "society"}: - ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match.get('identity', '')}" - else: - if timetable_type == "society": - timetable_type = "societie" - ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{timetable_type!s}s={match.get('identity', '')}" + ics_url = await _get_ics_link(timetable_type, match) embed = hikari.Embed( title=f"Timetable for {match.get('name', '')}", @@ -91,6 +168,15 @@ async def send_timetable_info( await ctx.respond(embed=embed) +async def send_timetable_info( + ctx: BlockbotContext, timetable_type: str, user_data: str, miru_client: miru.Client +) -> None: + matching_fields = await _get_matching_fields(timetable_type, user_data) + await _timetable_response( + ctx, timetable_type, user_data, matching_fields, miru_client + ) + + @timetable.include @arc.slash_subcommand( "course", "Allows you to get the timetable for a course using the course code." @@ -103,8 +189,9 @@ async def course_command( description="The course code. E.g. 'COMSCI1'.", min_length=3, max_length=12 ), ], + miru_client: miru.Client = arc.inject(), ) -> None: - await send_timetable_info(ctx, "course", course_id) + await send_timetable_info(ctx, "course", course_id, miru_client) @timetable.include @@ -119,8 +206,9 @@ async def module_command( description="The module code. E.g. 'ACC1005'.", min_length=3, max_length=12 ), ], + miru_client: miru.Client = arc.inject(), ) -> None: - await send_timetable_info(ctx, "module", module_id) + await send_timetable_info(ctx, "module", module_id, miru_client) @timetable.include @@ -138,8 +226,9 @@ async def location_command( max_length=12, ), ], + miru_client: miru.Client = arc.inject(), ) -> None: - await send_timetable_info(ctx, "location", location_id) + await send_timetable_info(ctx, "location", location_id, miru_client) @timetable.include @@ -156,8 +245,9 @@ async def club_command( max_length=12, ), ], + miru_client: miru.Client = arc.inject(), ) -> None: - await send_timetable_info(ctx, "club", club_name) + await send_timetable_info(ctx, "club", club_name, miru_client) @timetable.include @@ -174,8 +264,9 @@ async def society_command( max_length=12, ), ], + miru_client: miru.Client = arc.inject(), ) -> None: - await send_timetable_info(ctx, "society", society_name) + await send_timetable_info(ctx, "society", society_name, miru_client) @arc.loader From 8ca07e8b4cbb8f4bdf00e73573d6b9d6d5d164e8 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:37:07 +0000 Subject: [PATCH 09/15] Added doctypes + Constants + error handling --- src/extensions/timetable.py | 41 +++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index d80a9c6..95c3c2f 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -6,6 +6,9 @@ from src.config import Colour from src.models import Blockbot, BlockbotContext, BlockbotPlugin +MAX_EMBED = 4096 +MAX_DROPDOWN_OPTIONS = 25 + plugin = BlockbotPlugin(name="Get Timetable Command") timetable = plugin.include_slash_group( @@ -16,21 +19,24 @@ async def _get_matching_fields( timetable_type: str, user_data: str ) -> list[dict[str, str]]: + """Fetch matching fields from the timetable API using the ?query.""" matching_fields: list[dict[str, str]] = [] - async with ( - aiohttp.ClientSession() as session, - session.get( - f"https://timetable.redbrick.dcu.ie/api/all/{timetable_type}?query={user_data}" - ) as resp, - ): - if resp.status == 200: - matching_fields = await resp.json() - else: - matching_fields = [] + try: + async with ( + aiohttp.ClientSession() as session, + session.get( + f"https://timetable.redbrick.dcu.ie/api/all/{timetable_type}?query={user_data}" + ) as resp, + ): + if resp.status == 200: + matching_fields = await resp.json() + except aiohttp.ClientError: + matching_fields = [] return matching_fields async def _get_ics_link(timetable_type: str, match: dict[str, str]) -> str: + """Generate the ICS link for the matched timetable using its identity.""" if timetable_type not in {"club", "society"}: ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match.get('identity', '')}" else: @@ -48,8 +54,10 @@ async def _timetable_response( matching_fields: list[dict[str, str]], miru_client: miru.Client, ) -> None: - if len(matching_fields) > 25: - max_length = 4096 + """Handle the timetable response based on the number of matches.""" + + """Display a message and ask for clarification if there are more than 25 matches.""" + if len(matching_fields) > MAX_DROPDOWN_OPTIONS: base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" choices_lines = [ f"- {item.get('name', '')} (ID: {item.get('identity', '')})" @@ -57,7 +65,7 @@ async def _timetable_response( ] choices_str = "" for line in choices_lines: - if len(base_text) + len(choices_str) + len(line) + 4 > max_length: + if len(base_text) + len(choices_str) + len(line) + 4 > MAX_EMBED: choices_str += "\n..." break choices_str += line + "\n" @@ -70,7 +78,8 @@ async def _timetable_response( await ctx.respond(embed=embed) return - if 1 < len(matching_fields) <= 25: + """Display a dropdown if there are between 2 and 25 matches.""" + if 1 < len(matching_fields) <= MAX_DROPDOWN_OPTIONS: class TimetableSelectView(miru.View): def __init__(self, user_id: int) -> None: @@ -94,10 +103,10 @@ def __init__(self, user_id: int) -> None: async def on_select( self, ctx_sub: miru.ViewContext, select: miru.TextSelect ) -> None: - # Restruct matching_fields and recall _timetable_response selected_id = select.values[0] # Delete original message await ctx_sub.message.delete() + # Recalling the timetable response with the selected option await _timetable_response( ctx, timetable_type, @@ -147,6 +156,7 @@ async def on_timeout(self) -> None: miru_client.start_view(view, bind_to=await response.retrieve_message()) return + """Display the timetable ICS link if there is exactly one match.""" if len(matching_fields) == 1: match: dict[str, str] = matching_fields[0] ics_url = await _get_ics_link(timetable_type, match) @@ -171,6 +181,7 @@ async def on_timeout(self) -> None: async def send_timetable_info( ctx: BlockbotContext, timetable_type: str, user_data: str, miru_client: miru.Client ) -> None: + """Send timetable information based on the type and user data provided.""" matching_fields = await _get_matching_fields(timetable_type, user_data) await _timetable_response( ctx, timetable_type, user_data, matching_fields, miru_client From 8144691ec6fd45aef0db7d521818b1e717dbc29e Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:30:08 +0000 Subject: [PATCH 10/15] Improvements based on Review + Fixed to proper Plugin Name + Improved description for plugin + Changed multi-line comments to single-line + Improved error and status code handling for command (Prevents double sending of results) + Used global client session through DI --- src/extensions/timetable.py | 64 +++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 95c3c2f..ee09f1d 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -9,30 +9,21 @@ MAX_EMBED = 4096 MAX_DROPDOWN_OPTIONS = 25 -plugin = BlockbotPlugin(name="Get Timetable Command") +plugin = BlockbotPlugin(name="Timetable") timetable = plugin.include_slash_group( - "timetable", "Get Timetable's ics files and urls." + "timetable", "Get timetable information from https://timetable.redbrick.dcu.ie/" ) async def _get_matching_fields( - timetable_type: str, user_data: str -) -> list[dict[str, str]]: + timetable_type: str, user_data: str, session: aiohttp.ClientSession +) -> tuple[list[dict[str, str]], int]: """Fetch matching fields from the timetable API using the ?query.""" - matching_fields: list[dict[str, str]] = [] - try: - async with ( - aiohttp.ClientSession() as session, - session.get( - f"https://timetable.redbrick.dcu.ie/api/all/{timetable_type}?query={user_data}" - ) as resp, - ): - if resp.status == 200: - matching_fields = await resp.json() - except aiohttp.ClientError: - matching_fields = [] - return matching_fields + async with session.get( + f"https://timetable.redbrick.dcu.ie/api/all/{timetable_type}?query={user_data}" + ) as resp: + return await resp.json(), resp.status async def _get_ics_link(timetable_type: str, match: dict[str, str]) -> str: @@ -56,7 +47,7 @@ async def _timetable_response( ) -> None: """Handle the timetable response based on the number of matches.""" - """Display a message and ask for clarification if there are more than 25 matches.""" + # Display a message and ask for clarification if there are more than 25 matches. if len(matching_fields) > MAX_DROPDOWN_OPTIONS: base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" choices_lines = [ @@ -78,7 +69,7 @@ async def _timetable_response( await ctx.respond(embed=embed) return - """Display a dropdown if there are between 2 and 25 matches.""" + # Display a dropdown if there are between 2 and 25 matches. if 1 < len(matching_fields) <= MAX_DROPDOWN_OPTIONS: class TimetableSelectView(miru.View): @@ -156,7 +147,7 @@ async def on_timeout(self) -> None: miru_client.start_view(view, bind_to=await response.retrieve_message()) return - """Display the timetable ICS link if there is exactly one match.""" + # Display the timetable ICS link if there is exactly one match. if len(matching_fields) == 1: match: dict[str, str] = matching_fields[0] ics_url = await _get_ics_link(timetable_type, match) @@ -179,10 +170,24 @@ async def on_timeout(self) -> None: async def send_timetable_info( - ctx: BlockbotContext, timetable_type: str, user_data: str, miru_client: miru.Client + ctx: BlockbotContext, + timetable_type: str, + user_data: str, + miru_client: miru.Client, + session_client: aiohttp.ClientSession, ) -> None: """Send timetable information based on the type and user data provided.""" - matching_fields = await _get_matching_fields(timetable_type, user_data) + matching_fields, status = await _get_matching_fields( + timetable_type, user_data, session_client + ) + + if status != 200: + await ctx.respond( + f"❌ Failed to fetch timetable data. Status code: `{status}`", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + await _timetable_response( ctx, timetable_type, user_data, matching_fields, miru_client ) @@ -201,8 +206,9 @@ async def course_command( ), ], miru_client: miru.Client = arc.inject(), + session_client: aiohttp.ClientSession = arc.inject(), ) -> None: - await send_timetable_info(ctx, "course", course_id, miru_client) + await send_timetable_info(ctx, "course", course_id, miru_client, session_client) @timetable.include @@ -218,8 +224,9 @@ async def module_command( ), ], miru_client: miru.Client = arc.inject(), + session_client: aiohttp.ClientSession = arc.inject(), ) -> None: - await send_timetable_info(ctx, "module", module_id, miru_client) + await send_timetable_info(ctx, "module", module_id, miru_client, session_client) @timetable.include @@ -238,8 +245,9 @@ async def location_command( ), ], miru_client: miru.Client = arc.inject(), + session_client: aiohttp.ClientSession = arc.inject(), ) -> None: - await send_timetable_info(ctx, "location", location_id, miru_client) + await send_timetable_info(ctx, "location", location_id, miru_client, session_client) @timetable.include @@ -257,8 +265,9 @@ async def club_command( ), ], miru_client: miru.Client = arc.inject(), + session_client: aiohttp.ClientSession = arc.inject(), ) -> None: - await send_timetable_info(ctx, "club", club_name, miru_client) + await send_timetable_info(ctx, "club", club_name, miru_client, session_client) @timetable.include @@ -276,8 +285,9 @@ async def society_command( ), ], miru_client: miru.Client = arc.inject(), + session_client: aiohttp.ClientSession = arc.inject(), ) -> None: - await send_timetable_info(ctx, "society", society_name, miru_client) + await send_timetable_info(ctx, "society", society_name, miru_client, session_client) @arc.loader From 0f6ee9770bd044a37398b9b97c7ec7fe863730fd Mon Sep 17 00:00:00 2001 From: nova Date: Wed, 7 Jan 2026 21:01:43 +0000 Subject: [PATCH 11/15] make command loader dynamic --- src/extensions/timetable.py | 135 +++++++++++++----------------------- 1 file changed, 47 insertions(+), 88 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index ee09f1d..0e276d6 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -2,6 +2,7 @@ import arc import hikari import miru +from arc.command.option import StrOption from src.config import Colour from src.models import Blockbot, BlockbotContext, BlockbotPlugin @@ -193,103 +194,61 @@ async def send_timetable_info( ) -@timetable.include -@arc.slash_subcommand( - "course", "Allows you to get the timetable for a course using the course code." -) -async def course_command( +async def timetable_command( ctx: BlockbotContext, - course_id: arc.Option[ - str, - arc.StrParams( - description="The course code. E.g. 'COMSCI1'.", min_length=3, max_length=12 - ), - ], + item_id: arc.Option[str, arc.StrParams()], miru_client: miru.Client = arc.inject(), session_client: aiohttp.ClientSession = arc.inject(), ) -> None: - await send_timetable_info(ctx, "course", course_id, miru_client, session_client) + await send_timetable_info( + ctx, ctx.command.name, item_id, miru_client, session_client + ) -@timetable.include -@arc.slash_subcommand( - "module", "Allows you to get the timetable for a module using the module code." -) -async def module_command( - ctx: BlockbotContext, - module_id: arc.Option[ - str, - arc.StrParams( - description="The module code. E.g. 'ACC1005'.", min_length=3, max_length=12 +@arc.loader +def loader(client: Blockbot) -> None: + for name, cmd_description, opt_description in ( + ( + "course", + "Allows you to get the timetable for a course using the course code.", + "The course code. E.g. 'COMSCI1'.", ), - ], - miru_client: miru.Client = arc.inject(), - session_client: aiohttp.ClientSession = arc.inject(), -) -> None: - await send_timetable_info(ctx, "module", module_id, miru_client, session_client) - - -@timetable.include -@arc.slash_subcommand( - "location", - "Allows you to get the timetable for a location using its location code.", -) -async def location_command( - ctx: BlockbotContext, - location_id: arc.Option[ - str, - arc.StrParams( - description="The location code. E.g. 'AHC.CG01'.", - min_length=3, - max_length=12, + ( + "module", + "Allows you to get the timetable for a module using the module code.", + "The module code. E.g. 'ACC1005'.", ), - ], - miru_client: miru.Client = arc.inject(), - session_client: aiohttp.ClientSession = arc.inject(), -) -> None: - await send_timetable_info(ctx, "location", location_id, miru_client, session_client) - - -@timetable.include -@arc.slash_subcommand( - "club", "Allows you to get the timetable for a Specific club using its name." -) -async def club_command( - ctx: BlockbotContext, - club_name: arc.Option[ - str, - arc.StrParams( - description="The club name. E.g. 'Archery Club'.", - min_length=3, - max_length=12, + ( + "location", + "Allows you to get the timetable for a location using its location code.", + "The location code. E.g. 'AHC.CG01'.", ), - ], - miru_client: miru.Client = arc.inject(), - session_client: aiohttp.ClientSession = arc.inject(), -) -> None: - await send_timetable_info(ctx, "club", club_name, miru_client, session_client) - - -@timetable.include -@arc.slash_subcommand( - "society", "Allows you to get the timetable for a specific society using its name." -) -async def society_command( - ctx: BlockbotContext, - society_name: arc.Option[ - str, - arc.StrParams( - description="The society name. E.g. 'Redbrick'.", - min_length=3, - max_length=12, + ( + "club", + "Allows you to get the timetable for a Specific club using its name.", + "The club name. E.g. 'Archery Club'.", ), - ], - miru_client: miru.Client = arc.inject(), - session_client: aiohttp.ClientSession = arc.inject(), -) -> None: - await send_timetable_info(ctx, "society", society_name, miru_client, session_client) - + ( + "society", + "Allows you to get the timetable for a specific society using its name.", + "The society name. E.g. 'Redbrick'.", + ), + ): + timetable.include( + arc.SlashSubCommand( + name=name, + description=cmd_description, + callback=timetable_command, + options={ + "item_id": StrOption( # pyright: ignore[reportArgumentType] + name=name, + description=opt_description, + arg_name="item_id", + min_length=3, + max_length=12, + ), + }, + ) + ) -@arc.loader -def loader(client: Blockbot) -> None: client.add_plugin(plugin) From 80f376eaf69be3eea67577d3cef1ff60a782035c Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:58:19 +0000 Subject: [PATCH 12/15] Switched identity to being certain --- src/extensions/timetable.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 0e276d6..5dbd14e 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -30,11 +30,11 @@ async def _get_matching_fields( async def _get_ics_link(timetable_type: str, match: dict[str, str]) -> str: """Generate the ICS link for the matched timetable using its identity.""" if timetable_type not in {"club", "society"}: - ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match.get('identity', '')}" + ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match['identity']}" else: if timetable_type == "society": timetable_type = "societie" - ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{timetable_type!s}s={match.get('identity', '')}" + ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{timetable_type!s}s={match['identity']}" return ics_url @@ -52,7 +52,7 @@ async def _timetable_response( if len(matching_fields) > MAX_DROPDOWN_OPTIONS: base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" choices_lines = [ - f"- {item.get('name', '')} (ID: {item.get('identity', '')})" + f"- {item.get('name', '')} (ID: {item['identity']})" for item in matching_fields ] choices_str = "" @@ -86,8 +86,8 @@ def __init__(self, user_id: int) -> None: options=[ miru.SelectOption( label=item.get("name", ""), - value=item.get("identity", ""), - description=f"ID: {item.get('identity', '')}", + value=item["identity"], + description=f"ID: {item['identity']}", ) for item in matching_fields ], @@ -108,7 +108,7 @@ async def on_select( "name": next( item.get("name", "") for item in matching_fields - if item.get("identity", "") == selected_id + if item["identity"] == selected_id ), "identity": select.values[0], } From 96faf673a057b453e8558b5e9c7744657e4a5234 Mon Sep 17 00:00:00 2001 From: nova Date: Thu, 8 Jan 2026 12:21:10 +0000 Subject: [PATCH 13/15] rework the select menu --- src/extensions/timetable.py | 154 +++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 71 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 5dbd14e..7c51a2f 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -17,6 +17,66 @@ ) +class TimetableSelect(miru.TextSelect): + def __init__( + self, timetable_type: str, *, options: list[miru.SelectOption] + ) -> None: + self.timetable_type = timetable_type + + super().__init__( + options=options, + custom_id="timetable_select", + placeholder="Choose an option...", + min_values=1, + max_values=1, + ) + + async def callback(self, ctx: miru.ViewContext) -> None: + selected_identity = self.values[0] + selected_option = next( + opt for opt in self.options if opt.value == selected_identity + ) + + ics_url = await _get_ics_link(self.timetable_type, selected_identity) + + embed = hikari.Embed( + title=f"Timetable for {selected_option.label}", + description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", + color=Colour.BRICKIE_BLUE, + ).set_footer(text="Powered by TimetableSync") + + await ctx.edit_response(embed=embed, components=[]) + self.view.stop() + + +class TimetableSelectView(miru.View): + def __init__(self, user_id: int) -> None: + self.user_id = user_id + super().__init__(timeout=60) + + async def view_check(self, ctx_sub: miru.ViewContext) -> bool: + # This view will only handle interactions that belong to the + # user who originally ran the command. + # For every other user they will receive an error message. + if ctx_sub.user.id != self.user_id: + await ctx_sub.respond( + "You can't press this!", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return False + + return True + + async def on_timeout(self) -> None: + message = self.message + + # Since the view is bound to a message, we can assert it's not None + assert message + + await message.edit("Interaction Timed Out", components=[], embed=None) + self.stop() + + async def _get_matching_fields( timetable_type: str, user_data: str, session: aiohttp.ClientSession ) -> tuple[list[dict[str, str]], int]: @@ -27,14 +87,16 @@ async def _get_matching_fields( return await resp.json(), resp.status -async def _get_ics_link(timetable_type: str, match: dict[str, str]) -> str: +async def _get_ics_link(timetable_type: str, identity: str) -> str: """Generate the ICS link for the matched timetable using its identity.""" if timetable_type not in {"club", "society"}: - ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type!s}s={match['identity']}" + ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type}s={identity}" else: if timetable_type == "society": timetable_type = "societie" - ics_url = f"https://timetable.redbrick.dcu.ie/api/cns?{timetable_type!s}s={match['identity']}" + ics_url = ( + f"https://timetable.redbrick.dcu.ie/api/cns?{timetable_type}s={identity}" + ) return ics_url @@ -50,10 +112,11 @@ async def _timetable_response( # Display a message and ask for clarification if there are more than 25 matches. if len(matching_fields) > MAX_DROPDOWN_OPTIONS: - base_text = f"Multiple {timetable_type!s}s matched your query. Please be more specific:\n" + base_text = ( + f"Multiple {timetable_type}s matched your query. Please be more specific:\n" + ) choices_lines = [ - f"- {item.get('name', '')} (ID: {item['identity']})" - for item in matching_fields + f"- {item['name']} (ID: {item['identity']})" for item in matching_fields ] choices_str = "" for line in choices_lines: @@ -72,78 +135,27 @@ async def _timetable_response( # Display a dropdown if there are between 2 and 25 matches. if 1 < len(matching_fields) <= MAX_DROPDOWN_OPTIONS: + embed = hikari.Embed( + title="Multiple Matches Found", + description="Please select the correct option from the dropdown below.", + color=Colour.GERRY_YELLOW, + ) + view = TimetableSelectView(ctx.user.id) - class TimetableSelectView(miru.View): - def __init__(self, user_id: int) -> None: - self.user_id = user_id - super().__init__(timeout=60) - - @miru.text_select( - custom_id="timetable_select", - placeholder="Choose an option..", - min_values=1, - max_values=1, + view.add_item( + TimetableSelect( + timetable_type=timetable_type, options=[ miru.SelectOption( - label=item.get("name", ""), + label=item["name"], value=item["identity"], description=f"ID: {item['identity']}", ) for item in matching_fields ], ) - async def on_select( - self, ctx_sub: miru.ViewContext, select: miru.TextSelect - ) -> None: - selected_id = select.values[0] - # Delete original message - await ctx_sub.message.delete() - # Recalling the timetable response with the selected option - await _timetable_response( - ctx, - timetable_type, - user_data, - [ - { - "name": next( - item.get("name", "") - for item in matching_fields - if item["identity"] == selected_id - ), - "identity": select.values[0], - } - ], - miru_client, - ) - - async def view_check(self, ctx_sub: miru.ViewContext) -> bool: - # This view will only handle interactions that belong to the - # user who originally ran the command. - # For every other user they will receive an error message. - if ctx_sub.user.id != self.user_id: - await ctx_sub.respond( - "You can't press this!", - flags=hikari.MessageFlag.EPHEMERAL, - ) - return False - - return True - - async def on_timeout(self) -> None: - message = self.message - - # Since the view is bound to a message, we can assert it's not None - assert message - - await message.edit("Interaction Timed Out", components=[], embed=None) - self.stop() - - embed = hikari.Embed( - title="Multiple Matches Found", - description="Please select the correct option from the dropdown below.", - color=Colour.GERRY_YELLOW, ) - view = TimetableSelectView(ctx.user.id) + response = await ctx.respond(embed=embed, components=view) miru_client.start_view(view, bind_to=await response.retrieve_message()) return @@ -151,10 +163,10 @@ async def on_timeout(self) -> None: # Display the timetable ICS link if there is exactly one match. if len(matching_fields) == 1: match: dict[str, str] = matching_fields[0] - ics_url = await _get_ics_link(timetable_type, match) + ics_url = await _get_ics_link(timetable_type, match["identity"]) embed = hikari.Embed( - title=f"Timetable for {match.get('name', '')}", + title=f"Timetable for {match['name']}", description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", color=Colour.BRICKIE_BLUE, ).set_footer(text="Powered by TimetableSync") @@ -164,7 +176,7 @@ async def on_timeout(self) -> None: embed = hikari.Embed( title=f"{str(timetable_type).capitalize()} Not Found", - description=f"No {timetable_type!s} found matching '{user_data}'. Please check the {timetable_type!s} code/name and try again", + description=f"No {timetable_type} found matching '{user_data}'. Please check the {timetable_type} code/name and try again", color=Colour.REDBRICK_RED, ) await ctx.respond(embed=embed) From d17de384bcd9112cdefe49ca36b80efa354bf010 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:24:17 +0000 Subject: [PATCH 14/15] Created ics embed function to reduce repeats --- src/extensions/timetable.py | 43 +++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 7c51a2f..6538448 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -36,16 +36,12 @@ async def callback(self, ctx: miru.ViewContext) -> None: selected_option = next( opt for opt in self.options if opt.value == selected_identity ) - - ics_url = await _get_ics_link(self.timetable_type, selected_identity) - - embed = hikari.Embed( - title=f"Timetable for {selected_option.label}", - description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", - color=Colour.BRICKIE_BLUE, - ).set_footer(text="Powered by TimetableSync") - - await ctx.edit_response(embed=embed, components=[]) + await ctx.edit_response( + embed=await _create_ics_embed( + self.timetable_type, selected_identity, selected_option.label + ), + components=[], + ) self.view.stop() @@ -101,6 +97,19 @@ async def _get_ics_link(timetable_type: str, identity: str) -> str: return ics_url +async def _create_ics_embed( + timetable_type: str, identity: str, name: str +) -> hikari.Embed: + """Create an embed containing the ICS link for the timetable.""" + ics_url = await _get_ics_link(timetable_type, identity) + + return hikari.Embed( + title=f"Timetable for {name}", + description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", + color=Colour.BRICKIE_BLUE, + ).set_footer(text="Powered by TimetableSync") + + async def _timetable_response( ctx: BlockbotContext, timetable_type: str, @@ -163,15 +172,11 @@ async def _timetable_response( # Display the timetable ICS link if there is exactly one match. if len(matching_fields) == 1: match: dict[str, str] = matching_fields[0] - ics_url = await _get_ics_link(timetable_type, match["identity"]) - - embed = hikari.Embed( - title=f"Timetable for {match['name']}", - description=f"[Download ICS]({ics_url}) \n \n URL for calendar subscription: ```{ics_url}```", - color=Colour.BRICKIE_BLUE, - ).set_footer(text="Powered by TimetableSync") - - await ctx.respond(embed=embed) + await ctx.respond( + embed=await _create_ics_embed( + timetable_type, match["identity"], match["name"] + ) + ) return embed = hikari.Embed( From bcab1a83fe02f59489060ec95635c6f9d2c9b6d5 Mon Sep 17 00:00:00 2001 From: Aris <64918822+Arisamiga@users.noreply.github.com> Date: Sat, 10 Jan 2026 16:58:36 +0000 Subject: [PATCH 15/15] Added Error Handling and redefined function --- src/extensions/timetable.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/extensions/timetable.py b/src/extensions/timetable.py index 6538448..68731b8 100644 --- a/src/extensions/timetable.py +++ b/src/extensions/timetable.py @@ -75,15 +75,16 @@ async def on_timeout(self) -> None: async def _get_matching_fields( timetable_type: str, user_data: str, session: aiohttp.ClientSession -) -> tuple[list[dict[str, str]], int]: +) -> list[dict[str, str]]: """Fetch matching fields from the timetable API using the ?query.""" async with session.get( f"https://timetable.redbrick.dcu.ie/api/all/{timetable_type}?query={user_data}" ) as resp: - return await resp.json(), resp.status + resp.raise_for_status() + return await resp.json() -async def _get_ics_link(timetable_type: str, identity: str) -> str: +def _get_ics_link(timetable_type: str, identity: str) -> str: """Generate the ICS link for the matched timetable using its identity.""" if timetable_type not in {"club", "society"}: ics_url = f"https://timetable.redbrick.dcu.ie/api?{timetable_type}s={identity}" @@ -101,7 +102,7 @@ async def _create_ics_embed( timetable_type: str, identity: str, name: str ) -> hikari.Embed: """Create an embed containing the ICS link for the timetable.""" - ics_url = await _get_ics_link(timetable_type, identity) + ics_url = _get_ics_link(timetable_type, identity) return hikari.Embed( title=f"Timetable for {name}", @@ -195,17 +196,11 @@ async def send_timetable_info( session_client: aiohttp.ClientSession, ) -> None: """Send timetable information based on the type and user data provided.""" - matching_fields, status = await _get_matching_fields( + + matching_fields = await _get_matching_fields( timetable_type, user_data, session_client ) - if status != 200: - await ctx.respond( - f"❌ Failed to fetch timetable data. Status code: `{status}`", - flags=hikari.MessageFlag.EPHEMERAL, - ) - return - await _timetable_response( ctx, timetable_type, user_data, matching_fields, miru_client ) @@ -222,6 +217,23 @@ async def timetable_command( ) +@timetable.set_error_handler +async def timetable_error_handler(ctx: BlockbotContext, error: Exception) -> None: + if isinstance(error, aiohttp.ClientResponseError): + await ctx.respond( + f"❌ Timetable API returned an error: `{error.status} {error.message}`", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + if isinstance(error, aiohttp.ClientError): + await ctx.respond( + "❌ Failed to connect to the Timetable API. Please try again later.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + raise error + + @arc.loader def loader(client: Blockbot) -> None: for name, cmd_description, opt_description in (