Skip to content

Commit fa6edee

Browse files
authored
Merge branch 'development' into users/stephen/4-2-2-changelog
2 parents a2b112c + 4ab9055 commit fa6edee

4 files changed

Lines changed: 140 additions & 86 deletions

File tree

core/clients.py

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -662,39 +662,59 @@ async def append_log(
662662
channel_id: str = "",
663663
type_: str = "thread_message",
664664
) -> dict:
665-
channel_id = str(channel_id) or str(message.channel.id)
666-
message_id = str(message_id) or str(message.id)
667-
668-
content = message.content or ""
669-
if forwarded := extract_forwarded_content(message):
670-
if content:
671-
content += "\n" + forwarded
672-
else:
673-
content = forwarded
674-
675-
data = {
676-
"timestamp": str(message.created_at),
677-
"message_id": message_id,
678-
"author": {
679-
"id": str(message.author.id),
680-
"name": message.author.name,
681-
"discriminator": message.author.discriminator,
682-
"avatar_url": message.author.display_avatar.url if message.author.display_avatar else None,
683-
"mod": not isinstance(message.channel, DMChannel),
684-
},
685-
"content": content,
686-
"type": type_,
687-
"attachments": [
688-
{
689-
"id": a.id,
690-
"filename": a.filename,
691-
"is_image": a.width is not None,
692-
"size": a.size,
693-
"url": a.url,
694-
}
695-
for a in message.attachments
696-
],
697-
}
665+
channel_id = str(channel_id) or (str(message.channel.id) if message else "")
666+
message_id = str(message_id) or (str(message.id) if message else "")
667+
668+
if message:
669+
content = message.content or ""
670+
if forwarded := extract_forwarded_content(message):
671+
if content:
672+
content += "\n" + forwarded
673+
else:
674+
content = forwarded
675+
676+
data = {
677+
"timestamp": str(message.created_at),
678+
"message_id": message_id,
679+
"author": {
680+
"id": str(message.author.id),
681+
"name": message.author.name,
682+
"discriminator": message.author.discriminator,
683+
"avatar_url": (
684+
message.author.display_avatar.url if message.author.display_avatar else None
685+
),
686+
"mod": not isinstance(message.channel, DMChannel),
687+
},
688+
"content": content,
689+
"type": type_,
690+
"attachments": [
691+
{
692+
"id": a.id,
693+
"filename": a.filename,
694+
"is_image": a.width is not None,
695+
"size": a.size,
696+
"url": a.url,
697+
}
698+
for a in message.attachments
699+
],
700+
}
701+
else:
702+
# Fallback for when message is None but we still want to log something (e.g. system note)
703+
# This requires at least some manual data to be useful.
704+
data = {
705+
"timestamp": str(discord.utils.utcnow()),
706+
"message_id": message_id or "0",
707+
"author": {
708+
"id": "0",
709+
"name": "System",
710+
"discriminator": "0000",
711+
"avatar_url": None,
712+
"mod": True,
713+
},
714+
"content": "System Message (No Content)",
715+
"type": type_,
716+
"attachments": [],
717+
}
698718

699719
return await self.logs.find_one_and_update(
700720
{"channel_id": channel_id},

core/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,10 @@ def __init__(self, message):
438438
self._message = message
439439

440440
def __getattr__(self, name: str):
441+
if self._message is None:
442+
# If we're wrapping None, we can't delegate attributes.
443+
# This mimics behavior where the attribute doesn't exist.
444+
raise AttributeError(f"'DummyMessage' object has no attribute '{name}' (wrapped message is None)")
441445
return getattr(self._message, name)
442446

443447
def __bool__(self):

core/thread.py

Lines changed: 76 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ def cancelled(self) -> bool:
146146
def cancelled(self, flag: bool):
147147
self._cancelled = flag
148148
if flag:
149+
self._ready_event.set()
149150
for i in self.wait_tasks:
150151
i.cancel()
151152

@@ -260,9 +261,7 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None):
260261
{"channel_id": str(self.channel.id)},
261262
{"$set": {"snoozed": True, "snooze_data": self.snooze_data}},
262263
)
263-
import logging
264-
265-
logging.info(f"[SNOOZE] DB update result: {result.modified_count}")
264+
logger.info("[SNOOZE] DB update result: %s", result.modified_count)
266265

