def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None self.metadata_loop = None self.formatter = SafeFormatter() self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] self._connected = asyncio.Event() self.start_time = datetime.utcnow() self.config = ConfigManager(self) self.config.populate_cache() self.threads = ThreadManager(self) self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") self._configure_logging() mongo_uri = self.config["mongo_uri"] if mongo_uri is None: logger.critical("A Mongo URI is necessary for the bot to function.") raise RuntimeError try: self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: logger.critical( "Your MONGO_URI might be copied wrong, try re-copying from the source again. " "Otherwise noted in the following message:" ) logger.critical(e) sys.exit(0) self.plugin_db = PluginDatabaseClient(self) self.startup()
def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None self.metadata_loop = None self.formatter = SafeFormatter() self._connected = asyncio.Event() self.start_time = datetime.utcnow() self.config = ConfigManager(self) self.config.populate_cache() self.threads = ThreadManager(self) self._configure_logging() mongo_uri = self.config["mongo_uri"] if mongo_uri is None: logger.critical( "A Mongo URI is necessary for the bot to function.") raise RuntimeError try: self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: logger.critical( "Your MONGO_URI might be copied wrong, try re-copying from the source again. " "Otherwise noted in the following message:") logger.critical(str(e)) sys.exit(0) self.plugin_db = PluginDatabaseClient(self) logger.line() logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") logger.info("││││ │ │││││├─┤││") logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") logger.info("v%s", __version__) logger.info("Author: OhlookitsVeld") logger.line() self._load_extensions() logger.line()
def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None self.metadata_loop = None self.formatter = SafeFormatter() self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] self._connected = asyncio.Event() self.start_time = datetime.utcnow() self.config = ConfigManager(self) self.config.populate_cache() self.threads = ThreadManager(self) self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") self._configure_logging() self.plugin_db = PluginDatabaseClient(self) # Deprecated self.startup()
def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None self.metadata_loop = None self.formatter = SafeFormatter() self.loaded_cogs = ["cogs.modmail", "cogs.plugin", "cogs.utilita"] self._connected = asyncio.Event() self.start_time = datetime.utcnow() self.config = ConfigManager(self) self.config.populate_cache() self.threads = ThreadManager(self) self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") self._configure_logging() mongo_uri = self.config["mongo_uri"] if mongo_uri is None: logger.critical( "Un Mongo URI è necessario per il funzionamento del bot.") raise RuntimeError try: self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: logger.critical( "Il tuo MONGO_URI potrebbe essere copiato male, prova a ri-copiarlo dalla sorgente. " "Otherwise noted in the following message:") logger.critical(e) sys.exit(0) self.plugin_db = PluginDatabaseClient(self) self.startup()
class ModmailBot(commands.Bot): def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None self.metadata_loop = None self.formatter = SafeFormatter() self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] self._connected = asyncio.Event() self.start_time = datetime.utcnow() self.config = ConfigManager(self) self.config.populate_cache() self.threads = ThreadManager(self) self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") self._configure_logging() mongo_uri = self.config["mongo_uri"] if mongo_uri is None: logger.critical("A Mongo URI is necessary for the bot to function.") raise RuntimeError try: self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: logger.critical( "Your MONGO_URI might be copied wrong, try re-copying from the source again. " "Otherwise noted in the following message:" ) logger.critical(e) sys.exit(0) self.plugin_db = PluginDatabaseClient(self) self.startup() @property def uptime(self) -> str: now = datetime.utcnow() delta = now - self.start_time hours, remainder = divmod(int(delta.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) days, hours = divmod(hours, 24) fmt = "{h}h {m}m {s}s" if days: fmt = "{d}d " + fmt return self.formatter.format(fmt, d=days, h=hours, m=minutes, s=seconds) def startup(self): logger.line() logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") logger.info("││││ │ │││││├─┤││") logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") logger.info("v%s", __version__) logger.info("Authors: kyb3r, fourjr, Taaku18") logger.line() for cog in self.loaded_cogs: logger.debug("Loading %s.", cog) try: self.load_extension(cog) logger.debug("Successfully loaded %s.", cog) except Exception: logger.exception("Failed to load %s.", cog) logger.line("debug") def _configure_logging(self): level_text = self.config["log_level"].upper() logging_levels = { "CRITICAL": logging.CRITICAL, "ERROR": logging.ERROR, "WARNING": logging.WARNING, "INFO": logging.INFO, "DEBUG": logging.DEBUG, } logger.line() log_level = logging_levels.get(level_text) if log_level is None: log_level = self.config.remove("log_level") logger.warning("Invalid logging level set: %s.", level_text) logger.warning("Using default logging level: INFO.") else: logger.info("Logging level: %s", level_text) logger.info("Log file: %s", self.log_file_name) configure_logging(self.log_file_name, log_level) logger.debug("Successfully configured logging.") @property def version(self): return parse_version(__version__) @property def session(self) -> ClientSession: if self._session is None: self._session = ClientSession(loop=self.loop) return self._session @property def api(self) -> ApiClient: if self._api is None: self._api = ApiClient(self) return self._api async def get_prefix(self, message=None): return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] def run(self, *args, **kwargs): try: self.loop.run_until_complete(self.start(self.token)) except KeyboardInterrupt: pass except discord.LoginFailure: logger.critical("Invalid token") except Exception: logger.critical("Fatal exception", exc_info=True) finally: self.loop.run_until_complete(self.logout()) for task in asyncio.all_tasks(self.loop): task.cancel() try: self.loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(self.loop))) except asyncio.CancelledError: logger.debug("All pending tasks has been cancelled.") finally: self.loop.run_until_complete(self.session.close()) logger.error(" - Shutting down bot - ") @property def owner_ids(self): owner_ids = self.config["owners"] if owner_ids is not None: owner_ids = set(map(int, str(owner_ids).split(","))) if self.owner_id is not None: owner_ids.add(self.owner_id) permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, []) for perm in permissions: owner_ids.add(int(perm)) return owner_ids async def is_owner(self, user: discord.User) -> bool: if user.id in self.owner_ids: return True return await super().is_owner(user) @property def log_channel(self) -> typing.Optional[discord.TextChannel]: channel_id = self.config["log_channel_id"] if channel_id is not None: try: channel = self.get_channel(int(channel_id)) if channel is not None: return channel except ValueError: pass logger.debug("LOG_CHANNEL_ID was invalid, removed.") self.config.remove("log_channel_id") if self.main_category is not None: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id logger.warning( "No log channel set, setting #%s to be the log channel.", channel.name ) return channel except IndexError: pass logger.warning( "No log channel set, set one with `%ssetup` or `%sconfig set log_channel_id <id>`.", self.prefix, self.prefix, ) return None async def wait_for_connected(self) -> None: await self.wait_until_ready() await self._connected.wait() await self.config.wait_until_ready() @property def snippets(self) -> typing.Dict[str, str]: return self.config["snippets"] @property def aliases(self) -> typing.Dict[str, str]: return self.config["aliases"] @property def token(self) -> str: token = self.config["token"] if token is None: logger.critical( "TOKEN must be set, set this as bot token found on the Discord Developer Portal." ) sys.exit(0) return token @property def guild_id(self, ctx) -> typing.Optional[int]: guild = ctx.message.guild guild_id = guild.id try: return int(str(guild_id)) @property def guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is serving (the server where users message it from) """ return discord.utils.get(self.guilds, id=self.guild_id) @property def modmail_guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is operating in (where the bot is creating threads) """ modmail_guild_id = self.config["modmail_guild_id"] if modmail_guild_id is None: return self.guild try: guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) if guild is not None: return guild except ValueError: pass self.config.remove("modmail_guild_id") logger.critical("Invalid MODMAIL_GUILD_ID set.") return self.guild @property def using_multiple_server_setup(self) -> bool: return self.modmail_guild != self.guild @property def main_category(self) -> typing.Optional[discord.CategoryChannel]: if self.modmail_guild is not None: category_id = self.config["main_category_id"] if category_id is not None: try: cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) if cat is not None: return cat except ValueError: pass self.config.remove("main_category_id") logger.debug("MAIN_CATEGORY_ID was invalid, removed.") cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") if cat is not None: self.config["main_category_id"] = cat.id logger.debug( 'No main category set explicitly, setting category "Modmail" as the main category.' ) return cat return None @property def blocked_users(self) -> typing.Dict[str, str]: return self.config["blocked"] @property def blocked_whitelisted_users(self) -> typing.List[str]: return self.config["blocked_whitelist"] @property def prefix(self) -> str: return str(self.config["prefix"]) @property def mod_color(self) -> int: return self.config.get("mod_color") @property def recipient_color(self) -> int: return self.config.get("recipient_color") @property def main_color(self) -> int: return self.config.get("main_color") @property def error_color(self) -> int: return self.config.get("error_color") def command_perm(self, command_name: str) -> PermissionLevel: level = self.config["override_command_level"].get(command_name) if level is not None: try: return PermissionLevel[level.upper()] except KeyError: logger.warning("Invalid override_command_level for command %s.", command_name) self.config["override_command_level"].pop(command_name) command = self.get_command(command_name) if command is None: logger.debug("Command %s not found.", command_name) return PermissionLevel.INVALID level = next( ( check.permission_level for check in command.checks if hasattr(check, "permission_level") ), None, ) if level is None: logger.debug("Command %s does not have a permission level.", command_name) return PermissionLevel.INVALID return level async def on_connect(self): try: await self.validate_database_connection() except Exception: logger.debug("Logging out due to failed database connection.") return await self.logout() logger.debug("Connected to gateway.") await self.config.refresh() await self.setup_indexes() self._connected.set() async def setup_indexes(self): """Setup text indexes so we can use the $search operator""" coll = self.db.logs index_name = "messages.content_text_messages.author.name_text_key_text" index_info = await coll.index_information() # Backwards compatibility old_index = "messages.content_text_messages.author.name_text" if old_index in index_info: logger.info("Dropping old index: %s", old_index) await coll.drop_index(old_index) if index_name not in index_info: logger.info('Creating "text" index for logs collection.') logger.info("Name: %s", index_name) await coll.create_index( [("messages.content", "text"), ("messages.author.name", "text"), ("key", "text")] ) logger.debug("Successfully configured and verified database indexes.") async def on_ready(self): """Bot startup, sets uptime.""" # Wait until config cache is populated with stuff from db and on_connect ran await self.wait_for_connected() if self.guild is None: logger.error("Logging out due to invalid GUILD_ID.") return await self.logout() logger.line() logger.debug("Client ready.") logger.info("Logged in as: %s", self.user) logger.info("Bot ID: %s", self.user.id) owners = ", ".join( getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.owner_ids ) logger.info("Owners: %s", owners) logger.info("Prefix: %s", self.prefix) logger.info("Guild Name: %s", self.guild.name) logger.info("Guild ID: %s", self.guild.id) if self.using_multiple_server_setup: logger.info("Receiving guild ID: %s", self.modmail_guild.id) logger.line() await self.threads.populate_cache() # closures closures = self.config["closures"] logger.info("There are %d thread(s) pending to be closed.", len(closures)) logger.line() for recipient_id, items in tuple(closures.items()): after = (datetime.fromisoformat(items["time"]) - datetime.utcnow()).total_seconds() if after <= 0: logger.debug("Closing thread for recipient %s.", recipient_id) after = 0 else: logger.debug( "Thread for recipient %s will be closed after %s seconds.", recipient_id, after ) thread = await self.threads.find(recipient_id=int(recipient_id)) if not thread: # If the channel is deleted logger.debug("Failed to close thread for recipient %s.", recipient_id) self.config["closures"].pop(recipient_id) await self.config.update() continue await thread.close( closer=self.get_user(items["closer_id"]), after=after, silent=items["silent"], delete_channel=items["delete_channel"], message=items["message"], auto_close=items.get("auto_close", False), ) for log in await self.api.get_open_logs(): if self.get_channel(int(log["channel_id"])) is None: logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) log_data = await self.api.post_log( log["channel_id"], { "open": False, "closed_at": str(datetime.utcnow()), "close_message": "Channel has been deleted, no closer found.", "closer": { "id": str(self.user.id), "name": self.user.name, "discriminator": self.user.discriminator, "avatar_url": str(self.user.avatar_url), "mod": True, }, }, ) if log_data: logger.debug("Successfully closed thread with channel %s.", log["channel_id"]) else: logger.debug( "Failed to close thread with channel %s, skipping.", log["channel_id"] ) self.metadata_loop = tasks.Loop( self.post_metadata, seconds=0, minutes=0, hours=1, count=None, reconnect=True, loop=None, ) self.metadata_loop.before_loop(self.before_post_metadata) self.metadata_loop.start() async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() if name not in UNICODE_EMOJI: try: name = await converter.convert(ctx, name.strip(":")) except commands.BadArgument as e: logger.warning("%s is not a valid emoji. %s.", e) raise return name async def retrieve_emoji(self) -> typing.Tuple[str, str]: sent_emoji = self.config["sent_emoji"] blocked_emoji = self.config["blocked_emoji"] if sent_emoji != "disable": try: sent_emoji = await self.convert_emoji(sent_emoji) except commands.BadArgument: logger.warning("Removed sent emoji (%s).", sent_emoji) sent_emoji = self.config.remove("sent_emoji") await self.config.update() if blocked_emoji != "disable": try: blocked_emoji = await self.convert_emoji(blocked_emoji) except commands.BadArgument: logger.warning("Removed blocked emoji (%s).", blocked_emoji) blocked_emoji = self.config.remove("blocked_emoji") await self.config.update() return sent_emoji, blocked_emoji def check_account_age(self, author: discord.Member) -> bool: account_age = self.config.get("account_age") now = datetime.utcnow() try: min_account_age = author.created_at + account_age except ValueError: logger.warning("Error with 'account_age'.", exc_info=True) min_account_age = author.created_at + self.config.remove("account_age") if min_account_age > now: # User account has not reached the required time delta = human_timedelta(min_account_age) logger.debug("Blocked due to account age, user %s.", author.name) 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(author.id)] = new_reason 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 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 delta = human_timedelta(min_guild_age) logger.debug("Blocked due to guild age, user %s.", author.name) 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(author.id)] = new_reason 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, ) 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): _, 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 not self.check_manual_blocked(author): return True await self.config.update() 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: discord.Reaction) -> bool: if reaction != "disable": try: await msg.add_reaction(reaction) except (discord.HTTPException, discord.InvalidArgument) as e: logger.warning("Failed to add reaction %s: %s.", reaction, e) return False return True async def process_dm_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" blocked = await self._process_blocked(message) if blocked: 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"], color=self.error_color, description=self.config["disabled_new_thread_response"], ) embed.set_footer( text=self.config["disabled_new_thread_footer"], icon_url=self.guild.icon_url ) logger.info( "A new thread was blocked from %s due to disabled Modmail.", message.author ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) thread = await self.threads.create(message.author) else: if self.config["dm_disabled"] == 2: embed = discord.Embed( title=self.config["disabled_current_thread_title"], color=self.error_color, description=self.config["disabled_current_thread_response"], ) embed.set_footer( text=self.config["disabled_current_thread_footer"], icon_url=self.guild.icon_url, ) logger.info( "A message was blocked from %s due to disabled Modmail.", message.author ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) 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): """ Returns all invocation contexts from the message. Supports getting the prefix from database as well as command aliases. """ view = StringView(message.content) ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) thread = await self.threads.find(channel=ctx.channel) if self._skip_check(message.author.id, self.user.id): return [ctx] prefixes = await self.get_prefix() invoked_prefix = discord.utils.find(view.skip_string, prefixes) if invoked_prefix is None: return [ctx] invoker = view.get_word().lower() # Check if there is any aliases being called. alias = self.aliases.get(invoker) if alias is not None: ctxs = [] aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :]) if not aliases: logger.warning("Alias %s is invalid, removing.", invoker) self.aliases.pop(invoker) for alias in aliases: view = StringView(invoked_prefix + alias) ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) ctx_.thread = thread discord.utils.find(view.skip_string, prefixes) ctx_.invoked_with = view.get_word().lower() ctx_.command = self.all_commands.get(ctx_.invoked_with) ctxs += [ctx_] return ctxs ctx.thread = thread ctx.invoked_with = invoker ctx.command = self.all_commands.get(invoker) return [ctx] async def get_context(self, message, *, cls=commands.Context): """ Returns the invocation context from the message. Supports getting the prefix from database. """ view = StringView(message.content) ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) if self._skip_check(message.author.id, self.user.id): return ctx ctx.thread = await self.threads.find(channel=ctx.channel) prefixes = await self.get_prefix() invoked_prefix = discord.utils.find(view.skip_string, prefixes) if invoked_prefix is None: return ctx invoker = view.get_word().lower() ctx.invoked_with = invoker ctx.command = self.all_commands.get(invoker) return ctx async def update_perms( self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True ) -> None: value = int(value) if isinstance(name, PermissionLevel): permissions = self.config["level_permissions"] name = name.name else: permissions = self.config["command_permissions"] if name not in permissions: if add: permissions[name] = [value] else: if add: if value not in permissions[name]: permissions[name].append(value) else: if value in permissions[name]: permissions[name].remove(value) logger.info("Updating permissions for %s, %s (add=%s).", name, value, add) await self.config.update() async def on_message(self, message): await self.wait_for_connected() if message.type == discord.MessageType.pins_add and message.author == self.user: await message.delete() await self.process_commands(message) 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: snippet = self.snippets[cmd] message.content = f"{self.prefix}freply {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: if self.config.get("anon_reply_without_command"): await thread.reply(message, anonymous=True) elif self.config.get("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_typing(self, channel, user, _): await self.wait_for_connected() if user.bot: return if isinstance(channel, discord.DMChannel): if not self.config.get("user_typing"): return thread = await self.threads.find(recipient=user) if thread: await thread.channel.trigger_typing() else: if not self.config.get("mod_typing"): return thread = await self.threads.find(channel=channel) if thread is not None and thread.recipient: if await self.is_blocked(thread.recipient): return await thread.recipient.trigger_typing() async def handle_reaction_events(self, payload, *, add): 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, discord.Forbidden): return reaction = payload.emoji close_emoji = await self.convert_emoji(self.config["close_emoji"]) if isinstance(channel, discord.DMChannel): thread = await self.threads.find(recipient=user) if not thread: return if ( add and message.embeds and str(reaction) == str(close_emoji) and self.config.get("recipient_thread_close") ): ts = message.embeds[0].timestamp if thread and ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed # closing thread return await thread.close(closer=user) if not thread.recipient.dm_channel: await thread.recipient.create_dm() try: linked_message = await thread.find_linked_message_from_dm( message, either_direction=True ) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return else: thread = await self.threads.find(channel=channel) if not thread: return try: _, linked_message = await thread.find_linked_messages( message.id, either_direction=True ) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return if add: if await self.add_reaction(linked_message, reaction): await self.add_reaction(message, reaction) else: try: await linked_message.remove_reaction(reaction, self.user) await message.remove_reaction(reaction, self.user) except (discord.HTTPException, discord.InvalidArgument) as e: logger.warning("Failed to remove reaction: %s", e) async def on_raw_reaction_add(self, payload): await self.handle_reaction_events(payload, add=True) async def on_raw_reaction_remove(self, payload): await self.handle_reaction_events(payload, add=False) async def on_guild_channel_delete(self, channel): if channel.guild != self.modmail_guild: return try: audit_logs = self.modmail_guild.audit_logs() entry = await audit_logs.find(lambda a: a.target == channel) mod = entry.user except AttributeError as e: # discord.py broken implementation with discord API # TODO: waiting for dpy logger.warning("Failed to retrieve audit log: %s.", e) return if mod == self.user: return if isinstance(channel, discord.CategoryChannel): if self.main_category == channel: logger.debug("Main category was deleted.") self.config.remove("main_category_id") await self.config.update() return if not isinstance(channel, discord.TextChannel): return if self.log_channel is None or self.log_channel == channel: logger.info("Log channel deleted.") self.config.remove("log_channel_id") await self.config.update() return thread = await self.threads.find(channel=channel) if thread and thread.channel == channel: logger.debug("Manually closed channel %s.", channel.name) await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): if member.guild != self.guild: return thread = await self.threads.find(recipient=member) if thread: embed = discord.Embed( description="The recipient has left the server.", color=self.error_color ) await thread.channel.send(embed=embed) async def on_member_join(self, member): if member.guild != self.guild: return thread = await self.threads.find(recipient=member) if thread: embed = discord.Embed( description="The recipient has joined the server.", color=self.mod_color ) await thread.channel.send(embed=embed) async def on_message_delete(self, message): """Support for deleting linked messages""" # TODO: use audit log to check if modmail deleted the message if isinstance(message.channel, discord.DMChannel): thread = await self.threads.find(recipient=message.author) if not thread: return try: message = await thread.find_linked_message_from_dm(message) except ValueError as e: if str(e) != "Thread channel message not found.": logger.warning("Failed to find linked message to delete: %s", e) return embed = message.embeds[0] embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=embed.footer.icon_url) await message.edit(embed=embed) return thread = await self.threads.find(channel=message.channel) if not thread: return try: await thread.delete_message(message, note=False) except ValueError as e: if str(e) not in {"DM message not found.", "Malformed thread message."}: logger.warning("Failed to find linked message to delete: %s", e) return except discord.NotFound: return async def on_bulk_message_delete(self, messages): await discord.utils.async_all(self.on_message_delete(msg) for msg in messages) async def on_message_edit(self, before, after): if after.author.bot: return if before.content == after.content: return if isinstance(after.channel, discord.DMChannel): thread = await self.threads.find(recipient=before.author) if not thread: return try: await thread.edit_dm_message(after, after.content) except ValueError: _, blocked_emoji = await self.retrieve_emoji() await self.add_reaction(after, blocked_emoji) else: embed = discord.Embed( description="Successfully Edited Message", color=self.main_color ) embed.set_footer(text=f"Message ID: {after.id}") await after.channel.send(embed=embed) async def on_error(self, event_method, *args, **kwargs): logger.error("Ignoring exception in %s.", event_method) logger.error("Unexpected exception:", exc_info=sys.exc_info()) async def on_command_error(self, context, exception): if isinstance(exception, commands.BadUnionArgument): msg = "Could not find the specified " + human_join( [c.__name__ for c in exception.converters] ) await context.trigger_typing() await context.send(embed=discord.Embed(color=self.error_color, description=msg)) elif isinstance(exception, commands.BadArgument): await context.trigger_typing() await context.send( embed=discord.Embed(color=self.error_color, description=str(exception)) ) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) elif isinstance(exception, commands.MissingRequiredArgument): await context.send_help(context.command) elif isinstance(exception, commands.CheckFailure): for check in context.command.checks: if not await check(context): if hasattr(check, "fail_msg"): await context.send( embed=discord.Embed(color=self.error_color, description=check.fail_msg) ) if hasattr(check, "permission_level"): corrected_permission_level = self.command_perm( context.command.qualified_name ) logger.warning( "User %s does not have permission to use this command: `%s` (%s).", context.author.name, context.command.qualified_name, corrected_permission_level.name, ) logger.warning("CheckFailure: %s", exception) else: logger.error("Unexpected exception:", exc_info=exception) async def validate_database_connection(self): try: await self.db.command("buildinfo") except Exception as exc: logger.critical("Something went wrong while connecting to the database.") message = f"{type(exc).__name__}: {str(exc)}" logger.critical(message) if "ServerSelectionTimeoutError" in message: logger.critical( "This may have been caused by not whitelisting " "IPs correctly. Make sure to whitelist all " "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png" ) if "OperationFailure" in message: logger.critical( "This is due to having invalid credentials in your MONGO_URI. " "Remember you need to substitute `<password>` with your actual password." ) logger.critical( "Be sure to URL encode your username and password (not the entire URL!!), " "https://www.urlencoder.io/, if this issue persists, try changing your username and password " "to only include alphanumeric characters, no symbols." "" ) raise else: logger.debug("Successfully connected to the database.") logger.line("debug") async def post_metadata(self): owner = (await self.application_info()).owner data = { "owner_name": str(owner), "owner_id": owner.id, "bot_id": self.user.id, "bot_name": str(self.user), "avatar_url": str(self.user.avatar_url), "guild_id": self.guild_id, "guild_name": self.guild.name, "member_count": len(self.guild.members), "uptime": (datetime.utcnow() - self.start_time).total_seconds(), "latency": f"{self.ws.latency * 1000:.4f}", "version": str(self.version), "selfhosted": True, "last_updated": str(datetime.utcnow()), } async with self.session.post("https://api.logviewer.tech/metadata", json=data): logger.debug("Uploading metadata to Modmail server.") async def before_post_metadata(self): await self.wait_for_connected() logger.debug("Starting metadata loop.") logger.line("debug") if not self.guild: self.metadata_loop.cancel()
class ModmailBot(commands.Bot): def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None self.metadata_loop = None self.formatter = SafeFormatter() self._connected = asyncio.Event() self.start_time = datetime.utcnow() self.config = ConfigManager(self) self.config.populate_cache() self.threads = ThreadManager(self) self._configure_logging() mongo_uri = self.config["mongo_uri"] if mongo_uri is None: logger.critical( "A Mongo URI is necessary for the bot to function.") raise RuntimeError try: self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: logger.critical( "Your MONGO_URI might be copied wrong, try re-copying from the source again. " "Otherwise noted in the following message:") logger.critical(str(e)) sys.exit(0) self.plugin_db = PluginDatabaseClient(self) logger.line() logger.info("┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬") logger.info("││││ │ │││││├─┤││") logger.info("┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘") logger.info("v%s", __version__) logger.info("Author: OhlookitsVeld") logger.line() self._load_extensions() logger.line() @property def uptime(self) -> str: now = datetime.utcnow() delta = now - self.start_time hours, remainder = divmod(int(delta.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) days, hours = divmod(hours, 24) fmt = "{h}h {m}m {s}s" if days: fmt = "{d}d " + fmt return self.formatter.format(fmt, d=days, h=hours, m=minutes, s=seconds) def _configure_logging(self): level_text = self.config["log_level"].upper() logging_levels = { "CRITICAL": logging.CRITICAL, "ERROR": logging.ERROR, "WARNING": logging.WARNING, "INFO": logging.INFO, "DEBUG": logging.DEBUG, } log_file_name = self.token.split(".")[0] ch_debug = logging.FileHandler(os.path.join(temp_dir, f"{log_file_name}.log"), mode="a+") ch_debug.setLevel(logging.DEBUG) formatter_debug = FileFormatter( "%(asctime)s %(filename)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) ch_debug.setFormatter(formatter_debug) logger.addHandler(ch_debug) log_level = logging_levels.get(level_text) if log_level is None: log_level = self.config.remove("log_level") logger.line() if log_level is not None: logger.setLevel(log_level) ch.setLevel(log_level) logger.info("Logging level: %s", level_text) else: logger.info("Invalid logging level set.") logger.warning("Using default logging level: %s.", level_text) logger.debug("Successfully configured logging.") @property def version(self) -> str: return __version__ @property def session(self) -> ClientSession: if self._session is None: self._session = ClientSession(loop=self.loop) return self._session @property def api(self): if self._api is None: self._api = ApiClient(self) return self._api async def get_prefix(self, message=None): return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] def _load_extensions(self): """Adds commands automatically""" for file in os.listdir("cogs"): if not file.endswith(".py"): continue cog = f"cogs.{file[:-3]}" logger.info("Loading %s.", cog) try: self.load_extension(cog) except Exception: logger.exception("Failed to load %s.", cog) def run(self, *args, **kwargs): try: self.loop.run_until_complete(self.start(self.token)) except KeyboardInterrupt: pass except discord.LoginFailure: logger.critical("Invalid token") except Exception: logger.critical("Fatal exception", exc_info=True) finally: self.loop.run_until_complete(self.logout()) for task in asyncio.all_tasks(self.loop): task.cancel() try: self.loop.run_until_complete( asyncio.gather(*asyncio.all_tasks(self.loop))) except asyncio.CancelledError: logger.debug("All pending tasks has been cancelled.") finally: self.loop.run_until_complete(self.session.close()) logger.error(" - Shutting down bot - ") @property def owner_ids(self): owner_ids = self.config["owners"] if owner_ids is not None: owner_ids = set(map(int, str(owner_ids).split(","))) if self.owner_id is not None: owner_ids.add(self.owner_id) permissions = self.config["level_permissions"].get( PermissionLevel.OWNER.name, []) for perm in permissions: owner_ids.add(int(perm)) return owner_ids async def is_owner(self, user: discord.User) -> bool: if user.id in self.owner_ids: return True return await super().is_owner(user) @property def log_channel(self) -> typing.Optional[discord.TextChannel]: channel_id = self.config["log_channel_id"] if channel_id is not None: try: channel = self.get_channel(int(channel_id)) if channel is not None: return channel except ValueError: pass logger.debug("LOG_CHANNEL_ID was invalid, removed.") self.config.remove("log_channel_id") if self.main_category is not None: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id logger.warning( "No log channel set, setting #%s to be the log channel.", channel.name, ) return channel except IndexError: pass logger.warning( "No log channel set, set one with `%ssetup` or `%sconfig set log_channel_id <id>`.", self.prefix, self.prefix, ) return None async def wait_for_connected(self) -> None: await self.wait_until_ready() await self._connected.wait() await self.config.wait_until_ready() @property def snippets(self) -> typing.Dict[str, str]: return self.config["snippets"] @property def aliases(self) -> typing.Dict[str, str]: return self.config["aliases"] @property def token(self) -> str: token = self.config["token"] if token is None: logger.critical( "TOKEN must be set, set this as bot token found on the Discord Developer Portal." ) sys.exit(0) return token @property def guild_id(self) -> typing.Optional[int]: guild_id = self.config["guild_id"] if guild_id is not None: try: return int(str(guild_id)) except ValueError: self.config.remove("guild_id") logger.critical("Invalid GUILD_ID set.") else: logger.debug("No GUILD_ID set.") return None @property def guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is serving (the server where users message it from) """ return discord.utils.get(self.guilds, id=self.guild_id) @property def modmail_guild(self) -> typing.Optional[discord.Guild]: """ The guild that the bot is operating in (where the bot is creating threads) """ modmail_guild_id = self.config["modmail_guild_id"] if modmail_guild_id is None: return self.guild try: guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) if guild is not None: return guild except ValueError: pass self.config.remove("modmail_guild_id") logger.critical("Invalid MODMAIL_GUILD_ID set.") return self.guild @property def using_multiple_server_setup(self) -> bool: return self.modmail_guild != self.guild @property def main_category(self) -> typing.Optional[discord.CategoryChannel]: if self.modmail_guild is not None: category_id = self.config["main_category_id"] if category_id is not None: try: cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) if cat is not None: return cat except ValueError: pass self.config.remove("main_category_id") logger.debug("MAIN_CATEGORY_ID was invalid, removed.") cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") if cat is not None: self.config["main_category_id"] = cat.id logger.debug( 'No main category set explicitly, setting category "Modmail" as the main category.' ) return cat return None @property def blocked_users(self) -> typing.Dict[str, str]: return self.config["blocked"] @property def blocked_whitelisted_users(self) -> typing.List[str]: return self.config["blocked_whitelist"] @property def prefix(self) -> str: return str(self.config["prefix"]) def _parse_color(self, conf_name): color = self.config[conf_name] try: return int(color.lstrip("#"), base=16) except ValueError: logger.error("Invalid %s provided.", conf_name) return int(self.config.remove(conf_name).lstrip("#"), base=16) @property def mod_color(self) -> int: return self._parse_color("mod_color") @property def recipient_color(self) -> int: return self._parse_color("recipient_color") @property def main_color(self) -> int: return self._parse_color("main_color") def command_perm(self, command_name: str) -> PermissionLevel: level = self.config["override_command_level"].get(command_name) if level is not None: try: return PermissionLevel[level.upper()] except KeyError: logger.warning( "Invalid override_command_level for command %s.", command_name) self.config["override_command_level"].pop(command_name) command = self.get_command(command_name) if command is None: logger.debug("Command %s not found.", command_name) return PermissionLevel.INVALID level = next( (check.permission_level for check in command.checks if hasattr(check, "permission_level")), None, ) if level is None: logger.debug("Command %s does not have a permission level.", command_name) return PermissionLevel.INVALID return level async def on_connect(self): logger.line() try: await self.validate_database_connection() except Exception: logger.debug("Logging out due to failed database connection.") return await self.logout() logger.info("Connected to gateway.") await self.config.refresh() await self.setup_indexes() self._connected.set() async def setup_indexes(self): """Setup text indexes so we can use the $search operator""" coll = self.db.logs index_name = "messages.content_text_messages.author.name_text_key_text" index_info = await coll.index_information() # Backwards compatibility old_index = "messages.content_text_messages.author.name_text" if old_index in index_info: logger.info("Dropping old index: %s", old_index) await coll.drop_index(old_index) if index_name not in index_info: logger.info('Creating "text" index for logs collection.') logger.info("Name: %s", index_name) await coll.create_index([ ("messages.content", "text"), ("messages.author.name", "text"), ("key", "text"), ]) logger.debug("Successfully set up database indexes.") async def on_ready(self): """Bot startup, sets uptime.""" # Wait until config cache is populated with stuff from db and on_connect ran await self.wait_for_connected() if self.guild is None: logger.error("Logging out due to invalid GUILD_ID.") return await self.logout() logger.line() logger.info("Client ready.") logger.line() logger.info("Logged in as: %s", self.user) logger.info("User ID: %s", self.user.id) logger.info("Prefix: %s", self.prefix) logger.info("Guild Name: %s", self.guild.name) logger.info("Guild ID: %s", self.guild.id) if self.using_multiple_server_setup: logger.info("Receiving guild ID: %s", self.modmail_guild.id) logger.line() await self.threads.populate_cache() # closures closures = self.config["closures"] logger.info("There are %d thread(s) pending to be closed.", len(closures)) logger.line() for recipient_id, items in tuple(closures.items()): after = (datetime.fromisoformat(items["time"]) - datetime.utcnow()).total_seconds() if after < 0: after = 0 thread = await self.threads.find(recipient_id=int(recipient_id)) if not thread: # If the channel is deleted logger.debug("Failed to close thread for recipient %s.", recipient_id) self.config["closures"].pop(recipient_id) await self.config.update() continue logger.debug("Closing thread for recipient %s.", recipient_id) await thread.close( closer=self.get_user(items["closer_id"]), after=after, silent=items["silent"], delete_channel=items["delete_channel"], message=items["message"], auto_close=items.get("auto_close", False), ) self.metadata_loop = tasks.Loop( self.post_metadata, seconds=0, minutes=0, hours=1, count=None, reconnect=True, loop=None, ) self.metadata_loop.before_loop(self.before_post_metadata) self.metadata_loop.after_loop(self.after_post_metadata) self.metadata_loop.start() async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() if name not in UNICODE_EMOJI: try: name = await converter.convert(ctx, name.strip(":")) except commands.BadArgument: logger.warning("%s is not a valid emoji.", name) raise return name async def retrieve_emoji(self) -> typing.Tuple[str, str]: sent_emoji = self.config["sent_emoji"] blocked_emoji = self.config["blocked_emoji"] if sent_emoji != "disable": try: sent_emoji = await self.convert_emoji(sent_emoji) except commands.BadArgument: logger.warning("Removed sent emoji (%s).", sent_emoji) sent_emoji = self.config.remove("sent_emoji") await self.config.update() if blocked_emoji != "disable": try: blocked_emoji = await self.convert_emoji(blocked_emoji) except commands.BadArgument: logger.warning("Removed blocked emoji (%s).", blocked_emoji) blocked_emoji = self.config.remove("blocked_emoji") await self.config.update() return sent_emoji, blocked_emoji async def _process_blocked(self, message: discord.Message) -> bool: 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() if sent_emoji != "disable": try: await message.add_reaction(sent_emoji) except (discord.HTTPException, discord.InvalidArgument): logger.warning("Failed to add sent_emoji.", exc_info=True) return False now = datetime.utcnow() account_age = self.config["account_age"] guild_age = self.config["guild_age"] if account_age is None: account_age = isodate.Duration() if guild_age is None: guild_age = isodate.Duration() if not isinstance(account_age, isodate.Duration): try: account_age = isodate.parse_duration(account_age) except isodate.ISO8601Error: logger.warning( "The account age limit needs to be a " "ISO-8601 duration formatted duration string " 'greater than 0 days, not "%s".', str(account_age), ) account_age = self.config.remove("account_age") if not isinstance(guild_age, isodate.Duration): try: guild_age = isodate.parse_duration(guild_age) except isodate.ISO8601Error: logger.warning( "The guild join age limit needs to be a " "ISO-8601 duration formatted duration string " 'greater than 0 days, not "%s".', str(guild_age), ) guild_age = self.config.remove("guild_age") reason = self.blocked_users.get(str(message.author.id)) or "" min_guild_age = min_account_age = now try: min_account_age = message.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") 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) if str(message.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 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} " f"before you can contact me.", color=discord.Color.red(), )) elif 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) if str(message.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 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} " f"before you can contact me.", color=discord.Color.red(), )) 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 end_time = re.search(r"%(.+?)%$", reason) if end_time is not None: logger.debug("No longer blocked, user %s.", message.author.name) 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)) else: logger.debug("User blocked, user %s.", message.author.name) else: reaction = sent_emoji await self.config.update() if reaction != "disable": try: await message.add_reaction(reaction) except (discord.HTTPException, discord.InvalidArgument): logger.warning("Failed to add reaction %s.", reaction, exc_info=True) return str(message.author.id) in self.blocked_users async def process_dm_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" blocked = await self._process_blocked(message) if not blocked: thread = await self.threads.find_or_create(message.author) await thread.send(message) async def get_contexts(self, message, *, cls=commands.Context): """ Returns all invocation contexts from the message. Supports getting the prefix from database as well as command aliases. """ view = StringView(message.content) ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) ctx.thread = await self.threads.find(channel=ctx.channel) if self._skip_check(message.author.id, self.user.id): return [ctx] prefixes = await self.get_prefix() invoked_prefix = discord.utils.find(view.skip_string, prefixes) if invoked_prefix is None: return [ctx] invoker = view.get_word().lower() # Check if there is any aliases being called. alias = self.aliases.get(invoker) if alias is not None: aliases = parse_alias(alias) if not aliases: logger.warning("Alias %s is invalid, removing.", invoker) self.aliases.pop(invoker) else: len_ = len(f"{invoked_prefix}{invoker}") contents = parse_alias(message.content[len_:]) if not contents: contents = [message.content[len_:]] ctxs = [] for alias, content in zip_longest(aliases, contents): if alias is None: break ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) ctx.thread = await self.threads.find(channel=ctx.channel) if content is not None: view = StringView(f"{alias} {content.strip()}") else: view = StringView(alias) ctx.view = view ctx.invoked_with = view.get_word() ctx.command = self.all_commands.get(ctx.invoked_with) ctxs += [ctx] return ctxs ctx.invoked_with = invoker ctx.command = self.all_commands.get(invoker) return [ctx] async def get_context(self, message, *, cls=commands.Context): """ Returns the invocation context from the message. Supports getting the prefix from database. """ view = StringView(message.content) ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) if self._skip_check(message.author.id, self.user.id): return ctx ctx.thread = await self.threads.find(channel=ctx.channel) prefixes = await self.get_prefix() invoked_prefix = discord.utils.find(view.skip_string, prefixes) if invoked_prefix is None: return ctx invoker = view.get_word().lower() ctx.invoked_with = invoker ctx.command = self.all_commands.get(invoker) return ctx async def update_perms(self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True) -> None: value = int(value) if isinstance(name, PermissionLevel): permissions = self.config["level_permissions"] name = name.name else: permissions = self.config["command_permissions"] if name not in permissions: if add: permissions[name] = [value] else: if add: if value not in permissions[name]: permissions[name].append(value) else: if value in permissions[name]: permissions[name].remove(value) logger.info("Updating permissions for %s, %s (add=%s).", name, value, add) await self.config.update() async def on_message(self, message): await self.wait_for_connected() if message.type == discord.MessageType.pins_add and message.author == self.user: await message.delete() await self.process_commands(message) 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_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 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) async def on_guild_channel_delete(self, channel): if channel.guild != self.modmail_guild: return audit_logs = self.modmail_guild.audit_logs() entry = await audit_logs.find(lambda e: e.target.id == channel.id) mod = entry.user if mod == self.user: return if isinstance(channel, discord.CategoryChannel): if self.main_category.id == channel.id: logger.debug("Main category was deleted.") self.config.remove("main_category_id") await self.config.update() return if not isinstance(channel, discord.TextChannel): return if self.log_channel is None or self.log_channel.id == channel.id: logger.info("Log channel deleted.") self.config.remove("log_channel_id") await self.config.update() return thread = await self.threads.find(channel=channel) if thread: logger.debug("Manually closed channel %s.", channel.name) await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): if member.guild != self.guild: return thread = await self.threads.find(recipient=member) if thread: embed = discord.Embed( description="The recipient has left the server.", color=discord.Color.red(), ) await thread.channel.send(embed=embed) async def on_member_join(self, member): if member.guild != self.guild: return thread = await self.threads.find(recipient=member) if thread: embed = discord.Embed( description="The recipient has joined the server.", color=self.mod_color) await thread.channel.send(embed=embed) async def on_message_delete(self, message): """Support for deleting linked messages""" if message.embeds and not isinstance(message.channel, discord.DMChannel): 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 async for msg in channel.history(): if msg.embeds and msg.embeds[0].author: url = str(msg.embeds[0].author.url) if message_id == url.split("/")[-1]: return await msg.delete() async def on_bulk_message_delete(self, messages): await discord.utils.async_all( self.on_message_delete(msg) for msg in messages) async def on_message_edit(self, before, after): if before.author.bot: return if isinstance(before.channel, discord.DMChannel): thread = await self.threads.find(recipient=before.author) async for msg in thread.channel.history(): if msg.embeds: embed = msg.embeds[0] matches = str(embed.author.url).split("/") if matches and matches[-1] == str(before.id): embed.description = after.content await msg.edit(embed=embed) await self.api.edit_message(str(after.id), after.content) break async def on_error(self, event_method, *args, **kwargs): logger.error("Ignoring exception in %s.", event_method) logger.error("Unexpected exception:", exc_info=sys.exc_info()) async def on_command_error(self, context, exception): if isinstance(exception, commands.BadUnionArgument): msg = "Could not find the specified " + human_join( [c.__name__ for c in exception.converters]) await context.trigger_typing() await context.send( embed=discord.Embed(color=discord.Color.red(), description=msg) ) elif isinstance(exception, commands.BadArgument): await context.trigger_typing() await context.send(embed=discord.Embed(color=discord.Color.red(), description=str(exception))) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) elif isinstance(exception, commands.MissingRequiredArgument): await context.send_help(context.command) elif isinstance(exception, commands.CheckFailure): for check in context.command.checks: if not await check(context): if hasattr(check, "fail_msg"): await context.send( embed=discord.Embed(color=discord.Color.red(), description=check.fail_msg)) if hasattr(check, "permission_level"): corrected_permission_level = self.command_perm( context.command.qualified_name) logger.warning( "User %s does not have permission to use this command: `%s` (%s).", context.author.name, context.command.qualified_name, corrected_permission_level.name, ) logger.warning("CheckFailure: %s", exception) else: logger.error("Unexpected exception:", exc_info=exception) async def validate_database_connection(self): try: await self.db.command("buildinfo") except Exception as exc: logger.critical( "Something went wrong while connecting to the database.") message = f"{type(exc).__name__}: {str(exc)}" logger.critical(message) if "ServerSelectionTimeoutError" in message: logger.critical( "This may have been caused by not whitelisting " "IPs correctly. Make sure to whitelist all " "IPs (0.0.0.0/0) https://i.imgur.com/mILuQ5U.png") if "OperationFailure" in message: logger.critical( "This is due to having invalid credentials in your MONGO_URI. " "Remember you need to substitute `<password>` with your actual password." ) logger.critical( "Be sure to URL encode your username and password (not the entire URL!!), " "https://www.urlencoder.io/, if this issue persists, try changing your username and password " "to only include alphanumeric characters, no symbols." "") raise else: logger.info("Successfully connected to the database.") async def post_metadata(self): owner = (await self.application_info()).owner data = { "owner_name": str(owner), "owner_id": owner.id, "bot_id": self.user.id, "bot_name": str(self.user), "avatar_url": str(self.user.avatar_url), "guild_id": self.guild_id, "guild_name": self.guild.name, "member_count": len(self.guild.members), "uptime": (datetime.utcnow() - self.start_time).total_seconds(), "latency": f"{self.ws.latency * 1000:.4f}", "version": self.version, "selfhosted": True, "last_updated": str(datetime.utcnow()), } async with self.session.post("https://api.modmail.tk/metadata", json=data): logger.debug("Uploading metadata to Modmail server.") async def before_post_metadata(self): await self.wait_for_connected() logger.debug("Starting metadata loop.") logger.line() if not self.guild: self.metadata_loop.cancel() @staticmethod async def after_post_metadata(): logger.info("Metadata loop has been cancelled.")
class VincyBot07(commands.Bot): def __init__(self): super().__init__(command_prefix=None) # implemented in `get_prefix` self._session = None self._api = None self.metadata_loop = None self.formatter = SafeFormatter() self.loaded_cogs = ["cogs.modmail", "cogs.plugin", "cogs.utilita"] self._connected = asyncio.Event() self.start_time = datetime.utcnow() self.config = ConfigManager(self) self.config.populate_cache() self.threads = ThreadManager(self) self.log_file_name = os.path.join(temp_dir, f"{self.token.split('.')[0]}.log") self._configure_logging() mongo_uri = self.config["mongo_uri"] if mongo_uri is None: logger.critical( "Un Mongo URI è necessario per il funzionamento del bot.") raise RuntimeError try: self.db = AsyncIOMotorClient(mongo_uri).modmail_bot except ConfigurationError as e: logger.critical( "Il tuo MONGO_URI potrebbe essere copiato male, prova a ri-copiarlo dalla sorgente. " "Otherwise noted in the following message:") logger.critical(e) sys.exit(0) self.plugin_db = PluginDatabaseClient(self) self.startup() @property def uptime(self) -> str: now = datetime.utcnow() delta = now - self.start_time hours, remainder = divmod(int(delta.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) days, hours = divmod(hours, 24) fmt = "{h}h {m}m {s}s" if days: fmt = "{d}d " + fmt return self.formatter.format(fmt, d=days, h=hours, m=minutes, s=seconds) def startup(self): logger.line() logger.info("__ ___ ____ _ ___ _____ ") logger.info("\ \ / (_)_ __ ___ _ _| __ ) ___ | |_ / _ \___ |") logger.info(" \ \ / /| | '_ \ / __| | | | _ \ / _ \| __| | | | / / ") logger.info(" \ V / | | | | | (__| |_| | |_) | (_) | |_| |_| |/ / ") logger.info(" \_/ |_|_| |_|\___|\__, |____/ \___/ \__|\___//_/ ") logger.info(" |___/ ") logger.info("v%s", __version__) logger.info("Autori: Vincysuper07, Ergastolator") logger.line() for cog in self.loaded_cogs: logger.debug("Sto caricando %s.", cog) try: self.load_extension(cog) logger.debug("%s è stato caricato con successo.", cog) except Exception: logger.exception("Non è stato possibile caricare %s.", cog) logger.line("debug") def _configure_logging(self): level_text = self.config["log_level"].upper() logging_levels = { "CRITICAL": logging.CRITICAL, "ERROR": logging.ERROR, "WARNING": logging.WARNING, "INFO": logging.INFO, "DEBUG": logging.DEBUG, } logger.line() log_level = logging_levels.get(level_text) if log_level is None: log_level = self.config.remove("log_level") logger.warning( 'Il livello di logging configurato "%s" non è valido.', level_text) logger.warning("Uso il livelo di loggind predefinito: INFO.") else: logger.info("Livello di logging: %s", level_text) logger.info("File di log: %s", self.log_file_name) configure_logging(self.log_file_name, log_level) logger.debug("Il logging è stato configurato con successo.") @property def version(self): return parse_version(__version__) @property def session(self) -> ClientSession: if self._session is None: self._session = ClientSession(loop=self.loop) return self._session @property def api(self) -> ApiClient: if self._api is None: self._api = ApiClient(self) return self._api async def get_prefix(self, message=None): return [self.prefix, f"<@{self.user.id}> ", f"<@!{self.user.id}> "] def run(self, *args, **kwargs): try: self.loop.run_until_complete(self.start(self.token)) except KeyboardInterrupt: pass except discord.LoginFailure: logger.critical("Token non valido") except Exception: logger.critical("Errore fatale", exc_info=True) finally: self.loop.run_until_complete(self.logout()) for task in asyncio.all_tasks(self.loop): task.cancel() try: self.loop.run_until_complete( asyncio.gather(*asyncio.all_tasks(self.loop))) except asyncio.CancelledError: logger.debug( "Tutte le attività in sospeso sono state annullate.") finally: self.loop.run_until_complete(self.session.close()) logger.error(" - Spegnimento del bot - ") @property def owner_ids(self): owner_ids = self.config["owners"] if owner_ids is not None: owner_ids = set(map(int, str(owner_ids).split(","))) if self.owner_id is not None: owner_ids.add(self.owner_id) permissions = self.config["level_permissions"].get( PermissionLevel.OWNER.name, []) for perm in permissions: owner_ids.add(int(perm)) return owner_ids async def is_owner(self, user: discord.User) -> bool: if user.id in self.owner_ids: return True return await super().is_owner(user) @property def log_channel(self) -> typing.Optional[discord.TextChannel]: channel_id = self.config["log_channel_id"] if channel_id is not None: try: channel = self.get_channel(int(channel_id)) if channel is not None: return channel except ValueError: pass logger.debug( "LOG_CHANNEL_ID non era valido quindi è stato rimosso.") self.config.remove("log_channel_id") if self.main_category is not None: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id logger.warning( "Non è stato impostato alcun canale di log, imposto #%s come canale di log.", channel.name, ) return channel except IndexError: pass logger.warning( "Non è stato impostato alcun canale di log, impostane uno con `%ssetup` oppure `%sconfig set log_channel_id <id>`.", self.prefix, self.prefix, ) return None async def wait_for_connected(self) -> None: await self.wait_until_ready() await self._connected.wait() await self.config.wait_until_ready() @property def snippets(self) -> typing.Dict[str, str]: return self.config["snippets"] @property def aliases(self) -> typing.Dict[str, str]: return self.config["aliases"] @property def token(self) -> str: token = self.config["token"] if token is None: logger.critical( 'La configurazione "TOKEN" deve essere impostata, impostala con il token del bot che può essere trovato nel Discord Developer Portal.' ) sys.exit(0) return token @property def guild_id(self) -> typing.Optional[int]: guild_id = self.config["guild_id"] if guild_id is not None: try: return int(str(guild_id)) except ValueError: self.config.remove("guild_id") logger.critical("L'ID del server impostato non è valido.") else: logger.debug( "Non è stato impostato l'ID del server, impostalo riempiendo la configurazione \"GUILD_ID\" con l'ID del tuo server." ) return None @property def guild(self) -> typing.Optional[discord.Guild]: """ Il server dove il bot riceve i messaggi. """ return discord.utils.get(self.guilds, id=self.guild_id) @property def modmail_guild(self) -> typing.Optional[discord.Guild]: """ Il server dove il bot crea le stanze. """ modmail_guild_id = self.config["modmail_guild_id"] if modmail_guild_id is None: return self.guild try: guild = discord.utils.get(self.guilds, id=int(modmail_guild_id)) if guild is not None: return guild except ValueError: pass self.config.remove("modmail_guild_id") logger.critical( 'La configurazione "MODMAIL_GUILD_ID" inserita non è valida.') return self.guild @property def using_multiple_server_setup(self) -> bool: return self.modmail_guild != self.guild @property def main_category(self) -> typing.Optional[discord.CategoryChannel]: if self.modmail_guild is not None: category_id = self.config["main_category_id"] if category_id is not None: try: cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) if cat is not None: return cat except ValueError: pass self.config.remove("main_category_id") logger.debug( 'La configurazione "MAIN_CATEGORY_ID" non era valida quindi è stata rimossa.' ) cat = discord.utils.get(self.modmail_guild.categories, name="Modmail") if cat is not None: self.config["main_category_id"] = cat.id logger.debug( 'Nessuna categoria dedicata al Modmail è stata impostata, quindi imposto "Modmail" come categoria principale.' ) return cat return None @property def blocked_users(self) -> typing.Dict[str, str]: return self.config["blocked"] @property def blocked_whitelisted_users(self) -> typing.List[str]: return self.config["blocked_whitelist"] @property def prefix(self) -> str: return str(self.config["prefix"]) @property def mod_color(self) -> int: return self.config.get("mod_color") @property def recipient_color(self) -> int: return self.config.get("recipient_color") @property def main_color(self) -> int: return self.config.get("main_color") @property def error_color(self) -> int: return self.config.get("error_color") def command_perm(self, command_name: str) -> PermissionLevel: level = self.config["override_command_level"].get(command_name) if level is not None: try: return PermissionLevel[level.upper()] except KeyError: logger.warning( 'La configurazione "override_command_level" per il comando %s non è valida.', command_name, ) self.config["override_command_level"].pop(command_name) command = self.get_command(command_name) if command is None: logger.debug("Il comando %s non è stato trovato.", command_name) return PermissionLevel.INVALID level = next( (check.permission_level for check in command.checks if hasattr(check, "permission_level")), None, ) if level is None: logger.debug("Il comando %s non ha alcun livello di permesso.", command_name) return PermissionLevel.INVALID return level async def on_connect(self): try: await self.validate_database_connection() except Exception: logger.debug( "Spegnimento per via di errore di connessione al database.") return await self.logout() logger.debug("Connesso al gateway.") await self.config.refresh() await self.setup_indexes() self._connected.set() async def setup_indexes(self): """Setup text indexes so we can use the $search operator""" coll = self.db.logs index_name = "messages.content_text_messages.author.name_text_key_text" index_info = await coll.index_information() # Backwards compatibility old_index = "messages.content_text_messages.author.name_text" if old_index in index_info: logger.info("Dropping old index: %s", old_index) await coll.drop_index(old_index) if index_name not in index_info: logger.info('Creating "text" index for logs collection.') logger.info("Name: %s", index_name) await coll.create_index([("messages.content", "text"), ("messages.author.name", "text"), ("key", "text")]) logger.debug( "Gli index database sono stati configurati e verificati con successo." ) async def on_ready(self): """L'avvio del bot.""" # commands.Bot.remove_command(self, name="help") # Wait until config cache is populated with stuff from db and on_connect ran await self.wait_for_connected() if self.guild is None: logger.error( "Spegnimento per via della configurazione `GUILD_ID` non valida." ) return await self.logout() logger.line() logger.debug("Client pronto.") logger.info("Loggato come: %s", self.user) logger.info("ID Bot: %s", self.user.id) owners = ", ".join( getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.owner_ids) logger.info("Proprietari: %s", owners) logger.info("Prefix: %s", self.prefix) logger.info("Nome server: %s", self.guild.name) logger.info("ID Server: %s", self.guild.id) if self.using_multiple_server_setup: logger.info("Ricezione ID Server: %s", self.modmail_guild.id) logger.line() await self.threads.populate_cache() # closures closures = self.config["closures"] logger.info("Ci sono %d stanze che stanno per essere chiuse.", len(closures)) logger.line() for recipient_id, items in tuple(closures.items()): after = (datetime.fromisoformat(items["time"]) - datetime.utcnow()).total_seconds() if after <= 0: logger.debug("Sto chiudendo la stanza per l'utente %s.", recipient_id) after = 0 else: logger.debug( "La stanza dell'utente %s verrà chiusa tra %s secondi.", recipient_id, after) thread = await self.threads.find(recipient_id=int(recipient_id)) if not thread: # If the channel is deleted logger.debug( "Non è stato possibile chiudere la stanza per l'utente %s.", recipient_id) self.config["closures"].pop(recipient_id) await self.config.update() continue await thread.close( closer=self.get_user(items["closer_id"]), after=after, silent=items["silent"], delete_channel=items["delete_channel"], message=items["message"], auto_close=items.get("auto_close", False), ) for log in await self.api.get_open_logs(): if self.get_channel(int(log["channel_id"])) is None: logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) log_data = await self.api.post_log( log["channel_id"], { "open": False, "closed_at": str(datetime.utcnow()), "close_message": "Channel has been deleted, no closer found.", "closer": { "id": str(self.user.id), "name": self.user.name, "discriminator": self.user.discriminator, "avatar_url": str(self.user.avatar_url), "mod": True, }, }, ) if log_data: logger.debug( "La stanza con canale %s è stata chiusa con successo.", log["channel_id"]) else: logger.debug( "Non è stato possibile chiudere la stanza con canale %s, salto.", log["channel_id"], ) self.metadata_loop = tasks.Loop( self.post_metadata, seconds=0, minutes=0, hours=1, count=None, reconnect=True, loop=None, ) self.metadata_loop.before_loop(self.before_post_metadata) self.metadata_loop.start() async def convert_emoji(self, name: str) -> str: ctx = SimpleNamespace(bot=self, guild=self.modmail_guild) converter = commands.EmojiConverter() if name not in UNICODE_EMOJI: try: name = await converter.convert(ctx, name.strip(":")) except commands.BadArgument as e: logger.warning("%s non è un'emoji valida. %s.", e) raise return name async def retrieve_emoji(self) -> typing.Tuple[str, str]: sent_emoji = self.config["sent_emoji"] blocked_emoji = self.config["blocked_emoji"] if sent_emoji != "disable": try: sent_emoji = await self.convert_emoji(sent_emoji) except commands.BadArgument: logger.warning("Removed sent emoji (%s).", sent_emoji) sent_emoji = self.config.remove("sent_emoji") await self.config.update() if blocked_emoji != "disable": try: blocked_emoji = await self.convert_emoji(blocked_emoji) except commands.BadArgument: logger.warning("Rimossa emoji del bloccato (%s).", blocked_emoji) blocked_emoji = self.config.remove("blocked_emoji") await self.config.update() return sent_emoji, blocked_emoji def check_account_age(self, author: discord.Member) -> bool: account_age = self.config.get("account_age") now = datetime.utcnow() try: min_account_age = author.created_at + account_age except ValueError: logger.warning("Errore con la configurazione 'account_age'.", exc_info=True) min_account_age = author.created_at + self.config.remove( "account_age") if min_account_age > now: # User account has not reached the required time delta = human_timedelta(min_account_age) logger.debug( "L'utente %s è stato bloccato per via dell'età del suo account.", author.name) if str(author.id) not in self.blocked_users: new_reason = ( f"Messaggio di sistema: Account nuovo. È richiesto aspettare per {delta}." ) self.blocked_users[str(author.id)] = new_reason 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 try: min_guild_age = author.joined_at + guild_age except ValueError: logger.warning("Errore con la configurazione '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 delta = human_timedelta(min_guild_age) logger.debug( "L'utente %s è stato bloccato per via dell'eta dell'account", author.name) if str(author.id) not in self.blocked_users: new_reason = ( f"Messaggio di sistema: Entrato di recende. È richiesto aspettare per {delta}." ) self.blocked_users[str(author.id)] = new_reason 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, ) 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("L'utente %s non è più bloccato.", author.name) return True logger.debug("L'utente %s è stato bloccato.", author.name) return False async def _process_blocked(self, message): _, 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("L'utente %s non è nel server.", 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="Messaggio non inviato!", description=new_reason, color=self.error_color, )) return True if not self.check_manual_blocked(author): return True await self.config.update() 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("L'ultima stanza non è stata trovata, %s.", author.name) return last_log_closed_at = last_log.get("closed_at") if not last_log_closed_at: logger.debug("L'ultima stanza non è stata chiusa., %s.", author.name) return try: cooldown = datetime.fromisoformat( last_log_closed_at) + thread_cooldown except ValueError: logger.warning("Errore con la configurazione '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( "L'utente %s è stato bloccato per il cooldown della creazione di thread.", author.name, ) return delta return @staticmethod async def add_reaction(msg, reaction: discord.Reaction) -> bool: if reaction != "disable": try: await msg.add_reaction(reaction) except (discord.HTTPException, discord.InvalidArgument) as e: logger.warning( "Non è stato possibile aggiungere la reazione %s: %s.", reaction, e) return False return True async def process_dm_modmail(self, message: discord.Message) -> None: """Processa i messaggi inviati al bot.""" blocked = await self._process_blocked(message) if blocked: 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="Messaggio non inviato!", description= f"Devi aspettare {delta} prima che tu possa contattarmi di nuovo.", color=self.error_color, )) return if self.config["dm_disabled"] >= 1: embed = discord.Embed( title=self.config["disabled_new_thread_title"], color=self.error_color, description=self.config["disabled_new_thread_response"], ) embed.set_footer( text=self.config["disabled_new_thread_footer"], icon_url=self.guild.icon_url) logger.info( "Una nuova stanza da %s non è stata creata per via di funzioni Modmail disabilitate.", message.author, ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) thread = await self.threads.create(message.author) else: if self.config["dm_disabled"] == 2: embed = discord.Embed( title=self.config["disabled_current_thread_title"], color=self.error_color, description=self. config["disabled_current_thread_response"], ) embed.set_footer( text=self.config["disabled_current_thread_footer"], icon_url=self.guild.icon_url, ) logger.info( "Una nuova stanza da %s non è stata creata per via di funzioni Modmail disabilitate.", message.author, ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) try: await thread.send(message) except Exception: logger.error("Non è stato possibile inviare il messaggio:", 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): """ Returns all invocation contexts from the message. Supports getting the prefix from database as well as command aliases. """ view = StringView(message.content) ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) thread = await self.threads.find(channel=ctx.channel) if self._skip_check(message.author.id, self.user.id): return [ctx] prefixes = await self.get_prefix() invoked_prefix = discord.utils.find(view.skip_string, prefixes) if invoked_prefix is None: return [ctx] invoker = view.get_word().lower() # Check if there is any aliases being called. alias = self.aliases.get(invoker) if alias is not None: ctxs = [] aliases = normalize_alias( alias, message.content[len(f"{invoked_prefix}{invoker}"):]) if not aliases: logger.warning("L'alias %s non è valido, lo rimuovo.", invoker) self.aliases.pop(invoker) for alias in aliases: view = StringView(invoked_prefix + alias) ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) ctx_.thread = thread discord.utils.find(view.skip_string, prefixes) ctx_.invoked_with = view.get_word().lower() ctx_.command = self.all_commands.get(ctx_.invoked_with) ctxs += [ctx_] return ctxs ctx.thread = thread ctx.invoked_with = invoker ctx.command = self.all_commands.get(invoker) return [ctx] async def get_context(self, message, *, cls=commands.Context): """ Returns the invocation context from the message. Supports getting the prefix from database. """ view = StringView(message.content) ctx = cls(prefix=self.prefix, view=view, bot=self, message=message) if self._skip_check(message.author.id, self.user.id): return ctx ctx.thread = await self.threads.find(channel=ctx.channel) prefixes = await self.get_prefix() invoked_prefix = discord.utils.find(view.skip_string, prefixes) if invoked_prefix is None: return ctx invoker = view.get_word().lower() ctx.invoked_with = invoker ctx.command = self.all_commands.get(invoker) return ctx async def update_perms(self, name: typing.Union[PermissionLevel, str], value: int, add: bool = True) -> None: value = int(value) if isinstance(name, PermissionLevel): permissions = self.config["level_permissions"] name = name.name else: permissions = self.config["command_permissions"] if name not in permissions: if add: permissions[name] = [value] else: if add: if value not in permissions[name]: permissions[name].append(value) else: if value in permissions[name]: permissions[name].remove(value) logger.info("Aggiorno i permessi per %s, %s (add=%s).", name, value, add) await self.config.update() async def on_message(self, message): await self.wait_for_connected() if message.type == discord.MessageType.pins_add and message.author == self.user: await message.delete() await self.process_commands(message) 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: snippet = self.snippets[cmd] message.content = f"{self.prefix}freply {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( "Il comando %s non ha il controllo permessi, aggiungo il livello 'invalid'.", 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: if self.config.get("anon_reply_without_command"): await thread.reply(message, anonymous=True) elif self.config.get("reply_without_command"): await thread.reply(message) else: await self.api.append_log(message, type_="internal") elif ctx.invoked_with: exc = commands.CommandNotFound( 'Il comando "{}" non è stato trovato'.format( ctx.invoked_with)) self.dispatch("command_error", ctx, exc) async def on_typing(self, channel, user, _): await self.wait_for_connected() if user.bot: return if isinstance(channel, discord.DMChannel): if not self.config.get("user_typing"): return thread = await self.threads.find(recipient=user) if thread: await thread.channel.trigger_typing() else: if not self.config.get("mod_typing"): return thread = await self.threads.find(channel=channel) if thread is not None and thread.recipient: if await self.is_blocked(thread.recipient): return await thread.recipient.trigger_typing() async def handle_reaction_events(self, payload, *, add): 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, discord.Forbidden): return reaction = payload.emoji close_emoji = await self.convert_emoji(self.config["close_emoji"]) if isinstance(channel, discord.DMChannel): thread = await self.threads.find(recipient=user) if not thread: return if (add and message.embeds and str(reaction) == str(close_emoji) and self.config.get("recipient_thread_close")): ts = message.embeds[0].timestamp if thread and ts == thread.channel.created_at: # the reacted message is the corresponding thread creation embed # closing thread return await thread.close(closer=user) if not thread.recipient.dm_channel: await thread.recipient.create_dm() try: linked_message = await thread.find_linked_message_from_dm( message, either_direction=True) except ValueError as e: logger.warning( "Failed to find linked message for reactions: %s", e) return else: thread = await self.threads.find(channel=channel) if not thread: return try: _, linked_message = await thread.find_linked_messages( message.id, either_direction=True) except ValueError as e: logger.warning( "Failed to find linked message for reactions: %s", e) return if add: if await self.add_reaction(linked_message, reaction): await self.add_reaction(message, reaction) else: try: await linked_message.remove_reaction(reaction, self.user) await message.remove_reaction(reaction, self.user) except (discord.HTTPException, discord.InvalidArgument) as e: logger.warning( "Non è stato possibile rimuovere la reazione: %s", e) async def on_raw_reaction_add(self, payload): await self.handle_reaction_events(payload, add=True) async def on_raw_reaction_remove(self, payload): await self.handle_reaction_events(payload, add=False) async def on_guild_channel_delete(self, channel): if channel.guild != self.modmail_guild: return try: audit_logs = self.modmail_guild.audit_logs() entry = await audit_logs.find(lambda a: a.target == channel) mod = entry.user except AttributeError as e: # discord.py broken implementation with discord API # TODO: waiting for dpy logger.warning("Failed to retrieve audit log: %s.", e) return if mod == self.user: return if isinstance(channel, discord.CategoryChannel): if self.main_category == channel: logger.debug("La categoria del modmail è stata eliminata.") self.config.remove("main_category_id") await self.config.update() return if not isinstance(channel, discord.TextChannel): return if self.log_channel is None or self.log_channel == channel: logger.info("Il canale di log è stato eliminato.") self.config.remove("log_channel_id") await self.config.update() return thread = await self.threads.find(channel=channel) if thread and thread.channel == channel: logger.debug("Il canale %s è stato eliminato manualmente.", channel.name) await thread.close(closer=mod, silent=True, delete_channel=False) async def on_member_remove(self, member): if member.guild != self.guild: return thread = await self.threads.find(recipient=member) if thread: embed = discord.Embed(description="L'utente è uscito dal server.", color=self.error_color) await thread.channel.send(embed=embed) async def on_member_join(self, member): if member.guild != self.guild: return thread = await self.threads.find(recipient=member) if thread: embed = discord.Embed(description="L'utente è entrato nel server.", color=self.mod_color) await thread.channel.send(embed=embed) async def on_message_delete(self, message): """Support for deleting linked messages""" # TODO: use audit log to check if modmail deleted the message if isinstance(message.channel, discord.DMChannel): thread = await self.threads.find(recipient=message.author) if not thread: return try: message = await thread.find_linked_message_from_dm(message) except ValueError as e: if str(e) != "Thread channel message not found.": logger.warning( "Failed to find linked message to delete: %s", e) return embed = message.embeds[0] embed.set_footer(text=f"{embed.footer.text} (deleted)", icon_url=embed.footer.icon_url) await message.edit(embed=embed) return thread = await self.threads.find(channel=message.channel) if not thread: return try: await thread.delete_message(message, note=False) except ValueError as e: if str(e) not in { "DM message not found.", "Malformed thread message." }: logger.warning("Failed to find linked message to delete: %s", e) return except discord.NotFound: return async def on_bulk_message_delete(self, messages): await discord.utils.async_all( self.on_message_delete(msg) for msg in messages) async def on_message_edit(self, before, after): if after.author.bot: return if before.content == after.content: return if isinstance(after.channel, discord.DMChannel): thread = await self.threads.find(recipient=before.author) if not thread: return try: await thread.edit_dm_message(after, after.content) except ValueError: _, blocked_emoji = await self.retrieve_emoji() await self.add_reaction(after, blocked_emoji) else: embed = discord.Embed( description="Messaggio modificato con successo", color=self.main_color) embed.set_footer(text=f"ID messaggio: {after.id}") await after.channel.send(embed=embed) async def on_error(self, event_method, *args, **kwargs): logger.error("Ignoro l'errore i %s.", event_method) logger.error("Errore inaspettato:", exc_info=sys.exc_info()) async def on_command_error(self, context, exception): if isinstance(exception, commands.BadUnionArgument): msg = "Could not find the specified " + human_join( [c.__name__ for c in exception.converters]) await context.trigger_typing() await context.send( embed=discord.Embed(color=self.error_color, description=msg)) elif isinstance(exception, commands.BadArgument): await context.trigger_typing() await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) elif isinstance(exception, commands.MissingRequiredArgument): await context.send_help(context.command) elif isinstance(exception, commands.CheckFailure): for check in context.command.checks: if not await check(context): if hasattr(check, "fail_msg"): await context.send(embed=discord.Embed( color=self.error_color, description=check.fail_msg) ) if hasattr(check, "permission_level"): corrected_permission_level = self.command_perm( context.command.qualified_name) logger.warning( "L'utente %s non ha il permesso necessario per usare il seguente comando: `%s` (%s).", context.author.name, context.command.qualified_name, corrected_permission_level.name, ) logger.warning("CheckFailure: %s", exception) else: logger.error("Errore inaspettato:", exc_info=exception) async def validate_database_connection(self): try: await self.db.command("buildinfo") except Exception as exc: logger.critical("Non sono riuscito a connettermi al database.") message = f"{type(exc).__name__}: {str(exc)}" logger.critical(message) if "ServerSelectionTimeoutError" in message: logger.critical( "Questo potrebbe essere causato perchè non " "hai messo correttamente gli IP in whitelist. Assicurati di aver messo nella whitelist " "tutti gli IP (0.0.0.0/0) come nella foto: https://i.imgur.com/mILuQ5U.png" ) if "OperationFailure" in message: logger.critical( "Questo è per via delle credenziali nella configurazione MONGO_URI non valide. " "Ricorda di sostituire `<password>` con la password che hai messo." ) logger.critical( "Assicurati di URL encodare il tuo nome utente e password (non l'URL intero!), " "https://www.urlencoder.io/, se il problema persiste, prova a cambiare nome utente e password " "in modo che includa solo lettere alfanumeriche, senza simboli." "") raise else: logger.debug("Connesso al database.") logger.line("debug") async def post_metadata(self): owner = (await self.application_info()).owner data = { "owner_name": str(owner), "owner_id": owner.id, "bot_id": self.user.id, "bot_name": str(self.user), "avatar_url": str(self.user.avatar_url), "guild_id": self.guild_id, "guild_name": self.guild.name, "member_count": len(self.guild.members), "uptime": (datetime.utcnow() - self.start_time).total_seconds(), "latency": f"{self.ws.latency * 1000:.4f}", "version": str(self.version), "selfhosted": True, "last_updated": str(datetime.utcnow()), } async with self.session.post("https://api.logviewer.tech/metadata", json=data): logger.debug("Sto caricando i metadati ai server Modmail.") async def before_post_metadata(self): await self.wait_for_connected() logger.debug("Avvio del metadata loop.") logger.line("debug") if not self.guild: self.metadata_loop.cancel()