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
@@ -73,8 +78,15 @@ async def madlibs(self, ctx: commands.Context) -> None:
7378 """
7479 random_template = choice (self .templates )
7580
81+ self .end_game = False
82+
7683 def author_check (message : discord .Message ) -> bool :
77- return message .channel .id == ctx .channel .id and message .author .id == ctx .author .id
84+ if message .channel .id != ctx .channel .id or message .author .id != ctx .author .id :
85+ return False
86+
87+ # Ignore commands while a game is running
88+ prefix = ctx .prefix or ""
89+ return not (prefix and message .content .startswith (prefix ))
7890
7991 self .checks .add (author_check )
8092
@@ -83,17 +95,49 @@ def author_check(message: discord.Message) -> bool:
8395 )
8496 original_message = await ctx .send (embed = loading_embed )
8597
86- submitted_words = {}
87-
8898 for i , part_of_speech in enumerate (random_template ["blanks" ]):
8999 inputs_left = len (random_template ["blanks" ]) - i
90100
101+ if self .view and getattr (self .view , "cooldown_task" , None ) and not self .view .cooldown_task .done ():
102+ self .view .cooldown_task .cancel ()
103+
104+ self .view = MadlibsView (ctx , self , 60 , part_of_speech , i )
105+
91106 madlibs_embed = self .madlibs_embed (part_of_speech , inputs_left )
92- await original_message .edit (embed = madlibs_embed )
107+ await original_message .edit (embed = madlibs_embed , view = self . view )
93108
109+ self .view .cooldown_task = asyncio .create_task (self .view .enable_random_button_after (original_message ))
110+
111+ self .wait_task = asyncio .create_task (
112+ self .bot .wait_for ("message" , timeout = TIMEOUT , check = author_check )
113+ )
94114 try :
95- message = await self .bot .wait_for ("message" , check = author_check , timeout = TIMEOUT )
115+ message = await self .wait_task
116+ self .submitted_words [i ] = message .content
117+ except asyncio .CancelledError :
118+ if self .end_game :
119+ if self .view :
120+ self .view .stop ()
121+ for child in self .view .children :
122+ if isinstance (child , discord .ui .Button ):
123+ child .disabled = True
124+
125+ # cancel cooldown cleanly
126+ task = getattr (self .view , "cooldown_task" , None )
127+ if task and not task .done ():
128+ task .cancel ()
129+
130+ await original_message .edit (view = self .view )
131+ self .checks .remove (author_check )
132+
133+ return
134+ # else: "Choose for me" set self.submitted_words[i]; just continue
96135 except TimeoutError :
136+ # If we ended the game around the same time, don't show timeout
137+ if self .end_game :
138+ self .checks .remove (author_check )
139+ return
140+
97141 timeout_embed = discord .Embed (
98142 title = choice (NEGATIVE_REPLIES ),
99143 description = "Uh oh! You took too long to respond!" ,
@@ -102,16 +146,24 @@ def author_check(message: discord.Message) -> bool:
102146
103147 await ctx .send (ctx .author .mention , embed = timeout_embed )
104148
105- for msg_id in submitted_words :
106- self .edited_content .pop (msg_id , submitted_words [msg_id ])
149+ self .view .stop ()
150+ for child in self .view .children :
151+ if isinstance (child , discord .ui .Button ):
152+ child .disabled = True
153+
154+ await original_message .edit (view = self .view )
155+
156+ for j in self .submitted_words :
157+ self .edited_content .pop (j , self .submitted_words [j ])
107158
108159 self .checks .remove (author_check )
109160
110161 return
162+ finally :
163+ # Clean up so the next iteration doesn't see an old task
164+ self .wait_task = None
111165
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 ]
166+ blanks = [self .submitted_words [j ] for j in range (len (random_template ["blanks" ]))]
115167
116168 self .checks .remove (author_check )
117169
@@ -134,6 +186,20 @@ def author_check(message: discord.Message) -> bool:
134186
135187 await ctx .send (embed = story_embed )
136188
189+ # After sending the story, disable the view and cancel all wait tasks
190+ if self .view :
191+ task = getattr (self .view , "cooldown_task" , None )
192+ if task and not task .done ():
193+ task .cancel ()
194+ self .view .stop ()
195+ for child in self .view .children :
196+ if isinstance (child , discord .ui .Button ):
197+ child .disabled = True
198+ await original_message .edit (view = self .view )
199+
200+ if self .wait_task and not self .wait_task .done ():
201+ self .wait_task .cancel ()
202+
137203 @madlibs .error
138204 async def handle_madlibs_error (self , ctx : commands .Context , error : commands .CommandError ) -> None :
139205 """Error handler for the Madlibs command."""
@@ -142,6 +208,89 @@ async def handle_madlibs_error(self, ctx: commands.Context, error: commands.Comm
142208 error .handled = True
143209
144210
211+ class MadlibsView (discord .ui .View ):
212+ """A set of buttons to control a Madlibs game."""
213+
214+ def __init__ (self , ctx : commands .Context , cog : "Madlibs" , cooldown : float = 0 ,
215+ part_of_speech : str = "" , index : int = 0 ):
216+ super ().__init__ (timeout = 120 )
217+ self .disabled = None
218+ self .ctx = ctx
219+ self .cog = cog
220+ self .word_bank = self ._load_word_bank ()
221+ self .part_of_speech = part_of_speech
222+ self .index = index
223+ self ._cooldown = cooldown
224+
225+ # Reference to the async task that will re-enable the button
226+ self .cooldown_task : asyncio .Task | None = None
227+
228+ if cooldown > 0 :
229+ self .random_word_button .disabled = True
230+
231+ async def enable_random_button_after (self , message : discord .Message ) -> None :
232+ """Function that controls the cooldown of the "Choose for me" button to prevent spam."""
233+ if self ._cooldown <= 0 :
234+ return
235+ await asyncio .sleep (self ._cooldown )
236+
237+ # Game ended or this view is no longer the active one
238+ if self .is_finished () or self is not self .cog .view :
239+ return
240+
241+ self .random_word_button .disabled = False
242+ await message .edit (view = self )
243+
244+ @staticmethod
245+ def _load_word_bank () -> dict [str , list [str ]]:
246+ word_bank = Path ("bot/resources/fun/madlibs_word_bank.json" )
247+
248+ with open (word_bank ) as file :
249+ return json .load (file )
250+
251+ @discord .ui .button (style = discord .ButtonStyle .green , label = "Choose for me" )
252+ async def random_word_button (self , interaction : discord .Interaction , * _ ) -> None :
253+ """Button that randomly chooses a word for the user if they cannot think of a word."""
254+ if interaction .user == self .ctx .author :
255+ random_word = choice (self .word_bank [self .part_of_speech ])
256+ self .cog .submitted_words [self .index ] = random_word
257+
258+ wait_task = getattr (self .cog , "wait_task" , None )
259+ if wait_task and not wait_task .done ():
260+ wait_task .cancel ()
261+
262+ if self .cooldown_task and not self .cooldown_task .done ():
263+ self .cooldown_task .cancel ()
264+
265+ await interaction .response .send_message (f"Randomly chosen word: { random_word } " , ephemeral = True )
266+
267+ # Re-disable the button and restart the cooldown (so it can't be clicked again immediately)
268+ self .random_word_button .disabled = True
269+ await interaction .followup .edit_message (view = self )
270+ else :
271+ await interaction .response .send_message ("Only the owner of the game can end it!" , ephemeral = True )
272+
273+ @discord .ui .button (style = discord .ButtonStyle .red , label = "End Game" )
274+ async def end_button (self , interaction : discord .Interaction , * _ ) -> None :
275+ """Button that ends the current game."""
276+ if interaction .user == self .ctx .author :
277+ # Cancel the wait task if it's running
278+ self .cog .end_game = True
279+ wait_task = getattr (self .cog , "wait_task" , None )
280+ if wait_task and not wait_task .done ():
281+ wait_task .cancel ()
282+
283+ # Disable all buttons in the view
284+ for child in self .children :
285+ if isinstance (child , discord .ui .Button ):
286+ child .disabled = True
287+
288+ await interaction .response .send_message ("Ended the current game." , ephemeral = True )
289+ await interaction .followup .edit_message (message_id = interaction .message .id , view = self )
290+ else :
291+ await interaction .response .send_message ("Only the owner of the game can end it!" , ephemeral = True )
292+
293+
145294async def setup (bot : Bot ) -> None :
146295 """Load the Madlibs cog."""
147296 await bot .add_cog (Madlibs (bot ))
0 commit comments