Skip to content

Commit bfda6e3

Browse files
committed
Add poll support to agenda generate command
1 parent 5d4d9e7 commit bfda6e3

2 files changed

Lines changed: 225 additions & 14 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,6 @@ dmypy.json
133133

134134
# Pyre type checker
135135
.pyre/
136+
137+
# minio
138+
minio/

src/extensions/agenda.py

Lines changed: 222 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import contextlib
12
import datetime
3+
import typing
24

35
import aiohttp
46
import arc
57
import hikari
8+
import miru
69

710
from src.config import AGENDA_TEMPLATE_URL, CHANNEL_IDS, ROLE_IDS, UID_MAPS, Feature
811
from src.hooks import restrict_to_channels, restrict_to_roles
@@ -18,6 +21,9 @@
1821

1922
agenda = plugin.include_slash_group("agenda", "Interact with the agenda.")
2023

24+
POLL_MODE_CHOICES = ["yes_no", "custom"]
25+
CUSTOM_POLL_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]
26+
2127

2228
async def generate_date_choices(
2329
_: arc.AutocompleteData[arc.GatewayClient, str],
@@ -47,6 +53,129 @@ async def generate_time_autocomplete(
4753
return times[:25]
4854

4955

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+
50179
@agenda.include
51180
@arc.with_hook(
52181
restrict_to_channels(
@@ -83,10 +212,30 @@ async def gen_agenda(
83212
note: arc.Option[
84213
str | None, arc.StrParams("Optional note to be included in the announcement.")
85214
] = 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,
86234
url: arc.Option[
87235
str,
88236
arc.StrParams("URL of the agenda template from the MD"),
89237
] = AGENDA_TEMPLATE_URL, # pyright: ignore[reportArgumentType] - it is guaranteed to exist because of runtime checks!
238+
miru_client: miru.Client = arc.inject(),
90239
aiohttp_client: aiohttp.ClientSession = arc.inject(),
91240
) -> None:
92241
"""Generate a new agenda for committee meetings."""
@@ -115,6 +264,28 @@ async def gen_agenda(
115264
formatted_time = parsed_datetime.strftime("%H:%M")
116265
formatted_datetime = parsed_datetime.strftime("%A, %Y-%m-%d %H:%M")
117266

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+
118289
try:
119290
content = await get_md_content(url, aiohttp_client)
120291
except aiohttp.ClientResponseError as e:
@@ -161,25 +332,62 @@ async def gen_agenda(
161332
if note:
162333
announce_text += f"## Note:\n{note}"
163334

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!"
171365

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."
176382
)
177383

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,
181387
flags=hikari.MessageFlag.EPHEMERAL,
388+
components=view,
182389
)
390+
miru_client.start_view(view, bind_to=await response.retrieve_message())
183391
return
184392

185393

0 commit comments

Comments
 (0)