From 05d83f25535b1db61a4e5776440307b4a7ca5058 Mon Sep 17 00:00:00 2001 From: Ave O Date: Fri, 9 Mar 2018 01:47:53 +0300 Subject: [PATCH] Initial commit --- .gitignore | 100 ++++++++++++++++++++++++++ LICENSE | 21 ++++++ README.md | 3 + botbase.ini.example | 4 ++ botbase.py | 160 +++++++++++++++++++++++++++++++++++++++++ cogs/admin.py | 170 ++++++++++++++++++++++++++++++++++++++++++++ cogs/basic.py | 42 +++++++++++ cogs/common.py | 166 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 ++ 9 files changed, 672 insertions(+) create mode 100755 .gitignore create mode 100755 LICENSE create mode 100755 README.md create mode 100755 botbase.ini.example create mode 100755 botbase.py create mode 100644 cogs/admin.py create mode 100644 cogs/basic.py create mode 100644 cogs/common.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..c4bdd71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,100 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# botbase stuff +# *.log # mentioned above on django. +*.ini +files/* + +# pycharm +.idea +*.ttf + +priv-* \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..c5cc0d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Arda "Ave" Ozkal + +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. diff --git a/README.md b/README.md new file mode 100755 index 0000000..f3fd264 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# BotBase + +A crappy discord.py@rewrite bot base. \ No newline at end of file diff --git a/botbase.ini.example b/botbase.ini.example new file mode 100755 index 0000000..8b75ca3 --- /dev/null +++ b/botbase.ini.example @@ -0,0 +1,4 @@ +[base] +prefix = bb! +token = token_goes_here +description = Your bot description goes here. \ No newline at end of file diff --git a/botbase.py b/botbase.py new file mode 100755 index 0000000..892ac78 --- /dev/null +++ b/botbase.py @@ -0,0 +1,160 @@ +import os +import sys +import logging +import logging.handlers +import traceback +import configparser +from pathlib import Path +import aiohttp + +import discord +from discord.ext import commands + +script_name = os.path.basename(__file__).split('.')[0] + +log_file_name = f"{script_name}.log" + +# Limit of discord (non-nitro) is 8MB (not MiB) +max_file_size = 1000 * 1000 * 8 +backup_count = 10000 # random big number +file_handler = logging.handlers.RotatingFileHandler( + filename=log_file_name, maxBytes=max_file_size, backupCount=backup_count) +stdout_handler = logging.StreamHandler(sys.stdout) + +log_format = logging.Formatter( + '[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s') +file_handler.setFormatter(log_format) +stdout_handler.setFormatter(log_format) + +log = logging.getLogger('discord') +log.setLevel(logging.INFO) +log.addHandler(file_handler) +log.addHandler(stdout_handler) + +config = configparser.ConfigParser() +config.read(f"{script_name}.ini") + + +def get_prefix(bot, message): + prefixes = [config['base']['prefix']] + + return commands.when_mentioned_or(*prefixes)(bot, message) + + +initial_extensions = ['cogs.common', + 'cogs.admin', + 'cogs.basic'] + +bot = commands.Bot(command_prefix=get_prefix, + description=config['base']['description'], pm_help=None) + +bot.log = log +bot.config = config +bot.script_name = script_name + +if __name__ == '__main__': + for extension in initial_extensions: + try: + bot.load_extension(extension) + except Exception as e: + log.error(f'Failed to load extension {extension}.', file=sys.stderr) + log.error(traceback.print_exc()) + + +@bot.event +async def on_ready(): + aioh = {"User-Agent": f"{script_name}/1.0'"} + bot.aiosession = aiohttp.ClientSession(headers=aioh) + bot.app_info = await bot.application_info() + + log.info(f'\nLogged in as: {bot.user.name} - ' + f'{bot.user.id}\ndpy version: {discord.__version__}\n') + game_name = f"{config['base']['prefix']}help" + await bot.change_presence(game=discord.Game(name=game_name)) + + +@bot.event +async def on_command(ctx): + log_text = f"{ctx.message.author} ({ctx.message.author.id}): "\ + f"\"{ctx.message.content}\" " + if ctx.guild: # was too long for tertiary if + log_text += f"on \"{ctx.channel.name}\" ({ctx.channel.id}) "\ + f"at \"{ctx.guild.name}\" ({ctx.guild.id})" + else: + log_text += f"on DMs ({ctx.channel.id})" + log.info(log_text) + + +@bot.event +async def on_error(event_method, *args, **kwargs): + log.error(f"Error on {event_method}: {sys.exc_info()}") + + +@bot.event +async def on_command_error(ctx, error): + log.error(f"Error with \"{ctx.message.content}\" from " + f"\"{ctx.message.author}\ ({ctx.message.author.id}) " + f"of type {type(error)}: {error}") + + if isinstance(error, commands.NoPrivateMessage): + return await ctx.send("This command doesn't work on DMs.") + elif isinstance(error, commands.MissingPermissions): + roles_needed = '\n- '.join(error.missing_perms) + return await ctx.send(f"{ctx.author.mention}: You don't have the right" + " permissions to run this command. You need: " + f"```- {roles_needed}```") + elif isinstance(error, commands.BotMissingPermissions): + roles_needed = '\n-'.join(error.missing_perms) + return await ctx.send(f"{ctx.author.mention}: Bot doesn't have " + "the right permissions to run this command. " + "Please add the following roles: " + f"```- {roles_needed}```") + elif isinstance(error, commands.CommandOnCooldown): + return await ctx.send(f"{ctx.author.mention}: You're being " + "ratelimited. Try in " + f"{error.retry_after:.1f} seconds.") + elif isinstance(error, commands.CheckFailure): + return await ctx.send(f"{ctx.author.mention}: Check failed. " + "You might not have the right permissions " + "to run this command.") + + help_text = f"Usage of this command is: ```{ctx.prefix}"\ + f"{ctx.command.signature}```\nPlease see `{ctx.prefix}help "\ + f"{ctx.command.name}` for more info about this command." + if isinstance(error, commands.BadArgument): + return await ctx.send(f"{ctx.author.mention}: You gave incorrect " + f"arguments. {help_text}") + elif isinstance(error, commands.MissingRequiredArgument): + return await ctx.send(f"{ctx.author.mention}: You gave incomplete " + f"arguments. {help_text}") + + +@bot.event +async def on_guild_join(guild): + bot.log.info(f"Joined guild \"{guild.name}\" ({guild.id}).") + await guild.owner.send(f"Hello and welcome to {script_name}!\n" + "If you don't know why you're getting this message" + f", it's because someone added {script_name} to your" + " server\nDue to Discord API ToS, I am required to " + "inform you that **I log command usages and " + "errors**.\n**I don't log *anything* else**." + "\n\nIf you do not agree to be logged, stop" + f" using {script_name} and remove it from your " + "server as soon as possible.") + + +@bot.event +async def on_message(message): + if message.author.bot: + return + + ctx = await bot.get_context(message) + await bot.invoke(ctx) + +if not Path(f"{script_name}.ini").is_file(): + log.warning( + f"No config file ({script_name}.ini) found, " + f"please create one from {script_name}.ini.example file.") + exit(3) + +bot.run(config['base']['token'], bot=True, reconnect=True) diff --git a/cogs/admin.py b/cogs/admin.py new file mode 100644 index 0000000..08ba4f6 --- /dev/null +++ b/cogs/admin.py @@ -0,0 +1,170 @@ +import discord +from discord.ext import commands +import traceback +import inspect +import re + + +class AdminCog: + def __init__(self, bot): + self.bot = bot + self.last_eval_result = None + self.previous_eval_code = None + + @commands.is_owner() + @commands.command(aliases=['echo'], hidden=True) + async def say(self, ctx, *, the_text: str): + """Repeats a given text.""" + await ctx.send(the_text) + + @commands.is_owner() + @commands.command(name='exit', hidden=True) + async def _exit(self, ctx): + """Shuts down the bot, owner only.""" + await ctx.send(":wave: Exiting bot, goodbye!") + await self.bot.logout() + + @commands.is_owner() + @commands.command(hidden=True) + async def load(self, ctx, ext: str): + """Loads a cog, owner only.""" + try: + self.bot.load_extension("cogs." + ext) + except: + await ctx.send(f':x: Cog loading failed, traceback: ' + f'```\n{traceback.format_exc()}\n```') + return + self.bot.log.info(f'Loaded ext {ext}') + await ctx.send(f':white_check_mark: `{ext}` successfully loaded.') + + @commands.is_owner() + @commands.command(hidden=True) + async def fetchlog(self, ctx): + """Returns log""" + await ctx.send(file=discord.File(f"{self.bot.script_name}.log"), + content="Here's the current log file:") + + @commands.is_owner() + @commands.command(name='eval', hidden=True) + async def _eval(self, ctx, *, code: str): + """Evaluates some code (Owner only)""" + try: + code = code.strip('` ') + + env = { + 'bot': self.bot, + 'ctx': ctx, + 'message': ctx.message, + 'server': ctx.guild, + 'guild': ctx.guild, + 'channel': ctx.message.channel, + 'author': ctx.message.author, + + # modules + 'discord': discord, + 'commands': commands, + + # utilities + '_get': discord.utils.get, + '_find': discord.utils.find, + + # last result + '_': self.last_eval_result, + '_p': self.previous_eval_code, + } + env.update(globals()) + + self.bot.log.info(f"Evaling {repr(code)}:") + result = eval(code, env) + if inspect.isawaitable(result): + result = await result + + if result is not None: + self.last_eval_result = result + + self.previous_eval_code = code + + sliced_message = await self.bot.slice_message(repr(result), + prefix="```", + suffix="```") + for msg in sliced_message: + await ctx.send(msg) + except: + sliced_message = \ + await self.bot.slice_message(traceback.format_exc(), + prefix="```", + suffix="```") + for msg in sliced_message: + await ctx.send(msg) + + @commands.is_owner() + @commands.command(hidden=True) + async def pull(self, ctx, auto=False): + """Does a git pull (Owner only).""" + tmp = await ctx.send('Pulling...') + git_output = await self.bot.async_call_shell("git pull") + await tmp.edit(content=f"Pull complete. Output: ```{git_output}```") + if auto: + cogs_to_reload = re.findall(r'cogs/([a-z]*).py[ ]*\|', git_output) + for cog in cogs_to_reload: + try: + self.bot.unload_extension("cogs." + cog) + self.bot.load_extension("cogs." + cog) + self.bot.log.info(f'Reloaded ext {cog}') + await ctx.send(f':white_check_mark: `{cog}` ' + 'successfully reloaded.') + except: + await ctx.send(f':x: Cog reloading failed, traceback: ' + '```\n{traceback.format_exc()}\n```') + return + + @commands.is_owner() + @commands.command(hidden=True) + async def sh(self, ctx, *, command: str): + """Runs a command on shell.""" + command = command.strip('`') + tmp = await ctx.send(f'Running `{command}`...') + self.bot.log.info(f"Running {command}") + shell_output = await self.bot.async_call_shell(command) + shell_output = f"\"{command}\" output:\n\n{shell_output}" + self.bot.log.info(shell_output) + sliced_message = await self.bot.slice_message(shell_output, + prefix="```", + suffix="```") + if len(sliced_message) == 1: + await tmp.edit(content=sliced_message[0]) + return + await tmp.delete() + for msg in sliced_message: + await ctx.send(msg) + + @commands.is_owner() + @commands.command(hidden=True) + async def unload(self, ctx, ext: str): + """Unloads a cog, owner only.""" + self.bot.unload_extension("cogs." + ext) + self.bot.log.info(f'Unloaded ext {ext}') + await ctx.send(f':white_check_mark: `{ext}` successfully unloaded.') + + @commands.is_owner() + @commands.command(hidden=True) + async def reload(self, ctx, ext="_"): + """Reloads a cog, owner only.""" + if ext == "_": + ext = self.lastreload + else: + self.lastreload = ext + + try: + self.bot.unload_extension("cogs." + ext) + self.bot.load_extension("cogs." + ext) + except: + await ctx.send(f':x: Cog reloading failed, traceback: ' + f'```\n{traceback.format_exc()}\n```') + return + self.bot.log.info(f'Reloaded ext {ext}') + await ctx.send(f':white_check_mark: `{ext}` successfully reloaded.') + + +def setup(bot): + bot.add_cog(AdminCog(bot)) diff --git a/cogs/basic.py b/cogs/basic.py new file mode 100644 index 0000000..1cf9ef8 --- /dev/null +++ b/cogs/basic.py @@ -0,0 +1,42 @@ +import time + +from discord.ext import commands + + +class Basic: + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def invite(self, ctx): + """Sends an invite to add the bot""" + await ctx.send(f"{ctx.author.mention}: You can use " + " " + "to add RoleBot to your guild.") + + @commands.command() + async def hello(self, ctx): + """Says hello. Duh.""" + await ctx.send(f"Hello {ctx.author.mention}!") + + @commands.command(aliases=['p']) + async def ping(self, ctx): + """Shows ping values to discord. + + RTT = Round-trip time, time taken to send a message to discord + GW = Gateway Ping""" + before = time.monotonic() + tmp = await ctx.send('Calculating ping...') + after = time.monotonic() + rtt_ms = (after - before) * 1000 + gw_ms = self.bot.latency * 1000 + + message_text = f":ping_pong: rtt: `{rtt_ms:.1f}ms`, `gw: {gw_ms:.1f}ms`" + self.bot.log.info(message_text) + await tmp.edit(content=message_text) + + +def setup(bot): + bot.add_cog(Basic(bot)) diff --git a/cogs/common.py b/cogs/common.py new file mode 100644 index 0000000..74deed2 --- /dev/null +++ b/cogs/common.py @@ -0,0 +1,166 @@ +import asyncio +import traceback +import datetime +import humanize + + +class Common: + def __init__(self, bot): + self.bot = bot + + self.bot.async_call_shell = self.async_call_shell + self.bot.slice_message = self.slice_message + self.max_split_length = 3 + self.bot.hex_to_int = self.hex_to_int + self.bot.download_file = self.download_file + self.bot.aiojson = self.aiojson + self.bot.aioget = self.aioget + self.bot.aiogetbytes = self.aiogetbytes + self.bot.get_relative_timestamp = self.get_relative_timestamp + + +def get_relative_timestamp(self, time_from=None, time_to=None, + humanized=False, include_from=False, + include_to=False): + # Setting default value to utcnow() makes it show time from cog load + # which is not what we want + if not time_from: + time_from = datetime.datetime.utcnow() + if not time_to: + time_to = datetime.datetime.utcnow() + if humanized: + humanized_string = humanize.naturaltime(time_to - time_from) + if include_from and include_to: + str_with_from_and_to = f"{humanized_string} "\ + f"({str(time_from).split('.')[0]} "\ + f"- {str(time_to).split('.')[0]})" + return str_with_from_and_to + elif include_from: + str_with_from = f"{humanized_string} "\ + f"({str(time_from).split('.')[0]})" + return str_with_from + elif include_to: + str_with_to = f"{humanized_string} ({str(time_to).split('.')[0]})" + return str_with_to + return humanized_string + else: + epoch = datetime.datetime.utcfromtimestamp(0) + epoch_from = (time_from - epoch).total_seconds() + epoch_to = (time_to - epoch).total_seconds() + second_diff = epoch_to - epoch_from + result_string = str(datetime.timedelta( + seconds=second_diff)).split('.')[0] + return result_string + + async def aioget(self, url): + try: + data = await self.bot.aiosession.get(url) + if data.status == 200: + text_data = await data.text() + self.bot.log.info(f"Data from {url}: {text_data}") + return text_data + else: + self.bot.log.error(f"HTTP Error {data.status} " + "while getting {url}") + except: + self.bot.log.error(f"Error while getting {url} " + f"on aiogetbytes: {traceback.format_exc()}") + + async def aiogetbytes(self, url): + try: + data = await self.bot.aiosession.get(url) + if data.status == 200: + byte_data = await data.read() + self.bot.log.debug(f"Data from {url}: {byte_data}") + return byte_data + else: + self.bot.log.error(f"HTTP Error {data.status} " + "while getting {url}") + except: + self.bot.log.error(f"Error while getting {url} " + f"on aiogetbytes: {traceback.format_exc()}") + + async def aiojson(self, url): + try: + data = await self.bot.aiosession.get(url) + if data.status == 200: + text_data = await data.text() + self.bot.log.info(f"Data from {url}: {text_data}") + content_type = data.headers['Content-Type'] + return await data.json(content_type=content_type) + else: + self.bot.log.error(f"HTTP Error {data.status} " + "while getting {url}") + except: + self.bot.log.error(f"Error while getting {url} " + f"on aiogetbytes: {traceback.format_exc()}") + + def hex_to_int(self, color_hex: str): + """Turns a given hex color into an integer""" + return int("0x" + color_hex.strip('#'), 16) + + # This function is based on https://stackoverflow.com/a/35435419/3286892 + # by link2110 (https://stackoverflow.com/users/5890923/link2110) + # modified by Ave (https://github.com/aveao), licensed CC-BY-SA 3.0 + async def download_file(self, url, local_filename): + file_resp = await self.bot.aiosession.get(url) + file = await file_resp.read() + with open(local_filename, "wb") as f: + f.write(file) + + # 2000 is maximum limit of discord + async def slice_message(self, text, size=2000, prefix="", suffix=""): + """Slices a message into multiple messages""" + if len(text) > size * self.max_split_length: + haste_url = await self.haste(text) + return [f"Message is too long ({len(text)} > " + f"{size * self.max_split_length} " + f"({size} * {self.max_split_length}))" + f", go to haste: <{haste_url}>"] + reply_list = [] + size_wo_fix = size - len(prefix) - len(suffix) + while len(text) > size_wo_fix: + reply_list.append(f"{prefix}{text[:size_wo_fix]}{suffix}") + text = text[size_wo_fix:] + reply_list.append(f"{prefix}{text}{suffix}") + return reply_list + + async def haste(self, text, instance='https://hastebin.com/'): + response = await self.bot.aiosession.post(f"{instance}documents", + data=text) + if response.status == 200: + result_json = await response.json() + return f"{instance}{result_json['key']}" + + async def async_call_shell(self, shell_command: str, + inc_stdout=True, inc_stderr=True): + pipe = asyncio.subprocess.PIPE + proc = await asyncio.create_subprocess_shell(str(shell_command), + stdout=pipe, + stderr=pipe) + + if not (inc_stdout or inc_stderr): + return "??? you set both stdout and stderr to False????" + + proc_result = await proc.communicate() + stdout_str = proc_result[0].decode('utf-8').strip() + stderr_str = proc_result[1].decode('utf-8').strip() + + if inc_stdout and not inc_stderr: + return stdout_str + elif inc_stderr and not inc_stdout: + return stderr_str + + if stdout_str and stderr_str: + return f"stdout:\n\n{stdout_str}\n\n"\ + f"======\n\nstderr:\n\n{stderr_str}" + elif stdout_str: + return f"stdout:\n\n{stdout_str}" + elif stderr_str: + return f"stderr:\n\n{stderr_str}" + + return "No output." + + +def setup(bot): + bot.add_cog(Common(bot)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..23d0707 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +git+https://github.com/Rapptz/discord.py@rewrite + +asyncio==3.4.3 +python-dateutil==2.6.1 +humanize==0.5.1 +aiohttp==3.0.7 \ No newline at end of file