Skip to content

Commit 49150df

Browse files
committed
Plugin
1 parent ccc571c commit 49150df

File tree

6 files changed

+194
-31
lines changed

6 files changed

+194
-31
lines changed

bot.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
__version__ = '2.11.0'
2626

2727
import asyncio
28-
import uvloop
2928
import textwrap
3029
import datetime
3130
import os
@@ -46,6 +45,8 @@
4645
from core.config import ConfigManager
4746
from core.changelog import ChangeLog
4847

48+
if os.name != 'nt':
49+
import uvloop
4950

5051
init()
5152

@@ -80,13 +81,13 @@ def _add_commands(self):
8081
'┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘', sep='\n')
8182
print(f'v{__version__}')
8283
print('Authors: kyb3r, fourjr' + Style.RESET_ALL)
83-
print(line + Fore.CYAN)
84+
print(line)
8485

8586
for file in os.listdir('cogs'):
8687
if not file.endswith('.py'):
8788
continue
8889
cog = f'cogs.{file[:-3]}'
89-
print(f'Loading {cog}')
90+
print(Fore.CYAN + f'Loading {cog}' + Style.RESET_ALL)
9091
self.load_extension(cog)
9192

9293
async def logout(self):
@@ -100,7 +101,7 @@ def run(self):
100101
super().run(self.token)
101102
finally:
102103
print(Fore.RED + ' - Shutting down bot' + Style.RESET_ALL)
103-
104+
104105
@property
105106
def log_channel(self):
106107
channel_id = self.config.get('log_channel_id')
@@ -203,7 +204,7 @@ async def on_connect(self):
203204
print('Mode: Selfhosting logs.')
204205
print(line)
205206
print(Fore.CYAN + 'Connected to gateway.')
206-
207+
207208
await self.config.refresh()
208209

209210
activity_type = self.config.get('activity_type')
@@ -214,7 +215,7 @@ async def on_connect(self):
214215
activity = discord.Activity(type=activity_type, name=message,
215216
url=url)
216217
await self.change_presence(activity=activity)
217-
218+
218219
self._connected.set()
219220

220221
async def on_ready(self):
@@ -507,15 +508,15 @@ async def autoupdate_loop(self):
507508
await self.wait_until_ready()
508509

509510
if self.config.get('disable_autoupdates'):
510-
print('Autoupdates disabled.')
511+
print(Fore.CYAN + 'Autoupdates disabled.' + Style.RESET_ALL)
511512
print(line)
512-
return
513+
return
513514

514515
if self.selfhosted and not self.config.get('github_access_token'):
515516
print('Github access token not found.')
516-
print('Autoupdates disabled.')
517+
print(Fore.CYAN + 'Autoupdates disabled.' + Style.RESET_ALL)
517518
print(line)
518-
return
519+
return
519520

520521
while True:
521522
metadata = await self.modmail_api.get_metadata()
@@ -577,6 +578,7 @@ def uptime(self):
577578

578579

579580
if __name__ == '__main__':
580-
uvloop.install()
581+
if os.name != 'nt':
582+
uvloop.install()
581583
bot = ModmailBot()
582584
bot.run()

cogs/modmail.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,6 @@ async def unsubscribe(self, ctx, *, role=None):
293293
em.description = f'{mention} is now unsubscribed to this thread.'
294294
await ctx.send(embed=em)
295295

296-
297296
@commands.command()
298297
async def nsfw(self, ctx):
299298
"""Flags a Modmail thread as nsfw."""
@@ -302,16 +301,19 @@ async def nsfw(self, ctx):
302301
return
303302
await ctx.channel.edit(nsfw=True)
304303
await ctx.message.add_reaction('✅')
305-
304+
306305
@commands.command()
307306
async def loglink(self, ctx):
307+
"""Returns the log lnk of the current thread"""
308308
thread = await self.bot.threads.find(channel=ctx.channel)
309309
if thread:
310310
log_link = await self.bot.modmail_api.get_log_link(ctx.channel.id)
311-
await ctx.send(embed=discord.Embed(
311+
await ctx.send(
312+
embed=discord.Embed(
312313
color=discord.Color.blurple(),
313-
description=log_link)
314-
)
314+
description=log_link
315+
)
316+
)
315317

316318
@commands.command(aliases=['threads'])
317319
@commands.has_permissions(manage_messages=True)
@@ -388,19 +390,20 @@ async def reply(self, ctx, *, msg=''):
388390
if thread:
389391
await ctx.trigger_typing()
390392
await thread.reply(ctx.message)
391-
393+
392394
@commands.command()
393395
async def anonreply(self, ctx, *, msg=''):
396+
"""Anonymously reply to threads"""
394397
ctx.message.content = msg
395398
thread = await self.bot.threads.find(channel=ctx.channel)
396399
if thread:
397400
await ctx.trigger_typing()
398401
await thread.reply(ctx.message, anonymous=True)
399-
402+
400403
@commands.command()
401404
async def note(self, ctx, *, msg=''):
402405
"""Take a note about the current thread, useful for noting context."""
403-
ctx.message.content = msg
406+
ctx.message.content = msg
404407
thread = await self.bot.threads.find(channel=ctx.channel)
405408