267266
# Dispatch thread_snoozed event for plugins
268267
self.bot.dispatch("thread_snoozed", self, moderator, snooze_for)
@@ -728,9 +727,7 @@ async def _ensure_genesis(force: bool = False):
728727
"$unset": {"snoozed": "", "snooze_data": ""},
729728
},
730729
)
731-
import logging
732-
733-
logging.info(f"[UNSNOOZE] DB update result: {result.modified_count}")
730+
logger.info("[UNSNOOZE] DB update result: %s", result.modified_count)
734731
# Notify in the configured channel
735732
notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread"
736733
notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored."
@@ -1059,7 +1056,10 @@ async def close(
10591056
"""Close a thread now or after a set time in seconds"""
10601057

10611058
# restarts the after timer
1062-
await self.cancel_closure(auto_close)
1059+
await self.cancel_closure(
1060+
auto_close,
1061+
mark_auto_close_cancelled=not auto_close,
1062+
)
10631063

10641064
if after > 0:
10651065
# TODO: Add somewhere to clean up broken closures
@@ -1103,7 +1103,7 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None,
11031103
logger.error("Thread already closed: %s.", e)
11041104
return
11051105

1106-
await self.cancel_closure(all=True)
1106+
await self.cancel_closure(all=True, mark_auto_close_cancelled=False)
11071107

11081108
# Cancel auto closing the thread if closed by any means.
11091109

@@ -1277,18 +1277,32 @@ async def _disable_dm_creation_menu(self) -> None:
12771277
except Exception as inner_e:
12781278
logger.debug("Failed removing view from DM menu message: %s", inner_e)
12791279

1280-
async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> None:
1280+
async def cancel_closure(
1281+
self,
1282+
auto_close: bool = False,
1283+
all: bool = False,
1284+
*,
1285+
mark_auto_close_cancelled: bool = True,
1286+
) -> None:
12811287
if self.close_task is not None and (not auto_close or all):
12821288
self.close_task.cancel()
12831289
self.close_task = None
12841290
if self.auto_close_task is not None and (auto_close or all):
12851291
self.auto_close_task.cancel()
12861292
self.auto_close_task = None
1287-
self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled
1288-
1289-
to_update = self.bot.config["closures"].pop(str(self.id), None)
1290-
if to_update is not None:
1291-
await self.bot.config.update()
1293+
if mark_auto_close_cancelled:
1294+
self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled
1295+
1296+
closure_key = str(self.id)
1297+
existing = self.bot.config["closures"].get(closure_key)
1298+
if existing is not None:
1299+
existing_is_auto = bool(existing.get("auto_close", False))
1300+
should_remove = (
1301+
all or (auto_close and existing_is_auto) or ((not auto_close) and (not existing_is_auto))
1302+
)
1303+
if should_remove:
1304+
self.bot.config["closures"].pop(closure_key, None)
1305+
await self.bot.config.update()
12921306

12931307
async def _restart_close_timer(self):
12941308
"""
@@ -1798,6 +1812,13 @@ async def send(
17981812
reply commands to avoid mutating the original message object.
17991813
"""
18001814
# Handle notes with Discord-like system message format - return early
1815+
if message is None:
1816+
# Safeguard against None messages (e.g. from menu interactions without a source message)
1817+
if not note and not from_mod and not thread_creation:
1818+
# If we're just trying to log/relay a user message and there is none, existing behavior
1819+
# suggests we might skip or error. Logging a warning and returning is safer than crashing.
1820+
return
1821+
18011822
if note:
18021823
destination = destination or self.channel
18031824
content = message.content or "[No content]"
@@ -1830,11 +1851,14 @@ async def send(
18301851
return await destination.send(embed=embed)
18311852

18321853
if not note and from_mod:
1833-
# Only restart auto-close if it wasn't explicitly cancelled
1854+
# Only restart auto-close if it wasn't explicitly cancelled.
1855+
# Auto-close is driven by the last moderator reply.
18341856
if not self.auto_close_cancelled:
18351857
self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close
18361858
elif not note and not from_mod:
1837-
await self.cancel_closure(all=True)
1859+
# If the user replied last, the thread should not auto-close.
1860+
# Cancel any pending auto-close without marking it as an explicit cancellation.
1861+
await self.cancel_closure(auto_close=True, mark_auto_close_cancelled=False)
18381862

18391863
if self.close_task is not None:
18401864
# cancel closing if a thread message is sent.
@@ -1852,7 +1876,8 @@ async def send(
18521876
await self.wait_until_ready()
18531877

18541878
if not from_mod and not note:
1855-
self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id))
1879+
if self.channel:
1880+
self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id))
18561881

18571882
destination = destination or self.channel
18581883

