From 1fa212acdcb0cc6442da8a9fbede28b8d8b35a90 Mon Sep 17 00:00:00 2001 From: Taaku18 <45324516+Taaku18@users.noreply.github.com> Date: Tue, 12 Nov 2019 01:07:30 -0800 Subject: [PATCH] v3.3.1-dev2 --- CHANGELOG.md | 9 +- bot.py | 275 +++++++++++++++++++++++++----------------- core/clients.py | 7 ++ core/config.py | 39 +++--- core/config_help.json | 11 ++ core/thread.py | 22 ++-- core/time.py | 37 +++--- 7 files changed, 237 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b536a4808e..4e3ea1a70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,21 @@ This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2. however, insignificant breaking changes does not guarantee a major version bump, see the reasoning [here](https://github.com/kyb3r/modmail/issues/319). -# v3.3.1-dev1 +# v3.3.1-dev2 ### Added +- Thread cooldown! + - Set via the new config var `thread_cooldown`. + - Specify a time for the recipient to wait before allowed to create another thread. - "enable" and "disable" support for yes or no config vars. - Added "perhaps you meant" section to `?config help`. - Multi-command alias is now more stable. With support for a single quote escape `\"`. +### Fixed + +- Setting config vars using human time wasn't working. + ### Internal - Commit to black format line width max = 99, consistent with pylint. diff --git a/bot.py b/bot.py index 27c8e73e2b..5e578cac7d 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,4 @@ -__version__ = "3.3.1-dev1" +__version__ = "3.3.1-dev2" import asyncio import logging @@ -529,122 +529,171 @@ async def retrieve_emoji(self) -> typing.Tuple[str, str]: return sent_emoji, blocked_emoji - async def _process_blocked(self, message: discord.Message) -> typing.Tuple[bool, str]: - sent_emoji, blocked_emoji = await self.retrieve_emoji() - - if str(message.author.id) in self.blocked_whitelisted_users: - if str(message.author.id) in self.blocked_users: - self.blocked_users.pop(str(message.author.id)) - await self.config.update() - - return False, sent_emoji - - now = datetime.utcnow() - + def check_account_age(self, author: discord.Member) -> bool: account_age = self.config.get("account_age") - guild_age = self.config.get("guild_age") - - if account_age is None: - account_age = isodate.Duration() - if guild_age is None: - guild_age = isodate.Duration() - - reason = self.blocked_users.get(str(message.author.id)) or "" - min_guild_age = min_account_age = now + now = datetime.utcnow() try: - min_account_age = message.author.created_at + account_age + min_account_age = author.created_at + account_age except ValueError: logger.warning("Error with 'account_age'.", exc_info=True) - self.config.remove("account_age") - - try: - joined_at = getattr(message.author, "joined_at", None) - if joined_at is not None: - min_guild_age = joined_at + guild_age - except ValueError: - logger.warning("Error with 'guild_age'.", exc_info=True) - self.config.remove("guild_age") + min_account_age = author.created_at + self.config.remove("account_age") if min_account_age > now: # User account has not reached the required time - reaction = blocked_emoji - changed = False delta = human_timedelta(min_account_age) - logger.debug("Blocked due to account age, user %s.", message.author.name) + logger.debug("Blocked due to account age, user %s.", author.name) - if str(message.author.id) not in self.blocked_users: + if str(author.id) not in self.blocked_users: new_reason = f"System Message: New Account. Required to wait for {delta}." - self.blocked_users[str(message.author.id)] = new_reason - changed = True + self.blocked_users[str(author.id)] = new_reason - if reason.startswith("System Message: New Account.") or changed: - await message.channel.send( - embed=discord.Embed( - title="Message not sent!", - description=f"Your must wait for {delta} before you can contact me.", - color=self.error_color, - ) - ) + return False + return True + + def check_guild_age(self, author: discord.Member) -> bool: + guild_age = self.config.get("guild_age") + now = datetime.utcnow() + + if not hasattr(author, "joined_at"): + logger.warning("Not in guild, cannot verify guild_age, %s.", author.name) + return True - elif min_guild_age > now: + try: + min_guild_age = author.joined_at + guild_age + except ValueError: + logger.warning("Error with 'guild_age'.", exc_info=True) + min_guild_age = author.joined_at + self.config.remove("guild_age") + + if min_guild_age > now: # User has not stayed in the guild for long enough - reaction = blocked_emoji - changed = False delta = human_timedelta(min_guild_age) - logger.debug("Blocked due to guild age, user %s.", message.author.name) + logger.debug("Blocked due to guild age, user %s.", author.name) - if str(message.author.id) not in self.blocked_users: + if str(author.id) not in self.blocked_users: new_reason = f"System Message: Recently Joined. Required to wait for {delta}." - self.blocked_users[str(message.author.id)] = new_reason - changed = True + self.blocked_users[str(author.id)] = new_reason - if reason.startswith("System Message: Recently Joined.") or changed: - await message.channel.send( - embed=discord.Embed( - title="Message not sent!", - description=f"Your must wait for {delta} before you can contact me.", - color=self.error_color, - ) + return False + return True + + def check_manual_blocked(self, author: discord.Member) -> bool: + if str(author.id) not in self.blocked_users: + return True + + blocked_reason = self.blocked_users.get(str(author.id)) or "" + now = datetime.utcnow() + + if blocked_reason.startswith("System Message:"): + # Met the limits already, otherwise it would've been caught by the previous checks + logger.debug("No longer internally blocked, user %s.", author.name) + self.blocked_users.pop(str(author.id)) + return True + # etc "blah blah blah... until 2019-10-14T21:12:45.559948." + end_time = re.search(r"until ([^`]+?)\.$", blocked_reason) + if end_time is None: + # backwards compat + end_time = re.search(r"%([^%]+?)%", blocked_reason) + if end_time is not None: + logger.warning( + r"Deprecated time message for user %s, block and unblock again to update.", + author.name, ) - elif str(message.author.id) in self.blocked_users: - if reason.startswith("System Message: New Account.") or reason.startswith( - "System Message: Recently Joined." - ): - # Met the age limit already, otherwise it would've been caught by the previous if's - reaction = sent_emoji - logger.debug("No longer internally blocked, user %s.", message.author.name) - self.blocked_users.pop(str(message.author.id)) - else: - reaction = blocked_emoji - # etc "blah blah blah... until 2019-10-14T21:12:45.559948." - end_time = re.search(r"until ([^`]+?)\.$", reason) - if end_time is None: - # backwards compat - end_time = re.search(r"%([^%]+?)%", reason) - if end_time is not None: - logger.warning( - r"Deprecated time message for user %s, block and unblock again to update.", - message.author, + if end_time is not None: + after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() + if after <= 0: + # No longer blocked + self.blocked_users.pop(str(author.id)) + logger.debug("No longer blocked, user %s.", author.name) + return True + logger.debug("User blocked, user %s.", author.name) + return False + + async def _process_blocked(self, message): + sent_emoji, blocked_emoji = await self.retrieve_emoji() + if await self.is_blocked(message.author, channel=message.channel, send_message=True): + await self.add_reaction(message, blocked_emoji) + return True + return False + + async def is_blocked( + self, + author: discord.User, + *, + channel: discord.TextChannel = None, + send_message: bool = False, + ) -> typing.Tuple[bool, str]: + + member = self.guild.get_member(author.id) + if member is None: + logger.debug("User not in guild, %s.", author.id) + else: + author = member + + if str(author.id) in self.blocked_whitelisted_users: + if str(author.id) in self.blocked_users: + self.blocked_users.pop(str(author.id)) + await self.config.update() + return False + + blocked_reason = self.blocked_users.get(str(author.id)) or "" + + if ( + not self.check_account_age(author) + or not self.check_guild_age(author) + ): + new_reason = self.blocked_users.get(str(author.id)) + if new_reason != blocked_reason: + if send_message: + await channel.send( + embed=discord.Embed( + title="Message not sent!", + description=new_reason, + color=self.error_color, ) + ) + return True - if end_time is not None: - after = (datetime.fromisoformat(end_time.group(1)) - now).total_seconds() - if after <= 0: - # No longer blocked - reaction = sent_emoji - self.blocked_users.pop(str(message.author.id)) - logger.debug("No longer blocked, user %s.", message.author.name) - else: - logger.debug("User blocked, user %s.", message.author.name) - else: - logger.debug("User blocked, user %s.", message.author.name) - else: - reaction = sent_emoji + if not self.check_manual_blocked(author): + return True await self.config.update() - return str(message.author.id) in self.blocked_users, reaction + return False + + async def get_thread_cooldown(self, author: discord.Member): + thread_cooldown = self.config.get("thread_cooldown") + now = datetime.utcnow() + + if thread_cooldown == isodate.Duration(): + return + + last_log = await self.api.get_latest_user_logs(author.id) + + if last_log is None: + logger.debug("Last thread wasn't found, %s.", author.name) + return + + last_log_closed_at = last_log.get("closed_at") + + if not last_log_closed_at: + logger.debug("Last thread was not closed, %s.", author.name) + return + + try: + cooldown = datetime.fromisoformat(last_log_closed_at) + thread_cooldown + except ValueError: + logger.warning("Error with 'thread_cooldown'.", exc_info=True) + cooldown = datetime.fromisoformat(last_log_closed_at) + self.config.remove( + "thread_cooldown" + ) + + if cooldown > now: + # User messaged before thread cooldown ended + delta = human_timedelta(cooldown) + logger.debug("Blocked due to thread cooldown, user %s.", author.name) + return delta + return @staticmethod async def add_reaction(msg, reaction): @@ -656,11 +705,24 @@ async def add_reaction(msg, reaction): async def process_dm_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" - blocked, reaction = await self._process_blocked(message) + blocked = await self._process_blocked(message) if blocked: - return await self.add_reaction(message, reaction) + return + sent_emoji, blocked_emoji = await self.retrieve_emoji() + thread = await self.threads.find(recipient=message.author) if thread is None: + delta = await self.get_thread_cooldown(message.author) + if delta: + await message.channel.send( + embed=discord.Embed( + title="Message not sent!", + description=f"You must wait for {delta} before you can contact me again.", + color=self.error_color, + ) + ) + return + if self.config["dm_disabled"] >= 1: embed = discord.Embed( title=self.config["disabled_new_thread_title"], @@ -673,9 +735,9 @@ async def process_dm_modmail(self, message: discord.Message) -> None: logger.info( "A new thread was blocked from %s due to disabled Modmail.", message.author ) - _, blocked_emoji = await self.retrieve_emoji() await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) + thread = self.threads.create(message.author) else: if self.config["dm_disabled"] == 2: @@ -691,12 +753,16 @@ async def process_dm_modmail(self, message: discord.Message) -> None: logger.info( "A message was blocked from %s due to disabled Modmail.", message.author ) - _, blocked_emoji = await self.retrieve_emoji() await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) - await self.add_reaction(message, reaction) - await thread.send(message) + try: + await thread.send(message) + except Exception: + logger.error("Failed to send message:", exc_info=True) + await self.add_reaction(message, blocked_emoji) + else: + await self.add_reaction(message, sent_emoji) async def get_contexts(self, message, *, cls=commands.Context): """ @@ -849,9 +915,6 @@ async def on_typing(self, channel, user, _): if user.bot: return - async def _void(*_args, **_kwargs): - pass - if isinstance(channel, discord.DMChannel): if not self.config.get("user_typing"): return @@ -866,13 +929,7 @@ async def _void(*_args, **_kwargs): thread = await self.threads.find(channel=channel) if thread is not None and thread.recipient: - if ( - await self._process_blocked( - SimpleNamespace( - author=thread.recipient, channel=SimpleNamespace(send=_void) - ) - ) - )[0]: + if await self.is_blocked(thread.recipient): return await thread.recipient.trigger_typing() diff --git a/core/clients.py b/core/clients.py index 7bc17943e5..d8814335bf 100644 --- a/core/clients.py +++ b/core/clients.py @@ -91,6 +91,13 @@ async def get_user_logs(self, user_id: Union[str, int]) -> list: return await self.logs.find(query, projection).to_list(None) + async def get_latest_user_logs(self, user_id: Union[str, int]): + query = {"recipient.id": str(user_id), "guild_id": str(self.bot.guild_id), "open": False} + projection = {"messages": {"$slice": 5}} + logger.debug("Retrieving user %s latest logs.", user_id) + + return await self.logs.find_one(query, projection, limit=1, sort=[("closed_at", -1)]) + async def get_responded_logs(self, user_id: Union[str, int]) -> list: query = { "open": False, diff --git a/core/config.py b/core/config.py index ec8e66936d..695b743f56 100644 --- a/core/config.py +++ b/core/config.py @@ -13,7 +13,7 @@ from core._color_data import ALL_COLORS from core.models import InvalidConfigError, Default, getLogger -from core.time import UserFriendlyTime +from core.time import UserFriendlyTimeSync from core.utils import strtobool logger = getLogger(__name__) @@ -33,8 +33,9 @@ class ConfigManager: "error_color": str(discord.Color.red()), "user_typing": False, "mod_typing": False, - "account_age": None, - "guild_age": None, + "account_age": isodate.Duration(), + "guild_age": isodate.Duration(), + "thread_cooldown": isodate.Duration(), "reply_without_command": False, "anon_reply_without_command": False, # logging @@ -45,7 +46,7 @@ class ConfigManager: "close_emoji": "🔒", "recipient_thread_close": False, "thread_auto_close_silently": False, - "thread_auto_close": None, + "thread_auto_close": isodate.Duration(), "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.", "thread_creation_response": "The staff team will get back to you as soon as possible.", "thread_creation_footer": "Your message has been sent", @@ -115,7 +116,7 @@ class ConfigManager: colors = {"mod_color", "recipient_color", "main_color", "error_color"} - time_deltas = {"account_age", "guild_age", "thread_auto_close"} + time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown"} booleans = { "user_typing", @@ -224,17 +225,16 @@ def get(self, key: str, convert=True) -> typing.Any: value = int(self.remove(key).lstrip("#"), base=16) elif key in self.time_deltas: - if value is None: - return None - try: - value = isodate.parse_duration(value) - except isodate.ISO8601Error: - logger.warning( - "The {account} age limit needs to be a " - 'ISO-8601 duration formatted duration, not "%s".', - value, - ) - value = self.remove(key) + if not isinstance(value, isodate.Duration): + try: + value = isodate.parse_duration(value) + except isodate.ISO8601Error: + logger.warning( + "The {account} age limit needs to be a " + 'ISO-8601 duration formatted duration, not "%s".', + value, + ) + value = self.remove(key) elif key in self.booleans: try: @@ -298,13 +298,14 @@ def set(self, key: str, item: typing.Any, convert=True) -> None: isodate.parse_duration(item) except isodate.ISO8601Error: try: - converter = UserFriendlyTime() - time = self.bot.loop.run_until_complete(converter.convert(None, item)) + converter = UserFriendlyTimeSync() + time = converter.convert(None, item) if time.arg: raise ValueError except BadArgument as exc: raise InvalidConfigError(*exc.args) - except Exception: + except Exception as e: + logger.debug(e) raise InvalidConfigError( "Unrecognized time, please use ISO-8601 duration format " 'string or a simpler "human readable" time.' diff --git a/core/config_help.json b/core/config_help.json index 0a6cee5076..c6c0fc95d0 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -222,6 +222,17 @@ "See also: `thread_auto_close_silently`, `thread_auto_close_response`." ] }, + "thread_cooldown": { + "default": "Never", + "description": "Specify the time required for the recipient to wait before allowed to create a new thread.", + "examples": [ + "`{prefix}config set thread_cooldown P12DT3H` (stands for 12 days and 3 hours in [ISO-8601 Duration Format](https://en.wikipedia.org/wiki/ISO_8601#Durations))", + "`{prefix}config set thread_cooldown 3 days and 5 hours` (accepted readable time)" + ], + "notes": [ + "To disable thread cooldown, do `{prefix}config del thread_cooldown`." + ] + }, "thread_auto_close_response": { "default": "\"This thread has been closed automatically due to inactivity after {{timeout}}.\"", "description": "This is the message to display when the thread when the thread auto-closes.", diff --git a/core/thread.py b/core/thread.py index 9a40c07fd8..bf13030776 100644 --- a/core/thread.py +++ b/core/thread.py @@ -295,8 +295,8 @@ async def _close( ): try: self.manager.cache.pop(self.id) - except KeyError: - logger.warning("Thread already closed.", exc_info=True) + except KeyError as e: + logger.error("Thread already closed: %s.", str(e)) return await self.cancel_closure(all=True) @@ -436,7 +436,7 @@ async def _restart_close_timer(self): timeout = await self._fetch_timeout() # Exit if timeout was not set - if not timeout: + if timeout == isodate.Duration(): return # Set timeout seconds @@ -723,8 +723,8 @@ async def send( try: await destination.trigger_typing() except discord.NotFound: - logger.warning("Channel not found.", exc_info=True) - return + logger.warning("Channel not found.") + raise if not from_mod and not note: mentions = self.get_notifications() @@ -804,12 +804,12 @@ async def find( recipient_id = recipient.id try: - thread = self.cache[recipient_id] - if not thread.channel or not self.bot.get_channel(thread.channel.id): - self.bot.loop.create_task( - thread.close(closer=self.bot.user, silent=True, delete_channel=False) - ) - thread = None + return self.cache[recipient_id] + # if not thread.channel or not self.bot.get_channel(thread.channel.id): + # self.bot.loop.create_task( + # thread.close(closer=self.bot.user, silent=True, delete_channel=False) + # ) + # thread = None except KeyError: channel = discord.utils.get( self.bot.modmail_guild.text_channels, topic=f"User ID: {recipient_id}" diff --git a/core/time.py b/core/time.py index f10892ec62..f91ad0dc09 100644 --- a/core/time.py +++ b/core/time.py @@ -84,32 +84,23 @@ def __init__(self, argument): raise BadArgument("The time is in the past.") -class UserFriendlyTime(Converter): +class UserFriendlyTimeSync(Converter): """That way quotes aren't absolutely necessary.""" - def __init__(self, converter: Converter = None): - if isinstance(converter, type) and issubclass(converter, Converter): - converter = converter() - - if converter is not None and not isinstance(converter, Converter): - raise TypeError("commands.Converter subclass necessary.") + def __init__(self): self.raw: str = None self.dt: datetime = None self.arg = None self.now: datetime = None - self.converter = converter - async def check_constraints(self, ctx, now, remaining): + def check_constraints(self, now, remaining): if self.dt < now: raise BadArgument("This time is in the past.") - if self.converter is not None: - self.arg = await self.converter.convert(ctx, remaining) - else: - self.arg = remaining + self.arg = remaining return self - async def convert(self, ctx, argument): + def convert(self, ctx, argument): self.raw = argument remaining = "" try: @@ -122,7 +113,7 @@ async def convert(self, ctx, argument): data = {k: int(v) for k, v in match.groupdict(default="0").items()} remaining = argument[match.end() :].strip() self.dt = self.now + relativedelta(**data) - return await self.check_constraints(ctx, self.now, remaining) + return self.check_constraints(self.now, remaining) # apparently nlp does not like "from now" # it likes "from x" in other cases though @@ -133,14 +124,9 @@ async def convert(self, ctx, argument): if argument.startswith("for "): argument = argument[4:].strip() - if argument[0:2] == "me": - # starts with "me to", "me in", or "me at " - if argument[0:6] in ("me to ", "me in ", "me at "): - argument = argument[6:] - elements = calendar.nlp(argument, sourceTime=self.now) if elements is None or not elements: - return await self.check_constraints(ctx, self.now, argument) + return self.check_constraints(self.now, argument) # handle the following cases: # "date time" foo @@ -151,7 +137,7 @@ async def convert(self, ctx, argument): dt, status, begin, end, _ = elements[0] if not status.hasDateOrTime: - return await self.check_constraints(ctx, self.now, argument) + return self.check_constraints(self.now, argument) if begin not in (0, 1) and end != len(argument): raise BadArgument( @@ -190,12 +176,17 @@ async def convert(self, ctx, argument): elif len(argument) == end: remaining = argument[:begin].strip() - return await self.check_constraints(ctx, self.now, remaining) + return self.check_constraints(self.now, remaining) except Exception: logger.exception("Something went wrong while parsing the time.") raise +class UserFriendlyTime(UserFriendlyTimeSync): + async def convert(self, ctx, argument): + return super().convert(ctx, argument) + + def human_timedelta(dt, *, source=None): now = source or datetime.utcnow() if dt > now: