Skip to content

Commit 03dbb6e

Browse files
committed
add confirmation in modmail session + added a ci.yml for compile checks
1 parent 85d062f commit 03dbb6e

2 files changed

Lines changed: 115 additions & 26 deletions

File tree

.github/workflows/ci.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ci-${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
python-checks:
18+
name: Python checks (3.11)
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Set up Python
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: "3.11"
29+
cache: "pip"
30+
31+
- name: Install dependencies
32+
run: |
33+
python -m pip install --upgrade pip
34+
pip install -r requirements.txt
35+
36+
- name: Syntax check (compileall)
37+
run: |
38+
python -m compileall -q .
39+
40+
- name: Import smoke test
41+
run: |
42+
python -c "import utils.config; import cogs.modmail; import cogs.admin; import bot"

cogs/modmail.py

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020

2121
class ModMail(commands.Cog):
22-
# Session format per user_id:
23-
# { 'thread_id': int, 'last_activity': ISO8601 timestamp }
22+
# Session format per user_id (best-effort; older persisted schemas may exist):
23+
# { 'thread_id': int, 'last_activity': ISO8601 timestamp, 'state': 'open'|'closed'|'resolved' }
2424
modmail_sessions: Dict[int, Dict[str, Any]] = {}
2525
_session_locks: Dict[int, asyncio.Lock] = {}
2626
SESSIONS_FILE = Path("data/modmail_sessions.json")
@@ -111,6 +111,44 @@ async def _persist_sessions_to_file(self):
111111
except Exception:
112112
logger.exception("modmail: failed to persist sessions to file")
113113

114+
def _is_session_expired(self, session: Dict[str, Any]) -> bool:
115+
reset_seconds = int(getattr(self.config, 'modmail_reset_seconds', 0) or 0)
116+
if reset_seconds <= 0:
117+
return False
118+
119+
last_activity = session.get('last_activity')
120+
if not last_activity:
121+
return False
122+
123+
try:
124+
last_dt = datetime.fromisoformat(str(last_activity))
125+
except Exception:
126+
return False
127+
128+
return (datetime.utcnow() - last_dt) > timedelta(seconds=reset_seconds)
129+
130+
def _is_session_closed(self, session: Dict[str, Any]) -> bool:
131+
state = str(session.get('state') or '').lower()
132+
return state in {'closed', 'resolved'}
133+
134+
def _get_thread_from_session(
135+
self,
136+
session: Dict[str, Any],
137+
main_channel: discord.TextChannel,
138+
) -> Optional[discord.Thread]:
139+
thread_id = session.get('thread_id')
140+
if not thread_id:
141+
return None
142+
try:
143+
thread = main_channel.get_thread(int(thread_id))
144+
except Exception:
145+
return None
146+
if not thread:
147+
return None
148+
if getattr(thread, 'archived', False) or getattr(thread, 'locked', False):
149+
return None
150+
return thread
151+
114152
@commands.Cog.listener()
115153
async def on_message(self, message: discord.Message):
116154
if message.author.bot:
@@ -153,8 +191,15 @@ async def handle_dm_message(self, message: discord.Message):
153191

154192
webhook = await self._get_or_create_webhook(main_channel)
155193

156-
if not session:
157-
# Create new session
194+
thread: Optional[discord.Thread] = None
195+
session_active = False
196+
if session and isinstance(session, dict):
197+
if not self._is_session_closed(session) and not self._is_session_expired(session):
198+
thread = self._get_thread_from_session(session, main_channel)
199+
session_active = thread is not None
200+
201+
if not session_active:
202+
# Create new session (first-time or after closure/expiry)
158203
try:
159204
# Log to main channel first
160205
log_embed = discord.Embed(
@@ -173,12 +218,20 @@ async def handle_dm_message(self, message: discord.Message):
173218
await message.channel.send("An error occurred while starting the modmail session.")
174219
return
175220

221+
assert thread is not None
222+
176223
# Notify user
177-
await self._send_dm_safe(message.author, embed=discord.Embed(
178-
title="ModMail Started",
179-
description="A session has been started with the moderators. Messages you send here will be forwarded to them.",
180-
color=discord.Color.default()
181-
))
224+
await self._send_dm_safe(
225+
message.author,
226+
embed=discord.Embed(
227+
title="ModMail Started",
228+
description=(
229+
"✅ Your message has been received and a new modmail session has been opened.\n"
230+
"Messages you send here will be forwarded to the moderators."
231+
),
232+
color=discord.Color.default(),
233+
),
234+
)
182235

183236
# Send initial message via webhook
184237
files = [await f.to_file() for f in message.attachments]
@@ -191,29 +244,20 @@ async def handle_dm_message(self, message: discord.Message):
191244
files=files
192245
)
193246
except Exception as e:
194-
await thread.send(f"Failed to relay message from user: {e}")
247+
if thread is not None:
248+
await thread.send(f"Failed to relay message from user: {e}")
195249
raise e
196250

197251
self.modmail_sessions[user_id] = {
198252
'thread_id': thread.id,
199-
'last_activity': datetime.utcnow().isoformat()
253+
'last_activity': datetime.utcnow().isoformat(),
254+
'state': 'open'
200255
}
201256
else:
202257
# Continue session
203-
thread_id = session.get('thread_id')
204-
thread = None
205-
if thread_id:
206-
thread = main_channel.get_thread(int(thread_id))
207-
208-
if not thread:
209-
# Thread deleted manually? Re-create
210-
try:
211-
thread = await main_channel.create_thread(name=f"ModMail - {message.author.name} ({user_id})", type=discord.ChannelType.private_thread)
212-
except discord.HTTPException:
213-
thread = await main_channel.create_thread(name=f"ModMail - {message.author.name} ({user_id})")
214-
215-
session['thread_id'] = thread.id
216-
await thread.send(f"Wait, previous thread was lost. Resuming session for {message.author.mention}.")
258+
# `thread` is guaranteed by session_active
259+
assert thread is not None
260+
assert isinstance(session, dict)
217261

218262
files = [await f.to_file() for f in message.attachments]
219263
try:
@@ -225,9 +269,11 @@ async def handle_dm_message(self, message: discord.Message):
225269
files=files
226270
)
227271
except Exception as e:
228-
await thread.send(f"Failed to relay message from user: {e}")
272+
if thread is not None:
273+
await thread.send(f"Failed to relay message from user: {e}")
229274
raise e
230275
session['last_activity'] = datetime.utcnow().isoformat()
276+
session.setdefault('state', 'open')
231277

232278
await self._persist_sessions_to_file()
233279
except Exception as e:
@@ -359,6 +405,7 @@ async def set_modmail_channel_slash(self, interaction: discord.Interaction, chan
359405
else:
360406
await interaction.response.send_message("Please specify a text channel or use this in a text channel.", ephemeral=True)
361407
return
408+
assert channel is not None
362409
self.modmail_channel_id = channel.id
363410
await interaction.response.send_message(f"Modmail channel set to {channel.mention}.", ephemeral=True)
364411

0 commit comments

Comments
 (0)