WelcomeBot.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import logging
  2. import time
  3. import re
  4. import praw
  5. import yaml
  6. from rapidfuzz import fuzz
  7. from string import Template
  8. from praw.exceptions import APIException
  9. from . import models
  10. SUBREDDIT_NAME = 'Piracy'
  11. SETTINGS_FILE = 'settings/WelcomeBot_settings.yaml'
  12. # hours * seconds per hour
  13. # max time for submission existance before the bot rejects approval
  14. MAX_TIME_ALLOWANCE = 8 * 3600
  15. logger = logging.getLogger('main')
  16. console_logger = logging.getLogger('console_logger')
  17. class WelcomeBot:
  18. def __init__(self, reddit):
  19. self.reddit = reddit
  20. self.settings = {}
  21. self._load_settings()
  22. def handle_comment(self, comment):
  23. if comment.subreddit.display_name != SUBREDDIT_NAME:
  24. return
  25. user = models.RedditUser.objects.get_or_create(name=comment.author.name)[0]
  26. if user.has_generally_interacted:
  27. return
  28. console_logger.info(f'Welcoming user: {user.name}')
  29. self._message_user(
  30. comment.author, self.settings['WELCOME_MESSAGE_SUBJECT_TITLE'], self.settings['WELCOME_MESSAGE'])
  31. self._update_user(user)
  32. def handle_submission(self, submission):
  33. if submission.subreddit.display_name != SUBREDDIT_NAME:
  34. return
  35. # refresh post in case automoderator is delayed in applying its own removal rules
  36. time.sleep(2)
  37. submission = self.reddit.submission(id=submission.id)
  38. if (submission.banned_by is not None
  39. or submission.approved
  40. or submission.author is None):
  41. return
  42. user = models.RedditUser.objects.get_or_create(name=submission.author.name)[0]
  43. # move forward with removal
  44. if not user.has_previously_submitted and self.is_link_flair_remove_worthy(submission.link_flair_text):
  45. logger.info(
  46. f'Processing submission by new user - {submission.author.name} - https://reddit.com{submission.permalink}')
  47. self._update_user(user)
  48. self._remove_submission(submission, user)
  49. # skip removal of submission and just welcome the user
  50. elif not user.has_generally_interacted:
  51. console_logger.info(f'Welcoming user: ${submission.author.name}')
  52. self._update_user(user, include_submissions=True)
  53. self._message_user(submission.author,
  54. self.settings['WELCOME_MESSAGE_SUBJECT_TITLE'], self.settings['WELCOME_MESSAGE'])
  55. def _remove_submission(self, submission, user):
  56. """Attempt to remove submission by new user
  57. Args:
  58. submission (praw.models.Submission): praw submission instance
  59. user (models.RedditUser): database record instance of user
  60. """
  61. removal_message = Template(self.settings['REMOVAL_MESSAGE']).safe_substitute(
  62. REMOVED_POST_PERMALINK=submission.permalink,
  63. REQUIRED_REPLY=self.settings['REQUIRED_REPLY']
  64. )
  65. success = self._message_user(submission.author, self.settings['REMOVAL_MESSAGE_SUBJECT_TITLE'], removal_message)
  66. if not success:
  67. self._update_user(user, include_submissions=True)
  68. return
  69. submission.mod.remove()
  70. def handle_message(self, message):
  71. valid_message, first_message = self._check_message_validity(message)
  72. if not valid_message:
  73. return
  74. logger.info(f' >> Processing agreement message by user: {message.author.name}')
  75. submission_url = re.search(self.settings['PERMALINK_FIRST_MESSAGE_RE'], first_message.body).group(1)
  76. submission = self.reddit.submission(url=submission_url)
  77. self._process_submission_approval(submission, submission_url, message)
  78. def _check_message_validity(self, message):
  79. """Checks whether message is not a comment reply and if message is in reply to the bot's original removal message
  80. and if the user's message contains the required keywords needed to approve their post
  81. Args:
  82. message (praw.models.Message): praw message instance
  83. Returns:
  84. tuple: tuple of (bool, str); The boolean indicates whether the message is a valid required response
  85. """
  86. if not isinstance(message, praw.models.Message):
  87. return False, ''
  88. is_agreement_message = False
  89. if (fuzz.partial_ratio(self.settings['REQUIRED_REPLY'], message.body, score_cutoff=90)
  90. or re.search(self.settings['REQUIRED_REPLY_RE'], message.body, re.IGNORECASE)):
  91. is_agreement_message = True
  92. # check for a previous message in conversation. If it fails, it means the message is a new conversation: ignore message
  93. try:
  94. first_message = self.reddit.inbox.message(message.first_message_name[3:])
  95. except:
  96. message.mark_read()
  97. if is_agreement_message:
  98. message.reply(
  99. 'There was an issue. You must reply to the original message instead of creating a new conversation')
  100. return False, ''
  101. is_reply_to_removal_message = False
  102. # check if identifier markdown comment is inside first message
  103. if 'identifier: removal message by WelcomeBot' in first_message.body:
  104. is_reply_to_removal_message = True
  105. if not is_reply_to_removal_message:
  106. return False, ''
  107. elif not is_agreement_message:
  108. if any(substr in message.body.lower() for substr in ['wtf', 'fuck']):
  109. logger.info(f' > Replying badly to {message.author.name}')
  110. message.reply('Villain, I have done thy mother')
  111. else:
  112. logger.info(f' > Replying nicely to {message.author.name}')
  113. message.reply('I am just a wee bot and you are using strange words. I do not understand')
  114. return False, ''
  115. return True, first_message
  116. def _process_submission_approval(self, submission, submission_url, message):
  117. """Upon the user replying to the bot for approval, this will help the bot decide
  118. if the submission if eligible for approval. eg. if the user replied within a set timeframe,
  119. or if another moderator has decided to remove the post (decline approval)
  120. Args:
  121. submission (praw.models.Submission): praw submission instance
  122. submission_url (str): full URL of submission
  123. message (praw.models.Message): praw message instance
  124. """
  125. # if message is deleted
  126. if submission.author is None:
  127. return
  128. link_flair_text = submission.link_flair_text
  129. if link_flair_text is None:
  130. link_flair_text = ''
  131. # if submission is approved already
  132. if submission.approved or submission.banned_by is None:
  133. message.reply(f'[Your submission]({submission_url}) is already visible to everyone.')
  134. # if reply is past the MAX_TIME_ALLOWANCE (seconds)
  135. elif time.time() - submission.created_utc > MAX_TIME_ALLOWANCE:
  136. SORRY_REPLY = Template(self.settings['SORRY_REPLY']).safe_substitute(
  137. MAX_TIME_ALLOWANCE=MAX_TIME_ALLOWANCE//3600)
  138. message.reply(SORRY_REPLY)
  139. # if someone else removed the post
  140. elif submission.banned_by is not None and submission.banned_by != self.reddit.user.me().name:
  141. message.reply(self.settings['OVERRIDE_UNAVAILABLE_REPLY'])
  142. # if post qualifies for bot approval
  143. elif link_flair_text.startswith(('custom removal', 'rule')):
  144. message.reply(
  145. 'Hello there. Your submission has been removed for spam/breaking the rules by another moderator. This bot cannot override their actions.')
  146. elif submission.banned_by == self.reddit.user.me().name:
  147. logger.info(
  148. f' >>> Approving submission by {submission.author.name}: https://reddit.com{submission.permalink}')
  149. submission.mod.approve()
  150. if submission.is_self:
  151. self._report_if_possible_rule3(submission)
  152. message.reply(
  153. Template(self.settings['ALLS_GOOD_REPLY']).safe_substitute(REMOVED_POST_PERMALINK=submission.permalink)
  154. )
  155. user = models.RedditUser.objects.get_or_create(name=submission.author.name)[0]
  156. self._update_user(user, include_submissions=True)
  157. def _report_if_possible_rule3(self, submission):
  158. """Sometimes the submission may be blatantly a rule-breaking post regardless of whether the user is told what the rules are
  159. this will help the bot to decide whether to report the submission after approval based on keywords for possible rule 3 breaking
  160. Args:
  161. submission (praw.models.Submission): praw submission instance
  162. """
  163. # reddit report reasons have a max char length of 95-100 i forgot
  164. max_length = 65
  165. for str_re in self.settings['POSSIBLE_RULE3_RE']:
  166. match = re.search(str_re, submission.title, re.IGNORECASE | re.DOTALL)
  167. if match:
  168. report_match = match.group()[:max_length]
  169. submission.report(f'Possible rule 3? - in_title: [{report_match}]')
  170. return
  171. match = re.search(str_re, submission.selftext, re.IGNORECASE | re.DOTALL)
  172. if match:
  173. report_match = match.group()[:max_length]
  174. submission.report(f'Possible rule 3? - in_body: [{report_match}]')
  175. return
  176. def is_link_flair_remove_worthy(self, link_flair_text) -> bool:
  177. if link_flair_text is None:
  178. link_flair_text = ''
  179. for flair_re in self.settings['FLAIR_TYPES_RE']:
  180. if re.search(flair_re, link_flair_text, re.IGNORECASE):
  181. return True
  182. return False
  183. @staticmethod
  184. def _message_user(user, subject, message) -> bool:
  185. """Sends a user a private message
  186. Args:
  187. user (praw.models.Redditor): praw redditor instance
  188. subject (str): subject line
  189. message (str): message body
  190. Returns:
  191. bool: A boolean indicating if the message was sent successfully
  192. """
  193. try:
  194. user.message(subject=subject, message=message)
  195. return True
  196. except APIException as e:
  197. if e.error_type == "NOT_WHITELISTED_BY_USER_MESSAGE":
  198. logger.info(f"User {user.name} has a whitelist, therefore there is no way to message them")
  199. else:
  200. logger.info(f"Error with attempt to send message: {e}")
  201. logger.exception(e)
  202. return False
  203. def _update_user(self, user: models.RedditUser, include_submissions=False):
  204. """update RedditUser record in database
  205. Args:
  206. user (models.RedditUser): database record instance of user
  207. include_submissions (bool, optional): Defaults to False.
  208. """
  209. user.has_generally_interacted = True
  210. if include_submissions:
  211. user.has_previously_submitted = True
  212. user.save()
  213. def _load_settings(self):
  214. with open(SETTINGS_FILE, 'r', encoding='utf8') as fd:
  215. self.settings = yaml.safe_load(fd)