|
| 1 | +import contextlib |
1 | 2 | import datetime |
| 3 | +import typing |
2 | 4 |
|
3 | 5 | import aiohttp |
4 | 6 | import arc |
5 | 7 | import hikari |
| 8 | +import miru |
6 | 9 |
|
7 | 10 | from src.config import AGENDA_TEMPLATE_URL, CHANNEL_IDS, ROLE_IDS, UID_MAPS, Feature |
8 | 11 | from src.hooks import restrict_to_channels, restrict_to_roles |
|
18 | 21 |
|
19 | 22 | agenda = plugin.include_slash_group("agenda", "Interact with the agenda.") |
20 | 23 |
|
| 24 | +POLL_MODE_CHOICES = ["yes_no", "custom"] |
| 25 | +CUSTOM_POLL_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"] |
| 26 | + |
21 | 27 |
|
22 | 28 | async def generate_date_choices( |
23 | 29 | _: arc.AutocompleteData[arc.GatewayClient, str], |
@@ -47,6 +53,129 @@ async def generate_time_autocomplete( |
47 | 53 | return times[:25] |
48 | 54 |
|
49 | 55 |
|
| 56 | +def parse_custom_poll_options(raw_options: str | None) -> list[str] | None: |
| 57 | + """Parse custom poll options into a unique list.""" |
| 58 | + if not raw_options: |
| 59 | + return None |
| 60 | + |
| 61 | + parsed_options = [opt.strip() for opt in raw_options.split(",") if opt.strip()] |
| 62 | + unique_options = list(dict.fromkeys(parsed_options)) |
| 63 | + |
| 64 | + if not 2 <= len(unique_options) <= 5: |
| 65 | + return None |
| 66 | + return unique_options |
| 67 | + |
| 68 | + |
| 69 | +async def post_reaction_poll(*, question: str, options: list[str]) -> None: |
| 70 | + """Post a reaction-based poll in committee-announcements.""" |
| 71 | + option_emojis = ["👍", "👎"] if options == ["Yes", "No"] else CUSTOM_POLL_EMOJIS |
| 72 | + selected_emojis = option_emojis[: len(options)] |
| 73 | + |
| 74 | + poll_lines = "\n".join( |
| 75 | + f"{emoji} {option}" |
| 76 | + for emoji, option in zip(selected_emojis, options, strict=True) |
| 77 | + ) |
| 78 | + poll_message = await plugin.client.rest.create_message( |
| 79 | + CHANNEL_IDS["committee-announcements"], |
| 80 | + mentions_everyone=False, |
| 81 | + user_mentions=False, |
| 82 | + role_mentions=False, |
| 83 | + content=f"## 📊 Poll: {question}\n{poll_lines}\n\nReact below to vote.", |
| 84 | + ) |
| 85 | + |
| 86 | + for emoji in selected_emojis: |
| 87 | + await plugin.client.rest.add_reaction( |
| 88 | + channel=poll_message.channel_id, |
| 89 | + message=poll_message.id, |
| 90 | + emoji=emoji, |
| 91 | + ) |
| 92 | + |
| 93 | + |
| 94 | +async def post_poll(*, question: str, options: list[str]) -> None: |
| 95 | + """Post a native Discord poll, falling back to reactions if unavailable.""" |
| 96 | + from hikari.impl.special_endpoints import PollBuilder |
| 97 | + |
| 98 | + try: |
| 99 | + poll = PollBuilder( |
| 100 | + question_text=question, |
| 101 | + allow_multiselect=False, |
| 102 | + duration=24, |
| 103 | + ) |
| 104 | + for option in options: |
| 105 | + poll.add_answer(text=option) |
| 106 | + |
| 107 | + await plugin.client.rest.create_message( |
| 108 | + CHANNEL_IDS["committee-announcements"], |
| 109 | + poll=poll, |
| 110 | + ) |
| 111 | + return |
| 112 | + except Exception: |
| 113 | + pass |
| 114 | + |
| 115 | + await post_reaction_poll(question=question, options=options) |
| 116 | + |
| 117 | + |
| 118 | +class AgendaConfirmView(miru.View): |
| 119 | + def __init__( |
| 120 | + self, |
| 121 | + *, |
| 122 | + author_id: int, |
| 123 | + on_confirm: typing.Callable[[], typing.Awaitable[str]], |
| 124 | + ) -> None: |
| 125 | + self.author_id = author_id |
| 126 | + self.on_confirm = on_confirm |
| 127 | + super().__init__(timeout=120) |
| 128 | + |
| 129 | + async def disable_all(self) -> None: |
| 130 | + for item in self.children: |
| 131 | + item.disabled = True |
| 132 | + |
| 133 | + if self.message is None: |
| 134 | + return |
| 135 | + |
| 136 | + with contextlib.suppress(hikari.NotFoundError): |
| 137 | + await self.message.edit(components=self) |
| 138 | + |
| 139 | + async def on_timeout(self) -> None: |
| 140 | + await self.disable_all() |
| 141 | + self.stop() |
| 142 | + |
| 143 | + @miru.button(label="Confirm", style=hikari.ButtonStyle.SUCCESS, custom_id="agenda_confirm") |
| 144 | + async def confirm_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: |
| 145 | + if ctx.user.id != self.author_id: |
| 146 | + await ctx.respond( |
| 147 | + "You are not allowed to confirm this action.", |
| 148 | + flags=hikari.MessageFlag.EPHEMERAL, |
| 149 | + ) |
| 150 | + return |
| 151 | + |
| 152 | + try: |
| 153 | + response_text = await self.on_confirm() |
| 154 | + except aiohttp.ClientResponseError as error: |
| 155 | + response_text = ( |
| 156 | + "❌ Failed to post agenda/poll. " |
| 157 | + f"Upstream returned status `{error.status}`." |
| 158 | + ) |
| 159 | + except Exception: |
| 160 | + response_text = "❌ Failed to post agenda/poll due to an unexpected error." |
| 161 | + await self.disable_all() |
| 162 | + await ctx.edit_response(response_text, components=self) |
| 163 | + self.stop() |
| 164 | + |
| 165 | + @miru.button(label="Cancel", style=hikari.ButtonStyle.DANGER, custom_id="agenda_cancel") |
| 166 | + async def cancel_post(self, ctx: miru.ViewContext, _: miru.Button) -> None: |
| 167 | + if ctx.user.id != self.author_id: |
| 168 | + await ctx.respond( |
| 169 | + "You are not allowed to cancel this action.", |
| 170 | + flags=hikari.MessageFlag.EPHEMERAL, |
| 171 | + ) |
| 172 | + return |
| 173 | + |
| 174 | + await self.disable_all() |
| 175 | + await ctx.edit_response("❌ Agenda generation cancelled.", components=self) |
| 176 | + self.stop() |
| 177 | + |
| 178 | + |
50 | 179 | @agenda.include |
51 | 180 | @arc.with_hook( |
52 | 181 | restrict_to_channels( |
@@ -83,10 +212,30 @@ async def gen_agenda( |
83 | 212 | note: arc.Option[ |
84 | 213 | str | None, arc.StrParams("Optional note to be included in the announcement.") |
85 | 214 | ] = None, |
| 215 | + add_poll: arc.Option[ |
| 216 | + bool, |
| 217 | + arc.BoolParams("Add a poll to committee-announcements?"), |
| 218 | + ] = False, |
| 219 | + poll_mode: arc.Option[ |
| 220 | + str, |
| 221 | + arc.StrParams( |
| 222 | + "Poll mode (`yes_no` or `custom`).", |
| 223 | + choices=POLL_MODE_CHOICES, |
| 224 | + ), |
| 225 | + ] = "yes_no", |
| 226 | + poll_question: arc.Option[ |
| 227 | + str | None, |
| 228 | + arc.StrParams("Poll question (required when `add_poll` is enabled)."), |
| 229 | + ] = None, |
| 230 | + poll_options: arc.Option[ |
| 231 | + str | None, |
| 232 | + arc.StrParams("Comma-separated custom poll options (2-5 options)."), |
| 233 | + ] = None, |
86 | 234 | url: arc.Option[ |
87 | 235 | str, |
88 | 236 | arc.StrParams("URL of the agenda template from the MD"), |
89 | 237 | ] = AGENDA_TEMPLATE_URL, # pyright: ignore[reportArgumentType] - it is guaranteed to exist because of runtime checks! |
| 238 | + miru_client: miru.Client = arc.inject(), |
90 | 239 | aiohttp_client: aiohttp.ClientSession = arc.inject(), |
91 | 240 | ) -> None: |
92 | 241 | """Generate a new agenda for committee meetings.""" |
@@ -115,6 +264,28 @@ async def gen_agenda( |
115 | 264 | formatted_time = parsed_datetime.strftime("%H:%M") |
116 | 265 | formatted_datetime = parsed_datetime.strftime("%A, %Y-%m-%d %H:%M") |
117 | 266 |
|
| 267 | + parsed_poll_options: list[str] = [] |
| 268 | + if add_poll: |
| 269 | + cleaned_question = (poll_question or "").strip() |
| 270 | + if not cleaned_question: |
| 271 | + await ctx.respond( |
| 272 | + "❌ `poll_question` is required when `add_poll` is enabled.", |
| 273 | + flags=hikari.MessageFlag.EPHEMERAL, |
| 274 | + ) |
| 275 | + return |
| 276 | + |
| 277 | + if poll_mode == "custom": |
| 278 | + custom_options = parse_custom_poll_options(poll_options) |
| 279 | + if custom_options is None: |
| 280 | + await ctx.respond( |
| 281 | + "❌ `poll_options` must contain 2-5 unique, non-empty comma-separated values.", |
| 282 | + flags=hikari.MessageFlag.EPHEMERAL, |
| 283 | + ) |
| 284 | + return |
| 285 | + parsed_poll_options = custom_options |
| 286 | + else: |
| 287 | + parsed_poll_options = ["Yes", "No"] |
| 288 | + |
118 | 289 | try: |
119 | 290 | content = await get_md_content(url, aiohttp_client) |
120 | 291 | except aiohttp.ClientResponseError as e: |
@@ -161,25 +332,62 @@ async def gen_agenda( |
161 | 332 | if note: |
162 | 333 | announce_text += f"## Note:\n{note}" |
163 | 334 |
|
164 | | - announce = await plugin.client.rest.create_message( |
165 | | - CHANNEL_IDS["committee-announcements"], |
166 | | - mentions_everyone=False, |
167 | | - user_mentions=True, |
168 | | - role_mentions=True, |
169 | | - content=announce_text, |
170 | | - ) |
| 335 | + async def send_agenda_and_poll() -> str: |
| 336 | + announce = await plugin.client.rest.create_message( |
| 337 | + CHANNEL_IDS["committee-announcements"], |
| 338 | + mentions_everyone=False, |
| 339 | + user_mentions=True, |
| 340 | + role_mentions=True, |
| 341 | + content=announce_text, |
| 342 | + ) |
| 343 | + |
| 344 | + try: |
| 345 | + await plugin.client.rest.add_reaction( |
| 346 | + channel=announce.channel_id, |
| 347 | + message=announce.id, |
| 348 | + emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), |
| 349 | + ) |
| 350 | + except (hikari.BadRequestError, hikari.NotFoundError, hikari.ForbiddenError): |
| 351 | + await plugin.client.rest.add_reaction( |
| 352 | + channel=announce.channel_id, |
| 353 | + message=announce.id, |
| 354 | + emoji="🧱", |
| 355 | + ) |
| 356 | + |
| 357 | + if add_poll: |
| 358 | + await post_poll( |
| 359 | + question=(poll_question or "").strip(), |
| 360 | + options=parsed_poll_options, |
| 361 | + ) |
| 362 | + return "✅ Agenda generated and poll posted successfully!" |
| 363 | + |
| 364 | + return "✅ Agenda generated. Announcement sent successfully!" |
171 | 365 |
|
172 | | - await plugin.client.rest.add_reaction( |
173 | | - channel=announce.channel_id, |
174 | | - message=announce.id, |
175 | | - emoji=hikari.CustomEmoji.parse("<:bigRed:634311607039819776>"), |
| 366 | + if not add_poll: |
| 367 | + await ctx.respond( |
| 368 | + await send_agenda_and_poll(), |
| 369 | + flags=hikari.MessageFlag.EPHEMERAL, |
| 370 | + ) |
| 371 | + return |
| 372 | + |
| 373 | + preview_options = "\n".join(f"- {option}" for option in parsed_poll_options) |
| 374 | + confirmation_text = ( |
| 375 | + "## Confirm agenda + poll post\n" |
| 376 | + f"- Date/Time: `{formatted_datetime}`\n" |
| 377 | + f"- Room: `{room}`\n" |
| 378 | + f"- Poll mode: `{poll_mode}`\n" |
| 379 | + f"- Poll question: `{(poll_question or '').strip()}`\n" |
| 380 | + f"- Poll options:\n{preview_options}\n\n" |
| 381 | + "Use the buttons below to confirm or cancel." |
176 | 382 | ) |
177 | 383 |
|
178 | | - # respond with success if it executes successfully |
179 | | - await ctx.respond( |
180 | | - "✅ Agenda generated. Announcement sent successfully!", |
| 384 | + view = AgendaConfirmView(author_id=ctx.user.id, on_confirm=send_agenda_and_poll) |
| 385 | + response = await ctx.respond( |
| 386 | + confirmation_text, |
181 | 387 | flags=hikari.MessageFlag.EPHEMERAL, |
| 388 | + components=view, |
182 | 389 | ) |
| 390 | + miru_client.start_view(view, bind_to=await response.retrieve_message()) |
183 | 391 | return |
184 | 392 |
|
185 | 393 |
|
|
0 commit comments