@@ -2580,6 +2605,10 @@ async def create(
25802605
# checks for existing thread in cache
25812606
thread = self.cache.get(recipient.id)
25822607
if thread:
2608+
# If there's a pending menu, return the existing thread to avoid creating duplicates
2609+
if getattr(thread, "_pending_menu", False):
2610+
logger.debug("Thread for %s has pending menu, returning existing thread.", recipient)
2611+
return thread
25832612
try:
25842613
await thread.wait_until_ready()
25852614
except asyncio.CancelledError:
@@ -2588,8 +2617,8 @@ async def create(
25882617
label = f"{recipient} ({recipient.id})"
25892618
except Exception:
25902619
label = f"User ({getattr(recipient, 'id', 'unknown')})"
2591-
logger.warning("Thread for %s cancelled, abort creating.", label)
2592-
return thread
2620+
self.cache.pop(recipient.id, None)
2621+
thread = None
25932622
else:
25942623
if thread.channel and self.bot.get_channel(thread.channel.id):
25952624
logger.warning("Found an existing thread for %s, abort creating.", recipient)
@@ -2937,35 +2966,36 @@ async def callback(self, interaction: discord.Interaction):
29372966
setattr(self.outer_thread, "_pending_menu", False)
29382967
return
29392968
# Forward the user's initial DM to the thread channel
2940-
try:
2941-
await self.outer_thread.send(message)
2942-
except Exception:
2943-
logger.error(
2944-
"Failed to relay initial message after menu selection",
2945-
exc_info=True,
2946-
)
2947-
else:
2948-
# React to the user's DM with the 'sent' emoji
2969+
if message:
29492970
try:
2950-
(
2951-
sent_emoji,
2952-
_,
2953-
) = await self.outer_thread.bot.retrieve_emoji()
2954-
await self.outer_thread.bot.add_reaction(message, sent_emoji)
2955-
except Exception as e:
2956-
logger.debug(
2957-
"Failed to add sent reaction to user's DM: %s",
2958-
e,
2971+
await self.outer_thread.send(message)
2972+
except Exception:
2973+
logger.error(
2974+
"Failed to relay initial message after menu selection",
2975+
exc_info=True,
2976+
)
2977+
else:
2978+
# React to the user's DM with the 'sent' emoji
2979+
try:
2980+
(
2981+
sent_emoji,
2982+
_,
2983+
) = await self.outer_thread.bot.retrieve_emoji()
2984+
await self.outer_thread.bot.add_reaction(message, sent_emoji)
2985+
except Exception as e:
2986+
logger.debug(
2987+
"Failed to add sent reaction to user's DM: %s",
2988+
e,
2989+
)
2990+
# Dispatch thread_reply event for parity
2991+
self.outer_thread.bot.dispatch(
2992+
"thread_reply",
2993+
self.outer_thread,
2994+
False,
2995+
message,
2996+
False,
2997+
False,
29592998
)
2960-
# Dispatch thread_reply event for parity
2961-
self.outer_thread.bot.dispatch(
2962-
"thread_reply",
2963-
self.outer_thread,
2964-
False,
2965-
message,
2966-
False,
2967-
False,
2968-
)
29692999
# Clear pending flag
29703000
setattr(self.outer_thread, "_pending_menu", False)
29713001
except Exception:

plugins/registry.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@
6363
"thumbnail_url": "https://i.imgur.com/Mo60CdK.png"
6464
},
6565
"claim": {
66-
"repository": "fourjr/modmail-plugins",
67-
"branch": "v4",
68-
"description": "Allows supporters to claim thread by sending ?claim in the thread channel",
69-
"bot_version": "4.0.0",
66+
"repository": "martinbndr/kyb3r-modmail-plugins",
67+
"branch": "master",
68+
"description": "Adds claim functionality to your modmail bot.",
69+
"bot_version": "4.2.1",
7070
"title": "Claim Thread",
71-
"icon_url": "https://i.imgur.com/Mo60CdK.png",
72-
"thumbnail_url": "https://i.imgur.com/Mo60CdK.png"
71+
"icon_url": "https://i.ibb.co/dsPjgKLj/87249157.png",
72+
"thumbnail_url": "https://i.ibb.co/dsPjgKLj/87249157.png"
7373
},
7474
"emote-manager": {
7575
"repository": "fourjr/modmail-plugins",
@@ -125,4 +125,4 @@
125125
"icon_url": "https://i.imgur.com/A1auJ95.png",
126126
"thumbnail_url": "https://i.imgur.com/A1auJ95.png"
127127
}
128-
}
128+
}

0 commit comments

Comments
 (0)