def __init__(self): self._app_api = None self._client_api = None # Create reply argument_builder self.reply_argument_builder = ReplyArgumentBuilder(self._get_twitter_application_api()) # Create new aggregator self.aggregator = TrafficViolationsAggregator() # Need to find if unanswered statuses have been deleted self.tweet_detection_service = TweetDetectionService() # Log how many times we've called the apis self._direct_messages_iteration = 0 self._events_iteration = 0 self._statuses_iteration = 0 self._lookup_threads = [] # Initialize cached values to None self._follower_ids: Optional[list[int]] = None self._follower_ids_last_fetched: Optional[datetime] = None
def __init__(self): self.tweet_detection_service = TweetDetectionService() self.eastern = pytz.timezone('US/Eastern') self.utc = pytz.timezone('UTC')
class TrafficViolationsAggregator: CAMERA_VIOLATIONS = [ 'Bus Lane Violation', 'Failure To Stop At Red Light', 'School Zone Speed Camera Violation' ] MYSQL_TIME_FORMAT: str = '%Y-%m-%d %H:%M:%S' UNIQUE_IDENTIFIER_STRING_LENGTH = 8 def __init__(self): self.tweet_detection_service = TweetDetectionService() self.eastern = pytz.timezone('US/Eastern') self.utc = pytz.timezone('UTC') def initiate_reply(self, lookup_request: Type[BaseLookupRequest]): LOG.info('Calling initiate_reply') if lookup_request.requires_response(): return self._create_response(lookup_request) def _create_repeat_lookup_string( self, new_violations: int, plate: str, state: str, username: str, previous_lookup: Optional[Dict[str, Any]] = None): violations_string = '' if new_violations > 0: # assume we can't link can_link_tweet = False # Where did this come from? if previous_lookup.message_source == lookup_sources.LookupSource.STATUS.value: # Determine if tweet is still visible: if self.tweet_detection_service.tweet_exists( id=previous_lookup.message_id, username=username): can_link_tweet = True # Determine when the last lookup was... previous_time = previous_lookup.created_at now = datetime.now() adjusted_time = self.utc.localize(previous_time) adjusted_now = self.utc.localize(now) # If at least five minutes have passed... if adjusted_now - timedelta(minutes=5) > adjusted_time: # Add the new ticket info and previous lookup time to the string. violations_string += L10N.LAST_QUERIED_STRING.format( adjusted_time.astimezone(self.eastern).strftime( L10N.REPEAT_LOOKUP_DATE_FORMAT), adjusted_time.astimezone(self.eastern).strftime( L10N.REPEAT_LOOKUP_TIME_FORMAT)) if can_link_tweet: violations_string += L10N.PREVIOUS_LOOKUP_STATUS_STRING.format( previous_lookup.username, previous_lookup.username, previous_lookup.message_id) else: violations_string += '.' violations_string += L10N.REPEAT_LOOKUP_STRING.format( L10N.VEHICLE_HASHTAG.format(state, plate), new_violations, L10N.pluralize(new_violations)) return violations_string def _create_response(self, request_object: Type[BaseLookupRequest]): LOG.info('\n') LOG.info('Calling create_response') # Grab tweet details for reply. LOG.debug(f'request_object: {request_object}') # Collect response parts here. response_parts = [] successful_lookup = False error_on_lookup = False # Wrap in try/catch block try: # Find potential plates potential_vehicles: List[Vehicle] = self._find_potential_vehicles( request_object.string_tokens()) LOG.debug(f'potential_vehicles: {potential_vehicles}') potential_vehicles += self._find_potential_vehicles_using_legacy_logic( request_object.legacy_string_tokens()) LOG.debug(f'potential_vehicles: {potential_vehicles}') potential_vehicles = self._ensure_unique_plates( vehicles=potential_vehicles) # Find included campaign hashtags included_campaigns: List[Campaign] = self._detect_campaigns( request_object.string_tokens()) LOG.debug(f'included_campaigns: {included_campaigns}') # for each vehicle, we need to determine if the supplied information amounts to a valid plate # then we need to look up each valid plate # then we need to respond in a single thread in order with the responses summary: TrafficViolationsAggregatorResponse = TrafficViolationsAggregatorResponse( ) for potential_vehicle in potential_vehicles: if potential_vehicle.valid_plate: plate_query: PlateQuery = self._get_plate_query( vehicle=potential_vehicle, request_object=request_object) # do we have a previous lookup previous_lookup: Optional[ PlateLookup] = self._query_for_previous_lookup( plate_query=plate_query) LOG.debug( f'Previous lookup for this vehicle: {previous_lookup}') # Obtain a unique identifier for the lookup unique_identifier: str = self._get_unique_identifier() # Do the real work! open_data_response: OpenDataServiceResponse = self._perform_plate_lookup( campaigns=included_campaigns, plate_query=plate_query, unique_identifier=unique_identifier) if open_data_response.success: # Record successful lookup. successful_lookup = True plate_lookup: OpenDataServicePlateLookup = open_data_response.data # how many times have we searched for this plate from a tweet current_frequency: int = self._query_for_lookup_frequency( plate_query) # self._proceess_lookup_results() # Add lookup to summary summary.plate_lookups.append(plate_lookup) if plate_lookup.violations: plate_lookup_response_parts: List[ Any] = self._form_plate_lookup_response_parts( borough_data=plate_lookup.boroughs, camera_streak_data=plate_lookup. camera_streak_data, fine_data=plate_lookup.fines, frequency=current_frequency, plate=plate_lookup.plate, plate_types=plate_lookup.plate_types, previous_lookup=previous_lookup, state=plate_lookup.state, username=request_object.username(), unique_identifier=unique_identifier, violations=plate_lookup.violations, year_data=plate_lookup.years) response_parts.append(plate_lookup_response_parts) # [[campaign_stuff], tickets_0, tickets_1, etc.] else: # Let user know we didn't find anything. plate_types_string = ( f' (types: {plate_query.plate_types})' ) if plate_lookup.plate_types else '' response_parts.append( L10N.NO_TICKETS_FOUND_STRING.format( plate_query.state, plate_lookup.plate, plate_types_string)) else: # Record lookup error. error_on_lookup = True response_parts.append([ f"Sorry, I received an error when looking up " f"{plate_query.state}:{plate_query.plate}" f"{(' (types: ' + plate_query.plate_types + ')') if plate_query.plate_types else ''}. " f"Please try again." ]) else: # Record the failed lookup. new_failed_lookup = FailedPlateLookup( message_id=request_object.external_id(), username=request_object.username()) # Insert plate lookup FailedPlateLookup.query.session.add(new_failed_lookup) FailedPlateLookup.query.session.commit() # Legacy data where state is not a valid abbreviation. if potential_vehicle.state: LOG.debug("We have a state, but it's invalid.") response_parts.append([ f"The state should be two characters, but you supplied '{potential_vehicle.state}'. " f"Please try again." ]) # '<state>:<plate>' format, but no valid state could be detected. elif potential_vehicle.original_string: LOG.debug( "We don't have a state, but we have an attempted lookup with the new format." ) response_parts.append([ f"Sorry, a plate and state could not be inferred from " f"{potential_vehicle.original_string}." ]) # If we have a plate, but no state. elif potential_vehicle.plate: LOG.debug("We have a plate, but no state") response_parts.append( ["Sorry, the state appears to be blank."]) # If we have multiple vehicles, prepend a summary. if len(summary.plate_lookups) > 1: summary_string: Optional[str] = self._form_summary_string( summary) if summary_string: response_parts.insert(0, summary_string) # Look up campaign hashtags after doing the plate lookups and then # prepend to response. if included_campaigns: campaign_lookups: List[Tuple[ str, int, int]] = self._perform_campaign_lookup(included_campaigns) response_parts.insert( 0, self._form_campaign_lookup_response_parts( campaign_lookups, request_object.username())) successful_lookup = True # If we don't look up a single plate successfully, # figure out how we can help the user. if not successful_lookup and not error_on_lookup: # Record the failed lookup. new_failed_lookup = FailedPlateLookup( message_id=request_object.external_id(), username=request_object.username()) # Insert plate lookup FailedPlateLookup.query.session.add(new_failed_lookup) FailedPlateLookup.query.session.commit() LOG.debug('The data seems to be in the wrong format.') state_matches = [ regexp_constants.STATE_ABBREVIATIONS_PATTERN.search( s.upper()) != None for s in request_object.string_tokens() ] number_matches = [ regexp_constants.NUMBER_PATTERN.search(s.upper()) != None for s in list( filter( lambda part: re.sub(r'\.|@', '', part.lower()) not in set(request_object.mentioned_users), request_object.string_tokens())) ] # We have what appears to be a plate and a state abbreviation. if all([any(state_matches), any(number_matches)]): LOG.debug( 'There is both plate and state information in this message.' ) # Let user know plate format response_parts.append([ "I’d be happy to look that up for you!\n\nJust a reminder, the format is <state|province|territory>:<plate>, e.g. NY:abc1234" ]) # Maybe we have plate or state. Let's find out. else: LOG.debug( 'The tweet is missing either state or plate or both.') state_minus_words_matches = [ regexp_constants.STATE_MINUS_WORDS_PATTERN.search( s.upper()) != None for s in request_object.string_tokens() ] number_matches = [ regexp_constants.NUMBER_PATTERN.search(s.upper()) != None for s in list( filter( lambda part: re.sub(r'\.|@', '', part.lower()) not in set(request_object.mentioned_users), request_object.string_tokens())) ] # We have either plate or state. if any(state_minus_words_matches) or any(number_matches): # Let user know plate format response_parts.append([ "I think you're trying to look up a plate, but can't be sure.\n\nJust a reminder, the format is <state|province|territory>:<plate>, e.g. NY:abc1234" ]) # We have neither plate nor state. Do nothing. else: LOG.debug( 'ignoring message since no plate or state information to respond to.' ) except Exception as e: # Set response data error_on_lookup = True response_parts.append( ["Sorry, I encountered an error. Tagging @bdhowald."]) # Log error LOG.error('Missing necessary information to continue') LOG.error(e) LOG.error(str(e)) LOG.error(e.args) logging.exception("stack trace") # Indicate successful response processing. return { 'error_on_lookup': error_on_lookup, 'request_object': request_object, 'response_parts': response_parts, 'success': True, 'successful_lookup': successful_lookup, 'username': request_object.username() } def _detect_campaigns(self, string_tokens) -> List[Campaign]: """ Look for campaign hashtags in the message's text and return matching campaigns. """ return Campaign.get_all_in(hashtag=tuple([ regexp_constants.HASHTAG_PATTERN.sub('', string) for string in string_tokens ])) def _detect_plate_types(self, plate_types_input) -> bool: if ',' in plate_types_input: parts = plate_types_input.upper().split(',') return any([ regexp_constants.PLATE_TYPES_PATTERN.search(part) != None for part in parts ]) else: return regexp_constants.PLATE_TYPES_PATTERN.search( plate_types_input.upper()) != None def _detect_state(self, state_input) -> bool: # or state_full_pattern.search(state_input.upper()) != None """ Does this input constitute a valid state abbreviation """ if state_input is not None: return regexp_constants.STATE_ABBREVIATIONS_PATTERN.search( state_input.upper()) != None return False def _ensure_unique_plates(self, vehicles: List[Vehicle]) -> List[Vehicle]: vehicle_dict: Dict[str, Vehicle] = {} unique_vehicles: List[Vehicle] = [] for vehicle in vehicles: lookup_string = ( f'{vehicle.state}:' f'{vehicle.plate}' f"{(':' + vehicle.plate_types) if vehicle.plate_types else ''}" ) if vehicle_dict.get(lookup_string) is None: vehicle_dict[lookup_string] = vehicle unique_vehicles.append(vehicle) return unique_vehicles def _find_potential_vehicles(self, list_of_strings: List[str]) -> List[Vehicle]: # Use new logic of '<state>:<plate>' plate_tuples: Union[Tuple[str, str, str], Tuple[str, str]] = [ [part.strip() for part in match.split(':')] for match in re.findall(regexp_constants.PLATE_FORMAT_REGEX, ' '.join(list_of_strings)) if all(substr not in match.lower() for substr in ['://', 'state:', 'plate:']) ] return self._infer_plate_and_state_data(plate_tuples) def _find_potential_vehicles_using_legacy_logic( self, list_of_strings: List[str]) -> List[Vehicle]: # Find potential plates # Use old logic of 'state:<state> plate:<plate>' potential_vehicles: List[Vehicle] = [] legacy_plate_data: Tuple = dict([[ piece.strip() for piece in match.split(':') ] for match in [ part.lower() for part in list_of_strings if (('state:' in part.lower() or 'plate:' in part.lower() or 'types:' in part.lower()) and '://' not in part.lower()) ]]) if legacy_plate_data: if self._detect_state(legacy_plate_data.get( 'state')) and legacy_plate_data.get('plate'): legacy_plate_data['valid_plate'] = True else: legacy_plate_data['valid_plate'] = False vehicle: Vehicle = Vehicle( plate=legacy_plate_data.get('plate'), plate_types=legacy_plate_data.get('types'), state=legacy_plate_data.get('state'), valid_plate=legacy_plate_data['valid_plate']) potential_vehicles.append(vehicle) return potential_vehicles def _form_campaign_lookup_response_parts( self, campaign_summaries: List[Tuple[str, int, int]], username: str): campaign_chunks: List[str] = [] campaign_string = "" for campaign in campaign_summaries: campaign_name = campaign[0] campaign_vehicles = campaign[1] campaign_tickets = campaign[2] next_string_part = ( f"{'{:,}'.format(campaign_vehicles)} {'vehicle with' if campaign_vehicles == 1 else 'vehicles with a total of'} " f"{'{:,}'.format(campaign_tickets)} ticket{L10N.pluralize(campaign_tickets)} {'has' if campaign_vehicles == 1 else 'have'} " f"been tagged with {campaign_name}.\n\n") # how long would it be potential_response_length = len(username + ' ' + campaign_string + next_string_part) if (potential_response_length <= twitter_constants.MAX_TWITTER_STATUS_LENGTH): campaign_string += next_string_part else: campaign_chunks.append(campaign_string) campaign_string = next_string_part # Get any part of string left over campaign_chunks.append(campaign_string) return campaign_chunks def _form_plate_lookup_response_parts( self, borough_data: List[Tuple[str, int]], frequency: int, fine_data: FineData, plate: str, plate_types: List[str], state: str, unique_identifier: str, username: str, violations: List[Tuple[str, int]], year_data: List[Tuple[str, int]], camera_streak_data: Optional[CameraStreakData] = None, previous_lookup: Optional[PlateLookup] = None): # response_chunks holds tweet-length-sized parts of the response # to be tweeted out or appended into a single direct message. response_chunks: List[str] = [] violations_string: str = "" # Get total violations total_violations: int = sum([s['count'] for s in violations]) LOG.debug(f'total_violations: {total_violations}') # Append to initially blank string to build tweet. violations_string += L10N.LOOKUP_SUMMARY_STRING.format( L10N.VEHICLE_HASHTAG.format(state, plate), L10N.get_plate_types_string(plate_types), frequency, L10N.pluralize(int(frequency))) # If this vehicle has been queried before... if previous_lookup: previous_num_violations: int = previous_lookup.num_tickets violations_string += self._create_repeat_lookup_string( new_violations=(total_violations - previous_num_violations), plate=plate, previous_lookup=previous_lookup, state=state, username=username) response_chunks += self._handle_response_part_formation( collection=violations, continued_format_string=L10N.LOOKUP_TICKETS_STRING_CONTD.format( L10N.VEHICLE_HASHTAG.format(state, plate)), count='count', cur_string=violations_string, description='title', default_description='No Year Available', prefix_format_string=L10N.LOOKUP_TICKETS_STRING.format( total_violations), result_format_string=L10N.LOOKUP_RESULTS_DETAIL_STRING, username=username) if year_data: response_chunks += self._handle_response_part_formation( collection=year_data, continued_format_string=L10N.LOOKUP_YEAR_STRING_CONTD.format( L10N.VEHICLE_HASHTAG.format(state, plate)), count='count', description='title', default_description='No Year Available', prefix_format_string=L10N.LOOKUP_YEAR_STRING.format( L10N.VEHICLE_HASHTAG.format(state, plate)), result_format_string=L10N.LOOKUP_RESULTS_DETAIL_STRING, username=username) if borough_data: response_chunks += self._handle_response_part_formation( collection=borough_data, continued_format_string=L10N.LOOKUP_BOROUGH_STRING_CONTD. format(L10N.VEHICLE_HASHTAG.format(state, plate)), count='count', description='title', default_description='No Borough Available', prefix_format_string=L10N.LOOKUP_BOROUGH_STRING.format( L10N.VEHICLE_HASHTAG.format(state, plate)), result_format_string=L10N.LOOKUP_RESULTS_DETAIL_STRING, username=username) if fine_data and fine_data.fines_assessed(): cur_string = f"Known fines for {L10N.VEHICLE_HASHTAG.format(state, plate)}:\n\n" max_count_length = len('${:,.2f}'.format(fine_data.max_amount())) spaces_needed = (max_count_length * 2) + 1 for fine_type, amount in fine_data: currency_string = '${:,.2f}'.format(amount) count_length = len(str(currency_string)) # e.g., if spaces_needed is 5, and count_length is 2, we need # to pad to 3. left_justify_amount = spaces_needed - count_length # formulate next string part next_part = (f"{currency_string.ljust(left_justify_amount)}| " f"{fine_type.replace('_', ' ').title()}\n") # determine current string length if necessary potential_response_length = len(username + ' ' + cur_string + next_part) # If username, space, violation string so far and new part are less or # equal than 280 characters, append to existing tweet string. if (potential_response_length <= twitter_constants.MAX_TWITTER_STATUS_LENGTH): cur_string += next_part else: response_chunks.append(cur_string) cur_string = "Known fines for #{}_{}, cont'd:\n\n" cur_string += next_part # add to container response_chunks.append(cur_string) if camera_streak_data: if camera_streak_data.max_streak and camera_streak_data.max_streak >= 5: # formulate streak string streak_string = ( f"Under @bradlander's proposed legislation, " f"this vehicle could have been booted or impounded " f"due to its {camera_streak_data.max_streak} camera violations " f"(>= 5/year) from {camera_streak_data.min_streak_date}" f" to {camera_streak_data.max_streak_date}.\n") # add to container response_chunks.append(streak_string) unique_link: str = self._get_website_plate_lookup_link( unique_identifier) website_link_string = f'View more details at {unique_link}.' response_chunks.append(website_link_string) # Send it back! return response_chunks def _form_summary_string( self, summary: TrafficViolationsAggregatorResponse) -> Optional[str]: num_vehicles = len(summary.plate_lookups) vehicle_tickets = [ len(lookup.violations) for lookup in summary.plate_lookups ] total_tickets = sum(vehicle_tickets) fines_by_vehicle: List[FineData] = [ lookup.fines for lookup in summary.plate_lookups ] vehicles_with_fines: int = len([ fine_data for fine_data in fines_by_vehicle if fine_data.fined > 0 ]) aggregate_fines: FineData = FineData( **{ field: sum( getattr(lookup.fines, field) for lookup in summary.plate_lookups) for field in FineData.FINE_FIELDS }) if aggregate_fines.fined > 0: return [ f"You queried {num_vehicles} vehicles, of which " f"{vehicles_with_fines} vehicle{L10N.pluralize(vehicles_with_fines)} " f"{'has' if vehicles_with_fines == 1 else 'have collectively'} received {total_tickets} ticket{L10N.pluralize(total_tickets)} " f"with at least {'${:,.2f}'.format(aggregate_fines.fined - aggregate_fines.reduced)} " f"in fines, of which {'${:,.2f}'.format(aggregate_fines.paid)} has been paid.\n\n" ] def _generate_unique_identifier(self): return ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(self.UNIQUE_IDENTIFIER_STRING_LENGTH)) def _get_unique_identifier(self): unique_identifier = self._generate_unique_identifier() while PlateLookup.query.filter( PlateLookup.unique_identifier == unique_identifier).all(): unique_identifier = self._generate_unique_identifier() return unique_identifier def _get_plate_query(self, request_object: Type[BaseLookupRequest], vehicle: Vehicle) -> PlateQuery: """Transform a request object into plate query""" created_at: str = datetime.strptime( request_object.created_at, twitter_constants.TWITTER_TIME_FORMAT).strftime( self.MYSQL_TIME_FORMAT) message_id: Optional[str] = request_object.external_id() message_source: str = request_object.message_source plate: str = regexp_constants.PLATE_PATTERN.sub( '', vehicle.plate.strip().upper()) plate_types: Optional[str] = None if vehicle.plate_types is not None: plate_types = ','.join( sorted([ type.strip() for type in vehicle.plate_types.upper().split(',') ])) state: str = vehicle.state.upper() username: Optional[str] = request_object.username() plate_query: PlateQuery = PlateQuery(created_at=created_at, message_id=message_id, message_source=message_source, plate=plate, plate_types=plate_types, state=state, username=username) LOG.debug(f'plate_query: {plate_query}') return plate_query def _get_website_plate_lookup_link(self, unique_identifier: str) -> str: return f'{endpoints.HOWS_MY_DRIVING_NY_WEBSITE}/{unique_identifier}' def _handle_response_part_formation(self, count: str, collection: Dict[str, Any], continued_format_string: str, description: str, default_description: str, prefix_format_string: str, result_format_string: str, username: str, cur_string: str = None): # collect the responses response_container = [] cur_string = cur_string if cur_string else '' if prefix_format_string: cur_string += prefix_format_string max_count_length = len(str(max(item[count] for item in collection))) spaces_needed = (max_count_length * 2) + 1 # Grab item for item in collection: # Titleize for readability. violation_description = item[description].title() # Use a default description if need be if len(violation_description) == 0: violation_description = default_description violation_count = item[count] count_length = len(str(violation_count)) # e.g., if spaces_needed is 5, and count_length is 2, we need to # pad to 3. left_justify_amount = spaces_needed - count_length # formulate next string part next_part = result_format_string.format( str(violation_count).ljust(left_justify_amount), violation_description) # determine current string length potential_response_length = len( f'{username} {cur_string}{next_part}') # If username, space, violation string so far and new part are less or # equal than 280 characters, append to existing tweet string. if (potential_response_length <= twitter_constants.MAX_TWITTER_STATUS_LENGTH): cur_string += next_part else: response_container.append(cur_string) if continued_format_string: cur_string = continued_format_string else: cur_string = '' cur_string += next_part # If we finish the list with a non-empty string, # append that string to response parts if len(cur_string) != 0: # Append ready string into parts for response. response_container.append(cur_string) # Return parts return response_container def _infer_plate_and_state_data( self, list_of_vehicle_tuples: Union[Tuple[str, str, str], Tuple[str, str]] ) -> List[Vehicle]: potential_vehicles: List[Vehicle] = [] for vehicle_tuple in list_of_vehicle_tuples: original_string = ':'.join(vehicle_tuple) plate = None plate_types = None state = None valid_plate = False if len(vehicle_tuple) in range(2, 4): state_bools: List[bool] = [ self._detect_state(part) for part in vehicle_tuple ] try: state_index = state_bools.index(True) except ValueError: state_index = None plate_types_bools: List[bool] = [ self._detect_plate_types(part) for part in vehicle_tuple ] try: plate_types_index = plate_types_bools.index(True) except ValueError: plate_types_index = None have_valid_plate: bool = ( len(vehicle_tuple) == 2 and state_index is not None) or ( len(vehicle_tuple) == 3 and None not in [plate_types_index, state_index]) if have_valid_plate: non_state_plate_types_parts = [ x for x in list(range(0, len(vehicle_tuple))) if x not in [plate_types_index, state_index] ] plate_index = None # We have a tuple with state and plate, and possibly plate # types if non_state_plate_types_parts: plate_index = non_state_plate_types_parts[0] # We don't seem to have a plate, which means the plate # types might be the plate elif plate_types_index is not None: alphanumeric_only = re.match( '^[\w-]+$', vehicle_tuple[plate_types_index]) is not None if alphanumeric_only: plate_index = plate_types_index plate_types_index = None # Put plate data together if plate_index is not None and vehicle_tuple[ plate_index] != '': plate = vehicle_tuple[plate_index] state = vehicle_tuple[state_index] if plate_types_index is not None: plate_types = vehicle_tuple[plate_types_index] valid_plate = True vehicle: Vehicle = Vehicle(original_string=original_string, plate=plate, plate_types=plate_types, state=state, valid_plate=valid_plate) potential_vehicles.append(vehicle) return potential_vehicles def _perform_campaign_lookup( self, included_campaigns: List[Campaign]) -> List[Tuple[str, int, int]]: LOG.debug('Performing lookup for campaigns.') result: List[Tuple[str, int, int]] = [] for campaign in included_campaigns: subquery = campaign.plate_lookups.session.query( PlateLookup.plate, PlateLookup.state, func.max(PlateLookup.created_at).label( 'most_recent_campaign_lookup'), ).group_by(PlateLookup.plate, PlateLookup.state).filter( and_(PlateLookup.campaigns.any(Campaign.id.in_([campaign.id])), PlateLookup.count_towards_frequency == True)).subquery( 'subquery') full_query = PlateLookup.query.join( subquery, (PlateLookup.plate == subquery.c.plate) & (PlateLookup.state == subquery.c.state) & (PlateLookup.created_at == subquery.c.most_recent_campaign_lookup)).order_by( subquery.c.most_recent_campaign_lookup.desc(), PlateLookup.created_at.desc()) campaign_lookups = full_query.all() campaign_vehicles: int = len(campaign_lookups) campaign_tickets: int = sum( [lookup.num_tickets for lookup in campaign_lookups]) result.append( (campaign.hashtag, campaign_vehicles, campaign_tickets)) return result def _perform_plate_lookup( self, campaigns: List[Campaign], plate_query: PlateQuery, unique_identifier: str) -> OpenDataServiceResponse: LOG.debug('Performing lookup for plate.') nyc_open_data_service: OpenDataService = OpenDataService() open_data_response: OpenDataServiceResponse = nyc_open_data_service.look_up_vehicle( plate_query=plate_query) LOG.debug(f'Violation data: {open_data_response}') if open_data_response.success: open_data_plate_lookup: OpenDataServicePlateLookup = open_data_response.data bus_lane_camera_violations = 0 red_light_camera_violations = 0 speed_camera_violations = 0 for violation_type_summary in open_data_plate_lookup.violations: if violation_type_summary['title'] in self.CAMERA_VIOLATIONS: violation_count = violation_type_summary['count'] if violation_type_summary['title'] == 'Bus Lane Violation': bus_lane_camera_violations = violation_count if violation_type_summary[ 'title'] == 'Failure To Stop At Red Light': red_light_camera_violations = violation_count elif violation_type_summary[ 'title'] == 'School Zone Speed Camera Violation': speed_camera_violations = violation_count camera_streak_data: CameraStreakData = open_data_plate_lookup.camera_streak_data # If this came from message, add it to the plate_lookups table. if plate_query.message_source and plate_query.message_id and plate_query.created_at: new_lookup = PlateLookup( boot_eligible=camera_streak_data.max_streak >= 5 if camera_streak_data else False, bus_lane_camera_violations=bus_lane_camera_violations, created_at=plate_query.created_at, message_id=plate_query.message_id, message_source=plate_query.message_source, num_tickets=open_data_plate_lookup.num_violations, plate=plate_query.plate, plate_types=plate_query.plate_types, red_light_camera_violations=red_light_camera_violations, speed_camera_violations=speed_camera_violations, state=plate_query.state, unique_identifier=unique_identifier, username=plate_query.username) # Iterate through included campaigns to tie lookup to each for campaign in campaigns: # insert join record for campaign lookup new_lookup.campaigns.append(campaign) # Insert plate lookup PlateLookup.query.session.add(new_lookup) PlateLookup.query.session.commit() else: LOG.info(f'open data plate lookup failed') return open_data_response def _proceess_lookup_results(self, plate_query: PlateLookup): pass def _query_for_lookup_frequency(self, plate_query: PlateQuery) -> int: return len( PlateLookup.get_all_by(plate=plate_query.plate, plate_types=plate_query.plate_types, state=plate_query.state, count_towards_frequency=True)) def _query_for_previous_lookup( self, plate_query: PlateQuery) -> Optional[PlateLookup]: """ See if we've seen this vehicle before. """ lookups_for_vehicle: List[PlateLookup] = PlateLookup.get_all_by( plate=plate_query.plate, state=plate_query.state, plate_types=plate_query.plate_types, count_towards_frequency=True) if lookups_for_vehicle: lookups_for_vehicle.sort(key=lambda x: x.created_at, reverse=True) return lookups_for_vehicle[0] else: return None
def perform(self, *args, **kwargs): is_dry_run: bool = kwargs.get('is_dry_run') or False use_dvaa_thresholds: bool = kwargs.get('use_dvaa_thresholds') or False use_only_visible_tweets: bool = kwargs.get('use_only_visible_tweets') or False tweet_detection_service = TweetDetectionService() tweeter = TrafficViolationsTweeter() eastern = pytz.timezone('US/Eastern') utc = pytz.timezone('UTC') now = datetime.now() # If today is leap day, there were no lookups a year ago today if (now.day == self.LEAP_DAY_DATE and now.month == self.LEAP_DAY_MONTH): return # If today is March 1, and last year was a leap year, show lookups # from the previous February 29. one_year_after_leap_day = True if ( now.day == self.POST_LEAP_DAY_DATE and now.month == self.POST_LEAP_DAY_MONTH and self._is_leap_year(now.year - 1)) else False top_of_the_hour_last_year = now.replace( microsecond=0, minute=0, second=0) - relativedelta(years=1) top_of_the_next_hour_last_year = ( top_of_the_hour_last_year + relativedelta(hours=1)) if use_dvaa_thresholds: threshold_attribute: str = 'boot_eligible_under_dvaa_threshold' else: threshold_attribute: str = 'boot_eligible_under_rdaa_threshold' base_query: list[Tuple[int]] = PlateLookup.query.session.query( func.max(PlateLookup.id).label('most_recent_vehicle_lookup') ).filter( or_( and_(PlateLookup.created_at >= top_of_the_hour_last_year, PlateLookup.created_at < top_of_the_next_hour_last_year, getattr(PlateLookup, threshold_attribute) == True, PlateLookup.count_towards_frequency == True), and_(one_year_after_leap_day, PlateLookup.created_at >= (top_of_the_hour_last_year - relativedelta(days=1)), PlateLookup.created_at < (top_of_the_next_hour_last_year - relativedelta(days=1)), getattr(PlateLookup, threshold_attribute) == True, PlateLookup.count_towards_frequency == True) ) ) if use_only_visible_tweets: base_query = base_query.filter( PlateLookup.message_source == lookup_sources.LookupSource.STATUS.value) recent_plate_lookup_ids = base_query.group_by( PlateLookup.plate, PlateLookup.state ).all() lookup_ids_to_update: list[int] = [id[0] for id in recent_plate_lookup_ids] lookups_to_update: list[PlateLookup] = PlateLookup.get_all_in( id=lookup_ids_to_update) if not lookups_to_update: LOG.debug(f'No vehicles for which to perform retrospective job ' f'between {top_of_the_hour_last_year} and ' f'and {top_of_the_next_hour_last_year}.') for previous_lookup in lookups_to_update: LOG.debug(f'Performing retrospective job for ' f'{L10N.VEHICLE_HASHTAG.format(previous_lookup.state, previous_lookup.plate)} ') plate_query: PlateQuery = PlateQuery(created_at=now, message_source=previous_lookup.message_source, plate=previous_lookup.plate, plate_types=previous_lookup.plate_types, state=previous_lookup.state) nyc_open_data_service: OpenDataService = OpenDataService() data_before_query: OpenDataServiceResponse = nyc_open_data_service.look_up_vehicle( plate_query=plate_query, until=previous_lookup.created_at) lookup_before_query: OpenDataServicePlateLookup = data_before_query.data camera_streak_data_before_query: CameraStreakData = lookup_before_query.camera_streak_data['Mixed'] data_after_query: OpenDataServiceResponse = nyc_open_data_service.look_up_vehicle( plate_query=plate_query, since=previous_lookup.created_at, until=previous_lookup.created_at + relativedelta(years=1)) lookup_after_query: OpenDataServicePlateLookup = data_after_query.data new_bus_lane_camera_violations: Optional[int] = None new_speed_camera_violations: Optional[int] = None new_red_light_camera_violations: Optional[int] = None for violation_type_summary in lookup_after_query.violations: if violation_type_summary['title'] in self.CAMERA_VIOLATIONS: violation_count = violation_type_summary['count'] if violation_type_summary['title'] == 'Bus Lane Violation': new_bus_lane_camera_violations = violation_count if violation_type_summary['title'] == 'Failure To Stop At Red Light': new_red_light_camera_violations = violation_count if violation_type_summary['title'] == 'School Zone Speed Camera Violation': new_speed_camera_violations = violation_count if new_bus_lane_camera_violations is None: new_bus_lane_camera_violations = 0 if new_red_light_camera_violations is None: new_red_light_camera_violations = 0 if new_speed_camera_violations is None: new_speed_camera_violations = 0 new_boot_eligible_violations = (new_red_light_camera_violations + new_speed_camera_violations) if new_boot_eligible_violations > 0: vehicle_hashtag = L10N.VEHICLE_HASHTAG.format( previous_lookup.state, previous_lookup.plate) previous_lookup_created_at = utc.localize( previous_lookup.created_at) previous_lookup_date = previous_lookup_created_at.astimezone(eastern).strftime( L10N.REPEAT_LOOKUP_DATE_FORMAT) previous_lookup_time = previous_lookup_created_at.astimezone(eastern).strftime( L10N.REPEAT_LOOKUP_TIME_FORMAT) red_light_camera_violations_string = ( f'{new_red_light_camera_violations} | Red Light Camera Violations\n' if new_red_light_camera_violations > 0 else '') speed_camera_violations_string = ( f'{new_speed_camera_violations} | Speed Safety Camera Violations\n' if new_speed_camera_violations > 0 else '') reckless_driver_summary_string = ( f'{vehicle_hashtag} was originally ' f'queried on {previous_lookup_date} ' f'at {previous_lookup_time}') # assume we can't link can_link_tweet = False # Where did this come from? if previous_lookup.message_source == lookup_sources.LookupSource.STATUS.value: # Determine if tweet is still visible: if tweet_detection_service.tweet_exists(id=previous_lookup.message_id, username=previous_lookup.username): can_link_tweet = True if can_link_tweet: reckless_driver_summary_string += L10N.PREVIOUS_LOOKUP_STATUS_STRING.format( previous_lookup.username, previous_lookup.username, previous_lookup.message_id) else: reckless_driver_summary_string += '.' if use_only_visible_tweets and not can_link_tweet: # If we're only displaying tweets we can quote tweet, # skip this one since we can'tt. continue reckless_driver_update_string = ( f'From {camera_streak_data_before_query.min_streak_date} to ' f'{camera_streak_data_before_query.max_streak_date}, this vehicle ' f'received {camera_streak_data_before_query.max_streak} camera ' f'violations. Over the past 12 months, this vehicle ' f'received {new_boot_eligible_violations} new camera violation' f"{'' if new_boot_eligible_violations == 1 else 's'}: \n\n" f'{red_light_camera_violations_string}' f'{speed_camera_violations_string}') messages: list[str] = [ reckless_driver_summary_string, reckless_driver_update_string] if not is_dry_run: success: bool = tweeter.send_status( message_parts=messages, on_error_message=( f'Error printing reckless driver update. ' f'Tagging @bdhowald.')) if success: LOG.debug('Reckless driver retrospective job ' 'ran successfully.') else: print(reckless_driver_update_string)
class TrafficViolationsTweeter: PRODUCTION_APP_RATE_LIMITING_INTERVAL_IN_SECONDS = 3.0 DEVELOPMENT_CLIENT_RATE_LIMITING_INTERVAL_IN_SECONDS = 3000.0 PRODUCTION_CLIENT_RATE_LIMITING_INTERVAL_IN_SECONDS = 300.0 FOLLOWERS_RATE_LIMITING_INTERVAL_IN_SECONDS = 15 * SECONDS_PER_MINUTE MAX_DIRECT_MESSAGES_RETURNED = 50 def __init__(self): self._app_api = None self._client_api = None # Create reply argument_builder self.reply_argument_builder = ReplyArgumentBuilder(self._get_twitter_application_api()) # Create new aggregator self.aggregator = TrafficViolationsAggregator() # Need to find if unanswered statuses have been deleted self.tweet_detection_service = TweetDetectionService() # Log how many times we've called the apis self._direct_messages_iteration = 0 self._events_iteration = 0 self._statuses_iteration = 0 self._lookup_threads = [] # Initialize cached values to None self._follower_ids: Optional[list[int]] = None self._follower_ids_last_fetched: Optional[datetime] = None def find_and_respond_to_requests(self) -> None: """Convenience method to collect the different ways TwitterEvent objects are created, found, and responded to and begin the process of calling these methods at process start. """ self._find_and_respond_to_missed_direct_messages() self._find_and_respond_to_missed_statuses() self._find_and_respond_to_twitter_events() def send_status(self, message_parts: Union[list[any], list[str]], on_error_message: str) -> bool: """Send statuses from @HowsMyDrivingNY""" try: self._recursively_process_status_updates(message_parts) return True except Exception as e: LOG.error(e) self._recursively_process_status_updates(on_error_message) return False def terminate_lookups(self) -> None: """Stop looking for twitter events, statuses, or direct messages to respond to.""" for thread in self._lookup_threads: thread.cancel() def _add_twitter_events_for_missed_direct_messages(self, messages: list[tweepy.models.Status]) -> None: """Creates TwitterEvent objects when the Account Activity API fails to send us direct message events via webhooks to have them created by the HowsMyDrivingNY API. :param messages: list[tweepy.DirectMessage]: The direct messages returned via the Twitter Search API via Tweepy. """ undetected_messages = 0 sender_ids = set(int(message.message_create['sender_id']) for message in messages) sender_objects = self._get_twitter_client_api().lookup_users(user_id=sender_ids) senders = {sender.id_str:sender for sender in sender_objects} for message in messages: existing_event: Optional[TwitterEvent] = TwitterEvent.query.filter(TwitterEvent.event_id == message.id).first() if not existing_event and int(message.message_create['sender_id']) != HMDNY_TWITTER_USER_ID: undetected_messages += 1 sender = senders[message.message_create['sender_id']] event: TwitterEvent = TwitterEvent( event_type=TwitterMessageType.DIRECT_MESSAGE.value, event_id=message.id, user_handle=sender.screen_name, user_id=sender.id, event_text=message.message_create['message_data']['text'], created_at=message.created_timestamp, in_reply_to_message_id=None, location=None, user_mention_ids=','.join([user['id_str'] for user in message.message_create['message_data']['entities']['user_mentions']]), user_mentions=' '.join([user['screen_name'] for user in message.message_create['message_data']['entities']['user_mentions']]), detected_via_account_activity_api=False) TwitterEvent.query.session.add(event) TwitterEvent.query.session.commit() LOG.debug( f"Found {undetected_messages} direct message{'' if undetected_messages == 1 else 's'} that " f"{'was' if undetected_messages == 1 else 'were'} previously undetected.") def _add_twitter_events_for_missed_statuses(self, messages: list[tweepy.models.Status]) -> None: """Creates TwitterEvent objects when the Account Activity API fails to send us status events via webhooks to have them created by the HowsMyDrivingNY API. :param messages: list[tweepy.models.Status]: The statuses returned via the Twitter Search API via Tweepy. """ undetected_messages = 0 for message in messages: existing_event: Optional[TwitterEvent] = TwitterEvent.query.filter(TwitterEvent.event_id == message.id).first() if not existing_event and message.user.id != HMDNY_TWITTER_USER_ID: undetected_messages += 1 event: TwitterEvent = TwitterEvent( event_type=TwitterMessageType.STATUS.value, event_id=message.id, user_handle=message.user.screen_name, user_id=message.user.id, event_text=message.full_text, created_at=message.created_at.replace(tzinfo=pytz.timezone('UTC')).timestamp() * MILLISECONDS_PER_SECOND, in_reply_to_message_id=message.in_reply_to_status_id, location=message.place and message.place.full_name, user_mention_ids=','.join([user['id_str'] for user in message.entities['user_mentions']]), user_mentions=' '.join([user['screen_name'] for user in message.entities['user_mentions']]), detected_via_account_activity_api=False) TwitterEvent.query.session.add(event) TwitterEvent.query.session.commit() LOG.debug( f"Found {undetected_messages} status{'' if undetected_messages == 1 else 'es'} that " f"{'was' if undetected_messages == 1 else 'were'} previously undetected.") def _filter_failed_twitter_events(self, failed_events: list[TwitterEvent]) -> list[TwitterEvent]: failed_events_that_need_response: list[TwitterEvent] = [] for failed_event in failed_events: # If event is a tweet, but can no longer be found, there's nothing we can do. if (failed_event.event_type == TwitterMessageType.STATUS.value and not self.tweet_detection_service.tweet_exists(id=failed_event.event_id, username=failed_event.user_handle)): failed_event.error_on_lookup = False failed_event.num_times_failed = 0 failed_event.last_failed_at_time = None failed_event.query.session.commit() continue if failed_event.num_times_failed == 0: failed_events_that_need_response.append(failed_event) elif failed_event.num_times_failed == 1: time_to_retry = failed_event.last_failed_at_time + timedelta(minutes=5) if time_to_retry <= datetime.utcnow(): failed_events_that_need_response.append(failed_event) elif failed_event.num_times_failed == 2: time_to_retry = failed_event.last_failed_at_time + timedelta(hours=1) if time_to_retry <= datetime.utcnow(): failed_events_that_need_response.append(failed_event) elif failed_event.num_times_failed == 3: time_to_retry = failed_event.last_failed_at_time + timedelta(hours=3) if time_to_retry <= datetime.utcnow(): failed_events_that_need_response.append(failed_event) elif failed_event.num_times_failed == 4: time_to_retry = failed_event.last_failed_at_time + timedelta(days=1) if time_to_retry <= datetime.utcnow(): failed_events_that_need_response.append(failed_event) else: LOG.debug(f'Event response cannot be retried automatically.') LOG.debug(f'failed events to retry: {failed_events_that_need_response}') return failed_events_that_need_response def _find_and_respond_to_missed_direct_messages(self) -> None: """Uses Tweepy to call the Twitter Search API to find direct messages to/from HowsMyDrivingNY. It then passes this data to a function that creates TwitterEvent objects when those direct message events have not already been recorded. """ interval = (self.PRODUCTION_CLIENT_RATE_LIMITING_INTERVAL_IN_SECONDS if self._is_production() else self.DEVELOPMENT_CLIENT_RATE_LIMITING_INTERVAL_IN_SECONDS) self._direct_messages_iteration += 1 LOG.debug( f'Looking up missed direct messages on iteration {self._direct_messages_iteration}') # set up timer direct_message_thread = threading.Timer( interval, self._find_and_respond_to_missed_direct_messages) self._lookup_threads.append(direct_message_thread) # start timer direct_message_thread.start() try: # most_recent_undetected_twitter_event = TwitterEvent.query.filter( # and_(TwitterEvent.detected_via_account_activity_api == False, # TwitterEvent.event_type == TwitterMessageType.DIRECT_MESSAGE.value) # ).order_by(TwitterEvent.event_id.desc()).first() # Tweepy bug with cursors prevents us from searching for more than 50 events # at a time until 3.9, so it'll have to do. direct_messages_since_last_twitter_event = self._get_twitter_client_api( ).get_direct_messages( count=self.MAX_DIRECT_MESSAGES_RETURNED) received_messages = [message for message in direct_messages_since_last_twitter_event if int(message.message_create['sender_id']) != HMDNY_TWITTER_USER_ID] self._add_twitter_events_for_missed_direct_messages(messages=received_messages) except Exception as e: LOG.error(e) LOG.error(str(e)) LOG.error(e.args) logging.exception("stack trace") finally: TwitterEvent.query.session.close() def _find_and_respond_to_missed_statuses(self) -> None: """Uses Tweepy to call the Twitter Search API to find statuses mentioning HowsMyDrivingNY. It then passes this data to a function that creates TwitterEvent objects when those status events have not already been recorded. """ interval = (self.PRODUCTION_CLIENT_RATE_LIMITING_INTERVAL_IN_SECONDS if self._is_production() else self.DEVELOPMENT_CLIENT_RATE_LIMITING_INTERVAL_IN_SECONDS) self._statuses_iteration += 1 LOG.debug( f'Looking up missed statuses on iteration {self._statuses_iteration}') # set up timer statuses_thread = threading.Timer( interval, self._find_and_respond_to_missed_statuses) self._lookup_threads.append(statuses_thread) # start timer statuses_thread.start() try: # Find most recent undetected twitter status event, and then # search for recent events until we can find no more. most_recent_undetected_twitter_event = TwitterEvent.query.filter( and_(TwitterEvent.detected_via_account_activity_api == False, TwitterEvent.event_type == TwitterMessageType.STATUS.value) ).order_by(TwitterEvent.event_id.desc()).first() if most_recent_undetected_twitter_event: statuses_since_last_twitter_event: list[tweepy.models.Status] = [] max_status_id: Optional[int] = None while max_status_id is None or statuses_since_last_twitter_event: statuses_since_last_twitter_event = self._get_twitter_client_api( ).mentions_timeline( max_id=max_status_id, since_id=most_recent_undetected_twitter_event.event_id, tweet_mode='extended') if statuses_since_last_twitter_event: self._add_twitter_events_for_missed_statuses(statuses_since_last_twitter_event) max_status_id = statuses_since_last_twitter_event[-1].id - 1 else: max_status_id = most_recent_undetected_twitter_event.event_id - 1 except Exception as e: LOG.error(e) LOG.error(str(e)) LOG.error(e.args) logging.exception("stack trace") finally: TwitterEvent.query.session.close() def _find_and_respond_to_twitter_events(self) -> None: """Looks for TwitterEvent objects that have not yet been responded to and begins the process of creating a response. Additionally, failed events are rerun to provide a correct response, particularly useful in cases where external apis are down for maintenance. """ interval = ( self.PRODUCTION_APP_RATE_LIMITING_INTERVAL_IN_SECONDS if self._is_production() else self.DEVELOPMENT_CLIENT_RATE_LIMITING_INTERVAL_IN_SECONDS) self._events_iteration += 1 LOG.debug( f'Looking up twitter events on iteration {self._events_iteration}') # set up timer twitter_events_thread = threading.Timer( interval, self._find_and_respond_to_twitter_events) self._lookup_threads.append(twitter_events_thread) # start timer twitter_events_thread.start() try: new_events: list[TwitterEvent] = TwitterEvent.get_all_by( is_duplicate=False, responded_to=False, response_in_progress=False) LOG.debug(f'new events: {new_events}') failed_events: list[TwitterEvent] = TwitterEvent.get_all_by( is_duplicate=False, error_on_lookup=True, responded_to=True, response_in_progress=False) failed_events_that_need_response: list[TwitterEvent] = self._filter_failed_twitter_events(failed_events) events_to_respond_to: [list[TwitterEvent]] = new_events + failed_events_that_need_response LOG.debug(f'events to respond to: {events_to_respond_to}') for event in events_to_respond_to: self._process_twitter_event(event=event) except Exception as e: LOG.error(e) LOG.error(str(e)) LOG.error(e.args) logging.exception("stack trace") finally: TwitterEvent.query.session.close() def _get_twitter_application_api(self) -> twitter_api_wrapper.TwitterApplicationApiWrapper: """Set the application (non-client) api connection for this instance""" if not self._app_api: self._app_api = twitter_api_wrapper.TwitterApplicationApiWrapper().get_connection() return self._app_api def _get_twitter_client_api(self) -> twitter_api_wrapper.TwitterClientApiWrapper: """Set the client api connection for this instance""" if not self._client_api: self._client_api = twitter_api_wrapper.TwitterClientApiWrapper().get_connection() return self._client_api def _get_follower_ids(self): """Get list of followers from Twitter every 15 minutes. This is used to determine who should be prompted to like the reply tweet in order to trigger a response. If the cached value is older than 15 minutes, refetch. """ now = datetime.utcnow() # If cache is empty or stale, refetch. if not self._follower_ids_last_fetched or ( ((now - self._follower_ids_last_fetched).seconds > self.FOLLOWERS_RATE_LIMITING_INTERVAL_IN_SECONDS)): follower_ids = [] next_cursor: int = -1 while next_cursor: results, cursors = self._get_twitter_application_api().get_follower_ids(cursor=next_cursor) next_cursor = cursors[1] follower_ids += results # set follower_ids self._follower_ids = follower_ids # cache current time self._follower_ids_last_fetched = now # Return cached or fetched value. return self._follower_ids def _is_production(self): """Determines if we are running in production to see if we can create direct message and status responses. TODO: Come up with a better way to determine production environment. """ return os.getenv('ENV') == 'production' def _process_response(self, request_object: Type[BaseLookupRequest], response_parts: list[Any], successful_lookup: bool = False) -> Optional[int]: """Directs the response to a Twitter message, depending on whether or not the event is a direct message or a status. Statuses that mention HowsMyDrivingNY earn a favorite/like. """ message_source = request_object.message_source if request_object else None message_id = request_object.external_id() if request_object else None # Respond to user if message_source == LookupSource.DIRECT_MESSAGE.value: LOG.debug('responding as direct message') combined_message = self._recursively_compile_direct_messages( response_parts) LOG.debug(f'combined_message: {combined_message}') return self._send_direct_message(message=combined_message, recipient_id=request_object.user_id) elif message_source == LookupSource.STATUS.value: # If we have at least one successful lookup, favorite the status if successful_lookup: # Favorite every look-up from a status try: self._is_production() and self._get_twitter_application_api( ).create_favorite(message_id) # But don't crash on error except tweepy.error.TweepError as te: # There's no easy way to know if this status has already # been favorited pass LOG.debug('responding as status update') user_mention_ids = (request_object.mentioned_user_ids if request_object.mentioned_user_ids else None) return self._recursively_process_status_updates( response_parts=response_parts, message_id=message_id, user_mention_ids=user_mention_ids) else: LOG.error('Unkown message source. Cannot respond.') def _process_twitter_event(self, event: TwitterEvent): LOG.debug(f'Beginning response for event: {event.id}') # search for duplicates is_event_duplicate: bool = TwitterEvent.query.filter_by( event_type=event.event_type, event_id=event.event_id, user_handle=event.user_handle, responded_to=True ).filter( TwitterEvent.id != event.id).count() > 0 if is_event_duplicate: event.is_duplicate = True TwitterEvent.query.session.commit() LOG.info(f'Event {event.id} is a duplicate, skipping.') else: event.response_in_progress = True TwitterEvent.query.session.commit() try: message_source: str = LookupSource(event.event_type) # build request lookup_request: Type[BaseLookupRequest] = self.reply_argument_builder.build_reply_data( message=event, message_source=message_source) user_is_follower: bool = lookup_request.requesting_user_is_follower( follower_ids=self._get_follower_ids()) perform_lookup_for_user: bool = (user_is_follower or event.user_favorited_non_follower_reply) if self.aggregator.lookup_has_valid_plates( lookup_request=lookup_request) and not perform_lookup_for_user: response_parts: list[Any] if lookup_request.is_direct_message(): response_parts = [L10N.NON_FOLLOWER_DIRECT_MESSAGE_REPLY_STRING] elif lookup_request.is_status(): response_parts = [L10N.NON_FOLLOWER_TWEET_REPLY_STRING] try: reply_message_id = self._process_response( request_object=lookup_request, response_parts=response_parts) # Save the reply id, so that when the user favorites it, # we can trigger the search. non_follower_reply = NonFollowerReply( created_at=(int(datetime.utcnow().timestamp() * MILLISECONDS_PER_SECOND)), event_type=event.event_type, event_id=reply_message_id, in_reply_to_message_id=event.event_id, user_handle=event.user_handle, user_id=event.user_id) NonFollowerReply.query.session.add( non_follower_reply) NonFollowerReply.query.session.commit() except tweepy.error.TweepError as e: event.error_on_lookup = True event.num_times_failed += 1 event.last_failed_at_time = datetime.utcnow() else: # Reply to the event. reply_to_event = self.aggregator.initiate_reply( lookup_request=lookup_request) success = reply_to_event['success'] if success: # There's no need to tell people that # there was an error more than once. if not (reply_to_event[ 'error_on_lookup'] and event.error_on_lookup): try: self._process_response( request_object=reply_to_event['request_object'], response_parts=reply_to_event['response_parts'], successful_lookup=reply_to_event.get('successful_lookup')) except tweepy.error.TweepError as e: reply_to_event['error_on_lookup'] = True # Update error status if reply_to_event['error_on_lookup']: event.error_on_lookup = True event.num_times_failed += 1 event.last_failed_at_time = datetime.utcnow() else: event.error_on_lookup = False event.num_times_failed = 0 event.last_failed_at_time = None # We've responded! event.response_in_progress = False event.responded_to = True TwitterEvent.query.session.commit() except ValueError as e: LOG.error( f'Encountered unknown event type. ' f'Response is not possible.') def _recursively_compile_direct_messages(self, response_parts): """Direct message responses from the aggregator return lists of chunked information (by violation type, by borough, by year, etc.). Data look like: [ [data_type_1_part_1, data_type_1_part_2, data_type_1_part_3], [data_type_2_part_1, data_type_2_part_2], [data_type_3_part_1, data_type_3_part_2, data_type_3_part_3], ] This ensures that the data is grouped with like data. Using recursion, the final message is built into one large message. """ return_message = [] # Iterate through all response parts for part in response_parts: if isinstance(part, list): return_message.append( self._recursively_compile_direct_messages(part)) else: return_message.append(part) return '\n'.join(return_message) def _recursively_process_status_updates(self, response_parts: Union[list[any], list[str]], message_id: Optional[int] = None, user_mention_ids: Optional[list[str]] = None) -> Optional[int]: """Status responses from the aggregator return lists of chunked information (by violation type, by borough, by year, etc.). Data look like: [ [data_type_1_part_1, data_type_1_part_2, data_type_1_part_3], [data_type_2_part_1, data_type_2_part_2], [data_type_3_part_1, data_type_3_part_2, data_type_3_part_3], ] This ensures that the data is grouped with like data. Using recursion, response statuses are created, then their message ids are saved to be used as the in_reply_to_status_id for the next status. """ # Iterate through all response parts for part in response_parts: # Some may be lists themselves if isinstance(part, list): message_id = self._recursively_process_status_updates( response_parts=part, message_id=message_id, user_mention_ids=user_mention_ids) else: if self._is_production(): new_message = self._get_twitter_application_api( ).update_status( status=part, in_reply_to_status_id=message_id, exclude_reply_user_ids=user_mention_ids) message_id = new_message.id LOG.debug(f'message_id: {message_id}') else: LOG.debug( "This is where 'self._get_twitter_application_api()" ".update_status(status=part, in_reply_to_status_id=message_id, " "exclude_reply_user_ids=user_mention_ids)' " "would be called in production.") return None return message_id def _send_direct_message(self, message: str, recipient_id: int) -> Optional[int]: """Send a direct message to a Twitter user.""" if self._is_production(): new_message = self._get_twitter_application_api().send_direct_message( recipient_id=recipient_id, text=message) return new_message.id else: LOG.debug( "This is where 'self._get_twitter_application_api()" ".send_direct_message(recipient_id=recipient_id, " "text=message)' would be called in production.") return None