Skip to content

Commit 1fa212a

Browse files
committed
v3.3.1-dev2
1 parent 5a079c6 commit 1fa212a

File tree

7 files changed

+237
-163
lines changed

7 files changed

+237
-163
lines changed

CHANGELOG.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,21 @@ This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.
77
however, insignificant breaking changes does not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319).
88

99

10-
# v3.3.1-dev1
10+
# v3.3.1-dev2
1111

1212
### Added
1313

14+
- Thread cooldown!
15+
- Set via the new config var `thread_cooldown`.
16+
- Specify a time for the recipient to wait before allowed to create another thread.
1417
- "enable" and "disable" support for yes or no config vars.
1518
- Added "perhaps you meant" section to `?config help`.
1619
- Multi-command alias is now more stable. With support for a single quote escape `\"`.
1720

21+
### Fixed
22+
23+
- Setting config vars using human time wasn't working.
24+
1825
### Internal
1926

2027
- Commit to black format line width max = 99, consistent with pylint.

bot.py

+166-109
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "3.3.1-dev1"
1+
__version__ = "3.3.1-dev2"
22

33
import asyncio
44
import logging
@@ -529,122 +529,171 @@ async def retrieve_emoji(self) -> typing.Tuple[str, str]:
529529

530530
return sent_emoji, blocked_emoji
531531

532-
async def _process_blocked(self, message: discord.Message) -> typing.Tuple[bool, str]:
533-
sent_emoji, blocked_emoji = await self.retrieve_emoji()
534-
535-
if str(message.author.id) in self.blocked_whitelisted_users:
536-
if str(message.author.id) in self.blocked_users:
537-
self.blocked_users.pop(str(message.author.id))
538-
await self.config.update()
539-
540-
return False, sent_emoji
541-
542-
now = datetime.utcnow()
543-
532+
def check_account_age(self, author: discord.Member) -> bool:
544533
account_age = self.config.get("account_age")
545-
guild_age = self.config.get("guild_age")
546-
547-
if account_age is None:
548-
account_age = isodate.Duration()
549-
if guild_age is None:
550-
guild_age = isodate.Duration()
551-
552-
reason = self.blocked_users.get(str(message.author.id)) or ""
553-
min_guild_age = min_account_age = now
534+
now = datetime.utcnow()
554535

555536
try:
556-
min_account_age = message.author.created_at + account_age
537+
min_account_age = author.created_at + account_age
557538
except ValueError:
558539
logger.warning("Error with 'account_age'.", exc_info=True)
559-
self.config.remove("account_age")
560-
561-
try:
562-
joined_at = getattr(message.author, "joined_at", None)
563-
if joined_at is not None:
564-
min_guild_age = joined_at + guild_age
565-
except ValueError:
566-
logger.warning("Error with 'guild_age'.", exc_info=True)
567-
self.config.remove("guild_age")
540+
min_account_age = author.created_at + self.config.remove("account_age")
568541

569542
if min_account_age > now:
570543
# User account has not reached the required time
571-
reaction = blocked_emoji
572-
changed = False
573544
delta = human_timedelta(min_account_age)
574-
logger.debug("Blocked due to account age, user %s.", message.author.name)
545+
logger.debug("Blocked due to account age, user %s.", author.name)
575546

576-
if str(message.author.id) not in self.blocked_users:
547+
if str(author.id) not in self.blocked_users:
577548
new_reason = f"System Message: New Account. Required to wait for {delta}."
578-
self.blocked_users[str(message.author.id)] = new_reason
579-
changed = True
549+
self.blocked_users[str(author.id)] = new_reason
580550

581-
if reason.startswith("System Message: New Account.") or changed:
582-
await message.channel.send(
583-
embed=discord.Embed(
584-
title="Message not sent!",
585-
description=f"Your must wait for {delta} before you can contact me.",
586-
color=self.error_color,
587-
)
588-
)
551+
return False
552+
return True
553+
554+
def check_guild_age(self, author: discord.Member) -> bool:
555+
guild_age = self.config.get("guild_age")
556+
now = datetime.utcnow()
557+
558+
if not hasattr(author, "joined_at"):
559+
logger.warning("Not in guild, cannot verify guild_age, %s.", author.name)
560+
return True
589561

590-
elif min_guild_age > now:
562+
try:
563+
min_guild_age = author.joined_at + guild_age
564+
except ValueError:
565+
logger.warning("Error with 'guild_age'.", exc_info=True)
566+
min_guild_age = author.joined_at + self.config.remove("guild_age")
567+
568+
if min_guild_age > now:
591569
# User has not stayed in the guild for long enough
592-
reaction = blocked_emoji
593-
changed = False
594570
delta = human_timedelta(min_guild_age)
595-
logger.debug("Blocked due to guild age, user %s.", message.author.name)
571+
logger.debug("Blocked due to guild age, user %s.", author.name)
596572

