diff --git a/CHANGELOG.md b/CHANGELOG.md index 3569f5e1e6..007e75f1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# v2.5.0 + +Non-Breaking Changes: + +### Background +Bots hosted by Heroku restart at least once every 27 hours. +During this period, local caches are deleted, which results in the inability to +set the scheduled close time to longer than 24 hours. This update +resolves this issue. +[PR #135](https://github.com/kyb3r/modmail/pull/135) + + +### Changed + - Created a new internal config var: `closures`. + - Store closure details into `closures` when the scheduled time isn't "now". + - Loaded upon bot restart. + - Deleted when a thread is closed. + - Use `call_later()` instead of `sleep()` for scheduling. + # v2.4.5 ### Fixed diff --git a/bot.py b/bot.py index c630e44366..8df5db1e0e 100644 --- a/bot.py +++ b/bot.py @@ -22,7 +22,7 @@ SOFTWARE. """ -__version__ = '2.4.5' +__version__ = '2.5.0' import asyncio import textwrap @@ -202,8 +202,37 @@ async def on_ready(self): print(Fore.RED + Style.BRIGHT + 'WARNING - The GUILD_ID provided does not exist!' + Style.RESET_ALL) else: await self.threads.populate_cache() + await self.config.update() + closures = self.config.closures.copy() + for recipient_id, items in closures.items(): + after = (datetime.datetime.fromisoformat(items['time']) - + datetime.datetime.utcnow()).total_seconds() + if after < 0: + after = 0 + recipient = self.get_user(int(recipient_id)) + + thread = await self.threads.find( + recipient=recipient) + + if not thread: + # If the recipient is gone or channel is deleted + self.config.closures.pop(str(recipient_id)) + await self.config.update() + continue + + # TODO: Low priority, + # Retrieve messages/replies when bot is down, from history? + self.loop.create_task( + thread.close( + closer=self.get_user(items['closer_id']), + after=after, + silent=items['silent'], + delete_channel=items['delete_channel'], + message=items['message'] + ) + ) async def process_modmail(self, message): """Processes messages sent to the bot.""" diff --git a/cogs/modmail.py b/cogs/modmail.py index 5ae3eb1534..56aa90aeff 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -174,11 +174,11 @@ async def _close(self, ctx, *, after: UserFriendlyTime=None): silent = str(message).lower() in {'silent', 'silently'} cancel = str(message).lower() == 'cancel' - if cancel and thread.close_task is not None and not thread.close_task.cancelled(): - thread.close_task.cancel() - await ctx.send(embed=discord.Embed(color=discord.Color.red(), description='Scheduled close has been cancelled.')) - return - elif cancel: + if cancel: + if thread.close_task is not None: + await thread.cancel_closure() + await ctx.send(embed=discord.Embed(color=discord.Color.red(), description='Scheduled close has been cancelled.')) + return return await ctx.send(embed=discord.Embed(color=discord.Color.red(), description='This thread has not already been scheduled to close.')) if after and after.dt > now: diff --git a/core/config.py b/core/config.py index 6086a55c2c..cfb00934b8 100644 --- a/core/config.py +++ b/core/config.py @@ -7,12 +7,16 @@ class ConfigManager: """Class that manages a cached configuration""" allowed_to_change_in_command = { - 'activity_message', 'activity_type', 'log_channel_id', 'mention', 'disable_autoupdates', 'prefix', - 'main_category_id', 'sent_emoji', 'blocked_emoji', 'thread_creation_response', 'twitch_url' + 'activity_message', 'activity_type', 'log_channel_id', + 'mention', 'disable_autoupdates', 'prefix', + 'main_category_id', 'sent_emoji', 'blocked_emoji', + 'thread_creation_response', 'twitch_url' } internal_keys = { - 'snippets', 'aliases', 'blocked', 'notification_squad', 'subscriptions' + 'snippets', 'aliases', 'blocked', + 'notification_squad', 'subscriptions', + 'closures' } protected_keys = { @@ -20,7 +24,7 @@ class ConfigManager: 'mongo_uri', 'github_access_token', 'log_url' } - valid_keys = allowed_to_change_in_command.union(internal_keys).union(protected_keys) + valid_keys = allowed_to_change_in_command | internal_keys | protected_keys def __init__(self, bot): self.bot = bot @@ -38,7 +42,8 @@ def populate_cache(self): 'aliases': {}, 'blocked': {}, 'notification_squad': {}, - 'subscriptions': {} + 'subscriptions': {}, + 'closures': {}, } try: @@ -47,7 +52,8 @@ def populate_cache(self): pass finally: data.update(os.environ) - data = {k.lower(): v for k, v in data.items() if k.lower() in self.valid_keys} + data = {k.lower(): v for k, v in data.items() + if k.lower() in self.valid_keys} self.cache = data async def update(self, data=None): diff --git a/core/thread.py b/core/thread.py index 461500f8c1..37969b6099 100644 --- a/core/thread.py +++ b/core/thread.py @@ -24,10 +24,10 @@ def __init__(self, manager, recipient): self.channel = None self.ready_event = asyncio.Event() self.close_task = None - self.close_after = 0 # seconds def __repr__(self): - return f'Thread(recipient="{self.recipient}", channel={self.channel.id})' + return f'Thread(recipient="{self.recipient}", ' \ + f'channel={self.channel.id})' def wait_until_ready(self): """Blocks execution until the thread is fully set up.""" @@ -42,28 +42,54 @@ def ready(self, flag): if flag is True: self.ready_event.set() - async def _close_after(self, after, **kwargs): - await asyncio.sleep(after) - kwargs['scheduled'] = True - await self.close(**kwargs) + def _close_after(self, closer, silent, delete_channel, message): + return self.bot.loop.create_task( + self._close(closer, silent, delete_channel, message, True)) - async def close(self, *, closer, after=0, silent=False, delete_channel=True, message=None, scheduled=False): - '''Close a thread now or after a set time in seconds''' - if self.close_task is not None and not self.close_task.cancelled(): - if not scheduled or after > 0: - self.close_task.cancel() + async def close(self, *, closer, after=0, silent=False, + delete_channel=True, message=None): + """Close a thread now or after a set time in seconds""" + + # restarts the after timer + await self.cancel_closure() if after > 0: - self.close_task = asyncio.create_task(self._close_after(after, closer=closer, silent=silent, message=message)) - return + # TODO: Add somewhere to clean up broken closures + # (when channel is already deleted) + await self.bot.config.update() + now = datetime.datetime.utcnow() + items = { + # 'initiation_time': now.isoformat(), + 'time': (now + datetime.timedelta(seconds=after)).isoformat(), + 'closer_id': closer.id, + 'silent': silent, + 'delete_channel': delete_channel, + 'message': message + } + self.bot.config.closures[str(self.id)] = items + await self.bot.config.update() + + self.close_task = self.bot.loop.call_later( + after, self._close_after, closer, + silent, delete_channel, message) + return + + return await self._close(closer, silent, delete_channel, message) + async def _close(self, closer, silent=False, delete_channel=True, + message=None, scheduled=False): del self.manager.cache[self.id] + + await self.cancel_closure() + if str(self.id) in self.bot.config.subscriptions: del self.bot.config.subscriptions[str(self.id)] # Logging log_data = await self.bot.modmail_api.post_log(self.channel.id, { - 'open': False, 'closed_at': str(datetime.datetime.utcnow()), 'closer': { + 'open': False, + 'closed_at': str(datetime.datetime.utcnow()), + 'closer': { 'id': str(closer.id), 'name': closer.name, 'discriminator': closer.discriminator, @@ -73,12 +99,14 @@ async def close(self, *, closer, after=0, silent=False, delete_channel=True, mes }) if isinstance(log_data, str): - print(log_data) # errored somehow on server + print(log_data) # errored somehow on server + return if self.bot.selfhosted: log_url = f'{self.bot.config.log_url}/logs/{log_data["key"]}' else: - log_url = f"https://logs.modmail.tk/{log_data['user_id']}/{log_data['key']}" + log_url = f"https://logs.modmail.tk/" \ + f"{log_data['user_id']}/{log_data['key']}" user = self.recipient.mention if self.recipient else f'`{self.id}`' @@ -104,9 +132,9 @@ async def close(self, *, closer, after=0, silent=False, delete_channel=True, mes # Thread closed message - em = discord.Embed(title='Thread Closed') - em.description = message or f'{closer.mention} has closed this modmail thread.' - em.color = discord.Color.red() + em = discord.Embed(title='Thread Closed', color=discord.Color.red()) + em.description = message or \ + f'{closer.mention} has closed this modmail thread.' if not silent and self.recipient is not None: tasks.append(self.recipient.send(embed=em)) @@ -116,7 +144,17 @@ async def close(self, *, closer, after=0, silent=False, delete_channel=True, mes await asyncio.gather(*tasks) - async def _edit_thread_message(self, channel, message_id, message): + async def cancel_closure(self): + if self.close_task is not None: + self.close_task.cancel() + self.close_task = None + + to_update = self.bot.config.closures.pop(str(self.id), None) + if to_update is not None: + await self.bot.config.update() + + @staticmethod + async def _edit_thread_message(channel, message_id, message): async for msg in channel.history(): if not msg.embeds: continue @@ -124,7 +162,8 @@ async def _edit_thread_message(self, channel, message_id, message): if embed and embed.author and embed.author.url: if str(message_id) == str(embed.author.url).split('/')[-1]: if ' - (Edited)' not in embed.footer.text: - embed.set_footer(text=embed.footer.text + ' - (Edited)') + embed.set_footer( + text=embed.footer.text + ' - (Edited)') embed.description = message await msg.edit(embed=embed) break @@ -139,31 +178,47 @@ async def reply(self, message): if not message.content and not message.attachments: raise commands.UserInputError if all(not g.get_member(self.id) for g in self.bot.guilds): - return await message.channel.send(embed=discord.Embed(color=discord.Color.red(), description='This user shares no servers with me and is thus unreachable.')) + return await message.channel.send( + embed=discord.Embed( + color=discord.Color.red(), + description='This user shares no servers with ' + 'me and is thus unreachable.')) tasks = [ - self.send(message, self.channel, from_mod=True), # in thread channel - self.send(message, self.recipient, from_mod=True) # to user + # in thread channel + self.send(message, self.channel, from_mod=True), + # to user + self.send(message, self.recipient, from_mod=True) ] - if self.close_task is not None and not self.close_task.cancelled(): - self.close_task.cancel() # cancel closing if a thread message is sent. - tasks.append(self.channel.send(embed=discord.Embed(color=discord.Color.red(), description='Scheduled close has been cancelled.'))) + if self.close_task is not None: + # cancel closing if a thread message is sent. + await self.cancel_closure() + tasks.append(self.channel.send( + embed=discord.Embed(color=discord.Color.red(), + description='Scheduled close has ' + 'been cancelled.'))) await asyncio.gather(*tasks) - async def send(self, message, destination=None, from_mod=False, delete_message=True): - if self.close_task is not None and not self.close_task.cancelled(): - self.close_task.cancel() # cancel closing if a thread message is sent. - await self.channel.send(embed=discord.Embed(color=discord.Color.red(), description='Scheduled close has been cancelled.')) + async def send(self, message, destination=None, from_mod=False): + if self.close_task is not None: + # cancel closing if a thread message is sent. + await self.cancel_closure() + await self.channel.send(embed=discord.Embed( + color=discord.Color.red(), + description='Scheduled close has been cancelled.')) + if not self.ready: await self.wait_until_ready() destination = destination or self.channel if from_mod and not isinstance(destination, discord.User): - asyncio.create_task(self.bot.modmail_api.append_log(message)) + self.bot.loop.create_task( + self.bot.modmail_api.append_log(message)) elif not from_mod: - asyncio.create_task(self.bot.modmail_api.append_log(message, destination.id)) + self.bot.loop.create_task( + self.bot.modmail_api.append_log(message, destination.id)) author = message.author @@ -172,10 +227,16 @@ async def send(self, message, destination=None, from_mod=False, delete_message=T timestamp=message.created_at ) - em.set_author(name=str(author), icon_url=author.avatar_url, url=message.jump_url) # store message id in hidden url + # store message id in hidden url + em.set_author(name=str(author), + icon_url=author.avatar_url, + url=message.jump_url) image_types = ['.png', '.jpg', '.gif', '.jpeg', '.webp'] - is_image_url = lambda u, _: any(urlparse(u.lower()).path.endswith(x) for x in image_types) + + def is_image_url(u, _): + return any(urlparse(u.lower()).path.endswith(x) + for x in image_types) delete_message = not bool(message.attachments) @@ -184,7 +245,8 @@ async def send(self, message, destination=None, from_mod=False, delete_message=T images = [x for x in attachments if is_image_url(*x)] attachments = [x for x in attachments if not is_image_url(*x)] - image_links = [(link, None) for link in re.findall(r'(https?://[^\s]+)', message.content)] + image_links = [(link, None) for link in + re.findall(r'(https?://[^\s]+)', message.content)] image_links = [x for x in image_links if is_image_url(*x)] images.extend(image_links) @@ -195,19 +257,24 @@ async def send(self, message, destination=None, from_mod=False, delete_message=T additional_count = 1 for att in images: - if is_image_url(*att) and not embedded_image and att[1] if prioritize_uploads else True: + if is_image_url(*att) and not embedded_image and att[1] \ + if prioritize_uploads else True: em.set_image(url=att[0]) embedded_image = True elif att[1] is not None: link = f'[{att[1]}]({att[0]})' - em.add_field(name=f'Additional Image upload ({additional_count})', value=link, inline=False) + em.add_field( + name=f'Additional Image upload ({additional_count})', + value=link, + inline=False + ) additional_count += 1 file_upload_count = 1 - for att in attachments: - em.add_field(name=f'File upload ({file_upload_count})', value=f'[{att[1]}]({att[0]})') + em.add_field(name=f'File upload ({file_upload_count})', + value=f'[{att[1]}]({att[0]})') file_upload_count += 1 if from_mod: @@ -242,7 +309,7 @@ def get_notifications(self): if key in config['notification_squad']: mentions.extend(config['notification_squad'][key]) del config['notification_squad'][key] - asyncio.create_task(config.update()) + self.bot.loop.create_task(config.update()) return ' '.join(mentions) @@ -256,7 +323,8 @@ def __init__(self, bot): async def populate_cache(self): for channel in self.bot.modmail_guild.text_channels: - if not self.bot.using_multiple_server_setup and channel.category != self.bot.main_category: + if not self.bot.using_multiple_server_setup and \ + channel.category != self.bot.main_category: continue await self.find(channel=channel) @@ -273,6 +341,8 @@ async def find(self, *, recipient=None, channel=None): """Finds a thread from cache or from discord channel topics.""" if recipient is None and channel is not None: return await self._find_from_channel(channel) + + thread = None try: thread = self.cache[recipient.id] except KeyError: @@ -280,10 +350,9 @@ async def find(self, *, recipient=None, channel=None): self.bot.modmail_guild.text_channels, topic=f'User ID: {recipient.id}' ) - if not channel: - thread = None - else: + if channel: self.cache[recipient.id] = thread = Thread(self, recipient) + # TODO: Fix this: thread.channel = channel thread.ready = True finally: @@ -293,19 +362,22 @@ async def _find_from_channel(self, channel): """ Tries to find a thread from a channel channel topic, if channel topic doesnt exist for some reason, falls back to - searching channel history for genesis embed and extracts user_id fron that. + searching channel history for genesis embed and + extracts user_id from that. """ user_id = None if channel.topic and 'User ID: ' in channel.topic: user_id = int(re.findall(r'\d+', channel.topic)[0]) - # BUG: When discord fails to create channel topic. search through message history + # BUG: When discord fails to create channel topic. + # search through message history elif channel.topic is None: async for message in channel.history(limit=50): if message.embeds: em = message.embeds[0] - matches = re.findall(r'User ID: (\d+)', str(em.footer.text)) + # TODO: use re.search instead + matches = re.findall(r'User ID: (\d+)', em.footer.text) if matches: user_id = int(matches[0]) break @@ -328,12 +400,15 @@ async def create(self, recipient, *, creator=None): em = discord.Embed( title='Thread created!', - description=self.bot.config.get('thread_creation_response', 'The moderation team will get back to you as soon as possible!'), + description=self.bot.config.get( + 'thread_creation_response', + 'The moderation team will get back to you as soon as possible!' + ), color=discord.Color.green() ) if creator is None: - asyncio.create_task(recipient.send(embed=em)) + self.bot.loop.create_task(recipient.send(embed=em)) self.cache[recipient.id] = thread = Thread(self, recipient) @@ -345,16 +420,20 @@ async def create(self, recipient, *, creator=None): thread.channel = channel log_url, log_data = await asyncio.gather( - self.bot.modmail_api.get_log_url(recipient, channel, creator or recipient), + self.bot.modmail_api.get_log_url(recipient, channel, + creator or recipient), self.bot.modmail_api.get_user_logs(recipient.id) # self.get_dominant_color(recipient.avatar_url) ) log_count = sum(1 for log in log_data if not log['open']) - info_embed = self._format_info_embed(recipient, creator, log_url, log_count, discord.Color.green()) + info_embed = self._format_info_embed(recipient, creator, + log_url, log_count, + discord.Color.green()) topic = f'User ID: {recipient.id}' - mention = self.bot.config.get('mention', '@here') if not creator else None + mention = self.bot.config.get('mention', '@here') \ + if not creator else None _, msg = await asyncio.gather( channel.edit(topic=topic), @@ -368,7 +447,8 @@ async def create(self, recipient, *, creator=None): return thread async def find_or_create(self, recipient): - return await self.find(recipient=recipient) or await self.create(recipient) + return await self.find(recipient=recipient) or \ + await self.create(recipient) @staticmethod def valid_image_url(url): @@ -397,7 +477,7 @@ async def get_dominant_color(self, url=None, quality=10): async with self.bot.session.get(url) as resp: image = await resp.read() color = await self._do_get_dc(image, quality) - except Exception: + except: traceback.print_exc() return discord.Color.blurple() else: @@ -410,7 +490,8 @@ def _format_channel_name(self, author): new_name = ''.join(l for l in name if l in allowed) or 'null' new_name += f'-{author.discriminator}' - while new_name in [c.name for c in self.bot.modmail_guild.text_channels]: + while new_name in [c.name for c in + self.bot.modmail_guild.text_channels]: new_name += '-x' # two channels with same name return new_name @@ -421,21 +502,26 @@ def _format_info_embed(self, user, creator, log_url, log_count, dc): member = self.bot.guild.get_member(user.id) avi = user.avatar_url time = datetime.datetime.utcnow() - desc = f'{creator.mention} has created a thread with {user.mention}' if creator else f'{user.mention} has started a thread' + desc = f'{creator.mention} has created a thread with {user.mention}' \ + if creator else f'{user.mention} has started a thread' key = log_url.split('/')[-1] desc = f'{desc} [`{key}`]({log_url})' + role_names = '' if member: seperate_server = self.bot.guild != self.bot.modmail_guild roles = sorted(member.roles, key=lambda c: c.position) if seperate_server: - rolenames = ', '.join(r.name for r in roles if r.name != "@everyone") + role_names = ', '.join(r.name for r in roles + if r.name != "@everyone") else: - rolenames = ' '.join(r.mention for r in roles if r.name != "@everyone") + role_names = ' '.join(r.mention for r in roles + if r.name != "@everyone") em = discord.Embed(colour=dc, description=desc, timestamp=time) - days = lambda d: (' day ago.' if d == '1' else ' days ago.') + def days(d): + return ' day ago.' if d == '1' else ' days ago.' created = str((time - user.created_at).days) # em.add_field(name='Mention', value=user.mention) @@ -452,9 +538,11 @@ def _format_info_embed(self, user, creator, log_url, log_count, dc): em.add_field(name='Joined', value=joined + days(joined)) if member.nick: em.add_field(name='Nickname', value=member.nick, inline=True) - if rolenames: - em.add_field(name='Roles', value=rolenames, inline=False) + if role_names: + em.add_field(name='Roles', value=role_names, inline=False) else: - em.set_footer(text=footer + ' | Note: this member is not part of this server.') + em.set_footer( + text=f'{footer} | Note: this member' + f' is not part of this server.') return em