406409
if thread:

cogs/plugins.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import importlib
2+
import subprocess
3+
import shutil
4+
5+
from discord.ext import commands
6+
from colorama import Fore, Style
7+
8+
from core.decorators import owner_only
9+
10+
11+
class DownloadError(Exception):
12+
pass
13+
14+
15+
class Plugins:
16+
"""Plugins expand Mod Mail functionality by allowing third-party addons.
17+
18+
These addons could have a range of features from moderation to simply making
19+
your life as a moderator easier!
20+
Learn how to create a plugin yourself here: https://link.com
21+
"""
22+
def __init__(self, bot):
23+
self.bot = bot
24+
self.bot.loop.create_task(self.download_initital_plugins())
25+
26+
def parse_plugin(self, name):
27+
# returns: (username, repo, plugin_name)
28+
try:
29+
result = name.split('/')
30+
result[2] = '/'.join(result[2:])
31+
except IndexError:
32+
return None
33+
return tuple(result)
34+
35+
async def download_initital_plugins(self):
36+
await self.bot._connected.wait()
37+
for i in self.bot.config.plugins:
38+
parsed_plugin = self.parse_plugin(i)
39+
40+
try:
41+
await self.download_plugin_repo(*parsed_plugin[:-1])
42+
except DownloadError as e:
43+
print(Fore.RED + f'Unable to download plugin ({parsed_plugin[0]}/{parsed_plugin[1]} - {e}' + Style.RESET_ALL)
44+
45+
await self.load_plugin(*parsed_plugin)
46+
47+
async def download_plugin_repo(self, username, repo):
48+
try:
49+
subprocess.run(f'git clone https://github.com/{username}/{repo} plugins/{username}-{repo} -q', check=True, capture_output=True)
50+
# -q for quiet so there's no terminal output unless there's an error
51+
except subprocess.CalledProcessError as e:
52+
error = e.stderr.decode('utf8').strip()
53+
if not error.endswith('already exists and is not an empty directory.'):
54+
# don't raise error if the plugin folder exists
55+
raise DownloadError(error) from e
56+
57+
async def load_plugin(self, username, repo, plugin_name):
58+
try:
59+
self.bot.load_extension(f'plugins.{username}-{repo}.{plugin_name}.{plugin_name}')
60+
except ModuleNotFoundError as e:
61+
raise DownloadError('Invalid plugin structure') from e
62+
else:
63+
print(Fore.LIGHTCYAN_EX + f'Loading plugins.{username}-{repo}.{plugin_name}' + Style.RESET_ALL)
64+
65+
@commands.group(aliases=['plugins'])
66+
@owner_only()
67+
async def plugin(self, ctx):
68+
"""Plugin handler. Controls the plugins in the bot."""
69+
if ctx.invoked_subcommand is None:
70+
cmd = self.bot.get_command('help')
71+
await ctx.invoke(cmd, command='plugin')
72+
73+
@plugin.command()
74+
async def add(self, ctx, *, plugin_name):
75+
"""Adds a plugin"""
76+
# parsing plugin_name
77+
async with ctx.typing():
78+
if len(plugin_name.split('/')) >= 3:
79+
parsed_plugin = self.parse_plugin(plugin_name)
80+
81+
try:
82+
await self.download_plugin_repo(*parsed_plugin[:-1])
83+
except DownloadError as e:
84+
return await ctx.send(f'Unable to fetch plugin from Github: {e}')
85+
86+
try:
87+
await self.load_plugin(*parsed_plugin)
88+
except DownloadError as e:
89+
return await ctx.send(f'Unable to start plugin: {e}')
90+
91+
# if it makes it here, it has passed all checks and should
92+
# be entered into the config
93+
94+
self.bot.config.plugins.append(plugin_name)
95+
await self.bot.config.update()
96+
await ctx.send('Plugin installed. Any plugin that you install is of your OWN RISK.')
97+
else:
98+
await ctx.send('Invalid plugin name format. Use username/repo/plugin.')
99+
100+
@plugin.command()
101+
async def remove(self, ctx, *, plugin_name):
102+
"""Removes a certain plugin"""
103+
if plugin_name in self.bot.config.plugins:
104+
username, repo, name = self.parse_plugin(plugin_name)
105+
self.bot.unload_extension(f'plugins.{username}-{repo}.{name}.{name}')
106+
107+
self.bot.config.plugins.remove(plugin_name)
108+
109+
try:
110+
if not any(i.startswith(f'{username}/{repo}') for i in self.bot.config.plugins):
111+
# if there are no more of such repos, delete the folder
112+
shutil.rmtree(f'plugins/{username}-{repo}', ignore_errors=True)
113+
await ctx.send('')
114+
except Exception as e:
115+
print(e)
116+
self.bot.config.plugins.append(plugin_name)
117+
raise e
118+
119+
await self.bot.config.update()
120+
await ctx.send('Plugin uninstalled and all related data is erased.')
121+
else:
122+
await ctx.send('Plugin not installed.')
123+
124+
@plugin.command()
125+
async def update(self, ctx, *, plugin_name):
126+
async with ctx.typing():
127+
username, repo, name = self.parse_plugin(plugin_name)
128+
try:
129+
cmd = subprocess.run(f'cd plugins/{username}-{repo} && git pull', shell=True, check=True, capture_output=True)
130+
except subprocess.CalledProcessError as e:
131+
error = e.stdout.decode('utf8').strip()
132+
await ctx.send(f'Error when updating: {error}')
133+
else:
134+
output = cmd.stdout.decode('utf8').strip()
135+
136+
if output != 'Already up to date.':
137+
# repo was updated locally, now perform the cog reload
138+
ext = f'plugins.{username}-{repo}.{name}.{name}'
139+
importlib.reload(importlib.import_module(ext))
140+
self.bot.unload_extension(ext)
141+
self.bot.load_extension(ext)
142+
143+
await ctx.send(f'```\n{output}\n```')
144+
145+
@plugin.command(name='list')
146+
async def list_(self, ctx):
147+
"""Shows a list of currently enabled plugins"""
148+
await ctx.send('```\n' + '\n'.join(self.bot.config.plugins) + '\n```')
149+
150+
151+
def setup(bot):
152+
bot.add_cog(Plugins(bot))

