88from typing import Optional , Dict , Any , Union
99import asyncio
1010import json
11+ import aiofiles
1112from pathlib import Path
1213from datetime import datetime , timedelta
1314import logging
@@ -29,15 +30,21 @@ def __init__(self, bot: commands.Bot, config: Config):
2930 self .config = config
3031 self .modmail_channel_id : Optional [int ] = getattr (config , 'modmail_channel_id' , None )
3132 self .RESET_DELAY_SECONDS : int = getattr (config , 'modmail_reset_seconds' , 600 )
32- self ._dm_semaphore : asyncio .Semaphore = asyncio .Semaphore (2 )
33+ self ._dm_semaphore : asyncio .Semaphore = asyncio .Semaphore (10 ) # Simultaneous DMs
3334 self ._dm_channel_cache : Dict [int , discord .DMChannel ] = {}
3435 self ._webhook : Optional [discord .Webhook ] = None
3536
37+ # Per-user lock to ensure logical consistency
38+ self ._user_locks : Dict [int , asyncio .Lock ] = {}
39+
40+ # Anti-Spam: 1 message every 2 seconds per user bucket
41+ self .spam_control = commands .CooldownMapping .from_cooldown (1 , 2.0 , commands .BucketType .user )
42+
43+ async def cog_load (self ):
3644 try :
37- self ._load_sessions_from_file ()
45+ await self ._load_sessions_from_file ()
3846 except Exception :
3947 logger .exception ("modmail: failed to load persisted sessions" )
40-
4148 self .cleanup_inactive_sessions .start ()
4249
4350 def cog_unload (self ):
@@ -82,12 +89,13 @@ async def _get_or_create_webhook(self, channel: discord.TextChannel) -> discord.
8289 self ._webhook = await channel .create_webhook (name = "ModMail Relay" )
8390 return self ._webhook
8491
85- def _load_sessions_from_file (self ):
92+ async def _load_sessions_from_file (self ):
8693 if not self .SESSIONS_FILE .exists ():
8794 return
8895 try :
89- with self .SESSIONS_FILE .open ("r" , encoding = "utf-8" ) as fh :
90- data = json .load (fh )
96+ async with aiofiles .open (self .SESSIONS_FILE , "r" , encoding = "utf-8" ) as fh :
97+ content = await fh .read ()
98+ data = json .loads (content )
9199 for k , v in data .items ():
92100 try :
93101 self .modmail_sessions [int (k )] = v
@@ -96,12 +104,12 @@ def _load_sessions_from_file(self):
96104 except Exception :
97105 logger .exception ("modmail: error reading sessions file" )
98106
99- def _persist_sessions_to_file (self ):
107+ async def _persist_sessions_to_file (self ):
100108 try :
101109 self .SESSIONS_FILE .parent .mkdir (parents = True , exist_ok = True )
102110 dumpable = {str (k ): v for k , v in self .modmail_sessions .items ()}
103- with self . SESSIONS_FILE . open ("w" , encoding = "utf-8" ) as fh :
104- json .dump (dumpable , fh )
111+ async with aiofiles . open (self . SESSIONS_FILE , "w" , encoding = "utf-8" ) as fh :
112+ await fh . write ( json .dumps (dumpable ) )
105113 except Exception :
106114 logger .exception ("modmail: failed to persist sessions to file" )
107115
@@ -120,100 +128,111 @@ async def on_message(self, message: discord.Message):
120128 await self .handle_thread_reply (message )
121129
122130 async def handle_dm_message (self , message : discord .Message ):
131+ # Spam Control
132+ bucket = self .spam_control .get_bucket (message )
133+ retry_after = bucket .update_rate_limit () if bucket else None
134+
135+ if retry_after :
136+ # Optionally log or just return
137+ return
138+
123139 try :
124140 user_id = message .author .id
125- session = self .modmail_sessions .get (user_id )
126-
127- if not self .modmail_channel_id :
128- await message .channel .send ("ModMail system is currently disabled (Channel not set)." )
129- return
130-
131- main_channel = self .bot .get_channel (self .modmail_channel_id )
132- if not main_channel or not isinstance (main_channel , discord .TextChannel ):
133- await message .channel .send ("ModMail system is unavailable (Invalid channel configuration)." )
134- return
135-
136- webhook = await self ._get_or_create_webhook (main_channel )
137-
138- if not session :
139- # Create new session
140- try :
141- thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } " , type = discord .ChannelType .private_thread )
142- except discord .HTTPException :
143- # Fallback to public thread if private threads fail (e.g. no boost)
144- thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } " )
145-
146- # Log to main channel
147- try :
148- log_embed = discord .Embed (
149- title = "📨 New ModMail Created" ,
150- description = f"**User:** { message .author .mention } (`{ message .author .id } `)\n **Thread:** { thread .mention } " ,
151- color = discord .Color .gold (),
152- timestamp = datetime .utcnow ()
153- )
154- log_embed .set_thumbnail (url = message .author .display_avatar .url )
155- await main_channel .send (content = "@here" , embed = log_embed )
156- except Exception as e :
157- logger .error (f"Failed to send modmail log: { e } " )
158-
159- # Notify user
160- await self ._send_dm_safe (message .author , embed = discord .Embed (
161- title = "ModMail Started" ,
162- description = "A session has been started with the moderators. Messages you send here will be forwarded to them." ,
163- color = discord .Color .default ()
164- ))
141+ if user_id not in self ._user_locks :
142+ self ._user_locks [user_id ] = asyncio .Lock ()
165143
166- # Send initial message via webhook
167- files = [await f .to_file () for f in message .attachments ]
168- try :
169- await webhook .send (
170- content = message .content ,
171- username = message .author .name ,
172- avatar_url = message .author .display_avatar .url ,
173- thread = thread ,
174- files = files
175- )
176- except Exception as e :
177- await thread .send (f"Failed to relay message from user: { e } " )
178- raise e
144+ async with self ._user_locks [user_id ]:
145+ session = self .modmail_sessions .get (user_id )
179146
180- self .modmail_sessions [user_id ] = {
181- 'thread_id' : thread .id ,
182- 'last_activity' : datetime .utcnow ().isoformat ()
183- }
184- else :
185- # Continue session
186- thread_id = session .get ('thread_id' )
187- thread = None
188- if thread_id :
189- thread = main_channel .get_thread (int (thread_id ))
190-
191- if not thread :
192- # Thread deleted manually? Re-create
193- try :
194- thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } " , type = discord .ChannelType .private_thread )
195- except discord .HTTPException :
196- thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } " )
197-
198- session ['thread_id' ] = thread .id
199- # Optionally notify mods that user is back but thread was lost
200- await thread .send (f"Wait, previous thread was lost. Resuming session for { message .author .mention } ." )
201-
202- files = [await f .to_file () for f in message .attachments ]
203- try :
204- await webhook .send (
205- content = message .content ,
206- username = message .author .name ,
207- avatar_url = message .author .display_avatar .url ,
208- thread = thread ,
209- files = files
210- )
211- except Exception as e :
212- await thread .send (f"Failed to relay message from user: { e } " )
213- raise e
214- session ['last_activity' ] = datetime .utcnow ().isoformat ()
147+ if not self .modmail_channel_id :
148+ await message .channel .send ("ModMail system is currently disabled (Channel not set)." )
149+ return
150+
151+ main_channel = self .bot .get_channel (self .modmail_channel_id )
152+ if not main_channel or not isinstance (main_channel , discord .TextChannel ):
153+ await message .channel .send ("ModMail system is unavailable (Invalid channel configuration)." )
154+ return
155+
156+ webhook = await self ._get_or_create_webhook (main_channel )
157+
158+ if not session :
159+ # Create new session
160+ try :
161+ thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } ({ user_id } )" , type = discord .ChannelType .private_thread )
162+ except discord .HTTPException :
163+ # Fallback to public thread if private threads fail
164+ thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } ({ user_id } )" )
165+
166+ # Log to main channel
167+ try :
168+ log_embed = discord .Embed (
169+ title = "📨 New ModMail Created" ,
170+ description = f"**User:** { message .author .mention } (`{ message .author .id } `)\n **Thread:** { thread .mention } " ,
171+ color = discord .Color .gold (),
172+ timestamp = datetime .utcnow ()
173+ )
174+ log_embed .set_thumbnail (url = message .author .display_avatar .url )
175+ await main_channel .send (content = "@here" , embed = log_embed )
176+ except Exception as e :
177+ logger .error (f"Failed to send modmail log: { e } " )
178+
179+ # Notify user
180+ await self ._send_dm_safe (message .author , embed = discord .Embed (
181+ title = "ModMail Started" ,
182+ description = "A session has been started with the moderators. Messages you send here will be forwarded to them." ,
183+ color = discord .Color .default ()
184+ ))
185+
186+ # Send initial message via webhook
187+ files = [await f .to_file () for f in message .attachments ]
188+ try :
189+ await webhook .send (
190+ content = message .content ,
191+ username = message .author .name ,
192+ avatar_url = message .author .display_avatar .url ,
193+ thread = thread ,
194+ files = files
195+ )
196+ except Exception as e :
197+ await thread .send (f"Failed to relay message from user: { e } " )
198+ raise e
199+
200+ self .modmail_sessions [user_id ] = {
201+ 'thread_id' : thread .id ,
202+ 'last_activity' : datetime .utcnow ().isoformat ()
203+ }
204+ else :
205+ # Continue session
206+ thread_id = session .get ('thread_id' )
207+ thread = None
208+ if thread_id :
209+ thread = main_channel .get_thread (int (thread_id ))
215210
216- self ._persist_sessions_to_file ()
211+ if not thread :
212+ # Thread deleted manually? Re-create
213+ try :
214+ thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } ({ user_id } )" , type = discord .ChannelType .private_thread )
215+ except discord .HTTPException :
216+ thread = await main_channel .create_thread (name = f"ModMail - { message .author .name } ({ user_id } )" )
217+
218+ session ['thread_id' ] = thread .id
219+ await thread .send (f"Wait, previous thread was lost. Resuming session for { message .author .mention } ." )
220+
221+ files = [await f .to_file () for f in message .attachments ]
222+ try :
223+ await webhook .send (
224+ content = message .content ,
225+ username = message .author .name ,
226+ avatar_url = message .author .display_avatar .url ,
227+ thread = thread ,
228+ files = files
229+ )
230+ except Exception as e :
231+ await thread .send (f"Failed to relay message from user: { e } " )
232+ raise e
233+ session ['last_activity' ] = datetime .utcnow ().isoformat ()
234+
235+ await self ._persist_sessions_to_file ()
217236 except Exception as e :
218237 logger .exception (f"Error handling DM message from { message .author .id } " )
219238 try :
@@ -258,7 +277,7 @@ async def handle_thread_reply(self, message: discord.Message):
258277 await self ._send_dm_safe (user , embed = embed , files = files )
259278
260279 self .modmail_sessions [session_user_id ]['last_activity' ] = datetime .utcnow ().isoformat ()
261- self ._persist_sessions_to_file ()
280+ await self ._persist_sessions_to_file ()
262281 # Optional: React to confirm sent
263282 await message .add_reaction ("✅" )
264283 except Exception as e :
@@ -281,7 +300,7 @@ async def close_session(self, ctx):
281300
282301 # Close session
283302 del self .modmail_sessions [session_user_id ]
284- self ._persist_sessions_to_file ()
303+ await self ._persist_sessions_to_file ()
285304
286305 user = self .bot .get_user (session_user_id )
287306 if user :
@@ -362,7 +381,7 @@ async def cleanup_inactive_sessions(self):
362381 pass
363382
364383 if to_remove :
365- self ._persist_sessions_to_file ()
384+ await self ._persist_sessions_to_file ()
366385
367386 @commands .command (name = "set_modmail_channel" )
368387 @commands .has_permissions (administrator = True )
0 commit comments