Skip to content

Commit 516ba4f

Browse files
authored
Plugins (#156) - v2.13.0
* Plugin * delete plugins folder * Send additional images as seperate messages. * Merge from "master" * db api * more elegant shutdown, use run_in_executor * Add PIP requirements support (requirements.txt file in the plugin folder * Fix help cmd (check checks, blank cogs) * cleaner gitignore * some var namings * Fixed a mistake, and conform to pylint style
2 parents 8812bef + 4967f10 commit 516ba4f

File tree

9 files changed

+348
-37
lines changed

9 files changed

+348
-37
lines changed

.gitignore

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,15 @@ ENV/
102102
*.json
103103
!app.json
104104

105-
#Pycharm
105+
# Pycharm
106106
.idea/
107107

108-
#MacOs
108+
# MacOS
109109
.DS_Store
110110

111+
# VS Code
112+
.vscode/
113+
114+
# Modmail
111115
config.json
116+
plugins/

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
# v2.13.0
8+
9+
### Added
10+
- Plugins
11+
- Think of it like addons! Anyone (with the skills) can create a plugin, make it public and distribute it.
12+
Add a welcome message to Modmail, or moderation commands? It's all up to your imagination!
13+
Have a niche feature request that you think only your server would benefit from? Plugins are your go-to!
14+
- [Creating Plugins Documention](https://github.com/kyb3r/modmail/wiki/Plugins)
15+
716
# v2.12.5
817

918
### Fixed

bot.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,28 @@
2222
SOFTWARE.
2323
"""
2424

25-
__version__ = '2.12.5'
25+
__version__ = '2.13.0'
2626

2727
import asyncio
28+
import traceback
29+
import os
2830
from datetime import datetime
29-
from os import listdir
3031
from textwrap import dedent
3132
from types import SimpleNamespace
3233

3334
import discord
34-
import uvloop
35-
from aiohttp import ClientSession
36-
from colorama import init, Fore, Style
3735
from discord.enums import ActivityType
3836
from discord.ext import commands
3937
from discord.ext.commands.view import StringView
38+
39+
from aiohttp import ClientSession
40+
from colorama import init, Fore, Style
4041
from emoji import UNICODE_EMOJI
4142
from motor.motor_asyncio import AsyncIOMotorClient
4243

4344
from core.changelog import Changelog
4445
from core.clients import ModmailApiClient, SelfHostedClient
46+
from core.clients import PluginDatabaseClient
4547
from core.config import ConfigManager
4648
from core.models import Bot
4749
from core.thread import ThreadManager
@@ -59,14 +61,14 @@ def __init__(self):
5961
self._threads = None
6062
self._session = None
6163
self._config = None
62-
self._connected = asyncio.Event()
6364
self._db = None
6465

6566
if self.self_hosted:
6667
self._db = AsyncIOMotorClient(self.config.mongo_uri).modmail_bot
6768
self._api = SelfHostedClient(self)
6869
else:
6970
self._api = ModmailApiClient(self)
71+
self.plugin_db = PluginDatabaseClient(self)
7072

7173
self.data_task = self.loop.create_task(self.data_loop())
7274
self.autoupdate_task = self.loop.create_task(self.autoupdate_loop())
@@ -120,17 +122,18 @@ def _load_extensions(self):
120122
'┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘', sep='\n')
121123
print(f'v{__version__}')
122124
print('Authors: kyb3r, fourjr, Taaku18' + Style.RESET_ALL)
123-
print(LINE + Fore.CYAN)
125+
print(LINE)
124126

125-
for file in listdir('cogs'):
127+
for file in os.listdir('cogs'):
126128
if not file.endswith('.py'):
127129
continue
128130
cog = f'cogs.{file[:-3]}'
129-
print(f'Loading {cog}')
131+
print(Fore.CYAN + f'Loading {cog}' + Style.RESET_ALL)
130132
try:
131133
self.load_extension(cog)
132134
except Exception:
133135
print(f'Failed to load {cog}')
136+
traceback.print_exc()
134137

135138
async def is_owner(self, user):
136139
allowed = {int(x) for x in
@@ -145,8 +148,21 @@ async def logout(self):
145148

146149
def run(self, *args, **kwargs):
147150
try:
148-
super().run(self.token, *args, **kwargs)
151+
self.loop.run_until_complete(self.start(self.token))
152+
except discord.LoginFailure:
153+
print('Invalid token')
154+
except KeyboardInterrupt:
155+
pass
156+
except Exception:
157+
print('Fatal exception')
158+
traceback.print_exc()
149159
finally:
160+
self.data_task.cancel()
161+
self.autoupdate_task.cancel()
162+
163+
self.loop.run_until_complete(self.logout())
164+
self.loop.run_until_complete(self.session.close())
165+
self.loop.close()
150166
print(Fore.RED + ' - Shutting down bot' + Style.RESET_ALL)
151167

152168
@property
@@ -294,7 +310,7 @@ async def setup_indexes(self):
294310
await coll.create_index([
295311
('messages.content', 'text'),
296312
('messages.author.name', 'text')
297-
])
313+
])
298314

299315
async def on_ready(self):
300316
"""Bot startup, sets uptime."""
@@ -647,17 +663,19 @@ async def autoupdate_loop(self):
647663
await self.wait_until_ready()
648664

649665
if self.config.get('disable_autoupdates'):
650-
print('Autoupdates disabled.')
666+
print(Fore.CYAN + 'Autoupdates disabled.' + Style.RESET_ALL)
651667
print(LINE)
652668
return
653669

654670
if self.self_hosted and not self.config.get('github_access_token'):
671+
print('Github access token not found.')
672+
print(Fore.CYAN + 'Autoupdates disabled.' + Style.RESET_ALL)
655673
print('GitHub access token not found.')
656674
print('Autoupdates disabled.')
657675
print(LINE)
658676
return
659677

660-
while True:
678+
while not self.is_closed():
661679
metadata = await self.api.get_metadata()
662680

663681
if metadata['latest_version'] != self.version:
@@ -694,6 +712,8 @@ async def autoupdate_loop(self):
694712

695713

696714
if __name__ == '__main__':
697-
uvloop.install()
715+
if os.name != 'nt':
716+
import uvloop
717+
uvloop.install()
698718
bot = ModmailBot() # pylint: disable=invalid-name
699719
bot.run()

cogs/plugins.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import importlib
2+
import os
3+
import shutil
4+
import stat
5+
import subprocess
6+
7+
from colorama import Fore, Style
8+
from discord.ext import commands
9+
10+
from core.models import Bot
11+
12+
13+
class DownloadError(Exception):
14+
pass
15+
16+
17+
class Plugins:
18+
"""Plugins expand Mod Mail functionality by allowing third-party addons.
19+
20+
These addons could have a range of features from moderation to simply
21+
making your life as a moderator easier!
22+
Learn how to create a plugin yourself here:
23+
https://github.com/kyb3r/modmail/wiki/Plugins
24+
"""
25+
def __init__(self, bot: Bot):
26+
self.bot = bot
27+
self.bot.loop.create_task(self.download_initial_plugins())
28+
29+
def _asubprocess_run(self, cmd):
30+
return subprocess.run(cmd, shell=True, check=True,
31+
capture_output=True)
32+
33+
@staticmethod
34+
def parse_plugin(name):
35+
# returns: (username, repo, plugin_name)
36+
try:
37+
result = name.split('/')
38+
result[2] = '/'.join(result[2:])
39+
except IndexError:
40+
return None
41+
return tuple(result)
42+
43+
async def download_initial_plugins(self):
44+
await self.bot._connected.wait()
45+
for i in self.bot.config.plugins:
46+
parsed_plugin = self.parse_plugin(i)
47+
48+
try:
49+
await self.download_plugin_repo(*parsed_plugin[:-1])
50+
except DownloadError as exc:
51+
msg = f'{parsed_plugin[0]}/{parsed_plugin[1]} - {exc}'
52+
print(Fore.RED + msg + Style.RESET_ALL)
53+
else:
54+
try:
55+
await self.load_plugin(*parsed_plugin)
56+
except DownloadError as exc:
57+
msg = f'{parsed_plugin[0]}/{parsed_plugin[1]} - {exc}'
58+
print(Fore.RED + msg + Style.RESET_ALL)
59+
60+
async def download_plugin_repo(self, username, repo):
61+
try:
62+
cmd = f'git clone https://github.com/{username}/{repo} '
63+
cmd += f'plugins/{username}-{repo} -q'
64+
await self.bot.loop.run_in_executor(
65+
None,
66+
self._asubprocess_run,
67+
cmd
68+
)
69+
# -q (quiet) so there's no terminal output unless there's an error
70+
except subprocess.CalledProcessError as exc:
71+
error = exc.stderr.decode('utf-8').strip()
72+
if not error.endswith('already exists and is '
73+
'not an empty directory.'):
74+
# don't raise error if the plugin folder exists
75+
raise DownloadError(error) from exc
76+
77+
async def load_plugin(self, username, repo, plugin_name):
78+
ext = f'plugins.{username}-{repo}.{plugin_name}.{plugin_name}'
79+
dirname = f'plugins/{username}-{repo}/{plugin_name}'
80+
if 'requirements.txt' in os.listdir(dirname):
81+
# Install PIP requirements
82+
try:
83+
await self.bot.loop.run_in_executor(
84+
None, self._asubprocess_run,
85+
f'python3 -m pip install -U -r {dirname}/'
86+
'requirements.txt --user -q -q'
87+
)
88+
# -q -q (quiet)
89+
# so there's no terminal output unless there's an error
90+
except subprocess.CalledProcessError as exc:
91+
error = exc.stderr.decode('utf8').strip()
92+
if error:
93+
raise DownloadError(
94+
f'Unable to download requirements: ```\n{error}\n```'
95+
) from exc
96+
97+
try:
98+
self.bot.load_extension(ext)
99+
except ModuleNotFoundError as exc:
100+
raise DownloadError('Invalid plugin structure') from exc
101+
else:
102+
msg = f'Loaded plugins.{username}-{repo}.{plugin_name}'
103+
print(Fore.LIGHTCYAN_EX + msg + Style.RESET_ALL)
104+
105+
@commands.group(aliases=['plugins'])
106+
@commands.is_owner()
107+
async def plugin(self, ctx):
108+
"""Plugin handler. Controls the plugins in the bot."""
109+
if ctx.invoked_subcommand is None:
110+
cmd = self.bot.get_command('help')
111+
await ctx.invoke(cmd, command='plugin')
112+
113+
@plugin.command()
114+
async def add(self, ctx, *, plugin_name):
115+
"""Adds a plugin"""
116+
if plugin_name in self.bot.config.plugins:
117+
return await ctx.send('Plugin already installed')
118+
if plugin_name in self.bot.cogs.keys():
119+
# another class with the same name
120+
return await ctx.send('Another cog exists with the same name')
121+
122+
message = await ctx.send('Downloading plugin...')
123+
async with ctx.typing():
124+
if len(plugin_name.split('/')) >= 3:
125+
parsed_plugin = self.parse_plugin(plugin_name)
126+
127+
try:
128+
await self.download_plugin_repo(*parsed_plugin[:-1])
129+
except DownloadError as exc:
130+
return await ctx.send(
131+
f'Unable to fetch plugin from Github: {exc}'
132+
)
133+
134+
importlib.invalidate_caches()
135+
try:
136+
await self.load_plugin(*parsed_plugin)
137+
except DownloadError as exc:
138+
return await ctx.send(f'Unable to load plugin: `{exc}`')
139+
140+
# if it makes it here, it has passed all checks and should
141+
# be entered into the config
142+
143+
self.bot.config.plugins.append(plugin_name)
144+
await self.bot.config.update()
145+
146+
await message.edit(content='Plugin installed. Any plugin that '
147+
'you install is of your OWN RISK.')
148+
else:
149+
await message.edit(content='Invalid plugin name format. '
150+
'Use username/repo/plugin.')
151+
152+
@plugin.command()
153+
async def remove(self, ctx, *, plugin_name):
154+
"""Removes a certain plugin"""
155+
if plugin_name in self.bot.config.plugins:
156+
username, repo, name = self.parse_plugin(plugin_name)
157+
self.bot.unload_extension(
158+
f'plugins.{username}-{repo}.{name}.{name}'
159+
)
160+
161+
self.bot.config.plugins.remove(plugin_name)
162+
163+
try:
164+
if not any(i.startswith(f'{username}/{repo}')
165+
for i in self.bot.config.plugins):
166+
# if there are no more of such repos, delete the folder
167+
def onerror(func, path, exc_info):
168+
if not os.access(path, os.W_OK):
169+
# Is the error an access error?
170+
os.chmod(path, stat.S_IWUSR)
171+
func(path)
172+
173+
shutil.rmtree(f'plugins/{username}-{repo}',
174+
onerror=onerror)
175+
except Exception as exc:
176+
print(exc)
177+
self.bot.config.plugins.append(plugin_name)
178+
raise exc
179+
180+
await self.bot.config.update()
181+
await ctx.send('Plugin uninstalled and '
182+
'all related data is erased.')
183+
else:
184+
await ctx.send('Plugin not installed.')
185+
186+
@plugin.command()
187+
async def update(self, ctx, *, plugin_name):
188+
"""Updates a certain plugin"""
189+
if plugin_name not in self.bot.config.plugins:
190+
return await ctx.send('Plugin not installed')
191+
192+
async with ctx.typing():
193+
username, repo, name = self.parse_plugin(plugin_name)
194+
try:
195+
cmd = f'cd plugins/{username}-{repo} && git pull'
196+
cmd = await self.bot.loop.run_in_executor(
197+
None,
198+
self._asubprocess_run,
199+
cmd
200+
)
201+
except subprocess.CalledProcessError as exc:
202+
error = exc.stderr.decode('utf8').strip()
203+
await ctx.send(f'Error while updating: {error}')
204+
else:
205+
output = cmd.stdout.decode('utf8').strip()
206+
await ctx.send(f'```\n{output}\n```')
207+
208+
if output != 'Already up to date.':
209+
# repo was updated locally, now perform the cog reload
210+
ext = f'plugins.{username}-{repo}.{name}.{name}'
211+
importlib.reload(importlib.import_module(ext))
212+
213+
try:
214+
await self.load_plugin(username, repo, name)
215+
except DownloadError as exc:
216+
await ctx.send(f'Unable to start plugin: `{exc}`')
217+
218+
@plugin.command(name='list')
219+
async def list_(self, ctx):
220+
"""Shows a list of currently enabled plugins"""
221+
if self.bot.config.plugins:
222+
msg = '```\n' + '\n'.join(self.bot.config.plugins) + '\n```'
223+
await ctx.send(msg)
224+
else:
225+
await ctx.send('No plugins installed')
226+
227+
228+
def setup(bot):
229+
bot.add_cog(Plugins(bot))

0 commit comments

Comments
 (0)