cogs/utility.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,14 @@ def format_cog_help(self, ctx, cog):
4242
if cmd.instance is cog:
4343
if cmd.hidden:
4444
continue
45-
if len(fmt[index] + f'`{prefix+cmd.qualified_name:<{maxlen}}` - ' + f'{cmd.short_doc:<{maxlen}}\n') > 1024:
45+
if len(fmt[index] + f'`{prefix+cmd.qualified_name:<{maxlen}}` - ' + f'{cmd.short_doc or "No description":<{maxlen}}\n') > 1024:
4646
index += 1
4747
fmt.append('')
4848
fmt[index] += f'`{prefix+cmd.qualified_name:<{maxlen}}` - '
49-
fmt[index] += f'{cmd.short_doc:<{maxlen}}\n'
49+
fmt[index] += f'{cmd.short_doc or "No description":<{maxlen}}\n'
5050

5151
em = discord.Embed(
52-
description='*' + inspect.getdoc(cog) + '*',
52+
description=f"*{inspect.getdoc(cog) or 'No description'}*",
5353
color=discord.Colour.blurple()
5454
)
5555
em.set_author(name=cog.__class__.__name__ + ' - Help', icon_url=ctx.bot.user.avatar_url)
@@ -88,7 +88,7 @@ def format_command_help(self, ctx, cmd):
8888
else:
8989
branch = '├─ ' + c.name
9090
fmt += f"`{branch:<{maxlen+1}}` - "
91-
fmt += f"{c.short_doc:<{maxlen}}\n"
91+
fmt += f"{c.short_doc or 'No description':<{maxlen}}\n"
9292

9393
em.add_field(name='Subcommands', value=fmt)
9494
em.set_footer(text=f'Type "{prefix}help {cmd} command" for more info on a command.')

core/config.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import box
55

6+
67
class ConfigManager:
78
"""Class that manages a cached configuration"""
89

@@ -12,18 +13,21 @@ class ConfigManager:
1213
'thread_creation_response', 'twitch_url', 'mod_color',
1314
'recipient_color', 'mod_tag', 'anon_username', 'anon_avatar_url',
1415
'anon_tag'
15-
}
16-
16+
}
17+
1718
internal_keys = {
1819
'snippets', 'aliases', 'blocked',
1920
'notification_squad', 'subscriptions',
20-
'closures', 'activity_message', 'activity_type'
21-
}
22-
21+
'closures', 'activity_message', 'activity_type',
22+
'plugins'
23+
}
24+
2325
protected_keys = {
2426
'token', 'owners', 'modmail_api_token', 'guild_id', 'modmail_guild_id',
2527
'mongo_uri', 'github_access_token', 'log_url'
26-
}
28+
}
29+
30+
# plugins is internal as its a list and i dont want weird issues
2731

2832
valid_keys = allowed_to_change_in_command | internal_keys | protected_keys
2933

@@ -43,6 +47,7 @@ def api(self):
4347
def populate_cache(self):
4448
data = {
4549
'snippets': {},
50+
'plugins': [],
4651
'aliases': {},
4752
'blocked': {},
4853
'notification_squad': {},
@@ -59,7 +64,7 @@ def populate_cache(self):
5964
self.cache = {
6065
k.lower(): v for k, v in data.items()
6166
if k.lower() in self.valid_keys
62-
}
67+
}
6368

6469
async def update(self, data=None):
6570
"""Updates the config with data from the cache"""
@@ -72,7 +77,7 @@ async def refresh(self):
7277
data = await self.api.get_config()
7378
self.cache.update(data)
7479
self.ready_event.set()
75-
80+
7681
async def wait_until_ready(self):
7782
await self.ready_event.wait()
7883

plugins/fourjr-modmail-plugins

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit ef4b6240e323ad97c6ffbc4d62fc145adfddbcf6

0 commit comments

Comments
 (0)