Skip to content

Commit 3b4c606

Browse files
committed
complete new secure system
1 parent 34d07e9 commit 3b4c606

4 files changed

Lines changed: 142 additions & 103 deletions

File tree

bot.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import discord
77
from discord.ext import commands
88
from dotenv import load_dotenv
9+
from pydantic import ValidationError
910
from utils.config import Config
1011

1112
# Load environment variables
@@ -40,6 +41,20 @@ def __init__(self, config: Config):
4041

4142
self.config = config
4243

44+
async def on_command_error(self, ctx: commands.Context, error: commands.CommandError):
45+
"""Global error handler."""
46+
if isinstance(error, commands.CommandNotFound):
47+
return
48+
elif isinstance(error, commands.MissingPermissions):
49+
await ctx.send("You don't have permission to do that.", delete_after=5)
50+
elif isinstance(error, commands.NotOwner):
51+
await ctx.send("This command is restricted to the bot owner.", delete_after=5)
52+
elif isinstance(error, commands.CommandOnCooldown):
53+
await ctx.send(f"Command is on cooldown. Try again in {error.retry_after:.2f}s.", delete_after=5)
54+
else:
55+
logger.error(f"Unhandled error in command {ctx.command}: {error}", exc_info=error)
56+
await ctx.send("An unexpected error occurred.")
57+
4358
async def setup_hook(self) -> None:
4459
"""Setup hook called before the bot starts."""
4560
# Load all cogs
@@ -78,7 +93,11 @@ async def on_ready(self):
7893

7994
async def main():
8095
"""Main function to run the bot."""
81-
config = Config()
96+
try:
97+
config = Config()
98+
except ValidationError as e:
99+
logger.error(f"Configuration Error: {e}")
100+
return
82101

83102
if not config.discord_token:
84103
logger.error("DISCORD_TOKEN not found in environment variables.")

cogs/modmail.py

Lines changed: 120 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Optional, Dict, Any, Union
99
import asyncio
1010
import json
11+
import aiofiles
1112
from pathlib import Path
1213
from datetime import datetime, timedelta
1314
import 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)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
aiofiles
12
discord.py
23
python-dotenv
34
pydantic

utils/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
class Config(BaseSettings):
1212
"""Bot configuration settings."""
1313

14-
discord_token: str = Field(default='demo_token')
14+
discord_token: str = Field(...)
1515
guild_id: Optional[int] = Field(default=None)
1616
database_url: str = Field(default='sqlite+aiosqlite:///fun2oosh.db')
1717
log_level: str = Field(default='INFO')

0 commit comments

Comments
 (0)