diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..d53912927b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,30 @@
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+# v2.0.0
+
+This release introduces the use of our centralized [API service](https://github.com/kyb3r/webserver) to enable dynamic configuration, auto-updates, and thread logs. To use this release you must acquire an API token from https://modmail.tk. Read the updated installation guide [here](https://github.com/kyb3r/modmail/wiki/installation).
+
+### Changed
+- Stability improvements through synchronization primitives
+- Refactor thread management and code
+- Update command now uses `api.modmail.tk`
+- Removed `archive` command
+ - Explanation: With thread logs (that lasts forever), there's no point in archiving.
+- `contact` command no longer tells the user you messaged them 👻
+
+### Fixed
+- Status command now changes playing status indefinitely
+
+### Added
+- Dynamic help command (#84)
+- Dynamic configuration through `api.modmail.tk`
+- Thread logs via `logs.modmail.tk` (#78)
+ - `log` command added
+- Automatic updates (#73)
+- Dynamic command aliases and snippets (#86)
+- Optional support for using a seperate guild as the operations center (#81)
+- NSFW Command to change channels to NSFW (#77)
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 2f1131dace..2a09c00236 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2017 kyb3r
+Copyright (c) 2017-2019 kyb3r
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 2a70196723..69c037ae98 100644
--- a/README.md
+++ b/README.md
@@ -29,9 +29,7 @@
-Assuming you got the bot setup (Read below on how to set it up), the first thing that you would do is type the command ```setup [modrole]``` where `[modrole]` is an optional role you can specify which determines who can see the relayed messages. If a role is not specified, the bot will choose the first role that has `manage guild` permissions as the modrole. The bot will then set up a channel category named `Mod Mail`.
-
-When a user sends a direct message to the bot, a channel is created within this new category. This channel is where messages will be relayed. To reply to a message, simply type the command `reply ` in the channel.
+When a user sends a direct message to the bot, a channel is created within an isolated category. This channel is where messages will be relayed. To reply to a message, simply use the command `reply` in the channel. See a full list of commands [below](#commands).
## What it looks like
@@ -49,21 +47,37 @@ If you are keen to stay updated with the latest features then follow the updatin
## Commands
-| Name | Description |
-|--------------|----------------------------------------------------------------------|
-| setup | Sets up the categories that will be used by the bot. |
-| about | Shows some general information about the bot. |
-| contact | Allows a moderator to initiate a thread with a given recipient. |
-| reply | Sends a message to the current thread's recipient. |
-| close | Closes the current thread and deletes the channel. |
-| archive | Closes the thread and moves the channel to the archive category. |
-| block | Blocks a user from using modmail |
-| blocked | Shows a list of users that are currently blocked |
-| unblock | Unblocks a user from using modmail |
-| snippets | Shows a list of snippets that are currently configured. |
-| customstatus | Sets the bot playing status to a message of your choosin |
-| disable | Closes all threads and disables modmail for the server. |
-| update | Checks for new versions and updates the bot, follow the [updating guide](https://github.com/kyb3r/modmail/wiki/Updating) to use this command. |
+### Modmail related
+
+| Name | Command |
+|----------|------------------------------------------------------------|
+| setup | Sets up a server for modmail |
+| reply | Reply to users using this command. |
+| edit | Edit a message that was sent using the reply command. |
+| contact | Create a thread with a specified member. |
+| close | Close the current thread. |
+| move | Moves the thread channel to a specified category |
+| logs | Shows a list of previous modmail thread logs of a member. |
+| block | Block a user from using modmail. |
+| blocked | Returns a list of blocked users |
+| unblock | Unblocks a user from using modmail. |
+| nsfw | Flags a modmail thread as nsfw. |
+| snippets | Returns a list of snippets that are currently set. |
+| mention | Changes what the bot mentions at the start of each thread. |
+
+### Utility commands
+| Name | Command |
+|----------|------------------------------------------------------------|
+| help | Shows the help message. |
+| update | Checks for new versions and updates the bot |
+| github | Shows the github user your modmail api token is linked to. |
+| prefix | Changes the prefix for the bot. |
+| alias | Returns a list of aliases that are currently set. |
+| about | Shows information about the bot. |
+| status | Set a custom playing status for the bot. |
+| ping | Pong! Returns your websocket latency. |
+| eval | Evaluates python code (Bot owner only) |
+| config | Manually change configuration for the bot. |
## Features
@@ -74,10 +88,13 @@ Snippets are shortcuts for predefined messages that you can send. You can add sn
If you want the bot to mention a specific role instead of `@here`, you need to set a config variable `MENTION` and set the value to the mention of the role or user you want mentioned. To get the mention of a role or user, type `\@role` in chat and you will see something like `<@&515651147516608512>` use this string as the value for the config variable.
### Delete Linked Messages
-Did you accidentally send something you didnt mean to with the `reply` command? Dont fret, if you delete the original message on your side, this bot automatically deletes the corresponding message that was sent to the recipient of the thread!
+Did you accidentally send something you didnt mean to with the `reply` command? Dont fret, if you delete the original message on your side, this bot automatically deletes the corresponding message that was sent to the recipient of the thread! This also works with message edits in reverse.
-## Thanks For Using This Bot!
+### Thread Logs
+Thread conversations are automatically logged and a log link (logs.modmail.tk) is provided with each thread.
-If you do use the bot, a star on this repository is appreciated!
+### Automatic Updates
+The bot checks for new updates every hour and automatically updates to a newer version if found. You can disable this functionality by adding a `disable_autoupdates` config variable.
+## Contributing
This project is licenced under MIT. Feel free to contribute to the development of this bot.
diff --git a/app.json b/app.json
index 17d0ecc5d8..7ce9d3dea2 100644
--- a/app.json
+++ b/app.json
@@ -12,7 +12,7 @@
"required": true
},
"PREFIX": {
- "description": "Command prefix to use.",
+ "description": "Command prefix to use, default is `?`",
"required": false
},
"STATUS": {
@@ -27,9 +27,9 @@
"description": "Comma seperated user IDs of people that are allowed to use owner only commands. (eval and update)",
"required": false
},
- "GITHUB_ACCESS_TOKEN": {
- "description": "Github personal access token to enable use of the update command.",
- "required": false
+ "MODMAIL_API_TOKEN": {
+ "description": "API token from https://modmail.tk",
+ "required": true
}
}
-}
\ No newline at end of file
+}
diff --git a/bot.py b/bot.py
index fe71d6b32c..3eba9f7f06 100644
--- a/bot.py
+++ b/bot.py
@@ -1,7 +1,7 @@
-'''
+"""
MIT License
-Copyright (c) 2017 Kyb3r
+Copyright (c) 2017-2019 kyb3r
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -20,254 +20,275 @@
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-'''
+"""
-__version__ = '1.5.3'
+__version__ = '2.0.0'
-from contextlib import redirect_stdout
-from urllib.parse import urlparse
-from copy import deepcopy
-import functools
import asyncio
import textwrap
-import traceback
import datetime
-import inspect
-import string
-import time
-import json
import os
import re
-import io
-from colorama import init, Fore, Back, Style
-
-init()
-
-from discord.ext import commands
import discord
import aiohttp
+from discord.ext import commands
+from discord.ext.commands.view import StringView
+from colorama import init, Fore, Style
-from utils.paginator import PaginatorSession
-from utils.api import Github, ModmailApiClient
+from core.api import Github, ModmailApiClient
+from core.thread import ThreadManager
+from core.config import ConfigManager
+init()
-line = Fore.RED + Style.BRIGHT + '-------------------------' + Style.RESET_ALL
+line = Fore.BLACK + Style.BRIGHT + '-------------------------' + Style.RESET_ALL
-class Modmail(commands.Bot):
+
+class ModmailBot(commands.Bot):
+
+ mutable_config_keys = ['prefix', 'status', 'guild_id', 'mention', 'autoupdates', 'modmail_guild_id']
def __init__(self):
super().__init__(command_prefix=self.get_pre)
+ self.version = __version__
self.start_time = datetime.datetime.utcnow()
- self.loop.create_task(self.data_loop())
+ self.threads = ThreadManager(self)
+ self.session = aiohttp.ClientSession(loop=self.loop)
+ self.config = ConfigManager(self)
+ self.modmail_api = ModmailApiClient(self)
+ self.data_task = self.loop.create_task(self.data_loop())
+ self.autoupdate_task = self.loop.create_task(self.autoupdate_loop())
self._add_commands()
def _add_commands(self):
- '''Adds commands automatically'''
+ """Adds commands automatically"""
self.remove_command('help')
- print(line)
- print(Fore.YELLOW + '┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬',
+
+ print(line + Fore.CYAN)
+ print('┌┬┐┌─┐┌┬┐┌┬┐┌─┐┬┬',
'││││ │ │││││├─┤││',
'┴ ┴└─┘─┴┘┴ ┴┴ ┴┴┴─┘', sep='\n')
print(f'v{__version__}')
- print('Author: kyb3r' + Style.RESET_ALL)
- for attr in dir(self):
- cmd = getattr(self, attr)
- if isinstance(cmd, commands.Command):
- self.add_command(cmd)
-
- @property
- def config(self):
+ print('Authors: kyb3r, fourjr' + Style.RESET_ALL)
+ print(line + Fore.CYAN)
+
+ for file in os.listdir('cogs'):
+ if not file.endswith('.py'):
+ continue
+ cog = f'cogs.{file[:-3]}'
+ print(f'Loading {cog}')
+ self.load_extension(cog)
+
+ async def logout(self):
+ await self.session.close()
+ self.data_task.cancel()
+ self.autoupdate_task.cancel()
+ await super().logout()
+
+ def run(self):
try:
- with open('config.json') as f:
- config = json.load(f)
- except FileNotFoundError:
- print('config.json not found, falling back to env vars.')
- config = {}
- config.update(os.environ)
- return config
-
+ super().run(self.token)
+ finally:
+ print(Fore.CYAN + ' Shutting down bot' + Style.RESET_ALL)
+
@property
def snippets(self):
- return {
- key.split('_')[1].lower(): val
- for key, val in self.config.items()
- if key.startswith('SNIPPET_')
- }
+ return {k: v for k, v in self.config.get('snippets', {}).items() if v}
+
+ @property
+ def aliases(self):
+ return {k: v for k, v in self.config.get('aliases', {}).items() if v}
@property
def token(self):
- '''Returns your token wherever it is'''
- return self.config.get('TOKEN')
-
+ return self.config.token
+
+ @property
+ def guild_id(self):
+ return int(self.config.guild_id)
+
+ @property
+ def guild(self):
+ return discord.utils.get(self.guilds, id=self.guild_id)
+
+ @property
+ def modmail_guild(self):
+ modmail_guild_id = self.config.get('modmail_guild_id')
+ if not modmail_guild_id:
+ return self.guild
+ else:
+ return discord.utils.get(self.guilds, id=int(modmail_guild_id))
+
+ @property
+ def main_category(self):
+ if self.guild:
+ return discord.utils.get(self.modmail_guild.categories, name='Mod Mail')
+
+ @property
+ def blocked_users(self):
+ if self.modmail_guild:
+ top_chan = self.main_category.channels[0]
+ return [int(i) for i in re.findall(r'\d+', top_chan.topic)]
+
+ @property
+ def prefix(self):
+ return self.config.get('prefix', '?')
+
@staticmethod
async def get_pre(bot, message):
- '''Returns the prefix.'''
- p = bot.config.get('PREFIX') or 'm.'
- return [p, f'<@{bot.user.id}> ', f'<@!{bot.user.id}> ']
-
- def owner_only():
- async def predicate(ctx):
- allowed = [int(x) for x in ctx.bot.config.get('OWNERS', '0').split(',')]
- return ctx.author.id in allowed
- return commands.check(predicate)
-
- def trigger_typing(func):
- @functools.wraps(func)
- async def wrapper(self, ctx, *args, **kwargs):
- await ctx.trigger_typing()
- return await func(self, ctx, *args, **kwargs)
- return wrapper
+ """Returns the prefix."""
+ return [bot.prefix, f'<@{bot.user.id}> ', f'<@!{bot.user.id}> ']
async def on_connect(self):
print(line)
- print(Fore.YELLOW + 'Connected to gateway.')
-
- self.session = aiohttp.ClientSession()
- status = os.getenv('STATUS') or self.config.get('STATUS')
+ print(Fore.CYAN + 'Connected to gateway.')
+ await self.config.refresh()
+ status = self.config.get('status')
if status:
await self.change_presence(activity=discord.Game(status))
- @property
- def guild_id(self):
- return int(self.config.get('GUILD_ID'))
-
- @property
- def guild(self):
- g = discord.utils.get(self.guilds, id=self.guild_id)
- return g
-
async def on_ready(self):
- '''Bot startup, sets uptime.'''
- print(textwrap.dedent(f'''
+ """Bot startup, sets uptime."""
+ print(textwrap.dedent(f"""
{line}
- {Fore.YELLOW}Client ready.
+ {Fore.CYAN}Client ready.
{line}
- {Fore.YELLOW}Logged in as: {self.user}
- {Fore.YELLOW}User ID: {self.user.id}
- {Fore.YELLOW}Guild ID: {self.guild.id if self.guild else 0}
+ {Fore.CYAN}Logged in as: {self.user}
+ {Fore.CYAN}User ID: {self.user.id}
+ {Fore.CYAN}Guild ID: {self.guild.id if self.guild else 0}
{line}
- ''').strip())
+ """).strip())
+
+ await self.threads.populate_cache()
+
+ async def process_modmail(self, message):
+ """Processes messages sent to the bot."""
+
+ reaction = '🚫' if message.author.id in self.blocked_users else '✅'
+
+ try:
+ await message.add_reaction(reaction)
+ except:
+ pass
+
+ blocked_em = discord.Embed(
+ title='Message not sent!',
+ color=discord.Color.red(),
+ description='You have been blocked from using modmail.'
+ )
+
+ if message.author.id in self.blocked_users:
+ await message.author.send(embed=blocked_em)
+ else:
+ thread = await self.threads.find_or_create(message.author)
+ await thread.send(message)
+
+ async def get_context(self, message, *, cls=commands.Context):
+ """
+ Returns the invocation context from the message.
+ Supports getting the prefix from database as well as command aliases.
+ """
+
+ view = StringView(message.content)
+ ctx = cls(prefix=None, view=view, bot=self, message=message)
+
+ if self._skip_check(message.author.id, self.user.id):
+ return ctx
+
+ prefixes = [self.prefix, f'<@{bot.user.id}> ', f'<@!{bot.user.id}>']
+
+ invoked_prefix = discord.utils.find(view.skip_string, prefixes)
+ if invoked_prefix is None:
+ return ctx
+
+ invoker = view.get_word().lower()
+
+ # Check if there is any aliases being called.
+ alias = self.config.get('aliases', {}).get(invoker)
+ if alias is not None:
+ ctx._alias_invoked = True
+ _len = len(f'{invoked_prefix}{invoker}')
+ ctx.view = view = StringView(f'{alias}{ctx.message.content[_len:]}')
+ invoker = view.get_word()
+
+ ctx.invoked_with = invoker
+ ctx.prefix = self.prefix # Sane prefix (No mentions)
+ ctx.command = self.all_commands.get(invoker)
+
+ # if hasattr(ctx, '_alias_invoked'):
+ # ctx.command.checks = None # Let anyone use the command.
+
+ return ctx
async def on_message(self, message):
+ if message.type == discord.MessageType.pins_add and message.author == self.user:
+ await message.delete()
if message.author.bot:
return
if isinstance(message.channel, discord.DMChannel):
return await self.process_modmail(message)
- prefix = self.config.get('PREFIX', 'm.')
+ prefix = self.prefix
if message.content.startswith(prefix):
cmd = message.content[len(prefix):].strip()
if cmd in self.snippets:
message.content = f'{prefix}reply {self.snippets[cmd]}'
-
+
await self.process_commands(message)
-
+
async def on_message_delete(self, message):
- '''Support for deleting linked messages'''
+ """Support for deleting linked messages"""
if message.embeds and not isinstance(message.channel, discord.DMChannel):
- matches = re.findall(r'Moderator - (\d+)', str(message.embeds[0].footer.text))
+ matches = re.findall(r'\d+', str(message.embeds[0].author.url))
if matches:
- user_id = None
- if not message.channel.topic:
- user_id = await self.find_user_id_from_channel(message.channel)
- user_id = user_id or int(message.channel.topic.split(': ')[1])
+ thread = await self.threads.find(channel=message.channel)
- user = self.get_user(user_id)
- channel = user.dm_channel
+ channel = thread.recipient.dm_channel
message_id = matches[0]
async for msg in channel.history():
- if msg.embeds and f'Moderator - {message_id}' in msg.embeds[0].footer.text:
- await msg.delete()
- break
-
+ if msg.embeds and msg.embeds[0].author:
+ url = msg.embeds[0].author.url
+ if message_id == re.findall(r'\d+', url)[0]:
+ return await msg.delete()
+
async def on_message_edit(self, before, after):
if before.author.bot:
return
if isinstance(before.channel, discord.DMChannel):
- channel = await self.find_or_create_thread(before.author)
- async for msg in channel.history():
+ thread = await self.threads.find(recipient=before.author)
+ async for msg in thread.channel.history():
if msg.embeds:
embed = msg.embeds[0]
- if f'User - {before.id}' in embed.footer.text:
+ matches = re.findall(r'\d+', str(embed.author.url))
+ if matches and int(matches[0]) == before.id:
if ' - (Edited)' not in embed.footer.text:
embed.set_footer(text=embed.footer.text + ' - (Edited)')
embed.description = after.content
await msg.edit(embed=embed)
break
-
+
async def on_command_error(self, ctx, error):
if isinstance(error, (commands.MissingRequiredArgument, commands.UserInputError)):
- prefix = self.config.get('PREFIX', 'm.')
- em = discord.Embed(color=discord.Color.green())
- em.title = f'`{prefix}{ctx.command.signature}`'
- em.description = ctx.command.help
- await ctx.send(embed=em)
+ await ctx.invoke(self.get_command('help'), command=str(ctx.command))
else:
raise error
- def overwrites(self, ctx, modrole=None):
- '''Permision overwrites for the guild.'''
+ def overwrites(self, ctx):
+ """Permision overwrites for the guild."""
overwrites = {
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=False)
}
- if modrole:
- overwrites[modrole] = discord.PermissionOverwrite(read_messages=True)
- else:
- for role in self.guess_modroles(ctx):
+ for role in ctx.guild.roles:
+ if role.permissions.manage_guild:
overwrites[role] = discord.PermissionOverwrite(read_messages=True)
return overwrites
- def help_embed(self, prefix):
- em = discord.Embed(color=0x00FFFF)
- em.set_author(name='Mod Mail - Help', icon_url=self.user.avatar_url)
- em.description = 'Here is a list of commands for the bot.'
-
- cmds = f'`{prefix}setup` - Sets up the categories that will be used by the bot.\n' \
- f'`{prefix}about` - Shows general information about the bot.\n' \
- f'`{prefix}contact` - Allows a moderator to initiate a thread with a given recipient.\n' \
- f'`{prefix}reply` - Sends a message to the current thread\'s recipient.\n' \
- f'`{prefix}edit` - Edit a message sent by the reply command.\n' \
- f'`{prefix}close` - Closes the current thread and deletes the channel.\n' \
- f'`{prefix}archive` - Closes the thread and moves the channel to archive category.\n' \
- f'`{prefix}block` - Blocks a user from using modmail.\n' \
- f'`{prefix}blocked` - Shows a list of currently blocked users.\n' \
- f'`{prefix}unblock` - Unblocks a user from using modmail.\n' \
- f'`{prefix}snippets` - See a list of snippets that are currently configured.\n' \
- f'`{prefix}customstatus` - Sets the Bot status to whatever you want.\n' \
- f'`{prefix}disable` - Closes all threads and disables modmail for the server.\n' \
- f'`{prefix}update` - Checks for a new version and updates the bot.\n'
-
- warn = 'This bot saves no data and utilises channel topics for tracking and relaying messages.' \
- ' Therefore do not manually delete the category or channels as it will break the system. ' \
- 'Modifying the channel topic will also break the system. Dont break the system buddy.'
-
- snippets = 'Snippets are shortcuts for predefined messages that you can send.' \
- ' You can add snippets by adding config variables in the form **`SNIPPET_{NAME}`**' \
- ' and setting the value to what you want the message to be. You can now use the snippet by' \
- f' typing the command `{prefix}name` in the thread you want to reply to.'
-
- mention = 'If you want the bot to mention a specific role instead of @here,' \
- ' you need to set a config variable **`MENTION`** and set the value ' \
- 'to the *mention* of the role or user you want mentioned. To get the ' \
- 'mention of a role or user, type `\@role` in chat and you will see ' \
- 'something like `<@&515651147516608512>` use this string as ' \
- 'the value for the config variable.'
-
- em.add_field(name='Commands', value=cmds)
- em.add_field(name='Snippets', value=snippets)
- em.add_field(name='Custom Mentions', value=mention)
- em.add_field(name='Warning', value=warn)
- em.add_field(name='Github', value='https://github.com/kyb3r/modmail')
- em.set_footer(text=f'modmail v{__version__} • A star on the repository is appreciated.')
-
- return em
-
async def data_loop(self):
await self.wait_until_ready()
@@ -286,6 +307,42 @@ async def data_loop(self):
await asyncio.sleep(3600)
+ async def autoupdate_loop(self):
+ while True:
+ if self.config.get('disable_autoupdates'):
+ await asyncio.sleep(3600)
+ continue
+
+ metadata = await self.modmail_api.get_metadata()
+
+ if metadata['latest_version'] != self.version:
+ data = await self.modmail_api.update_repository()
+ print('Updating bot.')
+
+ em = discord.Embed(title='Updating bot', color=discord.Color.green())
+
+ commit_data = data['data']
+ user = data['user']
+ em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
+ em.set_footer(text=f"Updating modmail v{self.version} -> v{metadata['latest_version']}")
+
+ if commit_data:
+ em.description = 'Bot successfully updated, the bot will restart momentarily'
+ message = commit_data['commit']['message']
+ html_url = commit_data["html_url"]
+ short_sha = commit_data['sha'][:6]
+ em.add_field(name='Merge Commit', value=f"[`{short_sha}`]({html_url}) {message} - {user['username']}")
+ else:
+ await asyncio.sleep(3600)
+ continue
+
+ em.add_field(name='Latest Commit', value=await self.get_latest_updates(limit=1), inline=False)
+
+ channel = self.main_category.channels[0]
+ await channel.send(embed=em)
+
+ await asyncio.sleep(3600)
+
async def get_latest_updates(self, limit=3):
latest_commits = ''
@@ -294,9 +351,8 @@ async def get_latest_updates(self, limit=3):
short_sha = commit['sha'][:6]
html_url = commit['html_url']
message = commit['commit']['message'].splitlines()[0]
- author_name = commit['author']['login']
- latest_commits += f'[`{short_sha}`]({html_url}) {message} - {author_name}\n'
+ latest_commits += f'[`{short_sha}`]({html_url}) {message}\n'
return latest_commits
@@ -314,809 +370,7 @@ def uptime(self):
return fmt.format(d=days, h=hours, m=minutes, s=seconds)
- @commands.command()
- @trigger_typing
- async def help(self, ctx):
- '''Shows the help message'''
- prefix = self.config.get('PREFIX', 'm.')
-
- em1 = self.help_embed(prefix)
- em2 = deepcopy(em1)
- em1.set_footer(text=f'modmail v{__version__}')
- em2.description = None
- em2.remove_field(0)
- em1._fields = em1._fields[0:1]
-
- session = PaginatorSession(ctx, em1, em2)
- await session.run()
-
- @commands.command()
- @trigger_typing
- async def about(self, ctx):
- '''Shows information about the bot.'''
- em = discord.Embed(color=discord.Color.green(), timestamp=datetime.datetime.utcnow())
- em.set_author(name='Mod Mail - Information', icon_url=self.user.avatar_url)
- em.set_thumbnail(url=self.user.avatar_url)
-
- em.description = 'This is an open source discord bot made by kyb3r and '\
- 'improved upon suggestions by the users! This bot serves as a means for members to '\
- 'easily communicate with server leadership in an organised manner.'
-
- try:
- async with self.session.get('https://api.modmail.tk/metadata') as resp:
- meta = await resp.json()
- except:
- meta = None
-
- em.add_field(name='Uptime', value=self.uptime)
- if meta:
- em.add_field(name='Instances', value=meta['instances'])
- else:
- em.add_field(name='Latency', value=f'{self.latency*1000:.2f} ms')
-
-
- em.add_field(name='Version', value=f'[`{__version__}`](https://github.com/kyb3r/modmail/blob/master/bot.py#L25)')
- em.add_field(name='Author', value='[`kyb3r`](https://github.com/kyb3r)')
-
- em.add_field(name='Latest Updates', value=await self.get_latest_updates())
-
- footer = f'Bot ID: {self.user.id}'
-
- if meta:
- if __version__ != meta['latest_version']:
- footer = f"A newer version is available v{meta['latest_version']}"
- else:
- footer = 'You are up to date with the latest version.'
-
- em.add_field(name='Github', value='https://github.com/kyb3r/modmail', inline=False)
-
- em.set_footer(text=footer)
-
- await ctx.send(embed=em)
-
- @commands.group(invoke_without_subcommand=True)
- @owner_only()
- @trigger_typing
- async def github(self, ctx):
- if ctx.invoked_subcommand:
- return
-
- client = ModmailApiClient(self)
- data = await client.get_user_info()
-
- prefix = self.config.get('PREFIX', 'm.')
-
- em = discord.Embed(
- title='Github',
- color=discord.Color.red(),
- description=f'Not logged in, do `{prefix}github login` to login with GitHub.'
- )
- em.add_field(name='Subcommands', value=f'`{prefix}github login`\n`{prefix}github logout`')
-
- if not data['error']:
- user = data['user']
- em.color = discord.Color.green()
- em.description = f"Currently logged in."
- em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
- em.set_thumbnail(url=user['avatar_url'])
- await ctx.send(embed=em)
- else:
- await ctx.send(embed=em)
-
- @github.command(name='login')
- @owner_only()
- @trigger_typing
- async def _login(self, ctx):
- client = ModmailApiClient(self)
-
- oauth_url = 'https://github.com/login/oauth/authorize?client_id' \
- '=bcff71fd67581b703408&scope=public_repo&redirect_uri=' \
- 'https://api.kybr.tk/modmail/github/callback' \
- f'?token={client.token}'
-
- em = discord.Embed(
- color=discord.Color.green(),
- title='Login with GitHub',
- description='In order to use the update command, you need ' \
- 'to have fork the [repo](https://github.com/kyb3r/modmail) and ' \
- 'login with GitHub so that we can update your fork to ' \
- 'match the main repository whenever there is an update.' \
- 'Click the link below to be taken to log in with github to authorize Modmail.'
- )
- em.set_thumbnail(url='https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png')
-
- em.add_field(name='Login', value=f'[Click Here]({oauth_url})', inline=False)
- em.add_field(name='Warning', value='Dont share this link as it contains sensitive information.')
- await ctx.send('Check your direct messages.')
- await ctx.author.send(embed=em)
-
- @github.command(name='logout')
- @owner_only()
- @trigger_typing
- async def _logout(self, ctx):
- client = ModmailApiClient(self)
- data = await client.logout()
-
- em = discord.Embed(
- color=discord.Color.green(),
- title='Logged out',
- description='No longer logged into '
- )
-
- if data['error']:
- em.color = discord.Color.red()
- em.title = 'Error'
- em.description = 'You are not logged in already.'
- else:
- user = data['user']
- em.description += user['username']
- em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
- em.set_thumbnail(url=user['avatar_url'])
-
- await ctx.send(embed=em)
-
- @commands.command()
- @owner_only()
- @trigger_typing
- async def update(self, ctx):
- '''Updates the bot, this only works with heroku users.'''
-
- client = ModmailApiClient(self)
-
- metadata = await client.get_metadata()
-
- em = discord.Embed(
- title='Already up to date',
- description=f'The latest version is [`{__version__}`](https://github.com/kyb3r/modmail/blob/master/bot.py#L25)',
- color=discord.Color.green()
- )
-
- if metadata['latest_version'] == __version__:
- data = await client.get_user_info()
- if not data['error']:
- user = data['user']
- em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
-
- if metadata['latest_version'] != __version__:
- data = await client.update_repository()
-
- if data['error']:
- prefix = self.config.get('PREFIX', 'm.')
- em.title = 'Unauthorised'
- em.description = f"You haven't logged in with github yet. Type the command `{prefix}github login` to authorize this bot."
- em.color = discord.Color.red()
- return await ctx.send(embed=em)
-
- commit_data = data['data']
- user = data['user']
- em.title = 'Success'
- em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
- em.set_footer(text=f"Updating modmail v{__version__} -> v{metadata['latest_version']}")
-
- if commit_data:
- em.description = 'Bot successfully updated, the bot will restart momentarily'
- message = commit_data['commit']['message']
- html_url = commit_data["html_url"]
- short_sha = commit_data['sha'][:6]
- em.add_field(name='Merge Commit', value=f"[`{short_sha}`]({html_url}) {message} - {user['username']}")
- else:
- em.description = 'Already up to date with master repository.'
-
- em.add_field(name='Latest Commit', value=await self.get_latest_updates(limit=1), inline=False)
-
- await ctx.send(embed=em)
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(administrator=True)
- async def setup(self, ctx, *, modrole: discord.Role=None):
- '''Sets up a server for modmail'''
- if discord.utils.get(ctx.guild.categories, name='Mod Mail'):
- return await ctx.send('This server is already set up.')
-
- categ = await ctx.guild.create_category(
- name='Mod Mail',
- overwrites=self.overwrites(ctx, modrole=modrole)
- )
- archives = await ctx.guild.create_category(
- name='Mod Mail Archives',
- overwrites=self.overwrites(ctx, modrole=modrole)
- )
- await categ.edit(position=0)
- c = await ctx.guild.create_text_channel(name='bot-info', category=categ)
- await c.edit(topic='Manually add user id\'s to block users.\n\n'
- 'Blocked\n-------\n\n')
- await c.send(embed=self.help_embed(ctx.prefix))
- await ctx.send('Successfully set up server.')
-
- @commands.command(name='snippets')
- @commands.has_permissions(manage_messages=True)
- async def _snippets(self, ctx):
- '''Returns a list of snippets that are currently set.'''
- embeds = []
-
- em = discord.Embed(color=discord.Color.green())
- em.set_author(name='Snippets', icon_url=ctx.guild.icon_url)
-
- embeds.append(em)
-
- em.description = 'Here is a list of snippets that are currently configured.'
-
- if not self.snippets:
- em.color = discord.Color.red()
- em.description = 'You dont have any snippets at the moment.'
-
- for name, value in self.snippets.items():
- if len(em.fields) == 5:
- em = discord.Embed(color=discord.Color.green(), description=em.description)
- em.set_author(name='Snippets', icon_url=ctx.guild.icon_url)
- embeds.append(em)
- em.add_field(name=name, value=value, inline=False)
-
- session = PaginatorSession(ctx, *embeds)
- await session.run()
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(administrator=True)
- async def disable(self, ctx, delete_archives: bool=False):
- '''Close all threads and disable modmail.'''
- categ = discord.utils.get(ctx.guild.categories, name='Mod Mail')
- archives = discord.utils.get(ctx.guild.categories, name='Mod Mail Archives')
- if not categ:
- return await ctx.send('This server is not set up.')
- em = discord.Embed(title='Thread Closed')
- em.description = f'{ctx.author.mention} has closed this modmail thread.'
- em.color = discord.Color.red()
- for category, channels in ctx.guild.by_category():
- if category == categ:
- for chan in channels:
- if 'User ID:' in str(chan.topic):
- user_id = int(chan.topic.split(': ')[1])
- user = self.get_user(user_id)
- await user.send(embed=em)
- await chan.delete()
- await categ.delete()
- if delete_archives:
- await archives.delete()
- await ctx.send('Disabled modmail.')
-
- @commands.command(name='close')
- @commands.has_permissions(manage_channels=True)
- async def _close(self, ctx):
- '''Close the current thread.'''
- user_id = None
- if not ctx.channel.topic:
- user_id = await self.find_user_id_from_channel(ctx.channel)
- elif 'User ID:' not in str(ctx.channel.topic) and not user_id:
- return await ctx.send('This is not a modmail thread.')
-
- user_id = user_id or int(ctx.channel.topic.split(': ')[1])
- user = self.get_user(user_id)
- em = discord.Embed(title='Thread Closed')
- em.description = f'{ctx.author.mention} has closed this modmail thread.'
- em.color = discord.Color.red()
- if ctx.channel.category.name != 'Mod Mail Archives': # already closed.
- try:
- await user.send(embed=em)
- except:
- pass
- await ctx.channel.delete()
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(manage_channels=True)
- async def archive(self, ctx):
- '''
- Archive the current thread. (Visually closes the thread
- but moves the channel into an archives category instead of
- deleteing the channel.)
- '''
- user_id = None
-
- if not ctx.channel.topic:
- user_id = await self.find_user_id_from_channel(ctx.channel)
- elif 'User ID:' not in str(ctx.channel.topic) and not user_id:
- return await ctx.send('This is not a modmail thread.')
-
- user_id = user_id or int(ctx.channel.topic.split(': ')[1])
-
- archives = discord.utils.get(ctx.guild.categories, name='Mod Mail Archives')
-
- if ctx.channel.category is archives:
- return await ctx.send('This channel is already archived.')
-
- user = self.get_user(user_id)
- em = discord.Embed(title='Thread Closed')
- em.description = f'{ctx.author.mention} has closed this modmail thread.'
- em.color = discord.Color.red()
-
- try:
- await user.send(embed=em)
- except:
- pass
-
- await ctx.channel.edit(category=archives)
- done = discord.Embed(title='Thread Archived')
- done.description = f'{ctx.author.mention} has archived this modmail thread.'
- done.color = discord.Color.red()
- await ctx.send(embed=done)
- await ctx.message.delete()
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(administrator=True)
- async def ping(self, ctx):
- """Pong! Returns your websocket latency."""
- em = discord.Embed()
- em.title ='Pong! Websocket Latency:'
- em.description = f'{self.ws.latency * 1000:.4f} ms'
- em.color = 0x00FF00
- await ctx.send(embed=em)
-
- def guess_modroles(self, ctx):
- '''Finds roles if it has the manage_guild perm'''
- for role in ctx.guild.roles:
- if role.permissions.manage_guild:
- yield role
-
- def format_info(self, user, description=None):
- '''Get information about a member of a server
- supports users from the guild or not.'''
- server = self.guild
- member = self.guild.get_member(user.id)
- avi = user.avatar_url
- time = datetime.datetime.utcnow()
- desc = description or f'{user.mention} has started a thread.'
- color = discord.Color.blurple()
-
- if member:
- roles = sorted(member.roles, key=lambda c: c.position)
- rolenames = ' '.join([r.mention for r in roles if r.name != "@everyone"])
- member_number = sorted(server.members, key=lambda m: m.joined_at).index(member) + 1
- for role in roles:
- if str(role.color) != "#000000":
- color = role.color
-
- em = discord.Embed(colour=color, description=desc, timestamp=time)
-
- days = lambda d: (' day ago.' if d == '1' else ' days ago.')
-
- created = str((time - user.created_at).days)
- # em.add_field(name='Mention', value=user.mention)
- em.add_field(name='Registered', value=created + days(created))
- footer = 'User ID: '+str(user.id)
- em.set_footer(text=footer)
- em.set_thumbnail(url=avi)
- em.set_author(name=str(user), icon_url=avi)
-
- if member:
- joined = str((time - member.joined_at).days)
- em.add_field(name='Joined', value=joined + days(joined))
- em.add_field(name='Member No.',value=str(member_number),inline = True)
- em.add_field(name='Nickname', value=member.nick, inline=True)
- if rolenames:
- em.add_field(name='Roles', value=rolenames, inline=False)
- else:
- em.set_footer(text=footer+' | Note: this member is not part of this server.')
-
-
-
- return em
-
- async def send_mail(self, message, channel, from_mod, delete_message=True):
- author = message.author
- em = discord.Embed()
- em.description = message.content
- em.timestamp = message.created_at
-
- image_types = ['.png', '.jpg', '.gif', '.jpeg', '.webp']
- is_image_url = lambda u: any(urlparse(u).path.endswith(x) for x in image_types)
-
- delete_message = not bool(message.attachments)
- attachments = list(filter(lambda a: not is_image_url(a.url), message.attachments))
-
- image_urls = [a.url for a in message.attachments]
- image_urls.extend(re.findall(r'(https?://[^\s]+)', message.content))
- image_urls = list(filter(is_image_url, image_urls))
-
- if image_urls:
- em.set_image(url=image_urls[0])
-
- if attachments:
- att = attachments[0]
- em.add_field(name='File Attachment', value=f'[{att.filename}]({att.url})')
-
- if from_mod:
- em.color=discord.Color.green()
- em.set_author(name=str(author), icon_url=author.avatar_url)
- em.set_footer(text=f'Moderator - {message.id}')
- else:
- em.color=discord.Color.gold()
- em.set_author(name=str(author), icon_url=author.avatar_url)
- em.set_footer(text=f'User - {message.id}')
-
- await channel.trigger_typing()
- await channel.send(embed=em)
-
- if delete_message:
- try:
- await message.delete()
- except:
- pass
-
- async def process_reply(self, message, user_id=None):
- user_id = user_id or int(re.findall(r'\d+', message.channel.topic)[0])
- user = self.get_user(user_id)
- if not message.content and not message.attachments:
- raise commands.UserInputError('msg is required argument.')
- if not user:
- return await message.channel.send('This user does not share any servers with the bot and is thus unreachable.')
- await asyncio.gather(
- self.send_mail(message, message.channel, from_mod=True),
- self.send_mail(message, user, from_mod=True)
- )
-
- def format_name(self, author, channels):
- name = author.name.lower()
- new_name = ''
- for letter in name:
- if letter in string.ascii_letters + string.digits:
- new_name += letter
- if not new_name:
- new_name = 'null'
- new_name += f'-{author.discriminator}'
- while new_name in [c.name for c in channels]:
- new_name += '-x' # two channels with same name
- return new_name
-
- @property
- def blocked_em(self):
- em = discord.Embed(title='Message not sent!', color=discord.Color.red())
- em.description = 'You have been blocked from using modmail.'
- return em
-
- async def process_modmail(self, message):
- '''Processes messages sent to the bot.'''
-
- guild = self.guild
- categ = discord.utils.get(guild.categories, name='Mod Mail')
- top_chan = categ.channels[0] #bot-info
- blocked = top_chan.topic.split('Blocked\n-------')[1].strip().split('\n')
- blocked = [x.strip() for x in blocked]
- reaction = '🚫' if str(message.author.id) in blocked else '✅'
-
- try:
- await message.add_reaction(reaction)
- except:
- pass
-
- if str(message.author.id) in blocked:
- await message.author.send(embed=self.blocked_em)
- else:
- channel = await self.find_or_create_thread(message.author)
- await self.send_mail(message, channel, from_mod=False)
-
-
- async def find_or_create_thread(self, user, *, creator=None, reopen=False):
-
- guild = self.guild
- topic = f'User ID: {user.id}'
- channel = discord.utils.get(guild.text_channels, topic=topic)
- categ = discord.utils.get(guild.categories, name='Mod Mail')
- archives = discord.utils.get(guild.categories, name='Mod Mail Archives')
-
- em = discord.Embed(title='Thanks for the message!')
- em.description = 'The moderation team will get back to you as soon as possible!'
- em.color = discord.Color.green()
-
- info_description = None
-
- if creator:
- em = discord.Embed(title='Thread Started')
- second = 'has started a modmail thread with you.' if not reopen else 'has reopened this modmail thread.'
- em.description = f'{creator.mention} ' + second
-
- em.color = discord.Color.green()
-
- info_description = f'{creator.mention} has {"created" if not reopen else "reopened"} a thread with {user.mention}'
-
-
- mention = (self.config.get('MENTION') or '@here') if not creator else None
-
-
- if channel is not None:
- if channel.category is archives: # thread appears to be closed
- if creator: await user.send(embed=em)
- await channel.edit(category=categ)
- info_description = info_description or f'{user.mention} has reopened this thread.'
- await channel.send(mention, embed=self.format_info(user, info_description))
- else:
- await user.send(embed=em)
- channel = await guild.create_text_channel(
- name=self.format_name(user, guild.text_channels),
- category=categ
- )
- await channel.edit(topic=topic)
- await channel.send(mention, embed=self.format_info(user, info_description))
-
- return channel
-
- async def find_user_id_from_channel(self, channel):
- async for message in channel.history():
- if message.embeds:
- em = message.embeds[0]
- matches = re.findall(r'<@(\d+)>', str(em.description))
- if matches:
- return int(matches[0])
-
- @commands.command()
- @trigger_typing
- async def reply(self, ctx, *, msg=''):
- '''Reply to users using this command.
-
- Supports attachments and images as well as automatically embedding image_urls.
- '''
- ctx.message.content = msg
-
- categ = discord.utils.get(ctx.guild.categories, id=ctx.channel.category_id)
- if categ is not None and categ.name == 'Mod Mail':
- if ctx.channel.topic and 'User ID:' in ctx.channel.topic:
- await self.process_reply(ctx.message)
- if not ctx.channel.topic:
- user_id = await self.find_user_id_from_channel(ctx.channel)
- if user_id:
- await self.process_reply(ctx.message, user_id=user_id)
-
- async def _edit_thread_message(self, channel, message_id, message):
- async for msg in channel.history():
- if msg.embeds:
- embed = msg.embeds[0]
- if f'Moderator - {message_id}' in embed.footer.text:
- if ' - (Edited)' not in embed.footer.text:
- embed.set_footer(text=embed.footer.text + ' - (Edited)')
- embed.description = message
- await msg.edit(embed=embed)
- break
-
- def edit_thread_message(self, user, channel, message_id, message):
- return asyncio.gather(
- self._edit_thread_message(user, message_id, message),
- self._edit_thread_message(channel, message_id, message)
- )
-
- @commands.command()
- async def edit(self, ctx, message_id: int, *, new_message):
- '''Edit a message that was sent using the reply command.
-
- `` is the id shown in the footer of thread messages.
- `` is the new message that will be edited in.
- '''
- categ = ctx.channel.category
- if categ and categ.name in 'Mod Mail Archives':
- if ctx.channel.topic and 'User ID:' in ctx.channel.topic:
- user = self.get_user(int(re.findall(r'\d+', ctx.channel.topic)[0]))
- await self.edit_thread_message(user, ctx.channel, message_id, new_message)
- if not ctx.channel.topic:
- user_id = await self.find_user_id_from_channel(ctx.channel)
- if user_id:
- user = self.get_user(user_id)
- await self.edit_thread_message(user, ctx.channel, message_id, new_message)
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(manage_channels=True)
- async def contact(self, ctx, *, user: discord.Member=None):
- '''Create a thread with a specified member.'''
- reopen = False
- if not user and ctx.channel.category and ctx.channel.category.name == 'Mod Mail Archives':
- user_id = None
- if not ctx.channel.topic:
- user_id = await self.find_user_id_from_channel(ctx.channel)
- user_id = user_id or int(ctx.channel.topic.split(': ')[1])
- user = self.get_user(user_id)
- reopen = True
- if not user:
- return await ctx.send('This user does not share any servers with the bot and is thus unreachable.')
-
- if not user:
- raise commands.UserInputError('user must be provided')
-
- categ = discord.utils.get(ctx.guild.categories, id=ctx.channel.category_id)
- channel = await self.find_or_create_thread(user, creator=ctx.author, reopen=reopen)
-
- if channel is not ctx.channel:
- em = discord.Embed(title='Thread reopened' if reopen else 'Created thread')
- em.description = f'Thread {"reopned" if reopen else "started"} in {channel.mention} for {user.mention}'
- em.color = discord.Color.green()
-
- await ctx.send(embed=em)
-
- @commands.command(name="customstatus", aliases=['status', 'presence'])
- @commands.has_permissions(administrator=True)
- async def _status(self, ctx, *, message):
- '''Set a custom playing status for the bot.'''
- if message == 'clear':
- return await self.change_presence(activity=None)
- await self.change_presence(activity=discord.Game(message))
- em = discord.Embed(title='Status Changed')
- em.description = message
- em.color = discord.Color.green()
- em.set_footer(text='Note: this change is temporary.')
- await ctx.send(embed=em)
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(manage_channels=True)
- async def blocked(self, ctx):
- '''Returns a list of blocked users'''
- categ = discord.utils.get(ctx.guild.categories, name='Mod Mail')
- top_chan = categ.channels[0] #bot-info
- ids = re.findall(r'\d+', top_chan.topic)
-
- em = discord.Embed(title='Blocked Users', color=discord.Color.green())
- em.description = ''
-
- users = []
- not_reachable = []
-
- for id in ids:
- user = self.get_user(int(id))
- if user:
- users.append(user)
- else:
- not_reachable.append(id)
-
- em.description = 'Here is a list of blocked users.'
-
- if users:
- em.add_field(name='Currently Known', value=' '.join(u.mention for u in users))
- if not_reachable:
- em.add_field(name='Unknown', value='\n'.join(f'`{i}`' for i in not_reachable), inline=False)
-
- if not users and not not_reachable:
- em.description = 'Currently there are no blocked users'
-
- await ctx.send(embed=em)
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(manage_channels=True)
- async def block(self, ctx, id=None):
- '''Block a user from using modmail.'''
- if id is None:
- if 'User ID:' in str(ctx.channel.topic):
- id = ctx.channel.topic.split('User ID: ')[1].strip()
- else:
- return await ctx.send('No User ID provided.')
-
- categ = discord.utils.get(ctx.guild.categories, name='Mod Mail')
- top_chan = categ.channels[0] #bot-info
- topic = str(top_chan.topic)
- topic += '\n' + id
-
- user = self.get_user(int(id))
- mention = user.mention if user else f'`{id}`'
-
- em = discord.Embed()
- em.color = discord.Color.green()
-
- if id not in top_chan.topic:
- await top_chan.edit(topic=topic)
-
- em.title = 'Success'
- em.description = f'{mention} is now blocked'
-
- await ctx.send(embed=em)
- else:
- em.title = 'Error'
- em.description = f'{mention} is already blocked'
- em.color = discord.Color.red()
-
- await ctx.send(embed=em)
-
- @commands.command()
- @trigger_typing
- @commands.has_permissions(manage_channels=True)
- async def unblock(self, ctx, id=None):
- '''Unblocks a user from using modmail.'''
- if id is None:
- if 'User ID:' in str(ctx.channel.topic):
- id = ctx.channel.topic.split('User ID: ')[1].strip()
- else:
- return await ctx.send('No User ID provided.')
-
- categ = discord.utils.get(ctx.guild.categories, name='Mod Mail')
- top_chan = categ.channels[0] #bot-info
- topic = str(top_chan.topic)
- topic = topic.replace('\n'+id, '')
-
- user = self.get_user(int(id))
- mention = user.mention if user else f'`{id}`'
-
- em = discord.Embed()
- em.color = discord.Color.green()
-
- if id in top_chan.topic:
- await top_chan.edit(topic=topic)
-
- em.title = 'Success'
- em.description = f'{mention} is no longer blocked'
-
- await ctx.send(embed=em)
- else:
- em.title = 'Error'
- em.description = f'{mention} is not already blocked'
- em.color = discord.Color.red()
-
- await ctx.send(embed=em)
-
- @commands.command(hidden=True, name='eval')
- @owner_only()
- async def _eval(self, ctx, *, body: str):
- """Evaluates python code"""
-
- env = {
- 'bot': self,
- 'ctx': ctx,
- 'channel': ctx.channel,
- 'author': ctx.author,
- 'guild': ctx.guild,
- 'message': ctx.message,
- 'source': inspect.getsource
- }
-
- env.update(globals())
-
- body = self.cleanup_code(body)
- stdout = io.StringIO()
- err = out = None
-
- to_compile = f'async def func():\n{textwrap.indent(body, " ")}'
-
- try:
- exec(to_compile, env)
- except Exception as e:
- err = await ctx.send(f'```py\n{e.__class__.__name__}: {e}\n```')
- return await err.add_reaction('\u2049')
-
- func = env['func']
- try:
- with redirect_stdout(stdout):
- ret = await func()
- except Exception as e:
- value = stdout.getvalue()
- err = await ctx.send(f'```py\n{value}{traceback.format_exc()}\n```')
- else:
- value = stdout.getvalue()
- if ret is None:
- if value:
- try:
- out = await ctx.send(f'```py\n{value}\n```')
- except:
- await ctx.send('```Result is too long to send.```')
- else:
- self._last_result = ret
- try:
- out = await ctx.send(f'```py\n{value}{ret}\n```')
- except:
- await ctx.send('```Result is too long to send.```')
- if out:
- await ctx.message.add_reaction('\u2705') #tick
- if err:
- await ctx.message.add_reaction('\u2049') #x
- else:
- await ctx.message.add_reaction('\u2705')
-
- def cleanup_code(self, content):
- """Automatically removes code blocks from the code."""
- # remove ```py\n```
- if content.startswith('```') and content.endswith('```'):
- return '\n'.join(content.split('\n')[1:-1])
- # remove `foo`
- return content.strip('` \n')
-
if __name__ == '__main__':
- bot = Modmail()
- bot.run(bot.token)
+ bot = ModmailBot()
+ bot.run()
diff --git a/cogs/modmail.py b/cogs/modmail.py
new file mode 100644
index 0000000000..1393243b55
--- /dev/null
+++ b/cogs/modmail.py
@@ -0,0 +1,396 @@
+
+import discord
+from discord.ext import commands
+import datetime
+import dateutil.parser
+import re
+from typing import Optional, Union
+from core.decorators import trigger_typing
+from core.paginator import PaginatorSession
+
+
+class Modmail:
+ """Commands directly related to Modmail functionality."""
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.command()
+ @trigger_typing
+ @commands.has_permissions(administrator=True)
+ async def setup(self, ctx):
+ """Sets up a server for modmail"""
+ if self.bot.main_category:
+ return await ctx.send(f'{self.bot.modmail_guild} is already set up.')
+
+ categ = await self.bot.modmail_guild.create_category(
+ name='Mod Mail',
+ overwrites=self.bot.overwrites(ctx)
+ )
+
+ await categ.edit(position=0)
+
+ c = await self.bot.modmail_guild.create_text_channel(name='bot-logs', category=categ)
+ await c.edit(topic='Manually add user id\'s to block users.\n\n'
+ 'Blocked\n-------\n\n')
+
+ await ctx.send('Successfully set up server.')
+
+ @commands.group(name='snippets')
+ @commands.has_permissions(manage_messages=True)
+ async def snippets(self, ctx):
+ """Returns a list of snippets that are currently set."""
+ if ctx.invoked_subcommand is not None:
+ return
+
+ embeds = []
+
+ em = discord.Embed(color=discord.Color.green())
+ em.set_author(name='Snippets', icon_url=ctx.guild.icon_url)
+
+ embeds.append(em)
+
+ em.description = 'Here is a list of snippets that are currently configured.'
+
+ if not self.bot.snippets:
+ em.color = discord.Color.red()
+ em.description = f'You dont have any snippets at the moment.'
+ em.set_footer(text=f'Do {self.bot.prefix}help snippets for more commands.')
+
+ for name, value in self.bot.snippets.items():
+ if len(em.fields) == 5:
+ em = discord.Embed(color=discord.Color.green(), description=em.description)
+ em.set_author(name='Snippets', icon_url=ctx.guild.icon_url)
+ embeds.append(em)
+ em.add_field(name=name, value=value, inline=False)
+
+ session = PaginatorSession(ctx, *embeds)
+ await session.run()
+
+ @snippets.command(name='add')
+ async def _add(self, ctx, name: str.lower, *, value):
+ """Add a snippet to the bot config."""
+ if 'snippets' not in self.bot.config.cache:
+ self.bot.config['snippets'] = {}
+
+ self.bot.config.snippets[name] = value
+ await self.bot.config.update()
+
+ em = discord.Embed(
+ title='Added snippet',
+ color=discord.Color.green(),
+ description=f'`{name}` points to: {value}'
+ )
+
+ await ctx.send(embed=em)
+
+ @snippets.command(name='del')
+ async def __del(self, ctx, *, name: str.lower):
+ """Removes a snippet from bot config."""
+
+ if 'snippets' not in self.bot.config.cache:
+ self.bot.config['snippets'] = {}
+
+ em = discord.Embed(
+ title='Removed snippet',
+ color=discord.Color.green(),
+ description=f'`{name}` no longer exists.'
+ )
+
+ if not self.bot.config.snippets.get(name):
+ em.title = 'Error'
+ em.color = discord.Color.red()
+ em.description = f'Snippet `{name}` does not exist.'
+ else:
+ self.bot.config['snippets'][name] = None
+ await self.bot.config.update()
+
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @commands.has_permissions(manage_channels=True)
+ async def move(self, ctx, *, category: discord.CategoryChannel):
+ """Moves a thread to a specified cateogry."""
+ thread = await self.bot.threads.find(channel=ctx.channel)
+ if not thread:
+ return await ctx.send('This is not a modmail thread.')
+
+ await thread.channel.edit(category=category)
+ await ctx.message.add_reaction('✅')
+
+ @commands.command(name='close')
+ @commands.has_permissions(manage_channels=True)
+ async def _close(self, ctx):
+ """Close the current thread."""
+
+ thread = await self.bot.threads.find(channel=ctx.channel)
+ if not thread:
+ return await ctx.send('This is not a modmail thread.')
+
+ await thread.close()
+
+ em = discord.Embed(title='Thread Closed')
+ em.description = f'{ctx.author.mention} has closed this modmail thread.'
+ em.color = discord.Color.red()
+
+ try:
+ await thread.recipient.send(embed=em)
+ except:
+ pass
+
+ # Logging
+ categ = self.bot.main_category
+ log_channel = categ.channels[0]
+
+ log_data = await self.bot.modmail_api.post_log(ctx.channel.id, {
+ 'open': False, 'closed_at': str(datetime.datetime.utcnow()), 'closer': {
+ 'id': str(ctx.author.id),
+ 'name': ctx.author.name,
+ 'discriminator': ctx.author.discriminator,
+ 'avatar_url': ctx.author.avatar_url,
+ 'mod': True
+ }
+ })
+
+ log_url = f"https://logs.modmail.tk/{log_data['user_id']}/{log_data['key']}"
+
+ user = thread.recipient.mention if thread.recipient else f'`{thread.id}`'
+
+ desc = f"[`{log_data['key']}`]({log_url}) {ctx.author.mention} closed a thread with {user}"
+ em = discord.Embed(description=desc, color=em.color)
+ em.set_author(name='Thread closed', url=log_url)
+ await log_channel.send(embed=em)
+
+ @commands.command()
+ async def nsfw(self, ctx):
+ """Flags a modmail thread as nsfw."""
+ thread = await self.bot.threads.find(channel=ctx.channel)
+ if thread is None:
+ return
+ await ctx.channel.edit(nsfw=True)
+ await ctx.message.add_reaction('✅')
+
+ @commands.command()
+ @trigger_typing
+ async def logs(self, ctx, *, member: Union[discord.Member, discord.User]=None):
+ """Shows a list of previous modmail thread logs of a member."""
+
+ if not member:
+ thread = await self.bot.threads.find(channel=ctx.channel)
+ if not thread:
+ raise commands.UserInputError
+
+ user = member or thread.recipient
+
+ logs = await self.bot.modmail_api.get_user_logs(user.id)
+
+ if not any(not e['open'] for e in logs):
+ return await ctx.send('This user has no previous logs.')
+
+ em = discord.Embed(color=discord.Color.green())
+ em.set_author(name='Previous Logs', icon_url=user.avatar_url)
+
+ embeds = [em]
+
+ current_day = dateutil.parser.parse(logs[0]['created_at']).strftime(r'%d %b %Y')
+
+ fmt = ''
+
+ closed_logs = [l for l in logs if not l['open']]
+
+ for index, entry in enumerate(closed_logs):
+ if len(embeds[-1].fields) == 3:
+ em = discord.Embed(color=discord.Color.green())
+ em.set_author(name='Previous Logs', icon_url=user.avatar_url)
+ embeds.append(em)
+
+ date = dateutil.parser.parse(entry['created_at'])
+ new_day = date.strftime(r'%d %b %Y')
+
+ key = entry['key']
+ user_id = entry['user_id']
+ log_url = f"https://logs.modmail.tk/{user_id}/{key}"
+
+ fmt += f"[`{key}`]({log_url})\n"
+
+ if current_day != new_day or index == len(closed_logs) - 1:
+ embeds[-1].add_field(name=current_day, value=fmt)
+ current_day = new_day
+ fmt = ''
+
+ session = PaginatorSession(ctx, *embeds)
+ await session.run()
+
+ @commands.command()
+ @trigger_typing
+ async def reply(self, ctx, *, msg=''):
+ """Reply to users using this command.
+
+ Supports attachments and images as well as automatically embedding image_urls.
+ """
+ ctx.message.content = msg
+ thread = await self.bot.threads.find(channel=ctx.channel)
+ if thread:
+ await thread.reply(ctx.message)
+
+ @commands.command()
+ async def edit(self, ctx, message_id: Optional[int]=None, *, new_message):
+ """Edit a message that was sent using the reply command.
+
+ If no message_id is provided, that last message sent by a mod will be edited.
+
+ `[message_id]` the id of the message that you want to edit.
+ `` is the new message that will be edited in.
+ """
+ thread = await self.bot.threads.find(channel=ctx.channel)
+
+ if thread is None:
+ return
+
+ linked_message_id = None
+
+ async for msg in ctx.channel.history():
+ if message_id is None and msg.embeds:
+ em = msg.embeds[0]
+ if 'Moderator' not in str(em.footer.text):
+ continue
+ linked_message_id = int(re.findall(r'\d+', em.author.url)[0])
+ break
+ elif message_id and msg.id == message_id:
+ url = msg.embeds[0].author.url
+ linked_message_id = int(re.findall(r'\d+', url)[0])
+ break
+
+ if not linked_message_id:
+ raise commands.UserInputError
+
+ await thread.edit_message(linked_message_id, new_message)
+ await ctx.message.add_reaction('✅')
+
+ @commands.command()
+ @trigger_typing
+ @commands.has_permissions(manage_channels=True)
+ async def contact(self, ctx, *, user: Union[discord.Member, discord.User]):
+ """Create a thread with a specified member."""
+
+ exists = await self.bot.threads.find(recipient=user)
+ if exists:
+ return await ctx.send('Thread already exists.')
+ else:
+ thread = await self.bot.threads.create(user, creator=ctx.author)
+
+ em = discord.Embed(
+ title='Created thread',
+ description=f'Thread started in {thread.channel.mention} for {user.mention}',
+ color=discord.Color.green()
+ )
+
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @trigger_typing
+ @commands.has_permissions(manage_channels=True)
+ async def blocked(self, ctx):
+ """Returns a list of blocked users"""
+ em = discord.Embed(title='Blocked Users', color=discord.Color.green(), description='')
+
+ users = []
+ not_reachable = []
+
+ for id in self.bot.blocked_users:
+ user = self.bot.get_user(id)
+ if user:
+ users.append(user)
+ else:
+ not_reachable.append(id)
+
+ em.description = 'Here is a list of blocked users.'
+
+ if users:
+ em.add_field(name='Currently Known', value=' '.join(u.mention for u in users))
+ if not_reachable:
+ em.add_field(name='Unknown', value='\n'.join(f'`{i}`' for i in not_reachable), inline=False)
+
+ if not users and not not_reachable:
+ em.description = 'Currently there are no blocked users'
+
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @trigger_typing
+ @commands.has_permissions(manage_channels=True)
+ async def block(self, ctx, id=None):
+ """Block a user from using modmail."""
+
+ if id is None:
+ thread = await self.bot.threads.find(channel=ctx.channel)
+ if thread:
+ id = str(thread.recipient.id)
+ else:
+ raise commands.UserInputError
+
+ categ = self.bot.main_category
+ top_chan = categ.channels[0] # bot-info
+ topic = str(top_chan.topic)
+ topic += '\n' + id
+
+ user = self.bot.get_user(int(id))
+ mention = user.mention if user else f'`{id}`'
+
+ em = discord.Embed()
+ em.color = discord.Color.green()
+
+ if id not in top_chan.topic:
+ await top_chan.edit(topic=topic)
+
+ em.title = 'Success'
+ em.description = f'{mention} is now blocked'
+
+ await ctx.send(embed=em)
+ else:
+ em.title = 'Error'
+ em.description = f'{mention} is already blocked'
+ em.color = discord.Color.red()
+
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @trigger_typing
+ @commands.has_permissions(manage_channels=True)
+ async def unblock(self, ctx, id=None):
+ """Unblocks a user from using modmail."""
+ if id is None:
+ thread = await self.bot.threads.find(channel=ctx.channel)
+ if thread:
+ id = str(thread.recipient.id)
+ else:
+ raise commands.UserInputError
+
+ categ = self.bot.main_category
+ top_chan = categ.channels[0] # thread-logs
+ topic = str(top_chan.topic)
+ topic = topic.replace('\n' + id, '')
+
+ user = self.bot.get_user(int(id))
+ mention = user.mention if user else f'`{id}`'
+
+ em = discord.Embed()
+ em.color = discord.Color.green()
+
+ if id in top_chan.topic:
+ await top_chan.edit(topic=topic)
+
+ em.title = 'Success'
+ em.description = f'{mention} is no longer blocked'
+
+ await ctx.send(embed=em)
+ else:
+ em.title = 'Error'
+ em.description = f'{mention} is not blocked'
+ em.color = discord.Color.red()
+
+ await ctx.send(embed=em)
+
+
+def setup(bot):
+ bot.add_cog(Modmail(bot))
diff --git a/cogs/utility.py b/cogs/utility.py
new file mode 100644
index 0000000000..526008ad45
--- /dev/null
+++ b/cogs/utility.py
@@ -0,0 +1,576 @@
+import discord
+from discord.ext import commands
+import datetime
+import traceback
+import inspect
+import io
+import textwrap
+from contextlib import redirect_stdout
+from difflib import get_close_matches
+
+from core.paginator import PaginatorSession
+from core.decorators import auth_required, owner_only, trigger_typing
+
+
+class Utility:
+ """General commands that provide utility"""
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ def format_cog_help(self, ctx, cog):
+ """Formats the text for a cog help"""
+ sigs = []
+ prefix = self.bot.prefix
+
+ for cmd in self.bot.commands:
+ if cmd.hidden:
+ continue
+ if cmd.instance is cog:
+ sigs.append(len(cmd.qualified_name) + len(prefix))
+ if hasattr(cmd, 'all_commands'):
+ for c in cmd.all_commands.values():
+ sigs.append(len('\u200b └─ ' + c.name) + 1)
+
+ if not sigs:
+ return
+
+ maxlen = max(sigs)
+
+ fmt = ['']
+ index = 0
+ for cmd in self.bot.commands:
+ if cmd.instance is cog:
+ if cmd.hidden:
+ continue
+ if len(fmt[index] + f'`{prefix+cmd.qualified_name:<{maxlen}}` - ' + f'{cmd.short_doc:<{maxlen}}\n') > 1024:
+ index += 1
+ fmt.append('')
+ fmt[index] += f'`{prefix+cmd.qualified_name:<{maxlen}}` - '
+ fmt[index] += f'{cmd.short_doc:<{maxlen}}\n'
+ if hasattr(cmd, 'commands'):
+ for i, c in enumerate(cmd.commands):
+ if len(cmd.commands) == i + 1: # last
+ branch = '\u200b └─ ' + c.name
+ else:
+ branch = '\u200b ├─ ' + c.name
+ if len(fmt[index] + f"`{branch:<{maxlen+1}}` - " + f"{c.short_doc:<{maxlen}}\n") > 1024:
+ index += 1
+ fmt.append('')
+ fmt[index] += f"`{branch:<{maxlen+1}}` - "
+ fmt[index] += f"{c.short_doc:<{maxlen}}\n"
+
+ em = discord.Embed(
+ description='*' + inspect.getdoc(cog) + '*',
+ color=discord.Colour.green()
+ )
+ em.set_author(name=cog.__class__.__name__ + ' - Help', icon_url=ctx.bot.user.avatar_url)
+
+ for n, i in enumerate(fmt):
+ if n == 0:
+ em.add_field(name='Commands', value=i)
+ else:
+ em.add_field(name=u'\u200b', value=i)
+
+ em.set_footer(text=f'Type {prefix}command for more info on a command.')
+ return em
+
+ def format_command_help(self, ctx, cmd):
+ """Formats command help."""
+ prefix = self.bot.prefix
+ em = discord.Embed(
+ color=discord.Color.green(),
+ description=cmd.help
+ )
+
+ if hasattr(cmd, 'invoke_without_command') and cmd.invoke_without_command:
+ em.title = f'`Usage: {prefix}{cmd.signature}`'
+ else:
+ em.title = f'`{prefix}{cmd.signature}`'
+
+ if not hasattr(cmd, 'commands'):
+ return em
+
+ maxlen = max(len(prefix + str(c)) for c in cmd.commands)
+ fmt = ''
+
+ for i, c in enumerate(cmd.commands):
+ if len(cmd.commands) == i + 1: # last
+ branch = '└─ ' + c.name
+ else:
+ branch = '├─ ' + c.name
+ fmt += f"`{branch:<{maxlen+1}}` - "
+ fmt += f"{c.short_doc:<{maxlen}}\n"
+
+ em.add_field(name='Subcommands', value=fmt)
+ em.set_footer(text=f'Type {prefix}help {cmd} command for more info on a command.')
+
+ return em
+
+ def format_not_found(self, ctx, command):
+ prefix = ctx.prefix
+ em = discord.Embed()
+ em.title = 'Could not find a cog or command by that name.'
+ em.color = discord.Color.red()
+ em.set_footer(text=f'Type {prefix}help to get a full list of commands.')
+ cogs = get_close_matches(command, self.bot.cogs.keys())
+ cmds = get_close_matches(command, self.bot.all_commands.keys())
+ if cogs or cmds:
+ em.description = 'Did you mean...'
+ if cogs:
+ em.add_field(name='Cogs', value='\n'.join(f'`{x}`' for x in cogs))
+ if cmds:
+ em.add_field(name='Commands', value='\n'.join(f'`{x}`' for x in cmds))
+ return em
+
+ @commands.command()
+ async def help(self, ctx, *, command: str=None):
+ """Shows the help message."""
+
+ await ctx.trigger_typing()
+
+ if command is not None:
+ cog = self.bot.cogs.get(command)
+ cmd = self.bot.get_command(command)
+ if cog is not None:
+ em = self.format_cog_help(ctx, cog)
+ elif cmd is not None:
+ em = self.format_command_help(ctx, cmd)
+ else:
+ em = self.format_not_found(ctx, command)
+ if em:
+ return await ctx.send(embed=em)
+
+ pages = []
+
+ for _, cog in sorted(self.bot.cogs.items()):
+ em = self.format_cog_help(ctx, cog)
+ if em:
+ pages.append(em)
+
+ p_session = PaginatorSession(ctx, *pages)
+
+ await p_session.run()
+
+ @commands.command()
+ @trigger_typing
+ async def about(self, ctx):
+ """Shows information about the bot."""
+ em = discord.Embed(color=discord.Color.green(), timestamp=datetime.datetime.utcnow())
+ em.set_author(name='Mod Mail - Information', icon_url=self.bot.user.avatar_url)
+ em.set_thumbnail(url=self.bot.user.avatar_url)
+
+ em.description = 'This is an open source discord bot made by kyb3r and '\
+ 'improved upon suggestions by the users! This bot serves as a means for members to '\
+ 'easily communicate with server leadership in an organised manner.'
+
+ try:
+ async with self.bot.session.get('https://api.modmail.tk/metadata') as resp:
+ meta = await resp.json()
+ except:
+ meta = None
+
+ em.add_field(name='Uptime', value=self.bot.uptime)
+ if meta:
+ em.add_field(name='Instances', value=meta['instances'])
+ else:
+ em.add_field(name='Latency', value=f'{self.bot.latency*1000:.2f} ms')
+
+ em.add_field(name='Version', value=f'[`{self.bot.version}`](https://github.com/kyb3r/modmail/blob/master/bot.py#L25)')
+ em.add_field(name='Author', value='[`kyb3r`](https://github.com/kyb3r)')
+
+ em.add_field(name='Latest Updates', value=await self.bot.get_latest_updates())
+
+ footer = f'Bot ID: {self.bot.user.id}'
+
+ if meta:
+ if self.bot.version != meta['latest_version']:
+ footer = f"A newer version is available v{meta['latest_version']}"
+ else:
+ footer = 'You are up to date with the latest version.'
+
+ em.add_field(name='Github', value='https://github.com/kyb3r/modmail', inline=False)
+
+ em.set_footer(text=footer)
+
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @owner_only()
+ @auth_required
+ @trigger_typing
+ async def github(self, ctx):
+ """Shows the github user your access token is linked to."""
+ if ctx.invoked_subcommand:
+ return
+
+ data = await self.bot.modmail_api.get_user_info()
+
+ em = discord.Embed(
+ title='Github',
+ description='Current User',
+ color=discord.Color.green()
+ )
+ user = data['user']
+ em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
+ em.set_thumbnail(url=user['avatar_url'])
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @owner_only()
+ @auth_required
+ @trigger_typing
+ async def update(self, ctx):
+ """Updates the bot, this only works with heroku users."""
+ metadata = await self.bot.modmail_api.get_metadata()
+
+ em = discord.Embed(
+ title='Already up to date',
+ description=f'The latest version is [`{self.bot.version}`](https://github.com/kyb3r/modmail/blob/master/bot.py#L25)',
+ color=discord.Color.green()
+ )
+
+ if metadata['latest_version'] == self.bot.version:
+ data = await self.bot.modmail_api.get_user_info()
+ if not data['error']:
+ user = data['user']
+ em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
+ else:
+ data = await self.bot.modmail_api.update_repository()
+
+ commit_data = data['data']
+ user = data['user']
+ em.title = 'Success'
+ em.set_author(name=user['username'], icon_url=user['avatar_url'], url=user['url'])
+ em.set_footer(text=f"Updating modmail v{self.bot.version} -> v{metadata['latest_version']}")
+
+ if commit_data:
+ em.description = 'Bot successfully updated, the bot will restart momentarily'
+ message = commit_data['commit']['message']
+ html_url = commit_data["html_url"]
+ short_sha = commit_data['sha'][:6]
+ em.add_field(name='Merge Commit', value=f"[`{short_sha}`]({html_url}) {message} - {user['username']}")
+ else:
+ em.description = 'Already up to date with master repository.'
+
+ em.add_field(name='Latest Commit', value=await self.bot.get_latest_updates(limit=1), inline=False)
+
+ await ctx.send(embed=em)
+
+ @commands.command(name='status', aliases=['customstatus', 'presence'])
+ @commands.has_permissions(administrator=True)
+ async def _status(self, ctx, *, message):
+ """Set a custom playing status for the bot.
+
+ Set the message to `clear` if you want to remove the playing status.
+ """
+
+ if message == 'clear':
+ self.bot.config['status'] = None
+ await self.bot.config.update()
+ return await self.bot.change_presence(activity=None)
+
+ await self.bot.change_presence(activity=discord.Game(message))
+ self.bot.config['status'] = message
+ await self.bot.config.update()
+
+ em = discord.Embed(title='Status Changed')
+ em.description = message
+ em.color = discord.Color.green()
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @trigger_typing
+ @commands.has_permissions(administrator=True)
+ async def ping(self, ctx):
+ """Pong! Returns your websocket latency."""
+ em = discord.Embed()
+ em.title = 'Pong! Websocket Latency:'
+ em.description = f'{self.bot.ws.latency * 1000:.4f} ms'
+ em.color = 0x00FF00
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @commands.has_permissions(administrator=True)
+ async def mention(self, ctx, *, mention=None):
+ """Changes what the bot mentions at the start of each thread."""
+ current = self.bot.config.get('mention', '@here')
+ em = discord.Embed(
+ title='Current text',
+ color=discord.Color.green(),
+ description=f'{current}'
+ )
+
+ if mention is None:
+ await ctx.send(embed=em)
+ else:
+ em.title = 'Changed mention!'
+ em.description = f'On thread creation the bot now says {mention}'
+ self.bot.config['mention'] = mention
+ await self.bot.config.update()
+ await ctx.send(embed=em)
+
+ @commands.command()
+ @commands.has_permissions(administrator=True)
+ async def prefix(self, ctx, *, prefix=None):
+ """Changes the prefix for the bot."""
+
+ current = self.bot.prefix
+ em = discord.Embed(
+ title='Current prefix',
+ color=discord.Color.green(),
+ description=f'{current}'
+ )
+
+ if prefix is None:
+ await ctx.send(embed=em)
+ else:
+ em.title = 'Changed prefix!'
+ em.description = f'Set prefix to `{prefix}`'
+ self.bot.config['prefix'] = prefix
+ await self.bot.config.update()
+ await ctx.send(embed=em)
+
+ @commands.group()
+ @owner_only()
+ async def config(self, ctx):
+ """Change configuration for the bot.
+
+ You shouldn't have to use these commands as other commands such
+ as `prefix` and `status` should change config vars for you.
+ """
+ if ctx.invoked_subcommand is None:
+ cmd = self.bot.get_command('help')
+ await ctx.invoke(cmd, command='config')
+
+ @config.command(name='set')
+ async def _set(self, ctx, key: str.lower, *, value):
+ """
+ Sets a configuration variable and its value
+ """
+
+ em = discord.Embed(
+ title='Success',
+ color=discord.Color.green(),
+ description=f'Set `{key}` to `{value}`'
+ )
+
+ if key not in self.bot.mutable_config_keys:
+ em.title = 'Error'
+ em.color = discord.Color.green()
+ em.description = f'{key} is an invalid key.'
+ valid_keys = [f'`{k}`' for k in self.bot.mutable_config_keys]
+ em.add_field(name='Valid keys', value=', '.join(valid_keys))
+ else:
+ await self.bot.config.update({key: value})
+
+ await ctx.send(embed=em)
+
+ @config.command(name='del')
+ async def _del(self, ctx, key: str.lower):
+ """Sets a specified key from the config to nothing."""
+ em = discord.Embed(
+ title='Success',
+ color=discord.Color.green(),
+ description=f'Set `{key}` to nothing.'
+ )
+
+ if key not in self.bot.mutable_config_keys:
+ em.title = 'Error'
+ em.color = discord.Color.green()
+ em.description = f'{key} is an invalid key.'
+ valid_keys = [f'`{k}`' for k in self.bot.mutable_config_keys]
+ em.add_field(name='Valid keys', value=', '.join(valid_keys))
+ else:
+ self.bot.config.cache[key] = None
+ await self.bot.config.update()
+
+ await ctx.send(embed=em)
+
+ @config.command(name='get')
+ async def get(self, ctx, key=None):
+ """Shows the config variables that are currently set."""
+ em = discord.Embed(color=discord.Color.green())
+ em.set_author(name='Current config', icon_url=self.bot.user.avatar_url)
+
+ if key and key not in self.bot.mutable_config_keys:
+ em.title = 'Error'
+ em.color = discord.Color.green()
+ em.description = f'`{key}` is an invalid key.'
+ valid_keys = [f'`{k}`' for k in self.bot.mutable_config_keys]
+ em.add_field(name='Valid keys', value=', '.join(valid_keys))
+ elif key:
+ em.set_author(name='Config variable', icon_url=self.bot.user.avatar_url)
+ em.description = f'`{key}` is set to `{self.bot.config.get(key)}`'
+ else:
+ em.description = 'Here is a list of currently set configuration variables.'
+
+ config = {
+ k: v for k, v in self.bot.config.cache.items()
+ if v and k in self.bot.mutable_config_keys
+ }
+
+ for k, v in reversed(list(config.items())):
+ em.add_field(name=k, value=f'`{v}`', inline=False)
+
+ await ctx.send(embed=em)
+
+ @commands.group(name='alias', aliases=['aliases'])
+ @commands.has_permissions(manage_messages=True)
+ async def aliases(self, ctx):
+ """Returns a list of aliases that are currently set."""
+ if ctx.invoked_subcommand is not None:
+ return
+
+ embeds = []
+
+ em = discord.Embed(color=discord.Color.green())
+ em.set_author(name='Command aliases', icon_url=ctx.guild.icon_url)
+
+ embeds.append(em)
+
+ em.description = 'Here is a list of aliases that are currently configured.'
+ em.set_footer(text=f'Do {self.bot.prefix}help aliases for more commands.')
+
+ if not self.bot.aliases:
+ em.color = discord.Color.red()
+ em.description = f'You dont have any aliases at the moment.'
+
+ for name, value in self.bot.aliases.items():
+ if len(em.fields) == 5:
+ em = discord.Embed(color=discord.Color.green(), description=em.description)
+ em.set_author(name='Command aliases', icon_url=ctx.guild.icon_url)
+ em.set_footer(text=f'Do {self.bot.prefix}help aliases for more commands.')
+ embeds.append(em)
+ em.add_field(name=name, value=value, inline=False)
+
+ session = PaginatorSession(ctx, *embeds)
+ await session.run()
+
+ @aliases.command(name='add')
+ async def _add(self, ctx, name: str.lower, *, value):
+ """Add an alias to the bot config."""
+ if 'aliases' not in self.bot.config.cache:
+ self.bot.config['aliases'] = {}
+
+ self.bot.config.aliases[name] = value
+ await self.bot.config.update()
+
+ em = discord.Embed(
+ title='Added alias',
+ color=discord.Color.green(),
+ description=f'`{name}` points to: {value}'
+ )
+
+ await ctx.send(embed=em)
+
+ @aliases.command(name='del')
+ async def __del(self, ctx, *, name: str.lower):
+ """Removes a alias from bot config."""
+
+ if 'aliases' not in self.bot.config.cache:
+ self.bot.config['aliases'] = {}
+
+ em = discord.Embed(
+ title='Removed alias',
+ color=discord.Color.green(),
+ description=f'`{name}` no longer exists.'
+ )
+
+ if not self.bot.config.aliases.get(name):
+ em.title = 'Error'
+ em.color = discord.Color.red()
+ em.description = f'Alias `{name}` does not exist.'
+ else:
+ self.bot.config['aliases'][name] = None
+ await self.bot.config.update()
+
+ await ctx.send(embed=em)
+
+ @commands.command(hidden=True, name='eval')
+ @owner_only()
+ async def _eval(self, ctx, *, body):
+ """Evaluates python code"""
+ env = {
+ 'ctx': ctx,
+ 'bot': self.bot,
+ 'channel': ctx.channel,
+ 'author': ctx.author,
+ 'guild': ctx.guild,
+ 'message': ctx.message,
+ 'source': inspect.getsource,
+ }
+
+ env.update(globals())
+
+ def cleanup_code(content):
+ """Automatically removes code blocks from the code."""
+ # remove ```py\n```
+ if content.startswith('```') and content.endswith('```'):
+ return '\n'.join(content.split('\n')[1:-1])
+
+ # remove `foo`
+ return content.strip('` \n')
+
+ body = cleanup_code(body)
+ stdout = io.StringIO()
+ err = None
+
+ to_compile = f'async def func():\n{textwrap.indent(body, " ")}'
+
+ def paginate(text: str):
+ """Simple generator that paginates text."""
+ last = 0
+ pages = []
+ for curr in range(0, len(text)):
+ if curr % 1980 == 0:
+ pages.append(text[last:curr])
+ last = curr
+ appd_index = curr
+ if appd_index != len(text) - 1:
+ pages.append(text[last:curr])
+ return list(filter(lambda a: a != '', pages))
+
+ try:
+ exec(to_compile, env)
+ except Exception as e:
+ err = await ctx.send(f'```py\n{e.__class__.__name__}: {e}\n```')
+ return await ctx.message.add_reaction('\u2049')
+
+ func = env['func']
+ try:
+ with redirect_stdout(stdout):
+ ret = await func()
+ except Exception:
+ value = stdout.getvalue()
+ err = await ctx.send(f'```py\n{value}{traceback.format_exc()}\n```')
+ else:
+ value = stdout.getvalue()
+ if ret is None:
+ if value:
+ try:
+ await ctx.send(f'```py\n{value}\n```')
+ except:
+ paginated_text = paginate(value)
+ for page in paginated_text:
+ if page == paginated_text[-1]:
+ await ctx.send(f'```py\n{page}\n```')
+ break
+ await ctx.send(f'```py\n{page}\n```')
+ else:
+ try:
+ await ctx.send(f'```py\n{value}{ret}\n```')
+ except:
+ paginated_text = paginate(f"{value}{ret}")
+ for page in paginated_text:
+ if page == paginated_text[-1]:
+ await ctx.send(f'```py\n{page}\n```')
+ break
+ await ctx.send(f'```py\n{page}\n```')
+
+ if err:
+ await ctx.message.add_reaction('\u2049')
+
+
+def setup(bot):
+ bot.add_cog(Utility(bot))
diff --git a/core/api.py b/core/api.py
new file mode 100644
index 0000000000..ed11657fd4
--- /dev/null
+++ b/core/api.py
@@ -0,0 +1,107 @@
+import discord
+
+
+class ApiClient:
+ def __init__(self, app):
+ self.app = app
+ self.session = app.session
+ self.headers = None
+
+ async def request(self, url, method='GET', payload=None):
+ async with self.session.request(method, url, headers=self.headers, json=payload) as resp:
+ try:
+ return await resp.json()
+ except:
+ return await resp.text()
+
+
+class Github(ApiClient):
+ commit_url = 'https://api.github.com/repos/kyb3r/modmail/commits'
+
+ async def get_latest_commits(self, limit=3):
+ resp = await self.request(self.commit_url)
+ for index in range(limit):
+ yield resp[index]
+
+
+class ModmailApiClient(ApiClient):
+
+ base = 'https://api.modmail.tk'
+ github = base + '/github'
+ logs = base + '/logs'
+ config = base + '/config'
+
+ def __init__(self, bot):
+ super().__init__(bot)
+ self.token = bot.config.get('modmail_api_token')
+ if self.token:
+ self.headers = {
+ 'Authorization': 'Bearer ' + self.token
+ }
+
+ def get_user_info(self):
+ return self.request(self.github + '/userinfo')
+
+ def update_repository(self):
+ return self.request(self.github + '/update')
+
+ def get_metadata(self):
+ return self.request(self.base + '/metadata')
+
+ def get_user_logs(self, user_id):
+ return self.request(self.logs + '/user/' + str(user_id))
+
+ def get_log(self, channel_id):
+ return self.request(self.logs + '/' + str(channel_id))
+
+ def get_config(self):
+ return self.request(self.config)
+
+ def update_config(self, data):
+ valid_keys = ['prefix', 'status', 'guild_id', 'mention', 'snippets', 'aliases', 'autoupdates', 'modmail_guild_id']
+ data = {k: v for k, v in data.items() if k in valid_keys}
+ return self.request(self.config, method='PATCH', payload=data)
+
+ def get_log_url(self, recipient, channel, creator):
+ return self.request(self.logs + '/key', payload={
+ 'channel_id': str(channel.id),
+ 'guild_id': str(self.app.guild_id),
+ 'recipient': {
+ 'id': str(recipient.id),
+ 'name': recipient.name,
+ 'discriminator': recipient.discriminator,
+ 'avatar_url': recipient.avatar_url,
+ 'mod': False
+ },
+ 'creator': {
+ 'id': str(creator.id),
+ 'name': creator.name,
+ 'discriminator': creator.discriminator,
+ 'avatar_url': creator.avatar_url,
+ 'mod': isinstance(creator, discord.Member)
+ }
+ })
+
+ def append_log(self, message, channel_id=''):
+ channel_id = str(channel_id) or str(message.channel.id)
+ payload = {
+ 'payload': {
+ 'timestamp': str(message.created_at),
+ 'message_id': str(message.id),
+ # author
+ 'author': {
+ 'id': str(message.author.id),
+ 'name': message.author.name,
+ 'discriminator': message.author.discriminator,
+ 'avatar_url': message.author.avatar_url,
+ 'mod': not isinstance(message.channel, discord.DMChannel),
+ },
+ # message properties
+ 'content': message.content,
+ 'attachments': [i.url for i in message.attachments]
+ }
+ }
+ return self.request(self.logs + f'/{channel_id}', method='PATCH', payload=payload)
+
+ def post_log(self, channel_id, payload):
+ return self.request(self.logs + f'/{channel_id}', method='POST', payload=payload)
diff --git a/core/config.py b/core/config.py
new file mode 100644
index 0000000000..1af2909169
--- /dev/null
+++ b/core/config.py
@@ -0,0 +1,65 @@
+import asyncio
+import os
+import json
+import box
+
+
+class ConfigManager:
+ """Class that manages a cached configuration"""
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.cache = box.Box()
+ self._modified = True
+ self.populate_cache()
+
+ @property
+ def api(self):
+ return self.bot.modmail_api
+
+ def populate_cache(self):
+ try:
+ data = json.load(open('config.json'))
+ except FileNotFoundError:
+ data = {}
+ finally:
+ data.update(os.environ)
+ data = {k.lower(): v for k, v in data.items()}
+ self.cache = data
+
+ self.bot.loop.create_task(self.refresh())
+
+ async def update(self, data=None):
+ """Updates the config with data from the cache"""
+ self._modified = False
+ if data is not None:
+ self.cache.update(data)
+ await self.api.update_config(self.cache)
+
+ async def refresh(self):
+ """Refreshes internal cache with data from database"""
+ data = await self.api.get_config()
+ self.cache.update(data)
+
+ def __getattr__(self, value):
+ return self.cache[value]
+
+ def __setitem__(self, key, item):
+ self.cache[key] = item
+
+ def __getitem__(self, key):
+ return self.cache[key]
+
+ def get(self, value, default=None):
+ return self.cache.get(value) or default
+
+ @property
+ def modified(self):
+ return self._modified
+
+ @modified.setter
+ def modified(self, flag):
+ """If set to true, update() will be called"""
+ if flag is True:
+ asyncio.create_task(self.update())
+ self._modified = True
diff --git a/core/decorators.py b/core/decorators.py
new file mode 100644
index 0000000000..93f52bdf0f
--- /dev/null
+++ b/core/decorators.py
@@ -0,0 +1,40 @@
+import functools
+import discord
+from discord.ext import commands
+import asyncio
+
+def trigger_typing(func):
+ @functools.wraps(func)
+ async def wrapper(self, ctx, *args, **kwargs):
+ await ctx.trigger_typing()
+ return await func(self, ctx, *args, **kwargs)
+ return wrapper
+
+def auth_required(func):
+ @functools.wraps(func)
+ async def wrapper(self, ctx, *args, **kwargs):
+ if self.bot.config.get('modmail_api_token'):
+ return await func(self, ctx, *args, **kwargs)
+ em = discord.Embed(
+ color=discord.Color.red(),
+ title='Unauthorized',
+ description='You can only use this command if you have a configured `MODMAIL_API_TOKEN`. Get your token from https://dashboard.modmail.tk'
+ )
+ await ctx.send(embed=em)
+ return wrapper
+
+def owner_only():
+ async def predicate(ctx):
+ allowed = [int(x) for x in str(ctx.bot.config.get('owners', '0')).split(',')]
+ return ctx.author.id in allowed
+ return commands.check(predicate)
+
+def asyncexecutor(loop=None, executor=None):
+ loop = loop or asyncio.get_event_loop()
+ def decorator(func):
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ partial = functools.partial(func, *args, **kwargs)
+ return loop.run_in_executor(executor, partial)
+ return wrapper
+ return decorator
\ No newline at end of file
diff --git a/utils/paginator.py b/core/paginator.py
similarity index 82%
rename from utils/paginator.py
rename to core/paginator.py
index 4226d4bb96..4aedeff3c1 100644
--- a/utils/paginator.py
+++ b/core/paginator.py
@@ -1,11 +1,9 @@
import discord
-from discord.ext import commands
-from collections import OrderedDict
import asyncio
-import inspect
+
class PaginatorSession:
- '''
+ """
Class that interactively paginates a set of embeds
Parameters
@@ -25,7 +23,7 @@ class PaginatorSession:
Run the interactive session
close:
Forcefully destroy a session
- '''
+ """
def __init__(self, ctx, *embeds, **options):
self.ctx = ctx
self.timeout = options.get('timeout', 60)
@@ -39,17 +37,15 @@ def __init__(self, ctx, *embeds, **options):
'▶': self.next_page,
'⏭': self.last_page,
# '⏹': self.close
- }
-
+ }
+
if options.get('edit_footer', True) and len(self.embeds) > 1:
for i, em in enumerate(self.embeds):
footer_text = f'Page {i+1} of {len(self.embeds)}'
- em.footer.text = options.get('footer_text', em.footer.text)
if em.footer.text:
footer_text = footer_text + ' • ' + em.footer.text
em.set_footer(text=footer_text, icon_url=em.footer.icon_url)
-
def add_page(self, embed):
if isinstance(embed, discord.Embed):
self.embeds.append(embed)
@@ -61,7 +57,7 @@ async def create_base(self, embed):
if len(self.embeds) == 1:
self.running = False
- return
+ return
self.running = True
for reaction in self.reaction_map.keys():
@@ -82,7 +78,7 @@ async def show_page(self, index: int):
await self.create_base(page)
def react_check(self, reaction, user):
- return user.id == self.ctx.author.id and reaction.emoji in self.reaction_map.keys()
+ return reaction.message.id == self.base.id and user.id == self.ctx.author.id and reaction.emoji in self.reaction_map.keys()
async def run(self):
if not self.running:
@@ -100,18 +96,17 @@ async def run(self):
await self.base.remove_reaction(reaction, user)
except:
pass
-
-
+
def previous_page(self):
- '''Go to the previous page.'''
- return self.show_page(self.current-1)
+ """Go to the previous page."""
+ return self.show_page(self.current - 1)
def next_page(self):
- '''Go to the next page'''
- return self.show_page(self.current+1)
+ """Go to the next page"""
+ return self.show_page(self.current + 1)
async def close(self, delete=True):
- '''Delete this embed.'''
+ """Delete this embed."""
self.running = False
try:
@@ -121,16 +116,16 @@ async def close(self, delete=True):
if delete:
return await self.base.delete()
-
+
try:
await self.base.clear_reactions()
except:
pass
def first_page(self):
- '''Go to immediately to the first page'''
+ """Go to immediately to the first page"""
return self.show_page(0)
def last_page(self):
- '''Go to immediately to the last page'''
- return self.show_page(len(self.embeds)-1)
\ No newline at end of file
+ """Go to immediately to the last page"""
+ return self.show_page(len(self.embeds) - 1)
diff --git a/core/thread.py b/core/thread.py
new file mode 100644
index 0000000000..a519f1cac0
--- /dev/null
+++ b/core/thread.py
@@ -0,0 +1,333 @@
+from urllib.parse import urlparse
+import traceback
+import datetime
+import asyncio
+import string
+import re
+import io
+
+import discord
+from discord.ext import commands
+
+from core.decorators import asyncexecutor
+from colorthief import ColorThief
+
+
+class Thread:
+ """Represents a discord modmail thread"""
+
+ def __init__(self, manager, recipient):
+ self.manager = manager
+ self.bot = manager.bot
+ self.id = recipient.id if recipient else None
+ self.recipient = recipient
+ self.channel = None
+ self.ready_event = asyncio.Event()
+
+ def __repr__(self):
+ return f'Thread(recipient="{self.recipient}", channel={self.channel.id})'
+
+ def wait_until_ready(self):
+ """Blocks execution until the thread is fully set up."""
+ return self.ready_event.wait()
+
+ @property
+ def ready(self):
+ return self.ready_event.is_set()
+
+ @ready.setter
+ def ready(self, flag):
+ if flag is True:
+ self.ready_event.set()
+
+ def close(self):
+ del self.manager.cache[self.id]
+ return self.channel.delete()
+
+ async def _edit_thread_message(self, channel, message_id, message):
+ async for msg in channel.history():
+ if not msg.embeds:
+ continue
+ embed = msg.embeds[0]
+ if embed and embed.author:
+ if message_id == int(re.findall(r'\d+', embed.author.url)[0]):
+ if ' - (Edited)' not in embed.footer.text:
+ embed.set_footer(text=embed.footer.text + ' - (Edited)')
+ embed.description = message
+ await msg.edit(embed=embed)
+ break
+
+ def edit_message(self, message_id, message):
+ return asyncio.gather(
+ self._edit_thread_message(self.recipient, message_id, message),
+ self._edit_thread_message(self.channel, message_id, message)
+ )
+
+ async def reply(self, message):
+ if not message.content and not message.attachments:
+ raise commands.UserInputError('msg is a required argument.')
+ if not self.recipient:
+ return await message.channel.send('This user does not share any servers with the bot and is thus unreachable.')
+ await asyncio.gather(
+ self.send(message, self.channel, from_mod=True), # in thread channel
+ self.send(message, self.recipient, from_mod=True) # to user
+ )
+
+ async def send(self, message, destination=None, from_mod=False, delete_message=True):
+ if not self.ready:
+ await self.wait_until_ready()
+
+ destination = destination or self.channel
+ if from_mod and not isinstance(destination, discord.User):
+ asyncio.create_task(self.bot.modmail_api.append_log(message))
+ elif not from_mod:
+ asyncio.create_task(self.bot.modmail_api.append_log(message, destination.id))
+
+ author = message.author
+
+ em = discord.Embed(
+ description=message.content,
+ timestamp=message.created_at
+ )
+
+ em.set_author(name=str(author), icon_url=author.avatar_url, url=f'https://{message.id}.id') # store message id in hidden url
+
+ image_types = ['.png', '.jpg', '.gif', '.jpeg', '.webp']
+ is_image_url = lambda u: any(urlparse(u).path.endswith(x) for x in image_types)
+
+ delete_message = not bool(message.attachments)
+ attachments = list(filter(lambda a: not is_image_url(a.url), message.attachments))
+
+ image_urls = [a.url for a in message.attachments]
+ image_urls.extend(re.findall(r'(https?://[^\s]+)', message.content))
+ image_urls = list(filter(is_image_url, image_urls))
+
+ if image_urls:
+ em.set_image(url=image_urls[0])
+
+ if attachments:
+ att = attachments[0]
+ em.add_field(name='File Attachment', value=f'[{att.filename}]({att.url})')
+
+ if from_mod:
+ em.color = discord.Color.green()
+ em.set_footer(text=f'Moderator')
+ else:
+ em.color = discord.Color.gold()
+ em.set_footer(text=f'User')
+
+ await destination.trigger_typing()
+ await destination.send(embed=em)
+
+ if delete_message:
+ try:
+ await message.delete()
+ except:
+ pass
+
+
+class ThreadManager:
+ """Class that handles storing, finding and creating modmail threads."""
+
+ def __init__(self, bot):
+ self.bot = bot
+ self.cache = {}
+
+ async def populate_cache(self):
+ for channel in self.bot.modmail_guild.text_channels:
+ await self.find(channel=channel)
+
+ def __len__(self):
+ return len(self.cache)
+
+ def __iter__(self):
+ return iter(self.cache.values())
+
+ def __getitem__(self, item):
+ return self.cache[item]
+
+ async def find(self, *, recipient=None, channel=None):
+ """Finds a thread from cache or from discord channel topics."""
+ if recipient is None and channel is not None:
+ return await self._find_from_channel(channel)
+ try:
+ thread = self.cache[recipient.id]
+ except KeyError:
+ channel = discord.utils.get(
+ self.bot.modmail_guild.text_channels,
+ topic=f'User ID: {recipient.id}'
+ )
+ if not channel:
+ thread = None
+ else:
+ self.cache[recipient.id] = thread = Thread(self, recipient)
+ thread.channel = channel
+ thread.ready = True
+ finally:
+ return thread
+
+ async def _find_from_channel(self, channel):
+ """
+ Tries to find a thread from a channel channel topic,
+ if channel topic doesnt exist for some reason, falls back to
+ searching channel history for genesis embed and extracts user_id fron that.
+ """
+ user_id = None
+
+ if channel.topic and 'User ID: ' in channel.topic:
+ user_id = int(re.findall(r'\d+', channel.topic)[0])
+
+ # BUG: This wont work with multiple categories.
+ # elif channel.topic is None:
+ # async for message in channel.history(limit=50):
+ # if message.embeds:
+ # em = message.embeds[0]
+ # matches = re.findall(r'<@(\d+)>', str(em.description))
+ # if matches:
+ # user_id = int(matches[-1])
+ # break
+
+ if user_id is not None:
+ if user_id in self.cache:
+ return self.cache[user_id]
+
+ recipient = self.bot.get_user(user_id) # this could be None
+
+ self.cache[user_id] = thread = Thread(self, recipient)
+ thread.ready = True
+ thread.channel = channel
+ thread.id = user_id
+
+ return thread
+
+ async def create(self, recipient, *, creator=None):
+ """Creates a modmail thread"""
+
+ em = discord.Embed(
+ title='Thread started' if creator else 'Thanks for the message!',
+ description='The moderation team will get back to you as soon as possible!',
+ color=discord.Color.green()
+ )
+
+ if creator is None:
+ asyncio.create_task(recipient.send(embed=em))
+
+ self.cache[recipient.id] = thread = Thread(self, recipient)
+
+ channel = await self.bot.modmail_guild.create_text_channel(
+ name=self._format_channel_name(recipient),
+ category=self.bot.main_category
+ )
+
+ thread.channel = channel
+
+ log_url, log_data, dc = await asyncio.gather(
+ self.bot.modmail_api.get_log_url(recipient, channel, creator or recipient),
+ self.bot.modmail_api.get_user_logs(recipient.id),
+ self.get_dominant_color(recipient.avatar_url)
+ )
+
+ log_count = len(log_data)
+ info_embed = self._format_info_embed(recipient, creator, log_url, log_count, dc)
+
+ topic = f'User ID: {recipient.id}'
+ mention = self.bot.config.get('mention', '@here') if not creator else None
+
+ _, msg = await asyncio.gather(
+ channel.edit(topic=topic),
+ channel.send(mention, embed=info_embed)
+ )
+
+ thread.ready = True
+
+ await msg.pin()
+
+ return thread
+
+ async def find_or_create(self, recipient):
+ return await self.find(recipient=recipient) or await self.create(recipient)
+
+ @staticmethod
+ def valid_image_url(url):
+ """Checks if a url leads to an image."""
+ types = ['.png', '.jpg', '.gif', '.webp']
+ parsed = urlparse(url)
+ if any(parsed.path.endswith(i) for i in types):
+ return url.replace(parsed.query, 'size=128')
+ return False
+
+ @asyncexecutor()
+ def _do_get_dc(self, image, quality):
+ with io.BytesIO(image) as f:
+ return ColorThief(f).get_color(quality=quality)
+
+ async def get_dominant_color(self, url=None, quality=10):
+ """
+ Returns the dominant color of an image from a url
+ (misc)
+ """
+ url = self.valid_image_url(url)
+
+ if not url:
+ raise ValueError('Invalid image url passed.')
+ try:
+ async with self.bot.session.get(url) as resp:
+ image = await resp.read()
+ color = await self._do_get_dc(image, quality)
+ except Exception:
+ traceback.print_exc()
+ return discord.Color.blurple()
+ else:
+ return discord.Color.from_rgb(*color)
+
+ def _format_channel_name(self, author):
+ """Sanitises a username for use with text channel names"""
+ name = author.name.lower()
+ allowed = string.ascii_letters + string.digits + '-'
+ new_name = ''.join(l for l in name if l in allowed) or 'null'
+ new_name += f'-{author.discriminator}'
+
+ while new_name in [c.name for c in self.bot.modmail_guild.text_channels]:
+ new_name += '-x' # two channels with same name
+
+ return new_name
+
+ def _format_info_embed(self, user, creator, log_url, log_count, dc):
+ """Get information about a member of a server
+ supports users from the guild or not."""
+ member = self.bot.guild.get_member(user.id)
+ avi = user.avatar_url
+ time = datetime.datetime.utcnow()
+ desc = f'{creator.mention} has created a thread with {user.mention}' if creator else f'{user.mention} has started a thread'
+ key = log_url.split('/')[-1]
+ desc = f'{desc} [`{key}`]({log_url})'
+
+ if member:
+ roles = sorted(member.roles, key=lambda c: c.position)
+ rolenames = ' '.join([r.mention for r in roles if r.name != "@everyone"])
+
+ em = discord.Embed(colour=dc, description=desc, timestamp=time)
+
+ days = lambda d: (' day ago.' if d == '1' else ' days ago.')
+
+ created = str((time - user.created_at).days)
+ # em.add_field(name='Mention', value=user.mention)
+ em.add_field(name='Registered', value=created + days(created))
+ footer = 'User ID: ' + str(user.id)
+ em.set_footer(text=footer)
+ em.set_author(name=str(user), icon_url=avi)
+ em.set_thumbnail(url=avi)
+
+ if member:
+ if log_count:
+ em.add_field(name='Past logs', value=f'{log_count}')
+ joined = str((time - member.joined_at).days)
+ em.add_field(name='Joined', value=joined + days(joined))
+ # em.add_field(name='Member No.',value=str(member_number),inline = True)
+ em.add_field(name='Nickname', value=member.nick, inline=True)
+ if rolenames:
+ em.add_field(name='Roles', value=rolenames, inline=False)
+ else:
+ em.set_footer(text=footer + ' | Note: this member is not part of this server.')
+
+ return em
diff --git a/requirements.txt b/requirements.txt
index e17e99dbc8..2017cf690e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,6 @@
git+https://github.com/Rapptz/discord.py@rewrite
-colorama
\ No newline at end of file
+colorama
+cachetools
+python-dateutil
+python-box
+colorthief
\ No newline at end of file
diff --git a/runtime.txt b/runtime.txt
new file mode 100644
index 0000000000..881a2db1ab
--- /dev/null
+++ b/runtime.txt
@@ -0,0 +1 @@
+python-3.7.0
diff --git a/utils/api.py b/utils/api.py
deleted file mode 100644
index f1d877d719..0000000000
--- a/utils/api.py
+++ /dev/null
@@ -1,84 +0,0 @@
-from hashlib import sha256
-
-class ApiClient:
- def __init__(self, app):
- self.app = app
- self.session = app.session
- self.headers = None
-
- async def request(self, url, method='GET', payload=None):
- async with self.session.request(method, url, headers=self.headers, json=payload) as resp:
- try:
- return await resp.json()
- except:
- return await resp.text()
-
-
-class Github(ApiClient):
- head = 'https://api.github.com/repos/kyb3r/modmail/git/refs/heads/master'
- merge_url = 'https://api.github.com/repos/{username}/modmail/merges'
- commit_url = 'https://api.github.com/repos/kyb3r/modmail/commits'
-
- def __init__(self, app, access_token=None):
- super().__init__(app)
- self.username = None
- self.avatar_url = None
- self.url = None
- self.headers = None
- if access_token:
- self.headers = {'Authorization': 'token ' + str(access_token)}
-
- @classmethod
- async def login(cls, bot, access_token):
- self = cls(bot, access_token)
- resp = await self.request('https://api.github.com/user')
- self.username = resp['login']
- self.avatar_url = resp['avatar_url']
- self.url = resp['html_url']
- return self
-
- async def get_latest_commits(self, limit=3):
- resp = await self.request(self.commit_url)
- for index in range(limit):
- yield resp[index]
-
- async def update_repository(self, sha=None):
- if sha is None:
- resp = await self.request(self.head)
- sha = resp['object']['sha']
-
- payload = {
- 'base': 'master',
- 'head': sha,
- 'commit_message': 'Updating bot'
- }
-
- merge_url = self.merge_url.format(username=self.username)
-
- resp = await self.request(merge_url, method='POST', payload=payload)
- if isinstance(resp, dict):
- return resp
-
-class ModmailApiClient(ApiClient):
-
- base = 'https://api.kybr.tk/modmail'
- github = base + '/github'
-
- def __init__(self, bot):
- super().__init__(bot)
- self.token = str(sha256(bot.token.encode()).hexdigest()) # added security
- self.headers = {
- 'Authorization': 'Bearer ' + self.token
- }
-
- def get_user_info(self):
- return self.request(self.github + '/userinfo')
-
- def update_repository(self):
- return self.request(self.github + '/update-repository')
-
- def logout(self):
- return self.request(self.github + '/logout')
-
- def get_metadata(self):
- return self.request(self.base)