diff --git a/messages/en.json b/messages/en.json index cca0286..d121869 100644 --- a/messages/en.json +++ b/messages/en.json @@ -863,6 +863,7 @@ "acc_warning": "Changing accounts during game. Please do not change accounts while playing.", "tempban_kick": "Temporary ban for warning: {reason}", "error_log": "An error has occurred and has been logged.", + "error_pastebin": "(Unable to pastebin traceback; please check the console)", "_": " vim: set sw=4 expandtab:" } diff --git a/src/decorators.py b/src/decorators.py index 2d3281b..8cd1545 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -1,9 +1,12 @@ -import fnmatch -import socket import traceback import threading -import types -import sys +import string +import random +import json +import re + +import urllib.request, urllib.parse + from collections import defaultdict from oyoyo.client import IRCClient @@ -12,7 +15,7 @@ from oyoyo.parse import parse_nick import botconfig import src.settings as var from src.utilities import * -from src import channels, logger, errlog, events +from src import channels, users, logger, errlog, events from src.messages import messages adminlog = logger.logger("audit.log") @@ -20,64 +23,161 @@ adminlog = logger.logger("audit.log") COMMANDS = defaultdict(list) HOOKS = defaultdict(list) -# Error handler decorators - -_do_nothing = lambda: None +# Error handler decorators and context managers class _local(threading.local): - level = 0 frame_locals = None + handler = None + level = 0 _local = _local() +# This is a mapping of stringified tracebacks to (link, uuid) tuples +# That way, we don't have to call in to the website everytime we have +# another error. If you ever need to delete pastes, do the following: +# $ curl -x DELETE https://ptpb.pw/ + +_tracebacks = {} + +class chain_exceptions: + + def __init__(self, exc, *, suppress_context=False): + self.exc = exc + self.suppress_context = suppress_context + + def __enter__(self): + return self + + def __exit__(self, exc, value, tb): + if exc is value is tb is None: + return False + + value.__context__ = self.exc + value.__suppress_context__ = self.suppress_context + self.exc = value + return True + + @property + def traceback(self): + return "".join(traceback.format_exception(type(self.exc), self.exc, self.exc.__traceback__)) + +class print_traceback: + + def __enter__(self): + _local.level += 1 + return self + + def __exit__(self, exc_type, exc_value, tb): + if exc_type is exc_value is tb is None: + _local.level -= 1 + return False + + if not issubclass(exc_type, Exception): + _local.level -= 1 + return False + + if _local.frame_locals is None: + while tb.tb_next is not None: + tb = tb.tb_next + _local.frame_locals = tb.tb_frame.f_locals + + if _local.level > 1: + _local.level -= 1 + return False # the outermost caller should handle this + + if _local.handler is None: + _local.handler = chain_exceptions(exc_value) + + variables = ["", None, "Local variables from innermost frame:", ""] + + with _local.handler: + for name, value in _local.frame_locals.items(): + variables.append("{0} = {1!r}".format(name, value)) + + if len(variables) > 4: + variables.append("\n") + else: + variables[2] = "No local variables found in innermost frame." + + variables[1] = _local.handler.traceback + + if not botconfig.PASTEBIN_ERRORS or channels.Main is not channels.Dev: + channels.Main.send(messages["error_log"]) + if botconfig.PASTEBIN_ERRORS and channels.Dev is not None: + message = [messages["error_log"]] + + link_uuid = _tracebacks.get("\n".join(variables)) + if link_uuid is None: + bot_id = re.sub(r"[^A-Za-z0-9-]", "-", users.Bot.nick) + bot_id = re.sub(r"--+", "-", bot_id).strip("-") + + rand_id = "".join(random.sample(string.ascii_letters + string.digits, 8)) + + api_url = "https://ptpb.pw/~{0}-error-{1}".format(bot_id, rand_id) + + data = None + with _local.handler: + req = urllib.request.Request(api_url, urllib.parse.urlencode({ + "c": "\n".join(variables), # contents + "s": 86400 # expiry (seconds) + }).encode("utf-8", "replace")) + + req.add_header("Accept", "application/json") + resp = urllib.request.urlopen(req) + data = json.loads(resp.read().decode("utf-8")) + + if data is None: # couldn't fetch the link + message.append(messages["error_pastebin"]) + variables[1] = _local.handler.traceback # an error happened; update the stored traceback + else: + link, uuid = _tracebacks["\n".join(variables)] = (data["url"] + "/pytb", data.get("uuid")) + message.append(link) + if uuid is None: # if there's no uuid, the paste already exists and we don't have it + message.append("(Already reported by another instance)") + else: + message.append("(uuid: {0})".format(uuid)) + + else: + link, uuid = link_uuid + message.append(link) + if uuid is None: + message.append("(Previously reported)") + else: + message.append("(uuid: {0}-...)".format(uuid[:8])) + + channels.Dev.send(" ".join(message), prefix=botconfig.DEV_PREFIX) + + errlog("\n".join(variables)) + + _local.level -= 1 + if not _local.level: # outermost caller; we're done here + _local.frame_locals = None + _local.handler = None + + return True # a true return value tells the interpreter to swallow the exception + class handle_error: - def __new__(cls, func): - if isinstance(func, cls): # already decorated + def __new__(cls, func=None, *, instance=None): + if isinstance(func, cls) and instance is func.instance: # already decorated return func self = super().__new__(cls) + self.instance = instance self.func = func return self def __get__(self, instance, owner): - if instance is not None: - return types.MethodType(self, instance) + if instance is not self.instance: + return type(self)(self.func, instance=instance) return self - def __call__(self, *args, **kwargs): - fn = _do_nothing - _local.level += 1 - try: + def __call__(*args, **kwargs): + self, *args = args + if self.instance is not None: + args = self.instance, *args + with print_traceback(): return self.func(*args, **kwargs) - except Exception: - if _local.frame_locals is None: - exc_type, exc_value, tb = sys.exc_info() - while tb.tb_next is not None: - tb = tb.tb_next - _local.frame_locals = tb.tb_frame.f_locals - - if _local.level > 1: - raise # the outermost caller should handle this - - fn = lambda: errlog("\n{0}\n\n".format(data)) - data = traceback.format_exc() - variables = ["\nLocal variables from innermost frame:\n"] - for name, value in _local.frame_locals.items(): - variables.append("{0} = {1!r}".format(name, value)) - - data += "\n".join(variables) - - if not botconfig.PASTEBIN_ERRORS or channels.Main is not channels.Dev: - channels.Main.send(messages["error_log"]) - if botconfig.PASTEBIN_ERRORS and channels.Dev is not None: - pastebin_tb(channels.Dev, messages["error_log"], data, prefix=botconfig.DEV_PREFIX) - - finally: - fn() - _local.level -= 1 - if not _local.level: # outermost caller; we're done here - _local.frame_locals = None class cmd: def __init__(self, *cmds, raw_nick=False, flag=None, owner_only=False, diff --git a/src/utilities.py b/src/utilities.py index 8d091ea..e20d58c 100644 --- a/src/utilities.py +++ b/src/utilities.py @@ -1,11 +1,6 @@ -import fnmatch import itertools -import json -import random +import fnmatch import re -import string -import traceback -import urllib import botconfig import src.settings as var @@ -20,8 +15,7 @@ __all__ = ["pm", "is_fake_nick", "mass_mode", "mass_privmsg", "reply", "is_owner", "is_admin", "plural", "singular", "list_players", "list_players_and_roles", "list_participants", "get_role", "get_roles", "get_reveal_role", "get_templates", "role_order", "break_long_message", - "complete_match", "get_victim", "get_nick", "pastebin_tb", - "InvalidModeException"] + "complete_match", "get_victim", "get_nick", "InvalidModeException"] # message either privmsg or notice, depending on user settings def pm(cli, target, message): if is_fake_nick(target) and botconfig.DEBUG_MODE: @@ -463,31 +457,6 @@ def get_nick(cli, nick): return None return ul[ull.index(lnick)] -def pastebin_tb(context, msg, exc, prefix): - try: - bot_id = re.sub(r"[^A-Za-z0-9-]", "-", botconfig.NICK) - bot_id = re.sub(r"--+", "-", bot_id) - bot_id = re.sub(r"^-+|-+$", "", bot_id) - - rand_id = "".join(random.sample(string.ascii_letters + string.digits, 8)) - - api_url = "https://ptpb.pw/~{0}-error-{1}".format(bot_id, rand_id) - - req = urllib.request.Request(api_url, urllib.parse.urlencode({ - "c": exc, # contents - "s": 86400 # expiry (seconds) - }).encode("utf-8", "replace")) - - req.add_header("Accept", "application/json") - resp = urllib.request.urlopen(req) - data = json.loads(resp.read().decode("utf-8")) - url = data["url"] + "/pytb" - except Exception: - # Exception is already printed before calling this function, don't print twice - context.send(msg + " (Unable to pastebin traceback; please check the console.)", prefix=prefix) - else: - context.send(" ".join((msg, url)), prefix=prefix) - class InvalidModeException(Exception): pass # vim: set sw=4 expandtab: