1
+ import asyncio
1
2
import json
2
3
from pathlib import Path
3
4
from random import choice
9
10
from bot .bot import Bot
10
11
from bot .constants import Colours , NEGATIVE_REPLIES
11
12
12
- TIMEOUT = 60.0
13
+ TIMEOUT = 120
13
14
14
15
15
16
class MadlibsTemplate (TypedDict ):
16
- """Structure of a template in the madlibs JSON file."""
17
+ """Structure of a template in the madlibs_templates JSON file."""
17
18
18
19
title : str
19
20
blanks : list [str ]
@@ -27,6 +28,10 @@ def __init__(self, bot: Bot):
27
28
self .bot = bot
28
29
self .templates = self ._load_templates ()
29
30
self .edited_content = {}
31
+ self .submitted_words = {}
32
+ self .view = None
33
+ self .wait_task : asyncio .Task | None = None
34
+ self .end_game = False
30
35
self .checks = set ()
31
36
32
37
@staticmethod
@@ -73,8 +78,15 @@ async def madlibs(self, ctx: commands.Context) -> None:
73
78
"""
74
79
random_template = choice (self .templates )
75
80
81
+ self .end_game = False
82
+
76
83
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 ))
78
90
79
91
self .checks .add (author_check )
80
92
@@ -83,17 +95,49 @@ def author_check(message: discord.Message) -> bool:
83
95
)
84
96
original_message = await ctx .send (embed = loading_embed )
85
97
86
- submitted_words = {}
87
-
88
98
for i , part_of_speech in enumerate (random_template ["blanks" ]):
89
99
inputs_left = len (random_template ["blanks" ]) - i
90
100
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
+
91
106
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 )
93
108
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
+ )
94
114
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
96
135
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
+
97
141
timeout_embed = discord .Embed (
98
142
title = choice (NEGATIVE_REPLIES ),
99
143
description = "Uh oh! You took too long to respond!" ,
@@ -102,16 +146,24 @@ def author_check(message: discord.Message) -> bool:
102
146
103
147
await ctx .send (ctx .author .mention , embed = timeout_embed )
104
148
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 ])
107
158
108
159
self .checks .remove (author_check )
109
160
110
161
return
162
+ finally :
163
+ # Clean up so the next iteration doesn't see an old task
164
+ self .wait_task = None
111
165
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" ]))]
115
167
116
168
self .checks .remove (author_check )
117
169
@@ -134,6 +186,20 @@ def author_check(message: discord.Message) -> bool:
134
186
135
187
await ctx .send (embed = story_embed )
136
188
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
+
137
203
@madlibs .error
138
204
async def handle_madlibs_error (self , ctx : commands .Context , error : commands .CommandError ) -> None :
139
205
"""Error handler for the Madlibs command."""
@@ -142,6 +208,89 @@ async def handle_madlibs_error(self, ctx: commands.Context, error: commands.Comm
142
208
error .handled = True
143
209
144
210
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
+
145
294
async def setup (bot : Bot ) -> None :
146
295
"""Load the Madlibs cog."""
147
296
await bot .add_cog (Madlibs (bot ))
0 commit comments