messaging.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. from dataclasses import dataclass
  2. from enum import Enum, auto
  3. BANGS = ('!',)
  4. def pop_arg(text):
  5. """
  6. Pop an argument from a string of text. The text is split at the first
  7. substring containing only whitespace characters, ignoring leading and
  8. trailing whitespace of the string, with the text preceeding being
  9. returned as the first value, and the text following being returned
  10. as the second value.
  11. Both return values will be stripped of leading and trailing whitespace.
  12. If the given text is None, both return values will be None. If there is
  13. no text following the split point, the second return value will be None.
  14. """
  15. if text is None:
  16. return None, None
  17. text = text.strip()
  18. parts = text.split(maxsplit=1)
  19. if len(parts) == 1:
  20. return parts[0], None
  21. return parts[0], parts[1]
  22. @dataclass
  23. class RollbotMessage:
  24. """
  25. A data class modeling a message that was received by a Rollbot.
  26. This class is used both for mundane messages and for commands.
  27. Init Fields:
  28. src - a string describing the source of the message, usually GROUPME
  29. name - the plaintext display name of the sender of the message
  30. sender_id - the service-specific id of the sender, which will not change
  31. group_id - the id of the "group" the message was sent to, which can
  32. mean different concepts depending on src
  33. message_id - the service-specific unique id of the message
  34. message_txt - the raw, full text of the message
  35. from_admin - a boolean flag denoting if the sender has admin privileges
  36. Derived Fields:
  37. is_command - a boolean flag denoting if the message is a command. This
  38. will be true if message_txt begins with a "!" character followed by
  39. one or more non-whitespace characters (with whitespace between the
  40. bang and the first non-whitespace character being ignored)
  41. raw_command - the raw text of the command, i.e., the first "word" after
  42. the bang, with leading and trailing whitespace removed. This field
  43. will only be present if is_command is True
  44. command - raw_command normalized to lower case. This field will only be
  45. present if is_command is True
  46. raw_args - the raw text of the arguments following command, i.e., the
  47. remaining ontent of message_txt, with leading and trailing whitespace
  48. removed. This field will only be present if is_command is True
  49. """
  50. src: str
  51. name: str
  52. sender_id: str
  53. group_id: str
  54. message_id: str
  55. message_txt: str
  56. from_admin: bool
  57. def __post_init__(self):
  58. self.is_command = False
  59. self._subc_memo = None
  60. if self.message_txt is not None and len(self.message_txt) > 0 and self.message_txt[0] in BANGS:
  61. cmd, raw = pop_arg(self.message_txt[1:].strip())
  62. if cmd is not None:
  63. self.is_command = True
  64. self.raw_command = cmd
  65. self.command = cmd.lower()
  66. self.raw_args = raw
  67. self._arg_list_memo = None
  68. @staticmethod
  69. def from_subcommand(msg):
  70. if msg._subc_memo is None:
  71. msg._subc_memo = RollbotMessage(
  72. msg.src,
  73. msg.name,
  74. msg.sender_id,
  75. msg.group_id,
  76. msg.message_id,
  77. msg.raw_args, # this strips the command of the old message
  78. msg.from_admin
  79. )
  80. if msg._subc_memo.is_command:
  81. return msg._subc_memo
  82. # silently return None if a subcommand could not be made
  83. @staticmethod
  84. def from_groupme(msg, global_admins=(), group_admins={}):
  85. sender_id = msg["sender_id"]
  86. group_id = msg["group_id"]
  87. return RollbotMessage(
  88. "GROUPME",
  89. msg["name"],
  90. sender_id,
  91. group_id,
  92. msg["id"],
  93. msg["text"].strip(),
  94. sender_id in global_admins or (
  95. group_id in group_admins and
  96. sender_id in group_admins[group_id])
  97. )
  98. @staticmethod
  99. def from_discord(msg, global_admins=(), group_admins={}):
  100. sender_id = str(msg.author.id)
  101. group_id = str(msg.channel.id)
  102. return RollbotMessage(
  103. "DISCORD",
  104. msg.author.name,
  105. sender_id,
  106. group_id,
  107. msg.id,
  108. msg.content.strip(),
  109. sender_id in global_admins or (
  110. group_id in group_admins and
  111. sender_id in group_admins[group_id])
  112. )
  113. def args(self, normalize=True):
  114. """
  115. Lazily pop arguments from the raw argument string of this message
  116. and yield them one at a time as a generator. If the optional
  117. normalize parameter is set to False, the arguments will
  118. be returned exactly as they appear in the message, and if normalize
  119. is set to True or omitted, the arguments will be converted to lower
  120. case.
  121. For details on argument "popping", see the rollbot.pop_arg function.
  122. Behavior is undefined if this method is called on a message whose
  123. is_command field is false.
  124. """
  125. arg, rest = pop_arg(self.raw_args)
  126. while arg is not None:
  127. yield arg.lower() if normalize else arg
  128. arg, rest = pop_arg(rest)
  129. def arg_list(self):
  130. """
  131. Take the raw argument string of this message and split it on any
  132. sequence of one or more whitespace characters, and return the result.
  133. This can be useful to pass to an argparse.ArgumentParser.
  134. Behavior is undefined if this method is called on a message whose
  135. is_command field is false.
  136. """
  137. if self._arg_list_memo is None:
  138. self._arg_list_memo = self.raw_args.split()
  139. return self._arg_list_memo
  140. class RollbotFailureException(BaseException):
  141. def __init__(self, failure):
  142. super().__init__()
  143. self.failure = failure
  144. class RollbotFailure(Enum):
  145. INVALID_COMMAND = auto()
  146. MISSING_SUBCOMMAND = auto()
  147. INVALID_SUBCOMMAND = auto()
  148. INVALID_ARGUMENTS = auto()
  149. SERVICE_DOWN = auto()
  150. PERMISSIONS = auto()
  151. INTERNAL_ERROR = auto()
  152. def get_debugging(self):
  153. debugging = {}
  154. reason = getattr(self, "reason", None)
  155. if reason is not None:
  156. debugging["explain"] = reason
  157. exception = getattr(self, "exception", None)
  158. if exception is not None:
  159. debugging["exception"] = exception
  160. return debugging
  161. def with_reason(self, reason):
  162. self.reason = reason
  163. return self
  164. def with_exception(self, exception):
  165. self.exception = exception
  166. return self
  167. def raise_exc(self):
  168. raise RollbotFailureException(self)
  169. _RESPONSE_TEMPLATE = """Response{
  170. Original Message: %s,
  171. Text Response: %s,
  172. Image Response: %s,
  173. Respond: %s,
  174. Failure Reason: %s,
  175. Failure Notes: %s
  176. }"""
  177. @dataclass
  178. class RollbotResponse:
  179. msg: RollbotMessage
  180. txt: str = None
  181. img: str = None
  182. respond: bool = True
  183. failure: RollbotFailure = None
  184. debugging: dict = None
  185. def __post_init__(self):
  186. self.info = _RESPONSE_TEMPLATE % (self.msg, self.txt, self.img, self.respond, self.failure, self.debugging)
  187. self.is_success = self.failure is None
  188. if self.failure is None:
  189. self.failure_msg = None
  190. elif self.failure == RollbotFailure.INVALID_COMMAND:
  191. self.failure_msg = "Sorry - I don't think I understand the command '!%s'... " % self.msg.command \
  192. + "I'll try to figure it out and get back to you!"
  193. elif self.failure == RollbotFailure.MISSING_SUBCOMMAND:
  194. self.failure_msg = "Sorry - !%s requires a sub-command." % self.msg.command
  195. elif self.failure == RollbotFailure.INVALID_SUBCOMMAND:
  196. self.failure_msg = "Sorry - the sub-command you used for %s was not valid." % self.msg.command
  197. elif self.failure == RollbotFailure.INVALID_ARGUMENTS:
  198. self.failure_msg = "Sorry - !%s cannot use those arguments!" % self.msg.command
  199. elif self.failure == RollbotFailure.SERVICE_DOWN:
  200. self.failure_msg = "Sorry - !%s relies on a service I couldn't reach!" % self.msg.command
  201. elif self.failure == RollbotFailure.PERMISSIONS:
  202. self.failure_msg = "Sorry - you don't have permission to use that command or sub-command in this chat!"
  203. elif self.failure == RollbotFailure.INTERNAL_ERROR:
  204. self.failure_msg = "Sorry - I encountered an unrecoverable error, please review internal logs."
  205. if self.debugging is not None and "explain" in self.debugging:
  206. self.failure_msg += " " + self.debugging["explain"]