diff --git a/.idea/dictionaries/romain.xml b/.idea/dictionaries/romain.xml index 4f304c1..95da3c9 100644 --- a/.idea/dictionaries/romain.xml +++ b/.idea/dictionaries/romain.xml @@ -8,6 +8,7 @@ postgresql socketstats splt + systemd tutux webhooks diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 2e3acf2..5f9edb2 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -3,10 +3,17 @@ + + + + + + + @@ -38,7 +45,7 @@ - + @@ -49,6 +56,13 @@ + + + + + + + @@ -56,13 +70,6 @@ - - - - - - - @@ -96,7 +103,6 @@ - 1591290805787 - 1591290805787 - + - + diff --git a/setup.cfg b/setup.cfg index d5fe968..03cf944 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,9 +13,11 @@ install_requires = aiohttp==3.6.2 aiosqlite==0.13.0 appdirs==1.4.4 + astunparse==1.6.3 async-timeout==3.0.1 asyncpg==0.20.1 attrs==19.3.0 + braceexpand==0.1.5 cachetools==4.1.0 certifi==2020.4.5.1 chardet==3.0.4 @@ -27,9 +29,11 @@ install_requires = dnspython==1.16.0 humanize==2.4.0 idna==2.9 + import-expression==1.1.3 ipinfo==3.0.0 ipwhois==1.1.0 iso8601==0.1.12 + jishaku==1.18.2.188 multidict==4.7.6 psutil==5.7.0 PyPika==0.37.7 @@ -40,6 +44,7 @@ install_requires = typing-extensions==3.7.4.2 urllib3==1.25.9 websockets==8.1 + wheel==0.34.2 yarl==1.4.2 [options.entry_points] diff --git a/tuxbot/__main__.py b/tuxbot/__main__.py index d78f06b..6e03d70 100644 --- a/tuxbot/__main__.py +++ b/tuxbot/__main__.py @@ -16,7 +16,7 @@ from pip._vendor import distro import tuxbot.logging from tuxbot.core import data_manager -from tuxbot.core.bot import Tux +from tuxbot.core.bot import Tux, ExitCodes from tuxbot.core.utils.functions.cli import bordered from . import __version__ @@ -140,10 +140,10 @@ async def shutdown_handler(tux: Tux, signal_type, exit_code=None) -> NoReturn: """ if signal_type: log.info("%s received. Quitting...", signal_type) - sys.exit(0) + sys.exit(ExitCodes.SHUTDOWN) elif exit_code is None: log.info("Shutting down from unhandled exception") - tux.shutdown_code = 1 + tux.shutdown_code = ExitCodes.CRITICAL if exit_code is not None: tux.shutdown_code = exit_code @@ -161,7 +161,7 @@ async def shutdown_handler(tux: Tux, signal_type, exit_code=None) -> NoReturn: await asyncio.gather(*pending, return_exceptions=True) -async def run_bot(tux: Tux, cli_flags: Namespace) -> None: +async def run_bot(tux: Tux, cli_flags: Namespace, loop) -> None: """This run the bot. Parameters @@ -193,13 +193,14 @@ async def run_bot(tux: Tux, cli_flags: Namespace) -> None: if not token: log.critical("Token must be set if you want to login.") - sys.exit(1) + sys.exit(ExitCodes.CRITICAL) try: + await tux.load_packages() await tux.start(token, bot=True) except discord.LoginFailure: log.critical("This token appears to be valid.") - sys.exit(1) + sys.exit(ExitCodes.CRITICAL) return None @@ -229,7 +230,7 @@ def main() -> NoReturn: + "No instance provided ! " "You can use 'tuxbot -L' to list all available instances" + Style.RESET_ALL) - sys.exit(1) + sys.exit(ExitCodes.CRITICAL) tux = Tux( cli_flags=cli_flags, @@ -237,8 +238,11 @@ def main() -> NoReturn: dm_help=None ) - loop.run_until_complete(run_bot(tux, cli_flags)) + loop.run_until_complete(run_bot(tux, cli_flags, loop)) except KeyboardInterrupt: + print(Fore.RED + + "Please use quit instead of Ctrl+C to Shutdown!" + + Style.RESET_ALL) log.warning("Please use quit instead of Ctrl+C to Shutdown!") log.error("Received KeyboardInterrupt") if tux is not None: @@ -258,7 +262,7 @@ def main() -> NoReturn: asyncio.set_event_loop(None) loop.stop() loop.close() - exit_code = 1 if tux is None else tux.shutdown_code + exit_code = ExitCodes.CRITICAL if tux is None else tux.shutdown_code sys.exit(exit_code) diff --git a/tuxbot/cogs/__init__.py b/tuxbot/cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tuxbot/cogs/images/__init__.py b/tuxbot/cogs/images/__init__.py index 516710a..514e5e4 100644 --- a/tuxbot/cogs/images/__init__.py +++ b/tuxbot/cogs/images/__init__.py @@ -1,5 +1,6 @@ from .images import Images +from ...core.bot import Tux -def setup(bot): +def setup(bot: Tux): bot.add_cog(Images(bot)) diff --git a/tuxbot/cogs/logs/__init__.py b/tuxbot/cogs/logs/__init__.py index 733b020..717caff 100644 --- a/tuxbot/cogs/logs/__init__.py +++ b/tuxbot/cogs/logs/__init__.py @@ -3,9 +3,10 @@ import logging from discord.ext import commands from .logs import Logs, GatewayHandler, on_error +from ...core.bot import Tux -def setup(bot): +def setup(bot: Tux): cog = Logs(bot) bot.add_cog(cog) diff --git a/tuxbot/cogs/network/__init__.py b/tuxbot/cogs/network/__init__.py index b6623f9..70fca6e 100644 --- a/tuxbot/cogs/network/__init__.py +++ b/tuxbot/cogs/network/__init__.py @@ -1,5 +1,6 @@ from .network import Network +from ...core.bot import Tux -def setup(bot): +def setup(bot: Tux): bot.add_cog(Network(bot)) diff --git a/tuxbot/cogs/warnings/__init__.py b/tuxbot/cogs/warnings/__init__.py new file mode 100644 index 0000000..b49aebd --- /dev/null +++ b/tuxbot/cogs/warnings/__init__.py @@ -0,0 +1,6 @@ +from .warnings import Warnings +from ...core.bot import Tux + + +def setup(bot: Tux): + bot.add_cog(Warnings(bot)) diff --git a/tuxbot/cogs/warnings/warnings.py b/tuxbot/cogs/warnings/warnings.py new file mode 100644 index 0000000..cda3552 --- /dev/null +++ b/tuxbot/cogs/warnings/warnings.py @@ -0,0 +1,47 @@ +from typing import Union + +import discord +from discord.ext import commands + +from tuxbot.core import checks +from tuxbot.core.bot import Tux + + +class Warnings(commands.Cog, name="Warnings"): + def __init__(self, bot: Tux): + self.bot = bot + + @commands.group(name='warn', alias=['warning']) + @commands.guild_only() + @checks.is_mod() + async def _warn(self, ctx: commands.Context): + pass + + @_warn.command(name="add") + @commands.guild_only() + async def _warn_add( + self, + ctx: commands.Context, + member: Union[discord.User, discord.Member], + reason: str + ): + pass + + @_warn.command(name="delete", aliases=["del", "remove"]) + @commands.guild_only() + async def action_del( + self, + ctx: commands.Context, + warn_id: int, + reason: str = "" + ): + pass + + @_warn.command(name="list", aliases=["all"]) + @commands.guild_only() + async def action_del( + self, + ctx: commands.Context, + member: Union[discord.User, discord.Member] = None + ): + pass diff --git a/tuxbot/core/bot.py b/tuxbot/core/bot.py index 579bc80..f89170d 100644 --- a/tuxbot/core/bot.py +++ b/tuxbot/core/bot.py @@ -1,15 +1,19 @@ +import asyncio +import datetime import logging -from pathlib import Path -from typing import List +import sys +from typing import List, Union import discord from colorama import Fore, Style, init from discord.ext import commands from . import Config +from .data_manager import logs_data_path from .utils.functions.cli import bordered from . import __version__ +from .utils.functions.extra import ContextPlus log = logging.getLogger("tuxbot") init() @@ -22,24 +26,30 @@ NAME = r""" |_| \__,_/_/\_\_.__/ \___/ \__| |_.__/ \___/ \__| """ -l_extensions: List[str] = [ - "jishaku" +packages: List[str] = [ + "jishaku", + "tuxbot.cogs.warnings" ] class Tux(commands.AutoShardedBot): - def __init__(self, *args, cli_flags=None, bot_dir: Path = Path.cwd(), **kwargs): + _loading: asyncio.Task + + def __init__(self, *args, cli_flags=None, **kwargs): # by default, if the bot shutdown without any intervention, # it's a crash - self.shutdown_code = 1 + self.shutdown_code = ExitCodes.CRITICAL self.cli_flags = cli_flags self.instance_name = self.cli_flags.instance_name self.last_exception = None + self.logs = logs_data_path(self.instance_name) self.config = Config(self.instance_name) async def _prefixes(bot, message) -> List[str]: - prefixes = self.config.get_prefixes(message.guild) + prefixes = self.config('core').get('prefixes') + + prefixes.extend(self.config.get_prefixes(message.guild)) if self.config('core').get('mentionable'): return commands.when_mentioned_or(*prefixes)(bot, message) @@ -51,18 +61,38 @@ class Tux(commands.AutoShardedBot): if "owner_ids" in kwargs: kwargs["owner_ids"] = set(kwargs["owner_ids"]) else: - kwargs["owner_ids"] = self.config.owner_ids() + kwargs["owner_ids"] = self.config.owners_id() message_cache_size = 100_000 kwargs["max_messages"] = message_cache_size self.max_messages = message_cache_size self.uptime = None - self.main_dir = bot_dir + self._app_owners_fetched = False # to prevent abusive API calls super().__init__(*args, help_command=None, **kwargs) + async def load_packages(self): + if packages: + print("Loading packages...") + for package in packages: + try: + self.load_extension(package) + except Exception as e: + print(Fore.RED + + f"Failed to load package {package}" + + Style.RESET_ALL + + f" check " + f"{str((self.logs / 'tuxbot.log').resolve())} " + f"for more details") + + log.exception( + f"Failed to load package {package}", + exc_info=e + ) + async def on_ready(self): + self.uptime = datetime.datetime.now() INFO = { 'title': "INFO", 'rows': [ @@ -81,7 +111,7 @@ class Tux(commands.AutoShardedBot): 'title': "COGS", 'rows': [] } - for extension in l_extensions: + for extension in packages: COGS['rows'].append( f"[{'X' if extension in self.extensions else ' '}] {extension}" ) @@ -91,3 +121,84 @@ class Tux(commands.AutoShardedBot): print(bordered(INFO, COGS)) print(f"\n{'=' * 118}\n\n") + + async def is_owner(self, user: Union[discord.User, discord.Member]) -> bool: + """Determines if the user is a bot owner. + + Parameters + ---------- + user: Union[discord.User, discord.Member] + + Returns + ------- + bool + """ + if user.id in self.config.owners_id(): + return True + + owner = False + if not self._app_owners_fetched: + app = await self.application_info() + if app.team: + ids = [m.id for m in app.team.members] + self.config.update('core', 'owners_id', ids) + owner = user.id in ids + self._app_owners_fetched = True + + return owner + + async def get_context(self, message: discord.Message, *, cls=None): + return await super().get_context(message, cls=ContextPlus) + + async def process_commands(self, message: discord.Message): + """Check for blacklists. + + """ + if message.author.bot: + return + + if message.guild.id in self.config.get_blacklist('guild') \ + or message.channel.id in self.config.get_blacklist('channel') \ + or message.author.id in self.config.get_blacklist('user'): + return + + ctx = await self.get_context(message) + + if ctx is None or ctx.valid is False: + self.dispatch("message_without_command", message) + else: + await self.invoke(ctx) + + async def on_message(self, message: discord.Message): + await self.process_commands(message) + + async def logout(self): + """Disconnect from Discord and closes all actives connections. + + Todo: add postgresql logout here + """ + await super().logout() + + async def shutdown(self, *, restart: bool = False): + """Gracefully quit. + + Parameters + ---------- + restart:bool + If `True`, systemd or the launcher gonna see custom exit code + and reboot. + + """ + if not restart: + self.shutdown_code = ExitCodes.SHUTDOWN + else: + self.shutdown_code = ExitCodes.RESTART + + await self.logout() + sys.exit(self.shutdown_code) + + +class ExitCodes: + CRITICAL = 1 + SHUTDOWN = 0 + RESTART = 42 diff --git a/tuxbot/core/checks.py b/tuxbot/core/checks.py new file mode 100644 index 0000000..8838d10 --- /dev/null +++ b/tuxbot/core/checks.py @@ -0,0 +1,63 @@ +from typing import Awaitable, Dict + +import discord +from discord.ext import commands +from discord.ext.commands import ( + bot_has_permissions, + has_permissions, + is_owner, +) + +from tuxbot.core.utils.functions.extra import ContextPlus + +__all__ = [ + "bot_has_permissions", + "has_permissions", + "is_owner", + "is_mod", + "is_admin", + "check_permissions", + "guild_owner_or_permissions", +] + + +def is_mod(): + async def pred(ctx): + if await ctx.bot.is_owner(ctx.author): + return True + permissions: discord.Permissions = ctx.channel.permissions_for(ctx.author) + return permissions.manage_messages + + return commands.check(pred) + + +def is_admin(): + async def pred(ctx): + if await ctx.bot.is_owner(ctx.author): + return True + permissions: discord.Permissions = ctx.channel.permissions_for(ctx.author) + return permissions.administrator + + return commands.check(pred) + + +async def check_permissions(ctx: "ContextPlus", **perms: Dict[str, bool]): + if await ctx.bot.is_owner(ctx.author): + return True + + elif not perms: + return False + resolved = ctx.channel.permissions_for(ctx.author) + + return all( + getattr(resolved, name, None) == value for name, value in perms.items() + ) + + +def guild_owner_or_permissions(**perms: Dict[str, bool]): + async def pred(ctx): + if ctx.author is ctx.guild.owner: + return True + return await check_permissions(ctx, **perms) + + return commands.check(pred) diff --git a/tuxbot/core/config.py b/tuxbot/core/config.py index 385cbfa..8d9a621 100644 --- a/tuxbot/core/config.py +++ b/tuxbot/core/config.py @@ -3,7 +3,7 @@ import logging __all__ = ["Config"] -from typing import List, Dict +from typing import List, Dict, Union import discord @@ -39,8 +39,8 @@ class Config: def __call__(self, item): return self.__getitem__(item) - def owner_ids(self) -> List[int]: - return self.__getitem__('core').get('owner_ids') + def owners_id(self) -> List[int]: + return self.__getitem__('core').get('owners_id') def token(self) -> str: return self.__getitem__('core').get('token') @@ -53,3 +53,29 @@ class Config: .get('prefixes', []) return prefixes + + def get_blacklist(self, key: str) -> List[Union[str, int]]: + core = self.__getitem__('core') + blacklist = core \ + .get('blacklist', {}) \ + .get(key, []) + + return blacklist + + def update(self, cog_name, item, value) -> dict: + datas = self.__getitem__(cog_name) + path = data_path(self._cog_instance) + + datas[item] = value + + if cog_name != 'core': + path = path / 'cogs' / cog_name + else: + path /= 'core' + + settings_file = path / 'settings.json' + + with settings_file.open('w') as f: + json.dump(datas, f, indent=4) + + return datas diff --git a/tuxbot/core/data_manager.py b/tuxbot/core/data_manager.py index 6aefff9..1bd4d9a 100644 --- a/tuxbot/core/data_manager.py +++ b/tuxbot/core/data_manager.py @@ -69,3 +69,18 @@ def cog_data_path(instance_name: str, cog_name: str) -> Path: Generated path for cog's configs. """ return data_path(instance_name) / "data" / instance_name / "cogs" / cog_name + + +def logs_data_path(instance_name: str) -> Path: + """Return Path for logs. + + Parameters + ---------- + instance_name:str + + Returns + ------- + Path + Generated path for logs files. + """ + return data_path(instance_name) / "data" / instance_name / "logs" diff --git a/tuxbot/core/utils/functions/extra.py b/tuxbot/core/utils/functions/extra.py index 6783211..157fa7e 100644 --- a/tuxbot/core/utils/functions/extra.py +++ b/tuxbot/core/utils/functions/extra.py @@ -1,48 +1,11 @@ -import ast import asyncio -import json -import os import discord from discord.ext import commands, flags -from configs.bot.protected import protected -from configs.bot.settings import prefixes - class ContextPlus(commands.Context): async def send(self, content=None, *args, **kwargs): - if content is not None: - for value in protected: - content = content.replace( - str(value), - '[Deleted]' - ) - - if kwargs.get('content') is not None: - for value in protected: - kwargs['content'] = kwargs['content'].replace( - str(value), - '[Deleted]' - ) - - if kwargs.get('embeds') is not None and len(kwargs.get('embeds')) > 0: - for i, embed in enumerate(kwargs.get('embeds')): - embed = str(kwargs.get('embed').to_dict()) - for value in protected: - embed = embed.replace(str(value), '[Deleted]') - kwargs['embeds'][i] = discord.Embed.from_dict( - ast.literal_eval(embed) - ) - - if kwargs.get('embed') is not None: - embed = str(kwargs.get('embed').to_dict()) - for value in protected: - embed = embed.replace(str(value), '[Deleted]') - kwargs['embed'] = discord.Embed.from_dict( - ast.literal_eval(embed) - ) - if (hasattr(self.command, 'deletable') and self.command.deletable) \ and kwargs.pop('deletable', True): message = await super().send(content, *args, **kwargs) @@ -86,29 +49,3 @@ class GroupPlus(flags.FlagGroup): def group_extra(*args, **kwargs): return commands.group(*args, **kwargs, cls=GroupPlus) - - -async def get_prefix(bot, message): - custom_prefix = prefixes - if message.guild: - path = f"configs/guilds/{str(message.guild.id)}.json" - - if os.path.exists(path): - with open(path) as f: - datas = json.load(f) - - custom_prefix = datas["Prefix"] - - return commands.when_mentioned_or(*custom_prefix)(bot, message) - - -def get_owners() -> list: - with open("configs/bot/whitelist.json") as f: - datas = json.load(f) - - return datas['owners'] - - -def get_blacklist() -> dict: - with open("configs/bot/blacklist.json") as f: - return json.load(f) diff --git a/tuxbot/logging.py b/tuxbot/logging.py index 8a9b381..2013c0f 100644 --- a/tuxbot/logging.py +++ b/tuxbot/logging.py @@ -17,6 +17,7 @@ def init_logging(level: int, location: pathlib.Path) -> None: location:Path Where to store logs. """ + dpy_logger = logging.getLogger("discord") dpy_logger.setLevel(logging.WARN) dpy_logger_file = location / 'discord.log' @@ -39,10 +40,7 @@ def init_logging(level: int, location: pathlib.Path) -> None: maxBytes=MAX_BYTES, backupCount=MAX_OLD_LOGS ) - dpy_logger.addHandler(dpy_handler) - base_logger.addHandler(base_handler) - stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) - base_logger.addHandler(stdout_handler) - dpy_logger.addHandler(stdout_handler) + dpy_logger.addHandler(dpy_handler) + base_logger.addHandler(base_handler) diff --git a/tuxbot/setup.py b/tuxbot/setup.py index b8fa076..280da2d 100644 --- a/tuxbot/setup.py +++ b/tuxbot/setup.py @@ -292,6 +292,7 @@ def finish_setup(data_dir: Path) -> NoReturn: 'prefixes': prefixes, 'mentionable': mentionable, 'owners_id': owners_id, + 'locale': "en-US" } with core_file.open("w") as fs: