diff --git a/.gitignore b/.gitignore index a934c91bc29de91fcf5774930e87997fedd337bf..53d274c4bf9aab0f31579f35cb7a06ef9d99de16 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__/* # Secret files */tokens.ini + +*/config.py \ No newline at end of file diff --git a/All-In-One-bot/cogs/admin.py b/All-In-One-bot/cogs/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..4e4202d49033d1fc90b1e44b14f25d8bba36012a --- /dev/null +++ b/All-In-One-bot/cogs/admin.py @@ -0,0 +1,250 @@ +import asyncio +import inspect +import io +import textwrap +import traceback +from contextlib import redirect_stdout + +import discord +from discord.ext import commands + + +# to expose to the eval command + +class Admin: + """Admin-only commands that make the bot dynamic.""" + + def __init__(self, bot): + self.bot = bot + self._last_result = None + self.sessions = set() + + 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') + + async def __local_check(self, ctx): + return await self.bot.is_owner(ctx.author) + def get_syntax_error(self, e): + if e.text is None: + return f'```py\n{e.__class__.__name__}: {e}\n```' + return f'```py\n{e.text}{"^":>{e.offset}}\n{e.__class__.__name__}: {e}```' + + @commands.command(hidden=True) + async def load(self, ctx, *, module): + """Loads a module.""" + try: + self.bot.load_extension(module) + except Exception as e: + await ctx.send(f'```py\n{traceback.format_exc()}\n```') + else: + await ctx.send('\N{OK HAND SIGN}') + + @commands.command(hidden=True) + async def unload(self, ctx, *, module): + """Unloads a module.""" + try: + self.bot.unload_extension(module) + except Exception as e: + await ctx.send(f'```py\n{traceback.format_exc()}\n```') + else: + await ctx.send('\N{OK HAND SIGN}') + + @commands.command(name='reload', hidden=True) + async def _reload(self, ctx, *, module): + """Reloads a module.""" + try: + self.bot.unload_extension(module) + self.bot.load_extension(module) + except Exception as e: + await ctx.send(f'```py\n{traceback.format_exc()}\n```') + else: + await ctx.send('\N{OK HAND SIGN}') + + @commands.command(pass_context=True, hidden=True, name='eval') + async def _eval(self, ctx, *, body: str): + """Evaluates a code""" + + env = { + 'bot': self.bot, + 'ctx': ctx, + 'channel': ctx.channel, + 'author': ctx.author, + 'guild': ctx.guild, + 'message': ctx.message, + '_': self._last_result + } + + env.update(globals()) + + body = self.cleanup_code(body) + stdout = io.StringIO() + + to_compile = f'async def func():\n{textwrap.indent(body, " ")}' + + try: + exec(to_compile, env) + except Exception as e: + return await ctx.send(f'```py\n{e.__class__.__name__}: {e}\n```') + + func = env['func'] + try: + with redirect_stdout(stdout): + ret = await func() + except Exception as e: + value = stdout.getvalue() + await ctx.send(f'```py\n{value}{traceback.format_exc()}\n```') + else: + value = stdout.getvalue() + try: + await ctx.message.add_reaction('\u2705') + except: + pass + + if ret is None: + if value: + await ctx.send(f'```py\n{value}\n```') + else: + self._last_result = ret + await ctx.send(f'```py\n{value}{ret}\n```') + + @commands.command(pass_context=True, hidden=True) + async def repl(self, ctx): + """Launches an interactive REPL session.""" + variables = { + 'ctx': ctx, + 'bot': self.bot, + 'message': ctx.message, + 'guild': ctx.guild, + 'channel': ctx.channel, + 'author': ctx.author, + '_': None, + } + + if ctx.channel.id in self.sessions: + await ctx.send('Already running a REPL session in this channel. Exit it with `quit`.') + return + + self.sessions.add(ctx.channel.id) + await ctx.send('Enter code to execute or evaluate. `exit()` or `quit` to exit.') + + def check(m): + return m.author.id == ctx.author.id and \ + m.channel.id == ctx.channel.id and \ + m.content.startswith('`') + + while True: + try: + response = await self.bot.wait_for('message', check=check, timeout=10.0 * 60.0) + except asyncio.TimeoutError: + await ctx.send('Exiting REPL session.') + self.sessions.remove(ctx.channel.id) + break + + cleaned = self.cleanup_code(response.content) + + if cleaned in ('quit', 'exit', 'exit()'): + await ctx.send('Exiting.') + self.sessions.remove(ctx.channel.id) + return + + executor = exec + if cleaned.count('\n') == 0: + # single statement, potentially 'eval' + try: + code = compile(cleaned, '<repl session>', 'eval') + except SyntaxError: + pass + else: + executor = eval + + if executor is exec: + try: + code = compile(cleaned, '<repl session>', 'exec') + except SyntaxError as e: + await ctx.send(self.get_syntax_error(e)) + continue + + variables['message'] = response + + fmt = None + stdout = io.StringIO() + + try: + with redirect_stdout(stdout): + result = executor(code, variables) + if inspect.isawaitable(result): + result = await result + except Exception as e: + value = stdout.getvalue() + fmt = f'```py\n{value}{traceback.format_exc()}\n```' + else: + value = stdout.getvalue() + if result is not None: + fmt = f'```py\n{value}{result}\n```' + variables['_'] = result + elif value: + fmt = f'```py\n{value}\n```' + + try: + if fmt is not None: + if len(fmt) > 2000: + await ctx.send('Content too big to be printed.') + else: + await ctx.send(fmt) + except discord.Forbidden: + pass + except discord.HTTPException as e: + await ctx.send(f'Unexpected error: `{e}`') + + @commands.command(hidden=True) + async def sql(self, ctx, *, query: str): + """Run some SQL.""" + # the imports are here because I imagine some people would want to use + # this cog as a base for their other cog, and since this one is kinda + # odd and unnecessary for most people, I will make it easy to remove + # for those people. + from .utils.formats import TabularData, Plural + import time + + query = self.cleanup_code(query) + + is_multistatement = query.count(';') > 1 + if is_multistatement: + # fetch does not support multiple statements + strategy = ctx.db.execute + else: + strategy = ctx.db.fetch + + try: + start = time.perf_counter() + results = await strategy(query) + dt = (time.perf_counter() - start) * 1000.0 + except Exception: + return await ctx.send(f'```py\n{traceback.format_exc()}\n```') + + rows = len(results) + if is_multistatement or rows == 0: + return await ctx.send(f'`{dt:.2f}ms: {results}`') + + headers = list(results[0].keys()) + table = TabularData() + table.set_columns(headers) + table.add_rows(list(r.values()) for r in results) + render = table.render() + + fmt = f'```\n{render}\n```\n*Returned {Plural(row=rows)} in {dt:.2f}ms*' + if len(fmt) > 2000: + fp = io.BytesIO(fmt.encode('utf-8')) + await ctx.send('Too many results...', file=discord.File(fp, 'results.txt')) + else: + await ctx.send(fmt) + + +def setup(bot): + bot.add_cog(Admin(bot)) diff --git a/All-In-One-bot/cogs/buttons.py b/All-In-One-bot/cogs/buttons.py new file mode 100644 index 0000000000000000000000000000000000000000..db3ba4b45c3004a7b346f8448e12817864cd37f8 --- /dev/null +++ b/All-In-One-bot/cogs/buttons.py @@ -0,0 +1,631 @@ +from discord.ext import commands +from datetime import datetime +import discord +from .utils import checks +from lxml import etree +import random +import logging +from urllib.parse import quote as uriquote +from lru import LRU +import io + +log = logging.getLogger(__name__) + + +def date(argument): + formats = ( + '%Y/%m/%d', + '%Y-%m-%d', + ) + + for fmt in formats: + try: + return datetime.strptime(argument, fmt) + except ValueError: + continue + + raise commands.BadArgument('Cannot convert to date. Expected YYYY/MM/DD or YYYY-MM-DD.') + + +def can_use_spoiler(): + def predicate(ctx): + if ctx.guild is None: + raise commands.BadArgument('Cannot be used in private messages.') + + my_permissions = ctx.channel.permissions_for(ctx.guild.me) + if not (my_permissions.read_message_history and my_permissions.manage_messages and my_permissions.add_reactions): + raise commands.BadArgument('Need Read Message History, Add Reactions and Manage Messages ' \ + 'to permission to use this. Sorry if I spoiled you.') + return True + return commands.check(predicate) + + +SPOILER_EMOJI_ID = 430469957042831371 + + +class SpoilerCache: + __slots__ = ('author_id', 'channel_id', 'title', 'text', 'attachments') + + def __init__(self, data): + self.author_id = data['author_id'] + self.channel_id = data['channel_id'] + self.title = data['title'] + self.text = data['text'] + self.attachments = data['attachments'] + + def has_single_image(self): + return self.attachments and self.attachments[0].filename.lower().endswith(('.gif', '.png', '.jpg', '.jpeg')) + + def to_embed(self, bot): + embed = discord.Embed(title=f'{self.title} Spoiler', colour=0x01AEEE) + if self.text: + embed.description = self.text + + if self.has_single_image(): + if self.text is None: + embed.title = f'{self.title} Spoiler Image' + embed.set_image(url=self.attachments[0].url) + attachments = self.attachments[1:] + else: + attachments = self.attachments + + if attachments: + value = '\n'.join(f'[{a.filename}]({a.url})' for a in attachments) + embed.add_field(name='Attachments', value=value, inline=False) + + user = bot.get_user(self.author_id) + if user: + embed.set_author(name=str(user), icon_url=user.avatar_url_as(format='png')) + + return embed + + def to_spoiler_embed(self, ctx, storage_message): + description = 'React with <:spoiler:430469957042831371> to reveal the spoiler.' + embed = discord.Embed(title=f'{self.title} Spoiler', description=description) + if self.has_single_image() and self.text is None: + embed.title = f'{self.title} Spoiler Image' + + embed.set_footer(text=storage_message.id) + embed.colour = 0x01AEEE + embed.set_author(name=ctx.author, icon_url=ctx.author.avatar_url_as(format='png')) + return embed + + +class SpoilerCooldown(commands.CooldownMapping): + def __init__(self): + super().__init__(commands.Cooldown(1, 10.0, commands.BucketType.user)) + + def _bucket_key(self, tup): + return tup + + def is_rate_limited(self, message_id, user_id): + bucket = self.get_bucket((message_id, user_id)) + return bucket.update_rate_limit() is not None + + +class Buttons: + """Buttons that make you feel.""" + + def __init__(self, bot): + self.bot = bot + self._spoiler_cache = LRU(128) + self._spoiler_cooldown = SpoilerCooldown() + + @commands.command(hidden=True) + async def feelgood(self, ctx): + """press""" + await ctx.send('*pressed*') + + @commands.command(hidden=True) + async def feelbad(self, ctx): + """depress""" + await ctx.send('*depressed*') + + @commands.command() + async def love(self, ctx): + """What is love?""" + action = random.choice([ctx.send('https://www.youtube.com/watch?v=HEXWRTEbj1I'), await ctx.invoke(self.g, query='define: love')]) + await action + + @commands.command(hidden=True) + async def bored(self, ctx): + """boredom looms""" + await ctx.send('http://i.imgur.com/BuTKSzf.png') + + @commands.command(pass_context=True) + @checks.mod_or_permissions(manage_messages=True) + async def nostalgia(self, ctx, date: date, *, channel: discord.TextChannel = None): + """Pins an old message from a specific date. + + If a channel is not given, then pins from the channel the + command was ran on. + + The format of the date must be either YYYY-MM-DD or YYYY/MM/DD. + """ + channel = channel or ctx.channel + + message = await channel.history(after=date, limit=1).flatten() + + if len(message) == 0: + return await ctx.send('Could not find message.') + + message = message[0] + + try: + await message.pin() + except discord.HTTPException: + await ctx.send('Could not pin message.') + else: + await ctx.send('Pinned message.') + + @nostalgia.error + async def nostalgia_error(self, ctx, error): + if isinstance(error, commands.BadArgument): + await ctx.send(error) + + def parse_google_card(self, node): + e = discord.Embed(colour=discord.Colour.blurple()) + + # check if it's a calculator card: + calculator = node.find(".//span[@class='cwclet']") + if calculator is not None: + e.title = 'Calculator' + result = node.find(".//span[@class='cwcot']") + if result is not None: + result = ' '.join((calculator.text, result.text.strip())) + else: + result = calculator.text + ' ???' + e.description = result + return e + + # check for unit conversion card + + unit_conversions = node.xpath(".//input[contains(@class, '_eif') and @value]") + if len(unit_conversions) == 2: + e.title = 'Unit Conversion' + + # the <input> contains our values, first value = second value essentially. + # these <input> also have siblings with <select> and <option selected=1> + # that denote what units we're using + + # We will get 2 <option selected="1"> nodes by traversing the parent + # The first unit being converted (e.g. Miles) + # The second unit being converted (e.g. Feet) + + xpath = etree.XPath("parent::div/select/option[@selected='1']/text()") + try: + first_node = unit_conversions[0] + first_unit = xpath(first_node)[0] + first_value = float(first_node.get('value')) + second_node = unit_conversions[1] + second_unit = xpath(second_node)[0] + second_value = float(second_node.get('value')) + e.description = ' '.join((str(first_value), first_unit, '=', str(second_value), second_unit)) + except Exception: + return None + else: + return e + + # check for currency conversion card + if 'currency' in node.get('class', ''): + currency_selectors = node.xpath(".//div[@class='ccw_unit_selector_cnt']") + if len(currency_selectors) == 2: + e.title = 'Currency Conversion' + # Inside this <div> is a <select> with <option selected="1"> nodes + # just like the unit conversion card. + + first_node = currency_selectors[0] + first_currency = first_node.find("./select/option[@selected='1']") + + second_node = currency_selectors[1] + second_currency = second_node.find("./select/option[@selected='1']") + + # The parent of the nodes have a <input class='vk_gy vk_sh ccw_data' value=...> + xpath = etree.XPath("parent::td/parent::tr/td/input[@class='vk_gy vk_sh ccw_data']") + try: + first_value = float(xpath(first_node)[0].get('value')) + second_value = float(xpath(second_node)[0].get('value')) + + values = ( + str(first_value), + first_currency.text, + f'({first_currency.get("value")})', + '=', + str(second_value), + second_currency.text, + f'({second_currency.get("value")})' + ) + e.description = ' '.join(values) + except Exception: + return None + else: + return e + + # check for generic information card + info = node.find(".//div[@class='_f2g']") + if info is not None: + try: + e.title = ''.join(info.itertext()).strip() + actual_information = info.xpath("parent::div/parent::div//div[@class='_XWk' or contains(@class, 'kpd-ans')]")[0] + e.description = ''.join(actual_information.itertext()).strip() + except Exception: + return None + else: + return e + + # check for translation card + translation = node.find(".//div[@id='tw-ob']") + if translation is not None: + src_text = translation.find(".//pre[@id='tw-source-text']/span") + src_lang = translation.find(".//select[@id='tw-sl']/option[@selected='1']") + + dest_text = translation.find(".//pre[@id='tw-target-text']/span") + dest_lang = translation.find(".//select[@id='tw-tl']/option[@selected='1']") + + # TODO: bilingual dictionary nonsense? + + e.title = 'Translation' + try: + e.add_field(name=src_lang.text, value=src_text.text, inline=True) + e.add_field(name=dest_lang.text, value=dest_text.text, inline=True) + except Exception: + return None + else: + return e + + # check for "time in" card + time = node.find("./div[@class='vk_bk vk_ans']") + if time is not None: + date = node.find("./div[@class='vk_gy vk_sh']") + try: + e.title = node.find('span').text + e.description = f'{time.text}\n{"".join(date.itertext()).strip()}' + except Exception: + return None + else: + return e + + # time in has an alternative form without spans + time = node.find("./div/div[@class='vk_bk vk_ans _nEd']") + if time is not None: + converted = "".join(time.itertext()).strip() + try: + # remove the in-between text + parent = time.getparent() + parent.remove(time) + original = "".join(parent.itertext()).strip() + e.title = 'Time Conversion' + e.description = f'{original}...\n{converted}' + except Exception: + return None + else: + return e + + # check for definition card + words = node.xpath(".//span[@data-dobid='hdw']") + if words: + lex = etree.XPath(".//div[@class='lr_dct_sf_h']/i/span") + + # this one is derived if we were based on the position from lex + xpath = etree.XPath("../../../ol[@class='lr_dct_sf_sens']//" \ + "div[not(@class and @class='lr_dct_sf_subsen')]/" \ + "div[@class='_Jig']/div[@data-dobid='dfn']/span") + for word in words: + # we must go two parents up to get the root node + root = word.getparent().getparent() + + pronunciation = root.find(".//span[@class='lr_dct_ph']/span") + if pronunciation is None: + continue + + lexical_category = lex(root) + definitions = xpath(root) + + for category in lexical_category: + definitions = xpath(category) + try: + descrip = [f'*{category.text}*'] + for index, value in enumerate(definitions, 1): + descrip.append(f'{index}. {value.text}') + + e.add_field(name=f'{word.text} /{pronunciation.text}/', value='\n'.join(descrip)) + except: + continue + + return e + + # check for weather card + location = node.find("./div[@id='wob_loc']") + if location is None: + return None + + + # these units should be metric + + date = node.find("./div[@id='wob_dts']") + + # <img alt="category here" src="cool image"> + category = node.find(".//img[@id='wob_tci']") + + xpath = etree.XPath(".//div[@id='wob_d']//div[contains(@class, 'vk_bk')]//span[@class='wob_t']") + temperatures = xpath(node) + + misc_info_node = node.find(".//div[@class='vk_gy vk_sh wob-dtl']") + + if misc_info_node is None: + return None + + precipitation = misc_info_node.find("./div/span[@id='wob_pp']") + humidity = misc_info_node.find("./div/span[@id='wob_hm']") + wind = misc_info_node.find("./div/span/span[@id='wob_tws']") + + + try: + e.title = 'Weather for ' + location.text.strip() + e.description = f'*{category.get("alt")}*' + e.set_thumbnail(url='https:' + category.get('src')) + + if len(temperatures) == 4: + first_unit = temperatures[0].text + temperatures[2].text + second_unit = temperatures[1].text + temperatures[3].text + units = f'{first_unit} | {second_unit}' + else: + units = 'Unknown' + + e.add_field(name='Temperature', value=units, inline=False) + + if precipitation is not None: + e.add_field(name='Precipitation', value=precipitation.text) + + if humidity is not None: + e.add_field(name='Humidity', value=humidity.text) + + if wind is not None: + e.add_field(name='Wind', value=wind.text) + except: + return None + + return e + + async def get_google_entries(self, query): + url = f'https://www.google.com/search?q={uriquote(query)}' + params = { + 'safe': 'on', + 'lr': 'lang_en', + 'hl': 'en' + } + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) Gecko/20100101 Firefox/53.0' + } + + # list of URLs and title tuples + entries = [] + + # the result of a google card, an embed + card = None + + async with self.bot.session.get(url, params=params, headers=headers) as resp: + if resp.status != 200: + log.info('Google failed to respond with %s status code.', resp.status) + raise RuntimeError('Google has failed to respond.') + + root = etree.fromstring(await resp.text(), etree.HTMLParser()) + + # for bad in root.xpath('//style'): + # bad.getparent().remove(bad) + + # for bad in root.xpath('//script'): + # bad.getparent().remove(bad) + + # with open('google.html', 'w', encoding='utf-8') as f: + # f.write(etree.tostring(root, pretty_print=True).decode('utf-8')) + + """ + Tree looks like this.. sort of.. + + <div class="rc"> + <h3 class="r"> + <a href="url here">title here</a> + </h3> + </div> + """ + + card_node = root.xpath(".//div[@id='rso']/div[@class='_NId']//" \ + "div[contains(@class, 'vk_c') or @class='g mnr-c g-blk' or @class='kp-blk']") + + if card_node is None or len(card_node) == 0: + card = None + else: + card = self.parse_google_card(card_node[0]) + + search_results = root.findall(".//div[@class='rc']") + # print(len(search_results)) + for node in search_results: + link = node.find("./h3[@class='r']/a") + if link is not None: + # print(etree.tostring(link, pretty_print=True).decode()) + entries.append((link.get('href'), link.text)) + + return card, entries + + @commands.command(aliases=['google']) + async def g(self, ctx, *, query): + """Searches google and gives you top result.""" + await ctx.trigger_typing() + try: + card, entries = await self.get_google_entries(query) + except RuntimeError as e: + await ctx.send(str(e)) + else: + if card: + value = '\n'.join(f'[{title}]({url.replace(")", "%29")})' for url, title in entries[:3]) + if value: + card.add_field(name='Search Results', value=value, inline=False) + return await ctx.send(embed=card) + + if len(entries) == 0: + return await ctx.send('No results found... sorry.') + + next_two = [x[0] for x in entries[1:3]] + first_entry = entries[0][0] + if first_entry[-1] == ')': + first_entry = first_entry[:-1] + '%29' + + if next_two: + formatted = '\n'.join(f'<{x}>' for x in next_two) + msg = f'{first_entry}\n\n**See also:**\n{formatted}' + else: + msg = first_entry + + await ctx.send(msg) + + @commands.command() + @commands.is_owner() + async def pm(self, ctx, user_id: int, *, content: str): + user = self.bot.get_user(user_id) + + fmt = content + '\n\n*This is a DM sent because you had previously requested feedback or I found a bug' \ + ' in a command you used, I do not monitor this DM.*' + try: + await user.send(fmt) + except: + await ctx.send(f'Could not PM user by ID {user_id}.') + else: + await ctx.send('PM successfully sent.') + + async def redirect_post(self, ctx, title, text): + storage = self.bot.get_guild(182325885867786241).get_channel(430229522340773899) + + supported_attachments = ('.png', '.jpg', '.jpeg', '.webm', '.gif', '.mp4', '.txt') + if not all(attach.filename.lower().endswith(supported_attachments) for attach in ctx.message.attachments): + raise RuntimeError(f'Unsupported file in attachments. Only {", ".join(supported_attachments)} supported.') + + files = [] + total_bytes = 0 + eight_mib = 8 * 1024 * 1024 + for attach in ctx.message.attachments: + async with ctx.session.get(attach.url) as resp: + if resp.status != 200: + continue + + content_length = int(resp.headers.get('Content-Length')) + + # file too big, skip it + if (total_bytes + content_length) > eight_mib: + continue + + total_bytes += content_length + fp = io.BytesIO(await resp.read()) + files.append(discord.File(fp, filename=attach.filename)) + + if total_bytes >= eight_mib: + break + + await ctx.message.delete() + data = discord.Embed(title=title) + if text: + data.description = text + + data.set_author(name=ctx.author.id) + data.set_footer(text=ctx.channel.id) + + try: + message = await storage.send(embed=data, files=files) + except discord.HTTPException as e: + raise RuntimeError(f'Sorry. Could not store message due to {e.__class__.__name__}: {e}.') from e + + to_dict = { + 'author_id': ctx.author.id, + 'channel_id': ctx.channel.id, + 'attachments': message.attachments, + 'title': title, + 'text': text + } + + cache = SpoilerCache(to_dict) + return message, cache + + async def get_spoiler_cache(self, channel_id, message_id): + try: + return self._spoiler_cache[message_id] + except KeyError: + pass + + storage = self.bot.get_guild(182325885867786241).get_channel(430229522340773899) + + # slow path requires 2 lookups + # first is looking up the message_id of the original post + # to get the embed footer information which points to the storage message ID + # the second is getting the storage message ID and extracting the information from it + channel = self.bot.get_channel(channel_id) + if not channel: + return None + + try: + original_message = await channel.get_message(message_id) + storage_message_id = int(original_message.embeds[0].footer.text) + message = await storage.get_message(storage_message_id) + except: + # this message is probably not the proper format or the storage died + return None + + data = message.embeds[0] + to_dict = { + 'author_id': int(data.author.name), + 'channel_id': int(data.footer.text), + 'attachments': message.attachments, + 'title': data.title, + 'text': None if not data.description else data.description + } + cache = SpoilerCache(to_dict) + self._spoiler_cache[message_id] = cache + return cache + + async def on_raw_reaction_add(self, payload): + if payload.emoji.id != SPOILER_EMOJI_ID: + return + + user = self.bot.get_user(payload.user_id) + if not user or user.bot: + return + + if self._spoiler_cooldown.is_rate_limited(payload.message_id, payload.user_id): + return + + cache = await self.get_spoiler_cache(payload.channel_id, payload.message_id) + embed = cache.to_embed(self.bot) + await user.send(embed=embed) + + @commands.command() + @can_use_spoiler() + async def spoiler(self, ctx, title, *, text=None): + """Marks your post a spoiler with a title. + + Once your post is marked as a spoiler it will be + automatically deleted and the bot will DM those who + opt-in to view the spoiler. + + The only media types supported are png, gif, jpeg, mp4, + and webm. + + Only 8MiB of total media can be uploaded at once. + Sorry, Discord limitation. + + To opt-in to a post's spoiler you must click the reaction. + """ + + if len(title) > 100: + return await ctx.send('Sorry. Title has to be shorter than 100 characters.') + + try: + storage_message, cache = await self.redirect_post(ctx, title, text) + except Exception as e: + return await ctx.send(str(e)) + + spoiler_message = await ctx.send(embed=cache.to_spoiler_embed(ctx, storage_message)) + self._spoiler_cache[spoiler_message.id] = cache + await spoiler_message.add_reaction(':spoiler:430469957042831371') + +def setup(bot): + bot.add_cog(Buttons(bot)) \ No newline at end of file diff --git a/All-In-One-bot/cogs/config.py b/All-In-One-bot/cogs/config.py new file mode 100644 index 0000000000000000000000000000000000000000..28e2d570f42ca51ae1329685cabb5428b54f448c --- /dev/null +++ b/All-In-One-bot/cogs/config.py @@ -0,0 +1,425 @@ +from discord.ext import commands +from .utils import db, checks, cache +from .utils.paginator import Pages + +from collections import defaultdict + + +class LazyEntity: + """This is meant for use with the Paginator. + + It lazily computes __str__ when requested and + caches it so it doesn't do the lookup again. + """ + __slots__ = ('entity_id', 'guild', '_cache') + + def __init__(self, guild, entity_id): + self.entity_id = entity_id + self.guild = guild + self._cache = None + + def __str__(self): + if self._cache: + return self._cache + + e = self.entity_id + g = self.guild + resolved = g.get_channel(e) or g.get_member(e) + if resolved is None: + self._cache = f'<Not Found: {e}>' + else: + self._cache = resolved.mention + return self._cache + +class ChannelOrMember(commands.Converter): + async def convert(self, ctx, argument): + try: + return await commands.TextChannelConverter().convert(ctx, argument) + except commands.BadArgument: + return await commands.MemberConverter().convert(ctx, argument) + +class Plonks(db.Table): + id = db.PrimaryKeyColumn() + guild_id = db.Column(db.Integer(big=True), index=True) + + # this can either be a channel_id or an author_id + entity_id = db.Column(db.Integer(big=True), index=True, unique=True) + +class CommandConfig(db.Table, table_name='command_config'): + id = db.PrimaryKeyColumn() + + guild_id = db.Column(db.Integer(big=True), index=True) + channel_id = db.Column(db.Integer(big=True)) + + name = db.Column(db.String) + whitelist = db.Column(db.Boolean) + + @classmethod + def create_table(cls, *, exists_ok=True): + statement = super().create_table(exists_ok=exists_ok) + # create the unique index + sql = "CREATE UNIQUE INDEX IF NOT EXISTS command_config_uniq_idx ON command_config (channel_id, name, whitelist);" + return statement + '\n' + sql + +class CommandName(commands.Converter): + async def convert(self, ctx, argument): + lowered = argument.lower() + + valid_commands = { + c.qualified_name + for c in ctx.bot.walk_commands() + if c.cog_name not in ('Config', 'Admin') + } + + if lowered not in valid_commands: + raise commands.BadArgument('That command name is not valid.') + + return lowered + +class ResolvedCommandPermissions: + class _Entry: + __slots__ = ('allow', 'deny') + def __init__(self): + self.allow = set() + self.deny = set() + + def __init__(self, guild_id, records): + self.guild_id = guild_id + + self._lookup = defaultdict(self._Entry) + + # channel_id: { allow: [commands], deny: [commands] } + + for name, channel_id, whitelist in records: + entry = self._lookup[channel_id] + if whitelist: + entry.allow.add(name) + else: + entry.deny.add(name) + + def _split(self, obj): + # "hello there world" -> ["hello", "hello there", "hello there world"] + from itertools import accumulate + return list(accumulate(obj.split(), lambda x, y: f'{x} {y}')) + + def is_blocked(self, ctx): + # fast path + if len(self._lookup) == 0: + return False + + if ctx.author.guild_permissions.manage_guild: + return False + + command_names = self._split(ctx.command.qualified_name) + + guild = self._lookup[None] # no special channel_id + channel = self._lookup[ctx.channel.id] + + blocked = None + + # apply guild-level denies first + # then guild-level allow + # then channel-level deny + # then channel-level allow + + # use ?foo bar + # ?foo bar <- guild allow + # ?foo <- channel block + # result: blocked + # this is why the two for loops are separate + + for command in command_names: + if command in guild.deny: + blocked = True + + if command in guild.allow: + blocked = False + + for command in command_names: + if command in channel.deny: + blocked = True + + if command in channel.allow: + blocked = False + + return blocked + +class Config: + """Handles the bot's configuration system. + + This is how you disable or enable certain commands + for your server or block certain channels or members. + """ + + def __init__(self, bot): + self.bot = bot + + async def is_plonked(self, guild_id, member_id, *, channel_id=None, connection=None, check_bypass=True): + if check_bypass: + guild = self.bot.get_guild(guild_id) + if guild is not None: + member = guild.get_member(member_id) + if member is not None and member.guild_permissions.manage_guild: + return False + + connection = connection or self.bot.pool + + if channel_id is None: + query = "SELECT 1 FROM plonks WHERE guild_id=$1 AND entity_id=$2;" + row = await connection.fetchrow(query, guild_id, member_id) + else: + query = "SELECT 1 FROM plonks WHERE guild_id=$1 AND entity_id IN ($2, $3);" + row = await connection.fetchrow(query, guild_id, member_id, channel_id) + + return row is not None + + async def __global_check_once(self, ctx): + if ctx.guild is None: + return True + + is_owner = await ctx.bot.is_owner(ctx.author) + if is_owner: + return True + + # see if they can bypass: + bypass = ctx.author.guild_permissions.manage_guild + if bypass: + return True + + # check if we're plonked + is_plonked = await self.is_plonked(ctx.guild.id, ctx.author.id, channel_id=ctx.channel.id, + connection=ctx.db, check_bypass=False) + + return not is_plonked + + @cache.cache(strategy=cache.Strategy.raw) + async def get_command_permissions(self, guild_id, *, connection=None): + connection = connection or self.bot.pool + query = "SELECT name, channel_id, whitelist FROM command_config WHERE guild_id=$1;" + + records = await connection.fetch(query, guild_id) + return ResolvedCommandPermissions(guild_id, records) + + async def __global_check(self, ctx): + if ctx.guild is None: + return True + + is_owner = await ctx.bot.is_owner(ctx.author) + if is_owner: + return True + + resolved = await self.get_command_permissions(ctx.guild.id, connection=ctx.db) + return not resolved.is_blocked(ctx) + + async def _bulk_ignore_entries(self, ctx, entries): + async with ctx.db.transaction(): + query = "SELECT entity_id FROM plonks WHERE guild_id=$1;" + records = await ctx.db.fetch(query, ctx.guild.id) + + # we do not want to insert duplicates + current_plonks = {r[0] for r in records} + guild_id = ctx.guild.id + to_insert = [(guild_id, e.id) for e in entries if e.id not in current_plonks] + + # do a bulk COPY + await ctx.db.copy_records_to_table('plonks', columns=('guild_id', 'entity_id'), records=to_insert) + + async def __error(self, ctx, error): + if isinstance(error, commands.BadArgument): + await ctx.send(error) + + @commands.group() + async def config(self, ctx): + """Handles the server or channel permission configuration for the bot.""" + if ctx.invoked_subcommand is None: + await ctx.show_help('config') + + @config.group(invoke_without_command=True, aliases=['plonk']) + @checks.is_mod() + async def ignore(self, ctx, *entities: ChannelOrMember): + """Ignores text channels or members from using the bot. + + If no channel or member is specified, the current channel is ignored. + + Users with Manage Server can still use the bot, regardless of ignore + status. + + To use this command you must have Manage Server permissions. + """ + + if len(entities) == 0: + # shortcut for a single insert + query = "INSERT INTO plonks (guild_id, entity_id) VALUES ($1, $2) ON CONFLICT DO NOTHING;" + await ctx.db.execute(query, ctx.guild.id, ctx.channel.id) + else: + await self._bulk_ignore_entries(ctx, entities) + + await ctx.send(ctx.tick(True)) + + @ignore.command(name='list') + @checks.is_mod() + @commands.cooldown(2.0, 60.0, commands.BucketType.guild) + async def ignore_list(self, ctx): + """Tells you what channels or members are currently ignored in this server. + + To use this command you must have Manage Server permissions. + """ + + query = "SELECT entity_id FROM plonks WHERE guild_id=$1;" + + guild = ctx.guild + records = await ctx.db.fetch(query, guild.id) + + if len(records) == 0: + return await ctx.send('I am not ignoring anything here.') + + entries = [LazyEntity(guild, r[0]) for r in records] + await ctx.release() + + try: + p = Pages(ctx, entries=entries, per_page=20) + await p.paginate() + except Exception as e: + await ctx.send(str(e)) + + @ignore.command(name='all') + @checks.is_mod() + async def _all(self, ctx): + """Ignores every channel in the server from being processed. + + This works by adding every channel that the server currently has into + the ignore list. If more channels are added then they will have to be + ignored by using the ignore command. + + To use this command you must have Manage Server permissions. + """ + await self._bulk_ignore_entries(ctx, ctx.guild.text_channels) + await ctx.send('Successfully blocking all channels here.') + + @ignore.command(name='clear') + @checks.is_mod() + async def ignore_clear(self, ctx): + """Clears all the currently set ignores. + + To use this command you must have Manage Server permissions. + """ + + query = "DELETE FROM plonks WHERE guild_id=$1;" + await ctx.db.execute(query, ctx.guild.id) + await ctx.send('Successfully cleared all ignores.') + + @config.group(pass_context=True, invoke_without_command=True, aliases=['unplonk']) + @checks.is_mod() + async def unignore(self, ctx, *entities: ChannelOrMember): + """Allows channels or members to use the bot again. + + If nothing is specified, it unignores the current channel. + + To use this command you must have the Manage Server permission. + """ + + if len(entities) == 0: + query = "DELETE FROM plonks WHERE guild_id=$1 AND entity_id=$2;" + await ctx.db.execute(query, ctx.guild.id, ctx.channel.id) + else: + query = "DELETE FROM plonks WHERE guild_id=$1 AND entity_id = ANY($2::bigint[]);" + entities = [c.id for c in entities] + await ctx.db.execute(query, ctx.guild.id, entities) + + await ctx.send(ctx.tick(True)) + + @unignore.command(name='all') + @checks.is_mod() + async def unignore_all(self, ctx): + """An alias for ignore clear command.""" + await ctx.invoke(self.ignore_clear) + + @config.group(aliases=['guild']) + @checks.is_mod() + async def server(self, ctx): + """Handles the server-specific permissions.""" + pass + + @config.group() + @checks.is_mod() + async def channel(self, ctx): + """Handles the channel-specific permissions.""" + pass + + async def command_toggle(self, connection, guild_id, channel_id, name, *, whitelist=True): + # clear the cache + self.get_command_permissions.invalidate(self, guild_id) + + query = "DELETE FROM command_config WHERE guild_id=$1 AND name=$2 AND channel_id=$3 AND whitelist=$4;" + + # DELETE <num> + status = await connection.execute(query, guild_id, name, channel_id, whitelist) + if status[-1] != '0': + return + + query = "INSERT INTO command_config (guild_id, channel_id, name, whitelist) VALUES ($1, $2, $3, $4);" + + try: + await connection.execute(query, guild_id, channel_id, name, whitelist) + except asyncpg.UniqueViolationError: + msg = 'This command is already disabled.' if not whitelist else 'This command is already explicitly enabled.' + raise RuntimeError('This command is already disabled.') + + @channel.command(name='disable') + async def channel_disable(self, ctx, *, command: CommandName): + """Disables a command for this channel.""" + + try: + await self.command_toggle(ctx.db, ctx.guild.id, ctx.channel.id, command, whitelist=False) + except RuntimeError as e: + await ctx.send(e) + else: + await ctx.send('Command successfully disabled for this channel.') + + @channel.command(name='enable') + async def channel_enable(self, ctx, *, command: CommandName): + """Enables a command for this channel.""" + + try: + await self.command_toggle(ctx.db, ctx.guild.id, ctx.channel.id, command, whitelist=True) + except RuntimeError as e: + await ctx.send(e) + else: + await ctx.send('Command successfully enabled for this channel.') + + @server.command(name='disable') + async def server_disable(self, ctx, *, command: CommandName): + """Disables a command for this server.""" + + try: + await self.command_toggle(ctx.db, ctx.guild.id, None, command, whitelist=False) + except RuntimeError as e: + await ctx.send(e) + else: + await ctx.send('Command successfully disabled for this server') + + @server.command(name='enable') + async def server_enable(self, ctx, *, command: CommandName): + """Enables a command for this server.""" + + try: + await self.command_toggle(ctx.db, ctx.guild.id, None, command, whitelist=True) + except RuntimeError as e: + await ctx.send(e) + else: + await ctx.send('Command successfully enabled for this server.') + + @config.command(name='enable') + @checks.is_mod() + async def config_enable(self, ctx, *, command: CommandName): + """Enables a command for this server.""" + await ctx.invoke(self.server_enable, command=command) + + @config.command(name='disable') + @checks.is_mod() + async def config_disable(self, ctx, *, command: CommandName): + """Disables a command for this server.""" + await ctx.invoke(self.server_disable, command=command) + +def setup(bot): + bot.add_cog(Config(bot)) diff --git a/All-In-One-bot/cogs/general.py b/All-In-One-bot/cogs/general.py index 38dc6bbc580440ae37faef9b27e9efc6ed2dd35c..9c76697a8ce41db5f1dd9a1fdbfd52329152ec04 100644 --- a/All-In-One-bot/cogs/general.py +++ b/All-In-One-bot/cogs/general.py @@ -10,31 +10,31 @@ class General: def __init__(self, bot): self.bot = bot - @commands.command(name="ping",brief="Ping pong") - async def ping(self): - await self.bot.say("Pong!") + @commands.command(name="ping", brief="Ping pong") + async def ping(self, ctx): + await ctx.say("Pong!") @commands.group(name="faq", brief="Frequently Asked Questions", pass_context=True) - async def frequently_asked_questions(self, context): - if context.invoked_subcommand is None: + async def frequently_asked_questions(self, ctx): + if ctx.invoked_subcommand is None: embed = discord.Embed(title="Frequently Asked Questions") embed.add_field(name="Want to know about the requests system?", value="Just execute this command with with `requests` after it.", inline=False) - await self.bot.say(embed=embed) + await ctx.say(embed=embed) pass @frequently_asked_questions.command() - async def requests(self): + async def requests(self, ctx): embed=discord.Embed(title="Plex Media Request System FAQ", color=0xdc1111) embed.add_field(name="1. Where can I find it?", value="It's located at: https://home.samip.fi/pmrs/", inline=False) embed.add_field(name="2. How do I log-on?", value="You login with your plex.tv credentials.\nThe admin wont see it as it's securely checked against plex.tv API.", inline=False) embed.set_footer(text="Generated by samip5's code.") - await self.bot.say(embed=embed) + await ctx.say(embed=embed) @commands.group(name='dm-faq', pass_context=True) async def direct_message_frequently_asked_questions(self, ctx): if ctx.invoked_subcommand is None: message = "This is not supposed to be called without anything after it. Please fix your command and try again." - await self.bot.say(message) + await ctx.say(message) pass @direct_message_frequently_asked_questions.command(pass_context=True) @@ -44,7 +44,7 @@ class General: embed.add_field(name="1. Where can I find it?", value="It's located at: https://home.samip.fi/pmrs/", inline=False) embed.add_field(name="2. How do I log-on?", value="You login with your plex.tv credentials.\nThe admin wont see it as it's securely checked against plex.tv API.", inline=False) embed.set_footer(text=footer) - await self.bot.send_message(member, embed=embed) + await ctx.messegable.send(member, embed=embed) def setup(bot): diff --git a/All-In-One-bot/cogs/members.py b/All-In-One-bot/cogs/members.py deleted file mode 100644 index 3c48179fa10e60e97a9a8c74f6a7b4c5cc3382d8..0000000000000000000000000000000000000000 --- a/All-In-One-bot/cogs/members.py +++ /dev/null @@ -1,46 +0,0 @@ -import discord -from discord.ext import commands - - -class Members: - def __init__(self, bot): - self.bot = bot - - @commands.command() - async def joined(self, ctx, *, member: discord.Member): - """Says when a member joined.""" - await ctx.send(f'{member.display_name} joined on {member.joined_at}') - - @commands.command(name='top_role', aliases=['toprole']) - async def show_toprole(self, ctx, *, member: discord.Member=None): - """Simple command which shows the members Top Role.""" - - if member is None: - member = ctx.author - - await ctx.send(f'The top role for {member.display_name} is {member.top_role.name}') - - @commands.command(name='perms', aliases=['perms_for', 'permissions']) - async def check_permissions(self, ctx, *, member: discord.Member=None): - """A simple command which checks a members Guild Permissions. - If member is not provided, the author will be checked.""" - - if not member: - member = ctx.author - - # Here we check if the value of each permission is True. - perms = '\n'.join(perm for perm, value in member.guild_permissions if value) - - # And to make it look nice, we wrap it in an Embed. - embed = discord.Embed(title='Permissions for:', description=ctx.guild.name, colour=member.colour) - embed.set_author(icon_url=member.avatar_url, name=str(member)) - - # \uFEFF is a Zero-Width Space, which basically allows us to have an empty field name. - embed.add_field(name='\uFEFF', value=perms) - - await ctx.send(content=None, embed=embed) - # Thanks to Gio for the Command. - - -def setup(bot): - bot.add_cog(Members(bot)) diff --git a/All-In-One-bot/cogs/meta.py b/All-In-One-bot/cogs/meta.py index ea9f78e8bc1c4f33b6524693fbd670da2de8319e..0d508e95cde732260446b1090fe2cb13223addea 100644 --- a/All-In-One-bot/cogs/meta.py +++ b/All-In-One-bot/cogs/meta.py @@ -60,13 +60,13 @@ class Meta: reminder = 'Okay {0.mention}, I\'ll remind you about "{2}" in {1.seconds} seconds.' completed = 'Time is up {0.mention}! You asked to be reminded about "{1}".' - await self.bot.say(reminder.format(author, time, message)) + await ctx.send(reminder.format(author, time, message)) await asyncio.sleep(time.seconds) - await self.bot.say(completed.format(author, message)) + await ctx.send(completed.format(author, message)) @commands.command(name='quit', hidden=True) - @checks.is_owner() - async def _quit(self): + @checks.am_i_owner() + async def _quit(self, ctx): """Quits the bot.""" await self.bot.logout() @@ -78,7 +78,7 @@ class Meta: """ channel = ctx.message.channel if channel.is_private: - await self.bot.say('You cannot use this in PMs.') + await ctx.send('You cannot use this in PMs.') return if member is None: @@ -129,12 +129,12 @@ class Meta: return fmt.format(d=days, h=hours, m=minutes, s=seconds) @commands.command() - async def uptime(self): + async def uptime(self, ctx): """Tells you how long the bot has been up for.""" - await self.bot.say('Uptime: **{}**'.format(self.get_bot_uptime())) + await ctx.send('Uptime: **{}**'.format(self.get_bot_uptime())) @commands.command(name="about") - async def about_me(self): + async def about_me(self, ctx): """Tells you information about the bot itself.""" result = ['**About Me:**'] result.append('- Author: samip537 (Discord ID: 157970669261422592, Github: samip5)') @@ -144,7 +144,7 @@ class Meta: changes = os.popen(r'git show -s HEAD~3..HEAD --format="[%h](https://github.com/samip5/Discord-Bots/commit/%H) %s (%cr)"').read().strip() result.append('- Changes: {}'.format(changes)) result.append('- Uptime: {}'.format(self.get_bot_uptime())) - result.append('- Servers: {}'.format(len(self.bot.servers))) + result.append('- Servers: {}'.format(len(self.bot.guilds))) # stats total_members = sum(len(s.members) for s in self.bot.servers) total_online = sum(1 for m in self.bot.get_all_members() if m.status != discord.Status.offline) @@ -156,7 +156,7 @@ class Meta: result.append('- Total Members: {} ({} online)'.format(total_members, total_online)) result.append('- Unique Members: {} ({} online)'.format(len(unique_members), unique_online)) result.append('- {} text channels, {} voice channels'.format(text, voice)) - await self.bot.say('\n'.join(result)) + await ctx.send('\n'.join(result)) def setup(bot): diff --git a/All-In-One-bot/cogs/misc.py b/All-In-One-bot/cogs/misc.py index db29bf0464c84a9a202e9576433f91943ac67aae..bbb94285e12d85b13e4b14000604f5f463eb2d3f 100644 --- a/All-In-One-bot/cogs/misc.py +++ b/All-In-One-bot/cogs/misc.py @@ -10,17 +10,17 @@ class Misc: self.bot = bot @commands.command(name='8ball', description="Answers a yes/no question.", brief="Answers from the beyond.", pass_context=True) - async def answer_machine(self, context): + async def answer_machine(self, ctx): responses = [ 'That is a resounding no', 'It is not looking likely', 'Too hard to tell', 'It is quite possible', 'Definitely', ] - await self.bot.say(random.choice(responses) + ", " + context.message.author.mention) + await ctx.say(random.choice(responses) + ", " + ctx.message.author.mention) @commands.command(name='facepalmed', brief="Command says everything needed.", pass_context=True) - async def facepalm(self): + async def facepalm(self, ctx): urls = [ 'https://media1.giphy.com/media/AjYsTtVxEEBPO/giphy.gif', 'https://media2.giphy.com/media/3og0INyCmHlNylks9O/giphy.gif', @@ -31,10 +31,10 @@ class Misc: f = discord.Embed(color=random.randint(1, 255 ** 3 - 1)) f.set_image(url=thumb_url) f.set_footer(text="Powered by Giphy.") - await self.bot.say(embed=f) + await ctx.say(embed=f) @commands.command(name='cat', description="Gives you a random cat picture", brief="Returns random image of a cat") - async def randomcat(self): + async def randomcat(self, ctx): async with aiohttp.ClientSession() as ses: async with ses.get('https://aws.random.cat/meow') as response: ret = await response.json() @@ -42,7 +42,7 @@ class Misc: e.set_image(url=ret['file']) e.set_author(name="Random.cat", url='https://random.cat/') e.set_footer(text="Powered by random.cat") - await self.bot.say(embed=e) + await ctx.say(embed=e) async def on_member_update(self, before, after): if before.nick != after.nick: @@ -63,12 +63,12 @@ class Misc: await self.bot.db.commit() pass - print(f'Before nick: {before.nick}') - print(f'Before display_name: {before.display_name}') - print(f'Before id: {before.id}') - print(f'After nick: {after.nick}') - print(f'After display_name: {after.display_name}') - print(f'After id: {after.id}') + # print(f'Before nick: {before.nick}') + # print(f'Before display_name: {before.display_name}') + # print(f'Before id: {before.id}') + # print(f'After nick: {after.nick}') + # print(f'After display_name: {after.display_name}') + # print(f'After id: {after.id}') def setup(bot): diff --git a/All-In-One-bot/cogs/mod.py b/All-In-One-bot/cogs/mod.py index f7808c0903852c94a0b39f706e332404ea0a0401..06f75c9455c32286a5ea463adc181522a009ecd7 100644 --- a/All-In-One-bot/cogs/mod.py +++ b/All-In-One-bot/cogs/mod.py @@ -1,313 +1,960 @@ -# Borrowed from https://github.com/Rapptz/RoboDanny/tree/async - from discord.ext import commands -from .utils import config, checks -from collections import Counter +from .utils import checks, db, time, cache +from collections import Counter, defaultdict +from inspect import cleandoc + +import re +import json import discord +import enum +import datetime +import asyncio +import argparse, shlex +import logging + +log = logging.getLogger(__name__) + +## Misc utilities + +class Arguments(argparse.ArgumentParser): + def error(self, message): + raise RuntimeError(message) + +class RaidMode(enum.Enum): + off = 0 + on = 1 + strict = 2 + + def __str__(self): + return self.name + +## Tables + +class GuildConfig(db.Table, table_name='guild_mod_config'): + id = db.Column(db.Integer(big=True), primary_key=True) + raid_mode = db.Column(db.Integer(small=True)) + broadcast_channel = db.Column(db.Integer(big=True)) + mention_count = db.Column(db.Integer(small=True)) + safe_mention_channel_ids = db.Column(db.Array(db.Integer(big=True))) + +## Configuration + +class ModConfig: + __slots__ = ('raid_mode', 'id', 'bot', 'broadcast_channel_id', 'mention_count', 'safe_mention_channel_ids') + + @classmethod + async def from_record(cls, record, bot): + self = cls() + + # the basic configuration + self.bot = bot + self.raid_mode = record['raid_mode'] + self.id = record['id'] + self.broadcast_channel_id = record['broadcast_channel'] + self.mention_count = record['mention_count'] + self.safe_mention_channel_ids = set(record['safe_mention_channel_ids'] or []) + return self + + @property + def broadcast_channel(self): + guild = self.bot.get_guild(self.id) + return guild and guild.get_channel(self.broadcast_channel_id) + +## Converters + + +class MemberID(commands.Converter): + async def convert(self, ctx, argument): + try: + m = await commands.MemberConverter().convert(ctx, argument) + except commands.BadArgument: + try: + return int(argument, base=10) + except ValueError: + raise commands.BadArgument(f"{argument} is not a valid member or member ID.") from None + else: + can_execute = ctx.author.id == ctx.bot.owner_id or \ + ctx.author == ctx.guild.owner or \ + ctx.author.top_role > m.top_role + + if not can_execute: + raise commands.BadArgument('You cannot do this action on this user due to role hierarchy.') + return m.id +class BannedMember(commands.Converter): + async def convert(self, ctx, argument): + ban_list = await ctx.guild.bans() + try: + member_id = int(argument, base=10) + entity = discord.utils.find(lambda u: u.user.id == member_id, ban_list) + except ValueError: + entity = discord.utils.find(lambda u: str(u.user) == argument, ban_list) + + if entity is None: + raise commands.BadArgument("Not a valid previously-banned member.") + return entity + + +class ActionReason(commands.Converter): + async def convert(self, ctx, argument): + ret = f'{ctx.author} (ID: {ctx.author.id}): {argument}' + + if len(ret) > 512: + reason_max = 512 - len(ret) - len(argument) + raise commands.BadArgument(f'reason is too long ({len(argument)}/{reason_max})') + return ret + + +# The actual cog + class Mod: - """Moderation related commands.""" - - def __init__(self, bot): - self.bot = bot - self.config = config.Config('mod.json', loop=bot.loop) - - def bot_user(self, message): - return message.server.me if message.channel.is_private else self.bot.user - - @commands.group(pass_context=True, no_pm=True) - @checks.admin_or_permissions(manage_channel=True) - async def ignore(self, ctx): - """Handles the bot's ignore lists. - To use these commands, you must have the Bot Admin role or have - Manage Channel permissions. These commands are not allowed to be used - in a private message context. - Users with Manage Roles or Bot Admin role can still invoke the bot - in ignored channels. - """ - if ctx.invoked_subcommand is None: - await self.bot.say('Invalid subcommand passed: {0.subcommand_passed}'.format(ctx)) - - @ignore.command(name='channel', pass_context=True) - async def channel_cmd(self, ctx, *, channel: discord.Channel = None): - """Ignores a specific channel from being processed. - If no channel is specified, the current channel is ignored. - If a channel is ignored then the bot does not process commands in that - channel until it is unignored. - """ - - if channel is None: - channel = ctx.message.channel - - ignored = self.config.get('ignored', []) - if channel.id in ignored: - await self.bot.say('That channel is already ignored.') - return - - ignored.append(channel.id) - await self.config.put('ignored', ignored) - await self.bot.say('\U0001f44c') - - @ignore.command(name='all', pass_context=True) - @checks.admin_or_permissions(manage_server=True) - async def _all(self, ctx): - """Ignores every channel in the server from being processed. - This works by adding every channel that the server currently has into - the ignore list. If more channels are added then they will have to be - ignored by using the ignore command. - To use this command you must have Manage Server permissions along with - Manage Channel permissions. You could also have the Bot Admin role. - """ - - ignored = self.config.get('ignored', []) - channels = ctx.message.server.channels - ignored.extend(c.id for c in channels if c.type == discord.ChannelType.text) - await self.config.put('ignored', list(set(ignored))) # make unique - await self.bot.say('\U0001f44c') - - @commands.command(pass_context=True, no_pm=True) - @checks.admin_or_permissions(manage_channel=True) - async def unignore(self, ctx, *, channel: discord.Channel = None): - """Unignores a specific channel from being processed. - If no channel is specified, it unignores the current channel. - To use this command you must have the Manage Channel permission or have the - Bot Admin role. - """ - - if channel is None: - channel = ctx.message.channel - - # a set is the proper data type for the ignore list - # however, JSON only supports arrays and objects not sets. - ignored = self.config.get('ignored', []) - try: - ignored.remove(channel.id) - except ValueError: - await self.bot.say('Channel was not ignored in the first place.') - else: - await self.bot.say('\U0001f44c') - - @commands.command(pass_context=True, no_pm=True) - @checks.mod_or_permissions(manage_messages=True) - async def cleanup(self, ctx, search: int = 100): - """Cleans up the bot's messages from the channel. - If a search number is specified, it searches that many messages to delete. - If the bot has Manage Messages permissions, then it will try to delete - messages that look like they invoked the bot as well. - After the cleanup is completed, the bot will send you a message with - which people got their messages deleted and their count. This is useful - to see which users are spammers. - To use this command you must have Manage Messages permission or have the - Bot Mod role. - """ - - spammers = Counter() - channel = ctx.message.channel - prefixes = self.bot.command_prefix - - def is_possible_command_invoke(entry): - valid_call = any(entry.content.startswith(prefix) for prefix in prefixes) - return valid_call and not entry.content[1:2].isspace() - - async for entry in self.bot.logs_from(channel, limit=search): - if entry.author == self.bot.user: - await self.bot.delete_message(entry) - spammers['Bot'] += 1 - if is_possible_command_invoke(entry): - try: - await self.bot.delete_message(entry) - except discord.Forbidden: - continue - else: - spammers[entry.author.name] += 1 - - await self.bot.say('Clean up completed. {} message(s) were deleted.'.format(sum(spammers.values()))) - - spammers = sorted(spammers.items(), key=lambda t: t[1], reverse=True) - stats = '\n'.join(map(lambda t: '- **{0[0]}**: {0[1]}'.format(t), spammers)) - await self.bot.whisper(stats) - - @commands.command(no_pm=True) - @checks.admin_or_permissions(kick_members=True) - async def kick(self, *, member: discord.Member): - """Kicks a member from the server. - In order for this to work, the bot must have Kick Member permissions. - To use this command you must have Kick Members permission or have the - Bot Admin role. - """ - - try: - await self.bot.kick(member) - except discord.Forbidden: - await self.bot.say('The bot does not have permissions to kick members.') - except discord.HTTPException: - await self.bot.say('Kicking failed.') - else: - await self.bot.say('\U0001f44c') - - @commands.command(no_pm=True) - @checks.admin_or_permissions(ban_members=True) - async def ban(self, *, member: discord.Member): - """Bans a member from the server. - In order for this to work, the bot must have Ban Member permissions. - To use this command you must have Ban Members permission or have the - Bot Admin role. - """ - - try: - await self.bot.ban(member) - except discord.Forbidden: - await self.bot.say('The bot does not have permissions to ban members.') - except discord.HTTPException: - await self.bot.say('Banning failed.') - else: - await self.bot.say('\U0001f44c') - - @commands.command(no_pm=True) - @checks.admin_or_permissions(ban_members=True) - async def softban(self, *, member: discord.Member): - """Soft bans a member from the server. - A softban is basically banning the member from the server but - then unbanning the member as well. This allows you to essentially - kick the member while removing their messages. - To use this command you must have Ban Members permissions or have - the Bot Admin role. Note that the bot must have the permission as well. - """ - - try: - await self.bot.ban(member) - await self.bot.unban(member.server, member) - except discord.Forbidden: - await self.bot.say('The bot does not have permissions to ban members.') - except discord.HTTPException: - await self.bot.say('Banning failed.') - else: - await self.bot.say('\U0001f44c') - - @commands.command(no_pm=True) - @checks.admin_or_permissions(manage_server=True) - async def plonk(self, *, member: discord.Member): - """Bans a user from using the bot. - Note that this ban is **global**. So they are banned from - all servers that they access the bot with. So use this with - caution. - There is no way to bypass a plonk regardless of role or permissions. - The only person who cannot be plonked is the bot creator. So this - must be used with caution. - To use this command you must have the Manage Server permission - or have a Bot Admin role. - """ - - plonks = self.config.get('plonks', []) - if member.id in plonks: - await self.bot.say('That user is already bot banned.') - return - - plonks.append(member.id) - await self.config.put('plonks', plonks) - await self.bot.say('{0.name} has been banned from using the bot.'.format(member)) - - @commands.command(no_pm=True) - @checks.admin_or_permissions(manage_server=True) - async def unplonk(self, *, member: discord.Member): - """Unbans a user from using the bot. - To use this command you must have the Manage Server permission - or have a Bot Admin role. - """ - - plonks = self.config.get('plonks', []) - - try: - plonks.remove(member.id) - except ValueError: - pass - else: - await self.config.put('plonks', plonks) - await self.bot.say('{0.name} has been unbanned from using the bot.'.format(member)) - - @commands.command(pass_context=True, no_pm=True) - @checks.admin_or_permissions(manage_roles=True) - async def colour(self, ctx, colour: discord.Colour, *, role: discord.Role): - """Changes the colour of a role. - To use this command you must have the Manage Roles permission or - have the Bot Admin role. The bot must also have Manage Roles permissions. - This command cannot be used in a private message. - """ - try: - await self.bot.edit_role(ctx.message.server, role, colour=colour) - except discord.Forbidden: - await self.bot.say('The bot must have Manage Roles permissions to use this.') - else: - await self.bot.say('\U0001f44c') - - @commands.group(pass_context=True, no_pm=True) - @checks.admin_or_permissions(manage_messages=True) - async def remove(self, ctx): - """Removes messages that meet a criteria. - In order to use this command, you must have Manage Messages permissions - or have the Bot Admin role. Note that the bot needs Manage Messages as - well. These commands cannot be used in a private message. - When the command is done doing its work, you will get a private message - detailing which users got removed and how many messages got removed. - """ - - if ctx.invoked_subcommand is None: - await self.bot.say('Invalid criteria passed "{0.subcommand_passed}"'.format(ctx)) - - async def do_removal(self, channel, limit, predicate): - spammers = Counter() - async for message in self.bot.logs_from(channel, limit=limit): - if predicate(message): - try: - await self.bot.delete_message(message) - except discord.Forbidden: - await self.bot.say('The bot does not have permissions to delete messages.') - return - else: - spammers[message.author.name] += 1 - - await self.bot.say('{} messages(s) were removed.'.format(sum(spammers.values()))) - spammers = sorted(spammers.items(), key=lambda t: t[1], reverse=True) - stats = '\n'.join(map(lambda t: '**{0[0]}**: {0[1]}'.format(t), spammers)) - await self.bot.whisper(stats) - - @remove.command(pass_context=True) - async def embeds(self, ctx, search=100): - """Removes messages that have embeds in them.""" - await self.do_removal(ctx.message.channel, search, lambda e: len(e.embeds)) - - @remove.command(pass_context=True) - async def files(self, ctx, search=100): - """Removes messages that have attachments in them.""" - await self.do_removal(ctx.message.channel, search, lambda e: len(e.attachments)) - - @remove.command(pass_context=True) - async def images(self, ctx, search=100): - """Removes messages that have embeds or attachments.""" - await self.do_removal(ctx.message.channel, search, lambda e: len(e.embeds) or len(e.attachments)) - - @remove.command(name='all', pass_context=True) - async def _remove_all(self, ctx, search=100): - """Removes all messages.""" - await self.do_removal(ctx.message.channel, search, lambda e: True) - - @remove.command(pass_context=True) - async def user(self, ctx, member: discord.Member, search=100): - """Removes all messages by the member.""" - await self.do_removal(ctx.message.channel, search, lambda e: e.author == member) - - @remove.command(pass_context=True) - async def contains(self, ctx, *, substr: str): - """Removes all messages containing a substring. - The substring must be at least 3 characters long. - """ - if len(substr) < 3: - await self.bot.say('The substring length must be at least 3 characters.') - return - - await self.do_removal(ctx.message.channel, 100, lambda e: substr in e.content) + """Moderation related commands.""" + + def __init__(self, bot): + self.bot = bot + + # guild_id: set(user_id) + self._recently_kicked = defaultdict(set) + + def __repr__(self): + return '<cogs.Mod>' + + async def __error(self, ctx, error): + if isinstance(error, commands.BadArgument): + await ctx.send(error) + elif isinstance(error, commands.CommandInvokeError): + original = error.original + if isinstance(original, discord.Forbidden): + await ctx.send('I do not have permission to execute this action.') + elif isinstance(original, discord.NotFound): + await ctx.send(f'This entity does not exist: {original.text}') + elif isinstance(original, discord.HTTPException): + await ctx.send('Somehow, an unexpected error occurred. Try again later?') + + @cache.cache() + async def get_guild_config(self, guild_id): + query = """SELECT * FROM guild_mod_config WHERE id=$1;""" + async with self.bot.pool.acquire() as con: + record = await con.fetchrow(query, guild_id) + if record is not None: + return await ModConfig.from_record(record, self.bot) + return None + + async def check_raid(self, config, guild, member, timestamp): + if config.raid_mode != RaidMode.strict.value: + return + + delta = (member.joined_at - member.created_at).total_seconds() // 60 + + # they must have created their account at most 30 minutes before they joined. + if delta > 30: + return + + delta = (timestamp - member.joined_at).total_seconds() // 60 + + # check if this is their first action in the 30 minutes they joined + if delta > 30: + return + + try: + fmt = f"""Howdy. The server {guild.name} is currently in a raid mode lockdown. + + A raid is when a server is being bombarded with trolls or low effort posts. + Unfortunately, what this means is that you have been automatically kicked for + meeting the suspicious thresholds currently set. + + **Do not worry though, as you will be able to join again in the future!** + """ + + fmt = cleandoc(fmt) + await member.send(fmt) + except discord.HTTPException: + pass + + # kick anyway + try: + await member.kick(reason='Strict raid mode') + except discord.HTTPException: + log.info(f'[Raid Mode] Failed to kick {member} (ID: {member.id}) from server {member.guild} via strict mode.') + else: + log.info(f'[Raid Mode] Kicked {member} (ID: {member.id}) from server {member.guild} via strict mode.') + self._recently_kicked[guild.id].add(member.id) + + async def on_message(self, message): + author = message.author + if author.id in (self.bot.user.id, self.bot.owner_id): + return + + if message.guild is None: + return + + if not isinstance(author, discord.Member): + return + + # we're going to ignore members with roles + if len(author.roles) > 1: + return + + guild_id = message.guild.id + config = await self.get_guild_config(guild_id) + if config is None: + return + + # check for raid mode stuff + await self.check_raid(config, message.guild, author, message.created_at) + + # auto-ban tracking for mention spams begin here + if len(message.mentions) <= 3: + return + + if not config.mention_count: + return + + # check if it meets the thresholds required + mention_count = sum(not m.bot for m in message.mentions) + if mention_count < config.mention_count: + return + + if message.channel.id in config.safe_mention_channel_ids: + return + + try: + await author.ban(reason=f'Spamming mentions ({mention_count} mentions)') + except Exception as e: + log.info(f'Failed to autoban member {author} (ID: {author.id}) in guild ID {guild_id}') + else: + await message.channel.send(f'Banned {author} (ID: {author.id}) for spamming {mention_count} mentions.') + log.info(f'Member {author} (ID: {author.id}) has been autobanned from guild ID {guild_id}') + + async def on_voice_state_update(self, user, before, after): + if not isinstance(user, discord.Member): + return + + # joined a voice channel + if before.channel is None and after.channel is not None: + config = await self.get_guild_config(user.guild.id) + if config is None: + return + + await self.check_raid(config, user.guild, user, datetime.datetime.utcnow()) + + async def on_member_join(self, member): + config = await self.get_guild_config(member.guild.id) + if config is None or not config.raid_mode: + return + + now = datetime.datetime.utcnow() + + # these are the dates in minutes + created = (now - member.created_at).total_seconds() // 60 + was_kicked = False + + if config.raid_mode == RaidMode.strict.value: + was_kicked = self._recently_kicked.get(member.guild.id) + if was_kicked is not None: + try: + was_kicked.remove(member.id) + except KeyError: + was_kicked = False + else: + was_kicked = True + + # Do the broadcasted message to the channel + if was_kicked: + title = 'Member Re-Joined' + colour = 0xdd5f53 # red + else: + title = 'Member Joined' + colour = 0x53dda4 # green + + if created < 30: + colour = 0xdda453 # yellow + + e = discord.Embed(title=title, colour=colour) + e.timestamp = now + e.set_footer(text='Created') + e.set_author(name=str(member), icon_url=member.avatar_url) + e.add_field(name='ID', value=member.id) + e.add_field(name='Joined', value=member.joined_at) + e.add_field(name='Created', value=time.human_timedelta(member.created_at), inline=False) + + if config.broadcast_channel: + await config.broadcast_channel.send(embed=e) + + @commands.command(aliases=['newmembers']) + @commands.guild_only() + async def newusers(self, ctx, *, count=5): + """Tells you the newest members of the server. + + This is useful to check if any suspicious members have + joined. + + The count parameter can only be up to 25. + """ + count = max(min(count, 25), 5) + + if not ctx.guild.chunked: + await self.bot.request_offline_members(ctx.guild) + + members = sorted(ctx.guild.members, key=lambda m: m.joined_at, reverse=True)[:count] + + e = discord.Embed(title='New Members', colour=discord.Colour.green()) + + for member in members: + body = f'joined {time.human_timedelta(member.joined_at)}, created {time.human_timedelta(member.created_at)}' + e.add_field(name=f'{member} (ID: {member.id})', value=body, inline=False) + + await ctx.send(embed=e) + + @commands.group(aliases=['raids'], invoke_without_command=True) + @checks.is_mod() + async def raid(self, ctx): + """Controls raid mode on the server. + + Calling this command with no arguments will show the current raid + mode information. + + You must have Manage Server permissions to use this command or + its subcommands. + """ + + query = "SELECT raid_mode, broadcast_channel FROM guild_mod_config WHERE id=$1;" + + row = await ctx.db.fetchrow(query, ctx.guild.id) + if row is None: + fmt = 'Raid Mode: off\nBroadcast Channel: None' + else: + ch = f'<#{row[1]}>' if row[1] else None + fmt = f'Raid Mode: {RaidMode(row[0])}\nBroadcast Channel: {ch}' + + await ctx.send(fmt) + + @raid.command(name='on', aliases=['enable', 'enabled']) + @checks.is_mod() + async def raid_on(self, ctx, *, channel: discord.TextChannel = None): + """Enables basic raid mode on the server. + + When enabled, server verification level is set to table flip + levels and allows the bot to broadcast new members joining + to a specified channel. + + If no channel is given, then the bot will broadcast join + messages on the channel this command was used in. + """ + + channel = channel or ctx.channel + + try: + await ctx.guild.edit(verification_level=discord.VerificationLevel.high) + except discord.HTTPException: + await ctx.send('\N{WARNING SIGN} Could not set verification level.') + + query = """INSERT INTO guild_mod_config (id, raid_mode, broadcast_channel) + VALUES ($1, $2, $3) ON CONFLICT (id) + DO UPDATE SET + raid_mode = EXCLUDED.raid_mode, + broadcast_channel = EXCLUDED.broadcast_channel; + """ + + await ctx.db.execute(query, ctx.guild.id, RaidMode.on.value, channel.id) + self.get_guild_config.invalidate(self, ctx.guild.id) + await ctx.send(f'Raid mode enabled. Broadcasting join messages to {channel.mention}.') + + @raid.command(name='off', aliases=['disable', 'disabled']) + @checks.is_mod() + async def raid_off(self, ctx): + """Disables raid mode on the server. + + When disabled, the server verification levels are set + back to Low levels and the bot will stop broadcasting + join messages. + """ + + try: + await ctx.guild.edit(verification_level=discord.VerificationLevel.low) + except discord.HTTPException: + await ctx.send('\N{WARNING SIGN} Could not set verification level.') + + query = """INSERT INTO guild_mod_config (id, raid_mode, broadcast_channel) + VALUES ($1, $2, NULL) ON CONFLICT (id) + DO UPDATE SET + raid_mode = EXCLUDED.raid_mode, + broadcast_channel = NULL; + """ + + await ctx.db.execute(query, ctx.guild.id, RaidMode.off.value) + self._recently_kicked.pop(ctx.guild.id, None) + self.get_guild_config.invalidate(self, ctx.guild.id) + await ctx.send('Raid mode disabled. No longer broadcasting join messages.') + + @raid.command(name='strict') + @checks.is_mod() + async def raid_strict(self, ctx, *, channel: discord.TextChannel = None): + """Enables strict raid mode on the server. + + Strict mode is similar to regular enabled raid mode, with the added + benefit of auto-kicking members that meet the following requirements: + + - Account creation date and join date are at most 30 minutes apart. + - First message recorded on the server is 30 minutes apart from join date. + - Joining a voice channel within 30 minutes of joining. + + Members who meet these requirements will get a private message saying that the + server is currently in lock down. + + If this is considered too strict, it is recommended to fall back to regular + raid mode. + """ + channel = channel or ctx.channel + + if not ctx.me.guild_permissions.kick_members: + return await ctx.send('\N{NO ENTRY SIGN} I do not have permissions to kick members.') + + try: + await ctx.guild.edit(verification_level=discord.VerificationLevel.high) + except discord.HTTPException: + await ctx.send('\N{WARNING SIGN} Could not set verification level.') + + query = """INSERT INTO guild_mod_config (id, raid_mode, broadcast_channel) + VALUES ($1, $2, $3) ON CONFLICT (id) + DO UPDATE SET + raid_mode = EXCLUDED.raid_mode, + broadcast_channel = EXCLUDED.broadcast_channel; + """ + + await ctx.db.execute(query, ctx.guild.id, RaidMode.strict.value, ctx.channel.id) + self.get_guild_config.invalidate(self, ctx.guild.id) + await ctx.send(f'Raid mode enabled strictly. Broadcasting join messages to {channel.mention}.') + + async def _basic_cleanup_strategy(self, ctx, search): + count = 0 + async for msg in ctx.history(limit=search, before=ctx.message): + if msg.author == ctx.me: + await msg.delete() + count += 1 + return { 'Bot': count } + + async def _complex_cleanup_strategy(self, ctx, search): + prefixes = tuple(self.bot.get_guild_prefixes(ctx.guild)) # thanks startswith + + def check(m): + return m.author == ctx.me or m.content.startswith(prefixes) + + deleted = await ctx.channel.purge(limit=search, check=check, before=ctx.message) + return Counter(m.author.display_name for m in deleted) + + @commands.command() + @checks.has_permissions(manage_messages=True) + async def cleanup(self, ctx, search=100): + """Cleans up the bot's messages from the channel. + + If a search number is specified, it searches that many messages to delete. + If the bot has Manage Messages permissions then it will try to delete + messages that look like they invoked the bot as well. + + After the cleanup is completed, the bot will send you a message with + which people got their messages deleted and their count. This is useful + to see which users are spammers. + + You must have Manage Messages permission to use this. + """ + + strategy = self._basic_cleanup_strategy + if ctx.me.permissions_in(ctx.channel).manage_messages: + strategy = self._complex_cleanup_strategy + + spammers = await strategy(ctx, search) + deleted = sum(spammers.values()) + messages = [f'{deleted} message{" was" if deleted == 1 else "s were"} removed.'] + if deleted: + messages.append('') + spammers = sorted(spammers.items(), key=lambda t: t[1], reverse=True) + messages.extend(f'- **{author}**: {count}' for author, count in spammers) + + await ctx.send('\n'.join(messages), delete_after=10) + + @commands.command() + @commands.guild_only() + @checks.has_permissions(kick_members=True) + async def kick(self, ctx, member: discord.Member, *, reason: ActionReason = None): + """Kicks a member from the server. + + In order for this to work, the bot must have Kick Member permissions. + + To use this command you must have Kick Members permission. + """ + + if reason is None: + reason = f'Action done by {ctx.author} (ID: {ctx.author.id})' + + await member.kick(reason=reason) + await ctx.send('\N{OK HAND SIGN}') + + @commands.command() + @commands.guild_only() + @checks.has_permissions(ban_members=True) + async def ban(self, ctx, member: MemberID, *, reason: ActionReason = None): + """Bans a member from the server. + + You can also ban from ID to ban regardless whether they're + in the server or not. + + In order for this to work, the bot must have Ban Member permissions. + + To use this command you must have Ban Members permission. + """ + + if reason is None: + reason = f'Action done by {ctx.author} (ID: {ctx.author.id})' + + await ctx.guild.ban(discord.Object(id=member), reason=reason) + await ctx.send('\N{OK HAND SIGN}') + + @commands.command() + @commands.guild_only() + @checks.has_permissions(ban_members=True) + async def massban(self, ctx, reason: ActionReason, *members: MemberID): + """Mass bans multiple members from the server. + + You can also ban from ID to ban regardless whether they're + in the server or not. + + Note that unlike the ban command, the reason comes first + and is not optional. + + In order for this to work, the bot must have Ban Member permissions. + + To use this command you must have Ban Members permission. + """ + + for member_id in members: + await ctx.guild.ban(discord.Object(id=member_id), reason=reason) + + await ctx.send('\N{OK HAND SIGN}') + + @commands.command() + @commands.guild_only() + @checks.has_permissions(kick_members=True) + async def softban(self, ctx, member: MemberID, *, reason: ActionReason = None): + """Soft bans a member from the server. + + A softban is basically banning the member from the server but + then unbanning the member as well. This allows you to essentially + kick the member while removing their messages. + + In order for this to work, the bot must have Ban Member permissions. + + To use this command you must have Kick Members permissions. + """ + + if reason is None: + reason = f'Action done by {ctx.author} (ID: {ctx.author.id})' + + obj = discord.Object(id=member) + await ctx.guild.ban(obj, reason=reason) + await ctx.guild.unban(obj, reason=reason) + await ctx.send('\N{OK HAND SIGN}') + + @commands.command() + @commands.guild_only() + @checks.has_permissions(ban_members=True) + async def unban(self, ctx, member: BannedMember, *, reason: ActionReason = None): + """Unbans a member from the server. + + You can pass either the ID of the banned member or the Name#Discrim + combination of the member. Typically the ID is easiest to use. + + In order for this to work, the bot must have Ban Member permissions. + + To use this command you must have Ban Members permissions. + """ + + if reason is None: + reason = f'Action done by {ctx.author} (ID: {ctx.author.id})' + + await ctx.guild.unban(member.user, reason=reason) + if member.reason: + await ctx.send(f'Unbanned {member.user} (ID: {member.user.id}), previously banned for {member.reason}.') + else: + await ctx.send(f'Unbanned {member.user} (ID: {member.user.id}).') + + @commands.command() + @commands.guild_only() + @checks.has_permissions(ban_members=True) + async def tempban(self, ctx, duration: time.FutureTime, member: MemberID, *, reason: ActionReason = None): + """Temporarily bans a member for the specified duration. + + The duration can be a a short time form, e.g. 30d or a more human + duration such as "until thursday at 3PM" or a more concrete time + such as "2017-12-31". + + Note that times are in UTC. + + You can also ban from ID to ban regardless whether they're + in the server or not. + + In order for this to work, the bot must have Ban Member permissions. + + To use this command you must have Ban Members permission. + """ + + if reason is None: + reason = f'Action done by {ctx.author} (ID: {ctx.author.id})' + + reminder = self.bot.get_cog('Reminder') + if reminder is None: + return await ctx.send('Sorry, this functionality is currently unavailable. Try again later?') + + await ctx.guild.ban(discord.Object(id=member), reason=reason) + timer = await reminder.create_timer(duration.dt, 'tempban', ctx.guild.id, ctx.author.id, member, connection=ctx.db) + await ctx.send(f'Banned ID {member} for {time.human_timedelta(duration.dt)}.') + + async def on_tempban_timer_complete(self, timer): + guild_id, mod_id, member_id = timer.args + + guild = self.bot.get_guild(guild_id) + if guild is None: + # RIP + return + + moderator = guild.get_member(mod_id) + if moderator is None: + try: + moderator = await self.bot.get_user_info(mod_id) + except: + # request failed somehow + moderator = f'Mod ID {mod_id}' + else: + moderator = f'{moderator} (ID: {mod_id})' + else: + moderator = f'{moderator} (ID: {mod_id})' + + reason = f'Automatic unban from timer made on {timer.created_at} by {moderator}.' + await guild.unban(discord.Object(id=member_id), reason=reason) + + @commands.group(invoke_without_command=True) + @commands.guild_only() + @checks.has_permissions(ban_members=True) + async def mentionspam(self, ctx, count: int=None): + """Enables auto-banning accounts that spam mentions. + + If a message contains `count` or more mentions then the + bot will automatically attempt to auto-ban the member. + The `count` must be greater than 3. If the `count` is 0 + then this is disabled. + + This only applies for user mentions. Everyone or Role + mentions are not included. + + To use this command you must have the Ban Members permission. + """ + + if count is None: + query = """SELECT mention_count, COALESCE(safe_mention_channel_ids, '{}') AS channel_ids + FROM guild_mod_config + WHERE id=$1; + """ + + row = await ctx.db.fetchrow(query, ctx.guild.id) + if row is None or not row['mention_count']: + return await ctx.send('This server has not set up mention spam banning.') + + ignores = ', '.join(f'<#{e}>' for e in row['channel_ids']) or 'None' + return await ctx.send(f'- Threshold: {row["mention_count"]} mentions\n- Ignored Channels: {ignores}') + + if count == 0: + query = """UPDATE guild_mod_config SET mention_count = NULL WHERE id=$1;""" + await ctx.db.execute(query, ctx.guild.id) + self.get_guild_config.invalidate(self, ctx.guild.id) + return await ctx.send('Auto-banning members has been disabled.') + + if count <= 3: + await ctx.send('\N{NO ENTRY SIGN} Auto-ban threshold must be greater than three.') + return + + query = """INSERT INTO guild_mod_config (id, mention_count, safe_mention_channel_ids) + VALUES ($1, $2, '{}') + ON CONFLICT (id) DO UPDATE SET + mention_count = $2; + """ + await ctx.db.execute(query, ctx.guild.id, count) + self.get_guild_config.invalidate(self, ctx.guild.id) + await ctx.send(f'Now auto-banning members that mention more than {count} users.') + + @mentionspam.command(name='ignore', aliases=['bypass']) + @commands.guild_only() + @checks.has_permissions(ban_members=True) + async def mentionspam_ignore(self, ctx, *channels: discord.TextChannel): + """Specifies what channels ignore mentionspam auto-bans. + + If a channel is given then that channel will no longer be protected + by auto-banning from mention spammers. + + To use this command you must have the Ban Members permission. + """ + + query = """UPDATE guild_mod_config + SET safe_mention_channel_ids = + ARRAY(SELECT DISTINCT * FROM unnest(COALESCE(safe_mention_channel_ids, '{}') || $2::bigint[])) + WHERE id = $1; + """ + + if len(channels) == 0: + return await ctx.send('Missing channels to ignore.') + + channel_ids = [c.id for c in channels] + await ctx.db.execute(query, ctx.guild.id, channel_ids) + self.get_guild_config.invalidate(self, ctx.guild.id) + await ctx.send(f'Mentions are now ignored on {", ".join(c.mention for c in channels)}.') + + @mentionspam.command(name='unignore', aliases=['protect']) + @commands.guild_only() + @checks.has_permissions(ban_members=True) + async def mentionspam_unignore(self, ctx, *channels: discord.TextChannel): + """Specifies what channels to take off the ignore list. + + To use this command you must have the Ban Members permission. + """ + + if len(channels) == 0: + return await ctx.send('Missing channels to protect.') + + query = """UPDATE guild_mod_config + SET safe_mention_channel_ids = + ARRAY(SELECT element FROM unnest(safe_mention_channel_ids) AS element + WHERE NOT(element = ANY($2::bigint[]))) + WHERE id = $1; + """ + + await ctx.db.execute(query, ctx.guild.id, [c.id for c in channels]) + self.get_guild_config.invalidate(self, ctx.guild.id) + await ctx.send('Updated mentionspam ignore list.') + + @commands.group(aliases=['purge']) + @commands.guild_only() + @checks.has_permissions(manage_messages=True) + async def remove(self, ctx): + """Removes messages that meet a criteria. + + In order to use this command, you must have Manage Messages permissions. + Note that the bot needs Manage Messages as well. These commands cannot + be used in a private message. + + When the command is done doing its work, you will get a message + detailing which users got removed and how many messages got removed. + """ + + if ctx.invoked_subcommand is None: + help_cmd = self.bot.get_command('help') + await ctx.invoke(help_cmd, command='remove') + + async def do_removal(self, ctx, limit, predicate, *, before=None, after=None): + if limit > 2000: + return await ctx.send(f'Too many messages to search given ({limit}/2000)') + + if before is None: + before = ctx.message + else: + before = discord.Object(id=before) + + if after is not None: + after = discord.Object(id=after) + + try: + deleted = await ctx.channel.purge(limit=limit, before=before, after=after, check=predicate) + except discord.Forbidden as e: + return await ctx.send('I do not have permissions to delete messages.') + except discord.HTTPException as e: + return await ctx.send(f'Error: {e} (try a smaller search?)') + + spammers = Counter(m.author.display_name for m in deleted) + deleted = len(deleted) + messages = [f'{deleted} message{" was" if deleted == 1 else "s were"} removed.'] + if deleted: + messages.append('') + spammers = sorted(spammers.items(), key=lambda t: t[1], reverse=True) + messages.extend(f'**{name}**: {count}' for name, count in spammers) + + to_send = '\n'.join(messages) + + if len(to_send) > 2000: + await ctx.send(f'Successfully removed {deleted} messages.', delete_after=10) + else: + await ctx.send(to_send, delete_after=10) + + @remove.command() + async def embeds(self, ctx, search=100): + """Removes messages that have embeds in them.""" + await self.do_removal(ctx, search, lambda e: len(e.embeds)) + + @remove.command() + async def files(self, ctx, search=100): + """Removes messages that have attachments in them.""" + await self.do_removal(ctx, search, lambda e: len(e.attachments)) + + @remove.command() + async def images(self, ctx, search=100): + """Removes messages that have embeds or attachments.""" + await self.do_removal(ctx, search, lambda e: len(e.embeds) or len(e.attachments)) + + @remove.command(name='all') + async def _remove_all(self, ctx, search=100): + """Removes all messages.""" + await self.do_removal(ctx, search, lambda e: True) + + @remove.command() + async def user(self, ctx, member: discord.Member, search=100): + """Removes all messages by the member.""" + await self.do_removal(ctx, search, lambda e: e.author == member) + + @remove.command() + async def contains(self, ctx, *, substr: str): + """Removes all messages containing a substring. + + The substring must be at least 3 characters long. + """ + if len(substr) < 3: + await ctx.send('The substring length must be at least 3 characters.') + else: + await self.do_removal(ctx, 100, lambda e: substr in e.content) + + @remove.command(name='bot') + async def _bot(self, ctx, prefix=None, search=100): + """Removes a bot user's messages and messages with their optional prefix.""" + + def predicate(m): + return (m.webhook_id is None and m.author.bot) or (prefix and m.content.startswith(prefix)) + + await self.do_removal(ctx, search, predicate) + + @remove.command(name='emoji') + async def _emoji(self, ctx, search=100): + """Removes all messages containing custom emoji.""" + custom_emoji = re.compile(r'<:(\w+):(\d+)>') + def predicate(m): + return custom_emoji.search(m.content) + + await self.do_removal(ctx, search, predicate) + + @remove.command(name='reactions') + async def _reactions(self, ctx, search=100): + """Removes all reactions from messages that have them.""" + + if search > 2000: + return await ctx.send(f'Too many messages to search for ({search}/2000)') + + total_reactions = 0 + async for message in ctx.history(limit=search, before=ctx.message): + if len(message.reactions): + total_reactions += sum(r.count for r in message.reactions) + await message.clear_reactions() + + await ctx.send(f'Successfully removed {total_reactions} reactions.') + + @remove.command() + async def custom(self, ctx, *, args: str): + """A more advanced purge command. + + This command uses a powerful "command line" syntax. + Most options support multiple values to indicate 'any' match. + If the value has spaces it must be quoted. + + The messages are only deleted if all options are met unless + the `--or` flag is passed, in which case only if any is met. + + The following options are valid. + + `--user`: A mention or name of the user to remove. + `--contains`: A substring to search for in the message. + `--starts`: A substring to search if the message starts with. + `--ends`: A substring to search if the message ends with. + `--search`: How many messages to search. Default 100. Max 2000. + `--after`: Messages must come after this message ID. + `--before`: Messages must come before this message ID. + + Flag options (no arguments): + + `--bot`: Check if it's a bot user. + `--embeds`: Check if the message has embeds. + `--files`: Check if the message has attachments. + `--emoji`: Check if the message has custom emoji. + `--reactions`: Check if the message has reactions + `--or`: Use logical OR for all options. + `--not`: Use logical NOT for all options. + """ + parser = Arguments(add_help=False, allow_abbrev=False) + parser.add_argument('--user', nargs='+') + parser.add_argument('--contains', nargs='+') + parser.add_argument('--starts', nargs='+') + parser.add_argument('--ends', nargs='+') + parser.add_argument('--or', action='store_true', dest='_or') + parser.add_argument('--not', action='store_true', dest='_not') + parser.add_argument('--emoji', action='store_true') + parser.add_argument('--bot', action='store_const', const=lambda m: m.author.bot) + parser.add_argument('--embeds', action='store_const', const=lambda m: len(m.embeds)) + parser.add_argument('--files', action='store_const', const=lambda m: len(m.attachments)) + parser.add_argument('--reactions', action='store_const', const=lambda m: len(m.reactions)) + parser.add_argument('--search', type=int, default=100) + parser.add_argument('--after', type=int) + parser.add_argument('--before', type=int) + + try: + args = parser.parse_args(shlex.split(args)) + except Exception as e: + await ctx.send(str(e)) + return + + predicates = [] + if args.bot: + predicates.append(args.bot) + + if args.embeds: + predicates.append(args.embeds) + + if args.files: + predicates.append(args.files) + + if args.reactions: + predicates.append(args.reactions) + + if args.emoji: + custom_emoji = re.compile(r'<:(\w+):(\d+)>') + predicates.append(lambda m: custom_emoji.search(m.content)) + + if args.user: + users = [] + converter = commands.MemberConverter() + for u in args.user: + try: + user = await converter.convert(ctx, u) + users.append(user) + except Exception as e: + await ctx.send(str(e)) + return + + predicates.append(lambda m: m.author in users) + + if args.contains: + predicates.append(lambda m: any(sub in m.content for sub in args.contains)) + + if args.starts: + predicates.append(lambda m: any(m.content.startswith(s) for s in args.starts)) + + if args.ends: + predicates.append(lambda m: any(m.content.endswith(s) for s in args.ends)) + + op = all if not args._or else any + + def predicate(m): + r = op(p(m) for p in predicates) + if args._not: + return not r + return r + + args.search = max(0, min(2000, args.search)) # clamp from 0-2000 + await self.do_removal(ctx, args.search, predicate, before=args.before, after=args.after) def setup(bot): - bot.add_cog(Mod(bot)) \ No newline at end of file + bot.add_cog(Mod(bot)) \ No newline at end of file diff --git a/All-In-One-bot/cogs/music.py b/All-In-One-bot/cogs/music.py new file mode 100644 index 0000000000000000000000000000000000000000..90fc6d63db84aa995279192cac184b807adb9317 --- /dev/null +++ b/All-In-One-bot/cogs/music.py @@ -0,0 +1,444 @@ +""" +Please understand Music bots are complex, and that even this basic example can be daunting to a beginner. +For this reason it's highly advised you familiarize yourself with discord.py, python and asyncio, BEFORE +you attempt to write a music bot. +This example makes use of: Python 3.6 +For a more basic voice example please read: + https://github.com/Rapptz/discord.py/blob/rewrite/examples/basic_voice.py +This is a very basic playlist example, which allows per guild playback of unique queues. +The commands implement very basic logic for basic usage. But allow for expansion. It would be advisable to implement +your own permissions and usage logic for commands. +e.g You might like to implement a vote before skipping the song or only allow admins to stop the player. +Music bots require lots of work, and tuning. Goodluck. +If you find any bugs feel free to ping me on discord. @Eviee#0666 +""" +import asyncio +import itertools +import sys +import traceback +from functools import partial + +import discord +import youtube_dl +from async_timeout import timeout +from discord.ext import commands + +from .utils import checks + +ytdlopts = { + 'format': 'bestaudio/best', + 'outtmpl': 'downloads/%(extractor)s-%(id)s-%(title)s.%(ext)s', + 'restrictfilenames': True, + 'noplaylist': True, + 'nocheckcertificate': True, + 'ignoreerrors': False, + 'logtostderr': False, + 'quiet': True, + 'no_warnings': True, + 'default_search': 'auto', + 'source_address': '0.0.0.0' # ipv6 addresses cause issues sometimes +} + +ffmpegopts = { + 'before_options': '-nostdin', + 'options': '-vn' +} + +ytdl = youtube_dl.YoutubeDL(ytdlopts) + + +class VoiceConnectionError(commands.CommandError): + """Custom Exception class for connection errors.""" + + +class InvalidVoiceChannel(VoiceConnectionError): + """Exception for cases of invalid Voice Channels.""" + + +class YTDLSource(discord.PCMVolumeTransformer): + def __init__(self, source, *, data, requester): + super().__init__(source) + self.requester = requester + + self.title = data.get('title') + self.web_url = data.get('webpage_url') + + # YTDL info dicts (data) have other useful information you might want + # https://github.com/rg3/youtube-dl/blob/master/README.md + + def __getitem__(self, item: str): + """Allows us to access attributes similar to a dict. + This is only useful when you are NOT downloading. + """ + return self.__getattribute__(item) + + @classmethod + async def create_source(cls, ctx, search: str, *, loop, download=False): + loop = loop or asyncio.get_event_loop() + + to_run = partial(ytdl.extract_info, url=search, download=download) + data = await loop.run_in_executor(None, to_run) + + if 'entries' in data: + # take first item from a playlist + data = data['entries'][0] + + await ctx.send(f'```ini\n[Added {data["title"]} to the Queue.]\n```', delete_after=15) + + if download: + source = ytdl.prepare_filename(data) + else: + return {'webpage_url': data['webpage_url'], 'requester': ctx.author, 'title': data['title']} + + return cls(discord.FFmpegPCMAudio(source), data=data, requester=ctx.author) + + @classmethod + async def regather_stream(cls, data, *, loop): + """Used for preparing a stream, instead of downloading. + Since Youtube Streaming links expire.""" + loop = loop or asyncio.get_event_loop() + requester = data['requester'] + + to_run = partial(ytdl.extract_info, url=data['webpage_url'], download=False) + data = await loop.run_in_executor(None, to_run) + + return cls(discord.FFmpegPCMAudio(data['url']), data=data, requester=requester) + + +class MusicPlayer: + """A class which is assigned to each guild using the bot for Music. + This class implements a queue and loop, which allows for different guilds to listen to different playlists + simultaneously. + When the bot disconnects from the Voice it's instance will be destroyed. + """ + + __slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume') + + def __init__(self, ctx): + self.bot = ctx.bot + self._guild = ctx.guild + self._channel = ctx.channel + self._cog = ctx.cog + + self.queue = asyncio.Queue() + self.next = asyncio.Event() + + self.np = None # Now playing message + self.volume = 0.1 + self.current = None + + ctx.bot.loop.create_task(self.player_loop()) + + async def player_loop(self): + """Our main player loop.""" + await self.bot.wait_until_ready() + + while not self.bot.is_closed(): + self.next.clear() + + try: + # Wait for the next song. If we timeout cancel the player and disconnect... + async with timeout(300): # 5 minutes... + source = await self.queue.get() + except asyncio.TimeoutError: + return self.destroy(self._guild) + + if not isinstance(source, YTDLSource): + # Source was probably a stream (not downloaded) + # So we should regather to prevent stream expiration + try: + source = await YTDLSource.regather_stream(source, loop=self.bot.loop) + except Exception as e: + await self._channel.send(f'There was an error processing your song.\n' + f'```css\n[{e}]\n```') + continue + + source.volume = self.volume + self.current = source + + self._guild.voice_client.play(source, after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set)) + self.np = await self._channel.send(f'**Now Playing:** `{source.title}` requested by ' + f'`{source.requester}`.') + await self.next.wait() + + # Make sure the FFmpeg process is cleaned up. + source.cleanup() + self.current = None + + try: + # We are no longer playing this song... + await self.np.delete() + except discord.HTTPException: + pass + + def destroy(self, guild): + """Disconnect and cleanup the player.""" + return self.bot.loop.create_task(self._cog.cleanup(guild)) + + +class Music: + """Music related commands.""" + + __slots__ = ('bot', 'players') + + def __init__(self, bot): + self.bot = bot + self.players = {} + + async def cleanup(self, guild): + try: + await guild.voice_client.disconnect() + except AttributeError: + pass + + try: + del self.players[guild.id] + except KeyError: + pass + + async def __local_check(self, ctx): + """A local check which applies to all commands in this cog.""" + if not ctx.guild: + raise commands.NoPrivateMessage + return True + + async def __error(self, ctx, error): + """A local error handler for all errors arising from commands in this cog.""" + if isinstance(error, commands.NoPrivateMessage): + try: + return await ctx.send('This command can not be used in Private Messages.') + except discord.HTTPException: + pass + elif isinstance(error, InvalidVoiceChannel): + await ctx.send('Error connecting to Voice Channel. ' + 'Please make sure you are in a valid channel or provide me with one') + + print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr) + traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + + def get_player(self, ctx): + """Retrieve the guild player, or generate one.""" + try: + player = self.players[ctx.guild.id] + except KeyError: + player = MusicPlayer(ctx) + self.players[ctx.guild.id] = player + + return player + + @commands.command(name='connect', aliases=['join', 'summon']) + async def connect_(self, ctx, *, channel: discord.VoiceChannel = None): + """Connect to voice. + Parameters + ------------ + channel: discord.VoiceChannel [Optional] + The channel to connect to. If a channel is not specified, an attempt to join the voice channel you are in + will be made. + This command also handles moving the bot to different channels. + """ + if not channel: + try: + channel = ctx.author.voice.channel + except AttributeError: + raise InvalidVoiceChannel('No channel to join. Please either specify a valid channel or join one.') + + vc = ctx.voice_client + + if vc: + if vc.channel.id == channel.id: + return + try: + await vc.move_to(channel) + except asyncio.TimeoutError: + raise VoiceConnectionError(f'Moving to channel: <{channel}> timed out.') + else: + try: + await channel.connect() + except asyncio.TimeoutError: + raise VoiceConnectionError(f'Connecting to channel: <{channel}> timed out.') + + await ctx.send(f'Connected to: **{channel}**', delete_after=20) + + @commands.command(name='play', aliases=['sing']) + async def play_(self, ctx, *, search: str): + """Request a song and add it to the queue. + This command attempts to join a valid voice channel if the bot is not already in one. + Uses YTDL to automatically search and retrieve a song. + Parameters + ------------ + search: str [Required] + The song to search and retrieve using YTDL. This could be a simple search, an ID or URL. + """ + await ctx.trigger_typing() + + vc = ctx.voice_client + + if not vc: + await ctx.invoke(self.connect_) + + player = self.get_player(ctx) + + # If download is False, source will be a dict which will be used later to regather the stream. + # If download is True, source will be a discord.FFmpegPCMAudio with a VolumeTransformer. + source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop, download=False) + + await player.queue.put(source) + + @commands.command(name='pause') + async def pause_(self, ctx): + """Pause the currently playing song.""" + vc = ctx.voice_client + + if not vc or not vc.is_playing(): + return await ctx.send('I am not currently playing anything!', delete_after=20) + elif vc.is_paused(): + return + + vc.pause() + await ctx.send(f'**`{ctx.author}`**: Paused the song!') + + @commands.command(name='resume') + async def resume_(self, ctx): + """Resume the currently paused song.""" + vc = ctx.voice_client + + if not vc or not vc.is_connected(): + return await ctx.send('I am not currently playing anything!', delete_after=20) + elif not vc.is_paused(): + return + + vc.resume() + await ctx.send(f'**`{ctx.author}`**: Resumed the song!') + + @commands.command(name='vote_skip', enabled=False) + async def vote_skip_(self, ctx): + """Skips the song if the required votes have been acquired""" + vc = ctx.voice_client + + votes = [] + votes_needed = int(3) + vote_count = int(0) + + if not vc or not vc.is_connected(): + return await ctx.send('I am not currently playing anything!', delete_after=20) + + if vc.is_paused(): + pass + elif not vc.is_playing(): + return + + if not {ctx.author} in votes: + votes.append(f'{ctx.author}') + await ctx.send(f"There is now {len(votes)} votes!") + await ctx.send(votes) + elif {ctx.author} in votes: + await ctx.send(f"**`{ctx.author}`**: You have already voted") + + if len(votes) == votes_needed or len(votes) > votes_needed: + await ctx.send("We have enough votes to skip, skipping.") + vc.stop() + + @commands.command(name='skip') + @checks.song_requester_or_dj() + async def skip_(self, ctx): + """Skip the song.""" + vc = ctx.voice_client + + if not vc or not vc.is_connected(): + return await ctx.send('I am not currently playing anything!', delete_after=20) + + if vc.is_paused(): + pass + elif not vc.is_playing(): + return + + if ctx.message.author.name == vc.source.requester.name: + vc.stop() + await ctx.send(f'**`{ctx.author}`**: has skipped the song!') + elif discord.utils.get(ctx.message.author.roles, name="DJ"): + vc.stop() + await ctx.send(f'**`{ctx.author}`**: has skipped the song, with the help of the DJ role!') + + @commands.command(name='queue', aliases=['q', 'playlist']) + async def queue_info(self, ctx): + """Retrieve a basic queue of upcoming songs.""" + vc = ctx.voice_client + + if not vc or not vc.is_connected(): + return await ctx.send('I am not currently connected to voice!', delete_after=20) + + player = self.get_player(ctx) + if player.queue.empty(): + return await ctx.send('There are currently no more queued songs.') + + # Grab up to 5 entries from the queue... + upcoming = list(itertools.islice(player.queue._queue, 0, 5)) + + fmt = '\n'.join(f'**`{_["title"]}`**' for _ in upcoming) + embed = discord.Embed(title=f'Upcoming - Next {len(upcoming)}', description=fmt) + + await ctx.send(embed=embed) + + @commands.command(name='now_playing', aliases=['np', 'current', 'currentsong', 'playing']) + async def now_playing_(self, ctx): + """Display information about the currently playing song.""" + vc = ctx.voice_client + + if not vc or not vc.is_connected(): + return await ctx.send('I am not currently connected to voice!', delete_after=20) + + player = self.get_player(ctx) + if not player.current: + return await ctx.send('I am not currently playing anything!') + + try: + # Remove our previous now_playing message. + await player.np.delete() + except discord.HTTPException: + pass + + player.np = await ctx.send(f'**Now Playing:** `{vc.source.title}` ' + f'requested by `{vc.source.requester}`.') + + @commands.command(name='volume', aliases=['vol']) + async def change_volume(self, ctx, *, vol: float): + """Change the player volume. + Parameters + ------------ + volume: float or int [Required] + The volume to set the player to in percentage. This must be between 1 and 100. + """ + vc = ctx.voice_client + + if not vc or not vc.is_connected(): + return await ctx.send('I am not currently connected to voice!', delete_after=20) + + if not 0 < vol < 101: + return await ctx.send('Please enter a value between 1 and 100.') + + player = self.get_player(ctx) + + if vc.source: + vc.source.volume = vol / 100 + + player.volume = vol / 100 + await ctx.send(f'**`{ctx.author}`**: Set the volume to **{vol}%**') + + @commands.command(name='stop') + @checks.music_stop_check() + async def stop_(self, ctx): + """Stop the currently playing song and destroy the player. + !Warning! + This will destroy the player assigned to your guild, also deleting any queued songs and settings. + """ + vc = ctx.voice_client + + if not vc or not vc.is_connected(): + return await ctx.send('I am not currently playing anything!', delete_after=20) + + await self.cleanup(ctx.guild) + + +def setup(bot): + bot.add_cog(Music(bot)) + print("Music cog loaded.") diff --git a/All-In-One-bot/cogs/ombi.py b/All-In-One-bot/cogs/ombi.py index 5f9147b14df366c2f1fd9a0055d795a2ed6ca1f5..cb5d637090bec91d9b0b4dfe9418af332df410f4 100644 --- a/All-In-One-bot/cogs/ombi.py +++ b/All-In-One-bot/cogs/ombi.py @@ -13,15 +13,15 @@ class Pmrs: self.bot = bot @commands.group(name='pmrs-search', description="Search from Plex Media Request System", brief="Search pmrs", pass_context=True) - async def search_ombi(self, context): - if context.invoked_subcommand is None: + async def search_ombi(self, ctx): + if ctx.invoked_subcommand is None: embed = discord.Embed(title="Plex Media System - Search", color=0xdc1112) embed.add_field(name="How to use search with it?", value="If you wish to search for movies, you have to specify it after this command `movie <replaceme-with-title>`.\nIf you wish to search for tv shows, you have to specify it after this command `tv <replaceme-with-series>`.", inline=False) embed.add_field(name="How to lookup most popular content?", value="If you wish to lookup popular movies, execute `popularmovie` after this command.\nIf you wish to lookup popular TV Shows, execute `populartv` after this command.", inline=False) - await self.bot.say(embed=embed) + await ctx.say(embed=embed) @search_ombi.command() - async def movie(self, input): + async def movie(self, ctx, input): config = parser.ConfigParser() config.read('./config.ini') pmrs_url = config.get('urls', 'PMRS_BASE_URL') @@ -48,10 +48,10 @@ class Pmrs: embed.add_field(name="Description", value="".join(desc), inline=False) embed.set_thumbnail(url=generated_url_for_thumb) embed.set_footer(text="This data is gathered from PMRS's API.") - await self.bot.say(embed=embed) + await ctx.say(embed=embed) @search_ombi.command() - async def tv(self, input): + async def tv(self, ctx, input): config = parser.ConfigParser() config.read('./config.ini') pmrs_url = config.get('urls', 'PMRS_BASE_URL') @@ -78,10 +78,10 @@ class Pmrs: embed.add_field(name="Description", value="".join(desc), inline=False) embed.set_thumbnail(url=banner) embed.set_footer(text="This data is gathered from PMRS's API.") - await self.bot.say(embed=embed) + await ctx.say(embed=embed) @search_ombi.command() - async def populartv(self): + async def populartv(self, ctx): config = parser.ConfigParser() config.read('./config.ini') pmrs_url = config.get('urls', 'PMRS_BASE_URL') @@ -100,18 +100,18 @@ class Pmrs: aired_from_string = datetime.datetime.strptime(first_aired, '%Y-%m-%dT%H:%M:%S') formatted_aired = aired_from_string.strftime('%B %d, %Y') desc = entry['overview'] - imdbId = entry['imdbId'] - imdb_url_full = imdb_url_base + imdbId + imdbid = entry['imdbId'] + imdb_url_full = imdb_url_base + imdbid embed = discord.Embed(title="Currently popular TV shows", description="", color=0x00ff00) embed.add_field(name="Title", value="".join(title), inline=False) embed.add_field(name="First Aired", value="".join(formatted_aired), inline=False) embed.add_field(name="IMDb", value="".join(imdb_url_full), inline=False) embed.add_field(name="Description", value="".join(desc), inline=False) embed.set_footer(text="This data is gathered from PMRS's API.") - await self.bot.say(embed=embed) + await ctx.say(embed=embed) @search_ombi.command() - async def popularmovie(self): + async def popularmovie(self, ctx): config = parser.ConfigParser() config.read('./config.ini') pmrs_url = config.get('urls', 'PMRS_BASE_URL') @@ -127,14 +127,14 @@ class Pmrs: a = await response.json() for entry in a: title = entry['title'] - releaseDate = entry['releaseDate'] - released_from_string = datetime.datetime.strptime(releaseDate, '%Y-%m-%dT%H:%M:%S') + releasedate = entry['releaseDate'] + released_from_string = datetime.datetime.strptime(releasedate, '%Y-%m-%dT%H:%M:%S') formatted_released = released_from_string.strftime('%B %d, %Y') desc = entry['overview'] picture_url = entry['posterPath'] - moviedbID = entry['theMovieDbId'] + moviedbid = entry['theMovieDbId'] generated_picture_url = tmdb_image_not_full + picture_url - generated_tmdb_url = tmdb_base_url + moviedbID + generated_tmdb_url = tmdb_base_url + moviedbid embed = discord.Embed(title="Currently popular movies", description="", color=0x00ff00) embed.add_field(name="Title", value="".join(title), inline=False) embed.add_field(name="Release date", value="".join(formatted_released), inline=False) @@ -142,7 +142,7 @@ class Pmrs: embed.add_field(name="Description", value="".join(desc), inline=False) embed.set_thumbnail(url=generated_picture_url) embed.set_footer(text="This data is gathered from PMRS's API.") - await self.bot.say(embed=embed) + await ctx.say(embed=embed) def setup(bot): diff --git a/All-In-One-bot/cogs/plex.py b/All-In-One-bot/cogs/plex.py index 91ad02b89e7d7c4a8faf6881c0262ff538a5b325..e2230237538a913ec54581a23ab509d0db660fce 100644 --- a/All-In-One-bot/cogs/plex.py +++ b/All-In-One-bot/cogs/plex.py @@ -1,15 +1,10 @@ -## Author: samip5 +# Author: samip5 -import random -import time -import json import aiohttp -import requests import discord - -from plexapi.server import PlexServer -from plexapi.library import Library +import requests from discord.ext import commands +from plexapi.server import PlexServer PLEX_TOKEN = "" PLEX_SRV = "" @@ -25,36 +20,35 @@ class Plex: @commands.command(name='streams', description="Returns currently playing streams on Plex.", brief="Display current streams") - async def query_plex_streams(self): + async def query_plex_streams(self, ctx): sess = requests.Session() sess.verify = False - plex = PlexServer(baseurl=PLEX_SRV, token=PLEX_TOKEN, session=sess) - session_check = plex.sessions() == [] - session_check_not_empty = plex.sessions() != [] - for session in plex.sessions(): + server = PlexServer(baseurl=PLEX_SRV, token=PLEX_TOKEN, session=sess) + session_check = server.sessions() == [] + for session in server.sessions(): state = session.players[0].state + player = session.players[0].platform duration = session.duration duration_millis = session.duration - duration_seconds = (duration_millis / 1000) % 60 - duration_seconds = int(duration_seconds) - duration_minutes = (duration_millis / (1000 * 60)) % 60 - duration_minutes = int(duration_minutes) + # duration_seconds = int(duration_millis / 1000) % 60 + duration_minutes = int(duration_millis / (1000 * 60)) % 60 duration_hours = hours = (duration_millis / (1000 * 60 * 60)) % 24 if duration_hours >= 1: - total_duration = ("%d Hours and %d Minutes" % (duration_hours, duration_minutes)) + # total_duration = ("%d Hours and %d Minutes" % (duration_hours, duration_minutes)) + total_duration = f'{duration_hours} Hours and {duration_minutes} Minutes' else: - total_duration = ("%d Minutes" % duration_minutes) + # total_duration = ("%d Minutes" % duration_minutes) + total_duration = f'{duration_minutes} Minutes' view_offset = session.viewOffset view_offset_millis = session.viewOffset - view_offset_seconds = (view_offset_millis / 1000) % 60 - view_offset_seconds = int(view_offset_seconds) - view_offset_minutes = (view_offset_millis / (1000 * 60)) % 60 - view_offset_minutes = int(view_offset_minutes) - view_offset_hours = hours = (view_offset_millis / (1000 * 60 * 60)) % 24 + view_offset_minutes = int(view_offset_millis / (1000 * 60)) % 60 + view_offset_hours = int(view_offset_millis / (1000 * 60 * 60)) % 24 if view_offset_hours >= 1: - offset = ("%d Hours and %d Minutes" % (view_offset_hours, view_offset_minutes)) + # offset = ("%d Hours and %d Minutes" % (view_offset_hours, view_offset_minutes)) + offset = f'{view_offset_hours} Hours and {view_offset_minutes} Minutes' else: - offset = ("%d Minutes" % view_offset_minutes) + # offset = ("%d Minutes" % view_offset_minutes) + offset = f'{view_offset_minutes} Minutes' # print("ms: %d" % (view_offset)) # print("Minutes: %d" % (view_offset_minutes)) # print("Hours: %d" % (view_offset_hours)) @@ -64,17 +58,15 @@ class Plex: episode_number = int(session.index) season = int(session.parentIndex) season_and_ep_formatted = ("(s%d:e%d)" % (season, episode_number)) - current_tv_token = '?checkFiles=1&X-Plex-Token=' + PLEX_TOKEN + current_tv_token = f"?checkFiles=1&X-Plex-Token={PLEX_TOKEN}" current_tv_thumb = PLEX_URL + session.thumb + current_tv_token title = session.grandparentTitle + ' - ' + session.title + ' ' + season_and_ep_formatted if session.type == 'movie': year = ("(%d)" % session.year) - current_movie_token = '?checkFiles=1&X-Plex-Token=' + PLEX_TOKEN + current_movie_token = f"?checkFiles=1&X-Plex-Token={PLEX_TOKEN}" current_movie_thumb = PLEX_URL + session.thumb + current_movie_token # print(current_movie_thumb) title = session.title + ' ' + year - state = session.players[0].state - player = session.players[0].platform embed = discord.Embed(title="Currently streaming", description="", color=0x00ff00) embed.add_field(name="Username", value="{}".format(username)) embed.add_field(name="Player", value="".join(player), inline=False) @@ -88,12 +80,12 @@ class Plex: else: embed.set_thumbnail(url=current_movie_thumb) embed.set_footer(text="Powered by plexapi.") - await self.bot.say(embed=embed) + await ctx.say(embed=embed) if session_check: - await self.bot.say("Nothing is currently streaming.") + await ctx.say("Nothing is currently streaming.") @commands.command(name='plex-search', description="Search from PLex", brief="Search Plex") - async def search_plex_though_tautulli(self, input): + async def search_plex_though_tautulli(self, ctx, input): tautulli_url = "{tautulli_base_url}api/v2?apikey={api_key}&cmd=search&query={string}".format( tautulli_base_url=TAUTULLI_BASE_URL, api_key=TAUTULLI_API_KEY, string=input) # tautulli_query = "{string}" @@ -102,19 +94,13 @@ class Plex: async with ses.get(tautulli_url) as resp: a = await resp.json() for entry in range(a['response']['data']['results_count']): - a = await resp.json() - b = await resp.json() is_TvShow = (a['response']['data']['results_list']['show']) - is_movie = (b['response']['data']['results_list']['movie']) + is_movie = (a['response']['data']['results_list']['movie']) if is_TvShow: - c = await resp.json() - d = await resp.json() - e = await resp.json() - f = await resp.json() - title = (c['response']['data']['results_list']['show'][entry]['title']) - year = (d['response']['data']['results_list']['show'][entry]['year']) - desc = (e['response']['data']['results_list']['show'][entry]['summary']) - thumbail_not_full = (f['response']['data']['results_list']['show'][entry]['thumb']) + title = (a['response']['data']['results_list']['show'][entry]['title']) + year = (a['response']['data']['results_list']['show'][entry]['year']) + desc = (a['response']['data']['results_list']['show'][entry]['summary']) + thumbail_not_full = (a['response']['data']['results_list']['show'][entry]['thumb']) tv_plex_token = '?checkFiles=1&X-Plex-Token=' + PLEX_TOKEN tv_generated_url = PLEX_URL + thumbail_not_full + tv_plex_token tv_embed = discord.Embed(title="Search results for TV", description="", color=0x00ff00) @@ -122,18 +108,13 @@ class Plex: tv_embed.add_field(name="Title", value=title) tv_embed.add_field(name="Year", value=year) tv_embed.add_field(name="Summary", value=desc) - await self.bot.say(embed=tv_embed) + await ctx.say(embed=tv_embed) elif is_movie: - h = await resp.json() - i = await resp.json() - j = await resp.json() - k = await resp.json() - l = await resp.json() - movie_title = (h['response']['data']['results_list']['movie'][entry]['title']) - movie_genre = (i['response']['data']['results_list']['movie'][entry]['genres']) - movie_year = (j['response']['data']['results_list']['movie'][entry]['year']) - movie_desc = (k['response']['data']['results_list']['movie'][entry]['summary']) - movie_thumb_not_full = (l['response']['data']['results_list']['movie'][entry]['thumb']) + movie_title = (a['response']['data']['results_list']['movie'][entry]['title']) + movie_genre = (a['response']['data']['results_list']['movie'][entry]['genres']) + movie_year = (a['response']['data']['results_list']['movie'][entry]['year']) + movie_desc = (a['response']['data']['results_list']['movie'][entry]['summary']) + movie_thumb_not_full = (a['response']['data']['results_list']['movie'][entry]['thumb']) movie_plex_token = '?checkFiles=1&X-Plex-Token=' + PLEX_TOKEN movie_generated_url = PLEX_URL + movie_thumb_not_full + movie_plex_token movie_embed = discord.Embed(title="Search results for Movies", description="", color=0x00ff00) @@ -142,9 +123,9 @@ class Plex: movie_embed.add_field(name="Genre", value=" ,\n".join(movie_genre)) movie_embed.add_field(name="Year", value=movie_year) movie_embed.add_field(name="Summary", value=movie_desc) - await self.bot.say(embed=movie_embed) + await ctx.say(embed=movie_embed) else: - await self.bot.say("Unable to find anything.") + await ctx.say("Unable to find anything.") break diff --git a/All-In-One-bot/cogs/polls.py b/All-In-One-bot/cogs/polls.py new file mode 100644 index 0000000000000000000000000000000000000000..c7afcd17359e61bfed22c50c42aef5e94ffd3d28 --- /dev/null +++ b/All-In-One-bot/cogs/polls.py @@ -0,0 +1,93 @@ +from discord.ext import commands +import asyncio + + +def to_emoji(c): + base = 0x1f1e6 + return chr(base + c) + + +class Polls: + """Poll voting system.""" + + def __init__(self, bot): + self.bot = bot + + @commands.command() + @commands.guild_only() + async def poll(self, ctx, *, question): + """Interactively creates a poll with the following question. + + To vote, use reactions! + """ + + # a list of messages to delete when we're all done + messages = [ctx.message] + answers = [] + + def check(m): + return m.author == ctx.author and m.channel == ctx.channel and len(m.content) <= 100 + + for i in range(20): + messages.append(await ctx.send(f'Say poll option or {ctx.prefix}cancel to publish poll.')) + + try: + entry = await self.bot.wait_for('message', check=check, timeout=60.0) + except asyncio.TimeoutError: + break + + messages.append(entry) + + if entry.clean_content.startswith(f'{ctx.prefix}cancel'): + break + + answers.append((to_emoji(i), entry.clean_content)) + + try: + await ctx.channel.delete_messages(messages) + except: + pass # oh well + + answer = '\n'.join(f'{keycap}: {content}' for keycap, content in answers) + actual_poll = await ctx.send(f'{ctx.author} asks: {question}\n\n{answer}') + for emoji, _ in answers: + await actual_poll.add_reaction(emoji) + + @poll.error + async def poll_error(self, ctx, error): + if isinstance(error, commands.MissingRequiredArgument): + return await ctx.send('Missing the question.') + + @commands.command() + @commands.guild_only() + async def quickpoll(self, ctx, *questions_and_choices: str): + """Makes a poll quickly. + + The first argument is the question and the rest are the choices. + """ + + if len(questions_and_choices) < 3: + return await ctx.send('Need at least 1 question with 2 choices.') + elif len(questions_and_choices) > 21: + return await ctx.send('You can only have up to 20 choices.') + + perms = ctx.channel.permissions_for(ctx.me) + if not (perms.read_message_history or perms.add_reactions): + return await ctx.send('Need Read Message History and Add Reactions permissions.') + + question = questions_and_choices[0] + choices = [(to_emoji(e), v) for e, v in enumerate(questions_and_choices[1:])] + + try: + await ctx.message.delete() + except: + pass + + body = "\n".join(f"{key}: {c}" for key, c in choices) + poll = await ctx.send(f'{ctx.author} asks: {question}\n\n{body}') + for emoji, _ in choices: + await poll.add_reaction(emoji) + + +def setup(bot): + bot.add_cog(Polls(bot)) \ No newline at end of file diff --git a/All-In-One-bot/cogs/utils/__init__.py b/All-In-One-bot/cogs/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/All-In-One-bot/cogs/utils/cache.py b/All-In-One-bot/cogs/utils/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..694dcd506018424ed430230b8fa2588c334f26de --- /dev/null +++ b/All-In-One-bot/cogs/utils/cache.py @@ -0,0 +1,92 @@ +import inspect +import asyncio +import enum + +from functools import wraps + +from lru import LRU + + +def _wrap_and_store_coroutine(cache, key, coro): + async def func(): + value = await coro + cache[key] = value + return value + return func() + + +def _wrap_new_coroutine(value): + async def new_coroutine(): + return value + return new_coroutine() + + +class Strategy(enum.Enum): + lru = 1 + raw = 2 + + +def cache(maxsize=128, strategy=Strategy.lru): + def decorator(func): + if strategy is Strategy.lru: + _internal_cache = LRU(maxsize) + _stats = _internal_cache.get_stats + elif strategy is Strategy.raw: + _internal_cache = {} + _stats = lambda: (0, 0) + + def _make_key(args, kwargs): + # this is a bit of a cluster fuck + # we do care what 'self' parameter is when we __repr__ it + def _true_repr(o): + if o.__class__.__repr__ is object.__repr__: + return f'<{o.__class__.__module__}.{o.__class__.__name__}>' + return repr(o) + + key = [ f'{func.__module__}.{func.__name__}' ] + key.extend(_true_repr(o) for o in args) + for k, v in kwargs.items(): + # note: this only really works for this use case in particular + # I want to pass asyncpg.Connection objects to the parameters + # however, they use default __repr__ and I do not care what + # connection is passed in, so I needed a bypass. + if k == 'connection': + continue + + key.append(_true_repr(k)) + key.append(_true_repr(v)) + + return ''.join(key) + + @wraps(func) + def wrapper(*args, **kwargs): + key = _make_key(args, kwargs) + try: + value = _internal_cache[key] + except KeyError: + value = func(*args, **kwargs) + + if inspect.isawaitable(value): + return _wrap_and_store_coroutine(_internal_cache, key, value) + + _internal_cache[key] = value + return value + else: + if asyncio.iscoroutinefunction(func): + return _wrap_new_coroutine(value) + return value + + def _invalidate(*args, **kwargs): + try: + del _internal_cache[_make_key(args, kwargs)] + except KeyError: + return False + else: + return True + + wrapper.cache = _internal_cache + wrapper.get_key = lambda *args, **kwargs: _make_key(args, kwargs) + wrapper.invalidate = _invalidate + wrapper.get_stats = _stats + return wrapper + return decorator \ No newline at end of file diff --git a/All-In-One-bot/cogs/utils/checks.py b/All-In-One-bot/cogs/utils/checks.py index 081400a5b133c5d203f8bd86dedc8036de791f73..556b8ba844fa837df7fd9be3faae277b1a955ebc 100644 --- a/All-In-One-bot/cogs/utils/checks.py +++ b/All-In-One-bot/cogs/utils/checks.py @@ -1,50 +1,154 @@ -# Borrowed from https://github.com/Rapptz/RoboDanny/tree/async - +import discord from discord.ext import commands -import discord.utils +# The permission system of the bot is based on a "just works" basis +# You have permissions and the bot has permissions. If you meet the permissions +# required to execute the command (and the bot does as well) then it goes through +# and you can execute the command. +# Certain permissions signify if the person is a moderator (Manage Server) or an +# admin (Administrator). Having these signify certain bypasses. +# Of course, the owner will always be able to execute commands. -def is_owner_check(message): - return message.author.id == '157970669261422592' +async def check_permissions(ctx, perms, *, check=all): + is_owner = await ctx.bot.is_owner(ctx.author) + if is_owner: + return True -def is_owner(): - return commands.check(lambda ctx: is_owner_check(ctx.message)) + resolved = ctx.channel.permissions_for(ctx.author) + return check(getattr(resolved, name, None) == value for name, value in perms.items()) -def check_permissions(ctx, perms): - msg = ctx.message - if is_owner_check(msg): - return True +def has_permissions(*, check=all, **perms): + async def pred(ctx): + return await check_permissions(ctx, perms, check=check) + return commands.check(pred) - ch = msg.channel - author = msg.author - resolved = ch.permissions_for(author) - return all(getattr(resolved, name, None) == value for name, value in perms.items()) +async def check_guild_permissions(ctx, perms, *, check=all): + is_owner = await ctx.bot.is_owner(ctx.author) + if is_owner: + return True -def role_or_permissions(ctx, check, **perms): - if check_permissions(ctx, perms): - return True + if ctx.guild is None: + return False - ch = ctx.message.channel - author = ctx.message.author - if ch.is_private: - return False # can't have roles in PMs + resolved = ctx.author.guild_permissions + return check(getattr(resolved, name, None) == value for name, value in perms.items()) - role = discord.utils.find(check, author.roles) - return role is not None +def has_guild_permissions(*, check=all, **perms): + async def pred(ctx): + return await check_guild_permissions(ctx, perms, check=check) + return commands.check(pred) + +# These do not take channel overrides into account + + +def is_mod(): + async def pred(ctx): + return await check_guild_permissions(ctx, {'manage_guild': True}) + return commands.check(pred) -def mod_or_permissions(**perms): - def predicate(ctx): - return role_or_permissions(ctx, lambda r: r.name in ('Bot Mod', 'Bot Admin'), **perms) - return commands.check(predicate) +def is_admin(): + async def pred(ctx): + return await check_guild_permissions(ctx, {'administrator': True}) + return commands.check(pred) + + +def am_i_owner(): + async def predicate(ctx): + is_owner = await ctx.bot.is_owner(ctx.author) + if is_owner: + return True + else: + return False + return commands.check(predicate) + + +def mod_or_permissions(**perms): + perms['manage_guild'] = True + async def predicate(ctx): + return await check_guild_permissions(ctx, perms, check=any) + return commands.check(predicate) def admin_or_permissions(**perms): - def predicate(ctx): - return role_or_permissions(ctx, lambda r: r.name == 'Bot Admin', **perms) + perms['administrator'] = True + async def predicate(ctx): + return await check_guild_permissions(ctx, perms, check=any) + return commands.check(predicate) + + +def is_in_guilds(*guild_ids): + def predicate(ctx): + guild = ctx.guild + if guild is None: + return False + return guild.id in guild_ids + return commands.check(predicate) + + +def is_lounge_cpp(): + return is_in_guilds(145079846832308224) + + +def song_requester_or_dj(): + async def predicate(ctx): + vc = ctx.voice_client + try: + if ctx.message.author.name == vc.source.requester.name: + return True + elif discord.utils.get(ctx.message.author.roles, name="DJ"): + return True + elif ctx.message.author != vc.source.requester or not discord.utils.get(ctx.message.author.roles, name="DJ"): + await ctx.send(f"The bot owner has been naughty and has failed to add a voting system, so sorry but your " + f"permissions are not enough to use this command.") + return False + except AttributeError: + pass + + return commands.check(predicate) + + +def song_requester_or_owner_or_dj(): + async def predicate(ctx): + is_owner = await ctx.bot.is_owner(ctx.author) + try: + vc = ctx.voice_client + + if ctx.message.author.name == vc.source.requester.name: + return True + elif is_owner: + return True + elif discord.utils.get(ctx.message.author.roles, name="DJ"): + return True + elif ctx.message.author != vc.source.requester or not discord.utils.get(ctx.message.author.roles, name="DJ"): + await ctx.send(f"The bot owner has been naughty and has failed to add a voting system, so sorry but your " + f"permissions are not enough to use this command.") + return False + except AttributeError: + pass + return commands.check(predicate) + + +def music_stop_check(): + async def predicate(ctx): + is_owner = await ctx.bot.is_owner(ctx.author) + try: + vc = ctx.voice_client + + if ctx.message.author.name == vc.source.requester.name: + return True + elif is_owner: + return True + elif discord.utils.get(ctx.message.author.roles, name="DJ"): + return True + elif ctx.message.author != vc.source.requester or not discord.utils.get(ctx.message.author.roles, name="DJ"): + await ctx.send(f"You lack the permissions to use this command, sorry.") + return False + except AttributeError: + pass + return commands.check(predicate) - return commands.check(predicate) \ No newline at end of file diff --git a/All-In-One-bot/cogs/utils/config.py b/All-In-One-bot/cogs/utils/config.py index 84e73a823f09ee27ad72734a6013424478922b6d..6498b50cecb3687b832e39eb05ca5fbd97bf4603 100644 --- a/All-In-One-bot/cogs/utils/config.py +++ b/All-In-One-bot/cogs/utils/config.py @@ -1,58 +1,86 @@ -# This is purely from https://github.com/Rapptz/RoboDanny/blob/async/cogs/utils/config.py - import json +import os +import uuid import asyncio +def _create_encoder(cls): + def _default(self, o): + if isinstance(o, cls): + return o.to_json() + return super().default(o) + + return type('_Encoder', (json.JSONEncoder,), { 'default': _default }) + + class Config: - """The "database" object. Internally based on ``json``.""" - - def __init__(self, name, **options): - self.name = name - self.object_hook = options.pop('object_hook', None) - self.encoder = options.pop('encoder', None) - self.loop = options.pop('loop', asyncio.get_event_loop()) - if options.pop('load_later', False): - self.loop.create_task(self.load()) - else: - self.load_from_file() - - def load_from_file(self): - try: - with open(self.name, 'r') as f: - self._db = json.load(f, object_hook=self.object_hook) - except FileNotFoundError: - self._db = {} - - async def load(self): - await self.loop.run_in_executor(None, self.load_from_file) - - def _dump(self): - with open(self.name, 'w') as f: - json.dump(self._db, f, ensure_ascii=True, cls=self.encoder) - - async def save(self): - await self.loop.run_in_executor(None, self._dump) - - def get(self, key, *args): - """Retrieves a config entry.""" - return self._db.get(key, *args) - - async def put(self, key, value, *args): - """Edits a config entry.""" - self._db[key] = value - await self.save() - - async def remove(self, key): - """Removes a config entry.""" - del self._db[key] - await self.save() - - def __contains__(self, item): - return self._db.__contains__(item) - - def __len__(self): - return self._db.__len__() - - def all(self): - return self._db + """The "database" object. Internally based on ``json``.""" + + def __init__(self, name, **options): + self.name = name + self.object_hook = options.pop('object_hook', None) + self.encoder = options.pop('encoder', None) + + try: + hook = options.pop('hook') + except KeyError: + pass + else: + self.object_hook = hook.from_json + self.encoder = _create_encoder(hook) + + self.loop = options.pop('loop', asyncio.get_event_loop()) + self.lock = asyncio.Lock() + if options.pop('load_later', False): + self.loop.create_task(self.load()) + else: + self.load_from_file() + + def load_from_file(self): + try: + with open(self.name, 'r') as f: + self._db = json.load(f, object_hook=self.object_hook) + except FileNotFoundError: + self._db = {} + + async def load(self): + with await self.lock: + await self.loop.run_in_executor(None, self.load_from_file) + + def _dump(self): + temp = '%s-%s.tmp' % (uuid.uuid4(), self.name) + with open(temp, 'w', encoding='utf-8') as tmp: + json.dump(self._db.copy(), tmp, ensure_ascii=True, cls=self.encoder, separators=(',', ':')) + + # atomically move the file + os.replace(temp, self.name) + + async def save(self): + with await self.lock: + await self.loop.run_in_executor(None, self._dump) + + def get(self, key, *args): + """Retrieves a config entry.""" + return self._db.get(str(key), *args) + + async def put(self, key, value, *args): + """Edits a config entry.""" + self._db[str(key)] = value + await self.save() + + async def remove(self, key): + """Removes a config entry.""" + del self._db[str(key)] + await self.save() + + def __contains__(self, item): + return str(item) in self._db + + def __getitem__(self, item): + return self._db[str(item)] + + def __len__(self): + return len(self._db) + + def all(self): + return self._db \ No newline at end of file diff --git a/All-In-One-bot/cogs/utils/context.py b/All-In-One-bot/cogs/utils/context.py new file mode 100644 index 0000000000000000000000000000000000000000..ff039fbb96088e732394e8c85773179ac8e65098 --- /dev/null +++ b/All-In-One-bot/cogs/utils/context.py @@ -0,0 +1,207 @@ +from discord.ext import commands +import asyncio + +class _ContextDBAcquire: + __slots__ = ('ctx', 'timeout') + + def __init__(self, ctx, timeout): + self.ctx = ctx + self.timeout = timeout + + def __await__(self): + return self.ctx._acquire(self.timeout).__await__() + + async def __aenter__(self): + await self.ctx._acquire(self.timeout) + return self.ctx.db + + async def __aexit__(self, *args): + await self.ctx.release() + +class Context(commands.Context): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.pool = self.bot.pool + self.db = None + + async def entry_to_code(self, entries): + width = max(len(a) for a, b in entries) + output = ['```'] + for name, entry in entries: + output.append(f'{name:<{width}}: {entry}') + output.append('```') + await self.send('\n'.join(output)) + + async def indented_entry_to_code(self, entries): + width = max(len(a) for a, b in entries) + output = ['```'] + for name, entry in entries: + output.append(f'\u200b{name:>{width}}: {entry}') + output.append('```') + await self.send('\n'.join(output)) + + def __repr__(self): + # we need this for our cache key strategy + return '<Context>' + + @property + def session(self): + return self.bot.session + + async def disambiguate(self, matches, entry): + if len(matches) == 0: + raise ValueError('No results found.') + + if len(matches) == 1: + return matches[0] + + await self.send('There are too many matches... Which one did you mean? **Only say the number**.') + await self.send('\n'.join(f'{index}: {entry(item)}' for index, item in enumerate(matches, 1))) + + def check(m): + return m.content.isdigit() and m.author.id == self.author.id and m.channel.id == self.channel.id + + await self.release() + + # only give them 3 tries. + try: + for i in range(3): + try: + message = await self.bot.wait_for('message', check=check, timeout=30.0) + except asyncio.TimeoutError: + raise ValueError('Took too long. Goodbye.') + + index = int(message.content) + try: + return matches[index - 1] + except: + await self.send(f'Please give me a valid number. {2 - i} tries remaining...') + + raise ValueError('Too many tries. Goodbye.') + finally: + await self.acquire() + + async def prompt(self, message, *, timeout=60.0, delete_after=True, reacquire=True, author_id=None): + """An interactive reaction confirmation dialog. + + Parameters + ----------- + message: str + The message to show along with the prompt. + timeout: float + How long to wait before returning. + delete_after: bool + Whether to delete the confirmation message after we're done. + reacquire: bool + Whether to release the database connection and then acquire it + again when we're done. + author_id: Optional[int] + The member who should respond to the prompt. Defaults to the author of the + Context's message. + + Returns + -------- + Optional[bool] + ``True`` if explicit confirm, + ``False`` if explicit deny, + ``None`` if deny due to timeout + """ + + if not self.channel.permissions_for(self.me).add_reactions: + raise RuntimeError('Bot does not have Add Reactions permission.') + + fmt = f'{message}\n\nReact with \N{WHITE HEAVY CHECK MARK} to confirm or \N{CROSS MARK} to deny.' + + author_id = author_id or self.author.id + msg = await self.send(fmt) + + confirm = None + + def check(payload): + nonlocal confirm + + if payload.message_id != msg.id or payload.user_id != author_id: + return False + + codepoint = str(payload.emoji) + + if codepoint == '\N{WHITE HEAVY CHECK MARK}': + confirm = True + return True + elif codepoint == '\N{CROSS MARK}': + confirm = False + return True + + return False + + for emoji in ('\N{WHITE HEAVY CHECK MARK}', '\N{CROSS MARK}'): + await msg.add_reaction(emoji) + + if reacquire: + await self.release() + + try: + await self.bot.wait_for('raw_reaction_add', check=check, timeout=timeout) + except asyncio.TimeoutError: + confirm = None + + try: + if reacquire: + await self.acquire() + + if delete_after: + await msg.delete() + finally: + return confirm + + def tick(self, opt, label=None): + emoji = '<:greenTick:330090705336664065>' if opt else '<:redTick:330090723011592193>' + if label is not None: + return f'{emoji}: {label}' + return emoji + + async def _acquire(self, timeout): + if self.db is None: + self.db = await self.pool.acquire(timeout=timeout) + return self.db + + def acquire(self, *, timeout=None): + """Acquires a database connection from the pool. e.g. :: + + async with ctx.acquire(): + await ctx.db.execute(...) + + or: :: + + await ctx.acquire() + try: + await ctx.db.execute(...) + finally: + await ctx.release() + """ + return _ContextDBAcquire(self, timeout) + + async def release(self): + """Releases the database connection from the pool. + + Useful if needed for "long" interactive commands where + we want to release the connection and re-acquire later. + + Otherwise, this is called automatically by the bot. + """ + # from source digging asyncpg source, releasing an already + # released connection does nothing + + if self.db is not None: + await self.bot.pool.release(self.db) + self.db = None + + async def show_help(self, command=None): + """Shows the help command for the specified command if given. + + If no command is given, then it'll show help for the current + command. + """ + cmd = self.bot.get_command('help') + command = command or self.command.qualified_name + await self.invoke(cmd, command=command) diff --git a/All-In-One-bot/cogs/utils/db.py b/All-In-One-bot/cogs/utils/db.py new file mode 100644 index 0000000000000000000000000000000000000000..d8a4c898be148d152c073002ea0f8625ba1c2289 --- /dev/null +++ b/All-In-One-bot/cogs/utils/db.py @@ -0,0 +1,1011 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2017 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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. +""" + +# These are just things that allow me to make tables for PostgreSQL easier +# This isn't exactly good. It's just good enough for my uses. +# Also shoddy migration support. + +from collections import OrderedDict +from pathlib import Path +import json +import os +import pydoc +import uuid +import datetime +import inspect +import decimal +import asyncpg +import logging +import asyncio + +log = logging.getLogger(__name__) + + +class SchemaError(Exception): + pass + + +class SQLType: + python = None + + def to_dict(self): + o = self.__dict__.copy() + cls = self.__class__ + o['__meta__'] = cls.__module__ + '.' + cls.__qualname__ + return o + + @classmethod + def from_dict(cls, data): + meta = data.pop('__meta__') + given = cls.__module__ + '.' + cls.__qualname__ + if given != meta: + cls = pydoc.locate(meta) + if cls is None: + raise RuntimeError('Could not locate "%s".' % meta) + + self = cls.__new__(cls) + self.__dict__.update(data) + return self + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.__dict__ == other.__dict__ + + def __ne__(self, other): + return not self.__eq__(other) + + def to_sql(self): + raise NotImplementedError() + + def is_real_type(self): + return True + +class Binary(SQLType): + python = bytes + + def to_sql(self): + return 'BYTEA' + +class Boolean(SQLType): + python = bool + + def to_sql(self): + return 'BOOLEAN' + +class Date(SQLType): + python = datetime.date + + def to_sql(self): + return 'DATE' + +class Datetime(SQLType): + python = datetime.datetime + + def __init__(self, *, timezone=False): + self.timezone = timezone + + def to_sql(self): + if self.timezone: + return 'TIMESTAMP WITH TIMEZONE' + return 'TIMESTAMP' + +class Double(SQLType): + python = float + + def to_sql(self): + return 'REAL' + +class Float(SQLType): + python = float + + def to_sql(self): + return 'FLOAT' + +class Integer(SQLType): + python = int + + def __init__(self, *, big=False, small=False, auto_increment=False): + self.big = big + self.small = small + self.auto_increment = auto_increment + + if big and small: + raise SchemaError('Integer column type cannot be both big and small.') + + def to_sql(self): + if self.auto_increment: + if self.big: + return 'BIGSERIAL' + if self.small: + return 'SMALLSERIAL' + return 'SERIAL' + if self.big: + return 'BIGINT' + if self.small: + return 'SMALLINT' + return 'INTEGER' + + def is_real_type(self): + return not self.auto_increment + +class Interval(SQLType): + python = datetime.timedelta + + def __init__(self, field=None): + if field: + field = field.upper() + if field not in ('YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE', 'SECOND', + 'YEAR TO MONTH', 'DAY TO HOUR', 'DAY TO MINUTE', 'DAY TO SECOND', + 'HOUR TO MINUTE', 'HOUR TO SECOND', 'MINUTE TO SECOND'): + raise SchemaError('invalid interval specified') + self.field = field + else: + self.field = None + + def to_sql(self): + if self.field: + return 'INTERVAL ' + self.field + return 'INTERVAL' + +class Numeric(SQLType): + python = decimal.Decimal + + def __init__(self, *, precision=None, scale=None): + if precision is not None: + if precision < 0 or precision > 1000: + raise SchemaError('precision must be greater than 0 and below 1000') + if scale is None: + scale = 0 + + self.precision = precision + self.scale = scale + + def to_sql(self): + if self.precision is not None: + return 'NUMERIC({0.precision}, {0.scale})'.format(self) + return 'NUMERIC' + +class String(SQLType): + python = str + + def __init__(self, *, length=None, fixed=False): + self.length = length + self.fixed = fixed + + if fixed and length is None: + raise SchemaError('Cannot have fixed string with no length') + + def to_sql(self): + if self.length is None: + return 'TEXT' + if self.fixed: + return 'CHAR({0.length})'.format(self) + return 'VARCHAR({0.length})'.format(self) + +class Time(SQLType): + python = datetime.time + + def __init__(self, *, timezone=False): + self.timezone = timezone + + def to_sql(self): + if self.timezone: + return 'TIME WITH TIME ZONE' + return 'TIME' + +class JSON(SQLType): + python = None + + def to_sql(self): + return 'JSONB' + +class ForeignKey(SQLType): + def __init__(self, table, column, *, sql_type=None, on_delete='CASCADE', on_update='NO ACTION'): + if not table or not isinstance(table, str): + raise SchemaError('missing table to reference (must be string)') + + valid_actions = ( + 'NO ACTION', + 'RESTRICT', + 'CASCADE', + 'SET NULL', + 'SET DEFAULT', + ) + + on_delete = on_delete.upper() + on_update = on_update.upper() + + if on_delete not in valid_actions: + raise TypeError('on_delete must be one of %s.' % valid_actions) + + if on_update not in valid_actions: + raise TypeError('on_update must be one of %s.' % valid_actions) + + + self.table = table + self.column = column + self.on_update = on_update + self.on_delete = on_delete + + if sql_type is None: + sql_type = Integer + + if inspect.isclass(sql_type): + sql_type = sql_type() + + if not isinstance(sql_type, SQLType): + raise TypeError('Cannot have non-SQLType derived sql_type') + + if not sql_type.is_real_type(): + raise SchemaError('sql_type must be a "real" type') + + self.sql_type = sql_type.to_sql() + + def is_real_type(self): + return False + + def to_sql(self): + fmt = '{0.sql_type} REFERENCES {0.table} ({0.column})' \ + ' ON DELETE {0.on_delete} ON UPDATE {0.on_update}' + return fmt.format(self) + +class Array(SQLType): + python = list + + def __init__(self, sql_type): + if inspect.isclass(sql_type): + sql_type = sql_type() + + if not isinstance(sql_type, SQLType): + raise TypeError('Cannot have non-SQLType derived sql_type') + + if not sql_type.is_real_type(): + raise SchemaError('sql_type must be a "real" type') + + self.sql_type = sql_type.to_sql() + + def to_sql(self): + return '{0.sql_type} ARRAY'.format(self) + + def is_real_type(self): + # technically, it is a real type + # however, it doesn't play very well with migrations + # so we're going to pretend that it isn't + return False + +class Column: + __slots__ = ( 'column_type', 'index', 'primary_key', 'nullable', + 'default', 'unique', 'name', 'index_name' ) + def __init__(self, column_type, *, index=False, primary_key=False, + nullable=True, unique=False, default=None, name=None): + + if inspect.isclass(column_type): + column_type = column_type() + + if not isinstance(column_type, SQLType): + raise TypeError('Cannot have a non-SQLType derived column_type') + + self.column_type = column_type + self.index = index + self.unique = unique + self.primary_key = primary_key + self.nullable = nullable + self.default = default + self.name = name + self.index_name = None # to be filled later + + if sum(map(bool, (unique, primary_key, default is not None))) > 1: + raise SchemaError("'unique', 'primary_key', and 'default' are mutually exclusive.") + + @classmethod + def from_dict(cls, data): + index_name = data.pop('index_name', None) + column_type = data.pop('column_type') + column_type = SQLType.from_dict(column_type) + self = cls(column_type=column_type, **data) + self.index_name = index_name + return self + + @property + def _comparable_id(self): + return '-'.join('%s:%s' % (attr, getattr(self, attr)) for attr in self.__slots__) + + def _to_dict(self): + d = { + attr: getattr(self, attr) + for attr in self.__slots__ + } + d['column_type'] = self.column_type.to_dict() + return d + + def _qualifiers_dict(self): + return { attr: getattr(self, attr) for attr in ('nullable', 'default')} + + def _is_rename(self, other): + if self.name == other.name: + return False + + return self.unique == other.unique and self.primary_key == other.primary_key + + def _create_table(self): + builder = [] + builder.append(self.name) + builder.append(self.column_type.to_sql()) + + default = self.default + if default is not None: + builder.append('DEFAULT') + if isinstance(default, str) and isinstance(self.column_type, String): + builder.append("'%s'" % default) + elif isinstance(default, bool): + builder.append(str(default).upper()) + else: + builder.append("(%s)" % default) + elif self.unique: + builder.append('UNIQUE') + elif self.primary_key: + builder.append('PRIMARY KEY') + + if not self.nullable: + builder.append('NOT NULL') + + return ' '.join(builder) + +class PrimaryKeyColumn(Column): + """Shortcut for a SERIAL PRIMARY KEY column.""" + + def __init__(self): + super().__init__(Integer(auto_increment=True), primary_key=True) + +class SchemaDiff: + __slots__ = ('table', 'upgrade', 'downgrade') + + def __init__(self, table, upgrade, downgrade): + self.table = table + self.upgrade = upgrade + self.downgrade = downgrade + + def to_dict(self): + return { 'upgrade': self.upgrade, 'downgrade': self.downgrade } + + def is_empty(self): + return len(self.upgrade) == 0 and len(self.downgrade) == 0 + + def to_sql(self, *, downgrade=False): + statements = [] + base = 'ALTER TABLE %s ' % self.table.__tablename__ + path = self.upgrade if not downgrade else self.downgrade + + for rename in path.get('rename_columns', []): + fmt = '{0}RENAME COLUMN {1[before]} TO {1[after]};'.format(base, rename) + statements.append(fmt) + + sub_statements = [] + for dropped in path.get('remove_columns', []): + fmt = 'DROP COLUMN {0[name]} RESTRICT'.format(dropped) + sub_statements.append(fmt) + + for changed_types in path.get('changed_column_types', []): + fmt = 'ALTER COLUMN {0[name]} SET DATA TYPE {0[type]}'.format(changed_types) + + using = changed_types.get('using') + if using is not None: + fmt = '%s USING %s' % (fmt, using) + + sub_statements.append(fmt) + + for constraints in path.get('changed_constraints', []): + before, after = constraints['before'], constraints['after'] + + before_default, after_default = before.get('default'), after.get('default') + if before_default is None and after_default is not None: + fmt = 'ALTER COLUMN {0[name]} SET DEFAULT {1[default]}'.format(constraints, after) + sub_statements.append(fmt) + elif before_default is not None and after_default is None: + fmt = 'ALTER COLUMN {0[name]} DROP DEFAULT'.format(constraints) + sub_statements.append(fmt) + + before_nullable, after_nullable = before.get('nullable'), after.get('nullable') + if not before_nullable and after_nullable: + fmt = 'ALTER COLUMN {0[name]} DROP NOT NULL'.format(constraints) + sub_statements.append(fmt) + elif before_nullable and not after_nullable: + fmt = 'ALTER COLUMN {0[name]} SET NOT NULL'.format(constraints) + sub_statements.append(fmt) + + for added in path.get('add_columns', []): + column = Column.from_dict(added) + sub_statements.append('ADD COLUMN ' + column._create_table()) + + if sub_statements: + statements.append(base + ', '.join(sub_statements) + ';') + + # handle the index creation bits + for dropped in path.get('drop_index', []): + statements.append('DROP INDEX IF EXISTS {0[index]};'.format(dropped)) + + for added in path.get('add_index', []): + fmt = 'CREATE INDEX IF NOT EXISTS {0[index]} ON {1.__tablename__} ({0[name]});' + statements.append(fmt.format(added, self.table)) + + return '\n'.join(statements) + +class MaybeAcquire: + def __init__(self, connection, *, pool): + self.connection = connection + self.pool = pool + self._cleanup = False + + async def __aenter__(self): + if self.connection is None: + self._cleanup = True + self._connection = c = await self.pool.acquire() + return c + return self.connection + + async def __aexit__(self, *args): + if self._cleanup: + await self.pool.release(self._connection) + +class TableMeta(type): + @classmethod + def __prepare__(cls, name, bases, **kwargs): + return OrderedDict() + + def __new__(cls, name, parents, dct, **kwargs): + columns = [] + + try: + table_name = kwargs['table_name'] + except KeyError: + table_name = name.lower() + + dct['__tablename__'] = table_name + + for elem, value in dct.items(): + if isinstance(value, Column): + if value.name is None: + value.name = elem + + if value.index: + value.index_name = '%s_%s_idx' % (table_name, value.name) + + columns.append(value) + + dct['columns'] = columns + return super().__new__(cls, name, parents, dct) + + def __init__(self, name, parents, dct, **kwargs): + super().__init__(name, parents, dct) + +class Table(metaclass=TableMeta): + @classmethod + async def create_pool(cls, uri, **kwargs): + """Sets up and returns the PostgreSQL connection pool that is used. + + .. note:: + + This must be called at least once before doing anything with the tables. + And must be called on the ``Table`` class. + + Parameters + ----------- + uri: str + The PostgreSQL URI to connect to. + \*\*kwargs + The arguments to forward to asyncpg.create_pool. + """ + + def _encode_jsonb(value): + return json.dumps(value) + + def _decode_jsonb(value): + return json.loads(value) + + old_init = kwargs.pop('init', None) + + async def init(con): + await con.set_type_codec('jsonb', schema='pg_catalog', encoder=_encode_jsonb, decoder=_decode_jsonb, format='text') + if old_init is not None: + await old_init(con) + + cls._pool = pool = await asyncpg.create_pool(uri, init=init, **kwargs) + return pool + + @classmethod + def acquire_connection(cls, connection): + return MaybeAcquire(connection, pool=cls._pool) + + @classmethod + def write_migration(cls, *, directory='migrations'): + """Writes the migration diff into the data file. + + Note + ------ + This doesn't actually commit/do the migration. + To do so, use :meth:`migrate`. + + Returns + -------- + bool + ``True`` if a migration was written, ``False`` otherwise. + + Raises + ------- + RuntimeError + Could not find the migration data necessary. + """ + + directory = Path(directory) / cls.__tablename__ + p = directory.with_suffix('.json') + + if not p.exists(): + raise RuntimeError('Could not find migration file.') + + current = directory.with_name('current-' + p.name) + + if not current.exists(): + raise RuntimeError('Could not find current data file.') + + with current.open() as fp: + current_table = cls.from_dict(json.load(fp)) + + diff = cls().diff(current_table) + + # the most common case, no difference + if diff.is_empty(): + return None + + # load the migration data + with p.open('r', encoding='utf-8') as fp: + data = json.load(fp) + migrations = data['migrations'] + + # check if we should add it + our_migrations = diff.to_dict() + if len(migrations) == 0 or migrations[-1] != our_migrations: + # we have a new migration, so add it + migrations.append(our_migrations) + temp_file = p.with_name('%s-%s.tmp' % (uuid.uuid4(), p.name)) + with temp_file.open('w', encoding='utf-8') as tmp: + json.dump(data, tmp, ensure_ascii=True, indent=4) + + temp_file.replace(p) + return True + return False + + @classmethod + async def migrate(cls, *, directory='migrations', index=-1, downgrade=False, verbose=False, connection=None): + """Actually run the latest migration pointed by the data file. + + Parameters + ----------- + directory: str + The directory of where the migration data file resides. + index: int + The index of the migration array to use. + downgrade: bool + Whether to run an upgrade or a downgrade. + verbose: bool + Whether to output some information to stdout. + connection: Optional[asyncpg.Connection] + The connection to use, if not provided will acquire one from + the internal pool. + """ + + directory = Path(directory) / cls.__tablename__ + p = directory.with_suffix('.json') + if not p.exists(): + raise RuntimeError('Could not find migration file.') + + with p.open('r', encoding='utf-8') as fp: + data = json.load(fp) + migrations = data['migrations'] + + try: + migration = migrations[index] + except IndexError: + return False + + diff = SchemaDiff(cls, migration['upgrade'], migration['downgrade']) + if diff.is_empty(): + return False + + async with MaybeAcquire(connection, pool=cls._pool) as con: + sql = diff.to_sql(downgrade=downgrade) + if verbose: + print(sql) + await con.execute(sql) + + current = directory.with_name('current-' + p.name) + with current.open('w', encoding='utf-8') as fp: + json.dump(cls.to_dict(), fp, indent=4, ensure_ascii=True) + + @classmethod + async def create(cls, *, directory='migrations', verbose=False, connection=None, run_migrations=True): + """Creates the database and manages migrations, if any. + + Parameters + ----------- + directory: str + The migrations directory. + verbose: bool + Whether to output some information to stdout. + connection: Optional[asyncpg.Connection] + The connection to use, if not provided will acquire one from + the internal pool. + run_migrations: bool + Whether to run migrations at all. + + Returns + -------- + Optional[bool] + ``True`` if the table was successfully created or + ``False`` if the table was successfully migrated or + ``None`` if no migration took place. + """ + directory = Path(directory) / cls.__tablename__ + p = directory.with_suffix('.json') + current = directory.with_name('current-' + p.name) + + table_data = cls.to_dict() + + if not p.exists(): + p.parent.mkdir(parents=True, exist_ok=True) + + # we're creating this table for the first time, + # it's an uncommon case so let's get it out of the way + # first, try to actually create the table + async with MaybeAcquire(connection, pool=cls._pool) as con: + sql = cls.create_table(exists_ok=True) + if verbose: + print(sql) + await con.execute(sql) + + # since that step passed, let's go ahead and make the migration + with p.open('w', encoding='utf-8') as fp: + data = { 'table': table_data, 'migrations': [] } + json.dump(data, fp, indent=4, ensure_ascii=True) + + with current.open('w', encoding='utf-8') as fp: + json.dump(table_data, fp, indent=4, ensure_ascii=True) + + return True + + if not run_migrations: + return None + + with current.open() as fp: + current_table = cls.from_dict(json.load(fp)) + + diff = cls().diff(current_table) + + # the most common case, no difference + if diff.is_empty(): + return None + + # execute the upgrade SQL + async with MaybeAcquire(connection, pool=cls._pool) as con: + sql = diff.to_sql() + if verbose: + print(sql) + await con.execute(sql) + + # load the migration data + with p.open('r', encoding='utf-8') as fp: + data = json.load(fp) + migrations = data['migrations'] + + # check if we should add it + our_migrations = diff.to_dict() + if len(migrations) == 0 or migrations[-1] != our_migrations: + # we have a new migration, so add it + migrations.append(our_migrations) + temp_file = p.with_name('%s-%s.tmp' % (uuid.uuid4(), p.name)) + with temp_file.open('w', encoding='utf-8') as tmp: + json.dump(data, tmp, ensure_ascii=True, indent=4) + + temp_file.replace(p) + + # update our "current" data in the filesystem + with current.open('w', encoding='utf-8') as fp: + json.dump(table_data, fp, indent=4, ensure_ascii=True) + + return False + + @classmethod + async def drop(cls, *, directory='migrations', verbose=False, connection=None): + """Drops the database and migrations, if any. + + Parameters + ----------- + directory: str + The migrations directory. + verbose: bool + Whether to output some information to stdout. + connection: Optional[asyncpg.Connection] + The connection to use, if not provided will acquire one from + the internal pool. + """ + + directory = Path(directory) / cls.__tablename__ + p = directory.with_suffix('.json') + current = directory.with_name('current-' + p.name) + + if not p.exists() or not current.exists(): + raise RuntimeError('Could not find the appropriate data files.') + + try: + p.unlink() + except: + raise RuntimeError('Could not delete migration file') + + try: + current.unlink() + except: + raise RuntimeError('Could not delete current migration file') + + async with MaybeAcquire(connection, pool=cls._pool) as con: + sql = 'DROP TABLE {0} CASCADE;'.format(cls.__tablename__) + if verbose: + print(sql) + await con.execute(sql) + + @classmethod + def create_table(cls, *, exists_ok=True): + """Generates the CREATE TABLE stub.""" + statements = [] + builder = ['CREATE TABLE'] + + if exists_ok: + builder.append('IF NOT EXISTS') + + builder.append(cls.__tablename__) + builder.append('(%s)' % ', '.join(c._create_table() for c in cls.columns)) + statements.append(' '.join(builder) + ';') + + # handle the index creations + for column in cls.columns: + if column.index: + fmt = 'CREATE INDEX IF NOT EXISTS {1.index_name} ON {0} ({1.name});'.format(cls.__tablename__, column) + statements.append(fmt) + + return '\n'.join(statements) + + @classmethod + async def insert(cls, connection=None, **kwargs): + """Inserts an element to the table.""" + + # verify column names: + verified = {} + for column in cls.columns: + try: + value = kwargs[column.name] + except KeyError: + continue + + check = column.column_type.python + if value is None and not column.nullable: + raise TypeError('Cannot pass None to non-nullable column %s.' % column.name) + elif not check or not isinstance(value, check): + fmt = 'column {0.name} expected {1.__name__}, received {2.__class__.__name__}' + raise TypeError(fmt.format(column, check, value)) + + verified[column.name] = value + + sql = 'INSERT INTO {0} ({1}) VALUES ({2});'.format(cls.__tablename__, ', '.join(verified), + ', '.join('$' + str(i) for i, _ in enumerate(verified, 1))) + + async with MaybeAcquire(connection, pool=cls._pool) as con: + await con.execute(sql, *verified.values()) + + @classmethod + def to_dict(cls): + x = {} + x['name'] = cls.__tablename__ + x['__meta__'] = cls.__module__ + '.' + cls.__qualname__ + + # nb: columns is ordered due to the ordered dict usage + # this is used to help detect renames + x['columns'] = [a._to_dict() for a in cls.columns] + return x + + @classmethod + def from_dict(cls, data): + meta = data['__meta__'] + given = cls.__module__ + '.' + cls.__qualname__ + if given != meta: + cls = pydoc.locate(meta) + if cls is None: + raise RuntimeError('Could not locate "%s".' % meta) + + self = cls() + self.__tablename__ = data['name'] + self.columns = [Column.from_dict(a) for a in data['columns']] + return self + + @classmethod + def all_tables(cls): + return cls.__subclasses__() + + def diff(self, before): + """Outputs the upgrade and downgrade path in JSON. + + This isn't necessarily good, but it outputs it in a format + that allows the user to manually make edits if something is wrong. + + The following JSON schema is used: + + Note that every major key takes a list of objects as noted below. + + Note that add_column and drop_column automatically create and drop + indices as necessary. + + changed_column_types: + name: str [The column name] + type: str [The new column type] + using: Optional[str] [The USING expression to use, if applicable] + add_columns: + column: object + remove_columns: + column: object + rename_columns: + before: str [The previous column name] + after: str [The new column name] + drop_index: + name: str [The column name] + index: str [The index name] + add_index: + name: str [The column name] + index: str [The index name] + changed_constraints: + name: str [The column name] + before: + nullable: Optional[bool] + default: Optional[str] + after: + nullable: Optional[bool] + default: Optional[str] + """ + upgrade = {} + downgrade = {} + + def check_index_diff(a, b): + if a.index != b.index: + # Let's assume we have {name: thing, index: True} + # and we're going to { name: foo, index: False } + # This is a 'dropped' column when we upgrade with a rename + # care must be taken to use the old name when dropping + + # check if we're dropping the index + if not a.index: + # we could also be renaming so make sure to use the old index name + upgrade.setdefault('drop_index', []).append({ 'name': a.name, 'index': b.index_name }) + # if we want to roll back, we need to re-add the old index to the old column name + downgrade.setdefault('add_index', []).append({ 'name': b.name, 'index': b.index_name }) + else: + # we're not dropping an index, instead we're adding one + upgrade.setdefault('add_index', []).append({ 'name': a.name, 'index': a.index_name }) + downgrade.setdefault('drop_index', []).append({ 'name': a.name, 'index': a.index_name }) + + def insert_column_diff(a, b): + if a.column_type != b.column_type: + if a.name == b.name and a.column_type.is_real_type() and b.column_type.is_real_type(): + upgrade.setdefault('changed_column_types', []).append({ 'name': a.name, 'type': a.column_type.to_sql() }) + downgrade.setdefault('changed_column_types', []).append({ 'name': a.name, 'type': b.column_type.to_sql() }) + else: + a_dict, b_dict = a._to_dict(), b._to_dict() + upgrade.setdefault('add_columns', []).append(a_dict) + upgrade.setdefault('remove_columns', []).append(b_dict) + downgrade.setdefault('remove_columns', []).append(a_dict) + downgrade.setdefault('add_columns', []).append(b_dict) + check_index_diff(a, b) + return + + elif a._is_rename(b): + upgrade.setdefault('rename_columns', []).append({ 'before': b.name, 'after': a.name }) + downgrade.setdefault('rename_columns', []).append({ 'before': a.name, 'after': b.name }) + + # technically, adding UNIQUE or PRIMARY KEY is rather simple and straight forward + # however, since the inverse is a little bit more complicated (you have to remove + # the index it maintains and you can't easily know what it is), it's not exactly + # worth supporting any sort of change to the uniqueness/primary_key as it stands. + # So.. just drop/add the column and call it a day. + if a.unique != b.unique or a.primary_key != b.primary_key: + a_dict, b_dict = a._to_dict(), b._to_dict() + upgrade.setdefault('add_columns', []).append(a_dict) + upgrade.setdefault('remove_columns', []).append(b_dict) + downgrade.setdefault('remove_columns', []).append(a_dict) + downgrade.setdefault('add_columns', []).append(b_dict) + check_index_diff(a, b) + return + + check_index_diff(a, b) + + b_qual, a_qual = b._qualifiers_dict(), a._qualifiers_dict() + if a_qual != b_qual: + upgrade.setdefault('changed_constraints', []).append({ 'name': a.name, 'before': b_qual, 'after': a_qual }) + downgrade.setdefault('changed_constraints', []).append({ 'name': a.name, 'before': a_qual, 'after': b_qual }) + + if len(self.columns) == len(before.columns): + # check if we have any changes at all + for a, b in zip(self.columns, before.columns): + if a._comparable_id == b._comparable_id: + # no change + continue + insert_column_diff(a, b) + + elif len(self.columns) > len(before.columns): + # check if we have more columns + # typically when we add columns we add them at the end of + # the table, this assumption makes this particularly bit easier. + # Breaking this assumption will probably break this portion and thus + # will require manual handling, sorry. + + for a, b in zip(self.columns, before.columns): + if a._comparable_id == b._comparable_id: + # no change + continue + insert_column_diff(a, b) + + new_columns = self.columns[len(before.columns):] + added = [c._to_dict() for c in new_columns] + upgrade.setdefault('add_columns', []).extend(added) + downgrade.setdefault('remove_columns', []).extend(added) + elif len(self.columns) < len(before.columns): + # check if we have fewer columns + # this one is a little bit more complicated + + # first we sort the columns by comparable IDs. + sorted_before = sorted(before.columns, key=lambda c: c._comparable_id) + sorted_after = sorted(self.columns, key=lambda c: c._comparable_id) + + # handle the column diffs: + for a, b in zip(sorted_after, sorted_before): + if a._comparable_id == b._comparable_id: + continue + insert_column_diff(a, b) + + # check which columns are 'left over' and remove them + removed = [c._to_dict() for c in sorted_before[len(sorted_after):]] + upgrade.setdefault('remove_columns', []).extend(removed) + downgrade.setdefault('add_columns', []).extend(removed) + + return SchemaDiff(self, upgrade, downgrade) + + +async def _table_creator(tables, *, verbose=True): + for table in tables: + try: + await table.create(verbose=verbose) + except: + log.error('Failed to create table %s.', table.__tablename__) + + +def create_tables(*tables, verbose=True, loop=None): + if loop is None: + loop = asyncio.get_event_loop() + + loop.create_task(_table_creator(tables, verbose=verbose)) \ No newline at end of file diff --git a/All-In-One-bot/cogs/utils/formats.py b/All-In-One-bot/cogs/utils/formats.py index 759aa2e03ae7f4b1ecd474f31b052c1d19ecc5af..f438d8094c21b409f00038a5cf1c642e9a3129cc 100644 --- a/All-In-One-bot/cogs/utils/formats.py +++ b/All-In-One-bot/cogs/utils/formats.py @@ -1,27 +1,77 @@ -# Borrowed from https://github.com/Rapptz/RoboDanny/tree/async - - -async def entry_to_code(bot, entries): - width = max(map(lambda t: len(t[0]), entries)) - output = ['```'] - fmt = '{0:<{width}}: {1}' - for name, entry in entries: - output.append(fmt.format(name, entry, width=width)) - output.append('```') - await bot.say('\n'.join(output)) - - -async def too_many_matches(bot, msg, matches, entry): - check = lambda m: m.content.isdigit() - await bot.say('There are too many matches... Which one did you mean?') - await bot.say('\n'.join(map(entry, enumerate(matches, 1)))) - - # only give them 3 tries. - for i in range(3): - message = await bot.wait_for_message(author=msg.author, channel=msg.channel, check=check) - index = int(message.content) - try: - return matches[index - 1] - except: - await bot.say('Please give me a valid number. {} tries remaining...'.format(2 - i)) - raise ValueError('Too many tries. Goodbye.') \ No newline at end of file +class Plural: + def __init__(self, **attr): + iterator = attr.items() + self.name, self.value = next(iter(iterator)) + + def __str__(self): + v = self.value + if v == 0 or v > 1: + return f'{v} {self.name}s' + return f'{v} {self.name}' + + +def human_join(seq, delim=', ', final='or'): + size = len(seq) + if size == 0: + return '' + + if size == 1: + return seq[0] + + if size == 2: + return f'{seq[0]} {final} {seq[1]}' + + return delim.join(seq[:-1]) + f' {final} {seq[-1]}' + +class TabularData: + def __init__(self): + self._widths = [] + self._columns = [] + self._rows = [] + + def set_columns(self, columns): + self._columns = columns + self._widths = [len(c) + 2 for c in columns] + + def add_row(self, row): + rows = [str(r) for r in row] + self._rows.append(rows) + for index, element in enumerate(rows): + width = len(element) + 2 + if width > self._widths[index]: + self._widths[index] = width + + def add_rows(self, rows): + for row in rows: + self.add_row(row) + + def render(self): + """Renders a table in rST format. + + Example: + + +-------+-----+ + | Name | Age | + +-------+-----+ + | Alice | 24 | + | Bob | 19 | + +-------+-----+ + """ + + sep = '+'.join('-' * w for w in self._widths) + sep = f'+{sep}+' + + to_draw = [sep] + + def get_entry(d): + elem = '|'.join(f'{e:^{self._widths[i]}}' for i, e in enumerate(d)) + return f'|{elem}|' + + to_draw.append(get_entry(self._columns)) + to_draw.append(sep) + + for row in self._rows: + to_draw.append(get_entry(row)) + + to_draw.append(sep) + return '\n'.join(to_draw) \ No newline at end of file diff --git a/All-In-One-bot/cogs/utils/paginator.py b/All-In-One-bot/cogs/utils/paginator.py new file mode 100644 index 0000000000000000000000000000000000000000..fd912211e71c74b4fe6abe7fd469535a36c055b2 --- /dev/null +++ b/All-In-One-bot/cogs/utils/paginator.py @@ -0,0 +1,511 @@ +import asyncio +import discord + + +class CannotPaginate(Exception): + pass + + +class Pages: + """Implements a paginator that queries the user for the + pagination interface. + + Pages are 1-index based, not 0-index based. + + If the user does not reply within 2 minutes then the pagination + interface exits automatically. + + Parameters + ------------ + ctx: Context + The context of the command. + entries: List[str] + A list of entries to paginate. + per_page: int + How many entries show up per page. + show_entry_count: bool + Whether to show an entry count in the footer. + + Attributes + ----------- + embed: discord.Embed + The embed object that is being used to send pagination info. + Feel free to modify this externally. Only the description, + footer fields, and colour are internally modified. + permissions: discord.Permissions + Our permissions for the channel. + """ + def __init__(self, ctx, *, entries, per_page=12, show_entry_count=True): + self.bot = ctx.bot + self.entries = entries + self.message = ctx.message + self.channel = ctx.channel + self.author = ctx.author + self.per_page = per_page + pages, left_over = divmod(len(self.entries), self.per_page) + if left_over: + pages += 1 + self.maximum_pages = pages + self.embed = discord.Embed(colour=discord.Colour.blurple()) + self.paginating = len(entries) > per_page + self.show_entry_count = show_entry_count + self.reaction_emojis = [ + ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.first_page), + ('\N{BLACK LEFT-POINTING TRIANGLE}', self.previous_page), + ('\N{BLACK RIGHT-POINTING TRIANGLE}', self.next_page), + ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.last_page), + ('\N{INPUT SYMBOL FOR NUMBERS}', self.numbered_page ), + ('\N{BLACK SQUARE FOR STOP}', self.stop_pages), + ('\N{INFORMATION SOURCE}', self.show_help), + ] + + if ctx.guild is not None: + self.permissions = self.channel.permissions_for(ctx.guild.me) + else: + self.permissions = self.channel.permissions_for(ctx.bot.user) + + if not self.permissions.embed_links: + raise CannotPaginate('Bot does not have embed links permission.') + + if not self.permissions.send_messages: + raise CannotPaginate('Bot cannot send messages.') + + if self.paginating: + # verify we can actually use the pagination session + if not self.permissions.add_reactions: + raise CannotPaginate('Bot does not have add reactions permission.') + + if not self.permissions.read_message_history: + raise CannotPaginate('Bot does not have Read Message History permission.') + + def get_page(self, page): + base = (page - 1) * self.per_page + return self.entries[base:base + self.per_page] + + async def show_page(self, page, *, first=False): + self.current_page = page + entries = self.get_page(page) + p = [] + for index, entry in enumerate(entries, 1 + ((page - 1) * self.per_page)): + p.append(f'{index}. {entry}') + + if self.maximum_pages > 1: + if self.show_entry_count: + text = f'Page {page}/{self.maximum_pages} ({len(self.entries)} entries)' + else: + text = f'Page {page}/{self.maximum_pages}' + + self.embed.set_footer(text=text) + + if not self.paginating: + self.embed.description = '\n'.join(p) + return await self.channel.send(embed=self.embed) + + if not first: + self.embed.description = '\n'.join(p) + await self.message.edit(embed=self.embed) + return + + p.append('') + p.append('Confused? React with \N{INFORMATION SOURCE} for more info.') + self.embed.description = '\n'.join(p) + self.message = await self.channel.send(embed=self.embed) + for (reaction, _) in self.reaction_emojis: + if self.maximum_pages == 2 and reaction in ('\u23ed', '\u23ee'): + # no |<< or >>| buttons if we only have two pages + # we can't forbid it if someone ends up using it but remove + # it from the default set + continue + + await self.message.add_reaction(reaction) + + async def checked_show_page(self, page): + if page != 0 and page <= self.maximum_pages: + await self.show_page(page) + + async def first_page(self): + """goes to the first page""" + await self.show_page(1) + + async def last_page(self): + """goes to the last page""" + await self.show_page(self.maximum_pages) + + async def next_page(self): + """goes to the next page""" + await self.checked_show_page(self.current_page + 1) + + async def previous_page(self): + """goes to the previous page""" + await self.checked_show_page(self.current_page - 1) + + async def show_current_page(self): + if self.paginating: + await self.show_page(self.current_page) + + async def numbered_page(self): + """lets you type a page number to go to""" + to_delete = [] + to_delete.append(await self.channel.send('What page do you want to go to?')) + + def message_check(m): + return m.author == self.author and \ + self.channel == m.channel and \ + m.content.isdigit() + + try: + msg = await self.bot.wait_for('message', check=message_check, timeout=30.0) + except asyncio.TimeoutError: + to_delete.append(await self.channel.send('Took too long.')) + await asyncio.sleep(5) + else: + page = int(msg.content) + to_delete.append(msg) + if page != 0 and page <= self.maximum_pages: + await self.show_page(page) + else: + to_delete.append(await self.channel.send(f'Invalid page given. ({page}/{self.maximum_pages})')) + await asyncio.sleep(5) + + try: + await self.channel.delete_messages(to_delete) + except Exception: + pass + + async def show_help(self): + """shows this message""" + messages = ['Welcome to the interactive paginator!\n'] + messages.append('This interactively allows you to see pages of text by navigating with ' \ + 'reactions. They are as follows:\n') + + for (emoji, func) in self.reaction_emojis: + messages.append(f'{emoji} {func.__doc__}') + + self.embed.description = '\n'.join(messages) + self.embed.clear_fields() + self.embed.set_footer(text=f'We were on page {self.current_page} before this message.') + await self.message.edit(embed=self.embed) + + async def go_back_to_current_page(): + await asyncio.sleep(60.0) + await self.show_current_page() + + self.bot.loop.create_task(go_back_to_current_page()) + + async def stop_pages(self): + """stops the interactive pagination session""" + await self.message.delete() + self.paginating = False + + def react_check(self, reaction, user): + if user is None or user.id != self.author.id: + return False + + if reaction.message.id != self.message.id: + return False + + for (emoji, func) in self.reaction_emojis: + if reaction.emoji == emoji: + self.match = func + return True + return False + + async def paginate(self): + """Actually paginate the entries and run the interactive loop if necessary.""" + first_page = self.show_page(1, first=True) + if not self.paginating: + await first_page + else: + # allow us to react to reactions right away if we're paginating + self.bot.loop.create_task(first_page) + + while self.paginating: + try: + reaction, user = await self.bot.wait_for('reaction_add', check=self.react_check, timeout=120.0) + except asyncio.TimeoutError: + self.paginating = False + try: + await self.message.clear_reactions() + except: + pass + finally: + break + + try: + await self.message.remove_reaction(reaction, user) + except: + pass # can't remove it so don't bother doing so + + await self.match() + +class FieldPages(Pages): + """Similar to Pages except entries should be a list of + tuples having (key, value) to show as embed fields instead. + """ + async def show_page(self, page, *, first=False): + self.current_page = page + entries = self.get_page(page) + + self.embed.clear_fields() + self.embed.description = discord.Embed.Empty + + for key, value in entries: + self.embed.add_field(name=key, value=value, inline=False) + + if self.maximum_pages > 1: + if self.show_entry_count: + text = f'Page {page}/{self.maximum_pages} ({len(self.entries)} entries)' + else: + text = f'Page {page}/{self.maximum_pages}' + + self.embed.set_footer(text=text) + + if not self.paginating: + return await self.channel.send(embed=self.embed) + + if not first: + await self.message.edit(embed=self.embed) + return + + self.message = await self.channel.send(embed=self.embed) + for (reaction, _) in self.reaction_emojis: + if self.maximum_pages == 2 and reaction in ('\u23ed', '\u23ee'): + # no |<< or >>| buttons if we only have two pages + # we can't forbid it if someone ends up using it but remove + # it from the default set + continue + + await self.message.add_reaction(reaction) + +import itertools +import inspect +import re + +# ?help +# ?help Cog +# ?help command +# -> could be a subcommand + +_mention = re.compile(r'<@\!?([0-9]{1,19})>') + +def cleanup_prefix(bot, prefix): + m = _mention.match(prefix) + if m: + user = bot.get_user(int(m.group(1))) + if user: + return f'@{user.name} ' + return prefix + +async def _can_run(cmd, ctx): + try: + return await cmd.can_run(ctx) + except: + return False + +def _command_signature(cmd): + # this is modified from discord.py source + # which I wrote myself lmao + + result = [cmd.qualified_name] + if cmd.usage: + result.append(cmd.usage) + return ' '.join(result) + + params = cmd.clean_params + if not params: + return ' '.join(result) + + for name, param in params.items(): + if param.default is not param.empty: + # We don't want None or '' to trigger the [name=value] case and instead it should + # do [name] since [name=None] or [name=] are not exactly useful for the user. + should_print = param.default if isinstance(param.default, str) else param.default is not None + if should_print: + result.append(f'[{name}={param.default!r}]') + else: + result.append(f'[{name}]') + elif param.kind == param.VAR_POSITIONAL: + result.append(f'[{name}...]') + else: + result.append(f'<{name}>') + + return ' '.join(result) + +class HelpPaginator(Pages): + def __init__(self, ctx, entries, *, per_page=4): + super().__init__(ctx, entries=entries, per_page=per_page) + self.reaction_emojis.append(('\N{WHITE QUESTION MARK ORNAMENT}', self.show_bot_help)) + self.total = len(entries) + + @classmethod + async def from_cog(cls, ctx, cog): + cog_name = cog.__class__.__name__ + + # get the commands + entries = sorted(ctx.bot.get_cog_commands(cog_name), key=lambda c: c.name) + + # remove the ones we can't run + entries = [cmd for cmd in entries if (await _can_run(cmd, ctx)) and not cmd.hidden] + + self = cls(ctx, entries) + self.title = f'{cog_name} Commands' + self.description = inspect.getdoc(cog) + self.prefix = cleanup_prefix(ctx.bot, ctx.prefix) + + # no longer need the database + await ctx.release() + + return self + + @classmethod + async def from_command(cls, ctx, command): + try: + entries = sorted(command.commands, key=lambda c: c.name) + except AttributeError: + entries = [] + else: + entries = [cmd for cmd in entries if (await _can_run(cmd, ctx)) and not cmd.hidden] + + self = cls(ctx, entries) + self.title = command.signature + + if command.description: + self.description = f'{command.description}\n\n{command.help}' + else: + self.description = command.help or 'No help given.' + + self.prefix = cleanup_prefix(ctx.bot, ctx.prefix) + await ctx.release() + return self + + @classmethod + async def from_bot(cls, ctx): + def key(c): + return c.cog_name or '\u200bMisc' + + entries = sorted(ctx.bot.commands, key=key) + nested_pages = [] + per_page = 9 + + # 0: (cog, desc, commands) (max len == 9) + # 1: (cog, desc, commands) (max len == 9) + # ... + + for cog, commands in itertools.groupby(entries, key=key): + plausible = [cmd for cmd in commands if (await _can_run(cmd, ctx)) and not cmd.hidden] + if len(plausible) == 0: + continue + + description = ctx.bot.get_cog(cog) + if description is None: + description = discord.Embed.Empty + else: + description = inspect.getdoc(description) or discord.Embed.Empty + + nested_pages.extend((cog, description, plausible[i:i + per_page]) for i in range(0, len(plausible), per_page)) + + self = cls(ctx, nested_pages, per_page=1) # this forces the pagination session + self.prefix = cleanup_prefix(ctx.bot, ctx.prefix) + await ctx.release() + + # swap the get_page implementation with one that supports our style of pagination + self.get_page = self.get_bot_page + self._is_bot = True + + # replace the actual total + self.total = sum(len(o) for _, _, o in nested_pages) + return self + + def get_bot_page(self, page): + cog, description, commands = self.entries[page - 1] + self.title = f'{cog} Commands' + self.description = description + return commands + + async def show_page(self, page, *, first=False): + self.current_page = page + entries = self.get_page(page) + + self.embed.clear_fields() + self.embed.description = self.description + self.embed.title = self.title + + if hasattr(self, '_is_bot'): + value ='For more help, join the official bot support server: https://discord.gg/DWEaqMy' + self.embed.add_field(name='Support', value=value, inline=False) + + self.embed.set_footer(text=f'Use "{self.prefix}help command" for more info on a command.') + + signature = _command_signature + + for entry in entries: + self.embed.add_field(name=signature(entry), value=entry.short_doc or "No help given", inline=False) + + if self.maximum_pages: + self.embed.set_author(name=f'Page {page}/{self.maximum_pages} ({self.total} commands)') + + if not self.paginating: + return await self.channel.send(embed=self.embed) + + if not first: + await self.message.edit(embed=self.embed) + return + + self.message = await self.channel.send(embed=self.embed) + for (reaction, _) in self.reaction_emojis: + if self.maximum_pages == 2 and reaction in ('\u23ed', '\u23ee'): + # no |<< or >>| buttons if we only have two pages + # we can't forbid it if someone ends up using it but remove + # it from the default set + continue + + await self.message.add_reaction(reaction) + + async def show_help(self): + """shows this message""" + + self.embed.title = 'Paginator help' + self.embed.description = 'Hello! Welcome to the help page.' + + messages = [f'{emoji} {func.__doc__}' for emoji, func in self.reaction_emojis] + self.embed.clear_fields() + self.embed.add_field(name='What are these reactions for?', value='\n'.join(messages), inline=False) + + self.embed.set_footer(text=f'We were on page {self.current_page} before this message.') + await self.message.edit(embed=self.embed) + + async def go_back_to_current_page(): + await asyncio.sleep(30.0) + await self.show_current_page() + + self.bot.loop.create_task(go_back_to_current_page()) + + async def show_bot_help(self): + """shows how to use the bot""" + + self.embed.title = 'Using the bot' + self.embed.description = 'Hello! Welcome to the help page.' + self.embed.clear_fields() + + entries = ( + ('<argument>', 'This means the argument is __**required**__.'), + ('[argument]', 'This means the argument is __**optional**__.'), + ('[A|B]', 'This means the it can be __**either A or B**__.'), + ('[argument...]', 'This means you can have multiple arguments.\n' \ + 'Now that you know the basics, it should be noted that...\n' \ + '__**You do not type in the brackets!**__') + ) + + self.embed.add_field(name='How do I use this bot?', value='Reading the bot signature is pretty simple.') + + for name, value in entries: + self.embed.add_field(name=name, value=value, inline=False) + + self.embed.set_footer(text=f'We were on page {self.current_page} before this message.') + await self.message.edit(embed=self.embed) + + async def go_back_to_current_page(): + await asyncio.sleep(30.0) + await self.show_current_page() + + self.bot.loop.create_task(go_back_to_current_page()) diff --git a/All-In-One-bot/cogs/utils/time.py b/All-In-One-bot/cogs/utils/time.py new file mode 100644 index 0000000000000000000000000000000000000000..32207b43411966f155e82dfb7f243a2d75c11b70 --- /dev/null +++ b/All-In-One-bot/cogs/utils/time.py @@ -0,0 +1,196 @@ +import datetime +import parsedatetime as pdt +from dateutil.relativedelta import relativedelta +from .formats import Plural +from discord.ext import commands +import re + + +class ShortTime: + compiled = re.compile("""(?:(?P<years>[0-9])(?:years?|y))? # e.g. 2y + (?:(?P<months>[0-9]{1,2})(?:months?|mo))? # e.g. 2months + (?:(?P<weeks>[0-9]{1,4})(?:weeks?|w))? # e.g. 10w + (?:(?P<days>[0-9]{1,5})(?:days?|d))? # e.g. 14d + (?:(?P<hours>[0-9]{1,5})(?:hours?|h))? # e.g. 12h + (?:(?P<minutes>[0-9]{1,5})(?:minutes?|m))? # e.g. 10m + (?:(?P<seconds>[0-9]{1,5})(?:seconds?|s))? # e.g. 15s + """, re.VERBOSE) + + def __init__(self, argument): + match = self.compiled.fullmatch(argument) + if match is None or not match.group(0): + raise commands.BadArgument('invalid time provided') + + data = { k: int(v) for k, v in match.groupdict(default=0).items() } + now = datetime.datetime.utcnow() + self.dt = now + relativedelta(**data) + + +class HumanTime: + calendar = pdt.Calendar(version=pdt.VERSION_CONTEXT_STYLE) + + def __init__(self, argument): + now = datetime.datetime.utcnow() + dt, status = self.calendar.parseDT(argument, sourceTime=now) + if not status.hasDateOrTime: + raise commands.BadArgument('invalid time provided, try e.g. "tomorrow" or "3 days"') + + if not status.hasTime: + # replace it with the current time + dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) + + self.dt = dt + self._past = dt < now + + +class Time(HumanTime): + def __init__(self, argument): + try: + o = ShortTime(argument) + except Exception as e: + super().__init__(argument) + else: + self.dt = o.dt + self._past = False + + +class FutureTime(Time): + def __init__(self, argument): + super().__init__(argument) + + if self._past: + raise commands.BadArgument('this time is in the past') + + +class UserFriendlyTime(commands.Converter): + """That way quotes aren't absolutely necessary.""" + def __init__(self, converter=None, *, default=None): + if isinstance(converter, type) and issubclass(converter, commands.Converter): + converter = converter() + + if converter is not None and not isinstance(converter, commands.Converter): + raise TypeError('commands.Converter subclass necessary.') + + self.converter = converter + self.default = default + + async def check_constraints(self, ctx, now, remaining): + if self.dt < now: + raise commands.BadArgument('This time is in the past.') + + if not remaining: + if self.default is None: + raise commands.BadArgument('Missing argument after the time.') + remaining = self.default + + if self.converter is not None: + self.arg = await self.converter.convert(ctx, remaining) + else: + self.arg = remaining + return self + + async def convert(self, ctx, argument): + try: + calendar = HumanTime.calendar + regex = ShortTime.compiled + now = datetime.datetime.utcnow() + + match = regex.match(argument) + if match is not None and match.group(0): + data = { k: int(v) for k, v in match.groupdict(default=0).items() } + remaining = argument[match.end():].strip() + self.dt = now + relativedelta(**data) + return await self.check_constraints(ctx, now, remaining) + + + # apparently nlp does not like "from now" + # it likes "from x" in other cases though so let me handle the 'now' case + if argument.endswith('from now'): + argument = argument[:-8].strip() + + if argument[0:2] == 'me': + # starts with "me to" or "me in" + if argument[0:6] in ('me to ', 'me in '): + argument = argument[6:] + + elements = calendar.nlp(argument, sourceTime=now) + if elements is None or len(elements) == 0: + raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') + + # handle the following cases: + # "date time" foo + # date time foo + # foo date time + + # first the first two cases: + dt, status, begin, end, dt_string = elements[0] + + if not status.hasDateOrTime: + raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') + + if begin not in (0, 1) and end != len(argument): + raise commands.BadArgument('Time is either in an inappropriate location, which ' \ + 'must be either at the end or beginning of your input, ' \ + 'or I just flat out did not understand what you meant. Sorry.') + + if not status.hasTime: + # replace it with the current time + dt = dt.replace(hour=now.hour, minute=now.minute, second=now.second, microsecond=now.microsecond) + + self.dt = dt + + if begin in (0, 1): + if begin == 1: + # check if it's quoted: + if argument[0] != '"': + raise commands.BadArgument('Expected quote before time input...') + + if not (end < len(argument) and argument[end] == '"'): + raise commands.BadArgument('If the time is quoted, you must unquote it.') + + remaining = argument[end + 1:].lstrip(' ,.!') + else: + remaining = argument[end:].lstrip(' ,.!') + elif len(argument) == end: + remaining = argument[:begin].strip() + + return await self.check_constraints(ctx, now, remaining) + except: + import traceback + traceback.print_exc() + raise + + +def human_timedelta(dt, *, source=None): + now = source or datetime.datetime.utcnow() + if dt > now: + delta = relativedelta(dt, now) + suffix = '' + else: + delta = relativedelta(now, dt) + suffix = ' ago' + + if delta.microseconds and delta.seconds: + delta = delta + relativedelta(seconds=+1) + + attrs = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'] + + output = [] + for attr in attrs: + elem = getattr(delta, attr) + if not elem: + continue + + if elem > 1: + output.append(f'{elem} {attr}') + else: + output.append(f'{elem} {attr[:-1]}') + + if len(output) == 0: + return 'now' + elif len(output) == 1: + return output[0] + suffix + elif len(output) == 2: + return f'{output[0]} and {output[1]}{suffix}' + else: + return f'{output[0]}, {output[1]} and {output[2]}{suffix}' \ No newline at end of file diff --git a/All-In-One-bot/config.py b/All-In-One-bot/config.py new file mode 100644 index 0000000000000000000000000000000000000000..d31f5790481f9fbc424e47ebc54755872ace6575 --- /dev/null +++ b/All-In-One-bot/config.py @@ -0,0 +1,3 @@ +client_id = '440790222347829258' +token = 'NDQwNzkwMjIyMzQ3ODI5MjU4.DjfwxQ.blSXGcE4xda6jpTas0h8nBJs4OY' +postgresql = 'postgresql://samip537:VgZeE7BBj9@localhost/samip537' \ No newline at end of file diff --git a/All-In-One-bot/launcher.py b/All-In-One-bot/launcher.py new file mode 100644 index 0000000000000000000000000000000000000000..e2a0f5ba30963cf2bd20d6ba243db40cbd481b66 --- /dev/null +++ b/All-In-One-bot/launcher.py @@ -0,0 +1,329 @@ +import sys +import click +import logging +import asyncio +import asyncpg +import discord +import importlib +import contextlib + +from bot import RoboSamip, initial_extensions +from cogs.utils.db import Table + +from pathlib import Path +import config +import traceback + +try: + import uvloop +except ImportError: + pass +else: + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + + +@contextlib.contextmanager +def setup_logging(): + try: + # __enter__ + logging.getLogger('discord').setLevel(logging.INFO) + logging.getLogger('discord.http').setLevel(logging.WARNING) + + log = logging.getLogger() + log.setLevel(logging.INFO) + handler = logging.FileHandler(filename='samipbot.log', encoding='utf-8', mode='w') + dt_fmt = '%Y-%m-%d %H:%M:%S' + fmt = logging.Formatter('[{asctime}] [{levelname:<7}] {name}: {message}', dt_fmt, style='{') + handler.setFormatter(fmt) + log.addHandler(handler) + + yield + finally: + # __exit__ + handlers = log.handlers[:] + for hdlr in handlers: + hdlr.close() + log.removeHandler(hdlr) + + +def run_bot(): + loop = asyncio.get_event_loop() + log = logging.getLogger() + + try: + pool = loop.run_until_complete(Table.create_pool(config.postgresql, command_timeout=60)) + except Exception as e: + click.echo('Could not set up PostgreSQL. Exiting.', file=sys.stderr) + log.exception('Could not set up PostgreSQL. Exiting.') + return + + bot = RoboSamip() + bot.pool = pool + bot.run() + + +@click.group(invoke_without_command=True, options_metavar='[options]') +@click.pass_context +def main(ctx): + """Launches the bot.""" + if ctx.invoked_subcommand is None: + loop = asyncio.get_event_loop() + with setup_logging(): + run_bot() + + +@main.group(short_help='database stuff', options_metavar='[options]') +def db(): + pass + + +@db.command(short_help='initialises the databases for the bot', options_metavar='[options]') +@click.argument('cogs', nargs=-1, metavar='[cogs]') +@click.option('-q', '--quiet', help='less verbose output', is_flag=True) +def init(cogs, quiet): + """This manages the migrations and database creation system for you.""" + + run = asyncio.get_event_loop().run_until_complete + try: + run(Table.create_pool(config.postgresql)) + except Exception: + click.echo(f'Could not create PostgreSQL connection pool.\n{traceback.format_exc()}', err=True) + return + + if not cogs: + cogs = initial_extensions + else: + cogs = [f'cogs.{e}' if not e.startswith('cogs.') else e for e in cogs] + + for ext in cogs: + try: + importlib.import_module(ext) + except Exception: + click.echo(f'Could not load {ext}.\n{traceback.format_exc()}', err=True) + return + + for table in Table.all_tables(): + try: + created = run(table.create(verbose=not quiet, run_migrations=False)) + except Exception: + click.echo(f'Could not create {table.__tablename__}.\n{traceback.format_exc()}', err=True) + else: + if created: + click.echo(f'[{table.__module__}] Created {table.__tablename__}.') + else: + click.echo(f'[{table.__module__}] No work needed for {table.__tablename__}.') + + +@db.command(short_help='migrates the databases') +@click.argument('cog', nargs=1, metavar='[cog]') +@click.option('-q', '--quiet', help='less verbose output', is_flag=True) +@click.pass_context +def migrate(ctx, cog, quiet): + """Update the migration file with the newest schema.""" + + if not cog.startswith('cogs.'): + cog = f'cogs.{cog}' + + try: + importlib.import_module(cog) + except Exception: + click.echo(f'Could not load {ext}.\n{traceback.format_exc()}', err=True) + return + + def work(table, *, invoked=False): + try: + actually_migrated = table.write_migration() + except RuntimeError as e: + click.echo(f'Could not migrate {table.__tablename__}: {e}', err=True) + if not invoked: + click.confirm('do you want to create the table?', abort=True) + ctx.invoke(init, cogs=[cog], quiet=quiet) + work(table, invoked=True) + sys.exit(-1) + else: + if actually_migrated: + click.echo(f'Successfully updated migrations for {table.__tablename__}.') + else: + click.echo(f'Found no changes for {table.__tablename__}.') + + for table in Table.all_tables(): + work(table) + + click.echo(f'Done migrating {cog}.') + + +async def apply_migration(cog, quiet, index, *, downgrade=False): + try: + pool = await Table.create_pool(config.postgresql) + except Exception: + click.echo(f'Could not create PostgreSQL connection pool.\n{traceback.format_exc()}', err=True) + return + + if not cog.startswith('cogs.'): + cog = f'cogs.{cog}' + + try: + importlib.import_module(cog) + except Exception: + click.echo(f'Could not load {cog}.\n{traceback.format_exc()}', err=True) + return + + async with pool.acquire() as con: + tr = con.transaction() + await tr.start() + for table in Table.all_tables(): + try: + await table.migrate(index=index, downgrade=downgrade, verbose=not quiet, connection=con) + except RuntimeError as e: + click.echo(f'Could not migrate {table.__tablename__}: {e}', err=True) + await tr.rollback() + break + else: + await tr.commit() + + +@db.command(short_help='upgrades from a migration') +@click.argument('cog', nargs=1, metavar='[cog]') +@click.option('-q', '--quiet', help='less verbose output', is_flag=True) +@click.option('--index', help='the index to use', default=-1) +def upgrade(cog, quiet, index): + """Runs an upgrade from a migration""" + run = asyncio.get_event_loop().run_until_complete + run(apply_migration(cog, quiet, index)) + + +@db.command(short_help='downgrades from a migration') +@click.argument('cog', nargs=1, metavar='[cog]') +@click.option('-q', '--quiet', help='less verbose output', is_flag=True) +@click.option('--index', help='the index to use', default=-1) +def downgrade(cog, quiet, index): + """Runs an downgrade from a migration""" + run = asyncio.get_event_loop().run_until_complete + run(apply_migration(cog, quiet, index, downgrade=True)) + + +async def remove_databases(pool, cog, quiet): + async with pool.acquire() as con: + tr = con.transaction() + await tr.start() + for table in Table.all_tables(): + try: + await table.drop(verbose=not quiet, connection=con) + except RuntimeError as e: + click.echo(f'Could not drop {table.__tablename__}: {e}', err=True) + await tr.rollback() + break + else: + click.echo(f'Dropped {table.__tablename__}.') + else: + await tr.commit() + click.echo(f'successfully removed {cog} tables.') + + +@db.command(short_help="removes a cog's table", options_metavar='[options]') +@click.argument('cog', metavar='<cog>') +@click.option('-q', '--quiet', help='less verbose output', is_flag=True) +def drop(cog, quiet): + """This removes a database and all its migrations. + + You must be pretty sure about this before you do it, + as once you do it there's no coming back. + + Also note that the name must be the database name, not + the cog name. + """ + + run = asyncio.get_event_loop().run_until_complete + click.confirm('do you really want to do this?', abort=True) + + try: + pool = run(Table.create_pool(config.postgresql)) + except Exception: + click.echo(f'Could not create PostgreSQL connection pool.\n{traceback.format_exc()}', err=True) + return + + if not cog.startswith('cogs.'): + cog = f'cogs.{cog}' + + try: + importlib.import_module(cog) + except Exception: + click.echo(f'Could not load {cog}.\n{traceback.format_exc()}', err=True) + return + + run(remove_databases(pool, cog, quiet)) + + +@main.command(short_help='migrates from JSON files') +@click.argument('cogs', nargs=-1) +@click.pass_context +def convertjson(ctx, cogs): + """This migrates our older JSON files to PostgreSQL + + Note, this deletes all previous entries in the table + so you can consider this to be a destructive decision. + + Do not pass in cog names with "cogs." as a prefix. + + This also connects us to Discord itself so we can + use the cache for our migrations. + + The point of this is just to do some migration of the + data from v3 -> v4 once and call it a day. + """ + + import data_migrators + + run = asyncio.get_event_loop().run_until_complete + + if not cogs: + to_run = [(getattr(data_migrators, attr), attr.replace('migrate_', '')) + for attr in dir(data_migrators) if attr.startswith('migrate_')] + else: + to_run = [] + for cog in cogs: + try: + elem = getattr(data_migrators, 'migrate_' + cog) + except AttributeError: + click.echo(f'invalid cog name given, {cog}.', err=True) + return + + to_run.append((elem, cog)) + + async def make_pool(): + return await asyncpg.create_pool(config.postgresql) + + try: + pool = run(make_pool()) + except Exception: + click.echo(f'Could not create PostgreSQL connection pool.\n{traceback.format_exc()}', err=True) + return + + client = discord.AutoShardedClient() + + @client.event + async def on_ready(): + click.echo(f'successfully booted up bot {client.user} (ID: {client.user.id})') + await client.logout() + + try: + run(client.login(config.token)) + run(client.connect(reconnect=False)) + except: + pass + + extensions = ['cogs.' + name for _, name in to_run] + ctx.invoke(init, cogs=extensions) + + for migrator, _ in to_run: + try: + run(migrator(pool, client)) + except Exception: + click.echo(f'[error] {migrator.__name__} has failed, terminating\n{traceback.format_exc()}', err=True) + return + else: + click.echo(f'[{migrator.__name__}] completed successfully') + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/All-In-One-bot/main.py b/All-In-One-bot/main.py deleted file mode 100644 index a6cbbd17bf353108aecc4f3fe23512d9ac0375c0..0000000000000000000000000000000000000000 --- a/All-In-One-bot/main.py +++ /dev/null @@ -1,121 +0,0 @@ -# Author: samip5 -# Version: 0.5 - -import configparser as parser -import datetime -import logging -import sys -import traceback -import asyncpg - -import discord -from discord import Game -from discord.ext import commands - -config = parser.ConfigParser() - -config.read('./config.ini') - -TOKEN = config['secrets']['BOT-DEVELOMENT-TOKEN'] - -logger_info = logging.getLogger('discord') -# logger_debug = logging.getLogger('discord') -logger_info.setLevel(logging.INFO) -# logger_debug.setLevel(logging.DEBUG) -logFile_info_handler = logging.FileHandler(filename='discord_info.log', encoding='utf-8', mode='w') -# logFile_debug_handler = logging.FileHandler(filename='discord_debug.log', encoding='utf-8', mode='w') -logFile_info_handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) -# logFile_debug_handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(name)s: %(message)s')) -logger_info.addHandler(logFile_info_handler) -# logger_debug.addHandler(logFile_debug_handler) - - -def get_prefix(bot, message): - prefixes = ['!'] - return commands.when_mentioned_or(*prefixes)(bot, message) - - -initial_extensions = ['cogs.general', 'cogs.plex', 'cogs.ombi', 'cogs.misc', 'cogs.meta', 'cogs.mod'] - -help_attrs = dict(hidden=True) -bot = commands.Bot(command_prefix=get_prefix, help_attrs=help_attrs) - - -# @bot.event -# async def on_command_error(error, ctx): -# if isinstance(error, commands.NoPrivateMessage): -# await bot.send_message(ctx.message.author, 'This command cannot be used in private messages.') -# elif isinstance(error, commands.DisabledCommand): -# await bot.send_message(ctx.message.author, 'Sorry. This command is disabled and cannot be used.') - -@bot.event -async def on_ready(): - bot.uptime = datetime.datetime.utcnow() - credentials = { - "user": "samip537", - "password": "VgZeE7BBj9", - "database": "samip537", - "host": "psql1.n.kapsi.fi" - } - try: - bot.pool = await asyncpg.create_pool(**credentials) - except TimeoutError: - pass - print (f'\n\nLogged in as: {bot.user.name} - {bot.user.id}\nAPI Version: {discord.__version__}\n') - await bot.change_presence(game=Game(name="with code")) - print (f'Successfully connected.') - - -@bot.event -async def on_message(message): - mod = bot.get_cog('Mod') - - if mod is not None and not checks.is_owner_check(message): - # check if the user is bot banned - if message.author.id in mod.config.get('plonks', []): - return - - # check if the channel is ignored - # but first, resolve their permissions - - perms = message.channel.permissions_for(message.author) - bypass_ignore = perms.manage_roles - - # if we don't have manage roles then we should - # check if it's the owner of the bot or they have Bot Admin role. - - if not bypass_ignore: - if not message.channel.is_private: - bypass_ignore = discord.utils.get(message.author.roles, name='Bot Admin') is not None - - # now we can finally realise if we can actually bypass the ignore. - - if not bypass_ignore: - if message.channel.id in mod.config.get('ignored', []): - return - - # if someone private messages us with something that looks like a URL then - # we should try to see if it's an invite to a discord server and join it if so. - if message.channel.is_private and message.content.startswith('http'): - try: - invite = await bot.get_invite(message.content) - await bot.accept_invite(invite) - await bot.send_message(message.channel, 'Joined the server.') - except: - # if an error occurs at this point then ignore it and move on. - pass - finally: - return - await bot.process_commands(message) - -if __name__ == '__main__': - if any('debug' in arg.lower() for arg in sys.argv): - bot.command_prefix = '$' - for extension in initial_extensions: - try: - bot.load_extension(extension) - except: - print(f'Failed to load extension {extension}.', file=sys.stderr) - traceback.print_exc() - -bot.run(TOKEN, bot=True, reconnect=True) diff --git a/All-In-One-bot/requiments.txt b/All-In-One-bot/requiments.txt new file mode 100644 index 0000000000000000000000000000000000000000..39cd25759ad6a8f605dafb5b727c57796f06b41b --- /dev/null +++ b/All-In-One-bot/requiments.txt @@ -0,0 +1,10 @@ +git+https://github.com/Rapptz/discord.py@rewrite#egg=discord.py[voice] +lxml +psutil +asyncpg>=0.12.0 +click +parsedatetime +lru_dict +python-dateutil +plexapi +requests \ No newline at end of file diff --git a/Music-bot/cogs/meta.py b/Music-bot/cogs/meta.py index 9f7de8ab445fb8fd4b4ab38b3585ec2a0a3fcd3e..337b45672650bc2b33a3298f54b146945bd66074 100644 --- a/Music-bot/cogs/meta.py +++ b/Music-bot/cogs/meta.py @@ -17,9 +17,9 @@ class Meta: minutes, seconds = divmod(remainder, 60) days, hours = divmod(hours, 24) if days: - fmt = '{d} days, {h} hours, {m} minutes, and {s} seconds' + fmt = '{d} days, {h} hours and {m} minutes' else: - fmt = '{h} hours, {m} minutes, and {s} seconds' + fmt = '{h} hours and {m} minutes' return fmt.format(d=days, h=hours, m=minutes, s=seconds) def get_system_uptime(self): @@ -53,8 +53,7 @@ class Meta: if len(string) > 0 or hours > 0: string += str(hours) + " " + (hours == 1 and "hour" or "hours") + ", " if len(string) > 0 or minutes > 0: - string += str(minutes) + " " + (minutes == 1 and "minute" or "minutes") + ", " - string += str(seconds) + " " + (seconds == 1 and "second" or "seconds") + string += str(minutes) + " " + (minutes == 1 and "minute" or "minutes") return string diff --git a/Music-bot/cogs/mod.py b/Music-bot/cogs/mod.py new file mode 100644 index 0000000000000000000000000000000000000000..4cb0ba3394cf12d8d110ac7e58d835403125c5a3 --- /dev/null +++ b/Music-bot/cogs/mod.py @@ -0,0 +1,132 @@ +import discord + +from discord.ext import commands +from .utils import config, checks + + +class Mod: + """Moderation related commands.""" + + def __init__(self, bot): + self.bot = bot + self.config = config.Config('mod.json', loop=bot.loop) + + def bot_user(self, message): + return message.server.me if message.channel.is_private else self.bot.user + + @commands.command(no_pm=True) + @checks.admin_or_permissions(manage_server=True) + async def plonk(self, ctx, *, member: discord.Member): + """Bans a user from using the bot. + Note that this ban is **global**. So they are banned from + all servers that they access the bot with. So use this with + caution. + There is no way to bypass a plonk regardless of role or permissions. + The only person who cannot be plonked is the bot creator. So this + must be used with caution. + To use this command you must have the Manage Server permission + or have a Bot Admin role. + """ + + plonks = self.config.get('plonks', []) + if member.id in plonks: + await ctx.send('That user is already bot banned.') + return + + plonks.append(member.id) + await self.config.put('plonks', plonks) + await ctx.send('{0.name} has been banned from using the bot.'.format(member)) + + @commands.command(no_pm=True) + @checks.admin_or_permissions(manage_server=True) + async def unplonk(self, ctx, *, member: discord.Member): + """Unbans a user from using the bot. + To use this command you must have the Manage Server permission + or have a Bot Admin role. + """ + + plonks = self.config.get('plonks', []) + + try: + plonks.remove(member.id) + except ValueError: + pass + else: + await self.config.put('plonks', plonks) + await ctx.send('{0.name} has been unbanned from using the bot.'.format(member)) + + @commands.group(pass_context=True, no_pm=True) + @checks.admin_or_permissions(manage_channel=True) + async def ignore(self, ctx): + """Handles the bot's ignore lists. + To use these commands, you must have the Bot Admin role or have + Manage Channel permissions. These commands are not allowed to be used + in a private message context. + Users with Manage Roles or Bot Admin role can still invoke the bot + in ignored channels. + """ + if ctx.invoked_subcommand is None: + await ctx.send('Invalid subcommand passed: {0.subcommand_passed}'.format(ctx)) + + @ignore.command(name='channel', pass_context=True) + async def channel_cmd(self, ctx, *, channel: discord.guild.TextChannel = None): + """Ignores a specific channel from being processed. + If no channel is specified, the current channel is ignored. + If a channel is ignored then the bot does not process commands in that + channel until it is unignored. + """ + + if channel is None: + channel = ctx.message.channel + + ignored = self.config.get('ignored', []) + if channel.id in ignored: + await ctx.send('That channel is already ignored.') + return + + ignored.append(channel.id) + await self.config.put('ignored', ignored) + await ctx.send('\U0001f44c') + + @ignore.command(name='all', pass_context=True) + @checks.admin_or_permissions(manage_server=True) + async def _all(self, ctx): + """Ignores every channel in the server from being processed. + This works by adding every channel that the server currently has into + the ignore list. If more channels are added then they will have to be + ignored by using the ignore command. + To use this command you must have Manage Server permissions along with + Manage Channel permissions. You could also have the Bot Admin role. + """ + + ignored = self.config.get('ignored', []) + channels = ctx.message.server.channels + ignored.extend(c.id for c in channels if c.type == discord.ChannelType.text) + await self.config.put('ignored', list(set(ignored))) # make unique + await ctx.send('\U0001f44c') + + @commands.command(pass_context=True, no_pm=True) + @checks.admin_or_permissions(manage_channel=True) + async def unignore(self, ctx, *, channel: discord.guild.TextChannel = None): + """Unignores a specific channel from being processed. + If no channel is specified, it unignores the current channel. + To use this command you must have the Manage Channel permission or have the + Bot Admin role. + """ + + if channel is None: + channel = ctx.message.channel + + # a set is the proper data type for the ignore list + # however, JSON only supports arrays and objects not sets. + ignored = self.config.get('ignored', []) + try: + ignored.remove(channel.id) + except ValueError: + await ctx.send('Channel was not ignored in the first place.') + else: + await ctx.send('\U0001f44c') + + +def setup(bot): + bot.add_cog(Mod(bot)) diff --git a/Music-bot/cogs/music.py b/Music-bot/cogs/music.py index 813d3c43504ba52036c440cdd4385efffede6077..90fc6d63db84aa995279192cac184b807adb9317 100644 --- a/Music-bot/cogs/music.py +++ b/Music-bot/cogs/music.py @@ -17,7 +17,6 @@ import itertools import sys import traceback from functools import partial -from typing import List import discord import youtube_dl diff --git a/Music-bot/cogs/utils/checks.py b/Music-bot/cogs/utils/checks.py index 1c7230b8b8b6e028e89695ea4e996d322fd7ed14..336149f43ddd3e1b554c9aedb6a69543bfaeed1b 100644 --- a/Music-bot/cogs/utils/checks.py +++ b/Music-bot/cogs/utils/checks.py @@ -103,41 +103,39 @@ def song_requester_or_dj(): def song_requester_or_owner_or_dj(): async def predicate(ctx): is_owner = await ctx.bot.is_owner(ctx.author) - vc = ctx.voice_client - - if not vc or not vc.is_connected(): - await ctx.send('I am not currently connected to voice!') + try: + vc = ctx.voice_client - if ctx.message.author.name == vc.source.requester.name: - return True - elif is_owner: - return True - elif discord.utils.get(ctx.message.author.roles, name="DJ"): - return True - elif ctx.message.author != vc.source.requester or not discord.utils.get(ctx.message.author.roles, name="DJ"): - await ctx.send(f"The bot owner has been naughty and has failed to add a voting system, so sorry but your " + if ctx.message.author.name == vc.source.requester.name: + return True + elif is_owner: + return True + elif discord.utils.get(ctx.message.author.roles, name="DJ"): + return True + elif ctx.message.author != vc.source.requester or not discord.utils.get(ctx.message.author.roles, name="DJ"): + await ctx.send(f"The bot owner has been naughty and has failed to add a voting system, so sorry but your " f"permissions are not enough to use this command.") - return False - + return False + except AttributeError: + pass return commands.check(predicate) def music_stop_check(): async def predicate(ctx): is_owner = await ctx.bot.is_owner(ctx.author) - vc = ctx.voice_client - - if not vc or not vc.is_connected(): - await ctx.send('I am not currently connected to voice!') - - if ctx.message.author.name == vc.source.requester.name: - return True - elif is_owner: - return True - elif discord.utils.get(ctx.message.author.roles, name="DJ"): - return True - elif ctx.message.author != vc.source.requester or not discord.utils.get(ctx.message.author.roles, name="DJ"): - await ctx.send(f"You lack the permissions to use this command, sorry.") - return False + try: + vc = ctx.voice_client + if ctx.message.author.name == vc.source.requester.name: + return True + elif is_owner: + return True + elif discord.utils.get(ctx.message.author.roles, name="DJ"): + return True + elif ctx.message.author != vc.source.requester or not discord.utils.get(ctx.message.author.roles, name="DJ"): + await ctx.send(f"You lack the permissions to use this command, sorry.") + return False + except AttributeError: + pass return commands.check(predicate) \ No newline at end of file