def __init__(self, instance_name, testing=TESTING_NONE): """ Read config and set up logging, settings database, geocoding and Twitter OAuth """ # Instance name is something like 'whensmybus', 'whensmytube' self.instance_name = instance_name # Try opening the file first just to see if it exists, exception caught below try: config_file = 'config.cfg' open(HOME_DIR + '/' + config_file) config = ConfigParser.SafeConfigParser({'debug_level': 'INFO', 'yahoo_app_id': None, 'silent_mode' : 0 }) config.read(HOME_DIR + '/' + config_file) config.get(self.instance_name, 'debug_level') except (ConfigParser.Error, IOError): error_string = "Fatal error: can't find a valid config file for %s." % self.instance_name error_string += " Please make sure there is a %s file in this directory" % config_file raise RuntimeError(error_string) # Setup debugging debug_level = config.get(self.instance_name, 'debug_level') setup_logging(self.instance_name, testing, debug_level) if testing == TESTING_TEST_LOCAL_DATA: logging.info("In TEST MODE - No Tweets will be made and local test data will be used!") elif testing == TESTING_TEST_LIVE_DATA: logging.info("In TEST MODE - No Tweets will be made! Will be using LIVE TfL data") # Name of the admin so we know who to alert if there is an issue self.admin_name = config.get(self.instance_name, 'admin_name') # Setup browser for JSON & XML self.browser = WMTBrowser() self.urls = WMTURLProvider(use_test_data=(testing == TESTING_TEST_LOCAL_DATA)) # These get overridden by subclasses self.geodata = None self.parser = None # Setup geocoder for looking up place names self.geocoder = GoogleGeocoder() # Setup Twitter client # Silent mode is true if we are testing, or if we are live but the user has overridden # in the config file silent_mode = testing if silent_mode == TESTING_NONE and config.get(self.instance_name, 'silent_mode'): silent_mode = config.get(self.instance_name, 'silent_mode') self.username = config.get(self.instance_name, 'username') consumer_key = config.get(self.instance_name, 'consumer_key') consumer_secret = config.get(self.instance_name, 'consumer_secret') access_token = config.get(self.instance_name, 'key') access_token_secret = config.get(self.instance_name, 'secret') self.twitter_client = WMTTwitterClient(self.instance_name, consumer_key, consumer_secret, access_token, access_token_secret, silent_mode) # The following can be overridden by child classes - whether to allow blank tweets, # and what the default route should be if none is given self.allow_blank_tweets = False self.default_requested_route = None
class WhensMyTransport: """ Parent class for all WhensMy* bots, with common functions shared by all """ __metaclass__ = ABCMeta def __init__(self, instance_name, testing=TESTING_NONE): """ Read config and set up logging, settings database, geocoding and Twitter OAuth """ # Instance name is something like 'whensmybus', 'whensmytube' self.instance_name = instance_name # Try opening the file first just to see if it exists, exception caught below try: config_file = 'config.cfg' open(HOME_DIR + '/' + config_file) config = ConfigParser.SafeConfigParser({'debug_level': 'INFO', 'yahoo_app_id': None, 'silent_mode' : 0 }) config.read(HOME_DIR + '/' + config_file) config.get(self.instance_name, 'debug_level') except (ConfigParser.Error, IOError): error_string = "Fatal error: can't find a valid config file for %s." % self.instance_name error_string += " Please make sure there is a %s file in this directory" % config_file raise RuntimeError(error_string) # Setup debugging debug_level = config.get(self.instance_name, 'debug_level') setup_logging(self.instance_name, testing, debug_level) if testing == TESTING_TEST_LOCAL_DATA: logging.info("In TEST MODE - No Tweets will be made and local test data will be used!") elif testing == TESTING_TEST_LIVE_DATA: logging.info("In TEST MODE - No Tweets will be made! Will be using LIVE TfL data") # Name of the admin so we know who to alert if there is an issue self.admin_name = config.get(self.instance_name, 'admin_name') # Setup browser for JSON & XML self.browser = WMTBrowser() self.urls = WMTURLProvider(use_test_data=(testing == TESTING_TEST_LOCAL_DATA)) # These get overridden by subclasses self.geodata = None self.parser = None # Setup geocoder for looking up place names self.geocoder = GoogleGeocoder() # Setup Twitter client # Silent mode is true if we are testing, or if we are live but the user has overridden # in the config file silent_mode = testing if silent_mode == TESTING_NONE and config.get(self.instance_name, 'silent_mode'): silent_mode = config.get(self.instance_name, 'silent_mode') self.username = config.get(self.instance_name, 'username') consumer_key = config.get(self.instance_name, 'consumer_key') consumer_secret = config.get(self.instance_name, 'consumer_secret') access_token = config.get(self.instance_name, 'key') access_token_secret = config.get(self.instance_name, 'secret') self.twitter_client = WMTTwitterClient(self.instance_name, consumer_key, consumer_secret, access_token, access_token_secret, silent_mode) # The following can be overridden by child classes - whether to allow blank tweets, # and what the default route should be if none is given self.allow_blank_tweets = False self.default_requested_route = None def check_tweets(self): """ Check incoming Tweets, and reply to them """ tweets = self.twitter_client.fetch_tweets() logging.debug("%s Tweets to process", len(tweets)) for tweet in tweets: # If the Tweet is not valid (e.g. not directly addressed, from ourselves) then skip it if not self.validate_tweet(tweet): continue # Try processing the Tweet. This may fail with a WhensMyTransportException for a number of reasons, in which # case we catch the exception and process an apology accordingly. Other Python Exceptions may occur too - we handle # these by DMing the admin with an alert try: replies = self.process_tweet(tweet) except WhensMyTransportException as exc: replies = (exc.get_user_message(),) except Exception as exc: logging.error("Exception encountered: %s", exc.__class__.__name__) logging.error("Traceback:\r\n%s" % traceback.format_exc()) self.alert_admin_about_exception(tweet, exc.__class__.__name__) replies = (WhensMyTransportException('unknown_error').get_user_message(),) # If the reply is blank, probably didn't contain a bus number or Tube line, so check to see if there was a thank-you if not replies: replies = self.check_politeness(tweet) # Send a reply back, if we have one. DMs and @ replies have different structures and different handlers for reply in replies: if is_direct_message(tweet): self.twitter_client.send_reply_back(reply, tweet.sender.screen_name, True, tweet.id) else: self.twitter_client.send_reply_back(reply, tweet.user.screen_name, False, tweet.id) self.twitter_client.check_followers() def validate_tweet(self, tweet): """ Check to see if a Tweet is valid (i.e. we want to reply to it), and returns True if so Tweets from ourselves, and mentions that are not directly addressed to us, returns False """ message = tweet.text # Bit of logging, plus we always return True for DMs if is_direct_message(tweet): logging.info("Have a DM from %s: %s", tweet.sender.screen_name, message) return True else: username = tweet.user.screen_name logging.info("Have an @ reply from %s: %s", username, message) # Don't start talking to yourself if username == self.username: logging.debug("Not talking to myself, that way madness lies") return False # Ignore mentions that are not direct replies if not message.lower().startswith('@%s' % self.username.lower()): logging.debug("Not a proper @ reply, skipping") return False return True def process_tweet(self, tweet): """ Process a single Tweet object and return a list of strings (replies), one per route or line e.g.: '@whensmybus 341 from Clerkenwell' produces '341 Clerkenwell Road to Waterloo 1241; Rosebery Avenue to Angel Road 1247' Each reply might be more than 140 characters No replies at all are given if the message is a thank-you or does not include a route or line """ # Don't do anything if this is a thank-you if self.check_politeness(tweet): logging.debug("This Tweet is a thank-you Tweet, skipping") return [] # Get route number, from and to from the message message = self.sanitize_message(tweet.text) logging.debug("Message from user: %s", message) (requested_routes, origin, destination, direction) = self.parser.parse_message(message) # If no routes found, we may be able to deduce from origin or position if we have specified a default requested route if not requested_routes and self.default_requested_route and (origin or self.tweet_has_geolocation(tweet)): logging.debug("No line name detected, going to try %s for now and see if that works", self.default_requested_route) requested_routes = [self.default_requested_route] if not requested_routes: logging.debug("No routes or lines detected on this Tweet, cannot determine position, skipping") return [] # If no origin specified, let's see if we have co-ordinates on the Tweet if not origin: position = self.get_tweet_geolocation(tweet, message) else: position = None replies = [] for requested_route in requested_routes: try: replies.append(self.process_individual_request(requested_route, origin, destination, direction, position)) # Exceptions produced for an individual request are particular to a route/stop combination - e.g. the bus # given does not stop at the stop given, so we just provide an error message for that circumstance, treat as # a non-fatal error, and process the next one. The one case where there is a fatal error (TfL's servers are # down), we raise this exception to be caught higher up by check_tweets() except WhensMyTransportException as exc: if exc.msgid == 'tfl_server_down': raise else: replies.append(exc.get_user_message()) return replies def check_politeness(self, tweet): """ Checks a Tweet for politeness. In case someone's just being nice to us, return a "No problem" else return an empty list """ message = self.sanitize_message(tweet.text).lower() if message.startswith('thanks') or message.startswith('thank you'): return ("No problem :)",) return () def sanitize_message(self, message): """ Takes a message string, scrub out the @username of this bot and any #hashtags, and return the sanitized messages """ # Remove hashtags and kisses at end message = re.sub(r"\s#\w+\b", '', message) message = re.sub(r"\sx+$", '', message) # Remove usernames if message.lower().startswith('@%s' % self.username.lower()): message = message[len('@%s ' % self.username):].strip() else: message = message.strip() # Exception if the Tweet contains nothing useful if not message and not self.allow_blank_tweets: raise WhensMyTransportException('blank_%s_tweet' % self.instance_name.replace('whensmy', '')) return message def tweet_has_geolocation(self, tweet): """ Returns True if the Tweet has geolocation data """ # pylint: disable=R0201 return hasattr(tweet, 'geo') and tweet.geo and 'coordinates' in tweet.geo def get_tweet_geolocation(self, tweet, user_request): """ Ensure any geolocation on a Tweet is valid, and return the co-ordinates as a (latitude, longitude) tuple """ if self.tweet_has_geolocation(tweet): logging.debug("Detecting geolocation on Tweet") position = tweet.geo['coordinates'] easting, northing = convertWGS84toOSEastingNorthing(*position) # Grid reference provides us an easy way with checking to see if in the UK - it returns blank string if not in UK bounds if not gridrefNumToLet(easting, northing): raise WhensMyTransportException('not_in_uk') # Check minimums & maximum numeric grid references - corresponding to Chesham (W), Shenfield (E), Dorking (S) and Potters Bar (N) elif not (495000 <= easting <= 565000 and 145000 <= northing <= 205000): raise WhensMyTransportException('not_in_london') else: return position # Some people (especially Tweetdeck users) add a Place on the Tweet, but not an accurate enough lat & long elif hasattr(tweet, 'place') and tweet.place: raise WhensMyTransportException('placeinfo_only', user_request) # If there's no geoinformation at all then raise the appropriate exception else: if hasattr(tweet, 'geo'): raise WhensMyTransportException('no_geotag', user_request) else: raise WhensMyTransportException('dms_not_taggable', user_request) @abstractmethod def process_individual_request(self, code, origin, destination, direction, position): """ Abstract method. This must be overridden by a child class to do anything useful Takes a code (e.g. a bus route or line name), origin, destination, direction and (latitude, longitude) tuple Returns a string repesenting the message sent back to the user. This can be more than 140 characters """ #pylint: disable=W0613,R0201 return "" @abstractmethod def get_departure_data(self, station_or_stops, line_or_route, must_stop_at, direction): """ Abstract method. This must be overridden by a child class to do anything useful Takes a string or list of strings representing a station or stop, and a string representing the line or route, and a string representing the stop the line or route has to stop at Returns a DepartureCollection object """ #pylint: disable=W0613,R0201 return {} def alert_admin_about_exception(self, tweet, exception_name): """ Alert the administrator about a non-WhensMyTransportException encountered when processing a Tweet """ if is_direct_message(tweet): tweet_time = tweet.created_at.strftime('%d-%m-%y %H:%M:%S') error_message = "Hey! A DM from @%s at %s GMT caused me to crash with a %s" % (tweet.sender.screen_name, tweet_time, exception_name) else: twitter_permalink = "https://twitter.com/#!/%s/status/%s" % (tweet.user.screen_name, tweet.id) error_message = "Hey! A tweet from @%s caused me to crash with a %s: %s" % (tweet.user.screen_name, exception_name, twitter_permalink) self.twitter_client.send_reply_back(error_message, self.admin_name, True)