Skip to content

Commit fc1bbc3

Browse files
committed
added confirmation before modmail
1 parent e4772d0 commit fc1bbc3

3 files changed

Lines changed: 245 additions & 52 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ A simple and efficient ModMail bot for Discord, built with `discord.py`. This bo
4040
MODMAIL_CHANNEL_ID=your_modmail_channel_id
4141
LOG_LEVEL=INFO
4242
MODMAIL_RESET_SECONDS=600
43+
MODMAIL_CONFIRM_TIMEOUT_SECONDS=300
4344
```
4445

4546
5. **Run the bot:**
@@ -49,7 +50,7 @@ A simple and efficient ModMail bot for Discord, built with `discord.py`. This bo
4950

5051
## Usage
5152

52-
- **Users**: DM the bot to start a conversation with moderators.
53+
- **Users**: DM the bot — it will ask you to confirm before opening a ModMail thread. Reply **yes** to start or **no** to cancel.
5354
- **Moderators**:
5455
- Use `!reply_modmail <user_id> <message>` (prefix command) or `/reply_modmail` (slash command) to reply to a user.
5556
- Use `!set_modmail_channel` or `/set_modmail_channel` to change the log channel.

cogs/modmail.py

Lines changed: 242 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from datetime import datetime, timedelta
1414
import logging
1515
import random
16+
import io
1617

1718
logger = 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

utils/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Config(BaseSettings):
2323
# Modmail settings
2424
modmail_channel_id: Optional[int] = Field(default=None)
2525
modmail_reset_seconds: int = Field(default=600)
26+
modmail_confirm_timeout_seconds: int = Field(default=300)
2627

2728
# CodeBuddy settings
2829
question_channel_id: Optional[int] = Field(default=None)

0 commit comments

Comments
 (0)