597-
if str(message.author.id) not in self.blocked_users:
573+
if str(author.id) not in self.blocked_users:
598574
new_reason = f"System Message: Recently Joined. Required to wait for {delta}."
599-
self.blocked_users[str(message.author.id)] = new_reason
600-
changed = True
575+
self.blocked_users[str(author.id)] = new_reason
601576

602-
if reason.startswith("System Message: Recently Joined.") or changed:
603-
await message.channel.send(
604-
embed=discord.Embed(
605-
title="Message not sent!",
606-
description=f"Your must wait for {delta} before you can contact me.",
607-
color=self.error_color,
608-
)
577+
return False
578+
return True
579+
580+
def check_manual_blocked(self, author: discord.Member) -> bool:
581+
if str(author.id) not in self.blocked_users:
582+
return True
583+
584+
blocked_reason = self.blocked_users.get(str(author.id)) or ""
585+
now = datetime.utcnow()
586+
587+
if blocked_reason.startswith("System Message:"):
588+
# Met the limits already, otherwise it would've been caught by the previous checks
589+
logger.debug("No longer internally blocked, user %s.", author.name)
590+
self.blocked_users.pop(str(author.id))
591+
return True
592+
# etc "blah blah blah... until 2019-10-14T21:12:45.559948."
593+
end_time = re.search(r"until ([^`]+?)\.$", blocked_reason)
594+
if end_time is None:
595+
# backwards compat
596+
end_time = re.search(r"%([^%]+?)%", blocked_reason)
597+
if end_time is not None:
598+
logger.warning(
599+
r"Deprecated time message for user %s, block and unblock again to update.",
600+
author.name,
609601
)
610602

611-
elif str(message.author.id) in self.blocked_users:
612-
if reason.startswith("System Message: New Account.") or reason.startswith(
613-
"System Message: Recently Joined."
614-
):
615-
# Met the age limit already, otherwise it would've been caught by the previous if's
616-
reaction = sent_emoji
617-
logger.debug("No longer internally blocked, user %s.", message.author.name)
618-
self.blocked_users.pop(str(message.author.id))
619-
else:
620-
reaction = blocked_emoji
621-
# etc "blah blah blah... until 2019-10-14T21:12:45.559948."
622-
end_time = re.search(r"until ([^`]+?)\.$", reason)
623-
if end_time is None:
624-
# backwards compat
625-
end_time = re.search(r"%([^%]+?)%", reason)
626-
if end_time is not None:
627-
logger.warning(
628-
r"Deprecated time message for user %s, block and unblock again to update.",
629-
message.author,
603+
if end_time is not None:
604+
after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds()
605+
if after <= 0:
606+
# No longer blocked
607+
self.blocked_users.pop(str(author.id))
608+
logger.debug("No longer blocked, user %s.", author.name)
609+
return True
610+
logger.debug("User blocked, user %s.", author.name)
611+
return False
612+
613+
async def _process_blocked(self, message):
614+
sent_emoji, blocked_emoji = await self.retrieve_emoji()
615+
if await self.is_blocked(message.author, channel=message.channel, send_message=True):
616+
await self.add_reaction(message, blocked_emoji)
617+
return True
618+
return False
619+
620+
async def is_blocked(
621+
self,
622+
author: discord.User,
623+
*,
624+
channel: discord.TextChannel = None,
625+
send_message: bool = False,
626+
) -> typing.Tuple[bool, str]:
627+
628+
member = self.guild.get_member(author.id)
629+
if member is None:
630+
logger.debug("User not in guild, %s.", author.id)
631+
else:
632+
author = member
633+
634+
if str(author.id) in self.blocked_whitelisted_users:
635+
if str(author.id) in self.blocked_users:
636+
self.blocked_users.pop(str(author.id))
637+
await self.config.update()
638+
return False
639+
640+
blocked_reason = self.blocked_users.get(str(author.id)) or ""
641+
642+
if (
643+
not self.check_account_age(author)
644+
or not self.check_guild_age(author)
645+
):
646+
new_reason = self.blocked_users.get(str(author.id))
647+
if new_reason != blocked_reason:
648+
if send_message:
649+
await channel.send(
650+
embed=discord.Embed(
651+
title="Message not sent!",
652+
description=new_reason,
653+
color=self.error_color,
630654
)
655+
)
656+
return True
631657

632-
if end_time is not None:
633-
after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds()
634-
if after <= 0:
635-
# No longer blocked
636-
reaction = sent_emoji
637-
self.blocked_users.pop(str(message.author.id))
638-
logger.debug("No longer blocked, user %s.", message.author.name)
639-
else:
640-
logger.debug("User blocked, user %s.", message.author.name)
641-
else:
642-
logger.debug("User blocked, user %s.", message.author.name)
643-
else:
644-
reaction = sent_emoji
658+
if not self.check_manual_blocked(author):
659+
return True
645660

646661
await self.config.update()
647-
return str(message.author.id) in self.blocked_users, reaction
662+
return False
663+
664+
async def get_thread_cooldown(self, author: discord.Member):
665+
thread_cooldown = self.config.get("thread_cooldown")
666+
now = datetime.utcnow()
667+
668+
if thread_cooldown == isodate.Duration():
669+
return
670+
671+
last_log = await self.api.get_latest_user_logs(author.id)
672+
673+
if last_log is None:
674+
logger.debug("Last thread wasn't found, %s.", author.name)
675+
return
676+
677+
last_log_closed_at = last_log.get("closed_at")
678+
679+
if not last_log_closed_at:
680+
logger.debug("Last thread was not closed, %s.", author.name)
681+
return
682+
683+
try:
684+
cooldown = datetime.fromisoformat(last_log_closed_at) + thread_cooldown
685+
except ValueError:
686+
logger.warning("Error with 'thread_cooldown'.", exc_info=True)
687+
cooldown = datetime.fromisoformat(last_log_closed_at) + self.config.remove(
688+
"thread_cooldown"
689+
)
690+
691+
if cooldown > now:
692+
# User messaged before thread cooldown ended
693+
delta = human_timedelta(cooldown)
694+
logger.debug("Blocked due to thread cooldown, user %s.", author.name)
695+
return delta
696+
return
648697

649698
@staticmethod
650699
async def add_reaction(msg, reaction):
@@ -656,11 +705,24 @@ async def add_reaction(msg, reaction):
656705

657706
async def process_dm_modmail(self, message: discord.Message) -> None:
658707
"""Processes messages sent to the bot."""
659-
blocked, reaction = await self._process_blocked(message)
708+
blocked = await self._process_blocked(message)
660709
if blocked:
661-
return await self.add_reaction(message, reaction)
710+
return
711+
sent_emoji, blocked_emoji = await self.retrieve_emoji()
712+
662713
thread = await self.threads.find(recipient=message.author)
663714
if thread is None:
715+
delta = await self.get_thread_cooldown(message.author)
716+
if delta:
717+
await message.channel.send(
718+
embed=discord.Embed(
719+
title="Message not sent!",
720+
description=f"You must wait for {delta} before you can contact me again.",
721+
color=self.error_color,
722+
)
723+
)
724+
return
725+
664726
if self.config["dm_disabled"] >= 1:
665727
embed = discord.Embed(
666728
title=self.config["disabled_new_thread_title"],
@@ -673,9 +735,9 @@ async def process_dm_modmail(self, message: discord.Message) -> None:
673735
logger.info(
674736
"A new thread was blocked from %s due to disabled Modmail.", message.author
675737
)
676-
_, blocked_emoji = await self.retrieve_emoji()
677738
await self.add_reaction(message, blocked_emoji)
678739
return await message.channel.send(embed=embed)
740+
679741
thread = self.threads.create(message.author)
680742
else:
681743
if self.config["dm_disabled"] == 2:
@@ -691,12 +753,16 @@ async def process_dm_modmail(self, message: discord.Message) -> None:
691753
logger.info(
692754
"A message was blocked from %s due to disabled Modmail.", message.author
693755
)
694-
_, blocked_emoji = await self.retrieve_emoji()
695756
await self.add_reaction(message, blocked_emoji)
696757
return await message.channel.send(embed=embed)
697758

698-
await self.add_reaction(message, reaction)
699-
await thread.send(message)
759+
try:
760+
await thread.send(message)
761+
except Exception:
762+
logger.error("Failed to send message:", exc_info=True)
763+
await self.add_reaction(message, blocked_emoji)
764+
else:
765+
await self.add_reaction(message, sent_emoji)
700766

701767
async def get_contexts(self, message, *, cls=commands.Context):
702768
"""
@@ -849,9 +915,6 @@ async def on_typing(self, channel, user, _):
849915
if user.bot:
850916
return
851917

852-
async def _void(*_args, **_kwargs):
853-
pass
854-
855918
if isinstance(channel, discord.DMChannel):
856919
if not self.config.get("user_typing"):
857920
return
@@ -866,13 +929,7 @@ async def _void(*_args, **_kwargs):
866929

867930
thread = await self.threads.find(channel=channel)
868931
if thread is not None and thread.recipient:
869-
if (
870-
await self._process_blocked(
871-
SimpleNamespace(
872-
author=thread.recipient, channel=SimpleNamespace(send=_void)
873-
)
874-
)
875-
)[0]:
932+
if await self.is_blocked(thread.recipient):
876933
return
877934
await thread.recipient.trigger_typing()
878935

core/clients.py

+7
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ async def get_user_logs(self, user_id: Union[str, int]) -> list:
9191

9292
return await self.logs.find(query, projection).to_list(None)
9393

94+
async def get_latest_user_logs(self, user_id: Union[str, int]):
95+
query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False}
96+
projection = {"messages": {"$slice": 5}}
97+
logger.debug("Retrieving user %s latest logs.", user_id)
98+
99+
return await self.logs.find_one(query, projection, limit=1, sort=[("closed_at", -1)])
100+
94101
async def get_responded_logs(self, user_id: Union[str, int]) -> list:
95102
query = {
96103
"open": False,

0 commit comments

Comments
 (0)