async def move(self, ctx, category: discord.CategoryChannel, *, specifics: str = None): """ Move a thread to another category. `category` may be a category ID, mention, or name. `specifics` is a string which takes in arguments on how to perform the move. Ex: "silently" """ thread = ctx.thread silent = False if specifics: silent_words = ['silent', 'silently'] silent = any(word in silent_words for word in specifics.split()) await thread.channel.edit(category=category, sync_permissions=True) try: thread_move_notify = strtobool(self.bot.config["thread_move_notify"]) except ValueError: thread_move_notify = self.bot.config.remove("thread_move_notify") if thread_move_notify and not silent: embed = discord.Embed( title="Thread Moved", description=self.bot.config["thread_move_response"], color=self.bot.main_color ) await thread.recipient.send(embed=embed) sent_emoji, _ = await self.bot.retrieve_emoji() try: await ctx.message.add_reaction(sent_emoji) except (discord.HTTPException, discord.InvalidArgument): pass
def set(self, key: str, item: typing.Any, convert=True) -> None: if not convert: return self.__setitem__(key, item) if key in self.colors: try: hex_ = str(item) if hex_.startswith("#"): hex_ = hex_[1:] if len(hex_) == 3: hex_ = "".join(s for s in hex_ for _ in range(2)) if len(hex_) != 6: raise InvalidConfigError("Invalid color name or hex.") try: int(hex_, 16) except ValueError: raise InvalidConfigError("Invalid color name or hex.") except InvalidConfigError: name = str(item).lower() name = re.sub(r"[\-+|. ]+", " ", name) hex_ = ALL_COLORS.get(name) if hex_ is None: name = re.sub(r"[\-+|. ]+", "", name) hex_ = ALL_COLORS.get(name) if hex_ is None: raise return self.__setitem__(key, "#" + hex_) if key in self.time_deltas: try: isodate.parse_duration(item) except isodate.ISO8601Error: try: converter = UserFriendlyTime() time = self.bot.loop.run_until_complete( converter.convert(None, item) ) if time.arg: raise ValueError except BadArgument as exc: raise InvalidConfigError(*exc.args) except Exception: raise InvalidConfigError( "Unrecognized time, please use ISO-8601 duration format " 'string or a simpler "human readable" time.' ) item = isodate.duration_isoformat(time.dt - converter.now) return self.__setitem__(key, item) if key in self.booleans: try: return self.__setitem__(key, strtobool(item)) except ValueError: raise InvalidConfigError("Must be a yes/no value.") # elif key in self.special_types: # if key == "status": return self.__setitem__(key, item)
def set(self, key: str, item: typing.Any, convert=True) -> None: if not convert: return self.__setitem__(key, item) if key in self.colors: try: hex_ = str(item) if hex_.startswith("#"): hex_ = hex_[1:] if len(hex_) == 3: hex_ = "".join(s for s in hex_ for _ in range(2)) if len(hex_) != 6: raise InvalidConfigError( "Nombre de color o HEX inválido.") try: int(hex_, 16) except ValueError: raise InvalidConfigError( "Nombre de color o HEX inválido.") except InvalidConfigError: name = str(item).lower() name = re.sub(r"[\-+|. ]+", " ", name) hex_ = ALL_COLORS.get(name) if hex_ is None: name = re.sub(r"[\-+|. ]+", "", name) hex_ = ALL_COLORS.get(name) if hex_ is None: raise return self.__setitem__(key, "#" + hex_) if key in self.time_deltas: try: isodate.parse_duration(item) except isodate.ISO8601Error: try: converter = UserFriendlyTimeSync() time = converter.convert(None, item) if time.arg: raise ValueError except BadArgument as exc: raise InvalidConfigError(*exc.args) except Exception as e: logger.debug(e) raise InvalidConfigError( "Tiempo no reconocido, por favor use el formato de duración ISO-8601 " 'o un tiempo de "lectura humana" más simple.') item = isodate.duration_isoformat(time.dt - converter.now) return self.__setitem__(key, item) if key in self.booleans: try: return self.__setitem__(key, strtobool(item)) except ValueError: raise InvalidConfigError("Debe ser un valor de SÃ/No.") # elif key in self.special_types: # if key == "status": return self.__setitem__(key, item)
def get(self, key: str, convert=True) -> typing.Any: value = self.__getitem__(key) if not convert: return value if key in self.colors: try: return int(value.lstrip("#"), base=16) except ValueError: logger.error("Invalid %s provided.", key) value = int(self.remove(key).lstrip("#"), base=16) elif key in self.time_deltas: if value is None: return 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: value = strtobool(value) except ValueError: value = self.remove(key) elif key in self.special_types: if value is None: return if key == "status": try: # noinspection PyArgumentList value = discord.Status(value) except ValueError: logger.warning("Invalid status %s.", value) value = self.remove(key) elif key == "activity_type": try: # noinspection PyArgumentList value = discord.ActivityType(value) except ValueError: logger.warning("Invalid activity %s.", value) value = self.remove(key) return value
async def clean_data(self, key: str, val: typing.Any) -> typing.Tuple[str, str]: value_text = val clean_value = val # when setting a color if key in self.colors: hex_ = ALL_COLORS.get(val) if hex_ is None: hex_ = str(hex_) if hex_.startswith("#"): hex_ = hex_[1:] if len(hex_) == 3: hex_ = "".join(s for s in hex_ for _ in range(2)) if len(hex_) != 6: raise InvalidConfigError("Invalid color name or hex.") try: int(val, 16) except ValueError: raise InvalidConfigError("Invalid color name or hex.") clean_value = "#" + val value_text = clean_value else: clean_value = hex_ value_text = f"{val} ({clean_value})" elif key in self.time_deltas: try: isodate.parse_duration(val) except isodate.ISO8601Error: try: converter = UserFriendlyTime() time = await converter.convert(None, val) if time.arg: raise ValueError except BadArgument as exc: raise InvalidConfigError(*exc.args) except Exception: raise InvalidConfigError( "Unrecognized time, please use ISO-8601 duration format " 'string or a simpler "human readable" time.') clean_value = isodate.duration_isoformat(time.dt - converter.now) value_text = f"{val} ({clean_value})" elif key in self.booleans: try: clean_value = value_text = strtobool(val) except ValueError: raise InvalidConfigError("Must be a yes/no value.") return clean_value, value_text
async def _restart_close_timer(self): """ This will create or restart a timer to automatically close this thread. """ timeout = await self._fetch_timeout() # Exit if timeout was not set if not timeout: return # Set timeout seconds seconds = timeout.total_seconds() # seconds = 20 # Uncomment to debug with just 20 seconds reset_time = datetime.utcnow() + timedelta(seconds=seconds) human_time = human_timedelta(dt=reset_time) try: thread_auto_close_silently = strtobool( self.bot.config["thread_auto_close_silently"] ) except ValueError: thread_auto_close_silently = self.bot.config.remove( "thread_auto_close_silently" ) if thread_auto_close_silently: return await self.close( closer=self.bot.user, silent=True, after=int(seconds), auto_close=True ) # Grab message close_message = self.bot.formatter.format( self.bot.config["thread_auto_close_response"], timeout=human_time ) time_marker_regex = "%t" if len(re.findall(time_marker_regex, close_message)) == 1: close_message = re.sub(time_marker_regex, str(human_time), close_message) elif len(re.findall(time_marker_regex, close_message)) > 1: logger.warning( "The thread_auto_close_response should only contain one '%s' to specify time.", time_marker_regex, ) await self.close( closer=self.bot.user, after=int(seconds), message=close_message, auto_close=True, )
def get(self, key: str, convert=True) -> typing.Any: value = self.__getitem__(key) if not convert: return value if key in self.colors: try: return int(value.lstrip("#"), base=16) except ValueError: logger.error("Inválido %s.", key) value = int(self.remove(key).lstrip("#"), base=16) elif key in self.time_deltas: if not isinstance(value, isodate.Duration): try: value = isodate.parse_duration(value) except isodate.ISO8601Error: logger.warning( "El lÃmite de edad de la cuenta ${account} debe ser un" 'ISO-8601 duración formateada, no "%s".', value, ) value = self.remove(key) elif key in self.booleans: try: value = strtobool(value) except ValueError: value = self.remove(key) elif key in self.special_types: if value is None: return None if key == "status": try: # noinspection PyArgumentList value = discord.Status(value) except ValueError: logger.warning("Estado inválido %s.", value) value = self.remove(key) elif key == "activity_type": try: # noinspection PyArgumentList value = discord.ActivityType(value) except ValueError: logger.warning("Actividad inválida %s.", value) value = self.remove(key) return value
def get(self, key: str, convert=True) -> typing.Any: value = self.__getitem__(key) if not convert: return value if key in self.colors: try: return int(value.lstrip("#"), base=16) except ValueError: logger.error("Invalid %s provided.", key) value = int(self.remove(key).lstrip("#"), base=16) elif key in self.time_deltas: if not isinstance(value, isodate.Duration): try: value = isodate.parse_duration(value) except isodate.ISO8601Error: logger.warning( "L'età di {account} deve essere una durata " 'formattata in ISO-8601, non "%s".', value, ) value = self.remove(key) elif key in self.booleans: try: value = strtobool(value) except ValueError: value = self.remove(key) elif key in self.special_types: if value is None: return None if key == "status": try: # noinspection PyArgumentList value = discord.Status(value) except ValueError: logger.warning("Stato non valido: %s.", value) value = self.remove(key) elif key == "activity_type": try: # noinspection PyArgumentList value = discord.ActivityType(value) except ValueError: logger.warning("Attività non valida: %s.", value) value = self.remove(key) return value
async def on_typing(self, channel, user, _): await self.wait_for_connected() if user.bot: return async def _void(*_args, **_kwargs): pass if isinstance(channel, discord.DMChannel): try: user_typing = strtobool(self.config["user_typing"]) except ValueError: user_typing = self.config.remove("user_typing") if not user_typing: return thread = await self.threads.find(recipient=user) if thread: await thread.channel.trigger_typing() else: try: mod_typing = strtobool(self.config["mod_typing"]) except ValueError: mod_typing = self.config.remove("mod_typing") if not mod_typing: return 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), add_reaction=_void, )): return await thread.recipient.trigger_typing()
async def process_commands(self, message): if message.author.bot: return if isinstance(message.channel, discord.DMChannel): return await self.process_dm_modmail(message) if message.content.startswith(self.prefix): cmd = message.content[len(self.prefix):].strip() # Process snippets if cmd in self.snippets: thread = await self.threads.find(channel=message.channel) snippet = self.snippets[cmd] if thread: snippet = self.formatter.format(snippet, recipient=thread.recipient) message.content = f"{self.prefix}reply {snippet}" ctxs = await self.get_contexts(message) for ctx in ctxs: if ctx.command: if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): logger.debug( "Command %s has no permissions check, adding invalid level.", ctx.command.qualified_name, ) checks.has_permissions(PermissionLevel.INVALID)( ctx.command) await self.invoke(ctx) continue thread = await self.threads.find(channel=ctx.channel) if thread is not None: try: reply_without_command = strtobool( self.config["reply_without_command"]) except ValueError: reply_without_command = self.config.remove( "reply_without_command") if reply_without_command: await thread.reply(message) else: await self.api.append_log(message, type_="internal") elif ctx.invoked_with: exc = commands.CommandNotFound( 'Command "{}" is not found'.format(ctx.invoked_with)) self.dispatch("command_error", ctx, exc)
async def on_raw_reaction_add(self, payload): user = self.get_user(payload.user_id) if user.bot: return channel = self.get_channel(payload.channel_id) if not channel: # dm channel not in internal cache _thread = await self.threads.find(recipient=user) if not _thread: return channel = await _thread.recipient.create_dm() try: message = await channel.fetch_message(payload.message_id) except discord.NotFound: return reaction = payload.emoji close_emoji = await self.convert_emoji(self.config["close_emoji"]) if isinstance(channel, discord.DMChannel): if str(reaction) == str(close_emoji): # closing thread try: recipient_thread_close = strtobool( self.config["recipient_thread_close"]) except ValueError: recipient_thread_close = self.config.remove( "recipient_thread_close") if not recipient_thread_close: return thread = await self.threads.find(recipient=user) ts = message.embeds[0].timestamp if message.embeds else None if thread and ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed await thread.close(closer=user) else: if not message.embeds: return message_id = str(message.embeds[0].author.url).split("/")[-1] if message_id.isdigit(): thread = await self.threads.find(channel=message.channel) channel = thread.recipient.dm_channel if not channel: channel = await thread.recipient.create_dm() async for msg in channel.history(): if msg.id == int(message_id): await msg.add_reaction(reaction)
def get(self, key: str, convert=True) -> typing.Any: value = self.__getitem__(key) if not convert: return value if key in self.colors: try: return int(value.lstrip("#"), base=16) except ValueError: logger.error("Invalid %s provided.", key) value = int(self.remove(key).lstrip("#"), base=16) elif key in self.time_deltas: 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: value = strtobool(value) except ValueError: value = self.remove(key) elif key in self.enums: if value is None: return None try: value = self.enums[key](value) except ValueError: logger.warning("Invalid %s %s.", key, value) value = self.remove(key) return value
async def process_commands(self, message): if message.author.bot: return if isinstance(message.channel, discord.DMChannel): return await self.process_dm_modmail(message) if message.content.startswith(self.prefix): cmd = message.content[len(self.prefix):].strip() # Process snippets if cmd in self.snippets: thread = await self.threads.find(channel=message.channel) snippet = self.snippets[cmd] if thread: snippet = snippet.format(recipient=thread.recipient) message.content = f"{self.prefix}reply {snippet}" ctxs = await self.get_contexts(message) for ctx in ctxs: if ctx.command: await self.invoke(ctx) continue thread = await self.threads.find(channel=ctx.channel) if thread is not None: try: reply_without_command = strtobool( self.config["reply_without_command"]) except ValueError: reply_without_command = self.config.remove( "reply_without_command") if reply_without_command: await thread.reply(message) else: await self.api.append_log(message, type_="internal") elif ctx.invoked_with: exc = commands.CommandNotFound( 'Command "{}" is not found'.format(ctx.invoked_with)) self.dispatch("command_error", ctx, exc)
def get(self, key: str, convert=True) -> typing.Any: key = key.lower() if key not in self.all_keys: raise InvalidConfigError(f'Configuration "{key}" is invalid.') if key not in self._cache: self._cache[key] = deepcopy(self.defaults[key]) value = self._cache[key] if not convert: return value if key in self.colors: try: return int(value.lstrip("#"), base=16) except ValueError: logger.error("Invalid %s provided.", key) value = int(self.remove(key).lstrip("#"), base=16) elif key in self.time_deltas: 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: value = strtobool(value) except ValueError: value = self.remove(key) elif key in self.enums: if value is None: return None try: value = self.enums[key](value) except ValueError: logger.warning("Invalid %s %s.", key, value) value = self.remove(key) elif key in self.force_str: # Temporary: as we saved in int previously, leading to int32 overflow, # this is transitioning IDs to strings new_value = {} changed = False for k, v in value.items(): new_v = v if isinstance(v, list): new_v = [] for n in v: if n != -1 and not isinstance(n, str): changed = True n = str(n) new_v.append(n) new_value[k] = new_v if changed: # transition the database as well self.set(key, new_value) value = new_value return value
async def setup(self, *, creator=None, category=None): """Create the thread channel and other io related initialisation tasks""" self.bot.dispatch("thread_create", self) 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 = None try: channel = await self.bot.modmail_guild.create_text_channel( name=self.manager.format_channel_name(recipient), category=category, overwrites=overwrites, reason="Creating a thread channel.", ) except discord.HTTPException as e: # Failed to create due to 50 channel limit. logger.critical("An error occurred while creating a thread.", exc_info=True) self.manager.cache.pop(self.id) embed = discord.Embed(color=discord.Color.red()) 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 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 if creator: mention = None else: mention = self.bot.config["mention"] async def send_genesis_message(): info_embed = self._format_info_embed( recipient, log_url, log_count, discord.Color.green() ) try: msg = await channel.send(mention, embed=info_embed) self.bot.loop.create_task(msg.pin()) self.genesis_message = msg except Exception: logger.error("Failed unexpectedly:", exc_info=True) finally: self.ready = True await channel.edit(topic=f"User ID: {recipient.id}") self.bot.loop.create_task(send_genesis_message()) # Once thread is ready, tell the recipient. 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, ) try: recipient_thread_close = strtobool( self.bot.config["recipient_thread_close"] ) except ValueError: recipient_thread_close = self.bot.config.remove("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.guild.icon_url) embed.title = self.bot.config["thread_creation_title"] if creator is None: 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 msg.add_reaction(close_emoji)