From bfda6e391e6903f45294031bbead4e791ae320cf Mon Sep 17 00:00:00 2001 From: CG-5228 Date: Mon, 6 Apr 2026 19:40:00 +0100 Subject: [PATCH 1/3] Add poll support to agenda generate command --- .gitignore | 3 + src/extensions/agenda.py | 236 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 225 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 51997fb..61acb13 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ dmypy.json # Pyre type checker .pyre/ + +# minio +minio/ diff --git a/src/extensions/agenda.py b/src/extensions/agenda.py index db2cc10..1f0ec6b 100644 --- a/src/extensions/agenda.py +++ b/src/extensions/agenda.py @@ -1,8 +1,11 @@ +import contextlib import datetime +import typing import aiohttp import arc import hikari +import miru from src.config import AGENDA_TEMPLATE_URL, CHANNEL_IDS, ROLE_IDS, UID_MAPS, Feature from src.hooks import restrict_to_channels, restrict_to_roles @@ -18,6 +21,9 @@ agenda = plugin.include_slash_group("agenda", "Interact with the agenda.") +POLL_MODE_CHOICES = ["yes_no", "custom"] +CUSTOM_POLL_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"] + async def generate_date_choices( _: arc.AutocompleteData[arc.GatewayClient, str], @@ -47,6 +53,129 @@ async def generate_time_autocomplete( return times[:25] +def parse_custom_poll_options(raw_options: str | None) -> list[str] | None: + """Parse custom poll options into a unique list.""" + if not raw_options: + return None + + parsed_options = [opt.strip() for opt in raw_options.split(",") if opt.strip()] + unique_options = list(dict.fromkeys(parsed_options)) + + if not 2 <= len(unique_options) <= 5: + return None + return unique_options + + +async def post_reaction_poll(*, question: str, options: list[str]) -> None: + """Post a reaction-based poll in committee-announcements.""" + option_emojis = ["👍", "👎"] if options == ["Yes", "No"] else CUSTOM_POLL_EMOJIS + selected_emojis = option_emojis[: len(options)] + + poll_lines = "\n".join( + f"{emoji} {option}" + for emoji, option in zip(selected_emojis, options, strict=True) + ) + poll_message = await plugin.client.rest.create_message( + CHANNEL_IDS["committee-announcements"], + mentions_everyone=False, + user_mentions=False, + role_mentions=False, + content=f"## 📊 Poll: {question}\n{poll_lines}\n\nReact below to vote.", + ) + + for emoji in selected_emojis: + await plugin.client.rest.add_reaction( + channel=poll_message.channel_id, + message=poll_message.id, + emoji=emoji, + ) + + +async def post_poll(*, question: str, options: list[str]) -> None: + """Post a native Discord poll, falling back to reactions if unavailable.""" + from hikari.impl.special_endpoints import PollBuilder + + try: + poll = PollBuilder( + question_text=question, + allow_multiselect=False, + duration=24, + ) + for option in options: + poll.add_answer(text=option) + + await plugin.client.rest.create_message( + CHANNEL_IDS["committee-announcements"], + poll=poll, + ) + return + except Exception: + pass + + await post_reaction_poll(question=question, options=options) + + +class AgendaConfirmView(miru.View): + def __init__( + self, + *, + author_id: int, + on_confirm: typing.Callable[[], typing.Awaitable[str]], + ) -> None: + self.author_id = author_id + self.on_confirm = on_confirm + super().__init__(timeout=120) + + async def disable_all(self) -> None: + for item in self.children: + item.disabled = True + + if self.message is None: + return + + with contextlib.suppress(hikari.NotFoundError): + await self.message.edit(components=self) + + async def on_timeout(self) -> None: + await self.disable_all() + self.stop() + + @miru.button(label="Confirm", style=hikari.ButtonStyle.SUCCESS, custom_id="agenda_confirm") + async def confirm_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: + if ctx.user.id != self.author_id: + await ctx.respond( + "You are not allowed to confirm this action.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + + try: + response_text = await self.on_confirm() + except aiohttp.ClientResponseError as error: + response_text = ( + "❌ Failed to post agenda/poll. " + f"Upstream returned status `{error.status}`." + ) + except Exception: + response_text = "❌ Failed to post agenda/poll due to an unexpected error." + await self.disable_all() + await ctx.edit_response(response_text, components=self) + self.stop() + + @miru.button(label="Cancel", style=hikari.ButtonStyle.DANGER, custom_id="agenda_cancel") + async def cancel_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: + if ctx.user.id != self.author_id: + await ctx.respond( + "You are not allowed to cancel this action.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + + await self.disable_all() + await ctx.edit_response("❌ Agenda generation cancelled.", components=self) + self.stop() + + @agenda.include @arc.with_hook( restrict_to_channels( @@ -83,10 +212,30 @@ async def gen_agenda( note: arc.Option[ str | None, arc.StrParams("Optional note to be included in the announcement.") ] = None, + add_poll: arc.Option[ + bool, + arc.BoolParams("Add a poll to committee-announcements?"), + ] = False, + poll_mode: arc.Option[ + str, + arc.StrParams( + "Poll mode (`yes_no` or `custom`).", + choices=POLL_MODE_CHOICES, + ), + ] = "yes_no", + poll_question: arc.Option[ + str | None, + arc.StrParams("Poll question (required when `add_poll` is enabled)."), + ] = None, + poll_options: arc.Option[ + str | None, + arc.StrParams("Comma-separated custom poll options (2-5 options)."), + ] = None, url: arc.Option[ str, arc.StrParams("URL of the agenda template from the MD"), ] = AGENDA_TEMPLATE_URL, # pyright: ignore[reportArgumentType] - it is guaranteed to exist because of runtime checks! + miru_client: miru.Client = arc.inject(), aiohttp_client: aiohttp.ClientSession = arc.inject(), ) -> None: """Generate a new agenda for committee meetings.""" @@ -115,6 +264,28 @@ async def gen_agenda( formatted_time = parsed_datetime.strftime("%H:%M") formatted_datetime = parsed_datetime.strftime("%A, %Y-%m-%d %H:%M") + parsed_poll_options: list[str] = [] + if add_poll: + cleaned_question = (poll_question or "").strip() + if not cleaned_question: + await ctx.respond( + "❌ `poll_question` is required when `add_poll` is enabled.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + + if poll_mode == "custom": + custom_options = parse_custom_poll_options(poll_options) + if custom_options is None: + await ctx.respond( + "❌ `poll_options` must contain 2-5 unique, non-empty comma-separated values.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + parsed_poll_options = custom_options + else: + parsed_poll_options = ["Yes", "No"] + try: content = await get_md_content(url, aiohttp_client) except aiohttp.ClientResponseError as e: @@ -161,25 +332,62 @@ async def gen_agenda( if note: announce_text += f"## Note:\n{note}" - announce = await plugin.client.rest.create_message( - CHANNEL_IDS["committee-announcements"], - mentions_everyone=False, - user_mentions=True, - role_mentions=True, - content=announce_text, - ) + async def send_agenda_and_poll() -> str: + announce = await plugin.client.rest.create_message( + CHANNEL_IDS["committee-announcements"], + mentions_everyone=False, + user_mentions=True, + role_mentions=True, + content=announce_text, + ) + + try: + await plugin.client.rest.add_reaction( + channel=announce.channel_id, + message=announce.id, + emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), + ) + except (hikari.BadRequestError, hikari.NotFoundError, hikari.ForbiddenError): + await plugin.client.rest.add_reaction( + channel=announce.channel_id, + message=announce.id, + emoji="🧱", + ) + + if add_poll: + await post_poll( + question=(poll_question or "").strip(), + options=parsed_poll_options, + ) + return "✅ Agenda generated and poll posted successfully!" + + return "✅ Agenda generated. Announcement sent successfully!" - await plugin.client.rest.add_reaction( - channel=announce.channel_id, - message=announce.id, - emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), + if not add_poll: + await ctx.respond( + await send_agenda_and_poll(), + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + + preview_options = "\n".join(f"- {option}" for option in parsed_poll_options) + confirmation_text = ( + "## Confirm agenda + poll post\n" + f"- Date/Time: `{formatted_datetime}`\n" + f"- Room: `{room}`\n" + f"- Poll mode: `{poll_mode}`\n" + f"- Poll question: `{(poll_question or '').strip()}`\n" + f"- Poll options:\n{preview_options}\n\n" + "Use the buttons below to confirm or cancel." ) - # respond with success if it executes successfully - await ctx.respond( - "✅ Agenda generated. Announcement sent successfully!", + view = AgendaConfirmView(author_id=ctx.user.id, on_confirm=send_agenda_and_poll) + response = await ctx.respond( + confirmation_text, flags=hikari.MessageFlag.EPHEMERAL, + components=view, ) + miru_client.start_view(view, bind_to=await response.retrieve_message()) return From 10aeb21527aece6356e068cd46096c51565d5f41 Mon Sep 17 00:00:00 2001 From: CG-5228 Date: Mon, 6 Apr 2026 19:44:52 +0100 Subject: [PATCH 2/3] Fix format error --- src/extensions/agenda.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/extensions/agenda.py b/src/extensions/agenda.py index 1f0ec6b..335dd64 100644 --- a/src/extensions/agenda.py +++ b/src/extensions/agenda.py @@ -6,6 +6,7 @@ import arc import hikari import miru +from hikari.impl.special_endpoints import PollBuilder from src.config import AGENDA_TEMPLATE_URL, CHANNEL_IDS, ROLE_IDS, UID_MAPS, Feature from src.hooks import restrict_to_channels, restrict_to_roles @@ -93,8 +94,6 @@ async def post_reaction_poll(*, question: str, options: list[str]) -> None: async def post_poll(*, question: str, options: list[str]) -> None: """Post a native Discord poll, falling back to reactions if unavailable.""" - from hikari.impl.special_endpoints import PollBuilder - try: poll = PollBuilder( question_text=question, @@ -140,7 +139,9 @@ async def on_timeout(self) -> None: await self.disable_all() self.stop() - @miru.button(label="Confirm", style=hikari.ButtonStyle.SUCCESS, custom_id="agenda_confirm") + @miru.button( + label="Confirm", style=hikari.ButtonStyle.SUCCESS, custom_id="agenda_confirm" + ) async def confirm_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: if ctx.user.id != self.author_id: await ctx.respond( @@ -162,7 +163,9 @@ async def confirm_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: await ctx.edit_response(response_text, components=self) self.stop() - @miru.button(label="Cancel", style=hikari.ButtonStyle.DANGER, custom_id="agenda_cancel") + @miru.button( + label="Cancel", style=hikari.ButtonStyle.DANGER, custom_id="agenda_cancel" + ) async def cancel_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: if ctx.user.id != self.author_id: await ctx.respond( @@ -192,7 +195,7 @@ async def cancel_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: "Generate a new agenda for committee meetings.", autodefer=arc.AutodeferMode.EPHEMERAL, ) -async def gen_agenda( +async def gen_agenda( # noqa: PLR0911, PLR0915 ctx: BlockbotContext, date: arc.Option[ str, @@ -347,7 +350,7 @@ async def send_agenda_and_poll() -> str: message=announce.id, emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), ) - except (hikari.BadRequestError, hikari.NotFoundError, hikari.ForbiddenError): + except hikari.BadRequestError, hikari.NotFoundError, hikari.ForbiddenError: await plugin.client.rest.add_reaction( channel=announce.channel_id, message=announce.id, From bdf31adc07d223681cb379cf961219990d1b19c5 Mon Sep 17 00:00:00 2001 From: CG-5228 Date: Sun, 26 Apr 2026 19:57:58 +0100 Subject: [PATCH 3/3] refactor(agenda): address review feedback for generate + poll flow Refine `/agenda generate` based on review by improving error handling and splitting logic into focused helpers for validation, announcement building, and posting. Also tighten view authorization handling and keep behavior unchanged for successful agenda/poll posting. --- src/extensions/agenda.py | 260 +++++++++++++++++++++++---------------- 1 file changed, 154 insertions(+), 106 deletions(-) diff --git a/src/extensions/agenda.py b/src/extensions/agenda.py index 335dd64..a5d2401 100644 --- a/src/extensions/agenda.py +++ b/src/extensions/agenda.py @@ -67,6 +67,106 @@ def parse_custom_poll_options(raw_options: str | None) -> list[str] | None: return unique_options +def resolve_poll_payload( + *, + add_poll: bool, + poll_mode: str, + poll_question: str | None, + poll_options: str | None, +) -> tuple[str | None, list[str]]: + """Validate and normalize poll inputs.""" + if not add_poll: + return None, [] + + cleaned_question = (poll_question or "").strip() + if not cleaned_question: + raise ValueError("`poll_question` is required when `add_poll` is enabled.") + + if poll_mode == "custom": + custom_options = parse_custom_poll_options(poll_options) + if custom_options is None: + raise ValueError( + "`poll_options` must contain 2-5 unique, non-empty comma-separated values." + ) + return cleaned_question, custom_options + + return cleaned_question, ["Yes", "No"] + + +async def generate_agenda_url( + *, + template_url: str, + formatted_date: str, + formatted_time: str, + room: str, + aiohttp_client: aiohttp.ClientSession, +) -> str: + """Create an agenda URL from the configured template.""" + content = await get_md_content(template_url, aiohttp_client) + modified_content = content.format( + DATE=formatted_date, + TIME=formatted_time, + ROOM=room, + ) + return await post_new_md_content(modified_content, aiohttp_client) + + +def build_agenda_announcement_text( + *, + formatted_datetime: str, + room: str, + formatted_date: str, + new_agenda_url: str, + note: str | None, +) -> str: + """Build the agenda announcement message body.""" + announce_text = f""" +## 📣 Agenda for this week's meeting | {formatted_datetime} | {room} <:bigRed:634311607039819776> + + +[{formatted_date} Agenda](<{new_agenda_url}>) + +- Please fill in your sections with anything you would like to discuss. +- Put your Redbrick `username` beside any agenda items you add. +- If you can't attend the meeting, please DM {f"<@{UID_MAPS['secretary']}>" if "secretary" in UID_MAPS else "the secretary"} or {f"<@{UID_MAPS['chair']}>" if "chair" in UID_MAPS else "the chairperson"} with your reason. +- React with <:bigRed:634311607039819776> if you can make it. + +||{role_mention(ROLE_IDS["committee"])}|| +""" + + if note: + announce_text += f"## Note:\n{note}" + + return announce_text + + +async def post_agenda_announcement(announce_text: str) -> hikari.Message: + """Post agenda announcement in committee-announcements channel.""" + return await plugin.client.rest.create_message( + CHANNEL_IDS["committee-announcements"], + mentions_everyone=False, + user_mentions=True, + role_mentions=True, + content=announce_text, + ) + + +async def add_agenda_reaction(announce: hikari.Message) -> None: + """Add the agenda reaction, with unicode fallback for test guilds.""" + try: + await plugin.client.rest.add_reaction( + channel=announce.channel_id, + message=announce.id, + emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), + ) + except hikari.BadRequestError, hikari.NotFoundError, hikari.ForbiddenError: + await plugin.client.rest.add_reaction( + channel=announce.channel_id, + message=announce.id, + emoji="🧱", + ) + + async def post_reaction_poll(*, question: str, options: list[str]) -> None: """Post a reaction-based poll in committee-announcements.""" option_emojis = ["👍", "👎"] if options == ["Yes", "No"] else CUSTOM_POLL_EMOJIS @@ -94,22 +194,25 @@ async def post_reaction_poll(*, question: str, options: list[str]) -> None: async def post_poll(*, question: str, options: list[str]) -> None: """Post a native Discord poll, falling back to reactions if unavailable.""" - try: - poll = PollBuilder( - question_text=question, - allow_multiselect=False, - duration=24, - ) - for option in options: - poll.add_answer(text=option) - + poll = PollBuilder( + question_text=question, + allow_multiselect=False, + duration=24, + ) + for option in options: + poll.add_answer(text=option) + + with contextlib.suppress( + hikari.BadRequestError, + hikari.ForbiddenError, + hikari.NotFoundError, + hikari.UnauthorizedError, + ): await plugin.client.rest.create_message( CHANNEL_IDS["committee-announcements"], poll=poll, ) return - except Exception: - pass await post_reaction_poll(question=question, options=options) @@ -139,26 +242,21 @@ async def on_timeout(self) -> None: await self.disable_all() self.stop() - @miru.button( - label="Confirm", style=hikari.ButtonStyle.SUCCESS, custom_id="agenda_confirm" - ) - async def confirm_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: + async def view_check(self, ctx: miru.ViewContext) -> bool: if ctx.user.id != self.author_id: await ctx.respond( - "You are not allowed to confirm this action.", + "You are not allowed to use these controls.", flags=hikari.MessageFlag.EPHEMERAL, ) - return + return False - try: - response_text = await self.on_confirm() - except aiohttp.ClientResponseError as error: - response_text = ( - "❌ Failed to post agenda/poll. " - f"Upstream returned status `{error.status}`." - ) - except Exception: - response_text = "❌ Failed to post agenda/poll due to an unexpected error." + return True + + @miru.button( + label="Confirm", style=hikari.ButtonStyle.SUCCESS, custom_id="agenda_confirm" + ) + async def confirm_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: + response_text = await self.on_confirm() await self.disable_all() await ctx.edit_response(response_text, components=self) self.stop() @@ -167,13 +265,6 @@ async def confirm_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: label="Cancel", style=hikari.ButtonStyle.DANGER, custom_id="agenda_cancel" ) async def cancel_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: - if ctx.user.id != self.author_id: - await ctx.respond( - "You are not allowed to cancel this action.", - flags=hikari.MessageFlag.EPHEMERAL, - ) - return - await self.disable_all() await ctx.edit_response("❌ Agenda generation cancelled.", components=self) self.stop() @@ -195,7 +286,7 @@ async def cancel_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: "Generate a new agenda for committee meetings.", autodefer=arc.AutodeferMode.EPHEMERAL, ) -async def gen_agenda( # noqa: PLR0911, PLR0915 +async def gen_agenda( ctx: BlockbotContext, date: arc.Option[ str, @@ -267,36 +358,13 @@ async def gen_agenda( # noqa: PLR0911, PLR0915 formatted_time = parsed_datetime.strftime("%H:%M") formatted_datetime = parsed_datetime.strftime("%A, %Y-%m-%d %H:%M") - parsed_poll_options: list[str] = [] - if add_poll: - cleaned_question = (poll_question or "").strip() - if not cleaned_question: - await ctx.respond( - "❌ `poll_question` is required when `add_poll` is enabled.", - flags=hikari.MessageFlag.EPHEMERAL, - ) - return - - if poll_mode == "custom": - custom_options = parse_custom_poll_options(poll_options) - if custom_options is None: - await ctx.respond( - "❌ `poll_options` must contain 2-5 unique, non-empty comma-separated values.", - flags=hikari.MessageFlag.EPHEMERAL, - ) - return - parsed_poll_options = custom_options - else: - parsed_poll_options = ["Yes", "No"] - try: - content = await get_md_content(url, aiohttp_client) - except aiohttp.ClientResponseError as e: - await ctx.respond( - f"❌ Failed to fetch the agenda template. Status code: `{e.status}`", - flags=hikari.MessageFlag.EPHEMERAL, + poll_question_clean, parsed_poll_options = resolve_poll_payload( + add_poll=add_poll, + poll_mode=poll_mode, + poll_question=poll_question, + poll_options=poll_options, ) - return except ValueError as e: await ctx.respond( f"❌ {e}", @@ -304,62 +372,42 @@ async def gen_agenda( # noqa: PLR0911, PLR0915 ) return - modified_content = content.format( - DATE=formatted_date, - TIME=formatted_time, - ROOM=room, - ) - try: - new_agenda_url = await post_new_md_content(modified_content, aiohttp_client) + new_agenda_url = await generate_agenda_url( + template_url=url, + formatted_date=formatted_date, + formatted_time=formatted_time, + room=room, + aiohttp_client=aiohttp_client, + ) except aiohttp.ClientResponseError as e: await ctx.respond( - f"❌ Failed to generate the agenda. Status code: `{e.status}`", + f"❌ Failed to process the agenda template. Status code: `{e.status}`", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + except ValueError as e: + await ctx.respond( + f"❌ {e}", flags=hikari.MessageFlag.EPHEMERAL, ) return - announce_text = f""" -## 📣 Agenda for this week's meeting | {formatted_datetime} | {room} <:bigRed:634311607039819776> - - -[{formatted_date} Agenda](<{new_agenda_url}>) - -- Please fill in your sections with anything you would like to discuss. -- Put your Redbrick `username` beside any agenda items you add. -- If you can't attend the meeting, please DM {f"<@{UID_MAPS['secretary']}>" if "secretary" in UID_MAPS else "the secretary"} or {f"<@{UID_MAPS['chair']}>" if "chair" in UID_MAPS else "the chairperson"} with your reason. -- React with <:bigRed:634311607039819776> if you can make it. - -||{role_mention(ROLE_IDS["committee"])}|| -""" - if note: - announce_text += f"## Note:\n{note}" + announce_text = build_agenda_announcement_text( + formatted_datetime=formatted_datetime, + room=room, + formatted_date=formatted_date, + new_agenda_url=new_agenda_url, + note=note, + ) async def send_agenda_and_poll() -> str: - announce = await plugin.client.rest.create_message( - CHANNEL_IDS["committee-announcements"], - mentions_everyone=False, - user_mentions=True, - role_mentions=True, - content=announce_text, - ) - - try: - await plugin.client.rest.add_reaction( - channel=announce.channel_id, - message=announce.id, - emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), - ) - except hikari.BadRequestError, hikari.NotFoundError, hikari.ForbiddenError: - await plugin.client.rest.add_reaction( - channel=announce.channel_id, - message=announce.id, - emoji="🧱", - ) + announce = await post_agenda_announcement(announce_text) + await add_agenda_reaction(announce) if add_poll: await post_poll( - question=(poll_question or "").strip(), + question=poll_question_clean or "", options=parsed_poll_options, ) return "✅ Agenda generated and poll posted successfully!"