-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Expand file tree
/
Copy paththread.py
More file actions
3536 lines (3188 loc) · 155 KB
/
thread.py
File metadata and controls
3536 lines (3188 loc) · 155 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import asyncio
import copy
import base64
import functools
import io
import re
import time
import traceback
import typing
import warnings
from datetime import timedelta, datetime, timezone
from types import SimpleNamespace
import isodate
import discord
from discord.ext import commands
from discord.ext.commands import MissingRequiredArgument, CommandError
from lottie.importers import importers as l_importers
from lottie.exporters import exporters as l_exporters
from core.models import DMDisabled, DummyMessage, PermissionLevel, getLogger
from core import checks
from core.utils import (
is_image_url,
parse_channel_topic,
match_title,
match_user_id,
truncate,
get_top_role,
create_thread_channel,
get_joint_id,
AcceptButton,
DenyButton,
ConfirmThreadCreationView,
DummyParam,
extract_forwarded_content,
)
logger = getLogger(__name__)
class Thread:
"""Represents a discord Modmail thread"""
def __init__(
self,
manager: "ThreadManager",
recipient: typing.Union[discord.Member, discord.User, int],
channel: typing.Union[discord.DMChannel, discord.TextChannel] = None,
other_recipients: typing.List[typing.Union[discord.Member, discord.User]] = None,
):
self.manager = manager
self.bot = manager.bot
if isinstance(recipient, int):
self._id = recipient
self._recipient = None
else:
if recipient.bot:
raise CommandError("Recipient cannot be a bot.")
self._id = recipient.id
self._recipient = recipient
self._other_recipients = other_recipients or []
self._channel = channel
self._genesis_message = None
self._ready_event = asyncio.Event()
self.wait_tasks = []
self.close_task = None
self.auto_close_task = None
self.auto_close_cancelled = False # Track if auto-close was explicitly cancelled
self._cancelled = False
self._dm_menu_msg_id = None
self._dm_menu_channel_id = None
# --- SNOOZE STATE ---
self.snoozed = False # True if thread is snoozed
self.snooze_data = None # Dict with channel/category/position/messages for restoration
self.log_key = None # Ensure log_key always exists
# --- UNSNOOZE COMMAND QUEUE ---
self._unsnoozing = False # True while restore_from_snooze is running
self._command_queue = [] # Queue of (ctx, command) tuples; close commands always last
def __repr__(self):
return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id}, other_recipients={len(self._other_recipients)})'
def __eq__(self, other):
if isinstance(other, Thread):
return self.id == other.id
return super().__eq__(other)
async def wait_until_ready(self) -> None:
"""Blocks execution until the thread is fully set up."""
# timeout after 30 seconds
task = self.bot.loop.create_task(asyncio.wait_for(self._ready_event.wait(), timeout=25))
self.wait_tasks.append(task)
try:
await task
except asyncio.TimeoutError:
logger.warning("Waiting for thread setup timed out.")
finally:
if task in self.wait_tasks:
self.wait_tasks.remove(task)
@property
def id(self) -> int:
return self._id
@property
def channel(self) -> typing.Union[discord.TextChannel, discord.DMChannel]:
return self._channel
@property
def recipient(self) -> typing.Optional[typing.Union[discord.User, discord.Member]]:
return self._recipient
@property
def recipients(self) -> typing.List[typing.Union[discord.User, discord.Member]]:
return [self._recipient] + self._other_recipients
@property
def ready(self) -> bool:
return self._ready_event.is_set()
@ready.setter
def ready(self, flag: bool) -> None:
"""Set the ready state and dispatch thread_create when transitioning to ready.
Some legacy code paths set thread.ready = True/False. This setter preserves that API by
updating the internal event and emitting the creation event when entering the ready state.
"""
if flag:
if not self._ready_event.is_set():
self._ready_event.set()
try:
self.bot.dispatch("thread_create", self)
except Exception as e:
logger.warning("Error dispatching thread_create: %s", e)
else:
self._ready_event.clear()
@property
def cancelled(self) -> bool:
return self._cancelled
@cancelled.setter
def cancelled(self, flag: bool):
self._cancelled = flag
if flag:
self._ready_event.set()
for i in self.wait_tasks:
i.cancel()
async def snooze(self, moderator=None, command_used=None, snooze_for=None):
"""
Save channel/category/position/messages to DB, mark as snoozed.
Behavior is configurable:
- delete (default): delete the channel and store all data for full restore later
- move: move channel to a configured snoozed category and hide it (keeps channel alive)
"""
if self.snoozed:
return False # Already snoozed
channel = self.channel
if not isinstance(channel, discord.TextChannel):
return False
# If using move-based snooze, hard-cap snoozed category to 49 channels
behavior_pre = (self.bot.config.get("snooze_behavior") or "delete").lower()
if behavior_pre == "move":
snoozed_cat_id = self.bot.config.get("snoozed_category_id")
target_category = None
if snoozed_cat_id:
try:
target_category = self.bot.modmail_guild.get_channel(int(snoozed_cat_id))
except Exception:
target_category = None
if isinstance(target_category, discord.CategoryChannel):
try:
if len(target_category.channels) >= 49:
logger.warning(
"Snoozed category (%s) is full (>=49 channels). Blocking snooze for thread %s.",
target_category.id,
self.id,
)
return False
except Exception:
# If we cannot determine channel count, proceed; downstream will handle errors
pass
# Ensure self.log_key is set before snoozing
if not self.log_key:
# Try to fetch from DB using channel_id
log_entry = await self.bot.api.get_log(self.channel.id)
if log_entry and "key" in log_entry:
self.log_key = log_entry["key"]
# Fallback: try by recipient id
elif hasattr(self, "id"):
log_entry = await self.bot.api.get_log(str(self.id))
if log_entry and "key" in log_entry:
self.log_key = log_entry["key"]
now = datetime.now(timezone.utc)
self.snooze_data = {
"category_id": channel.category_id,
"position": channel.position,
"name": channel.name,
"topic": channel.topic,
"slowmode_delay": channel.slowmode_delay,
"nsfw": channel.nsfw,
"overwrites": [(role.id, perm._values) for role, perm in channel.overwrites.items()],
"messages": [
{
"author_id": m.author.id,
"content": m.content,
"attachments": [a.url for a in m.attachments],
"embeds": [e.to_dict() for e in m.embeds],
"created_at": m.created_at.isoformat(),
"type": (
"mod_only"
if (
m.embeds
and getattr(m.embeds[0], "author", None)
and (
getattr(m.embeds[0].author, "name", "").startswith("📝 Note")
or getattr(m.embeds[0].author, "name", "").startswith("📝 Persistent Note")
)
)
else None
),
"author_name": (
getattr(m.embeds[0].author, "name", "").split(" (")[0]
if m.embeds and m.embeds[0].author and m.author == self.bot.user
else getattr(m.author, "name", None)
if m.author != self.bot.user
else None
),
"author_avatar": (
getattr(m.embeds[0].author, "icon_url", None)
if m.embeds and m.embeds[0].author and m.author == self.bot.user
else m.author.display_avatar.url
if m.author != self.bot.user
else None
),
}
async for m in channel.history(limit=None, oldest_first=True)
],
"snoozed_by": getattr(moderator, "name", None) if moderator else None,
"snooze_command": command_used,
"log_key": self.log_key,
"snooze_start": now.isoformat(),
"snooze_for": snooze_for,
}
self.snoozed = True
# Save to DB (robust: try recipient.id, then channel_id)
result = await self.bot.api.logs.update_one(
{"recipient.id": str(self.id)},
{"$set": {"snoozed": True, "snooze_data": self.snooze_data}},
)
if result.modified_count == 0 and self.channel:
result = await self.bot.api.logs.update_one(
{"channel_id": str(self.channel.id)},
{"$set": {"snoozed": True, "snooze_data": self.snooze_data}},
)
import logging
logging.info(f"[SNOOZE] DB update result: {result.modified_count}")
# Dispatch thread_snoozed event for plugins
self.bot.dispatch("thread_snoozed", self, moderator, snooze_for)
behavior = behavior_pre
if behavior == "move":
# Move the channel to the snoozed category (if configured) and optionally apply a prefix
snoozed_cat_id = self.bot.config.get("snoozed_category_id")
target_category = None
guild = self.bot.modmail_guild
if snoozed_cat_id:
try:
target_category = guild.get_channel(int(snoozed_cat_id))
except Exception:
target_category = None
# If no valid snooze category is configured, create one automatically
if not isinstance(target_category, discord.CategoryChannel):
try:
# By default, hide the snoozed category from everyone and allow only the bot to see it
overwrites = {guild.default_role: discord.PermissionOverwrite(view_channel=False)}
bot_member = guild.me
if bot_member is not None:
overwrites[bot_member] = discord.PermissionOverwrite(
view_channel=True,
send_messages=True,
read_message_history=True,
manage_channels=True,
manage_messages=True,
attach_files=True,
embed_links=True,
add_reactions=True,
)
target_category = await guild.create_category(
name="Snoozed Threads",
overwrites=overwrites,
reason="Auto-created snoozed category for move-based snoozing",
)
# Persist the newly created category ID into config for future runs
try:
await self.bot.config.set("snoozed_category_id", target_category.id)
await self.bot.config.update()
except Exception:
logger.warning("Failed to persist snoozed_category_id after auto-creation.")
except Exception as e:
logger.warning(
"Failed to auto-create snoozed category (%s). Falling back to current category.",
e,
)
target_category = channel.category
try:
# Move and sync permissions so the channel inherits the hidden snoozed-category perms
await channel.edit(
category=target_category,
reason="Thread snoozed (moved)",
sync_permissions=True,
)
# Keep channel reference; just moved
self._channel = channel
# mark in snooze data that this was a move-based snooze
self.snooze_data["moved"] = True
except Exception as e:
logger.warning(
"Failed to move channel to snoozed category: %s. Falling back to delete.",
e,
)
await channel.delete(reason="Thread snoozed by moderator (fallback delete)")
self._channel = None
else:
# Delete channel
await channel.delete(reason="Thread snoozed by moderator")
self._channel = None
return True
async def restore_from_snooze(self):
"""
Restore a snoozed thread.
- If channel was deleted (delete behavior), recreate and replay messages.
- If channel was moved (move behavior), move back to original category and position.
Mark as not snoozed and clear snooze data.
"""
# Prevent concurrent unsnooze operations
if self._unsnoozing:
logger.warning(f"Unsnooze already in progress for thread {self.id}, skipping duplicate call")
return False
# Mark that unsnooze is in progress
self._unsnoozing = True
if not self.snooze_data or not isinstance(self.snooze_data, dict):
import logging
logging.warning(
f"[UNSNOOZE] Tried to restore thread {self.id} but snooze_data is None or not a dict."
)
self._unsnoozing = False
return False
# Cache some fields we need later (before we potentially clear snooze_data)
snoozed_by = self.snooze_data.get("snoozed_by")
snooze_command = self.snooze_data.get("snooze_command")
guild = self.bot.modmail_guild
behavior = (self.bot.config.get("snooze_behavior") or "delete").lower()
# Determine original category; fall back to main_category_id if original missing
orig_category = (
guild.get_channel(self.snooze_data.get("category_id"))
if self.snooze_data.get("category_id")
else None
)
if not isinstance(orig_category, discord.CategoryChannel):
main_cat_id = self.bot.config.get("main_category_id")
orig_category = guild.get_channel(int(main_cat_id)) if main_cat_id else None
# Default: assume we'll need to recreate
channel: typing.Optional[discord.TextChannel] = None
# If move-behavior and channel still exists, move it back and restore overwrites
if behavior == "move" and isinstance(self.channel, discord.TextChannel):
try:
await self.channel.edit(
category=orig_category,
position=self.snooze_data.get("position", self.channel.position),
reason="Thread unsnoozed/restored",
)
# Restore original overwrites captured at snooze time
try:
ow_map: dict = {}
for role_id, perm_values in self.snooze_data.get("overwrites", []):
target = guild.get_role(role_id) or guild.get_member(role_id)
if target is None:
continue
ow_map[target] = discord.PermissionOverwrite(**perm_values)
if ow_map:
await self.channel.edit(overwrites=ow_map, reason="Restore original overwrites")
except Exception as e:
logger.warning("Failed to restore original overwrites on unsnooze: %s", e)
channel = self.channel
except Exception as e:
logger.warning("Failed to move snoozed channel back, recreating: %s", e)
channel = None
# If we couldn't move back (or behavior=delete), recreate the channel
if channel is None:
try:
ow_map: dict = {}
for role_id, perm_values in self.snooze_data.get("overwrites", []):
target = guild.get_role(role_id) or guild.get_member(role_id)
if target is None:
continue
ow_map[target] = discord.PermissionOverwrite(**perm_values)
channel = await guild.create_text_channel(
name=self.snooze_data.get("name") or f"thread-{self.id}",
category=orig_category,
# discord.py expects a dict for overwrites; use empty dict if none
overwrites=ow_map or {},
position=self.snooze_data.get("position"),
topic=self.snooze_data.get("topic"),
slowmode_delay=self.snooze_data.get("slowmode_delay") or 0,
nsfw=bool(self.snooze_data.get("nsfw")),
reason="Thread unsnoozed/restored (recreated)",
)
self._channel = channel
except Exception:
logger.error("Failed to recreate thread channel during unsnooze.", exc_info=True)
return False
# Helper to safely send to thread channel, recreating once if deleted
async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=None):
nonlocal channel
try:
return await channel.send(content=content, embeds=embeds, allowed_mentions=allowed_mentions)
except discord.NotFound:
# Channel was deleted between restore and send; try to recreate once
try:
ow_map: dict = {}
for role_id, perm_values in self.snooze_data.get("overwrites", []) or []:
target = guild.get_role(role_id) or guild.get_member(role_id)
if target is None:
continue
ow_map[target] = discord.PermissionOverwrite(**perm_values)
channel = await guild.create_text_channel(
name=(self.snooze_data.get("name") or f"thread-{self.id}"),
category=orig_category,
# discord.py expects a dict for overwrites; use empty dict if none
overwrites=ow_map or {},
position=self.snooze_data.get("position"),
topic=self.snooze_data.get("topic"),
slowmode_delay=self.snooze_data.get("slowmode_delay") or 0,
nsfw=bool(self.snooze_data.get("nsfw")),
reason="Thread unsnoozed/restored (recreated after NotFound)",
)
self._channel = channel
return await channel.send(
content=content,
embeds=embeds,
allowed_mentions=allowed_mentions,
)
except Exception:
logger.error(
"Failed to recreate channel during unsnooze send.",
exc_info=True,
)
return None
# Ensure genesis message exists; always present after unsnooze
genesis_already_sent = False
async def _ensure_genesis(force: bool = False):
nonlocal genesis_already_sent
try:
existing = await self.get_genesis_message()
except Exception:
existing = None
if existing is None or force:
# Build log_url and log_count best-effort
prefix = (self.bot.config.get("log_url_prefix") or "").strip("/")
if prefix == "NONE":
prefix = ""
key = self.snooze_data.get("log_key") or self.log_key
log_url = (
f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{key}"
if key
else None
)
log_count = None
try:
logs = await self.bot.api.get_user_logs(self.id)
log_count = sum(1 for log in logs if not log.get("open"))
except Exception:
log_count = None
# Resolve recipient object
user = self.recipient
if user is None:
try:
user = await self.bot.get_or_fetch_user(self.id)
except Exception:
user = SimpleNamespace(
id=self.id,
mention=f"<@{self.id}>",
created_at=datetime.now(timezone.utc),
)
try:
info_embed = self._format_info_embed(user, log_url, log_count, self.bot.main_color)
msg = await channel.send(embed=info_embed)
try:
await msg.pin()
except Exception as e:
logger.warning("Failed to pin genesis message during unsnooze: %s", e)
self._genesis_message = msg
genesis_already_sent = True
except Exception:
logger.warning("Failed to send genesis message during unsnooze.", exc_info=True)
# If we recreated the channel, force-send genesis; if moved back, ensure it's present
try:
if behavior == "move" and isinstance(channel, discord.TextChannel):
await _ensure_genesis(force=False)
else:
await _ensure_genesis(force=True)
except Exception:
logger.debug("Genesis ensure step encountered an error.")
# Strictly restore the log_key from snooze_data (never create a new one)
self.log_key = self.snooze_data.get("log_key")
# Replay messages only if we re-created the channel (delete behavior or move fallback)
if behavior != "move" or (behavior == "move" and not self.snooze_data.get("moved", False)):
# Get history limit from config (0 or None = show all)
history_limit = self.bot.config.get("unsnooze_history_limit")
all_messages = self.snooze_data.get("messages", [])
# Separate genesis, notes, and regular messages
genesis_msg = None
notes = []
regular_messages = []
for msg in all_messages:
msg_type = msg.get("type")
# Check if it's the genesis message (has Roles field)
if msg.get("embeds"):
for embed_dict in msg.get("embeds", []):
if embed_dict.get("fields"):
for field in embed_dict.get("fields", []):
if field.get("name") == "Roles":
genesis_msg = msg
break
if genesis_msg:
break
# Check if it's a note
if msg_type == "mod_only":
notes.append(msg)
elif genesis_msg != msg:
regular_messages.append(msg)
# Apply limit if set
limited = False
if history_limit:
try:
history_limit = int(history_limit)
if history_limit > 0 and len(regular_messages) > history_limit:
regular_messages = regular_messages[-history_limit:]
limited = True
except (ValueError, TypeError):
pass
# Replay genesis first (only if we didn't already create it above)
if genesis_msg and not genesis_already_sent:
msg = genesis_msg
try:
author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user(
msg["author_id"]
)
except discord.NotFound:
author = None
embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e]
if embeds:
await _safe_send_to_channel(
embeds=embeds, allowed_mentions=discord.AllowedMentions.none()
)
# Send history limit notification after genesis
if limited:
prefix = self.bot.config["log_url_prefix"].strip("/")
if prefix == "NONE":
prefix = ""
log_url = (
f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{self.log_key}"
if self.log_key
else None
)
limit_embed = discord.Embed(
color=0xFFA500,
title="⚠️ History Limited",
description=f"Only showing the last **{history_limit}** messages due to the `unsnooze_history_limit` setting.",
)
if log_url:
limit_embed.description += f"\n\n[View full history in logs]({log_url})"
await _safe_send_to_channel(
embeds=[limit_embed],
allowed_mentions=discord.AllowedMentions.none(),
)
# Build list of remaining messages to show
messages_to_show = []
messages_to_show.extend(notes)
messages_to_show.extend(regular_messages)
for msg in messages_to_show:
try:
author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user(
msg["author_id"]
)
except discord.NotFound:
author = None
content = msg.get("content")
embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e]
attachments = msg.get("attachments", [])
# Only send if there is something to send
if not content and not embeds and not attachments:
continue
author_is_mod = msg["author_id"] not in [r.id for r in self.recipients]
if author_is_mod:
# Prefer stored author_name/avatar
username = (
msg.get("author_name")
or (getattr(author, "name", None) if author else None)
or "Unknown"
)
user_id = msg.get("author_id")
if embeds:
# Ensure embeds show author details
embeds[0].set_author(
name=f"{username} ({user_id})",
icon_url=msg.get("author_avatar")
or (
author.display_avatar.url
if author and hasattr(author, "display_avatar")
else None
),
)
# If there were attachment URLs, include them as a field so mods can access them
if attachments:
try:
embeds[0].add_field(
name="Attachments",
value="\n".join(attachments),
inline=False,
)
except Exception as e:
logger.info(
"Failed to add attachments field while replaying unsnoozed messages: %s",
e,
)
await _safe_send_to_channel(
embeds=embeds,
allowed_mentions=discord.AllowedMentions.none(),
)
else:
# Plain-text path (no embeds): prefix with username and user id
header = f"**{username} ({user_id})**"
body = content or ""
if attachments and not body:
# no content; include attachment URLs on new lines
body = "\n".join(attachments)
formatted = f"{header}: {body}" if body else header
await _safe_send_to_channel(
content=formatted,
allowed_mentions=discord.AllowedMentions.none(),
)
else:
# Recipient message: include attachment URLs if content is empty
# When no embeds, prefix plain text with username and user id
username = (
msg.get("author_name")
or (getattr(author, "name", None) if author else None)
or "Unknown"
)
user_id = msg.get("author_id")
if embeds:
await _safe_send_to_channel(
content=None,
embeds=embeds or None,
allowed_mentions=discord.AllowedMentions.none(),
)
else:
header = f"**{username} ({user_id})**"
body = content or ""
if attachments and not body:
body = "\n".join(attachments)
formatted = f"{header}: {body}" if body else header
await _safe_send_to_channel(
content=formatted,
allowed_mentions=discord.AllowedMentions.none(),
)
self.snoozed = False
# Store snooze_data for notification before clearing
snooze_data_for_notify = self.snooze_data
self.snooze_data = None
# Update channel_id in DB and clear snooze_data (robust: try log_key first)
if self.log_key:
result = await self.bot.api.logs.update_one(
{"key": self.log_key},
{"$set": {"channel_id": str(channel.id)}, "$unset": {"snoozed": "", "snooze_data": ""}},
)
else:
result = await self.bot.api.logs.update_one(
{"recipient.id": str(self.id)},
{"$set": {"channel_id": str(channel.id)}, "$unset": {"snoozed": "", "snooze_data": ""}},
)
if result.modified_count == 0:
result = await self.bot.api.logs.update_one(
{"channel_id": str(channel.id)},
{
"$set": {"channel_id": str(channel.id)},
"$unset": {"snoozed": "", "snooze_data": ""},
},
)
import logging
logging.info(f"[UNSNOOZE] DB update result: {result.modified_count}")
# Notify in the configured channel
notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread"
notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored."
if notify_channel == "thread":
await _safe_send_to_channel(content=notify_text, allowed_mentions=discord.AllowedMentions.none())
else:
# Extract channel ID from mention format <#123> or use raw ID
channel_id = str(notify_channel).strip("<#>")
ch = self.bot.get_channel(int(channel_id))
if ch:
await ch.send(
f"⏰ Thread for user <@{self.id}> has been unsnoozed and restored in {channel.mention}",
allowed_mentions=discord.AllowedMentions.none(),
)
# Show who ran the snooze command and the command used
# Use snooze_data_for_notify to avoid accessing self.snooze_data after it is set to None
snoozed_by = snooze_data_for_notify.get("snoozed_by") if snooze_data_for_notify else None
snooze_command = snooze_data_for_notify.get("snooze_command") if snooze_data_for_notify else None
if snoozed_by or snooze_command:
info = f"Snoozed by: {snoozed_by or 'Unknown'} | Command: {snooze_command or '?snooze'}"
await channel.send(info, allowed_mentions=discord.AllowedMentions.none())
# Ensure channel is set before processing commands
self._channel = channel
# Mark unsnooze as complete
self._unsnoozing = False
# Dispatch thread_unsnoozed event for plugins
self.bot.dispatch("thread_unsnoozed", self)
# Process queued commands
await self._process_command_queue()
return True
@classmethod
async def from_channel(cls, manager: "ThreadManager", channel: discord.TextChannel) -> "Thread":
# there is a chance it grabs from another recipient's main thread
_, recipient_id, other_ids = parse_channel_topic(channel.topic)
if recipient_id in manager.cache:
thread = manager.cache[recipient_id]
else:
recipient = await manager.bot.get_or_fetch_user(recipient_id)
other_recipients = []
for uid in other_ids:
try:
other_recipient = await manager.bot.get_or_fetch_user(uid)
except discord.NotFound:
continue
other_recipients.append(other_recipient)
thread = cls(manager, recipient or recipient_id, channel, other_recipients)
return thread
async def get_genesis_message(self) -> discord.Message:
if self._genesis_message is None:
async for m in self.channel.history(limit=5, oldest_first=True):
if m.author == self.bot.user:
if m.embeds and m.embeds[0].fields and m.embeds[0].fields[0].name == "Roles":
self._genesis_message = m
return self._genesis_message
async def setup(self, *, creator=None, category=None, initial_message=None):
"""Create the thread channel and other io related initialisation tasks"""
self.bot.dispatch("thread_initiate", self, creator, category, initial_message)
recipient = self.recipient
# in case it creates a channel outside of category
overwrites = {self.bot.modmail_guild.default_role: discord.PermissionOverwrite(read_messages=False)}
category = category or self.bot.main_category
if category is not None:
overwrites = {}
# If thread menu is enabled and this setup call is marked as deferred genesis (initial_message carries flag),
# then we may have already created the channel earlier. Only create if channel missing.
if self._channel is None:
try:
channel = await create_thread_channel(self.bot, recipient, category, overwrites)
except discord.HTTPException as e: # Failed to create due to missing perms.
logger.critical("An error occurred while creating a thread.", exc_info=True)
self.manager.cache.pop(self.id)
embed = discord.Embed(color=self.bot.error_color)
embed.title = "Error while trying to create a thread."
embed.description = str(e)
embed.add_field(name="Recipient", value=recipient.mention)
if self.bot.log_channel is not None:
await self.bot.log_channel.send(embed=embed)
return
else:
self._channel = channel
try:
log_url, log_data = await asyncio.gather(
self.bot.api.create_log_entry(recipient, channel, creator or recipient),
self.bot.api.get_user_logs(recipient.id),
)
log_count = sum(1 for log in log_data if not log["open"])
except Exception:
logger.error("An error occurred while posting logs to the database.", exc_info=True)
log_url = log_count = None
# ensure core functionality still works
self.ready = True
if creator is not None and creator != recipient:
mention = None
else:
mention = self.bot.config["mention"]
async def send_genesis_message():
info_embed = self._format_info_embed(recipient, log_url, log_count, self.bot.main_color)
try:
msg = await channel.send(mention, embed=info_embed)
self.bot.loop.create_task(msg.pin())
self._genesis_message = msg
# Option selection logging (if a thread-creation menu option was chosen prior to creation)
if getattr(self, "_selected_thread_creation_menu_option", None) and self.bot.config.get(
"thread_creation_menu_selection_log"
):
path = self._selected_thread_creation_menu_option
try:
log_txt = f"Selected menu path: {' -> '.join(path)}"
await channel.send(embed=discord.Embed(description=log_txt, color=self.bot.mod_color))
except Exception:
logger.warning(
"Failed logging thread-creation menu selection",
exc_info=True,
)
except Exception:
logger.error("Failed unexpectedly:", exc_info=True)
async def send_recipient_genesis_message():
# Once thread is ready, tell the recipient (don't send if using contact on others)
# Allow disabling the DM receipt embed via config
if not self.bot.config.get("thread_creation_send_dm_embed"):
# If self-closable is enabled, add the close reaction to the user's
# original message instead so functionality is preserved without an embed.
try:
recipient_thread_close = self.bot.config.get("recipient_thread_close")
if recipient_thread_close and initial_message is not None:
close_emoji = self.bot.config["close_emoji"]
close_emoji = await self.bot.convert_emoji(close_emoji)
await self.bot.add_reaction(initial_message, close_emoji)
except Exception as e:
logger.info("Failed to add self-close reaction to initial message: %s", e)
return
thread_creation_response = self.bot.config["thread_creation_response"]
embed = discord.Embed(
color=self.bot.mod_color,
description=thread_creation_response,
timestamp=channel.created_at,
)
recipient_thread_close = self.bot.config.get("recipient_thread_close")
if recipient_thread_close:
footer = self.bot.config["thread_self_closable_creation_footer"]
else:
footer = self.bot.config["thread_creation_footer"]
embed.set_footer(
text=footer,
icon_url=self.bot.get_guild_icon(guild=self.bot.guild, size=128),
)
embed.title = self.bot.config["thread_creation_title"]
if creator is None or creator == recipient:
msg = await recipient.send(embed=embed)
if recipient_thread_close:
close_emoji = self.bot.config["close_emoji"]
close_emoji = await self.bot.convert_emoji(close_emoji)
await self.bot.add_reaction(msg, close_emoji)
async def send_persistent_notes():
notes = await self.bot.api.find_notes(self.recipient)
ids = {}
class State:
def store_user(self, user, cache):
return user
for note in notes:
author = note["author"]
class Author:
name = author["name"]
id = author["id"]
discriminator = author["discriminator"]
display_avatar = SimpleNamespace(url=author["avatar_url"])
data = {
"id": round(time.time() * 1000 - discord.utils.DISCORD_EPOCH) << 22,
"attachments": {},
"embeds": {},
"edited_timestamp": None,
"type": None,
"pinned": None,
"mention_everyone": None,
"tts": None,
"content": note["message"],
"author": Author(),
}
message = discord.Message(state=State(), channel=self.channel, data=data)
ids[note["_id"]] = str((await self.note(message, persistent=True, thread_creation=True)).id)
await self.bot.api.update_note_ids(ids)
async def activate_auto_triggers():
if initial_message:
message = DummyMessage(copy.copy(initial_message))
try:
return await self.bot.trigger_auto_triggers(message, channel)
except RuntimeError:
pass
await asyncio.gather(
send_genesis_message(),
send_recipient_genesis_message(),
activate_auto_triggers(),
send_persistent_notes(),
)
self.bot.dispatch("thread_ready", self, creator, category, initial_message)
def _format_info_embed(self, user, log_url, log_count, color):
"""Get information about a member of a server
supports users from the guild or not."""
member = self.bot.guild.get_member(user.id)
time = discord.utils.utcnow()
# key = log_url.split('/')[-1]
role_names = ""
if member is not None and self.bot.config["thread_show_roles"]:
sep_server = self.bot.using_multiple_server_setup
separator = ", " if sep_server else " "
roles = []
for role in sorted(member.roles, key=lambda r: r.position):
if role.is_default():
# @everyone
continue
fmt = role.name if sep_server else role.mention
roles.append(fmt)
if len(separator.join(roles)) > 1024:
roles.append("...")
while len(separator.join(roles)) > 1024:
roles.pop(-2)
break
role_names = separator.join(roles)
user_info = []
if self.bot.config["thread_show_account_age"]: