messaging.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  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. if self.message_txt is not None and len(self.message_txt) > 0 and self.message_txt[0] in BANGS:
  60. cmd, raw = pop_arg(self.message_txt[1:].strip())
  61. if cmd is not None:
  62. self.is_command = True
  63. self.raw_command = cmd
  64. self.command = cmd.lower()
  65. self.raw_args = raw
  66. self._arg_list_thunk = None
  67. @staticmethod
  68. def from_subcommand(msg):
  69. subc = RollbotMessage(
  70. msg.src,
  71. msg.name,
  72. msg.sender_id,
  73. msg.group_id,
  74. msg.message_id,
  75. msg.raw_args, # this strips the command of the old message
  76. msg.from_admin
  77. )
  78. if subc.is_command:
  79. return subc
  80. # silently return None if a subcommand could not be made
  81. @staticmethod
  82. def from_groupme(msg, global_admins=(), group_admins={}):
  83. sender_id = msg["sender_id"]
  84. group_id = msg["group_id"]
  85. return RollbotMessage(
  86. "GROUPME",
  87. msg["name"],
  88. sender_id,
  89. group_id,
  90. msg["id"],
  91. msg["text"].strip(),
  92. sender_id in global_admins or (
  93. group_id in group_admins and
  94. sender_id in group_admins[group_id])
  95. )
  96. @staticmethod
  97. def from_discord(msg, global_admins=(), group_admins={}):
  98. sender_id = str(msg.author.id)
  99. group_id = str(msg.channel.id)
  100. return RollbotMessage(
  101. "DISCORD",
  102. msg.author.name,
  103. sender_id,
  104. group_id,
  105. msg.id,
  106. msg.content.strip(),
  107. sender_id in global_admins or (
  108. group_id in group_admins and
  109. sender_id in group_admins[group_id])
  110. )
  111. def args(self, normalize=True):
  112. """
  113. Lazily pop arguments from the raw argument string of this message
  114. and yield them one at a time as a generator. If the optional
  115. normalize parameter is set to False, the arguments will
  116. be returned exactly as they appear in the message, and if normalize
  117. is set to True or omitted, the arguments will be converted to lower
  118. case.
  119. For details on argument "popping", see the rollbot.pop_arg function.
  120. Behavior is undefined if this method is called on a message whose
  121. is_command field is false.
  122. """
  123. arg, rest = pop_arg(self.raw_args)
  124. while arg is not None:
  125. yield arg.lower() if normalize else arg
  126. arg, rest = pop_arg(rest)
  127. def arg_list(self):
  128. """
  129. Take the raw argument string of this message and split it on any
  130. sequence of one or more whitespace characters, and return the result.
  131. This can be useful to pass to an argparse.ArgumentParser.
  132. Behavior is undefined if this method is called on a message whose
  133. is_command field is false.
  134. """
  135. if self._arg_list_thunk is None:
  136. self._arg_list_thunk = self.raw_args.split()
  137. return self._arg_list_thunk
  138. class RollbotFailureException(BaseException):
  139. def __init__(self, failure):
  140. super().__init__()
  141. self.failure = failure
  142. class RollbotFailure(Enum):
  143. INVALID_COMMAND = auto()
  144. MISSING_SUBCOMMAND = auto()
  145. INVALID_SUBCOMMAND = auto()
  146. INVALID_ARGUMENTS = auto()
  147. SERVICE_DOWN = auto()
  148. PERMISSIONS = auto()
  149. INTERNAL_ERROR = auto()
  150. def get_debugging(self):
  151. debugging = {}
  152. reason = getattr(self, "reason", None)
  153. if reason is not None:
  154. debugging["explain"] = reason
  155. exception = getattr(self, "exception", None)
  156. if exception is not None:
  157. debugging["exception"] = exception
  158. return debugging
  159. def with_reason(self, reason):
  160. self.reason = reason
  161. return self
  162. def with_exception(self, exception):
  163. self.exception = exception
  164. return self
  165. def raise_exc(self):
  166. raise RollbotFailureException(self)
  167. _RESPONSE_TEMPLATE = """Response{
  168. Original Message: %s,
  169. Text Response: %s,
  170. Image Response: %s,
  171. Respond: %s,
  172. Failure Reason: %s,
  173. Failure Notes: %s
  174. }"""
  175. @dataclass
  176. class RollbotResponse:
  177. msg: RollbotMessage
  178. txt: str = None
  179. img: str = None
  180. respond: bool = True
  181. failure: RollbotFailure = None
  182. debugging: dict = None
  183. def __post_init__(self):
  184. self.info = _RESPONSE_TEMPLATE % (self.msg, self.txt, self.img, self.respond, self.failure, self.debugging)
  185. self.is_success = self.failure is None
  186. if self.failure is None:
  187. self.failure_msg = None
  188. elif self.failure == RollbotFailure.INVALID_COMMAND:
  189. self.failure_msg = "Sorry - I don't think I understand the command '!%s'... " % self.msg.command \
  190. + "I'll try to figure it out and get back to you!"
  191. elif self.failure == RollbotFailure.MISSING_SUBCOMMAND:
  192. self.failure_msg = "Sorry - !%s requires a sub-command." % self.msg.command
  193. elif self.failure == RollbotFailure.INVALID_SUBCOMMAND:
  194. self.failure_msg = "Sorry - the sub-command you used for %s was not valid." % self.msg.command
  195. elif self.failure == RollbotFailure.INVALID_ARGUMENTS:
  196. self.failure_msg = "Sorry - !%s cannot use those arguments!" % self.msg.command
  197. elif self.failure == RollbotFailure.SERVICE_DOWN:
  198. self.failure_msg = "Sorry - !%s relies on a service I couldn't reach!" % self.msg.command
  199. elif self.failure == RollbotFailure.PERMISSIONS:
  200. self.failure_msg = "Sorry - you don't have permission to use that command or sub-command in this chat!"
  201. elif self.failure == RollbotFailure.INTERNAL_ERROR:
  202. self.failure_msg = "Sorry - I encountered an unrecoverable error, please review internal logs."
  203. if self.debugging is not None and "explain" in self.debugging:
  204. self.failure_msg += " " + self.debugging["explain"]