from dataclasses import dataclass from enum import Enum, auto BANGS = ('!',) def pop_arg(text): """ Pop an argument from a string of text. The text is split at the first substring containing only whitespace characters, ignoring leading and trailing whitespace of the string, with the text preceeding being returned as the first value, and the text following being returned as the second value. Both return values will be stripped of leading and trailing whitespace. If the given text is None, both return values will be None. If there is no text following the split point, the second return value will be None. """ if text is None: return None, None text = text.strip() parts = text.split(maxsplit=1) if len(parts) == 1: return parts[0], None return parts[0], parts[1] @dataclass class RollbotMessage: """ A data class modeling a message that was received by a Rollbot. This class is used both for mundane messages and for commands. Init Fields: src - a string describing the source of the message, usually GROUPME name - the plaintext display name of the sender of the message sender_id - the service-specific id of the sender, which will not change group_id - the id of the "group" the message was sent to, which can mean different concepts depending on src message_id - the service-specific unique id of the message message_txt - the raw, full text of the message from_admin - a boolean flag denoting if the sender has admin privileges Derived Fields: is_command - a boolean flag denoting if the message is a command. This will be true if message_txt begins with a "!" character followed by one or more non-whitespace characters (with whitespace between the bang and the first non-whitespace character being ignored) raw_command - the raw text of the command, i.e., the first "word" after the bang, with leading and trailing whitespace removed. This field will only be present if is_command is True command - raw_command normalized to lower case. This field will only be present if is_command is True raw_args - the raw text of the arguments following command, i.e., the remaining ontent of message_txt, with leading and trailing whitespace removed. This field will only be present if is_command is True """ src: str name: str sender_id: str group_id: str message_id: str message_txt: str from_admin: bool def __post_init__(self): self.is_command = False self._subc_memo = None if self.message_txt is not None and len(self.message_txt) > 0 and self.message_txt[0] in BANGS: cmd, raw = pop_arg(self.message_txt[1:].strip()) if cmd is not None: self.is_command = True self.raw_command = cmd self.command = cmd.lower() self.raw_args = raw self._arg_list_memo = None @staticmethod def from_subcommand(msg): if msg._subc_memo is None: msg._subc_memo = RollbotMessage( msg.src, msg.name, msg.sender_id, msg.group_id, msg.message_id, msg.raw_args, # this strips the command of the old message msg.from_admin ) if msg._subc_memo.is_command: return msg._subc_memo # silently return None if a subcommand could not be made @staticmethod def from_groupme(msg, global_admins=(), group_admins={}): sender_id = msg["sender_id"] group_id = msg["group_id"] return RollbotMessage( "GROUPME", msg["name"], sender_id, group_id, msg["id"], msg["text"].strip(), sender_id in global_admins or ( group_id in group_admins and sender_id in group_admins[group_id]) ) @staticmethod def from_discord(msg, global_admins=(), group_admins={}): sender_id = str(msg.author.id) group_id = str(msg.channel.id) return RollbotMessage( "DISCORD", msg.author.name, sender_id, group_id, msg.id, msg.content.strip(), sender_id in global_admins or ( group_id in group_admins and sender_id in group_admins[group_id]) ) def args(self, normalize=True): """ Lazily pop arguments from the raw argument string of this message and yield them one at a time as a generator. If the optional normalize parameter is set to False, the arguments will be returned exactly as they appear in the message, and if normalize is set to True or omitted, the arguments will be converted to lower case. For details on argument "popping", see the rollbot.pop_arg function. Behavior is undefined if this method is called on a message whose is_command field is false. """ arg, rest = pop_arg(self.raw_args) while arg is not None: yield arg.lower() if normalize else arg arg, rest = pop_arg(rest) def arg_list(self): """ Take the raw argument string of this message and split it on any sequence of one or more whitespace characters, and return the result. This can be useful to pass to an argparse.ArgumentParser. Behavior is undefined if this method is called on a message whose is_command field is false. """ if self._arg_list_memo is None: self._arg_list_memo = self.raw_args.split() return self._arg_list_memo class RollbotFailureException(BaseException): def __init__(self, failure): super().__init__() self.failure = failure class RollbotFailure(Enum): INVALID_COMMAND = auto() MISSING_SUBCOMMAND = auto() INVALID_SUBCOMMAND = auto() INVALID_ARGUMENTS = auto() SERVICE_DOWN = auto() PERMISSIONS = auto() INTERNAL_ERROR = auto() def get_debugging(self): debugging = {} reason = getattr(self, "reason", None) if reason is not None: debugging["explain"] = reason exception = getattr(self, "exception", None) if exception is not None: debugging["exception"] = exception return debugging def with_reason(self, reason): self.reason = reason return self def with_exception(self, exception): self.exception = exception return self def raise_exc(self): raise RollbotFailureException(self) _RESPONSE_TEMPLATE = """Response{ Original Message: %s, Text Response: %s, Image Response: %s, Respond: %s, Failure Reason: %s, Failure Notes: %s }""" @dataclass class RollbotResponse: msg: RollbotMessage txt: str = None img: str = None respond: bool = True failure: RollbotFailure = None debugging: dict = None def __post_init__(self): self.info = _RESPONSE_TEMPLATE % (self.msg, self.txt, self.img, self.respond, self.failure, self.debugging) self.is_success = self.failure is None if self.failure is None: self.failure_msg = None elif self.failure == RollbotFailure.INVALID_COMMAND: self.failure_msg = "Sorry - I don't think I understand the command '!%s'... " % self.msg.command \ + "I'll try to figure it out and get back to you!" elif self.failure == RollbotFailure.MISSING_SUBCOMMAND: self.failure_msg = "Sorry - !%s requires a sub-command." % self.msg.command elif self.failure == RollbotFailure.INVALID_SUBCOMMAND: self.failure_msg = "Sorry - the sub-command you used for %s was not valid." % self.msg.command elif self.failure == RollbotFailure.INVALID_ARGUMENTS: self.failure_msg = "Sorry - !%s cannot use those arguments!" % self.msg.command elif self.failure == RollbotFailure.SERVICE_DOWN: self.failure_msg = "Sorry - !%s relies on a service I couldn't reach!" % self.msg.command elif self.failure == RollbotFailure.PERMISSIONS: self.failure_msg = "Sorry - you don't have permission to use that command or sub-command in this chat!" elif self.failure == RollbotFailure.INTERNAL_ERROR: self.failure_msg = "Sorry - I encountered an unrecoverable error, please review internal logs." if self.debugging is not None and "explain" in self.debugging: self.failure_msg += " " + self.debugging["explain"]