messaging.py 8.1 KB

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