1414import logging
1515import random
1616import io
17+ from typing import TypeVar , Callable , Awaitable , cast
1718
1819logger = logging .getLogger (__name__ )
1920
21+ T = TypeVar ('T' )
22+
2023
2124
2225class ModMail (commands .Cog ):
@@ -43,7 +46,7 @@ def __init__(self, bot: commands.Bot, config: Config):
4346 # DM confirmation state:
4447 # When a user DMs without an active session, we ask them to confirm
4548 # before creating a new modmail thread.
46- # { user_id: { 'created_at': iso, 'messages': [ {content, attachments, created_at} ] } }
49+ # { user_id: { 'created_at': iso, 'prompt_message_id': int, ' messages': [ {content, attachments, created_at} ] } }
4750 self ._pending_confirmations : Dict [int , Dict [str , Any ]] = {}
4851
4952 # Limits for queued messages while awaiting confirmation.
@@ -58,17 +61,6 @@ def _confirm_timeout_seconds(self) -> int:
5861 value = 300
5962 return max (30 , value )
6063
61- def _parse_confirmation (self , content : str ) -> Optional [bool ]:
62- text = (content or '' ).strip ().lower ()
63- if not text :
64- return None
65- first = text .split ()[0 ]
66- if first in {"yes" , "y" , "yeah" , "yep" , "sure" , "ok" , "okay" , "confirm" }:
67- return True
68- if first in {"no" , "n" , "nope" , "nah" , "cancel" , "stop" }:
69- return False
70- return None
71-
7264 def _pending_is_expired (self , pending : Dict [str , Any ]) -> bool :
7365 created_at = pending .get ('created_at' )
7466 if not created_at :
@@ -152,18 +144,104 @@ def _pending_to_discord_files(self, queued_attachments: list[dict]) -> tuple[lis
152144 files .append (discord .File (bio , filename = filename ))
153145 return files , had_skipped
154146
155- async def _send_modmail_confirmation_prompt (self , user : Union [discord .User , discord .Member ]):
156- await self ._send_dm_safe (
147+ async def _send_modmail_confirmation_prompt (self , user : Union [discord .User , discord .Member ]) -> discord . Message :
148+ prompt = await self ._send_dm_safe (
157149 user ,
158150 embed = discord .Embed (
159151 title = "Start ModMail?" ,
160152 description = (
161153 "I can open a ModMail thread so moderators can see your messages.\n \n "
162- "Reply with **yes** to start, or **no** to cancel."
154+ "React with ✅ to start, or ❌ to cancel."
163155 ),
164156 color = discord .Color .blurple (),
165157 ),
166158 )
159+ try :
160+ await prompt .add_reaction ("✅" )
161+ await prompt .add_reaction ("❌" )
162+ except Exception :
163+ # Best-effort; if reactions fail, user can DM again.
164+ logger .exception ("modmail: failed to add reactions to confirmation prompt" )
165+ return prompt
166+
167+ async def _cancel_pending_confirmation (self , user : Union [discord .User , discord .Member ]):
168+ self ._pending_confirmations .pop (user .id , None )
169+ await self ._send_dm_safe (
170+ user ,
171+ content = "Okay — I won’t start a modmail thread. If you change your mind, DM me again." ,
172+ )
173+
174+ @commands .Cog .listener ()
175+ async def on_raw_reaction_add (self , payload : discord .RawReactionActionEvent ):
176+ # Reaction-based confirmation only applies in DMs
177+ if payload .user_id == getattr (self .bot .user , 'id' , None ):
178+ return
179+
180+ if payload .guild_id is not None :
181+ return
182+
183+ user_id = int (payload .user_id )
184+ pending = self ._pending_confirmations .get (user_id )
185+ if not pending :
186+ return
187+
188+ if self ._pending_is_expired (pending ):
189+ self ._pending_confirmations .pop (user_id , None )
190+ return
191+
192+ prompt_message_id = pending .get ('prompt_message_id' )
193+ if not prompt_message_id or int (prompt_message_id ) != int (payload .message_id ):
194+ return
195+
196+ emoji = str (payload .emoji )
197+ if emoji not in {"✅" , "❌" }:
198+ return
199+
200+ if user_id not in self ._user_locks :
201+ self ._user_locks [user_id ] = asyncio .Lock ()
202+
203+ async with self ._user_locks [user_id ]:
204+ # Re-check within lock
205+ pending = self ._pending_confirmations .get (user_id )
206+ if not pending :
207+ return
208+ if self ._pending_is_expired (pending ):
209+ self ._pending_confirmations .pop (user_id , None )
210+ return
211+ if int (pending .get ('prompt_message_id' ) or 0 ) != int (payload .message_id ):
212+ return
213+
214+ if emoji == "❌" :
215+ user = self .bot .get_user (user_id ) or await self .bot .fetch_user (user_id )
216+ await self ._cancel_pending_confirmation (user )
217+ return
218+
219+ # ✅: start session and flush queued messages
220+ if not self .modmail_channel_id :
221+ self ._pending_confirmations .pop (user_id , None )
222+ return
223+
224+ main_channel = self .bot .get_channel (self .modmail_channel_id )
225+ if not main_channel or not isinstance (main_channel , discord .TextChannel ):
226+ self ._pending_confirmations .pop (user_id , None )
227+ return
228+
229+ user = self .bot .get_user (user_id ) or await self .bot .fetch_user (user_id )
230+ webhook = await self ._get_or_create_webhook (main_channel )
231+
232+ try :
233+ await self ._start_new_session_and_flush_pending (
234+ user = user ,
235+ main_channel = main_channel ,
236+ webhook = webhook ,
237+ pending = pending ,
238+ )
239+ except Exception :
240+ logger .exception ("modmail: failed to create session after reaction confirm" )
241+ # Keep pending so user can retry reacting.
242+ return
243+
244+ self ._pending_confirmations .pop (user_id , None )
167245
168246 async def _start_new_session_and_flush_pending (
169247 self ,
@@ -250,20 +328,32 @@ async def cog_load(self):
250328 def cog_unload (self ):
251329 pass
252330
253- async def _send_with_retry (self , send_func , * args , max_retries = 3 , ** kwargs ):
331+ async def _send_with_retry (
332+ self ,
333+ send_func : Callable [..., Awaitable [T ]],
334+ * args ,
335+ max_retries : int = 3 ,
336+ ** kwargs ,
337+ ) -> T :
338+ last_exc : Optional [BaseException ] = None
254339 for attempt in range (max_retries ):
255340 try :
256341 return await send_func (* args , ** kwargs )
257342 except discord .errors .HTTPException as e :
343+ last_exc = e
258344 if e .status == 429 and attempt < max_retries - 1 :
259345 retry_after = getattr (e , 'retry_after' , None ) or (2 ** attempt ) + random .uniform (0 , 1 )
260346 await asyncio .sleep (retry_after )
261347 else :
262348 raise
263- except Exception :
349+ except Exception as e :
350+ last_exc = e
264351 raise
352+
353+ # Should be unreachable, but keeps type-checkers happy.
354+ raise RuntimeError ("send_with_retry exhausted retries" ) from last_exc
265355
266- async def _send_dm_safe (self , user : Union [discord .User , discord .Member ], ** kwargs ):
356+ async def _send_dm_safe (self , user : Union [discord .User , discord .Member ], ** kwargs ) -> discord . Message :
267357 async with self ._dm_semaphore :
268358 dm_channel = self ._dm_channel_cache .get (user .id )
269359 if dm_channel is None :
@@ -274,7 +364,9 @@ async def _send_dm_safe(self, user: Union[discord.User, discord.Member], **kwarg
274364 dm_channel = await actual_user .create_dm ()
275365 self ._dm_channel_cache [user .id ] = dm_channel
276366
277- return await self ._send_with_retry (dm_channel .send , ** kwargs )
367+ # discord.py DMChannel.send returns discord.Message
368+ result = await self ._send_with_retry (dm_channel .send , ** kwargs )
369+ return cast (discord .Message , result )
278370
279371 async def _get_or_create_webhook (self , channel : discord .TextChannel ) -> discord .Webhook :
280372 if self ._webhook :
@@ -415,43 +507,20 @@ async def handle_dm_message(self, message: discord.Message):
415507 self ._pending_confirmations .pop (user_id , None )
416508 pending = None
417509
418- decision = self ._parse_confirmation (message .content )
419-
420510 if pending is None :
421511 # First DM (or after close/expiry): queue this message and ask.
422512 await self ._queue_pending_message (user_id , message )
423- await self ._send_modmail_confirmation_prompt (message .author )
513+ prompt = await self ._send_modmail_confirmation_prompt (message .author )
514+ self ._pending_confirmations [user_id ]['prompt_message_id' ] = prompt .id
424515 return
425516
426- # We have a pending confirmation; handle yes/no or keep queuing.
427- if decision is True :
428- try :
429- await self ._start_new_session_and_flush_pending (
430- user = message .author ,
431- main_channel = main_channel ,
432- webhook = webhook ,
433- pending = pending ,
434- )
435- except Exception as e :
436- logger .error (f"Failed to create modmail session: { e } " )
437- await message .channel .send ("An error occurred while starting the modmail session." )
438- return
439- self ._pending_confirmations .pop (user_id , None )
440- return
441- elif decision is False :
442- self ._pending_confirmations .pop (user_id , None )
443- await self ._send_dm_safe (
444- message .author ,
445- content = "Okay — I won’t start a modmail thread. If you change your mind, DM me again." ,
446- )
447- return
448- else :
449- await self ._queue_pending_message (user_id , message )
450- await self ._send_dm_safe (
451- message .author ,
452- content = "Please reply with **yes** to start ModMail or **no** to cancel." ,
453- )
454- return
517+ # Pending exists: queue the message and remind the user to react on the prompt.
518+ await self ._queue_pending_message (user_id , message )
519+ await self ._send_dm_safe (
520+ message .author ,
521+ content = "React on the prompt message with ✅ to start or ❌ to cancel." ,
522+ )
523+ return
455524 else :
456525 # Continue session
457526 # `thread` is guaranteed by session_active
0 commit comments