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..a5d2401 100644 --- a/src/extensions/agenda.py +++ b/src/extensions/agenda.py @@ -1,8 +1,12 @@ +import contextlib import datetime +import typing import aiohttp 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 @@ -18,6 +22,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 +54,222 @@ 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 + + +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 + 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.""" + 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 + + 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() + + async def view_check(self, ctx: miru.ViewContext) -> bool: + if ctx.user.id != self.author_id: + await ctx.respond( + "You are not allowed to use these controls.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return False + + 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() + + @miru.button( + label="Cancel", style=hikari.ButtonStyle.DANGER, custom_id="agenda_cancel" + ) + async def cancel_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: + 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 +306,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.""" @@ -116,13 +359,12 @@ async def gen_agenda( formatted_datetime = parsed_datetime.strftime("%A, %Y-%m-%d %H:%M") 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}", @@ -130,56 +372,73 @@ async def gen_agenda( ) 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> + 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 post_agenda_announcement(announce_text) + await add_agenda_reaction(announce) -[{formatted_date} Agenda](<{new_agenda_url}>) + if add_poll: + await post_poll( + question=poll_question_clean or "", + options=parsed_poll_options, + ) + return "✅ Agenda generated and poll posted successfully!" -- 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. + return "✅ Agenda generated. Announcement sent successfully!" -||{role_mention(ROLE_IDS["committee"])}|| -""" - 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, - ) + if not add_poll: + await ctx.respond( + await send_agenda_and_poll(), + flags=hikari.MessageFlag.EPHEMERAL, + ) + return - await plugin.client.rest.add_reaction( - channel=announce.channel_id, - message=announce.id, - emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), + 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