├── .flake8 ├── .gitignore ├── LICENSE ├── README.md ├── autorole └── autorole.py ├── embedder └── embedder.py ├── purger └── purger.py ├── role-assignment └── role-assignment.py ├── stale-alert └── stale-alert.py └── supporters └── supporters.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | 3 | # flake8-import-order 4 | application-import-names = core 5 | 6 | # flake8-annotations 7 | extend-ignore = 8 | ANN101, # missing type annotation for self in method 9 | ANN201, # missing return type annotation for public function 10 | ANN204, # missing return type annotation for special method 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 Robin Mahieu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modmail Plugins 2 | 3 | This repository hosts several custom plugins for Modmail. 4 | 5 | ## Mod-what? 6 | 7 | Modmail is a bot for [Discord][discord] that provides a shared inbox for server staff and regular members to communicate with each other. 8 | 9 | These plugins extend the functionality of the [https://github.com/modmail-dev/Modmail][modmail] adaptation, by providing additional commands. 10 | 11 | Currently, all of the plugins support Modmail version 4.0.0 and higher. 12 | 13 | ## Plugins 14 | 15 | Each plugin has a distinct purpose, as described below. After installing one of the plugins, a dedicated page in the help menu provides more information about its commands. 16 | 17 | You can install a plugin by using the following command. 18 | 19 | ```sh 20 | ?plugins add robinmahieu/modmail-plugins/plugin-name@stardust 21 | ``` 22 | 23 | Make sure to replace the `plugin-name` dummy variable with a valid plugin name, like `autorole`, `embedder`, `purger`, `role-assignment` or `supporters`. Keep in mind that the default branch of this repository has an unconventional name and should be stated explicitly. If not, an `InvalidPluginError` is raised when trying to install one of these plugins. 24 | 25 | ### Autorole 26 | 27 | This plugin is intended to assign roles to members when they join the server. 28 | 29 | ### Embedder 30 | 31 | This plugin is intended to easily embed text. 32 | 33 | ### Purger 34 | 35 | This plugin is intended to delete multiple messages at once. 36 | 37 | ### Role Assignment 38 | 39 | This plugin is intended to assign roles by clicking reactions. 40 | 41 | Please note that this plugin does not provide the usual reaction roles. Instead, it allows server staff to assign roles to regular members when they open a thread. This could be useful when roles are only supposed to be assigned after explicit approval. 42 | 43 | ### Supporters 44 | 45 | This plugin is intended to view which members are part of the support team. 46 | 47 | ## Contributing 48 | 49 | This project is licensed under the terms of the [MIT][mit-license] license. 50 | 51 | [discord]: 52 | [mit-license]: 53 | [modmail]: 54 | -------------------------------------------------------------------------------- /autorole/autorole.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | from core import checks 5 | from core.models import PermissionLevel, getLogger 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | class Autorole(commands.Cog): 11 | """Plugin to assign roles to members when they join the server.""" 12 | 13 | def __init__(self, bot: commands.Bot): 14 | self.bot = bot 15 | 16 | self.db = self.bot.api.get_plugin_partition(self) 17 | 18 | @commands.Cog.listener() 19 | async def on_member_join(self, member: discord.Member): 20 | """Function that executes when a member joins a server. 21 | 22 | It looks for an autorole configuration file in the database. If 23 | one is found, the conigured set of roles will be assigned to 24 | the new member. 25 | """ 26 | if member.guild.id != self.bot.guild_id: 27 | return 28 | 29 | config = await self.db.find_one({"_id": "autorole-config"}) 30 | 31 | if not config: 32 | return 33 | 34 | try: 35 | role_ids = config["roles"] 36 | except KeyError: 37 | return logger.error( 38 | "Something went wrong in the database! The `roles` field " 39 | "could not be found in the configuration file." 40 | ) 41 | 42 | if not isinstance(role_ids, list): 43 | return logger.error( 44 | "Something went wrong in the database! The `roles` field " 45 | "in the configuration file has an invalid format." 46 | ) 47 | 48 | roles = [ 49 | role 50 | for role_id in role_ids 51 | if (role := member.guild.get_role(role_id)) 52 | ] 53 | 54 | await member.add_roles(*roles) 55 | 56 | logger.debug(f"Added configured roles to new member {member}.") 57 | 58 | @commands.group(name="autorole", invoke_without_command=True) 59 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 60 | async def autorole(self, ctx: commands.Context): 61 | """Assign roles to members when they join the server.""" 62 | 63 | await ctx.send_help(ctx.command) 64 | 65 | @autorole.command(name="set") 66 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 67 | async def autorole_set( 68 | self, ctx: commands.Context, roles: commands.Greedy[discord.Role] 69 | ): 70 | """Set the roles to assign to new server members.""" 71 | 72 | if not roles: 73 | return await ctx.send_help(ctx.command) 74 | 75 | config = await self.db.find_one({"_id": "autorole-config"}) 76 | 77 | if not config: 78 | await self.db.insert_one({"_id": "autorole-config"}) 79 | 80 | role_ids = [role.id for role in roles] 81 | role_mentions = [role.mention for role in roles] 82 | 83 | await self.db.find_one_and_update( 84 | {"_id": "autorole-config"}, {"$set": {"roles": role_ids}} 85 | ) 86 | 87 | embed = discord.Embed(title="Autorole", color=self.bot.main_color) 88 | 89 | embed.description = ( 90 | f"{', '.join(role_mentions)} will now be assigned to new server " 91 | "members." 92 | ) 93 | 94 | await ctx.send(embed=embed) 95 | 96 | @autorole.command(name="give") 97 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 98 | async def autorole_give(self, ctx: commands.Context, role: discord.Role): 99 | """Assign a role to all current members of the server.""" 100 | 101 | members = [ 102 | member 103 | for member in ctx.guild.members 104 | if member not in role.members 105 | ] 106 | 107 | s = "" if len(members) == 1 else "s" 108 | 109 | embed = discord.Embed(title="Autorole", color=self.bot.main_color) 110 | 111 | embed.description = ( 112 | f"Adding {role.mention} to {len(members)} member{s}!\n" 113 | "Please note that this operation could take a while." 114 | ) 115 | 116 | await ctx.send(embed=embed) 117 | 118 | for member in members: 119 | await member.add_roles(role) 120 | 121 | embed = discord.Embed( 122 | title="Autorole", 123 | description=f"Added {role.mention} to {len(members)} member{s}!", 124 | colour=self.bot.main_color, 125 | ) 126 | 127 | await ctx.send(embed=embed) 128 | 129 | @autorole.command(name="clear", aliases=["reset"]) 130 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 131 | async def autorole_clear(self, ctx: commands.Context): 132 | """Clear the list of roles to assign to new server members.""" 133 | 134 | embed = discord.Embed( 135 | title="Autorole", 136 | description="No roles will be assigned to new server members.", 137 | color=self.bot.main_color, 138 | ) 139 | 140 | config = await self.db.find_one({"_id": "autorole-config"}) 141 | 142 | if not config: 143 | return await ctx.send(embed=embed) 144 | 145 | await self.db.find_one_and_update( 146 | {"_id": "autorole-config"}, {"$set": {"roles": []}} 147 | ) 148 | 149 | await ctx.send(embed=embed) 150 | 151 | 152 | async def setup(bot: commands.Bot): 153 | await bot.add_cog(Autorole(bot)) 154 | -------------------------------------------------------------------------------- /embedder/embedder.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | 4 | import discord 5 | from discord.ext import commands 6 | 7 | from core import checks 8 | from core.models import PermissionLevel 9 | 10 | 11 | class Embedder(commands.Cog): 12 | """Plugin to easily embed text.""" 13 | 14 | def __init__(self, bot: commands.Bot): 15 | self.bot = bot 16 | 17 | self.db = self.bot.api.get_plugin_partition(self) 18 | 19 | @commands.group(name="embedder", invoke_without_command=True) 20 | @checks.has_permissions(PermissionLevel.MODERATOR) 21 | async def embedder(self, ctx: commands.Context): 22 | """Easily embed text.""" 23 | 24 | await ctx.send_help(ctx.command) 25 | 26 | @embedder.command(name="color", aliases=["colour"]) 27 | @checks.has_permissions(PermissionLevel.MODERATOR) 28 | async def color(self, ctx: commands.Context, colorcode: str): 29 | """Save a hex code for use in embeds.""" 30 | 31 | is_valid = re.search(r"^#(?:[0-9a-fA-F]{3}){1,2}$", colorcode) 32 | 33 | if not is_valid: 34 | link = "https://htmlcolorcodes.com/color-picker" 35 | 36 | embed = discord.Embed( 37 | title="Embedder", 38 | description=f"Enter a valid [hex code]({link}).", 39 | color=self.bot.main_color, 40 | ) 41 | 42 | return await ctx.send(embed=embed) 43 | 44 | color = discord.Color(int(colorcode.replace("#", "0x"), 0)) 45 | 46 | await self.db.find_one_and_update( 47 | {"_id": "embedcolor-config"}, 48 | {"$set": {"colorcode": colorcode.replace("#", "0x").lower()}}, 49 | upsert=True, 50 | ) 51 | 52 | embed = discord.Embed( 53 | title="Embedder", 54 | description=f"`{color}` will be used for every future embed.", 55 | color=color, 56 | ) 57 | 58 | await ctx.send(embed=embed) 59 | 60 | @embedder.command(name="send", aliases=["make"]) 61 | @checks.has_permissions(PermissionLevel.MODERATOR) 62 | async def send(self, ctx: commands.Context, title: str, *, message: str): 63 | """Send an embed.""" 64 | 65 | config = await self.db.find_one({"_id": "embedcolor-config"}) 66 | 67 | if config: 68 | colorcode = config.get("colorcode", str(discord.Color.blue())) 69 | else: 70 | colorcode = str(discord.Color.blue()) 71 | 72 | embed = discord.Embed( 73 | title=title, 74 | description=message, 75 | color=discord.Color(int(colorcode.replace("#", "0x"), 0)), 76 | timestamp=datetime.datetime.utcnow(), 77 | ) 78 | 79 | embed.set_author( 80 | name=ctx.author.display_name, icon_url=ctx.author.avatar.url 81 | ) 82 | 83 | await ctx.send(embed=embed) 84 | 85 | await ctx.message.delete() 86 | 87 | 88 | async def setup(bot: commands.Bot): 89 | await bot.add_cog(Embedder(bot)) 90 | -------------------------------------------------------------------------------- /purger/purger.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | from core import checks 5 | from core.models import PermissionLevel, getLogger 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | class Purger(commands.Cog): 11 | """Plugin to delete multiple messages at once.""" 12 | 13 | def __init__(self, bot: commands.Bot): 14 | self.bot = bot 15 | 16 | @commands.command() 17 | @checks.has_permissions(PermissionLevel.MODERATOR) 18 | async def purge(self, ctx: commands.Context, amount: int): 19 | """Delete multiple messages at once.""" 20 | 21 | if amount < 1: 22 | raise commands.BadArgument( 23 | "The amount of messages to delete should be a scrictly " 24 | f"positive integer, not `{amount}`." 25 | ) 26 | 27 | try: 28 | deleted = await ctx.channel.purge(limit=amount + 1) 29 | except discord.Forbidden: 30 | embed = discord.Embed(color=self.bot.error_color) 31 | 32 | embed.description = ( 33 | "This command requires the `Manage Messages` permission, " 34 | "which the bot does not have at the moment." 35 | ) 36 | 37 | return await ctx.send(embed=embed) 38 | 39 | logger.debug( 40 | f"{ctx.author} purged {len(deleted)} messages in the " 41 | f"#{ctx.channel} channel." 42 | ) # len(deleted) >= 2 so no plural checks necessary 43 | 44 | message = f"{len(deleted)} messages have been deleted!" 45 | to_delete = await ctx.send(message) 46 | 47 | await to_delete.delete(delay=3) 48 | 49 | 50 | async def setup(bot: commands.Bot): 51 | await bot.add_cog(Purger(bot)) 52 | -------------------------------------------------------------------------------- /role-assignment/role-assignment.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import discord 4 | from discord.ext import commands 5 | 6 | from core import checks 7 | from core.models import PermissionLevel, getLogger 8 | 9 | logger = getLogger(__name__) 10 | 11 | 12 | class RoleAssignment(commands.Cog): 13 | """Plugin to assign roles by clicking reactions.""" 14 | 15 | def __init__(self, bot: commands.Bot): 16 | self.bot = bot 17 | 18 | self.db = self.bot.api.get_plugin_partition(self) 19 | 20 | asyncio.create_task(self.remove_obsolete_ids()) 21 | 22 | async def remove_obsolete_ids(self): 23 | """Function that gets invoked whenever this plugin is loaded. 24 | 25 | It will look for a configuration file in the database and 26 | remove message IDs that no longer exist, in order to prevent 27 | them from cluttering the database. 28 | """ 29 | config = await self.db.find_one({"_id": "role-config"}) 30 | 31 | if config is None: 32 | return 33 | 34 | category_id = int(self.bot.config["main_category_id"] or 0) 35 | 36 | if category_id == 0: 37 | logger.warning("No main_category_id set.") 38 | return 39 | 40 | guild = self.bot.modmail_guild 41 | 42 | if guild is None: 43 | logger.warning("No guild_id set.") 44 | return 45 | 46 | category = discord.utils.get(guild.categories, id=category_id) 47 | 48 | if category is None: 49 | logger.warning("Invalid main_category_id set.") 50 | 51 | message_ids = [] 52 | 53 | for channel in category.text_channels: 54 | thread = await self.bot.threads.find(channel=channel) 55 | 56 | if thread is None: 57 | continue 58 | 59 | if thread._genesis_message is None: 60 | history = channel.history(oldest_first=True) 61 | thread._genesis_message = [ 62 | message async for message in history 63 | ][0] 64 | 65 | message_ids.append(str(thread._genesis_message.id)) 66 | 67 | await self.db.find_one_and_update( 68 | {"_id": "role-config"}, {"$set": {"ids": message_ids}} 69 | ) 70 | 71 | @commands.group( 72 | name="role", aliases=["roles"], invoke_without_command=True 73 | ) 74 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 75 | async def role(self, ctx): 76 | """Assign roles by clicking a reaction.""" 77 | 78 | await ctx.send_help(ctx.command) 79 | 80 | @role.command(name="add") 81 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 82 | async def role_add(self, ctx, emoji: discord.Emoji, *, role: discord.Role): 83 | """Add a reaction to each new thread.""" 84 | 85 | config = await self.db.find_one({"_id": "role-config"}) 86 | 87 | if config is None: 88 | await self.db.insert_one( 89 | {"_id": "role-config", "emoji": {}, "ids": []} 90 | ) 91 | config = await self.db.find_one({"_id": "role-config"}) 92 | 93 | failed = config["emoji"].get(str(emoji)) is not None 94 | 95 | if failed: 96 | return await ctx.send("That emoji already assigns a role.") 97 | 98 | config["emoji"][str(emoji)] = role.name 99 | 100 | await self.db.update_one( 101 | {"_id": "role-config"}, {"$set": {"emoji": config["emoji"]}} 102 | ) 103 | 104 | await ctx.send(f"{emoji} will now assign the {role.name} role.") 105 | 106 | @role.command(name="remove") 107 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 108 | async def role_remove(self, ctx, emoji: discord.Emoji): 109 | """Remove a reaction from each new thread.""" 110 | 111 | config = await self.db.find_one({"_id": "role-config"}) 112 | 113 | if config is None: 114 | return await ctx.send("There are no roles set up at the moment.") 115 | 116 | config["emoji"] 117 | 118 | try: 119 | del config["emoji"][str(emoji)] 120 | except KeyError: 121 | return await ctx.send("That emoji doesn't assign any role.") 122 | 123 | await self.db.update_one( 124 | {"_id": "role-config"}, {"$set": {"emoji": config["emoji"]}} 125 | ) 126 | 127 | await ctx.send(f"The {emoji} emoji has been unlinked.") 128 | 129 | @role.command(name="list") 130 | @checks.has_permissions(PermissionLevel.ADMINISTRATOR) 131 | async def role_list(self, ctx): 132 | """View a list of reactions added to each new thread.""" 133 | 134 | config = await self.db.find_one({"_id": "role-config"}) 135 | 136 | if config is None: 137 | return await ctx.send("There are no roles set up at the moment.") 138 | 139 | embed = discord.Embed( 140 | title="Role Assignment", color=self.bot.main_color, description="" 141 | ) 142 | 143 | for emoji, role_name in config["emoji"].items(): 144 | role = discord.utils.get(self.bot.guild.roles, name=role_name) 145 | 146 | embed.description += f"{emoji} — {role.mention}\n" 147 | 148 | await ctx.send(embed=embed) 149 | 150 | @commands.Cog.listener() 151 | async def on_thread_ready( 152 | self, thread, creator, category, initial_message 153 | ): 154 | """Function that gets invoked whenever a new thread is created. 155 | 156 | It will look for a configuration file in the database and add 157 | all emoji as reactions to the _genesis message. Furthermore, it 158 | will update the list of _genesis message IDs. 159 | """ 160 | message = thread._genesis_message 161 | 162 | config = await self.db.find_one({"_id": "role-config"}) 163 | 164 | if config is None: 165 | return 166 | 167 | for emoji in config["emoji"].keys(): 168 | stripped_emoji = emoji.strip( 169 | "<:>" 170 | ) # unannounced Discord API breaking change >:( 171 | await message.add_reaction(stripped_emoji) 172 | 173 | config["ids"].append(str(message.id)) 174 | 175 | await self.db.find_one_and_update( 176 | {"_id": "role-config"}, {"$set": {"ids": config["ids"]}} 177 | ) 178 | 179 | @commands.Cog.listener() 180 | async def on_raw_reaction_add( 181 | self, payload: discord.RawReactionActionEvent 182 | ): 183 | """Function that gets invoked whenever a reaction is added. 184 | 185 | It will look for a configuration file in the database and 186 | update the member's role according to the added emoji. 187 | """ 188 | config = await self.db.find_one({"_id": "role-config"}) 189 | 190 | if config is None: 191 | return 192 | 193 | if str(payload.message_id) not in config["ids"]: 194 | return 195 | 196 | if str(payload.emoji) not in config["emoji"].keys(): 197 | payload.emoji.animated = True 198 | 199 | if str(payload.emoji) not in config["emoji"].keys(): 200 | return 201 | 202 | if payload.user_id == self.bot.user.id: 203 | return 204 | 205 | channel = self.bot.get_channel(payload.channel_id) 206 | thread = await self.bot.threads.find(channel=channel) 207 | 208 | if thread is None: 209 | return 210 | 211 | user = thread.recipient 212 | 213 | if not isinstance(user, int): 214 | user = user.id 215 | 216 | member = self.bot.guild.get_member(user) 217 | 218 | role_name = config["emoji"][str(payload.emoji)] 219 | role = discord.utils.get(self.bot.guild.roles, name=role_name) 220 | 221 | if role is None: 222 | message = ( 223 | f"The role associated with {payload.emoji} ({role_name}) " 224 | "could not be found." 225 | ) 226 | 227 | await channel.send(message) 228 | 229 | await member.add_roles(role) 230 | 231 | await channel.send(f"The {role} role has been added to {member}.") 232 | 233 | @commands.Cog.listener() 234 | async def on_raw_reaction_remove( 235 | self, payload: discord.RawReactionActionEvent 236 | ): 237 | """Function that gets invoked whenever a reaction is removed. 238 | 239 | It will look for a configuration file in the database and 240 | update the member's role according to the removed emoji. 241 | """ 242 | config = await self.db.find_one({"_id": "role-config"}) 243 | 244 | if config is None: 245 | return 246 | 247 | if str(payload.message_id) not in config["ids"]: 248 | return 249 | 250 | if str(payload.emoji) not in config["emoji"].keys(): 251 | payload.emoji.animated = True 252 | 253 | if str(payload.emoji) not in config["emoji"].keys(): 254 | return 255 | 256 | if payload.user_id == self.bot.user.id: 257 | return 258 | 259 | channel = self.bot.get_channel(payload.channel_id) 260 | thread = await self.bot.threads.find(channel=channel) 261 | 262 | if thread is None: 263 | return 264 | 265 | user = thread.recipient 266 | 267 | if not isinstance(user, int): 268 | user = user.id 269 | 270 | member = self.bot.guild.get_member(user) 271 | 272 | role_name = config["emoji"][str(payload.emoji)] 273 | role = discord.utils.get(self.bot.guild.roles, name=role_name) 274 | 275 | if role is None: 276 | await channel.send( 277 | f"The role associated with {payload.emoji} ({role_name}) " 278 | "could not be found." 279 | ) 280 | 281 | await member.remove_roles(role) 282 | 283 | await channel.send(f"The {role} role has been removed from {member}.") 284 | 285 | 286 | async def setup(bot: commands.Bot): 287 | await bot.add_cog(RoleAssignment(bot)) 288 | -------------------------------------------------------------------------------- /stale-alert/stale-alert.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Union 3 | 4 | import discord 5 | from discord.ext import commands, tasks 6 | 7 | from core import checks 8 | from core.models import PermissionLevel, getLogger 9 | from core.time import UserFriendlyTime 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | class StaleAlert(commands.Cog): 15 | """Plugin to alert when tickets are going stale.""" 16 | 17 | def __init__(self, bot: commands.Bot): 18 | self.bot = bot 19 | 20 | self.db = self.bot.api.get_plugin_partition(self) 21 | 22 | async def cog_load(self): 23 | self.check_threads_loop.start() 24 | 25 | @tasks.loop(minutes=5) 26 | async def check_threads_loop(self): 27 | """Function that executes every five minutes. 28 | 29 | It checks every open thread to see if the last sent message in 30 | the channel was sent earlier than the configured time duration. 31 | If so, it will send the configured alert message and make a 32 | note entry in the logs. 33 | """ 34 | config = await self.db.find_one({"_id": "stale-alert-config"}) 35 | 36 | if not config: 37 | return 38 | 39 | try: 40 | duration = config["duration"] 41 | except KeyError: 42 | return logger.error( 43 | "Something went wrong in the database! The `duration` field " 44 | "could not be found in the configuration file." 45 | ) 46 | 47 | message = config.get("message", "alert") 48 | ignore = config.get("ignore", []) 49 | 50 | open_threads = await self.bot.api.get_open_logs() 51 | 52 | counter = 0 53 | 54 | for thread in open_threads: 55 | most_recent_message = None 56 | 57 | for thread_message in thread["messages"]: 58 | if thread_message["type"] == "thread_message" or ( 59 | thread_message["type"] == "system" 60 | and int(thread_message["author"]["id"]) == self.bot.user.id 61 | ): 62 | most_recent_message = thread_message 63 | 64 | if ( 65 | thread_message["type"] == "thread_message" 66 | and most_recent_message["author"]["mod"] 67 | ): 68 | continue 69 | 70 | timestamp = datetime.datetime.fromisoformat( 71 | most_recent_message["timestamp"] 72 | ).astimezone(datetime.timezone.utc) 73 | 74 | delta = (discord.utils.utcnow() - timestamp).total_seconds() 75 | 76 | if delta > duration: 77 | channel = self.bot.get_channel(int(thread["channel_id"])) 78 | recipient = self.bot.get_user(int(thread["recipient"]["id"])) 79 | 80 | if not channel: 81 | logger.warning( 82 | "Found an open thread without a valid channel ID: " 83 | f"{thread['key']}." 84 | ) 85 | continue 86 | 87 | if not recipient: 88 | logger.warning( 89 | "Found an open thread without a valid recipient ID: " 90 | f"{thread['key']}." 91 | ) 92 | continue 93 | 94 | if channel.id in ignore or channel.category.id in ignore: 95 | continue 96 | 97 | sent_message = await channel.send(message) 98 | await self.bot.api.append_log(sent_message, type_="system") 99 | 100 | counter += 1 101 | 102 | logger.debug(f"Sent {counter} stale alert(s).") 103 | 104 | @check_threads_loop.before_loop 105 | async def before_check_threads_loop(self): 106 | await self.bot.wait_for_connected() 107 | 108 | @commands.group(name="stale", invoke_without_command=True) 109 | @checks.has_permissions(PermissionLevel.SUPPORTER) 110 | async def stale(self, ctx: commands.Context): 111 | """Alert when tickets are going stale.""" 112 | 113 | await ctx.send_help(ctx.command) 114 | 115 | @stale.command(name="ignore") 116 | @checks.has_permissions(PermissionLevel.SUPPORTER) 117 | async def stale_ignore( 118 | self, 119 | ctx: commands.Context, 120 | *, 121 | channel: Union[discord.TextChannel, discord.CategoryChannel] = None, 122 | ): 123 | """Disable the stale alerts in a certain channel or category.""" 124 | 125 | if not channel: 126 | channel = ctx.channel 127 | 128 | config = await self.db.find_one({"_id": "stale-alert-config"}) 129 | 130 | if not config: 131 | await self.db.insert_one({"_id": "stale-alert-config"}) 132 | 133 | ignore_list = config.get("ignore", []) 134 | 135 | if channel.id in ignore_list: 136 | return await ctx.send( 137 | "That channel or category is already being ignored." 138 | ) 139 | 140 | ignore_list.append(channel.id) 141 | 142 | await self.db.find_one_and_update( 143 | {"_id": "stale-alert-config"}, {"$set": {"ignore": ignore_list}} 144 | ) 145 | 146 | message = f"The <#{channel.id}> channel" 147 | 148 | if channel == ctx.channel: 149 | message = "This channel" 150 | 151 | if isinstance(channel, discord.CategoryChannel): 152 | message = f"The {channel.name} category" 153 | 154 | embed = discord.Embed( 155 | title="Stale Alert", 156 | color=self.bot.main_color, 157 | description=f"{message} is now ignored.", 158 | ) 159 | 160 | await ctx.send(embed=embed) 161 | 162 | @stale.command(name="unignore") 163 | @checks.has_permissions(PermissionLevel.SUPPORTER) 164 | async def stale_unignore( 165 | self, 166 | ctx: commands.Context, 167 | *, 168 | channel: Union[discord.TextChannel, discord.CategoryChannel] = None, 169 | ): 170 | """Re-enable the stale alerts in a certain channel or category.""" 171 | 172 | if not channel: 173 | channel = ctx.channel 174 | 175 | config = await self.db.find_one({"_id": "stale-alert-config"}) 176 | 177 | if not config: 178 | await self.db.insert_one({"_id": "stale-alert-config"}) 179 | 180 | ignore_list = config.get("ignore", []) 181 | 182 | if channel.id not in ignore_list: 183 | return await ctx.send( 184 | "That channel or category is not being ignored." 185 | ) 186 | 187 | ignore_list.remove(channel.id) 188 | 189 | await self.db.find_one_and_update( 190 | {"_id": "stale-alert-config"}, {"$set": {"ignore": ignore_list}} 191 | ) 192 | 193 | message = f"The <#{channel.id}> channel" 194 | 195 | if channel == ctx.channel: 196 | message = "This channel" 197 | 198 | if isinstance(channel, discord.CategoryChannel): 199 | message = f"The {channel.name} category" 200 | 201 | embed = discord.Embed( 202 | title="Stale Alert", 203 | color=self.bot.main_color, 204 | description=f"{message} is no longer ignored.", 205 | ) 206 | 207 | await ctx.send(embed=embed) 208 | 209 | @stale.command(name="message") 210 | @checks.has_permissions(PermissionLevel.MODERATOR) 211 | async def stale_message( 212 | self, ctx: commands.Context, *, message: str = None 213 | ): 214 | """Set the message to send when a ticket is considered stale.""" 215 | 216 | if not message: 217 | return await ctx.send_help(ctx.command) 218 | 219 | config = await self.db.find_one({"_id": "stale-alert-config"}) 220 | 221 | if not config: 222 | await self.db.insert_one({"_id": "stale-alert-config"}) 223 | 224 | await self.db.find_one_and_update( 225 | {"_id": "stale-alert-config"}, {"$set": {"message": message}} 226 | ) 227 | 228 | embed = discord.Embed( 229 | title="Stale Alert", 230 | color=self.bot.main_color, 231 | description=f"The alert message was set to `{message}`.", 232 | ) 233 | 234 | await ctx.send(embed=embed) 235 | 236 | @stale.command(name="time") 237 | @checks.has_permissions(PermissionLevel.MODERATOR) 238 | async def stale_time( 239 | self, ctx: commands.Context, *, duration: UserFriendlyTime = None 240 | ): 241 | """Set the time before a ticket is considered stale.""" 242 | 243 | if not duration: 244 | return await ctx.send_help(ctx.command) 245 | 246 | config = await self.db.find_one({"_id": "stale-alert-config"}) 247 | 248 | if not config: 249 | await self.db.insert_one({"_id": "stale-alert-config"}) 250 | 251 | seconds = (duration.dt - duration.now).total_seconds() 252 | 253 | await self.db.find_one_and_update( 254 | {"_id": "stale-alert-config"}, {"$set": {"duration": seconds}} 255 | ) 256 | 257 | embed = discord.Embed( 258 | title="Stale Alert", 259 | color=self.bot.main_color, 260 | description=f"The time duration was set to {seconds} seconds.", 261 | ) 262 | 263 | await ctx.send(embed=embed) 264 | 265 | 266 | async def setup(bot: commands.Bot): 267 | await bot.add_cog(StaleAlert(bot)) 268 | -------------------------------------------------------------------------------- /supporters/supporters.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | from core import checks 5 | from core.models import PermissionLevel 6 | 7 | 8 | class Supporters(commands.Cog): 9 | """Plugin to view which members are part of the support team.""" 10 | 11 | def __init__(self, bot: commands.Bot): 12 | self.bot = bot 13 | 14 | @commands.command(aliases=["helpers", "supporters", "supportmembers"]) 15 | @checks.has_permissions(PermissionLevel.REGULAR) 16 | async def support(self, ctx: commands.Context): 17 | """View which members are part of the support team.""" 18 | 19 | category = self.bot.main_category 20 | 21 | if category is None: 22 | description = ( 23 | "The Modmail category could not be found.\nPlease make sure " 24 | "that it has been set correctly with the `?config set " 25 | "main_category_id` command." 26 | ) 27 | 28 | embed = discord.Embed( 29 | title="Supporters", 30 | description=description, 31 | color=self.bot.main_color, 32 | ) 33 | 34 | return await ctx.send(embed=embed) 35 | 36 | members = { 37 | "online": [], 38 | "idle": [], 39 | "dnd": [], 40 | "offline": [], 41 | } 42 | 43 | status_fmt = { 44 | "online": "Online 🟢", 45 | "idle": "Idle 🟡", 46 | "dnd": "Do Not Disturb 🔴", 47 | "offline": "Offline ⚪", 48 | } 49 | 50 | for member in self.bot.modmail_guild.members: 51 | if ( 52 | category.permissions_for(member).read_messages 53 | and not member.bot 54 | ): 55 | members[str(member.status)].append(member.mention) 56 | 57 | embed = discord.Embed( 58 | title="Support Members", color=self.bot.main_color 59 | ) 60 | 61 | for status, member_list in members.items(): 62 | if member_list: 63 | embed.add_field( 64 | name=status_fmt[status], value=", ".join(member_list) 65 | ) 66 | 67 | await ctx.send(embed=embed) 68 | 69 | 70 | async def setup(bot: commands.Bot): 71 | await bot.add_cog(Supporters(bot)) 72 | --------------------------------------------------------------------------------