├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config.sample.yml ├── main.py ├── modules ├── core │ ├── __init__.py │ ├── admin.py │ ├── error.py │ ├── i18n │ │ ├── en_US.json │ │ ├── fr_FR.json │ │ └── hi_IN.json │ ├── info.py │ ├── locale.py │ └── prefix.py ├── db │ ├── __init__.py │ ├── db.py │ ├── db.yml │ ├── locale.py │ └── prefix.py ├── fun │ ├── __init__.py │ ├── comics.py │ ├── dev.py │ ├── i18n │ │ └── en_US.json │ ├── images.py │ ├── random.py │ ├── reactions.py │ └── text.py ├── logging │ ├── __init__.py │ ├── commands.py │ └── db.yml ├── lookups │ ├── __init__.py │ ├── dev.py │ ├── gaming.py │ ├── i18n │ │ └── en_US.json │ ├── kitsu.py │ └── language.py ├── moderation │ ├── __init__.py │ ├── actions.py │ ├── i18n │ │ └── en_US.json │ └── staff.py └── nsfw │ ├── __init__.py │ └── images.py ├── nest ├── COPYING ├── __init__.py ├── client.py ├── exceptions.py ├── helpers.py ├── i18n.json └── i18n.py ├── requirements.txt └── utils └── init_db.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Bot configuration 2 | config.yml 3 | 4 | # macOS files 5 | *.DS_Store 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # dotenv 89 | .env 90 | 91 | # virtualenv 92 | .venv 93 | venv/ 94 | ENV/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Bot configuration 2 | config.yml 3 | 4 | # macOS files 5 | *.DS_Store 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # dotenv 89 | .env 90 | 91 | # virtualenv 92 | .venv 93 | venv/ 94 | ENV/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine3.7 2 | 3 | RUN addgroup -g 1000 python \ 4 | && adduser -u 1000 -G python -s /bin/sh -D python 5 | 6 | COPY . /opt/app 7 | WORKDIR /opt/app 8 | 9 | RUN chmod g+rw /opt/app && \ 10 | chown python:python /opt/app; 11 | 12 | RUN python3 -m pip install -r requirements.txt 13 | 14 | # Expose RethinkDB client port. 15 | EXPOSE 28015 16 | 17 | CMD ["python3", "main.py"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2019 Oxylibrium 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 16 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Birb 2 | 3 | [![MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://github.com/Oxylibrium/Nest/blob/master/LICENSE) 4 | [![Python](https://img.shields.io/badge/Python-3.6-brightgreen.svg)](https://python.org/) 5 | 6 | Nest is the repo for a Discord Bot named Birb, Birb was designed to be an "everything bot", i.e does everything that you/a discord server owner could need. Currently the bot is being rewritten to be more stable inside of the discord.py rewrite and have features that are of a higher quality than what preceeded it! (Again, I know...) 7 | 8 | ## Setup 9 | 10 | Setup PostgreSQL and Python 3.6 and: 11 | 12 | ```shell 13 | pip install -r ./requirements.txt # Install requirements 14 | python3.6 utils/init_db.py # Initialize the database 15 | python3.6 bot.py # Run the bot 16 | ``` 17 | -------------------------------------------------------------------------------- /config.sample.yml: -------------------------------------------------------------------------------- 1 | tokens: 2 | discord: "" 3 | 4 | settings: 5 | owners: 6 | - 181353804266995713 7 | database: nest 8 | prefix: 9 | user: nest! 10 | mod: nest@ 11 | owner: nest# -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Load and start the Nest client. 5 | """ 6 | 7 | import os 8 | import logging 9 | 10 | import yaml 11 | 12 | from nest import client, helpers, exceptions 13 | 14 | DEFAULTS = { 15 | "prefix": "nest!", 16 | "locale": "en_US", 17 | } 18 | 19 | 20 | def main(): 21 | """ 22 | Parse config from file or environment and launch bot. 23 | """ 24 | logger = logging.getLogger() 25 | if os.path.isfile("config.yml"): 26 | logger.debug("Found config, loading...") 27 | with open("config.yml") as file: 28 | config = yaml.safe_load(file) 29 | else: 30 | logger.debug("Config not found, trying to read from env...") 31 | env = { 32 | key[8:].lower(): val 33 | for key, val in os.environ.items() 34 | if key.startswith("NESTBOT_") 35 | } 36 | config = {"tokens": {}, "settings": {}} 37 | 38 | for key, val in env.items(): 39 | if key.startswith("token_"): 40 | basedict = config["tokens"] 41 | keys = key[6:].split("_") 42 | else: 43 | basedict = config["settings"] 44 | keys = key.split("_") 45 | 46 | pointer = helpers.dictwalk( 47 | dictionary=basedict, tree=keys[:-1], fill=True 48 | ) 49 | 50 | if "," in val: 51 | val = val.split(",") 52 | 53 | pointer[keys[-1]] = val 54 | 55 | settings = {**DEFAULTS, **config["settings"], "tokens": config["tokens"]} 56 | 57 | bot = client.NestClient(**settings) 58 | 59 | if settings["database"]: 60 | bot.load_module("db") 61 | 62 | for module in os.listdir("modules"): 63 | # Ignore hidden directories 64 | if not module.startswith(".") and module != "db": 65 | try: 66 | bot.load_module(module) 67 | except exceptions.MissingFeatures as exc: 68 | if ( 69 | settings.get("database", None) 70 | and exc.features != {"database"} 71 | ): 72 | raise 73 | 74 | bot.run() 75 | 76 | 77 | if __name__ == "__main__": 78 | main() 79 | -------------------------------------------------------------------------------- /modules/core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides core bot functionality and settings management. 3 | """ 4 | 5 | from .info import InfoCommands 6 | from .admin import AdminCommands 7 | from .prefix import PrefixManager 8 | from .locale import LocaleManager 9 | from .error import ErrorHandler 10 | 11 | def setup(bot): 12 | bot.add_cog(InfoCommands()) 13 | bot.add_cog(AdminCommands()) 14 | bot.add_cog(ErrorHandler()) 15 | 16 | if bot.get_cog("PrefixStore"): 17 | bot.add_cog(PrefixManager()) 18 | if bot.get_cog("LocaleStore"): 19 | bot.add_cog(LocaleManager()) 20 | -------------------------------------------------------------------------------- /modules/core/admin.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import time 3 | import textwrap 4 | 5 | import discord 6 | 7 | from discord.ext import commands 8 | 9 | HASTE_POST_URL = "https://hastebin.com/documents/" 10 | HASTE_URL = "https://hastebin.com/{key}.py" 11 | 12 | 13 | class AdminCommands(commands.Cog): 14 | def __init__(self): 15 | self._eval = { 16 | "env": {}, 17 | "count": 0 18 | } 19 | 20 | @commands.is_owner() 21 | @commands.group() 22 | async def module(self, ctx): 23 | """Group command for modules.""" 24 | 25 | @module.command() 26 | async def reload(self, ctx, module: str): 27 | """Reload a module.""" 28 | ctx.bot.reload_module(module) 29 | await ctx.send(f"Successfully reloaded {module}!") 30 | 31 | @commands.is_owner() 32 | @module.command() 33 | async def load(self, ctx, module: str): 34 | """Load a module.""" 35 | ctx.bot.load_module(module) 36 | await ctx.send(f"Successfully loaded {module}!") 37 | 38 | @commands.is_owner() 39 | @commands.command(usage='') 40 | async def eval(self, ctx, *, code: str): 41 | """Evaluate some code.""" 42 | await ctx.trigger_typing() 43 | env = self._eval['env'] 44 | env.update({'ctx': ctx}) 45 | 46 | # Unwrap formatting and construct function 47 | code = code.replace('```py\n', '').replace('```', '').replace('`', '') 48 | fn = \ 49 | f""" 50 | async def func(env): 51 | try: 52 | {textwrap.indent(code, ' '*8)} 53 | finally: 54 | env.update(locals()) 55 | """ 56 | before = time.monotonic() 57 | 58 | try: 59 | # Evaluate the function, with given global env, then await it 60 | exec(fn, env) 61 | func = env['func'] 62 | output = await func(env) 63 | if output is not None: 64 | output = repr(output) 65 | except Exception as e: 66 | output = f'{type(e).__name__}: {e}' 67 | 68 | after = time.monotonic() 69 | self._eval['count'] += 1 70 | count = self._eval['count'] 71 | lines = code.split('\n') 72 | 73 | if len(lines) == 1: 74 | in_ = f'In [{count}]: {lines[0]}' 75 | else: 76 | prefix = f'In [{count}]: ' 77 | first_line = f'{prefix}{lines[0]}' 78 | rest = '\n'.join(lines[1:]) 79 | rest = textwrap.indent(rest, '...: '.rjust(len(prefix))) 80 | in_ = "\n".join([first_line, rest]) 81 | 82 | message = '```py\n{}'.format(in_) 83 | ms = int(round((after - before) * 1000)) 84 | 85 | if output is not None: 86 | message += '\nOut[{}]: {}'.format(count, output) 87 | 88 | if ms > 100: # noticeable delay 89 | message += '\n# {} ms\n```'.format(ms) 90 | else: 91 | message += '\n```' 92 | 93 | try: 94 | await ctx.send(message) 95 | except discord.HTTPException: 96 | async with ctx.bot.session.post(HASTE_POST_URL, data=message) as resp: 97 | if not resp.status == 200: 98 | await ctx.send(ctx._("eval_too_large")) 99 | return 100 | data = await resp.json() 101 | 102 | await ctx.send(HASTE_URL.format(key=data["key"])) 103 | 104 | @commands.is_owner() 105 | @commands.command() 106 | async def reset_eval(self, ctx): 107 | self._eval = { 108 | "env": {}, 109 | "count": 0 110 | } 111 | await ctx.send(ctx._("eval_reset")) 112 | -------------------------------------------------------------------------------- /modules/core/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provide a default error handler that logs errors to Discord. 3 | """ 4 | import functools 5 | import math 6 | import sys 7 | import traceback 8 | 9 | from discord.ext import commands 10 | 11 | import nest 12 | 13 | 14 | class ErrorHandler(commands.Cog): 15 | @commands.Cog.listener() 16 | async def on_command_error(self, ctx: commands.Context, error): 17 | """ 18 | Handle an exception raised by a command. 19 | Handle expected exceptions first, then log to console. 20 | 21 | Parameters 22 | ---------- 23 | ctx: 24 | Context in which the exception occured. 25 | exception: 26 | The CommandInvokeError that was raised. 27 | """ 28 | 29 | if not hasattr(ctx, '_'): 30 | traceback.print_exception(type(error), error, error.__traceback__) 31 | return 32 | 33 | ctx._ = functools.partial(ctx._, cog="ErrorHandler") 34 | error = getattr(error, 'original', error) 35 | etype = type(error) 36 | 37 | if isinstance(error, commands.CommandNotFound): 38 | # Ignore Command Not Found 39 | return 40 | 41 | elif isinstance(error, commands.CommandOnCooldown): 42 | await ctx.send(ctx._("cooldown").format(math.ceil(error.retry_after))) 43 | 44 | elif isinstance(error, commands.DisabledCommand): 45 | await ctx.send(ctx._("disabled")) 46 | 47 | elif isinstance(error, commands.MissingRequiredArgument): 48 | await ctx.send(ctx._("missing_arg").format(error.param.name)) 49 | 50 | elif isinstance(error, commands.NSFWChannelRequired): 51 | await ctx.send(ctx._("nsfw_required")) 52 | 53 | elif etype in nest.exceptions.EXC_I18N_MAP: 54 | await ctx.send( 55 | ctx._(nest.exceptions.EXC_I18N_MAP[etype]).format(**error.__dict__) 56 | ) 57 | 58 | elif isinstance(error, commands.CommandError): 59 | await ctx.send(str(error)) 60 | 61 | else: 62 | lines = traceback.format_exception(etype, error, error.__traceback__) 63 | error_tb = "".join(lines) 64 | print(ctx._) 65 | await ctx.send(ctx._("unknown_error").format(f"```py\n{error_tb}```")) 66 | 67 | print(error_tb, file=sys.stderr) 68 | -------------------------------------------------------------------------------- /modules/core/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "LocaleManager": { 3 | "locale": "Your locale is **{}**.", 4 | "locale_success": "Your locale is now **{}**.", 5 | "locale_invalid": "**{}** is not a valid locale." 6 | }, 7 | "PrefixManager": { 8 | "current_prefix": "The bot's current prefix is `{prefix}`.", 9 | "prefix_set_success": "Successfully set prefix to {prefix}." 10 | }, 11 | "InfoCommands": { 12 | "information": "{bot} is playing in {guilds} guilds, with {channels} channels and {users} users. Online for {uptime}, with {commands} commands.", 13 | "ping_response": "I'm alive! Response time: {}ms." 14 | }, 15 | "AdminCommands": { 16 | "eval_reset": "Eval environment reset.", 17 | "eval_too_large": "Message was too large to send on Discord, uploading to Hastebin failed." 18 | }, 19 | "ErrorHandler": { 20 | "cooldown": "This command is on a cooldown for `{}` seconds.", 21 | "disabled": "This command has been disabled", 22 | "invalid_response": "Error: Got status code `{status}` from {api}.", 23 | "missing_arg": "Missing required argument `{}`.", 24 | "nsfw_required": "Command requires NSFW channel.", 25 | "no_results": "No results found on {api} for `{q}`.", 26 | "unreachable": "Error: Could not reach {api}.", 27 | "unknown_error": "Unknown error in command:\n{}" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /modules/core/i18n/fr_FR.json: -------------------------------------------------------------------------------- 1 | { 2 | "LocaleManager": { 3 | "locale": "Votre locale est **{}**.", 4 | "locale_success": "Votre locale est **{}**.", 5 | "locale_invalid": "**{}** n'est pas une locale valide." 6 | }, 7 | "InfoCommands": { 8 | "ping_response": "Je suis vivant! Temps de réponse: {}ms." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /modules/core/i18n/hi_IN.json: -------------------------------------------------------------------------------- 1 | { 2 | "LocaleManager": { 3 | "locale": "आपका लोकेल **{}** है।", 4 | "locale_success": "आपका लोकेल अब **{}** है।", 5 | "locale_invalid": "**{}** मान्य लोकेल नहीं है।" 6 | }, 7 | "InfoCommands": { 8 | "ping_response": "मैं ज़िंदा हूं! अनुक्रिया काल: {}ms." 9 | } 10 | } -------------------------------------------------------------------------------- /modules/core/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides informational commands. 3 | 4 | :copyright: (c) 2017 Jakeoid, 2018 n303p4, 2019 Oxylibrium. 5 | :license: MIT, see LICENSE.md for details. 6 | """ 7 | 8 | import time 9 | import sys 10 | from datetime import datetime 11 | 12 | import discord 13 | from discord.ext import commands 14 | from dateutil.relativedelta import relativedelta 15 | 16 | 17 | class InfoCommands(commands.Cog): 18 | @commands.command(aliases=["info"]) 19 | async def stats(self, ctx): 20 | """Display statistics about the bot.""" 21 | uptime = relativedelta(datetime.now(), ctx.bot.created) 22 | 23 | text = ctx._("information").format( 24 | bot=ctx.bot.user.name, 25 | guilds=len(ctx.bot.guilds), 26 | channels=sum(1 for _ in ctx.bot.get_all_channels()), 27 | users=sum(1 for _ in ctx.bot.get_all_members()), 28 | uptime=ctx.bot.i18n.format_timedelta(ctx.locale, uptime), 29 | commands=len(ctx.bot.commands), 30 | ) 31 | 32 | await ctx.send(text) 33 | 34 | @commands.command() 35 | async def ping(self, ctx): 36 | """Returns the bot's response time.""" 37 | 38 | pre_typing = time.monotonic() 39 | await ctx.trigger_typing() 40 | latency = int(round((time.monotonic() - pre_typing) * 1000)) 41 | await ctx.send(ctx._("ping_response").format(latency)) 42 | -------------------------------------------------------------------------------- /modules/core/locale.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides core user-facing features, such as ping. 3 | """ 4 | from discord.ext import commands 5 | 6 | 7 | class LocaleManager(commands.Cog): 8 | """ 9 | Commands to manage bot locale. 10 | """ 11 | @commands.group() 12 | async def locale(self, ctx) -> None: 13 | """List locales and get current locale.""" 14 | 15 | if ctx.invoked_subcommand: 16 | return 17 | text = ctx._("locale").format(ctx.locale) 18 | text += "\n```yml\n" 19 | for loc, names in ctx.bot.i18n.locales(ctx.locale).items(): 20 | text += f"{loc}: " 21 | if names["user"] == names["native"]: 22 | text += names["user"] + "\n" 23 | else: 24 | text += f"{names['user']} - {names['native']}\n" 25 | text += "```" 26 | await ctx.send(text) 27 | 28 | @locale.command(name="set") 29 | async def set_locale(self, ctx, locale: str) -> None: 30 | """Set a locale.""" 31 | 32 | if not ctx.bot.i18n.is_locale(locale): 33 | await ctx.send(ctx._("locale_invalid").format(locale)) 34 | return 35 | 36 | await ctx.bot.providers["locale"].set(ctx, locale) 37 | await ctx.send(ctx._("locale_success", locale=locale).format(locale)) 38 | -------------------------------------------------------------------------------- /modules/core/prefix.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | 3 | 4 | class PrefixManager(commands.Cog): 5 | @commands.command(aliases=["whatprefix"]) 6 | async def prefix(self, ctx, *_): 7 | """Get the current prefix.""" 8 | 9 | prefix = await ctx.bot.get_cog("PrefixStore").get(ctx.message) 10 | await ctx.send(ctx._("current_prefix").format(prefix=prefix)) 11 | 12 | @commands.command() 13 | @commands.has_permissions(manage_guild=True) 14 | @commands.guild_only() 15 | async def setprefix(self, ctx, prefix: str): 16 | """Set a prefix for a guild.""" 17 | 18 | await ctx.bot.get_cog("PrefixStore").set(ctx, prefix) 19 | await ctx.send(ctx._("prefix_set_success").format(prefix=prefix)) 20 | -------------------------------------------------------------------------------- /modules/db/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for providers that store data in the database. 3 | """ 4 | 5 | from .db import PostgreSQL 6 | from .prefix import PrefixStore 7 | from .locale import LocaleStore 8 | 9 | 10 | def setup(bot): 11 | bot.add_cog(PostgreSQL(bot)) 12 | bot.add_cog(PrefixStore(bot)) 13 | bot.add_cog(LocaleStore(bot)) 14 | -------------------------------------------------------------------------------- /modules/db/db.py: -------------------------------------------------------------------------------- 1 | import asyncpg 2 | from discord.ext import commands 3 | 4 | class PostgreSQL(commands.Cog): 5 | def __init__(self, bot): 6 | self._db = bot.options["database"] 7 | self.pool = bot.loop.run_until_complete( 8 | asyncpg.create_pool(database=self._db) 9 | ) 10 | -------------------------------------------------------------------------------- /modules/db/db.yml: -------------------------------------------------------------------------------- 1 | guild: 2 | prefix: text 3 | userdata: 4 | locale: text -------------------------------------------------------------------------------- /modules/db/locale.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | 3 | 4 | class LocaleStore(commands.Cog): 5 | def __init__(self, bot): 6 | self._db = bot.get_cog("PostgreSQL") 7 | 8 | async def get(self, ctx: commands.Context): 9 | """|coro| 10 | 11 | Returns a valid locale when given a context. 12 | 13 | Parameters 14 | ---------- 15 | ctx: commands.Context 16 | Context to return a locale for. 17 | 18 | Returns 19 | ------- 20 | str 21 | Locale for the given context. 22 | """ 23 | async with self._db.pool.acquire() as conn: 24 | locale = await conn.fetchval( 25 | "SELECT locale FROM userdata WHERE id=$1", 26 | ctx.message.author.id, 27 | ) 28 | return locale 29 | 30 | async def set(self, ctx: commands.Context, locale: str): 31 | """|coro| 32 | 33 | Sets a valid locale for a given context. 34 | 35 | Parameters 36 | ---------- 37 | ctx: discord.abc.Messageable 38 | The context to set prefix for. 39 | data: Dict[str, str] 40 | Dictionary of prefixes. 41 | """ 42 | async with self._db.pool.acquire() as conn: 43 | await conn.execute( 44 | """ 45 | INSERT INTO userdata (id, locale) VALUES ($1, $2) 46 | ON CONFLICT (id) DO UPDATE SET (id, locale) = ($1, $2); 47 | """, 48 | ctx.author.id, 49 | locale, 50 | ) 51 | -------------------------------------------------------------------------------- /modules/db/prefix.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | 5 | class PrefixStore(commands.Cog): 6 | """ 7 | Provider for prefixes. 8 | """ 9 | 10 | def __init__(self, bot): 11 | self._db = bot.get_cog("PostgreSQL") 12 | 13 | async def get(self, message: discord.Message): 14 | """|coro| 15 | 16 | Returns a valid prefix when given a message. 17 | 18 | Parameters 19 | ---------- 20 | message: discord.Message 21 | The message to get a prefix for. 22 | 23 | Returns 24 | ------- 25 | str 26 | Prefix set by guild, if any. 27 | """ 28 | if message.guild: 29 | async with self._db.pool.acquire() as conn: 30 | return await conn.fetchval( 31 | "SELECT prefix FROM guild WHERE id=$1", 32 | message.guild.id, 33 | ) 34 | 35 | async def set(self, ctx: commands.Context, prefix: str): 36 | """|coro| 37 | 38 | Sets a valid prefix for . 39 | 40 | Parameters 41 | ---------- 42 | ctx: commands.Context 43 | The context to set prefix for. 44 | prefix: str 45 | Prefix to set. 46 | """ 47 | if not ctx.guild: 48 | return 49 | 50 | async with self._db.pool.acquire() as conn: 51 | await conn.execute( 52 | """ 53 | INSERT INTO guild (id, prefix) 54 | VALUES ($1, $2) 55 | ON CONFLICT (id) DO UPDATE 56 | SET (id, prefix) = ($1, $2); 57 | """, 58 | ctx.guild.id, 59 | prefix, 60 | ) 61 | -------------------------------------------------------------------------------- /modules/fun/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides various classes of fun commands. 3 | """ 4 | 5 | from .dev import DeveloperFun 6 | from .comics import Comics 7 | from .images import RandomImages 8 | from .random import RandomCommands 9 | from .reactions import ReactionImages 10 | from .text import TextManipulation 11 | 12 | 13 | def setup(bot): 14 | """Setup cogs.""" 15 | 16 | bot.add_cog(DeveloperFun()) 17 | bot.add_cog(Comics()) 18 | bot.add_cog(RandomImages()) 19 | bot.add_cog(RandomCommands()) 20 | bot.add_cog(ReactionImages(bot)) 21 | bot.add_cog(TextManipulation()) 22 | 23 | -------------------------------------------------------------------------------- /modules/fun/comics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Commands to search for comics. 3 | """ 4 | 5 | import discord 6 | from discord.ext import commands 7 | 8 | from nest import exceptions 9 | 10 | 11 | class Comics(commands.Cog): 12 | """Display comics from various online webcomics.""" 13 | 14 | @commands.command() 15 | async def xkcd(self, ctx, number: int = None): 16 | """Display a (or the latest) comic from xkcd.""" 17 | 18 | if number: 19 | url = f"https://xkcd.com/{number}/info.0.json" 20 | else: 21 | url = f"https://xkcd.com/info.0.json" 22 | 23 | async with ctx.bot.session.get(url) as resp: 24 | if resp.status not in [200, 404]: 25 | raise exceptions.WebAPIInvalidResponse( 26 | api="xkcd", status=resp.status 27 | ) 28 | 29 | if resp.status == 404: 30 | await ctx.send( 31 | ctx._("not_a_comic").format(num=number, comic="XKCD") 32 | ) 33 | return 34 | 35 | data = await resp.json() 36 | 37 | number = data["num"] 38 | image = data["img"] 39 | title = data["safe_title"] 40 | day = data["day"] 41 | month = data["month"] 42 | year = data["year"] 43 | 44 | link = f"https://xkcd.com/{number}" 45 | 46 | embed = discord.Embed(title=f"{number} - **{title}**", url=link) 47 | embed.set_footer(text=ctx._("published") + f"{year}{month}{day}") 48 | embed.set_image(url=image) 49 | 50 | await ctx.send(embed=embed) 51 | -------------------------------------------------------------------------------- /modules/fun/dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides the fakegit command. 3 | """ 4 | 5 | import discord 6 | from discord.ext import commands 7 | 8 | from nest import exceptions 9 | 10 | WHATTHECOMMIT_API_URL = "http://whatthecommit.com/index.json" 11 | 12 | 13 | class DeveloperFun(commands.Cog): 14 | """Developer humor (or lack thereof).""" 15 | 16 | @commands.command() 17 | async def fakegit(self, ctx): 18 | """Generate a fake commit message that looks like a Discord webhook.""" 19 | 20 | async with ctx.bot.session.get(WHATTHECOMMIT_API_URL) as resp: 21 | if resp.status != 200: 22 | raise exceptions.WebAPIInvalidResponse( 23 | api="whatthecommit", status=resp.status 24 | ) 25 | data = await resp.json() 26 | 27 | guild = ctx.guild.name.lower().replace(" ", "_") 28 | channel = ctx.channel.name.lower().replace(" ", "_") 29 | 30 | embed = discord.Embed( 31 | title=f"[{guild}:{channel}] 1 new commit", 32 | description=f"[`{data['hash'][:8]}`]({data['permalink']}) {data['commit_message']}", 33 | url=data["permalink"], 34 | ) 35 | 36 | embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) 37 | 38 | await ctx.send(embed=embed) 39 | -------------------------------------------------------------------------------- /modules/fun/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "Comics": { 3 | "published": "Published on: ", 4 | "not_a_comic": "Error: {num} is not a comic on {comic}." 5 | }, 6 | "RandomCommands": { 7 | "heads": "Heads!", 8 | "tails": "Tails!", 9 | "roll_result": "You rolled a `{score}/{maxscore}`.", 10 | "rating": "Birb rates `{content}` a {rating}/10.", 11 | "8ball": [ 12 | "That it is certain.", 13 | "That it is decidedly so.", 14 | "That there is without a doubt.", 15 | "Yes, definitely.", 16 | "You may rely on it.", 17 | "As I see it, yes.", 18 | "Most likely.", 19 | "The outlook's good.", 20 | "Yes.", 21 | "Signs point to yes.", 22 | "Reply hazy, try again.", 23 | "Ask again later.", 24 | "Better not tell you now.", 25 | "Cannot predict now.", 26 | "Concentrate and ask again.", 27 | "Don't count on it.", 28 | "My reply is no.", 29 | "My sources say no.", 30 | "Outlook not so good.", 31 | "Very doubtful." 32 | ] 33 | }, 34 | "RandomImages": { 35 | "provided_by {service}": "Provided by {service}." 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /modules/fun/images.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provide random images from various APIs. 3 | """ 4 | 5 | from urllib.parse import urlparse 6 | 7 | import discord 8 | from discord.ext import commands 9 | 10 | from nest import exceptions 11 | 12 | SERVICES = { 13 | "dog": ("https://random.dog/woof.json?filter=mp4", "url"), 14 | "birb": ("https://random.birb.pw/tweet.json", "file"), 15 | "cat": ("https://nekos.life/api/v2/img/meow", "url"), 16 | } 17 | 18 | ALIASES = { 19 | "birb": ("randombirb", "bird", "randombird"), 20 | "cat": ("randomcat",), 21 | "dog": ("randomdog",), 22 | } 23 | 24 | INSPIROBOT_URL = "http://inspirobot.me/api?generate=true" 25 | 26 | TEXT = {"birb": "https://random.birb.pw/img/"} 27 | 28 | 29 | def gen_command(service: str, url: str, key: str): 30 | """Helper to generate a command.""" 31 | 32 | @commands.command( 33 | name=service, 34 | aliases=ALIASES.get(service, tuple()), 35 | help=f"Search {service} for images.", 36 | ) 37 | async def image(self, ctx): 38 | """Image search command, common for all APIs.""" 39 | 40 | async with ctx.bot.session.get(url) as resp: 41 | if not resp.status == 200: 42 | raise exceptions.WebAPIInvalidResponse( 43 | api=service, status=resp.status 44 | ) 45 | 46 | data = await resp.json(content_type=None) # some APIs don't set it 47 | img = TEXT.get(service, "") + data[key] 48 | 49 | embed = discord.Embed() 50 | embed.set_image(url=img) 51 | embed.set_footer( 52 | text=ctx._("provided_by {service}").format( 53 | service=urlparse(url).netloc 54 | ) 55 | ) 56 | await ctx.send(embed=embed) 57 | 58 | return image 59 | 60 | 61 | class _RandomImages: 62 | """Hidden base class.""" 63 | 64 | @commands.command(aliases=["inspirobot", "inspire"]) 65 | async def inspiro(self, ctx): 66 | """Generate a random image from Inspirobot.""" 67 | 68 | async with ctx.bot.session.get(INSPIROBOT_URL) as resp: 69 | if resp.status != 200: 70 | raise exceptions.WebAPIInvalidResponse( 71 | api="inspirobot", status=resp.status 72 | ) 73 | url = await resp.text() 74 | 75 | embed = discord.Embed() 76 | embed.set_image(url=url) 77 | embed.set_footer( 78 | text=ctx._("provided_by {service}").format(service="inspirobot.me") 79 | ) 80 | 81 | await ctx.send(embed=embed) 82 | 83 | 84 | for sv, params in SERVICES.items(): 85 | setattr(_RandomImages, sv, gen_command(sv, *params)) 86 | 87 | 88 | class RandomImages(_RandomImages, commands.Cog): 89 | """Provide random images from various online services.""" 90 | -------------------------------------------------------------------------------- /modules/fun/random.py: -------------------------------------------------------------------------------- 1 | """ 2 | RNG commands. 3 | """ 4 | 5 | import random 6 | from discord.ext import commands 7 | 8 | AAA = ("a", "A") 9 | 10 | 11 | class RandomCommands(commands.Cog): 12 | """Commands that produce a random text output.""" 13 | 14 | @commands.command(aliases=["coinflip"]) 15 | async def coin(self, ctx): 16 | """Flip a coin.""" 17 | 18 | choice = random.choice(["heads", "tails"]) 19 | await ctx.send(ctx._(choice)) 20 | 21 | @commands.command(aliases=["diceroll"]) 22 | async def dice(self, ctx, diceroll: str = "1d6"): 23 | """Roll a die wiith input in the AdX notation.""" 24 | 25 | times, num = diceroll.split("d") 26 | times = int(times) if times else 1 27 | num = int(num) if num else 6 28 | maxscore = times * num 29 | score = random.randint(times, maxscore) 30 | await ctx.send( 31 | ctx._("roll_result").format(score=score, maxscore=maxscore) 32 | ) 33 | 34 | @commands.command() 35 | async def rate(self, ctx, *, content: str): 36 | """Gives something a rating.""" 37 | 38 | num = random.randint(0, 10) 39 | await ctx.send(ctx._("rating").format(content=content, rating=num)) 40 | 41 | @commands.command(name="8ball") 42 | async def eightball(self, ctx): 43 | """Asks the magic 8ball a question.""" 44 | await ctx.send(random.choice(ctx._("8ball"))) 45 | 46 | @commands.command(aliases=("a", "aa")) 47 | async def aaa(self, ctx): 48 | """AAAAAAA!""" 49 | await ctx.send(random.choice(AAA) * random.randint(1, 200)) 50 | -------------------------------------------------------------------------------- /modules/fun/reactions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provide reaction images. 3 | """ 4 | 5 | import discord 6 | from discord.ext import commands 7 | 8 | from nest import exceptions 9 | 10 | WEEBSH_API = "https://api.weeb.sh/images/random" 11 | 12 | CATEGORIES = ( 13 | "cry", 14 | "dance", 15 | "happy", 16 | "hug", 17 | "kiss", 18 | "lewd", 19 | "neko", 20 | "owo", 21 | "pat", 22 | "slap", 23 | "smug", 24 | "triggered", 25 | ) 26 | 27 | 28 | def gen_command(category: str): 29 | """Generates a command.""" 30 | 31 | @commands.command( 32 | name=category, help=f"Show a random anime {category} image.", 33 | ) 34 | async def image(self, ctx): 35 | """Image search command, common for all categories.""" 36 | async with ctx.bot.session.get( 37 | WEEBSH_API, params={"type": category}, headers=self._headers 38 | ) as resp: 39 | if not resp.status == 200: 40 | raise exceptions.WebAPIInvalidResponse( 41 | api="weeb.sh", status=resp.status 42 | ) 43 | 44 | data = await resp.json() # some APIs don't set it 45 | img = data["url"] 46 | 47 | embed = discord.Embed() 48 | embed.set_image(url=img) 49 | embed.set_footer( 50 | text=ctx._("provided_by {service}").format(service="weebsh") 51 | ) 52 | await ctx.send(embed=embed) 53 | 54 | return image 55 | 56 | 57 | class _ReactionImages: 58 | """Internal class that holds commands.""" 59 | 60 | def __init__(self, bot): 61 | self._headers = {"Authorization": bot.tokens["weebsh"]} 62 | 63 | 64 | for c in CATEGORIES: 65 | setattr(_ReactionImages, c, gen_command(c)) 66 | 67 | 68 | class ReactionImages(_ReactionImages, commands.Cog): 69 | """Reaction images provided by weeb.sh.""" 70 | -------------------------------------------------------------------------------- /modules/fun/text.py: -------------------------------------------------------------------------------- 1 | """ 2 | Commands for fun text manipulation. 3 | """ 4 | import random 5 | import string 6 | 7 | import discord 8 | from discord.ext import commands 9 | 10 | XD = """``` 11 | {word} {word} {word} {word} 12 | {word} {word} {word} {word} 13 | {word} {word} {word} {word} 14 | {word} {word} {word} {word} 15 | {word} {word} {word} {word} 16 | {word} {word} {word} {word} 17 | ```""" 18 | 19 | class TextManipulation(commands.Cog): 20 | """Manipulate text in funny ways.""" 21 | 22 | def _create_embed(self, author: discord.User): 23 | embed = discord.Embed() 24 | embed.set_footer(text=author.name, icon_url=author.avatar_url) 25 | return embed 26 | 27 | @commands.command() 28 | async def bigtext(self, ctx, *, text: commands.clean_content): 29 | """Convert text into huge emoji.""" 30 | 31 | table = str.maketrans({x: f":regional_indicator_{x.lower()}:" for x in string.ascii_letters}) 32 | res = text.translate(table) 33 | await ctx.send(res, embed=self._create_embed(ctx.author)) 34 | 35 | @commands.command() 36 | async def xd(self, ctx, *, word: commands.clean_content): 37 | """Make an XD out of the word given.""" 38 | 39 | await ctx.send(XD.format(word=word), embed=self._create_embed(ctx.author)) 40 | 41 | @commands.command() 42 | async def clapify(self, ctx, *, text: commands.clean_content): 43 | """Add clap emojis after each word.""" 44 | 45 | res = " 👏 ".join(text.split()) 46 | await ctx.send(res, embed=self._create_embed(ctx.author)) 47 | 48 | @commands.command() 49 | async def tobleflep(self, ctx): 50 | """Tableflip, but random.""" 51 | 52 | tableflip = list("(╯°□°)╯︵ ┻━┻") 53 | random.shuffle(tableflip) 54 | await ctx.send("".join(tableflip)) 55 | 56 | -------------------------------------------------------------------------------- /modules/logging/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides various statistics on bot usage. 3 | """ 4 | 5 | from .commands import CommandLogger 6 | 7 | def setup(bot): 8 | if bot.get_cog("PostgreSQL"): 9 | bot.add_cog(CommandLogger(bot)) 10 | -------------------------------------------------------------------------------- /modules/logging/commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | Log usage of commands. 3 | """ 4 | 5 | from discord.ext import commands 6 | 7 | 8 | class CommandLogger(commands.Cog): 9 | def __init__(self, bot): 10 | self._db = bot.get_cog("PostgreSQL") 11 | 12 | @commands.Cog.listener() 13 | async def on_command(self, ctx: commands.Context): 14 | """|coro| 15 | 16 | Logs a command. 17 | 18 | Parameters 19 | ---------- 20 | ctx: commands.Context 21 | The context to log. 22 | """ 23 | async with self._db.pool.acquire() as conn: 24 | await conn.execute( 25 | """ 26 | INSERT INTO command (id, command, message, author, guild) VALUES ($1, $2, $3, $4, $5) 27 | """, 28 | ctx.message.id, 29 | ctx.command.name, 30 | ctx.message.content, 31 | ctx.author.id, 32 | ctx.guild.id if ctx.guild else None 33 | ) 34 | -------------------------------------------------------------------------------- /modules/logging/db.yml: -------------------------------------------------------------------------------- 1 | command: 2 | command: text 3 | message: text 4 | author: bigint 5 | guild: bigint 6 | -------------------------------------------------------------------------------- /modules/lookups/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for utility cogs 3 | """ 4 | 5 | from .language import LanguageCommands 6 | from .kitsu import Kitsu 7 | from .dev import PackageLookups 8 | from .gaming import GamingLookups 9 | 10 | 11 | def setup(bot): 12 | bot.add_cog(LanguageCommands()) 13 | bot.add_cog(Kitsu()) 14 | bot.add_cog(PackageLookups()) 15 | bot.add_cog(GamingLookups()) 16 | 17 | -------------------------------------------------------------------------------- /modules/lookups/dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Commands to look up developer resources. 3 | """ 4 | 5 | import discord 6 | from discord.ext import commands 7 | 8 | from nest import exceptions, helpers 9 | 10 | URL_PYPI_API = "https://pypi.python.org/pypi/{package}/json" 11 | URL_PYPI_PACKAGE = "https://pypi.python.org/pypi/{package}" 12 | FIELDS_PYPI = {"license", "docs_url", "home_page", "requires_python", "author"} 13 | 14 | URL_NPM_API = "https://registry.npmjs.org/{package}/{version}" 15 | FIELDS_NPM = {"license", "homepage"} 16 | 17 | 18 | class PackageLookups(commands.Cog): 19 | @commands.command() 20 | async def pypi(self, ctx, package: str): 21 | """ 22 | Look up a package on the Python Package Index. 23 | """ 24 | data_url = URL_PYPI_API.format(package=package) 25 | 26 | async with ctx.bot.session.get(data_url) as resp: 27 | if not resp.status in [200, 404]: 28 | raise exceptions.WebAPIInvalidResponse( 29 | api="PyPI", status=resp.status 30 | ) 31 | 32 | if resp.status == 404: 33 | await ctx.send("{package} isn't a package on PyPI!") 34 | return 35 | 36 | data = await resp.json() 37 | 38 | info = data["info"] 39 | 40 | embed = discord.Embed( 41 | title=f"{info['name']} `({info['version']})`", 42 | description=helpers.smart_truncate(info["description"]), 43 | url=URL_PYPI_PACKAGE.format(package=package), 44 | ) 45 | 46 | for field in FIELDS_PYPI & info.keys(): 47 | if info[field]: 48 | embed.add_field(name=ctx._(field), value=info[field]) 49 | 50 | embed.set_thumbnail(url="http://i.imgur.com/1Pp5s56.png") 51 | await ctx.send(embed=embed) 52 | 53 | @commands.command() 54 | async def npm(self, ctx, package: str, version: str = "latest"): 55 | """ 56 | Look up a package on the official Node.js package manager registry. 57 | """ 58 | data_url = URL_NPM_API.format(package=package, version=version) 59 | 60 | async with ctx.bot.session.get(data_url) as resp: 61 | if not resp.status in [200, 404]: 62 | raise exceptions.WebAPIInvalidResponse( 63 | api="NPMjs", status=resp.status 64 | ) 65 | 66 | if resp.status == 404: 67 | await ctx.send("{package} isn't a package on PyPI!") 68 | return 69 | 70 | info = await resp.json() 71 | 72 | embed = discord.Embed( 73 | title=f"{info['name']} `({info['version']})`", 74 | description=helpers.smart_truncate(info["description"]), 75 | url=URL_PYPI_PACKAGE.format(package=package), 76 | ) 77 | 78 | if "author" in info: 79 | embed.add_field(name=ctx._("author"), value=info["author"]["name"]) 80 | 81 | for field in FIELDS_NPM & info.keys(): 82 | embed.add_field(name=ctx._(field), value=info[field]) 83 | 84 | embed.set_thumbnail( 85 | url="https://raw.githubusercontent.com/npm/logos/master/npm%20square/n-64.png" 86 | ) 87 | await ctx.send(embed=embed) 88 | -------------------------------------------------------------------------------- /modules/lookups/gaming.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gaming-related lookup commands. 3 | """ 4 | 5 | from io import BytesIO 6 | 7 | import discord 8 | from discord.ext import commands 9 | from pycountry import countries 10 | 11 | from nest import exceptions 12 | 13 | URL_MCUUID_API = "https://api.mojang.com/users/profiles/minecraft/{user}" 14 | URL_MCSKIN_API = "https://visage.surgeplay.com/{image}/{uuid}.png" 15 | URL_OSU_API = "https://osu.ppy.sh/api/get_user" 16 | 17 | 18 | class GamingLookups(commands.Cog): 19 | @commands.command() 20 | async def mcskin(self, ctx, user: str, image: str = "full"): 21 | url = URL_MCUUID_API.format(user=user) 22 | 23 | # Mojang API returns empty response instead of 404 :( 24 | async with ctx.bot.session.get(url) as resp: 25 | if resp.status == 204: 26 | uuid = None 27 | else: 28 | uuid = (await resp.json())["id"] 29 | 30 | if not uuid: 31 | await ctx.send(ctx._("mc_usernotfound").format(user)) 32 | return 33 | 34 | url = URL_MCSKIN_API.format(image=image, uuid=uuid) 35 | 36 | async with ctx.bot.session.get(url) as resp: 37 | if resp.status == 200: 38 | image = BytesIO(await resp.read()) 39 | else: 40 | raise exceptions.WebAPIInvalidResponse( 41 | api="surgeplay", status=resp.status 42 | ) 43 | 44 | await ctx.send(file=discord.File(fp=image, filename=f"{uuid}.png")) 45 | 46 | @commands.command() 47 | async def osu(self, ctx, user: str): 48 | query = {"k": ctx.bot.tokens["osu"], "u": user} 49 | 50 | async with ctx.bot.session.get(URL_OSU_API, params=query) as response: 51 | if response.status == 200: 52 | data = await response.json() 53 | else: 54 | return 55 | 56 | user = data[0] 57 | 58 | keys = { 59 | ctx._("Play Count"): "playcount", 60 | ctx._("Country"): "country", 61 | ctx._("Level"): "level", 62 | ctx._("Ranked Score"): "ranked_score", 63 | ctx._("Total Score"): "total_score", 64 | ctx._("Accuracy"): "accuracy", 65 | ctx._("{level} Ranking".format(level="SS")): "count_rank_ss", 66 | ctx._("{level} Ranking".format(level="S")): "count_rank_s", 67 | ctx._("{level} Ranking".format(level="A")): "count_rank_a", 68 | "300s": "count300", 69 | "100s": "count100", 70 | "50s": "count50", 71 | } 72 | 73 | userid = user["user_id"] 74 | username = user["username"] 75 | 76 | embed = discord.Embed( 77 | title=username, 78 | description=userid, 79 | url=f"https://osu.ppy.sh/u/{userid}", 80 | ) 81 | 82 | # Convert some data in the dict before iterating through it 83 | user["accuracy"] = round(float(user["accuracy"]), 2) 84 | user["country"] = countries.get(alpha_2="PH").name 85 | 86 | for field, value in keys.items(): 87 | embed.add_field(name=field, value=user[value], inline=True) 88 | 89 | embed.set_thumbnail(url=f"http://a.ppy.sh/{userid}") 90 | await ctx.send(embed=embed) 91 | -------------------------------------------------------------------------------- /modules/lookups/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "LanguageCommands": { 3 | "english": "English", 4 | "definition": "Definition", 5 | "example": "Example", 6 | "upvotes": "Upvotes", 7 | "downvotes": "Downvotes" 8 | }, 9 | "Kitsu": { 10 | "rating": "Rating", 11 | "status": "Status", 12 | "startdate": "Started", 13 | "enddate": "Finished" 14 | }, 15 | "PackageLookups": { 16 | "license": "License", 17 | "docs_url": "Documentation", 18 | "home_page": "Website", 19 | "homepage": "Website", 20 | "author": "Author", 21 | "requires_python": "Requires Python" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /modules/lookups/kitsu.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | from nest import exceptions 5 | 6 | FIELDS = { 7 | "rating": "averageRating", 8 | "status": "status", 9 | "started": "startDate", 10 | } 11 | 12 | 13 | class Kitsu(commands.Cog): 14 | @commands.command(aliases=["manga", "anime"]) 15 | async def kitsu(self, ctx, *, name: str): 16 | """ 17 | Get manga or anime from kitsu.io 18 | """ 19 | req = "anime" if ctx.invoked_with == "kitsu" else ctx.invoked_with 20 | url = f"https://kitsu.io/api/edge/{req}" 21 | params = {"filter[text]": name, "page[limit]": 1} 22 | async with ctx.bot.session.get(url, params=params) as resp: 23 | if not resp.status == 200: 24 | raise exceptions.WebAPIInvalidResponse( 25 | api="kitsu", status=resp.status 26 | ) 27 | 28 | data = await resp.json(content_type="application/vnd.api+json") 29 | 30 | if not data["meta"]["count"]: 31 | raise exceptions.WebAPINoResults(api="kitsu", q=name) 32 | 33 | attributes = data["data"][0]["attributes"] 34 | 35 | embed = discord.Embed( 36 | title=attributes["canonicalTitle"], 37 | url=f"https://kitsu.io/{req}/{attributes['slug']}", 38 | description=attributes["synopsis"], 39 | ) 40 | 41 | for field, item in FIELDS.items(): 42 | embed.add_field(name=ctx._(field), value=attributes[item]) 43 | 44 | if attributes["endDate"]: 45 | embed.add_field(name=ctx._("enddate"), value=attributes["endDate"]) 46 | 47 | embed.set_thumbnail(url=attributes["posterImage"]["original"]) 48 | await ctx.send(embed=embed) 49 | -------------------------------------------------------------------------------- /modules/lookups/language.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides language commands. 3 | 4 | :copyright: (c) 2017 Jakeoid, 2018 n303p4. 5 | :license: MIT, see LICENSE.md for details. 6 | """ 7 | 8 | import json 9 | 10 | import discord 11 | from discord.ext import commands 12 | 13 | from nest import exceptions 14 | 15 | 16 | API_JISHO_ORG = "http://jisho.org/api/v1/search/words?keyword={0}" 17 | API_URBAN_DICTIONARY = "http://api.urbandictionary.com/v0/define" 18 | 19 | 20 | class LanguageCommands(commands.Cog): 21 | @commands.command() 22 | async def jisho(self, ctx, *, word: str): 23 | """Translate a word into Japanese.""" 24 | url = API_JISHO_ORG.format(word) 25 | headers = {"Content-type": "application/json"} 26 | 27 | async with ctx.bot.session.get(url, headers=headers) as response: 28 | if response.status == 200: 29 | data = await response.text() 30 | response = json.loads(data) 31 | else: 32 | raise exceptions.WebAPIInvalidResponse( 33 | api="jisho", status=response.status 34 | ) 35 | 36 | data = response["data"][0] 37 | 38 | japanese = data["japanese"][0] 39 | senses = data["senses"][0]["english_definitions"] 40 | 41 | title = japanese.get("word", "???") 42 | reading = japanese.get("reading", "???") 43 | 44 | embed = discord.Embed(title=title, description=reading) 45 | 46 | embed.add_field(name=ctx._("english"), value=", ".join(senses)) 47 | 48 | try: 49 | speech = data["senses"][0]["parts_of_speech"][0] 50 | embed.set_footer(text=speech or "???") 51 | except (KeyError, IndexError): 52 | speech = None 53 | 54 | await ctx.send(embed=embed) 55 | 56 | @commands.command(aliases=["urbandictionary"]) 57 | async def urban(self, ctx, *, word: str): 58 | """Grab a word from urban dictionary.""" 59 | 60 | params = {"term": word} 61 | async with ctx.bot.session.get( 62 | API_URBAN_DICTIONARY, params=params 63 | ) as response: 64 | if response.status == 200: 65 | data = await response.text() 66 | response = json.loads(data) 67 | else: 68 | raise exceptions.WebAPIInvalidResponse( 69 | api="urbandictionary.com", status=response.status 70 | ) 71 | 72 | if not response["list"]: 73 | raise exceptions.WebAPINoResults(api="urbandictionary", q=word) 74 | 75 | content = response["list"][0] 76 | thumbs_up = content["thumbs_up"] 77 | thumbs_down = content["thumbs_down"] 78 | 79 | embed = discord.Embed( 80 | title=content["word"], 81 | description=content["author"], 82 | url=content["permalink"], 83 | ) 84 | 85 | if "definition" in content: 86 | embed.add_field( 87 | name=ctx._("definition"), 88 | value=content["definition"], 89 | inline=False, 90 | ) 91 | 92 | if "example" in content: 93 | embed.add_field( 94 | name=ctx._("example"), 95 | value=content["example"], 96 | inline=False, 97 | ) 98 | 99 | embed.add_field( 100 | name=ctx._("upvotes"), 101 | value=f":thumbsup::skin-tone-2: {thumbs_up}", 102 | inline=True, 103 | ) 104 | 105 | embed.add_field( 106 | name=ctx._("downvotes"), 107 | value=f":thumbsdown::skin-tone-2: {thumbs_down}", 108 | inline=True, 109 | ) 110 | 111 | await ctx.send(embed=embed) 112 | -------------------------------------------------------------------------------- /modules/moderation/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic moderation utilities for Birb. 3 | """ 4 | 5 | from .staff import CheckMods 6 | from .actions import ModActions 7 | 8 | def setup(bot): 9 | bot.add_cog(CheckMods()) 10 | bot.add_cog(ModActions()) 11 | 12 | -------------------------------------------------------------------------------- /modules/moderation/actions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides basic moderation actions. 3 | """ 4 | 5 | import discord 6 | from discord.ext import commands 7 | 8 | class ModActions(commands.Cog): 9 | @commands.command() 10 | @commands.bot_has_permissions(ban_members=True) 11 | @commands.has_permissions(ban_members=True) 12 | @commands.guild_only() 13 | async def ban(self, ctx, user: discord.Member, reason: str = None): 14 | """ 15 | Bans a member for a given reason. 16 | """ 17 | 18 | if ctx.guild.roles.index(ctx.author.top_role) < \ 19 | ctx.guild.roles.index(user.top_role) or \ 20 | ctx.author is ctx.guild.owner: 21 | await ctx.guild.ban(user, reason=reason) 22 | await ctx.send(ctx._("ban_success").format(str(user))) 23 | else: 24 | await ctx.send(ctx._("ban_failure").format(str(user))) 25 | 26 | @commands.command() 27 | @commands.bot_has_permissions(ban_members=True) 28 | @commands.has_permissions(ban_members=True) 29 | @commands.guild_only() 30 | async def softban(self, ctx, user: discord.Member, reason: str = None): 31 | """ 32 | Softbans a member for a given reason to clear messages. 33 | """ 34 | 35 | if ctx.guild.roles.index(ctx.author.top_role) < \ 36 | ctx.guild.roles.index(user.top_role) or \ 37 | ctx.author is ctx.guild.owner: 38 | await ctx.guild.ban(user, reason=reason) 39 | await ctx.guild.unban(user) 40 | await ctx.send(ctx._("softban_success").format(str(user))) 41 | else: 42 | await ctx.send(ctx._("softban_failure").format(str(user))) 43 | 44 | @commands.command() 45 | @commands.bot_has_permissions(kick_members=True) 46 | @commands.has_permissions(kick_members=True) 47 | @commands.guild_only() 48 | async def kick(self, ctx, user: discord.Member, reason: str = None): 49 | """ 50 | Kicks a member for a given reason. 51 | """ 52 | 53 | if ctx.guild.roles.index(ctx.author.top_role) < \ 54 | ctx.guild.roles.index(user.top_role) or \ 55 | ctx.author is ctx.guild.owner: 56 | await ctx.guild.kick(user, reason=reason) 57 | await ctx.send(ctx._("kick_success").format(str(user))) 58 | else: 59 | await ctx.send(ctx._("kick_failure").format(str(user))) 60 | 61 | 62 | -------------------------------------------------------------------------------- /modules/moderation/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "ModActions": { 3 | "ban_success": "Banned {} successfully.", 4 | "ban_failure": "You don't have enough permissions to ban {}!", 5 | "softban_success": "Softbanned {} successfully.", 6 | "softban_failure": "You don't have enough permissions to softban {}!", 7 | "kick_success": "Kicked {} successfully.", 8 | "kick_failure": "You don't have enough permissions to kick {}!" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /modules/moderation/staff.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic moderation utilities for Birb. 3 | """ 4 | 5 | from discord.ext import commands 6 | 7 | MOD_EMOTICONS = { 8 | "online": "<:online2:464520569975603200>", 9 | "offline": "<:offline2:464520569929334784>", 10 | "idle": "<:away2:464520569862357002>", 11 | "dnd": "<:dnd2:464520569560498197>" 12 | } 13 | 14 | class CheckMods(commands.Cog): 15 | @commands.command(aliases=["staff"]) 16 | async def mods(self, ctx): 17 | mods = [m for m in ctx.guild.members 18 | if m.permissions_in(ctx.channel).ban_members and not m.bot] 19 | 20 | mods_by_status = {'online': [], 'offline': [], 'idle': [], 'dnd': []} 21 | 22 | for mod in mods: 23 | mods_by_status[str(mod.status)].append(mod) 24 | 25 | msg = "" 26 | 27 | for status in ['online', 'idle', 'dnd', 'offline']: 28 | if mods_by_status[status]: 29 | msg += MOD_EMOTICONS[status] + " " + \ 30 | ", ".join(str(mod) for mod in mods_by_status[status]) + "\n" 31 | 32 | await ctx.send(msg) 33 | 34 | -------------------------------------------------------------------------------- /modules/nsfw/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | NSFW image search commands to grab images from various boorus. 3 | """ 4 | 5 | from .images import NSFW 6 | 7 | def setup(bot): 8 | bot.add_cog(NSFW(bot)) 9 | -------------------------------------------------------------------------------- /modules/nsfw/images.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | import nsfw_dl 4 | import nsfw_dl.errors 5 | 6 | from nest import exceptions 7 | 8 | SERVICES = ["rule34", "e621", "furrybooru", "gelbooru", "konachan", "tbib", 9 | "xbooru", "yandere"] 10 | 11 | def wrap_fn(newname: str, doc: str): 12 | """ 13 | Decorator which renames a function. 14 | 15 | Parameters 16 | ---------- 17 | newname: str 18 | Name of the new function. 19 | doc: str 20 | Docstring of the new function. 21 | """ 22 | def decorator(f): 23 | f.__name__ = newname 24 | f.__doc__ = doc 25 | return f 26 | return decorator 27 | 28 | def gen_command(service: str): 29 | """ 30 | Generates a command helper. 31 | """ 32 | @commands.is_nsfw() 33 | @commands.command() 34 | @wrap_fn(service, f"Search {service} for images.") 35 | async def nsfwsearch(self, ctx, *, query: str = ""): 36 | """ 37 | NSFW search utility command, common for every library. 38 | """ 39 | service_arg = service.capitalize() 40 | if query: 41 | service_arg += 'Search' 42 | else: 43 | service_arg += 'Random' 44 | try: 45 | content = await self.client.download(service_arg, args=query) 46 | except nsfw_dl.errors.NoResultsFound: 47 | raise exceptions.WebAPINoResults(api=service, q=query) 48 | embed = discord.Embed() 49 | embed.set_image(url=content) 50 | embed.set_footer(text=f"Requested by {ctx.author.display_name}") 51 | await ctx.send(embed=embed) 52 | 53 | return nsfwsearch 54 | 55 | class _NSFWCommands: 56 | def __init__(self, bot): 57 | self.client = nsfw_dl.NSFWDL(session=bot.session, loop=bot.loop) 58 | self.client.async_ = True 59 | 60 | for sv in SERVICES: 61 | setattr(_NSFWCommands, sv, gen_command(sv)) 62 | 63 | class NSFW(_NSFWCommands, commands.Cog): 64 | pass 65 | -------------------------------------------------------------------------------- /nest/COPYING: -------------------------------------------------------------------------------- 1 | Portions of the client are from discord.py, licensed under the MIT License: 2 | 3 | The MIT License (MIT) 4 | 5 | Copyright (c) 2015-2017 Rapptz 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a 8 | copy of this software and associated documentation files (the "Software"), 9 | to deal in the Software without restriction, including without limitation 10 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 11 | and/or sell copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 18 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /nest/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides core functionality for Nest. 3 | """ 4 | 5 | from nest import client, i18n, helpers, exceptions 6 | -------------------------------------------------------------------------------- /nest/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides the Nest client class. 3 | """ 4 | 5 | import asyncio 6 | import functools 7 | import logging 8 | import traceback 9 | from datetime import datetime 10 | from typing import Dict 11 | 12 | import aiohttp 13 | import discord 14 | from discord.ext import commands 15 | from discord.ext.commands.view import StringView 16 | 17 | from nest import i18n, exceptions 18 | 19 | 20 | class PrefixGetter: 21 | def __init__(self, default: str): 22 | self._default = default 23 | 24 | async def __call__(self, bot, message: discord.Message): 25 | """|coro| 26 | 27 | Retrieves the prefix the bot is listening to 28 | with the message as a context. 29 | 30 | Parameters 31 | ----------- 32 | ctx: :class:`commands.Context` 33 | The context to get the prefix of. 34 | 35 | Returns 36 | -------- 37 | Dict[str, str] 38 | A prefix the bot is listening for in each category. 39 | """ 40 | cog = bot.get_cog("PrefixStore") 41 | if cog: 42 | try: 43 | prefix = cog.get(message) 44 | if asyncio.iscoroutine(prefix): 45 | prefix = await prefix 46 | except Exception as e: 47 | traceback.print_exc() 48 | prefix = None 49 | 50 | if not isinstance(prefix, str): 51 | prefix = self._default 52 | else: 53 | prefix = self._default 54 | 55 | return commands.when_mentioned_or(prefix)(bot, message) 56 | 57 | async def get_locale(bot, ctx: commands.Context): 58 | """|coro| 59 | 60 | Retrieves the locale of a user to respond with. 61 | 62 | Parameters 63 | ----------- 64 | ctx: :class:`commands.Context` 65 | The context to get the prefix of. 66 | 67 | Returns 68 | ------- 69 | str: 70 | Locale to use in responses. 71 | """ 72 | cog = bot.get_cog("LocaleStore") 73 | 74 | if cog: 75 | try: 76 | ret = cog.get(ctx) 77 | if asyncio.iscoroutine(ret): 78 | ret = await ret 79 | return ret 80 | except Exception as e: 81 | traceback.print_exc() 82 | 83 | 84 | class NestClient(commands.AutoShardedBot): 85 | """Main client for Nest. 86 | 87 | Attributes 88 | ---------- 89 | session: aiohttp.ClientSession 90 | aiohttp session to use for making requests. 91 | tokens: Dict[str, str] 92 | Tokens passed to the bot. 93 | created: datetime.datetime() 94 | Time when bot instance was initialised. 95 | i18n: nest.i18n.I18n 96 | Internationalization functions for the bot. 97 | """ 98 | 99 | def __init__(self, **options): 100 | super().__init__( 101 | PrefixGetter(options.pop("prefix")), 102 | **options, 103 | ) 104 | self._logger = logging.getLogger("NestClient") 105 | self.tokens: Dict[str, str] = options.pop("tokens", {}) 106 | self.owner_ids = set(options.pop("owners", [])) 107 | self.created = datetime.now() 108 | self.session = aiohttp.ClientSession(loop=self.loop) 109 | 110 | self.i18n = i18n.I18n(locale=options.pop("locale", "en_US")) 111 | self.options = options 112 | 113 | async def on_ready(self): 114 | """Log successful login event""" 115 | self._logger.info( 116 | f"Logged in as {self.user.name}. ID: {str(self.user.id)}" 117 | ) 118 | 119 | # Set the game. 120 | await self.change_presence(activity=discord.Activity(name="with code")) 121 | 122 | async def get_context( 123 | self, message: discord.Message, *, cls=commands.Context 124 | ) -> commands.Context: 125 | """|coro| 126 | 127 | Returns the invocation context from the message. 128 | 129 | The returned context is not guaranteed to be a valid invocation 130 | context, :attr:`.Context.valid` must be checked to make sure it is. 131 | If the context is not valid then it is not a valid candidate to be 132 | invoked under :meth:`~.Bot.invoke`. 133 | 134 | Parameters 135 | ----------- 136 | message: :class:`discord.Message` 137 | The message to get the invocation context from. 138 | cls 139 | The factory class that will be used to create the context. 140 | By default, this is :class:`.Context`. Should a custom 141 | class be provided, it must be similar enough to :class:`.Context`'s 142 | interface. 143 | 144 | Returns 145 | -------- 146 | :class:`.Context` 147 | The invocation context. The type of this can change via the 148 | ``cls`` parameter. 149 | """ 150 | ctx = await super().get_context(message, cls=cls) 151 | 152 | if ctx.command: 153 | user_locale = await get_locale(self, ctx) 154 | ctx.locale = user_locale if user_locale else self.i18n.locale 155 | ctx._ = functools.partial( 156 | self.i18n.getstr, 157 | locale=ctx.locale, 158 | cog=ctx.command.cog_name, 159 | ) 160 | 161 | return ctx 162 | 163 | def load_module(self, name: str): 164 | """Loads a module from the modules directory. 165 | 166 | A module is a d.py extension that contains commands, cogs, or 167 | listeners and i18n data. 168 | 169 | Parameters 170 | ---------- 171 | name: str 172 | The extension name to load. It must be a valid name of a folder 173 | within the modules directory. 174 | 175 | Raises 176 | ------ 177 | ClientException 178 | The extension does not have a setup function. 179 | ImportError 180 | The extension could not be imported. 181 | """ 182 | if name in self.extensions: 183 | return 184 | 185 | self.load_extension(f"modules.{name}") 186 | self.i18n.load_module(name) 187 | 188 | def reload_module(self, name: str): 189 | """Loads a module from the modules directory. 190 | 191 | A module is a d.py extension that contains commands, cogs, or 192 | listeners and i18n data. 193 | 194 | Parameters 195 | ---------- 196 | name: str 197 | The extension name to load. It must be a valid name of a folder 198 | within the modules directory. 199 | 200 | Raises 201 | ------ 202 | ClientException 203 | The extension does not have a setup function. 204 | ImportError 205 | The extension could not be imported. 206 | """ 207 | 208 | self.reload_extension(f"modules.{name}") 209 | self.i18n.load_module(name) 210 | 211 | def run(self, bot: bool = True): 212 | """ 213 | Start running the bot. 214 | 215 | Parameters 216 | ---------- 217 | bot: bool 218 | If bot is a bot account or a selfbot. 219 | """ 220 | super().run(self.tokens["discord"], bot=bot) 221 | -------------------------------------------------------------------------------- /nest/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exceptions raised by the nest client and modules. 3 | """ 4 | 5 | from discord import ClientException 6 | 7 | 8 | class MissingFeatures(ClientException): 9 | """ 10 | Called when a module requires a feature not present in the client. 11 | """ 12 | 13 | def __init__(self, cog, features: set): 14 | self.features = features 15 | self.cog = cog 16 | super().__init__( 17 | f"{cog.__class__.__name__} requires missing features: {features}" 18 | ) 19 | 20 | 21 | class WebAPIException(Exception): 22 | """ 23 | Base exception from which API exceptions are derived. 24 | """ 25 | 26 | def __init__(self, api: str): 27 | super().__init__() 28 | self.api = api 29 | 30 | 31 | class WebAPINoResults(WebAPIException): 32 | """ 33 | Raised when an API does not return a result. 34 | """ 35 | 36 | def __init__(self, api: str, q: str): 37 | super().__init__(api) 38 | self.q = q 39 | 40 | 41 | class WebAPIUnreachable(WebAPIException): 42 | """ 43 | Raised when a web API could not be reached. 44 | """ 45 | 46 | pass 47 | 48 | 49 | class WebAPIInvalidResponse(WebAPIException): 50 | """ 51 | Raised when a web API returns an invalid response. 52 | """ 53 | 54 | def __init__(self, api: str, status: int): 55 | super().__init__(api) 56 | self.status = status 57 | 58 | 59 | EXC_I18N_MAP = { 60 | WebAPIInvalidResponse: "invalid_response", 61 | WebAPINoResults: "no_results", 62 | WebAPIUnreachable: "unreachable" 63 | } 64 | -------------------------------------------------------------------------------- /nest/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Miscallaneous utils separate from the rest of Nest's core. 3 | """ 4 | 5 | from typing import List 6 | 7 | 8 | def dictwalk(dictionary: dict, tree: List[str], fill: bool = False): 9 | """Walk down a dictionary tree and return an element. 10 | Parameters 11 | ---------- 12 | dictionary: dict 13 | Dictionary to walk through. 14 | tree: list 15 | Sorted tree to walk down. 16 | fill: bool 17 | If true, create empty dictionaries 18 | """ 19 | 20 | # Walk the pointer down the tree. 21 | # Python dictionaries are passed by reference, 22 | # So iterate over each element in the tree to arrive at 23 | # the last element. 24 | 25 | item = dictionary 26 | for k in tree: 27 | if k not in item: 28 | if fill: 29 | item[k] = {} 30 | else: 31 | raise KeyError(f"{k} not a valid key.") 32 | item = item[k] 33 | return item 34 | 35 | 36 | def smart_truncate(content: str, length: int = 400, suffix: str = "..") -> str: 37 | """ 38 | Truncates a string to `...` where necessary 39 | 40 | Parameters 41 | ---------- 42 | content: str 43 | String to truncate. 44 | length: int 45 | Length to truncate to. 46 | suffix: str = ".." 47 | String to suffix with. 48 | 49 | Returns 50 | ------- 51 | str: 52 | Truncated string. 53 | """ 54 | 55 | if len(content) >= length: 56 | content = content[:length] 57 | content = content.rsplit("\n", 1)[0] # Cut to nearest paragraph. 58 | content = content.rsplit(".", 1)[0] + "." # Cut to nearest sentence. 59 | content += suffix 60 | return content 61 | -------------------------------------------------------------------------------- /nest/i18n.json: -------------------------------------------------------------------------------- 1 | { 2 | "en_US": { 3 | "names": { 4 | "en": "English (United States)", 5 | "fr": "Anglais (Les États-Unis)", 6 | "hi": "अंग्रेज़ी (अमेरिका)" 7 | }, 8 | "time": { 9 | "month": "month", 10 | "months": "months", 11 | "day": "day", 12 | "days": "days", 13 | "hour": "hour", 14 | "hours": "hours", 15 | "minute": "minute", 16 | "minutes": "minutes" 17 | } 18 | }, 19 | "fr_FR": { 20 | "names": { 21 | "en": "French (France)", 22 | "fr": "Français (France)", 23 | "hi": "फ्रेंच (फ्रांस)" 24 | } 25 | }, 26 | "fr_CA": { 27 | "names": { 28 | "en": "French (Canada)", 29 | "fr": "Français (Canada)", 30 | "hi": "फ्रेंच (कनाडा)" 31 | } 32 | }, 33 | "hi_IN": { 34 | "names": { 35 | "en": "Hindi (India)", 36 | "fr": "Hindi (Inde)", 37 | "hi": "हिंदी (भारत)" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /nest/i18n.py: -------------------------------------------------------------------------------- 1 | """ 2 | Implement internationalization on a per-module level. 3 | """ 4 | import json 5 | import logging 6 | import os 7 | from typing import Dict 8 | 9 | from dateutil.relativedelta import relativedelta 10 | 11 | from nest.helpers import dictwalk 12 | 13 | TIME_UNITS = ["years", "months", "days", "hours", "minutes", "seconds"] 14 | 15 | 16 | class I18n: 17 | """Internationalization functions for Nest. 18 | 19 | Attributes 20 | ---------- 21 | locale: str 22 | Default locale. 23 | """ 24 | 25 | def __init__(self, locale: str): 26 | self._i18n_data = {} 27 | self._logger = logging.getLogger("nest.i18n") 28 | self.locale = locale 29 | self.load_locales() 30 | 31 | def load_locales(self): 32 | """Load core data about each supported locale.""" 33 | directory = os.path.dirname(os.path.realpath(__file__)) 34 | path = os.path.join(directory, "i18n.json") 35 | 36 | with open(path) as datafile: 37 | data = json.load(datafile) 38 | 39 | for lang, lang_data in data.items(): 40 | if lang not in self._i18n_data: 41 | self._logger.debug(f"Registering lang {lang}") 42 | self._i18n_data[lang] = {} 43 | 44 | self._i18n_data[lang].update(lang_data) 45 | 46 | def locales(self, current_locale: str) -> Dict[str, str]: 47 | """Return dictionary of language data. 48 | 49 | Parameters 50 | ---------- 51 | current_locale: str 52 | Locale to use (truncated to first two characters) 53 | """ 54 | 55 | locales = {} 56 | current_lang = current_locale[:2] 57 | for locale, data in self._i18n_data.items(): 58 | lang = locale[:2] 59 | try: 60 | l_user = data["names"].get( 61 | current_lang, data["names"].get(self.lang) 62 | ) 63 | 64 | l_native = data["names"].get(lang) 65 | locales[locale] = {"user": l_user, "native": l_native} 66 | except (KeyError, ValueError): 67 | self._logger.warning(f"{locale} has no name data! Ignoring.") 68 | continue 69 | return locales 70 | 71 | def load_module(self, module): 72 | """Load language data for a module. 73 | 74 | Parameters 75 | ---------- 76 | module: str 77 | Module to load from. 78 | Must be a valid module located in the modules/ directory. 79 | """ 80 | path = os.path.join("modules", module, "i18n") 81 | 82 | if not os.path.exists(path): 83 | return 84 | 85 | for filename in os.listdir(path): 86 | if not filename.endswith(".json"): 87 | self._logger.warning(f"Ignoring {filename}!") 88 | continue 89 | 90 | locale = filename[:-5] 91 | 92 | if locale not in self._i18n_data: 93 | self._logger.debug(f"Creating locale {locale}.") 94 | self._i18n_data[locale] = {} 95 | 96 | with open(f"{path}/{filename}") as file: 97 | self._i18n_data[locale].update(json.load(file)) 98 | 99 | def getstr(self, string: str, *, locale: str, cog: str): 100 | """Get a localized string. 101 | 102 | Parameters 103 | ---------- 104 | string: str 105 | Internal name of translated string. 106 | locale: str 107 | Locale to use, defaults to en_US if data not present. 108 | cog: str 109 | Cog to search for string. 110 | """ 111 | try: 112 | item = dictwalk(self._i18n_data, [locale, cog, string]) 113 | except KeyError: 114 | try: 115 | item = dictwalk(self._i18n_data, [self.locale, cog, string]) 116 | except KeyError: 117 | item = string 118 | return item 119 | 120 | @property 121 | def lang(self): 122 | """Default language.""" 123 | return self.locale[:2] 124 | 125 | def is_locale(self, locale: str): 126 | """Check if given locale is valid.""" 127 | return locale in self._i18n_data.keys() 128 | 129 | def format_timedelta(self, locale: str, delta: relativedelta): 130 | """Format a delta to a string.""" 131 | res = "" 132 | time = [delta.__dict__[unit] for unit in TIME_UNITS] 133 | 134 | for unit, value in zip(TIME_UNITS, time): 135 | value = abs(value) 136 | if value == 0: 137 | continue 138 | if value == 1: 139 | unit = unit[:-1] 140 | unit_tr = self.getstr(unit, locale=locale, cog="time") 141 | if res: 142 | res += ", " 143 | res += f"{str(value)} {unit_tr}" 144 | 145 | return res 146 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML 2 | discord 3 | asyncpg 4 | py-dateutil 5 | pycountry 6 | nsfw_dl 7 | -------------------------------------------------------------------------------- /utils/init_db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module to initialize the database with the required tables. 3 | """ 4 | 5 | import asyncio 6 | import os 7 | import asyncpg 8 | import yaml 9 | 10 | SQL_QUERY = "ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {field} {ftype};" 11 | SQL_TABLECREATE = "CREATE TABLE IF NOT EXISTS {table} (id BIGINT PRIMARY KEY);" 12 | 13 | 14 | def buildqueries(): 15 | """ 16 | Load fields and types from module configurations and build database queries. 17 | """ 18 | data = {} 19 | queries = [] 20 | 21 | # Fetch all queries. 22 | for module in os.listdir("modules"): 23 | if not module.startswith("."): 24 | path = os.path.join("modules", module, "db.yml") 25 | if not os.path.exists(path): 26 | continue 27 | with open(path) as dbdata: 28 | module = yaml.load(dbdata) 29 | for table, fields in module.items(): 30 | if table in data.keys(): 31 | data[table].update(fields) 32 | else: 33 | data[table] = fields 34 | 35 | for table in data: 36 | queries.append(SQL_TABLECREATE.format(table=table)) 37 | 38 | for table, fields in data.items(): 39 | for field, ftype in fields.items(): 40 | queries.append( 41 | SQL_QUERY.format(table=table, field=field, ftype=ftype) 42 | ) 43 | 44 | return queries 45 | 46 | 47 | async def runqueries(*queries: str): 48 | """ 49 | Run all database queries using asyncpg. 50 | """ 51 | pool = await asyncpg.create_pool(database="nest") 52 | 53 | async with pool.acquire() as conn: 54 | for query in queries: 55 | await conn.execute(query) 56 | 57 | 58 | def main(): 59 | """ 60 | Run as a script. 61 | """ 62 | queries = buildqueries() 63 | print("Queries are: ", *queries, sep="\n") 64 | 65 | choice = input("Continue? [y/n]: ") 66 | if choice.lower() == "y": 67 | print("Running queries on database...") 68 | loop = asyncio.get_event_loop() 69 | loop.run_until_complete(runqueries(*queries)) 70 | else: 71 | print("Exiting.") 72 | 73 | 74 | if __name__ == "__main__": 75 | main() 76 | --------------------------------------------------------------------------------