diff --git a/dev.requirements.txt b/dev.requirements.txt index e33385e..d75ecc1 100644 --- a/dev.requirements.txt +++ b/dev.requirements.txt @@ -1,3 +1,2 @@ -youtrack pylint>=2.6.0 black>=20.8b1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index f5f62b5..c3baa74 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,7 @@ install_requires = psutil>=5.7.2 requests>=2.25.1 rich>=6.0.0 + sentry_sdk>=0.19.5 structured_config>=4.12 tortoise-orm>=0.16.17 diff --git a/tuxbot/__main__.py b/tuxbot/__main__.py index a49981b..837f97a 100644 --- a/tuxbot/__main__.py +++ b/tuxbot/__main__.py @@ -1,9 +1,5 @@ -from rich.console import Console -from rich.traceback import install from tuxbot import ExitCodes - -console = Console() -install(console=console, show_locals=True) +from tuxbot.core.utils.console import console def main() -> None: diff --git a/tuxbot/__run__.py b/tuxbot/__run__.py index 5057b79..5e58b04 100644 --- a/tuxbot/__run__.py +++ b/tuxbot/__run__.py @@ -11,9 +11,7 @@ import discord import humanize import pip from rich.columns import Columns -from rich.console import Console from rich.panel import Panel -from rich.traceback import install from rich.table import Table, box from rich.text import Text from rich import print as rprint @@ -21,14 +19,12 @@ from rich import print as rprint import tuxbot.logging from tuxbot.core.bot import Tux from tuxbot.core import config -from .core.utils import data_manager +from tuxbot.core.utils import data_manager +from tuxbot.core.utils.console import console from . import __version__, version_info, ExitCodes log = logging.getLogger("tuxbot.main") -console = Console() -install(console=console, show_locals=True) - BORDER_STYLE = "not dim" diff --git a/tuxbot/cogs/Dev/config.py b/tuxbot/cogs/Dev/config.py index 83ef6fc..ac2ba2b 100644 --- a/tuxbot/cogs/Dev/config.py +++ b/tuxbot/cogs/Dev/config.py @@ -4,19 +4,12 @@ HAS_MODELS = False class DevConfig(Structure): - url: str = StrField("") - login: str = StrField("") - password: str = StrField("") + sentryKey: str = StrField("") extra = { - "url": { + "sentryKey": { "type": str, - "description": "URL of the YouTrack instance (without /youtrack/)", - }, - "login": {"type": str, "description": "Login for YouTrack instance"}, - "password": { - "type": str, - "description": "Password for YouTrack instance", + "description": "Sentry KEY for error logging (https://sentry.io/)", }, } diff --git a/tuxbot/cogs/Dev/dev.py b/tuxbot/cogs/Dev/dev.py index 8773369..4124eac 100644 --- a/tuxbot/cogs/Dev/dev.py +++ b/tuxbot/cogs/Dev/dev.py @@ -1,46 +1,30 @@ import logging from discord.ext import commands -from youtrack.connection import Connection as YouTrack -from structured_config import ConfigFile from tuxbot.core.bot import Tux from tuxbot.core.i18n import ( Translator, ) -from tuxbot.core.utils.data_manager import cogs_data_path -from .config import DevConfig -from ...core.utils import checks -from ...core.utils.functions.extra import group_extra, ContextPlus +from tuxbot.core.utils import checks +from tuxbot.core.utils.functions.extra import command_extra, ContextPlus log = logging.getLogger("tuxbot.cogs.Dev") _ = Translator("Dev", __file__) class Dev(commands.Cog, name="Dev"): - yt: YouTrack # pylint: disable=invalid-name - def __init__(self, bot: Tux): self.bot = bot - self.__config: DevConfig = ConfigFile( - str(cogs_data_path(self.bot.instance_name, "Dev") / "config.yaml"), - DevConfig, - ).config - - # pylint: disable=invalid-name - self.yt = YouTrack( - self.__config.url.rstrip("/") + "/youtrack/", - login=self.__config.login, - password=self.__config.password, - ) # ========================================================================= # ========================================================================= - @group_extra(name="issue", aliases=["issues"], deletable=True) + @command_extra(name="crash", deletable=True) @checks.is_owner() - async def _issue(self, ctx: ContextPlus): - """Manage bot issues.""" - - @_issue.command(name="list", aliases=["liste", "all", "view"]) - async def _lang_list(self, ctx: ContextPlus): - pass + async def _crash(self, ctx: ContextPlus, crash_type: str): + if crash_type == "ZeroDivisionError": + await ctx.send(str(5 / 0)) + elif crash_type == "TypeError": + await ctx.send(str(int([]))) + elif crash_type == "IndexError": + await ctx.send(str([0][5])) diff --git a/tuxbot/cogs/Logs/__init__.py b/tuxbot/cogs/Logs/__init__.py index 9f7c6ad..ed87faf 100644 --- a/tuxbot/cogs/Logs/__init__.py +++ b/tuxbot/cogs/Logs/__init__.py @@ -4,7 +4,7 @@ from collections import namedtuple from discord.ext import commands from tuxbot.core.bot import Tux -from .logs import Logs, on_error, GatewayHandler +from .logs import Logs, GatewayHandler from .config import LogsConfig, HAS_MODELS VersionInfo = namedtuple("VersionInfo", "major minor micro release_level") @@ -24,4 +24,3 @@ def setup(bot: Tux): handler = GatewayHandler(cog) logging.getLogger().addHandler(handler) - commands.AutoShardedBot.on_error = on_error diff --git a/tuxbot/cogs/Logs/config.py b/tuxbot/cogs/Logs/config.py index 366efdf..4959470 100644 --- a/tuxbot/cogs/Logs/config.py +++ b/tuxbot/cogs/Logs/config.py @@ -9,6 +9,7 @@ class LogsConfig(Structure): guilds: str = StrField("") errors: str = StrField("") gateway: str = StrField("") + sentryKey: str = StrField("") extra = { @@ -35,4 +36,8 @@ extra = { "type": str, "description": "URL of the webhook used for send gateway information", }, + "sentryKey": { + "type": str, + "description": "Sentry KEY for error logging (https://sentry.io/)", + }, } diff --git a/tuxbot/cogs/Logs/logs.py b/tuxbot/cogs/Logs/logs.py index 968e784..c89a17b 100644 --- a/tuxbot/cogs/Logs/logs.py +++ b/tuxbot/cogs/Logs/logs.py @@ -10,6 +10,7 @@ from logging import LogRecord import discord import humanize import psutil +import sentry_sdk from discord.ext import commands, tasks from structured_config import ConfigFile @@ -64,7 +65,70 @@ class Logs(commands.Cog, name="Logs"): self._resumes = [] self._identifies = defaultdict(list) - def _clear_gateway_data(self): + self.old_on_error = bot.on_error + bot.on_error = self.on_error + + sentry_sdk.init( + dsn=self.__config.sentryKey, + traces_sample_rate=1.0, + environment=self.bot.instance_name, + debug=False, + ) + + def cog_unload(self): + self.bot.on_error = self.old_on_error + + async def on_error(self, event): + raise event + + # ========================================================================= + # ========================================================================= + + def webhook(self, log_type): + webhook = discord.Webhook.from_url( + getattr(self.__config, log_type), + adapter=discord.AsyncWebhookAdapter(self.bot.session), + ) + return webhook + + async def send_guild_stats(self, e, guild): + e.add_field(name="Name", value=guild.name) + e.add_field(name="ID", value=guild.id) + e.add_field(name="Shard ID", value=guild.shard_id or "N/A") + e.add_field( + name="Owner", value=f"{guild.owner} (ID: {guild.owner.id})" + ) + + bots = sum(member.bot for member in guild.members) + total = guild.member_count + online = sum( + member.status is discord.Status.online for member in guild.members + ) + + e.add_field(name="Members", value=str(total)) + e.add_field(name="Bots", value=f"{bots} ({bots / total:.2%})") + e.add_field(name="Online", value=f"{online} ({online / total:.2%})") + + if guild.icon: + e.set_thumbnail(url=guild.icon_url) + + if guild.me: + e.timestamp = guild.me.joined_at + + await self.webhook("guilds").send(embed=e) + + def add_record(self, record: LogRecord): + self._gateway_queue.put_nowait(record) + + async def notify_gateway_status(self, record: LogRecord): + types = {"INFO": ":information_source:", "WARNING": ":warning:"} + + emoji = types.get(record.levelname, ":heavy_multiplication_x:") + dt = datetime.datetime.utcfromtimestamp(record.created) + msg = f"{emoji} `[{dt:%Y-%m-%d %H:%M:%S}] {record.message}`" + await self.webhook("gateway").send(msg) + + def clear_gateway_data(self): one_week_ago = datetime.datetime.utcnow() - datetime.timedelta(days=7) to_remove = [ index @@ -81,11 +145,6 @@ class Logs(commands.Cog, name="Logs"): for index in reversed(to_remove): del dates[index] - @tasks.loop(seconds=0.0) - async def gateway_worker(self): - record = await self._gateway_queue.get() - await self.notify_gateway_status(record) - async def register_command(self, ctx: ContextPlus): if ctx.command is None: return @@ -120,6 +179,14 @@ class Logs(commands.Cog, name="Logs"): } ) + # ========================================================================= + # ========================================================================= + + @tasks.loop(seconds=0.0) + async def gateway_worker(self): + record = await self._gateway_queue.get() + await self.notify_gateway_status(record) + @commands.Cog.listener() async def on_command_completion(self, ctx: ContextPlus): await self.register_command(ctx) @@ -128,57 +195,6 @@ class Logs(commands.Cog, name="Logs"): async def on_socket_response(self, msg): self.bot.stats["socket"][msg.get("t")] += 1 - def webhook(self, log_type): - webhook = discord.Webhook.from_url( - getattr(self.__config, log_type), - adapter=discord.AsyncWebhookAdapter(self.bot.session), - ) - return webhook - - async def log_error(self, *, ctx: ContextPlus = None, extra=None): - e = discord.Embed(title="Error", colour=0xDD5F53) - e.description = f"```py\n{traceback.format_exc()}\n```" - e.add_field(name="Extra", value=extra, inline=False) - e.timestamp = datetime.datetime.utcnow() - - if ctx is not None: - fmt = "{0} (ID: {0.id})" - author = fmt.format(ctx.author) - channel = fmt.format(ctx.channel) - guild = "None" if ctx.guild is None else fmt.format(ctx.guild) - - e.add_field(name="Author", value=author) - e.add_field(name="Channel", value=channel) - e.add_field(name="Guild", value=guild) - - await self.webhook("errors").send(embed=e) - - async def send_guild_stats(self, e, guild): - e.add_field(name="Name", value=guild.name) - e.add_field(name="ID", value=guild.id) - e.add_field(name="Shard ID", value=guild.shard_id or "N/A") - e.add_field( - name="Owner", value=f"{guild.owner} (ID: {guild.owner.id})" - ) - - bots = sum(member.bot for member in guild.members) - total = guild.member_count - online = sum( - member.status is discord.Status.online for member in guild.members - ) - - e.add_field(name="Members", value=str(total)) - e.add_field(name="Bots", value=f"{bots} ({bots / total:.2%})") - e.add_field(name="Online", value=f"{online} ({online / total:.2%})") - - if guild.icon: - e.set_thumbnail(url=guild.icon_url) - - if guild.me: - e.timestamp = guild.me.joined_at - - await self.webhook("guilds").send(embed=e) - @commands.Cog.listener() async def on_guild_join(self, guild: discord.guild): e = discord.Embed(colour=0x53DDA4, title="New Guild") # green colour @@ -204,7 +220,9 @@ class Logs(commands.Cog, name="Logs"): await self.webhook("dm").send(embed=e) @commands.Cog.listener() - async def on_command_error(self, ctx: ContextPlus, error): + async def on_command_error( + self, ctx: ContextPlus, error: commands.CommandError + ): await self.register_command(ctx) if not isinstance( error, (commands.CommandInvokeError, commands.ConversionError) @@ -215,6 +233,11 @@ class Logs(commands.Cog, name="Logs"): if isinstance(error, (discord.Forbidden, discord.NotFound)): return + sentry_sdk.capture_exception(error) + self.bot.console.log( + "Command Error, check sentry or discord error channel" + ) + e = discord.Embed(title="Command Error", colour=0xCC3366) e.add_field(name="Name", value=ctx.command.qualified_name) e.add_field(name="Author", value=f"{ctx.author} (ID: {ctx.author.id})") @@ -251,18 +274,10 @@ class Logs(commands.Cog, name="Logs"): else: self._resumes.append(datetime.datetime.utcnow()) - self._clear_gateway_data() + self.clear_gateway_data() - def add_record(self, record: LogRecord): - self._gateway_queue.put_nowait(record) - - async def notify_gateway_status(self, record: LogRecord): - types = {"INFO": ":information_source:", "WARNING": ":warning:"} - - emoji = types.get(record.levelname, ":heavy_multiplication_x:") - dt = datetime.datetime.utcfromtimestamp(record.created) - msg = f"{emoji} `[{dt:%Y-%m-%d %H:%M:%S}] {record.message}`" - await self.webhook("gateway").send(msg) + # ========================================================================= + # ========================================================================= @command_extra(name="commandstats", hidden=True, deletable=True) @commands.is_owner() @@ -318,27 +333,3 @@ class Logs(commands.Cog, name="Logs"): datetime.datetime.now() - self.bot.uptime ) await ctx.send(f"Uptime: **{uptime}**") - - -async def on_error(self, event, *args): - e = discord.Embed(title="Event Error", colour=0xA32952) - e.add_field(name="Event", value=event) - e.description = f"```py\n{traceback.format_exc()}\n```" - e.timestamp = datetime.datetime.utcnow() - - args_str = ["```py"] - for index, arg in enumerate(args): - args_str.append(f"[{index}]: {arg!r}") - args_str.append("```") - e.add_field(name="Args", value="\n".join(args_str), inline=False) - - hook = self.get_cog("Logs").webhook("errors") - try: - await hook.send(embed=e) - except ( - discord.HTTPException, - discord.NotFound, - discord.Forbidden, - discord.InvalidArgument, - ): - pass diff --git a/tuxbot/core/bot.py b/tuxbot/core/bot.py index 2343524..9712591 100644 --- a/tuxbot/core/bot.py +++ b/tuxbot/core/bot.py @@ -10,9 +10,8 @@ import discord from discord.ext import commands from rich import box from rich.columns import Columns -from rich.console import Console from rich.panel import Panel -from rich.progress import Progress, TextColumn, BarColumn +from rich.progress import Progress from rich.table import Table from tortoise import Tortoise @@ -22,7 +21,10 @@ from tuxbot.core.utils.data_manager import ( data_path, config_dir, ) -from .config import ( +from tuxbot.core.utils.functions.extra import ContextPlus +from tuxbot.core.utils.functions.prefix import get_prefixes +from tuxbot.core.utils.console import console +from tuxbot.core.config import ( Config, ConfigFile, search_for, @@ -31,17 +33,14 @@ from .config import ( ) from . import __version__, ExitCodes from . import exceptions -from .utils.functions.extra import ContextPlus -from .utils.functions.prefix import get_prefixes log = logging.getLogger("tuxbot") -console = Console() packages: List[str] = [ "jishaku", "tuxbot.cogs.Admin", "tuxbot.cogs.Logs", - # "tuxbot.cogs.Dev", + "tuxbot.cogs.Dev", "tuxbot.cogs.Utils", "tuxbot.cogs.Polls", "tuxbot.cogs.Custom", @@ -51,13 +50,7 @@ packages: List[str] = [ class Tux(commands.AutoShardedBot): _loading: asyncio.Task - _progress = { - "main": Progress( - TextColumn("[bold blue]{task.fields[task_name]}", justify="right"), - BarColumn(), - ), - "tasks": {}, - } + _progress = {"tasks": {}, "main": Progress()} def __init__(self, *args, cli_flags=None, **kwargs): # by default, if the bot shutdown without any intervention, @@ -162,20 +155,19 @@ class Tux(commands.AutoShardedBot): last_run=datetime.datetime.timestamp(self.uptime), ) - self._progress["main"].stop_task(self._progress["tasks"]["connecting"]) - self._progress["main"].remove_task( - self._progress["tasks"]["connecting"] - ) - self._progress["tasks"].pop("connecting") - console.clear() + with self._progress["main"] as progress: + progress.stop_task(self._progress["tasks"]["discord_connecting"]) + progress.remove_task(self._progress["tasks"]["discord_connecting"]) + self._progress["tasks"].pop("discord_connecting") + self.console.clear() - console.print( + self.console.print( Panel(f"[bold blue]Tuxbot V{version_info.major}", style="blue"), justify="center", ) - console.print() + self.console.print() - columns = Columns(expand=True, align="center") + columns = Columns(align="center", expand=True) table = Table(style="dim", border_style="not dim", box=box.HEAVY_HEAD) table.add_column( @@ -204,8 +196,8 @@ class Tux(commands.AutoShardedBot): table.add_row(status) columns.add_renderable(table) - console.print(columns) - console.print() + self.console.print(columns) + self.console.print() async def is_owner( self, user: Union[discord.User, discord.Member] @@ -278,29 +270,24 @@ class Tux(commands.AutoShardedBot): await self.process_commands(message) async def start(self, token, bot): # pylint: disable=arguments-differ - """Connect to Discord and start all connections. - - Todo: add postgresql connect here - """ - with self._progress.get("main") as progress: - task_id = self._progress.get("tasks")[ - "connecting" - ] = progress.add_task( - "connecting", - task_name="Connecting to PostgreSQL...", - start=False, + """Connect to Discord and start all connections.""" + with Progress() as progress: + task = progress.add_task( + "Connecting to PostgreSQL...", total=len(self.extensions) ) models = [] for extension, _ in self.extensions.items(): if extension == "jishaku": + progress.advance(task) continue if importlib.import_module(extension).HAS_MODELS: models.append(f"{extension}.models.__init__") - progress.update(task_id) + progress.advance(task) + await Tortoise.init( db_url="postgres://{}:{}@{}:{}/{}".format( self.config.Core.Database.username, @@ -313,17 +300,13 @@ class Tux(commands.AutoShardedBot): ) await Tortoise.generate_schemas() - self._progress["main"].stop_task(self._progress["tasks"]["connecting"]) - self._progress["main"].remove_task( - self._progress["tasks"]["connecting"] - ) - self._progress["tasks"].pop("connecting") - - with self._progress.get("main") as progress: - task_id = self._progress.get("tasks")[ - "connecting" + with self._progress["main"] as progress: + task_id = self._progress["tasks"][ + "discord_connecting" ] = progress.add_task( - "connecting", task_name="Connecting to Discord...", start=False + "discord_connecting", + task_name="Connecting to Discord...", + start=False, ) progress.update(task_id) await super().start(token, bot=bot) @@ -341,21 +324,22 @@ class Tux(commands.AutoShardedBot): active=False, ) - for task in self._progress["tasks"]: - self._progress["main"].log("Shutting down", task) + with self._progress["main"] as progress: + for task in self._progress["tasks"]: + progress.log("Shutting down", task) - self._progress["main"].stop_task(self._progress["tasks"][task]) - self._progress["main"].remove_task( - self._progress["tasks"]["connecting"] - ) - self._progress["main"].stop() + progress.stop_task(self._progress["tasks"][task]) + progress.remove_task(self._progress["tasks"][task]) + progress.stop() pending = [ t for t in asyncio.all_tasks() if t is not asyncio.current_task() ] for task in pending: - console.log("Canceling", task.get_name(), f"({task.get_coro()})") + self.console.log( + "Canceling", task.get_name(), f"({task.get_coro()})" + ) task.cancel() await asyncio.gather(*pending, return_exceptions=False) diff --git a/tuxbot/core/utils/functions/extra.py b/tuxbot/core/utils/functions/extra.py index 23cd4fe..221513c 100644 --- a/tuxbot/core/utils/functions/extra.py +++ b/tuxbot/core/utils/functions/extra.py @@ -6,10 +6,6 @@ import discord from discord import Embed from discord.ext import commands -from rich.console import Console - -console = Console() - TOKEN_REPLACEMENT = "■" * random.randint(3, 15) PASSWORD_REPLACEMENT = "■" * random.randint(3, 15) IP_REPLACEMENT = "■" * random.randint(3, 15) @@ -130,6 +126,11 @@ class ContextPlus(commands.Context): def session(self) -> aiohttp.ClientSession: return self.bot.session + def __repr__(self): + items = ("%s = %r" % (k, v) for k, v in self.__dict__.items()) + + return "<%s: {%s}>" % (self.__class__.__name__, ", ".join(items)) + class CommandPLus(commands.Command): def __init__(self, function, **kwargs):