1+ import asyncio
12import json
23from pathlib import Path
34from random import choice
910from bot .bot import Bot
1011from bot .constants import Colours , NEGATIVE_REPLIES
1112
12- TIMEOUT = 60.0
13+ TIMEOUT = 120
1314
1415
1516class MadlibsTemplate (TypedDict ):
16- """Structure of a template in the madlibs JSON file."""
17+ """Structure of a template in the madlibs_templates JSON file."""
1718
1819 title : str
1920 blanks : list [str ]
@@ -27,6 +28,10 @@ def __init__(self, bot: Bot):
2728 self .bot = bot
2829 self .templates = self ._load_templates ()
2930 self .edited_content = {}
31+ self .submitted_words = {}
32+ self .view = None
33+ self .wait_task : asyncio .Task | None = None
34+ self .end_game = False
3035 self .checks = set ()
3136
3237 @staticmethod
@@ -43,7 +48,9 @@ def madlibs_embed(part_of_speech: str, number_of_inputs: int) -> discord.Embed:
4348
4449 madlibs_embed .add_field (
4550 name = "Enter a word that fits the given part of speech!" ,
46- value = f"Part of speech: { part_of_speech } \n \n Make sure not to spam, or you may get auto-muted!"
51+ value = f"Part of speech: { part_of_speech } \n \n Make sure not to spam, or you may get auto-muted!\n \n "
52+ f"Note: You'll be able to use the 'Choose for me' button\n one minute after each new part "
53+ f"of speech appears."
4754 )
4855
4956 madlibs_embed .set_footer (text = f"Inputs remaining: { number_of_inputs } " )
@@ -73,8 +80,15 @@ async def madlibs(self, ctx: commands.Context) -> None:
7380 """
7481 random_template = choice (self .templates )
7582
83+ self .end_game = False
84+
7685 def author_check (message : discord .Message ) -> bool :
77- return message .channel .id == ctx .channel .id and message .author .id == ctx .author .id
86+ if message .channel .id != ctx .channel .id or message .author .id != ctx .author .id :
87+ return False
88+
89+ # Ignore commands while a game is running
90+ prefix = ctx .prefix or ""
91+ return not (prefix and message .content .startswith (prefix ))
7892
7993 self .checks .add (author_check )
8094
@@ -83,17 +97,49 @@ def author_check(message: discord.Message) -> bool:
8397 )
8498 original_message = await ctx .send (embed = loading_embed )
8599
86- submitted_words = {}
87-
88100 for i , part_of_speech in enumerate (random_template ["blanks" ]):
89101 inputs_left = len (random_template ["blanks" ]) - i
90102
103+ if self .view and getattr (self .view , "cooldown_task" , None ) and not self .view .cooldown_task .done ():
104+ self .view .cooldown_task .cancel ()
105+
106+ self .view = MadlibsView (ctx , self , 60 , part_of_speech , i )
107+
91108 madlibs_embed = self .madlibs_embed (part_of_speech , inputs_left )
92- await original_message .edit (embed = madlibs_embed )
109+ await original_message .edit (embed = madlibs_embed , view = self . view )
93110
111+ self .view .cooldown_task = asyncio .create_task (self .view .enable_random_button_after (original_message ))
112+
113+ self .wait_task = asyncio .create_task (
114+ self .bot .wait_for ("message" , timeout = TIMEOUT , check = author_check )
115+ )
94116 try :
95- message = await self .bot .wait_for ("message" , check = author_check , timeout = TIMEOUT )
117+ message = await self .wait_task
118+ self .submitted_words [i ] = message .content
119+ except asyncio .CancelledError :
120+ if self .end_game :
121+ if self .view :
122+ self .view .stop ()
123+ for child in self .view .children :
124+ if isinstance (child , discord .ui .Button ):
125+ child .disabled = True
126+
127+ # cancel cooldown cleanly
128+ task = getattr (self .view , "cooldown_task" , None )
129+ if task and not task .done ():
130+ task .cancel ()
131+
132+ await original_message .edit (view = self .view )
133+ self .checks .remove (author_check )
134+
135+ return
136+ # else: "Choose for me" set self.submitted_words[i]; just continue
96137 except TimeoutError :
138+ # If we ended the game around the same time, don't show timeout
139+ if self .end_game :
140+ self .checks .remove (author_check )
141+ return
142+
97143 timeout_embed = discord .Embed (
98144 title = choice (NEGATIVE_REPLIES ),
99145 description = "Uh oh! You took too long to respond!" ,
@@ -102,16 +148,24 @@ def author_check(message: discord.Message) -> bool:
102148
103149 await ctx .send (ctx .author .mention , embed = timeout_embed )
104150
105- for msg_id in submitted_words :
106- self .edited_content .pop (msg_id , submitted_words [msg_id ])
151+ self .view .stop ()
152+ for child in self .view .children :
153+ if isinstance (child , discord .ui .Button ):
154+ child .disabled = True
155+
156+ await original_message .edit (view = self .view )
157+
158+ for j in self .submitted_words :
159+ self .edited_content .pop (j , self .submitted_words [j ])
107160
108161 self .checks .remove (author_check )
109162
110163 return
164+ finally :
165+ # Clean up so the next iteration doesn't see an old task
166+ self .wait_task = None
111167
112- submitted_words [message .id ] = message .content
113-
114- blanks = [self .edited_content .pop (msg_id , submitted_words [msg_id ]) for msg_id in submitted_words ]
168+ blanks = [self .submitted_words [j ] for j in range (len (random_template ["blanks" ]))]
115169
116170 self .checks .remove (author_check )
117171
@@ -134,6 +188,20 @@ def author_check(message: discord.Message) -> bool:
134188
135189 await ctx .send (embed = story_embed )
136190
191+ # After sending the story, disable the view and cancel all wait tasks
192+ if self .view :
193+ task = getattr (self .view , "cooldown_task" , None )
194+ if task and not task .done ():
195+ task .cancel ()
196+ self .view .stop ()
197+ for child in self .view .children :
198+ if isinstance (child , discord .ui .Button ):
199+ child .disabled = True
200+ await original_message .edit (view = self .view )
201+
202+ if self .wait_task and not self .wait_task .done ():
203+ self .wait_task .cancel ()
204+
137205 @madlibs .error
138206 async def handle_madlibs_error (self , ctx : commands .Context , error : commands .CommandError ) -> None :
139207 """Error handler for the Madlibs command."""
@@ -142,6 +210,89 @@ async def handle_madlibs_error(self, ctx: commands.Context, error: commands.Comm
142210 error .handled = True
143211
144212
213+ class MadlibsView (discord .ui .View ):
214+ """A set of buttons to control a Madlibs game."""
215+
216+ def __init__ (self , ctx : commands .Context , cog : "Madlibs" , cooldown : float = 0 ,
217+ part_of_speech : str = "" , index : int = 0 ):
218+ super ().__init__ (timeout = 120 )
219+ self .disabled = None
220+ self .ctx = ctx
221+ self .cog = cog
222+ self .word_bank = self ._load_word_bank ()
223+ self .part_of_speech = part_of_speech
224+ self .index = index
225+ self ._cooldown = cooldown
226+
227+ # Reference to the async task that will re-enable the button
228+ self .cooldown_task : asyncio .Task | None = None
229+
230+ if cooldown > 0 :
231+ self .random_word_button .disabled = True
232+
233+ async def enable_random_button_after (self , message : discord .Message ) -> None :
234+ """Function that controls the cooldown of the "Choose for me" button to prevent spam."""
235+ if self ._cooldown <= 0 :
236+ return
237+ await asyncio .sleep (self ._cooldown )
238+
239+ # Game ended or this view is no longer the active one
240+ if self .is_finished () or self is not self .cog .view :
241+ return
242+
243+ self .random_word_button .disabled = False
244+ await message .edit (view = self )
245+
246+ @staticmethod
247+ def _load_word_bank () -> dict [str , list [str ]]:
248+ word_bank = Path ("bot/resources/fun/madlibs_word_bank.json" )
249+
250+ with open (word_bank ) as file :
251+ return json .load (file )
252+
253+ @discord .ui .button (style = discord .ButtonStyle .green , label = "Choose for me" )
254+ async def random_word_button (self , interaction : discord .Interaction , * _ ) -> None :
255+ """Button that randomly chooses a word for the user if they cannot think of a word."""
256+ if interaction .user == self .ctx .author :
257+ random_word = choice (self .word_bank [self .part_of_speech ])
258+ self .cog .submitted_words [self .index ] = random_word
259+
260+ wait_task = getattr (self .cog , "wait_task" , None )
261+ if wait_task and not wait_task .done ():
262+ wait_task .cancel ()
263+
264+ if self .cooldown_task and not self .cooldown_task .done ():
265+ self .cooldown_task .cancel ()
266+
267+ await interaction .response .send_message (f"Randomly chosen word: { random_word } " , ephemeral = True )
268+
269+ # Re-disable the button and restart the cooldown (so it can't be clicked again immediately)
270+ self .random_word_button .disabled = True
271+ await interaction .followup .edit_message (view = self )
272+ else :
273+ await interaction .response .send_message ("Only the owner of the game can end it!" , ephemeral = True )
274+
275+ @discord .ui .button (style = discord .ButtonStyle .red , label = "End Game" )
276+ async def end_button (self , interaction : discord .Interaction , * _ ) -> None :
277+ """Button that ends the current game."""
278+ if interaction .user == self .ctx .author :
279+ # Cancel the wait task if it's running
280+ self .cog .end_game = True
281+ wait_task = getattr (self .cog , "wait_task" , None )
282+ if wait_task and not wait_task .done ():
283+ wait_task .cancel ()
284+
285+ # Disable all buttons in the view
286+ for child in self .children :
287+ if isinstance (child , discord .ui .Button ):
288+ child .disabled = True
289+
290+ await interaction .response .send_message ("Ended the current game." , ephemeral = True )
291+ await interaction .followup .edit_message (message_id = interaction .message .id , view = self )
292+ else :
293+ await interaction .response .send_message ("Only the owner of the game can end it!" , ephemeral = True )
294+
295+
145296async def setup (bot : Bot ) -> None :
146297 """Load the Madlibs cog."""
147298 await bot .add_cog (Madlibs (bot ))
0 commit comments