Skip to content

Commit 12ee4a4

Browse files
committed
change ui
1 parent fc1bbc3 commit 12ee4a4

2 files changed

Lines changed: 121 additions & 52 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ A simple and efficient ModMail bot for Discord, built with `discord.py`. This bo
5050

5151
## Usage
5252

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

cogs/modmail.py

Lines changed: 120 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
import logging
1515
import random
1616
import io
17+
from typing import TypeVar, Callable, Awaitable, cast
1718

1819
logger = logging.getLogger(__name__)
1920

21+
T = TypeVar('T')
22+
2023

2124

2225
class 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

Comments
 (0)