├── .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 |
--------------------------------------------------------------------------------