def process_override(reply, cfg): """ This process is for moderators of ToR to force u/transcribersofreddit to mark a post as complete and award flair when the bot refutes a `done` claim. The comment containing "!override" must be in response to the bot's comment saying that it cannot find the transcript. :param reply: the comment reply object from the moderator. :param cfg: the global config object. :return: None. """ # don't remove this check, it's not covered like other admin_commands # because it's used in reply to people, not as a PM if not from_moderator(reply, cfg): reply.reply(_(random.choice(cfg.no_gifs))) logging.info( f'{reply.author.name} just tried to override. Lolno.' ) return # okay, so the parent of the reply should be the bot's comment # saying it can't find it. In that case, we need the parent's # parent. That should be the comment with the `done` call in it. reply_parent = cfg.r.comment(id=clean_id(reply.parent_id)) parents_parent = cfg.r.comment(id=clean_id(reply_parent.parent_id)) if 'done' in parents_parent.body.lower(): logging.info( f'Starting validation override for post {parents_parent.fullname}, ' f'approved by {reply.author.name}' ) process_done( parents_parent, cfg, override=True )
def process_wrong_post_location(post): transcript_on_tor_post = i18n['responses']['general'][ 'transcript_on_tor_post'] try: post.reply(_(transcript_on_tor_post)) except praw.exceptions.APIException: logging.debug( 'Something went wrong with asking about a misplaced post; ' 'ignoring.')
def process_thanks(post, cfg): thumbs_up_gifs = i18n['urls']['thumbs_up_gifs'] youre_welcome = i18n['responses']['general']['youre_welcome'] try: post.reply(_(youre_welcome.format(random.choice(thumbs_up_gifs)))) except praw.exceptions.APIException as e: if e.error_type == 'DELETED_COMMENT': logging.debug('Comment requiring thanks was deleted') return raise
def process_mention(mention): """ Handles username mentions and handles the formatting and posting of those calls as workable jobs to ToR. :param mention: the Comment object containing the username mention. :return: None. """ i18n = translation() pm_subject = i18n['responses']['direct_message']['subject'] pm_body = i18n['responses']['direct_message']['body'] # message format is subject, then body mention.author.message(pm_subject, _(pm_body)) logging.info(f'Message sent to {mention.author.name}!')
def process_debug(user: Redditor, blossom_submission: Dict, cfg) -> Tuple[str, None]: # TODO: turn this into a decorator # don't remove this check, it's not covered like other admin_commands # because it's used in reply to people, not as a PM if not is_moderator(user.name, cfg): logging.info(f"{user.name} just tried to override. Lolno.") return _(random.choice(cfg.no_gifs)), None # format for reddit by putting four spaces at the beginning of each line message = "\n".join([ " {}".format(i) for i in json.dumps(blossom_submission, indent=4).split("\n") ]) return message, None
def request_transcription(post: PostSummary, content_type: str, content_format: str, cfg: Config) -> None: """Request a transcription by posting the provided post to our subreddit.""" title = i18n["posts"]["discovered_submit_title"].format( sub=str(post["subreddit"]), type=content_type.title(), title=truncate_title(cleanup_post_title(str(post["title"]))), ) permalink = i18n["urls"]["reddit_url"].format(str(post["permalink"])) submission = cfg.tor.submit(title=title, url=permalink) intro = i18n["posts"]["rules_comment"].format( post_type=content_type, formatting=content_format, header=cfg.header, ) submission.reply(_(intro)) flair_post(submission, flair.unclaimed) create_blossom_submission(post, submission, cfg)
def process_mention(mention: Comment) -> None: """ Handles username mentions and handles the formatting and posting of those calls as workable jobs to ToR. :param mention: the Comment object containing the username mention. :return: None. """ try: pm_subject = i18n["responses"]["direct_message"]["subject"] pm_body = i18n["responses"]["direct_message"]["body"] # message format is subject, then body mention.author.message(pm_subject, _(pm_body)) log.info(f"Message sent to {mention.author.name}!") except (ClientException, AttributeError): # apparently this crashes with an AttributeError if someone # calls the bot and immediately deletes their comment. This # should fix that. pass
def process_override(user: Redditor, blossom_submission: Dict, parent_id: str, cfg): """ This process is for moderators of ToR to force u/transcribersofreddit to mark a post as complete and award flair when the bot refutes a `done` claim. The comment containing "!override" must be in response to the bot's comment saying that it cannot find the transcript. :param user: The user requesting the override :param blossom_submission: The relevant Submission of Blossom :param parent_id: The ID of the parent comment of the override :param cfg: the global config object. """ # TODO: turn this into a decorator # don't remove this check, it's not covered like other admin_commands # because it's used in reply to people, not as a PM if not is_moderator(user.name, cfg): logging.info(f"{user.name} just tried to override. Lolno.") return _(random.choice(cfg.no_gifs)), None # okay, so the parent of the reply should be the bot's comment # saying it can't find it. In that case, we need the parent's # parent. That should be the comment with the `done` call in it. reply_parent = cfg.r.comment(id=clean_id(parent_id)) grandparent = cfg.r.comment(id=clean_id(reply_parent.parent_id)) if grandparent.body.lower() in (CLAIM_PHRASES + DONE_PHRASES): logging.info( f"Starting validation override for post {grandparent.fullname}, " f"approved by {user.name}") return process_done(grandparent.author, blossom_submission, grandparent, cfg, override=True) return "Cannot process - no target comment found.", None
def process_claim(post, cfg, first_time=False): """ Handles comment replies containing the word 'claim' and routes based on a basic decision tree. :param post: The Comment object containing the claim. :param cfg: the global config dict. :return: None. """ top_parent = get_parent_post_id(post, cfg.r) already_claimed = i18n['responses']['claim']['already_claimed'] claim_already_complete = i18n['responses']['claim']['already_complete'] please_accept_coc = i18n['responses']['general']['coc_not_accepted'] if first_time: claim_success = i18n['responses']['claim']['first_claim_success'] else: claim_success = i18n['responses']['claim']['success'] # WAIT! Do we actually own this post? if top_parent.author.name not in __BOT_NAMES__: logging.debug('Received `claim` on post we do not own. Ignoring.') return try: if not coc_accepted(post, cfg): # do not cache this page. We want to get it every time. post.reply( _(please_accept_coc.format(get_wiki_page('codeofconduct', cfg)))) return # this can be either '' or None depending on how the API is feeling # today if top_parent.link_flair_text in ['', None]: # There exists the very small possibility that the post was # malformed and doesn't actually have flair on it. In that case, # let's set something so the next part doesn't crash. flair_post(top_parent, flair.unclaimed) if flair.unclaimed in top_parent.link_flair_text: # need to get that "Summoned - Unclaimed" in there too post.reply(_(claim_success)) flair_post(top_parent, flair.in_progress) logging.info( f'Claim on ID {top_parent.fullname} by {post.author} successful' ) # can't claim something that's already claimed elif top_parent.link_flair_text == flair.in_progress: post.reply(_(already_claimed)) elif top_parent.link_flair_text == flair.completed: post.reply(_(claim_already_complete)) except praw.exceptions.APIException as e: if e.error_type == 'DELETED_COMMENT': logging.info( f'Comment attempting to claim ID {top_parent.fullname} has ' f'been deleted. Back up for grabs! ') return raise # Re-raise exception if not
def process_unclaim(post, cfg): # Sometimes people need to unclaim things. Usually this happens because of # an issue with the post itself, like it's been locked or deleted. Either # way, we should probably be able to handle it. # Process: # If the post has been reported, then remove it. No checks, just do it. # If the post has not been reported, attempt to load the linked post. # If the linked post is still up, then reset the flair on ToR's side # and reply to the user. # If the linked post has been taken down or deleted, then remove the post # on ToR's side and reply to the user. top_parent = post.submission unclaim_failure_post_already_completed = i18n['responses']['unclaim'][ 'post_already_completed'] unclaim_still_unclaimed = i18n['responses']['unclaim']['still_unclaimed'] unclaim_success = i18n['responses']['unclaim']['success'] unclaim_success_with_report = i18n['responses']['unclaim'][ 'success_with_report'] unclaim_success_without_report = i18n['responses']['unclaim'][ 'success_without_report'] # WAIT! Do we actually own this post? if top_parent.author.name not in __BOT_NAMES__: logging.info('Received `unclaim` on post we do not own. Ignoring.') return if flair.unclaimed in top_parent.link_flair_text: post.reply(_(unclaim_still_unclaimed)) return for item in top_parent.user_reports: if (reports.original_post_deleted_or_locked in item[0] or reports.post_violates_rules in item[0]): top_parent.mod.remove() send_to_modchat( 'Removed the following reported post in response to an ' '`unclaim`: {}'.format(top_parent.shortlink), cfg, channel='removed_posts') post.reply(_(unclaim_success_with_report)) return # Okay, so they commented with unclaim, but they didn't report it. # Time to check to see if they should have. linked_resource = cfg.r.submission(top_parent.id_from_url(top_parent.url)) if is_removed(linked_resource): top_parent.mod.remove() send_to_modchat( 'Received `unclaim` on an unreported post, but it looks like it ' 'was removed on the parent sub. I removed ours here: {}' ''.format(top_parent.shortlink), cfg, channel='removed_posts') post.reply(_(unclaim_success_without_report)) return # Finally, if none of the other options apply, we'll reset the flair and # continue on as normal. if top_parent.link_flair_text == flair.completed: post.reply(_(unclaim_failure_post_already_completed)) return if top_parent.link_flair_text == flair.in_progress: flair_post(top_parent, flair.unclaimed) post.reply(_(unclaim_success)) return
def process_done(post, cfg, override=False, alt_text_trigger=False): """ Handles comments where the user says they've completed a post. Also includes a basic decision tree to enable verification of the posts to try and make sure they actually posted a transcription. :param post: the Comment object which contains the string 'done'. :param cfg: the global config object. :param override: A parameter that can only come from process_override() and skips the validation check. :param alt_text_trigger: a trigger that adds an extra piece of text onto the response. Just something to help ease the number of false-positives. :return: None. """ top_parent = get_parent_post_id(post, cfg.r) done_cannot_find_transcript = i18n['responses']['done'][ 'cannot_find_transcript'] done_completed_transcript = i18n['responses']['done'][ 'completed_transcript'] done_still_unclaimed = i18n['responses']['done']['still_unclaimed'] # WAIT! Do we actually own this post? if top_parent.author.name not in __BOT_NAMES__: logging.info('Received `done` on post we do not own. Ignoring.') return try: if flair.unclaimed in top_parent.link_flair_text: post.reply(_(done_still_unclaimed)) elif top_parent.link_flair_text == flair.in_progress: if not override and not verified_posted_transcript(post, cfg): # we need to double-check these things to keep people # from gaming the system logging.info( f'Post {top_parent.fullname} does not appear to have a ' f'post by claimant {post.author}. Hrm... ') # noinspection PyUnresolvedReferences try: post.reply(_(done_cannot_find_transcript)) except praw.exceptions.ClientException as e: # We've run into an issue where someone has commented and # then deleted the comment between when the bot pulls mail # and when it processes comments. This should catch that. # Possibly should look into subclassing praw.Comment.reply # to include some basic error handling of this so that # we can fix it throughout the application. logging.warning(e) return # Control flow: # If we have an override, we end up here to complete. # If there is no override, we go into the validation above. # If the validation fails, post the apology and return. # If the validation succeeds, come down here. if override: logging.info('Moderator override starting!') # noinspection PyUnresolvedReferences try: if alt_text_trigger: post.reply( _('I think you meant `done`, so here we go!\n\n' f'{done_completed_transcript}')) else: post.reply(_(done_completed_transcript)) update_user_flair(post, cfg) logging.info( f'Post {top_parent.fullname} completed by {post.author}!') # get that information saved for the user author = User(str(post.author), cfg.redis) author.list_update('posts_completed', clean_id(post.fullname)) author.save() except praw.exceptions.ClientException: # If the butt deleted their comment and we're already this # far into validation, just mark it as done. Clearly they # already passed. logging.info(f'Attempted to mark post {top_parent.fullname} ' f'as done... hit ClientException.') flair_post(top_parent, flair.completed) cfg.redis.incr('total_completed', amount=1) except praw.exceptions.APIException as e: if e.error_type == 'DELETED_COMMENT': logging.info( f'Comment attempting to mark ID {top_parent.fullname} ' f'as done has been deleted') return raise # Re-raise exception if not
def process_post(new_post, cfg): """ After a valid post has been discovered, this handles the formatting and posting of those calls as workable jobs to ToR. :param new_post: Submission object that needs to be posted. :param cfg: the config object. :return: None. """ id_already_handled_in_db = i18n['debug']['id_already_handled_in_db'] discovered_submit_title = i18n['posts']['discovered_submit_title'] rules_comment = i18n['posts']['rules_comment'] yt_already_has_transcripts = i18n['posts']['yt_already_has_transcripts'] if new_post['subreddit'] in cfg.upvote_filter_subs: # ignore posts if they don't meet the threshold for karma and the sub # is in our list of upvoted filtered ones if new_post['ups'] < cfg.upvote_filter_subs[new_post['subreddit']]: return if not is_valid(new_post['name'], cfg): logging.debug(id_already_handled_in_db.format(new_post['name'])) return if new_post['archived']: return if new_post['author'] is None: # we don't want to handle deleted posts, that's just silly return logging.info( f'Posting call for transcription on ID {new_post["name"]} posted by ' f'{new_post["author"]}' ) if new_post['domain'] in cfg.image_domains: content_type = 'image' content_format = cfg.image_formatting elif new_post['domain'] in cfg.audio_domains: content_type = 'audio' content_format = cfg.audio_formatting elif new_post['domain'] in cfg.video_domains: if 'youtu' in new_post['domain']: if not valid_youtube_video(new_post['url']): add_complete_post_id(new_post['name'], cfg) return if get_yt_transcript(new_post['url']): np = cfg.r.submission(id=new_post['name']) np.reply(_( yt_already_has_transcripts )) add_complete_post_id(new_post['name'], cfg) logging.info( f'Found YouTube video, {get_yt_video_id(new_post["url"])},' f' with good transcripts.' ) return content_type = 'video' content_format = cfg.video_formatting else: # This means we pulled from a subreddit bypassing the filters. content_type = 'Other' content_format = cfg.other_formatting # Truncate a post title if it exceeds 250 characters, so the added # formatting still fits in Reddit's 300 char limit for post titles post_title = new_post['title'] max_title_length = 250 if len(post_title) > max_title_length: post_title = post_title[:max_title_length - 3] + '...' # noinspection PyBroadException try: result = cfg.tor.submit( title=discovered_submit_title.format( sub=new_post['subreddit'], type=content_type.title(), title=post_title ), url=reddit_url.format(new_post['permalink']) ) result.reply( _( rules_comment.format( post_type=content_type, formatting=content_format, header=cfg.header ) ) ) flair_post(result, flair.unclaimed) add_complete_post_id(new_post['name'], cfg) cfg.redis.incr('total_posted', amount=1) if cfg.OCR and content_type == 'image': # hook for OCR bot; in order to avoid race conditions, we add the # key / value pair that the bot isn't looking for before adding # to the set that it's monitoring. cfg.redis.set(new_post['name'], result.fullname) cfg.redis.rpush('ocr_ids', new_post['name']) cfg.redis.incr('total_new', amount=1) # The only errors that happen here are on Reddit's side -- pretty much # exclusively 503s and 403s that arbitrarily resolve themselves. A missed # post or two is not the end of the world. except Exception as e: logging.error( f'{e} - unable to post content.\nID: {new_post["name"]}\n ' f'Title: {new_post["title"]}\n Subreddit: ' f'{new_post["subreddit"]}' )