1313from datetime import datetime , timedelta
1414import logging
1515import random
16+ import io
1617
1718logger = logging .getLogger (__name__ )
1819
@@ -39,6 +40,207 @@ def __init__(self, bot: commands.Bot, config: Config):
3940 # Anti-Spam: 1 message every 2 seconds per user bucket
4041 self .spam_control = commands .CooldownMapping .from_cooldown (1 , 2.0 , commands .BucketType .user )
4142
43+ # DM confirmation state:
44+ # When a user DMs without an active session, we ask them to confirm
45+ # before creating a new modmail thread.
46+ # { user_id: { 'created_at': iso, 'messages': [ {content, attachments, created_at} ] } }
47+ self ._pending_confirmations : Dict [int , Dict [str , Any ]] = {}
48+
49+ # Limits for queued messages while awaiting confirmation.
50+ self ._max_pending_messages : int = 5
51+ # Store up to 8 MiB of attachment bytes per user while pending.
52+ self ._max_pending_attachment_bytes : int = 8 * 1024 * 1024
53+
54+ def _confirm_timeout_seconds (self ) -> int :
55+ try :
56+ value = int (getattr (self .config , 'modmail_confirm_timeout_seconds' , 300 ) or 300 )
57+ except Exception :
58+ value = 300
59+ return max (30 , value )
60+
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+
72+ def _pending_is_expired (self , pending : Dict [str , Any ]) -> bool :
73+ created_at = pending .get ('created_at' )
74+ if not created_at :
75+ return True
76+ try :
77+ created_dt = datetime .fromisoformat (str (created_at ))
78+ except Exception :
79+ return True
80+ return (datetime .utcnow () - created_dt ) > timedelta (seconds = self ._confirm_timeout_seconds ())
81+
82+ async def _queue_pending_message (self , user_id : int , message : discord .Message ):
83+ pending = self ._pending_confirmations .setdefault (
84+ user_id ,
85+ {'created_at' : datetime .utcnow ().isoformat (), 'messages' : []},
86+ )
87+
88+ messages = pending .setdefault ('messages' , [])
89+ if not isinstance (messages , list ):
90+ messages = []
91+ pending ['messages' ] = messages
92+
93+ # Enforce max queued messages by dropping oldest.
94+ while len (messages ) >= self ._max_pending_messages :
95+ messages .pop (0 )
96+
97+ attachment_payloads = []
98+ # Only read attachments if the total size stays within budget.
99+ existing_bytes = 0
100+ for queued in messages :
101+ for a in (queued .get ('attachments' ) or []):
102+ existing_bytes += int (a .get ('size' , 0 ) or 0 )
103+
104+ to_read = []
105+ total_new_bytes = 0
106+ for att in message .attachments :
107+ size = int (getattr (att , 'size' , 0 ) or 0 )
108+ if size <= 0 :
109+ continue
110+ total_new_bytes += size
111+ to_read .append (att )
112+
113+ if (existing_bytes + total_new_bytes ) <= self ._max_pending_attachment_bytes :
114+ for att in to_read :
115+ try :
116+ data = await att .read ()
117+ attachment_payloads .append ({
118+ 'filename' : att .filename ,
119+ 'data' : data ,
120+ 'size' : len (data ),
121+ })
122+ except Exception :
123+ logger .exception ("modmail: failed to read attachment while pending confirmation" )
124+ else :
125+ # Skip storing large attachments; user can resend after confirming.
126+ if to_read :
127+ attachment_payloads .append ({
128+ 'filename' : None ,
129+ 'data' : None ,
130+ 'size' : 0 ,
131+ 'skipped' : True ,
132+ })
133+
134+ messages .append ({
135+ 'content' : message .content or '' ,
136+ 'attachments' : attachment_payloads ,
137+ 'created_at' : datetime .utcnow ().isoformat (),
138+ })
139+
140+ def _pending_to_discord_files (self , queued_attachments : list [dict ]) -> tuple [list [discord .File ], bool ]:
141+ files : list [discord .File ] = []
142+ had_skipped = False
143+ for payload in queued_attachments or []:
144+ if payload .get ('skipped' ):
145+ had_skipped = True
146+ continue
147+ data = payload .get ('data' )
148+ filename = payload .get ('filename' )
149+ if not data or not filename :
150+ continue
151+ bio = io .BytesIO (data )
152+ files .append (discord .File (bio , filename = filename ))
153+ return files , had_skipped
154+
155+ async def _send_modmail_confirmation_prompt (self , user : Union [discord .User , discord .Member ]):
156+ await self ._send_dm_safe (
157+ user ,
158+ embed = discord .Embed (
159+ title = "Start ModMail?" ,
160+ description = (
161+ "I can open a ModMail thread so moderators can see your messages.\n \n "
162+ "Reply with **yes** to start, or **no** to cancel."
163+ ),
164+ color = discord .Color .blurple (),
165+ ),
166+ )
167+
168+ async def _start_new_session_and_flush_pending (
169+ self ,
170+ user : Union [discord .User , discord .Member ],
171+ main_channel : discord .TextChannel ,
172+ webhook : discord .Webhook ,
173+ pending : Dict [str , Any ],
174+ ):
175+ user_id = user .id
176+ # Log to main channel first
177+ log_embed = discord .Embed (
178+ title = "📨 New ModMail Created" ,
179+ description = f"**User:** { user .mention } (`{ user_id } `)" ,
180+ color = discord .Color .gold (),
181+ timestamp = datetime .utcnow (),
182+ )
183+ log_embed .set_thumbnail (url = user .display_avatar .url )
184+ starter_msg = await main_channel .send (content = "@here" , embed = log_embed )
185+ thread = await starter_msg .create_thread (name = f"ModMail - { user .name } ({ user_id } )" )
186+
187+ await self ._send_dm_safe (
188+ user ,
189+ embed = discord .Embed (
190+ title = "ModMail Started" ,
191+ description = (
192+ "✅ A modmail session is now open. Messages you send here will be forwarded to the moderators."
193+ ),
194+ color = discord .Color .default (),
195+ ),
196+ )
197+
198+ had_skipped_any = False
199+ queued_messages = pending .get ('messages' ) or []
200+ if not isinstance (queued_messages , list ):
201+ queued_messages = []
202+
203+ for qm in queued_messages :
204+ content = qm .get ('content' ) or ''
205+ files , had_skipped = self ._pending_to_discord_files (qm .get ('attachments' ) or [])
206+ had_skipped_any = had_skipped_any or had_skipped
207+ try :
208+ if not content and not files :
209+ continue
210+ send_kwargs : Dict [str , Any ] = {
211+ 'username' : user .name ,
212+ 'avatar_url' : user .display_avatar .url ,
213+ 'thread' : thread ,
214+ }
215+ if content :
216+ send_kwargs ['content' ] = content
217+ if files :
218+ send_kwargs ['files' ] = files
219+ await webhook .send (** send_kwargs )
220+ except Exception as e :
221+ await thread .send (f"Failed to relay queued message from user: { e } " )
222+ raise
223+
224+ if had_skipped_any :
225+ try :
226+ await self ._send_dm_safe (
227+ user ,
228+ content = (
229+ "⚠️ Some attachments were too large to hold while waiting for confirmation. "
230+ "If needed, please resend them now that the session is open."
231+ ),
232+ )
233+ except Exception :
234+ pass
235+
236+ self .modmail_sessions [user_id ] = {
237+ 'thread_id' : thread .id ,
238+ 'last_activity' : datetime .utcnow ().isoformat (),
239+ 'state' : 'open' ,
240+ }
241+ await self ._persist_sessions_to_file ()
242+
243+
42244 async def cog_load (self ):
43245 try :
44246 await self ._load_sessions_from_file ()
@@ -207,60 +409,49 @@ async def handle_dm_message(self, message: discord.Message):
207409 session_active = thread is not None
208410
209411 if not session_active :
210- # Create new session (first-time or after closure/expiry)
211- try :
212- # Log to main channel first
213- log_embed = discord .Embed (
214- title = "📨 New ModMail Created" ,
215- description = f"**User:** { message .author .mention } (`{ message .author .id } `)" ,
216- color = discord .Color .gold (),
217- timestamp = datetime .utcnow ()
218- )
219- log_embed .set_thumbnail (url = message .author .display_avatar .url )
220- starter_msg = await main_channel .send (content = "@here" , embed = log_embed )
221-
222- # Create public thread from the log message
223- thread = await starter_msg .create_thread (name = f"ModMail - { message .author .name } ({ user_id } )" )
224- except Exception as e :
225- logger .error (f"Failed to create modmail session: { e } " )
226- await message .channel .send ("An error occurred while starting the modmail session." )
412+ # Ask for confirmation before creating a new session.
413+ pending = self ._pending_confirmations .get (user_id )
414+ if pending and self ._pending_is_expired (pending ):
415+ self ._pending_confirmations .pop (user_id , None )
416+ pending = None
417+
418+ decision = self ._parse_confirmation (message .content )
419+
420+ if pending is None :
421+ # First DM (or after close/expiry): queue this message and ask.
422+ await self ._queue_pending_message (user_id , message )
423+ await self ._send_modmail_confirmation_prompt (message .author )
227424 return
228425
229- assert thread is not None
230-
231- # Notify user
232- await self ._send_dm_safe (
233- message .author ,
234- embed = discord .Embed (
235- title = "ModMail Started" ,
236- description = (
237- "✅ Your message has been received and a new modmail session has been opened.\n "
238- "Messages you send here will be forwarded to the moderators."
239- ),
240- color = discord .Color .default (),
241- ),
242- )
243-
244- # Send initial message via webhook
245- files = [await f .to_file () for f in message .attachments ]
246- try :
247- await webhook .send (
248- content = message .content ,
249- username = message .author .name ,
250- avatar_url = message .author .display_avatar .url ,
251- thread = thread ,
252- files = files
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." ,
253446 )
254- except Exception as e :
255- if thread is not None :
256- await thread .send (f"Failed to relay message from user: { e } " )
257- raise e
258-
259- self .modmail_sessions [user_id ] = {
260- 'thread_id' : thread .id ,
261- 'last_activity' : datetime .utcnow ().isoformat (),
262- 'state' : 'open'
263- }
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
264455 else :
265456 # Continue session
266457 # `thread` is guaranteed by session_active
0 commit comments