diff --git a/PokeAlarm/Alarms/Discord/DiscordAlarm.py b/PokeAlarm/Alarms/Discord/DiscordAlarm.py index 27425b727..5243ebbb0 100644 --- a/PokeAlarm/Alarms/Discord/DiscordAlarm.py +++ b/PokeAlarm/Alarms/Discord/DiscordAlarm.py @@ -3,11 +3,12 @@ import requests # 3rd Party Imports - +import itertools # Local Imports from PokeAlarm.Alarms import Alarm from PokeAlarm.Utils import parse_boolean, get_static_map_url, \ - reject_leftover_parameters, require_and_remove_key, get_image_url + reject_leftover_parameters, require_and_remove_key, get_image_url, \ + get_static_weather_map_url log = logging.getLogger('Discord') try_sending = Alarm.try_sending @@ -71,14 +72,27 @@ class DiscordAlarm(Alarm): 'url': "", 'body': "The raid is available until " "<24h_raid_end> ()." + }, + 'weather': { + 'username': "Weather", + 'content': "", + 'icon_url': "https://raw.githubusercontent.com/ZeChrales" + "/monocle-icons/larger-outlined/assets" + "/weather__day.png", + 'avatar_url': "https://raw.githubusercontent.com/ZeChrales" + "/monocle-icons/larger-outlined/assets" + "/weather__day.png", + 'title': "Weather Change", + 'url': None, + 'body': "At <12h_time_weather_changed>, weather in " + " became ", } } # Gather settings and create alarm def __init__(self, settings, max_attempts, static_map_key): # Required Parameters - self.__webhook_url = require_and_remove_key( - 'webhook_url', settings, "'Discord' type alarms.") + self.__webhook_url = "https://discordapp.com/api/webhooks/" self.__max_attempts = max_attempts # Optional Alarm Parameters @@ -88,19 +102,21 @@ def __init__(self, settings, max_attempts, static_map_key): settings.pop('disable_embed', "False")) self.__avatar_url = settings.pop('avatar_url', "") self.__map = settings.pop('map', {}) - self.__static_map_key = static_map_key + self.__static_map_key = itertools.cycle(static_map_key) # Set Alert Parameters self.__monsters = self.create_alert_settings( - settings.pop('monsters', {}), self._defaults['monsters']) + settings.pop('monsters', {}), self._defaults['monsters'], 'monsters') self.__stops = self.create_alert_settings( - settings.pop('stops', {}), self._defaults['stops']) + settings.pop('stops', {}), self._defaults['stops'], 'stops') self.__gyms = self.create_alert_settings( - settings.pop('gyms', {}), self._defaults['gyms']) + settings.pop('gyms', {}), self._defaults['gyms'], 'gyms') self.__eggs = self.create_alert_settings( - settings.pop('eggs', {}), self._defaults['eggs']) + settings.pop('eggs', {}), self._defaults['eggs'], 'eggs') self.__raids = self.create_alert_settings( - settings.pop('raids', {}), self._defaults['raids']) + settings.pop('raids', {}), self._defaults['raids'], 'raids') + self.__weather = self.create_alert_settings( + settings.pop('weather', {}), self._defaults['weather'], 'weather') # Warn user about leftover parameters reject_leftover_parameters(settings, "'Alarm level in Discord alarm.") @@ -126,7 +142,13 @@ def startup_message(self): log.info("Startup message sent!") # Set the appropriate settings for each alert - def create_alert_settings(self, settings, default): + def create_alert_settings(self, settings, default, kind): + if kind == 'weather': + static_map = get_static_weather_map_url( + settings.pop('map', self.__map), next(self.__static_map_key)) + else: + static_map = get_static_map_url( + settings.pop('map', self.__map), next(self.__static_map_key)) alert = { 'webhook_url': settings.pop('webhook_url', self.__webhook_url), 'username': settings.pop('username', default['username']), @@ -138,8 +160,7 @@ def create_alert_settings(self, settings, default): 'title': settings.pop('title', default['title']), 'url': settings.pop('url', default['url']), 'body': settings.pop('body', default['body']), - 'map': get_static_map_url( - settings.pop('map', self.__map), self.__static_map_key) + 'map': static_map } reject_leftover_parameters(settings, "'Alert level in Discord alarm.") @@ -162,12 +183,26 @@ def send_alert(self, alert, info): 'thumbnail': {'url': replace(alert['icon_url'], info)} }] if alert['map'] is not None: - coords = { - 'lat': info['lat'], - 'lng': info['lng'] - } + if info.get('alert_type') == 'weather': + map_info = { + 'lat1': info['coords'][0][0], + 'lng1': info['coords'][0][1], + 'lat2': info['coords'][1][0], + 'lng2': info['coords'][1][1], + 'lat3': info['coords'][2][0], + 'lng3': info['coords'][2][1], + 'lat4': info['coords'][3][0], + 'lng4': info['coords'][3][1], + 'gkey': next(self.__static_map_key), + } + else: + map_info = { + 'lat': info['lat'], + 'lng': info['lng'], + 'gkey': next(self.__static_map_key), + } payload['embeds'][0]['image'] = { - 'url': replace(alert['map'], coords) + 'url': replace(alert['map'], map_info) } args = { 'url': replace(alert['webhook_url'], info), @@ -198,6 +233,10 @@ def raid_egg_alert(self, raid_info): def raid_alert(self, raid_info): self.send_alert(self.__raids, raid_info) + def weather_alert(self, weather_info): + log.debug("Weather notification triggered.") + self.send_alert(self.__weather, weather_info) + # Send a payload to the webhook url def send_webhook(self, url, payload): log.debug(payload) diff --git a/PokeAlarm/Cache/Cache.py b/PokeAlarm/Cache/Cache.py index 6ed9f3e8a..3ce6e9c4c 100644 --- a/PokeAlarm/Cache/Cache.py +++ b/PokeAlarm/Cache/Cache.py @@ -29,6 +29,7 @@ def __init__(self): self._gym_info = {} self._egg_hist = {} self._raid_hist = {} + self._weather_hist = {} def get_pokemon_expiration(self, pkmn_id): """ Get the datetime that the pokemon expires.""" @@ -80,6 +81,14 @@ def update_raid_expiration(self, gym_id, expiration): """ Updates the datetime that the raid expires. """ self._raid_hist[gym_id] = expiration + def get_cell_weather(self, weather_cell_id): + """ Returns the weather for the S2 cell. """ + return self._weather_hist.get(weather_cell_id) + + def update_cell_weather(self, weather_cell_id, condition): + """ Update the current weather in an S2 cell. """ + self._weather_hist[weather_cell_id] = condition + def clean_and_save(self): """ Cleans the cache and saves the contents if capable. """ self._clean_hist() diff --git a/PokeAlarm/Cache/FileCache.py b/PokeAlarm/Cache/FileCache.py index d914a8ea9..1638fe9b1 100644 --- a/PokeAlarm/Cache/FileCache.py +++ b/PokeAlarm/Cache/FileCache.py @@ -36,6 +36,7 @@ def _load(self): self._gym_info = data.get('gym_info', {}) self._egg_hist = data.get('egg_hist', {}) self._raid_hist = data.get('raid_hist', {}) + self._weather_hist = data.get('weather_hist', {}) log.debug("LOADED: \n {}".format(data)) def _save(self): @@ -47,7 +48,8 @@ def _save(self): 'gym_team': self._gym_team, 'gym_info': self._gym_info, 'egg_hist': self._egg_hist, - 'raid_hist': self._raid_hist + 'raid_hist': self._raid_hist, + 'weather_hist': self._weather_hist } log.debug(self._pokestop_hist) log.debug("SAVED: {}".format(data)) diff --git a/PokeAlarm/Events/EggEvent.py b/PokeAlarm/Events/EggEvent.py index 21e1949aa..a64ecfc7b 100644 --- a/PokeAlarm/Events/EggEvent.py +++ b/PokeAlarm/Events/EggEvent.py @@ -44,6 +44,10 @@ def __init__(self, data): str, data.get('description'), Unknown.REGULAR).strip() self.gym_image = check_for_none( str, data.get('url'), Unknown.REGULAR) + self.gym_sponsor = check_for_none( + int, data.get('sponsor'), Unknown.SMALL) + self.gym_park = check_for_none( + str, data.get('park'), Unknown.REGULAR) # Gym Team (this is only available from cache) self.current_team_id = check_for_none( @@ -51,7 +55,9 @@ def __init__(self, data): self.name = self.gym_id self.geofence = Unknown.REGULAR + self.geofence_list = [] self.custom_dts = {} + self.channel_id = Unknown.REGULAR def generate_dts(self, locale, timezone, units): """ Return a dict with all the DTS for this event. """ @@ -83,6 +89,8 @@ def generate_dts(self, locale, timezone, units): 'gmaps': get_gmaps_link(self.lat, self.lng), 'applemaps': get_applemaps_link(self.lat, self.lng), 'geofence': self.geofence, + 'geofence_list': self.geofence_list, + 'channel_id': self.channel_id, 'weather_id': self.weather_id, 'weather': weather_name, 'weather_or_empty': Unknown.or_empty(weather_name), @@ -95,6 +103,10 @@ def generate_dts(self, locale, timezone, units): 'gym_name': self.gym_name, 'gym_description': self.gym_description, 'gym_image': self.gym_image, + 'gym_sponsor': self.gym_sponsor, + 'gym_sponsor_phrase': ("\nSponsored Gym" if Unknown.or_empty(self.gym_sponsor) else ""), + 'gym_park': self.gym_park, + 'gym_park_phrase': ("\n***Possible EX Raid Location (" + self.gym_park + ")***" if Unknown.or_empty(self.gym_park) else ""), 'team_id': self.current_team_id, 'team_name': locale.get_team_name(self.current_team_id), 'team_leader': locale.get_leader_name(self.current_team_id) diff --git a/PokeAlarm/Events/MonEvent.py b/PokeAlarm/Events/MonEvent.py index ebab3c090..e189c39cb 100644 --- a/PokeAlarm/Events/MonEvent.py +++ b/PokeAlarm/Events/MonEvent.py @@ -84,8 +84,8 @@ def __init__(self, data): self.gender = MonUtils.get_gender_sym( check_for_none(int, data.get('gender'), Unknown.TINY)) - self.height = check_for_none(float, data.get('height'), Unknown.SMALL) - self.weight = check_for_none(float, data.get('weight'), Unknown.SMALL) + self.height = check_for_none(float, data.get('height'), 0) + self.weight = check_for_none(float, data.get('weight'), 0) if Unknown.is_not(self.height, self.weight): self.size_id = get_pokemon_size( self.monster_id, self.height, self.weight) @@ -96,7 +96,9 @@ def __init__(self, data): # Correct this later self.name = self.monster_id self.geofence = Unknown.REGULAR + self.geofence_list = [] self.custom_dts = {} + self.channel_id = Unknown.REGULAR def generate_dts(self, locale, timezone, units): """ Return a dict with all the DTS for this event. """ @@ -138,6 +140,8 @@ def generate_dts(self, locale, timezone, units): 'gmaps': get_gmaps_link(self.lat, self.lng), 'applemaps': get_applemaps_link(self.lat, self.lng), 'geofence': self.geofence, + 'geofence_list': self.geofence_list, + 'channel_id': self.channel_id, # Weather 'weather_id': self.weather_id, @@ -154,6 +158,10 @@ def generate_dts(self, locale, timezone, units): 'boosted_or_empty': locale.get_boosted_text() if \ Unknown.is_not(self.boosted_weather_id) and self.boosted_weather_id != 0 else '', + 'boosted_weather_phrase_or_empty': ( + "\nBoosted by {} weather".format(boosted_weather_name) if \ + Unknown.is_not(self.boosted_weather_id) and + self.boosted_weather_id != 0 else ''), # Encounter Stats 'mon_lvl': self.mon_lvl, @@ -213,8 +221,8 @@ def generate_dts(self, locale, timezone, units): # Cosmetic 'gender': self.gender, - 'height': self.height, - 'weight': self.weight, + 'height': "{:.2f}".format(self.height), + 'weight': "{:.2f}".format(self.weight), 'size': locale.get_size_name(self.size_id), # Misc diff --git a/PokeAlarm/Events/RaidEvent.py b/PokeAlarm/Events/RaidEvent.py index 36108d436..5659f1bc9 100644 --- a/PokeAlarm/Events/RaidEvent.py +++ b/PokeAlarm/Events/RaidEvent.py @@ -62,6 +62,10 @@ def __init__(self, data): str, data.get('description'), Unknown.REGULAR).strip() self.gym_image = check_for_none( str, data.get('url'), Unknown.REGULAR) + self.gym_sponsor = check_for_none( + int, data.get('sponsor'), Unknown.SMALL) + self.gym_park = check_for_none( + str, data.get('park'), Unknown.REGULAR) # Gym Team (this is only available from cache) self.current_team_id = check_for_none( @@ -69,7 +73,9 @@ def __init__(self, data): self.name = self.gym_id self.geofence = Unknown.REGULAR + self.geofence_list = [] self.custom_dts = {} + self.channel_id = Unknown.REGULAR def generate_dts(self, locale, timezone, units): """ Return a dict with all the DTS for this event. """ @@ -89,6 +95,7 @@ def generate_dts(self, locale, timezone, units): type2 = locale.get_type_name(self.types[1]) cp_range = get_pokemon_cp_range(self.mon_id, boss_level) + dts.update({ # Identification 'gym_id': self.gym_id, @@ -126,11 +133,12 @@ def generate_dts(self, locale, timezone, units): 'gmaps': get_gmaps_link(self.lat, self.lng), 'applemaps': get_applemaps_link(self.lat, self.lng), 'geofence': self.geofence, + 'geofence_list': self.geofence_list, + 'channel_id': self.channel_id, 'weather_id': self.weather_id, 'weather': weather_name, 'weather_or_empty': Unknown.or_empty(weather_name), 'weather_emoji': get_weather_emoji(self.weather_id), - 'boosted_weather_id': boosted_weather_id, 'boosted_weather': boosted_weather_name, 'boosted_weather_or_empty': ( '' if boosted_weather_id == 0 @@ -138,6 +146,9 @@ def generate_dts(self, locale, timezone, units): 'boosted_weather_emoji': get_weather_emoji(boosted_weather_id), 'boosted_or_empty': locale.get_boosted_text() if boss_level == 25 else '', + 'boosted_weather_phrase_or_empty': ( + "\nBoosted by {} weather".format(boosted_weather_name) + if boss_level == 25 else ''), # Raid Info 'raid_lvl': self.raid_lvl, @@ -171,6 +182,10 @@ def generate_dts(self, locale, timezone, units): 'gym_name': self.gym_name, 'gym_description': self.gym_description, 'gym_image': self.gym_image, + 'gym_sponsor': self.gym_sponsor, + 'gym_sponsor_phrase': ("\nSponsored Gym" if Unknown.or_empty(self.gym_sponsor) else ""), + 'gym_park': self.gym_park, + 'gym_park_phrase': ("\n***Possible EX Raid Location (" + self.gym_park + ")***" if Unknown.or_empty(self.gym_park) else ""), 'team_id': self.current_team_id, 'team_name': locale.get_team_name(self.current_team_id), 'team_leader': locale.get_leader_name(self.current_team_id) diff --git a/PokeAlarm/Events/WeatherEvent.py b/PokeAlarm/Events/WeatherEvent.py new file mode 100644 index 000000000..153ed16ac --- /dev/null +++ b/PokeAlarm/Events/WeatherEvent.py @@ -0,0 +1,73 @@ +# Standard Library Imports +from datetime import datetime +# 3rd Party Imports +# Local Imports +from PokeAlarm import Unknown +from . import BaseEvent +from PokeAlarm.Utils import get_time_as_str, get_weather_emoji + + +class WeatherEvent(BaseEvent): + """ Event representing the change occurred in Weather """ + + def __init__(self, data): + """ Creates a new Weather Event based on the given dict. """ + super(WeatherEvent, self).__init__('weather') + check_for_none = BaseEvent.check_for_none + + # Identification + self.alert_type = 'weather' + self.weather_cell_id = data.get('s2_cell_id') + + # Time of weather change + self.time_changed = datetime.utcfromtimestamp( + data.get('time_changed')) + + # S2 Cell vertices coordinates + self.coords = data.get('coords') + + # Weather conditions + self.condition = check_for_none( + int, data.get('condition'), Unknown.SMALL) + self.alert_severity = check_for_none( + str, data.get('alert_severity'), Unknown.SMALL) + self.warn = check_for_none( + str, data.get('warn'), Unknown.REGULAR).strip() + self.day = check_for_none( + int, data.get('day'), Unknown.SMALL) + + self.name = self.weather_cell_id + self.geofence = Unknown.REGULAR + self.geofence_list = [] + self.custom_dts = {} + self.channel_id = Unknown.REGULAR + + def generate_dts(self, locale, timezone, units): + """ Return a dict with all the DTS for this event. """ + time_changed = get_time_as_str(self.time_changed) + dts = self.custom_dts.copy() + dts.update({ + # Identification + 'alert_type': self.alert_type, + 'weather_cell_id': self.weather_cell_id, + + # Time Remaining + '12h_time_weather_changed': time_changed[1], + '24h_time_weather_changed': time_changed[2], + + # Location + 'coords': self.coords, + 'channel_id': self.channel_id, + + 'geofence': self.geofence, + 'geofence_list': self.geofence_list, + + # Weather info + 'condition': self.condition, + 'weather': locale.get_weather_name(self.condition), + 'weather_emoji': get_weather_emoji(self.condition), + 'alert_severity': self.alert_severity, + 'warn': self.warn, + 'day': self.day + }) + return dts diff --git a/PokeAlarm/Events/__init__.py b/PokeAlarm/Events/__init__.py index 24f3ae76c..6340e31b3 100644 --- a/PokeAlarm/Events/__init__.py +++ b/PokeAlarm/Events/__init__.py @@ -7,6 +7,7 @@ from GymEvent import GymEvent from EggEvent import EggEvent from RaidEvent import RaidEvent +from WeatherEvent import WeatherEvent log = logging.getLogger('Events') @@ -28,6 +29,8 @@ def event_factory(data): elif kind == 'raid' and message.get('pokemon_id'): # RM/M send Monster ID in raids return RaidEvent(message) + elif kind == 'weather': + return WeatherEvent(message) elif kind in ['captcha', 'scheduler']: log.debug( "{} data ignored - unsupported webhook type.".format(kind)) diff --git a/PokeAlarm/Filters/BaseFilter.py b/PokeAlarm/Filters/BaseFilter.py index 069a0197c..8b8b080a3 100644 --- a/PokeAlarm/Filters/BaseFilter.py +++ b/PokeAlarm/Filters/BaseFilter.py @@ -50,7 +50,7 @@ def check_event(self, event): def reject(self, event, reason): """ Log the reason for rejecting the Event. """ - log.info("[%10s] %s rejected: %s", self._name, event.name, reason) + log.debug("[%10s] %s rejected: %s", self._name, event.name, reason) def evaluate_attribute(self, limit, eval_func, event_attribute): """ Evaluates a parameter and generate a check if needed. """ diff --git a/PokeAlarm/Filters/EggFilter.py b/PokeAlarm/Filters/EggFilter.py index fddffd7bd..80bb947df 100644 --- a/PokeAlarm/Filters/EggFilter.py +++ b/PokeAlarm/Filters/EggFilter.py @@ -46,6 +46,18 @@ def __init__(self, name, data): limit=BaseFilter.parse_as_set( GymUtils.create_regex, 'gym_name_contains', data)) + # Gym sponsor + self.gym_sponsor_index_contains = self.evaluate_attribute( + event_attribute='gym_sponsor', eval_func=GymUtils.match_regex_dict, + limit=BaseFilter.parse_as_set( + GymUtils.create_regex, 'gym_sponsor_index_contains', data)) + + # Gym park + self.gym_park_contains = self.evaluate_attribute( # f.gp matches e.gp + event_attribute='gym_park', eval_func=GymUtils.match_regex_dict, + limit=BaseFilter.parse_as_set( + GymUtils.create_regex, 'gym_park_contains', data)) + # Team Info self.old_team = self.evaluate_attribute( # f.ctis contains m.cti event_attribute='current_team_id', eval_func=operator.contains, @@ -88,6 +100,14 @@ def to_dict(self): if self.gym_name_contains is not None: settings['gym_name_matches'] = self.gym_name_contains + # Gym Sponsor + if self.gym_sponsor_index_contains is not None: + settings['gym_sponsor_matches'] = self.gym_sponsor_index_contains + + # Gym Park + if self.gym_park_contains is not None: + settings['gym_park_matches'] = self.gym_park_contains + # Geofences if self.geofences is not None: settings['geofences'] = self.geofences diff --git a/PokeAlarm/Filters/MonFilter.py b/PokeAlarm/Filters/MonFilter.py index 9635b7b92..cd3190019 100644 --- a/PokeAlarm/Filters/MonFilter.py +++ b/PokeAlarm/Filters/MonFilter.py @@ -128,6 +128,11 @@ def __init__(self, name, data): event_attribute='weather_id', eval_func=operator.contains, limit=BaseFilter.parse_as_set(get_weather_id, 'weather', data)) + # Weather + self.weather_ids = self.evaluate_attribute( + event_attribute='weather_id', eval_func=operator.contains, + limit=BaseFilter.parse_as_set(get_weather_id, 'weather', data)) + # Geofences self.geofences = BaseFilter.parse_as_set(str, 'geofences', data) diff --git a/PokeAlarm/Filters/RaidFilter.py b/PokeAlarm/Filters/RaidFilter.py index c1a9915e3..2538c1ee1 100644 --- a/PokeAlarm/Filters/RaidFilter.py +++ b/PokeAlarm/Filters/RaidFilter.py @@ -72,6 +72,18 @@ def __init__(self, name, data): limit=BaseFilter.parse_as_set( GymUtils.create_regex, 'gym_name_contains', data)) + # Gym sponsor + self.gym_sponsor_index_contains = self.evaluate_attribute( + event_attribute='gym_sponsor', eval_func=GymUtils.match_regex_dict, + limit=BaseFilter.parse_as_set( + GymUtils.create_regex, 'gym_sponsor_index_contains', data)) + + # Gym park + self.gym_park_contains = self.evaluate_attribute( + event_attribute='gym_park', eval_func=GymUtils.match_regex_dict, + limit=BaseFilter.parse_as_set( + GymUtils.create_regex, 'gym_park_contains', data)) + # Team Info self.old_team = self.evaluate_attribute( # f.ctis contains m.cti event_attribute='current_team_id', eval_func=operator.contains, diff --git a/PokeAlarm/Filters/WeatherFilter.py b/PokeAlarm/Filters/WeatherFilter.py new file mode 100644 index 000000000..dd8255a48 --- /dev/null +++ b/PokeAlarm/Filters/WeatherFilter.py @@ -0,0 +1,42 @@ +# Standard Library Imports +# 3rd Party Imports +# Local Imports +from . import BaseFilter + + +class WeatherFilter(BaseFilter): + """ Filter class for limiting which egg trigger a notification. """ + + def __init__(self, name, data): + """ Initializes base parameters for a filter. """ + super(WeatherFilter, self).__init__(name) + + # Geofences + self.geofences = BaseFilter.parse_as_set(str, 'geofences', data) + + # Custom DTS + self.custom_dts = BaseFilter.parse_as_dict( + str, str, 'custom_dts', data) + + # Missing Info + self.is_missing_info = BaseFilter.parse_as_type( + bool, 'is_missing_info', data) + + # Reject leftover parameters + for key in data: + raise ValueError("'{}' is not a recognized parameter for" + " Weather filters".format(key)) + + def to_dict(self): + """ Create a dict representation of this Filter. """ + settings = {} + + # Geofences + if self.geofences is not None: + settings['geofences'] = self.geofences + + # Missing Info + if self.is_missing_info is not None: + settings['missing_info'] = self.is_missing_info + + return settings diff --git a/PokeAlarm/Filters/__init__.py b/PokeAlarm/Filters/__init__.py index 53c9a46be..6bf75fecc 100644 --- a/PokeAlarm/Filters/__init__.py +++ b/PokeAlarm/Filters/__init__.py @@ -4,3 +4,4 @@ from GymFilter import GymFilter # noqa F401 from EggFilter import EggFilter # noqa F401 from RaidFilter import RaidFilter # noqa F401 +from WeatherFilter import WeatherFilter # noqa F401 diff --git a/PokeAlarm/Geofence.py b/PokeAlarm/Geofence.py index f5868e884..5c5fc89ab 100644 --- a/PokeAlarm/Geofence.py +++ b/PokeAlarm/Geofence.py @@ -5,6 +5,7 @@ import traceback from collections import OrderedDict # 3rd Party Imports +from shapely.geometry import Polygon # Local Imports @@ -97,3 +98,7 @@ def contains(self, x, y): # Returns the name of this geofence def get_name(self): return self.__name + + # Checks to see if two regions overlap + def check_overlap(self, weather): + return Polygon(self.__points).intersects(Polygon(weather.coords)) diff --git a/PokeAlarm/Load.py b/PokeAlarm/Load.py index e0f67d13a..838353ea2 100644 --- a/PokeAlarm/Load.py +++ b/PokeAlarm/Load.py @@ -47,7 +47,8 @@ def parse_rules_file(manager, filename): load_rules_section(manager.add_egg_rule, rules.pop('eggs', {})) log.debug("Parsing 'raids' section.") load_rules_section(manager.add_raid_rule, rules.pop('raids', {})) - + log.debug("Parsing 'weather' section.") + load_rules_section(manager.add_weather_rule, rules.pop('weather', {})) for key in rules: raise ValueError("Unknown Event type '{}'. Rules must be defined " "under the correct event type. See " diff --git a/PokeAlarm/Locale.py b/PokeAlarm/Locale.py index 73e18bccb..df32f44f7 100644 --- a/PokeAlarm/Locale.py +++ b/PokeAlarm/Locale.py @@ -90,10 +90,22 @@ def get_move_name(self, move_id): def get_team_name(self, team_id): return self.__team_names.get(team_id, 'unknown') + # Returns the name of the team associated with the Team ID + def get_weather_name(self, weather_id): + return self.__weather_names.get(weather_id, 'None') + # Returns the name of the team ledaer associated with the Team ID def get_leader_name(self, team_id): return self.__leader_names.get(team_id, 'unknown') + # Returns the size of the Pokemon based on the Calculated Size Value + def get_size_name(self, size_id): + return self.__size_names.get(size_id, 'unknown') + + # Returns the name of the type associated with the Type ID + def get_type_name(self, type_id): + return self.__type_names.get(type_id, 'unknown') + # Returns the name of the weather associated with the given ID def get_weather_name(self, weather_id): return self.__weather_names.get(weather_id, 'unknown') diff --git a/PokeAlarm/LocationServices/GoogleMaps.py b/PokeAlarm/LocationServices/GoogleMaps.py index 8efc5eacf..212c9dead 100644 --- a/PokeAlarm/LocationServices/GoogleMaps.py +++ b/PokeAlarm/LocationServices/GoogleMaps.py @@ -3,6 +3,7 @@ import traceback # 3rd Party Imports import googlemaps +import itertools # Local Imports log = logging.getLogger('LocService') @@ -13,11 +14,10 @@ class GoogleMaps(object): # Initialize the APIs def __init__(self, api_key, locale, units): - self.__client = googlemaps.Client( - key=api_key, timeout=3, retry_timeout=5) self.__locale = locale # Language to use for Geocoding results self.__units = units # imperial or metric + self.__google_key = itertools.cycle(api_key) # For Reverse Location API self.__reverse_location = False @@ -46,8 +46,9 @@ def add_optional_arguments(self, origin, dest, data): # Returns an array in the format [ Lat, Lng ], or exit if an error occurs. def get_location_from_name(self, location_name): try: - result = self.__client.geocode( - location_name, language=self.__locale) + result = googlemaps.Client( + key = next(self.__google_key), timeout=3, retry_timeout=5).geocode( + location_name, language=self.__locale) # Get the first (most likely) result loc = result[0]['geometry']['location'] latitude, longitude = loc.get("lat"), loc.get("lng") @@ -80,8 +81,10 @@ def __get_reverse_location(self, location): 'state': 'unknown', 'country': 'country' } try: - result = self.__client.reverse_geocode( - location, language=self.__locale)[0] + gkey = next(self.__google_key) + result = googlemaps.Client( + key = gkey, timeout=3, retry_timeout=5).reverse_geocode( + location, language=self.__locale)[0] loc = {} for item in result['address_components']: for category in item['types']: @@ -109,7 +112,7 @@ def __get_reverse_location(self, location): self.__reverse_location_history[key] = details # memoize except Exception as e: log.error("Encountered error while getting reverse " - + "location data ({}: {})".format(type(e).__name__, e)) + + "location data ({}: {}, api: {})".format(type(e).__name__, e, gkey)) log.debug("Stack trace: \n {}".format(traceback.format_exc())) # Return results, even if unable to complete return details @@ -129,9 +132,10 @@ def __get_walking_data(self, origin, dest): return self.__walk_data_history[key] data = {'walk_dist': "unknown", 'walk_time': "unknown"} try: - result = self.__client.distance_matrix( - origin, dest, mode='walking', - units=self.__units, language=self.__locale) + result = googlemaps.Client( + key = next(self.__google_key), timeout=3, retry_timeout=5).distance_matrix( + origin, dest, mode='walking', + units=self.__units, language=self.__locale) result = result.get('rows')[0].get('elements')[0] data['walk_dist'] = result.get( 'distance').get('text').encode('utf-8') @@ -159,9 +163,10 @@ def __get_biking_data(self, origin, dest): return self.__bike_data_history[key] data = {'bike_dist': "unknown", 'bike_time': "unknown"} try: - result = self.__client.distance_matrix( - origin, dest, mode='bicycling', - units=self.__units, language=self.__locale) + result = googlemaps.Client( + key = next(self.__google_key), timeout=3, retry_timeout=5).distance_matrix( + origin, dest, mode='bicycling', + units=self.__units, language=self.__locale) result = result.get('rows')[0].get('elements')[0] data['bike_dist'] = result.get( 'distance').get('text').encode('utf-8') @@ -189,9 +194,10 @@ def __get_driving_data(self, origin, dest): return self.__driving_data_history[key] data = {'drive_dist': "unknown", 'drive_time': "unknown"} try: - result = self.__client.distance_matrix( - origin, dest, mode='driving', - units=self.__units, language=self.__locale) + result = googlemaps.Client( + key = next(self.__google_key), timeout=3, retry_timeout=5).distance_matrix( + origin, dest, mode='driving', + units=self.__units, language=self.__locale) result = result.get('rows')[0].get('elements')[0] data['drive_dist'] = result.get( 'distance').get('text').encode('utf-8') diff --git a/PokeAlarm/Manager.py b/PokeAlarm/Manager.py index 4ca712903..57b6b9f08 100644 --- a/PokeAlarm/Manager.py +++ b/PokeAlarm/Manager.py @@ -12,6 +12,7 @@ import gevent from gevent.queue import Queue from gevent.event import Event +import itertools # Local Imports import Alarms @@ -32,7 +33,7 @@ class Manager(object): def __init__(self, name, google_key, locale, units, timezone, time_limit, max_attempts, location, quiet, cache_type, filter_file, - geofence_file, alarm_file, debug): + geofence_file, alarm_file, debug, channel_id_file): # Set the name of the Manager self.__name = str(name).lower() log.info("----------- Manager '{}' ".format(self.__name) @@ -40,15 +41,10 @@ def __init__(self, name, google_key, locale, units, timezone, time_limit, self.__debug = debug # Get the Google Maps API - self.__google_key = None - self.__loc_service = None - if str(google_key).lower() != 'none': - self.__google_key = google_key - self.__loc_service = location_service_factory( - "GoogleMaps", google_key, locale, units) - else: - log.warning("NO GOOGLE API KEY SET - Reverse Location and" - + " Distance Matrix DTS will NOT be detected.") + self.__google_key = google_key + + self.__loc_service = location_service_factory( + "GoogleMaps", self.__google_key, locale, units) self.__locale = Locale(locale) # Setup the language-specific stuff self.__units = units # type of unit used for distances @@ -76,12 +72,18 @@ def __init__(self, name, google_key, locale, units, timezone, time_limit, self.__ignore_neutral = False self.__eggs_enabled, self.__egg_filters = False, OrderedDict() self.__raids_enabled, self.__raid_filters = False, OrderedDict() + self.__weather_enabled, self.__weather_filters = False, OrderedDict() self.load_filter_file(get_path(filter_file)) # Create the Geofences to filter with from given file self.geofences = None if str(geofence_file).lower() != 'none': self.geofences = load_geofence_file(get_path(geofence_file)) + + # Load in the file to get discord API key from geofence/filter-set + self.channel_id = {} + self.load_channel_id_file(get_path(channel_id_file)) + # Create the alarms to send notifications out with self.__alarms = [] self.load_alarms_file(get_path(alarm_file), int(max_attempts)) @@ -92,6 +94,7 @@ def __init__(self, name, google_key, locale, units, timezone, time_limit, self.__gym_rules = {} self.__egg_rules = {} self.__raid_rules = {} + self.__weather_rules = {} # Initialize the queue and start the process self.__queue = Queue() @@ -220,6 +223,23 @@ def add_raid_rule(self, name, filters, alarms): self.__raid_rules[name] = Rule(filters, alarms) + # Add new Weather Rule + def add_weather_rule(self, name, filters, alarms): + if name in self.__weather_rules: + raise ValueError("Unable to add Rule: Weather Rule with the name " + "{} already exists!".format(name)) + + for filt in filters: + if filt not in self.__weather_filters: + raise ValueError("Unable to create Rule: No weather Filter " + "named {}!".format(filt)) + + for alarm in alarms: + if alarm not in self.__alarms: + raise ValueError("Unable to create Rule: No Alarm " + "named {}!".format(alarm)) + + self.__weather_rules[name] = Rule(filters, alarms) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MANAGER LOADING ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -309,6 +329,13 @@ def load_filter_file(self, file_path): self.__raid_filters = self.load_filter_section( section, 'raids', Filters.RaidFilter) + # Load Weather Section + log.info("Parsing 'weather' section.") + section = filters.pop('weather', {}) + self.__weather_enabled = bool(section.pop('enabled', True)) + self.__weather_filters = self.load_filter_section( + section, 'weather', Filters.WeatherFilter) + return # exit function except Exception as e: @@ -359,6 +386,37 @@ def load_alarms_file(self, file_path, max_attempts): log.debug("Stack trace: \n {}".format(traceback.format_exc())) sys.exit(1) + def load_channel_id_file(self, file_path): + log.info("Loading API keys from the file at {}".format(file_path)) + try: + with open(file_path, 'r') as f: + self.channel_id = json.load(f) + if type(self.channel_id) is not dict: + log.critical("API key file must be a dict objects " + + "- { {...}, {...}, ... {...} }") + sys.exit(1) + log.info("API Key file found") + return # all done + except ValueError as e: + log.error("Encountered error while loading Alarms file: " + + "{}: {}".format(type(e).__name__, e)) + log.error( + "PokeAlarm has encountered a 'ValueError' while loading the " + + " API key file. This typically means your file isn't in the " + + "correct json format. Try loading your file contents into" + + " a json validator.") + except IOError as e: + log.error("Encountered error while loading API key: " + + "{}: {}".format(type(e).__name__, e)) + log.error("PokeAlarm was unable to find a api key file " + + "at {}. Please check that this file".format(file_path) + + " exists and PA has read permissions.") + except Exception as e: + log.error("Encountered error while loading api key: " + + "{}: {}".format(type(e).__name__, e)) + log.debug("Stack trace: \n {}".format(traceback.format_exc())) + sys.exit(1) + # Check for optional arguments and enable APIs as needed def set_optional_args(self, line): # Reverse Location @@ -486,6 +544,8 @@ def run(self): self.process_egg(event) elif kind == Events.RaidEvent: self.process_raid(event) + elif kind == Events.WeatherEvent: + self.process_weather(event) else: log.error("!!! Manager does not support " + "{} events!".format(kind)) @@ -559,6 +619,12 @@ def process_monster(self, mon): mon.direction = get_cardinal_dir( [mon.lat, mon.lng], self.__location) + # Checks to see which geofences contain the event + if not self.match_geofences(mon): + log.debug("{} monster was skipped because not in any geofences" + "".format(mon.name)) + return + # Check for Rules rules = self.__mon_rules if len(rules) == 0: # If no rules, default to all @@ -568,16 +634,23 @@ def process_monster(self, mon): for r_name, rule in rules.iteritems(): # For all rules for f_name in rule.filter_names: # Check Filters in Rules f = self.__mon_filters.get(f_name) - passed = f.check_event(mon) and self.check_geofences(f, mon) + passed = f.check_event(mon) if not passed: continue # go to next filter - mon.custom_dts = f.custom_dts - if self.__quiet is False: - log.info("{} monster notification" - " has been triggered in rule '{}'!" - "".format(mon.name, r_name)) - self._trigger_mon(mon, rule.alarm_names) - break # Next rule + for geofence_name in mon.geofence_list: + if not self.get_channel_id(mon, f_name, geofence_name): + log.debug("No API key set for {} monster" + " notification for geofence: {}," + " filter set: {}!" + "".format(mon.name, geofence_name, f_name)) + continue + mon.custom_dts = f.custom_dts + mon.geofence = mon.geofence_list[0] if geofence_name not in self.geofences.iterkeys() else geofence_name + if self.__quiet is False: + log.info("{} monster notification" + " has been triggered in rule '{}', for geofence: {}, filter set: {} channel: {}!" + "".format(mon.name, r_name, geofence_name, f_name, mon.channel_id)) + self._trigger_mon(mon, rule.alarm_names) def _trigger_mon(self, mon, alarms): # Generate the DTS for the event @@ -779,7 +852,6 @@ def process_egg(self, egg): # Assigned cached info info = self.__cache.get_gym_info(egg.gym_id) - egg.current_team_id = self.__cache.get_gym_team(egg.gym_id) egg.gym_name = info['name'] egg.gym_description = info['description'] egg.gym_image = info['url'] @@ -791,6 +863,12 @@ def process_egg(self, egg): egg.direction = get_cardinal_dir( [egg.lat, egg.lng], self.__location) + # Checks to see which geofences contain the event + if not self.match_geofences(egg): + log.debug("{} egg was skipped because not in any geofences" + "".format(egg.name)) + return + # Check for Rules rules = self.__egg_rules if len(rules) == 0: # If no rules, default to all @@ -800,21 +878,28 @@ def process_egg(self, egg): for r_name, rule in rules.iteritems(): # For all rules for f_name in rule.filter_names: # Check Filters in Rules f = self.__egg_filters.get(f_name) - passed = f.check_event(egg) and self.check_geofences(f, egg) + passed = f.check_event(egg) if not passed: continue # go to next filter - egg.custom_dts = f.custom_dts - if self.__quiet is False: - log.info("{} egg notification" - " has been triggered in rule '{}'!" - "".format(egg.name, r_name)) - self._trigger_egg(egg, rule.alarm_names) - break # Next rule + for geofence_name in egg.geofence_list: + if not self.get_channel_id(egg, f_name, geofence_name): + log.debug("No API key set for {} egg" + " notification for geofence: {}," + " filter set: {}!" + "".format(egg.name, geofence_name, f_name)) + continue + egg.custom_dts = f.custom_dts + egg.geofence = egg.geofence_list[0] if geofence_name not in self.geofences.iterkeys() else geofence_name + if self.__quiet is False: + log.info("{} egg notification" + " has been triggered in rule '{}', for geofence: {}, filter set: {} channel: {}!" + "".format(egg.name, r_name, geofence_name, f_name, egg.channel_id)) + self._trigger_egg(egg, rule.alarm_names) def _trigger_egg(self, egg, alarms): # Generate the DTS for the event dts = egg.generate_dts(self.__locale, self.__timezone, self.__units) - dts.update(self.__cache.get_gym_info(egg.gym_id)) # update gym info + #dts.update(self.__cache.get_gym_info(egg.gym_id)) # update gym info # Get reverse geocoding if self.__loc_service: self.__loc_service.add_optional_arguments( @@ -861,7 +946,6 @@ def process_raid(self, raid): # Assigned cached info info = self.__cache.get_gym_info(raid.gym_id) - raid.current_team_id = self.__cache.get_gym_team(raid.gym_id) raid.gym_name = info['name'] raid.gym_description = info['description'] raid.gym_image = info['url'] @@ -873,6 +957,12 @@ def process_raid(self, raid): raid.direction = get_cardinal_dir( [raid.lat, raid.lng], self.__location) + # Checks to see which geofences contain the event + if not self.match_geofences(raid): + log.debug("{} raid was skipped because not in any geofences" + "".format(raid.name)) + return + # Check for Rules rules = self.__raid_rules if len(rules) == 0: # If no rules, default to all @@ -882,21 +972,28 @@ def process_raid(self, raid): for r_name, rule in rules.iteritems(): # For all rules for f_name in rule.filter_names: # Check Filters in Rules f = self.__raid_filters.get(f_name) - passed = f.check_event(raid) and self.check_geofences(f, raid) + passed = f.check_event(raid) if not passed: continue # go to next filter - raid.custom_dts = f.custom_dts - if self.__quiet is False: - log.info("{} raid notification" - " has been triggered in rule '{}'!" - "".format(raid.name, r_name)) - self._trigger_raid(raid, rule.alarm_names) - break # Next rule + for geofence_name in raid.geofence_list: + if not self.get_channel_id(raid, f_name, geofence_name): + log.debug("No API key set for {} raid" + " notification for geofence: {}," + " filter set: {}!" + "".format(raid.name, geofence_name, f_name)) + continue + raid.custom_dts = f.custom_dts + raid.geofence = raid.geofence_list[0] if geofence_name not in self.geofences.iterkeys() else geofence_name + if self.__quiet is False: + log.info("{} raid notification" + " has been triggered in rule '{}', for geofence: {}, filter set: {} channel: {}!" + "".format(raid.name, r_name, geofence_name, f_name, raid.channel_id)) + self._trigger_raid(raid, rule.alarm_names) def _trigger_raid(self, raid, alarms): # Generate the DTS for the event dts = raid.generate_dts(self.__locale, self.__timezone, self.__units) - dts.update(self.__cache.get_gym_info(raid.gym_id)) # update gym info + #dts.update(self.__cache.get_gym_info(raid.gym_id)) # update gym info # Get reverse geocoding if self.__loc_service: self.__loc_service.add_optional_arguments( @@ -914,6 +1011,74 @@ def _trigger_raid(self, raid, alarms): for thread in threads: # Wait for all alarms to finish thread.join() + def process_weather(self, weather): + # type: (Events.WeatherEvent) -> None + """ Process a weather event and notify alarms if it passes. """ + + # Make sure that weather is enabled + if self.__weather_enabled is False: + log.debug("Weather ignored: weather notifications are disabled.") + return + + # Skip if previously processed + if self.__cache.get_cell_weather( + weather.weather_cell_id) == weather.condition: + log.debug("Weather alert for cell {} was skipped " + "because it was already {} weather.".format( + weather.weather_cell_id, weather.condition)) + return + self.__cache.update_cell_weather( + weather.weather_cell_id, weather.condition) + + # Checks to see which geofences contain the event + if not self.match_weather_geofences(weather): + log.debug("{} weather was skipped because not in any geofences" + "".format(weather.name)) + return + + # Check for Rules + rules = self.__weather_rules + if len(rules) == 0: # If no rules, default to all + rules = {"default": Rule( + self.__weather_filters.keys(), self.__alarms.keys())} + + for r_name, rule in rules.iteritems(): # For all rules + for f_name in rule.filter_names: # Check Filters in Rules + f = self.__weather_filters.get(f_name) + passed = f.check_event(weather) + if not passed: + continue # go to next filter + for geofence_name in weather.geofence_list: + if not self.get_channel_id(weather, f_name, geofence_name): + log.debug("No API key set for {} weather" + " notification for geofence: {}," + " filter set: {}!" + "".format(weather.name, geofence_name, f_name)) + continue + weather.custom_dts = f.custom_dts + weather.geofence = weather.geofence_list[0] if geofence_name not in self.geofences.iterkeys() else geofence_name + if self.__quiet is False: + log.info("{} weather notification" + " has been triggered in rule '{}', for geofence: {}, filter set: {} channel: {}!" + "".format(weather.name, r_name, geofence_name, f_name, weather.channel_id)) + self._trigger_weather(weather, rule.alarm_names) + + def _trigger_weather(self, weather, alarms): + # Generate the DTS for the event + dts = weather.generate_dts(self.__locale, self.__timezone, self.__units) + + threads = [] + # Spawn notifications in threads so they can work in background + for name in alarms: + alarm = self.__alarms.get(name) + if alarm: + threads.append(gevent.spawn(alarm.weather_alert, dts)) + else: + log.critical("Alarm '{}' not found!".format(name)) + + for thread in threads: # Wait for all alarms to finish + thread.join() + # Check to see if a notification is within the given range def check_geofences(self, f, e): """ Returns true if the event passes the filter's geofences. """ @@ -936,4 +1101,81 @@ def check_geofences(self, f, e): f.reject(e, "not in geofences") return False + # Check to see if a notification is within the given range + def match_geofences(self, e): + """ Returns true if the event passes the filter's geofences. """ + if self.geofences is None: # No geofences set (Improve here) + return False + for name in self.geofences.iterkeys(): + gf = self.geofences.get(name) + if not gf: # gf doesn't exist + log.error("Cannot check geofence %s: does not exist!", name) + elif gf.contains(e.lat, e.lng): # e in gf + gf_name = gf.get_name() + log.debug("{} is in geofence {}!".format( + e.name, gf_name)) + e.geofence_list.append(gf_name) # Set the geofence for dts + e.geofence_list.append('All') + if "-" in gf_name: + e.geofence_list.append(gf_name.split('-')[1]) + return True + else: # e not in gf + log.debug("%s not in %s.", e.name, name) + return False + +# Check to see if a weather notification s2 cell +# overlaps with a given range (geofence) + def check_weather_geofences(self, f, e): + """ Returns true if the event passes the filter's geofences. """ + if self.geofences is None or f.geofences is None: # No geofences set + return True + targets = f.geofences + if len(targets) == 1 and "all" in targets: + targets = self.geofences.iterkeys() + for name in targets: + gf = self.geofences.get(name) + if not gf: # gf doesn't exist + log.error("Cannot check geofence %s: does not exist!", name) + elif gf.check_overlap(e): # weather cell overlaps gf + log.debug("{} is in geofence {}!".format( + e.weather_cell_id, gf.get_name())) + e.geofence = name # Set the geofence for dts + return True + else: # weather not in gf + log.debug("%s not in %s.", e.weather_cell_id, name) + f.reject(e, "not in geofences") + return False + + def match_weather_geofences(self, e): + """ Returns true if the event passes the filter's geofences. """ + if self.geofences is None: # No geofences set (Improve here) + return False + for name in self.geofences.iterkeys(): + gf = self.geofences.get(name) + if not gf: # gf doesn't exist + log.error("Cannot check geofence %s: does not exist!", name) + elif gf.contains(e.lat, e.lng): # e in gf + gf_name = gf.get_name() + log.debug("{} is in geofence {}!".format( + e.name, gf_name)) + e.geofence_list.append(gf_name) # Set the geofence for dts + e.geofence_list.append('All') + if "-" in gf_name: + e.geofence_list.append(gf_name.split('-')[1]) + return True + else: # e not in gf + log.debug("%s not in %s.", e.name, name) + return False + + def get_channel_id(self, e, filter_name, geofence_name): + try: + api_filter_name = filter_name.split('-')[0] + e.channel_id = self.channel_id[geofence_name][api_filter_name] + return True + except KeyError: + log.debug("error in geofence: %s filter: %s.", geofence_name, api_filter_name) + return False + + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/PokeAlarm/Utils.py b/PokeAlarm/Utils.py index 91d9a2922..056a88db1 100644 --- a/PokeAlarm/Utils.py +++ b/PokeAlarm/Utils.py @@ -382,14 +382,39 @@ def get_static_map_url(settings, api_key=None): # TODO: optimize formatting map_ = ('https://maps.googleapis.com/maps/api/staticmap?' + query_center + '&' + query_markers + '&' + - query_maptype + '&' + query_size + '&' + query_zoom) + query_maptype + '&' + query_size + '&' + query_zoom + '&key=') - if api_key is not None: - map_ += ('&key=%s' % api_key) - log.debug("API_KEY added to static map url.") + # if api_key is not None: + # map_ += ('&key=%s' % api_key) + # log.debug("API_KEY added to static map url.") return map_ +# TODO: optimize formatting +def get_static_weather_map_url(settings, api_key=None): + if not parse_boolean(settings.get('enabled', 'True')): + return None + width = settings.get('width', '400') + height = settings.get('height', '400') + maptype = settings.get('maptype', 'roadmap') + zoom = settings.get('zoom', '12') + + query_size = 'size={}x{}'.format(width, height) + query_zoom = 'zoom={}'.format(zoom) + query_maptype = 'maptype={}'.format(maptype) + query_path = 'path=fillcolor:0xFFFF0033|weight:5|' \ + ',|,|,' \ + '|,|,' + + map_ = ('https://maps.googleapis.com/maps/api/staticmap?' + + query_maptype + '&' + query_size + + '&' + query_zoom + '&' + query_path + '&key=') + + # if api_key is not None: + # map_ += ('&key=%s' % api_key) + # log.debug("API_KEY added to static map url.") + return map_ + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ GENERAL UTILITIES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~# @@ -510,4 +535,5 @@ def match_items_in_array(list, items): return True return False + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/README.md b/README.md index 8daa2dd17..71d0edfab 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,50 @@ ![Python 2.7](https://img.shields.io/badge/python-2.7-blue.svg) ![license](https://img.shields.io/github/license/PokeAlarm/PokeAlarm.svg) +### How to use the OneManager branch + +####Overview: +This PA modification allows you to run all your alerts with 1 manager. It accomplishes this by three main additions +1) All the filters get checked. For each filter that is matched, an alarm is triggered +2) All matching geofence(s) for each event can trigger an alarm. (you do not need a duplicated filter set for each geofence) +3) A new file, channel_id.json, is required. This file translates your geofence+filter combination into the channel/webhook portion of the discord webhook url + + +channel_id.json, filters.json, and geofence.txt are all required and must all be formatted appropriately to make alerts work. + +See examples files to see an example set up similar to the one I use. + +####geofence.txt + +1) Formatted exactly the same as always. +2) The area names (ie [Area1]) must match the Area names used in channel_id.json +3) Do not set a geofence for "All". If an event matches any geofence, an alarm is also created for the corresponding Filter/Discord webhook url pair in the "All" key of channel_id.json. +4) Use SubAreas if you have multiple smaller areas within a larger area. For example, you have a discord channel for all ultra rare spawns within a city and you also have multiple channels for rare spawns occuring in each of many different neighborhoods within that city. In your geofence file, do not specify a geofence for [Area2], instead only specify geofences for each SubArea in the format [Area2-SubArea1] + + +####channel_id.json + +- This file should look a lot like the organization of your discord server. See the example file for format. Some important points: +1) You can include an "All" key, if an event occurs within any geofence, it can trigger an alarm using the "All" set of Filter names +2) The first dictionary level are Area/Filter pairs. The Area name in this file must must match the Area names used in geofence.txt +3) The second dictionary level are Filter/Discord Webhook Url pairs. The Filter name must match the Filter names in filters.json. The Discord Webhook Url is the last portion of the webhook url (ie everything after "discordapp.com/api/webhooks/" ) for the channel you want to send the alarm to. + + +####filters.json + +1) No format changes from standard PA +2) Do not use '"geofences": [ "Area" ]'. All geofences supplied in geofences.txt will be checked for every event +3) The Filter name must match the Filter name specified in channel_id.json. (Except as described below) +4) In setting the Filter name the hyphen character (ie "-"), has a special use. If you have multiple filters you want to send to one discord channel you can use the hyphen to have different Filter names in filters.json that will match the same Filter name in channel_id.json. For example, in filters.json the Filter name "UltraRare-1" and "UltraRare-2" will both match the "UltraRare" key in channel_id.json. Thus, an event matching the conditions for either filter "UltraRare-1" or "UltraRare-2" will use the same Discord Webhook url. + +####alarms.json + +1) Do not include '"webhook_url":"YOUR_WEBHOOK_URL"' in alarms.json +2) Make sure your alarm format is robust enough to accomidate all filters. Only 1 alarm file is used + + + + PokeAlarm is a highly configurable application that filters and relays alerts about PokemonGo to your favorite online service, allowing you to be first to know of any rare spawns or raids. ### Patch Notes diff --git a/alarms.json.example b/alarms.json.example index 19ea1a15b..f362a6f6b 100644 --- a/alarms.json.example +++ b/alarms.json.example @@ -1,8 +1,7 @@ { "discord_alarm":{ "active":false, - "type":"discord", - "webhook_url":"YOUR_WEBHOOK_URL" + "type":"discord" }, "facebook_alarm":{ "active":false, diff --git a/channel_id.json.example b/channel_id.json.example new file mode 100644 index 000000000..4bd2ca5e0 --- /dev/null +++ b/channel_id.json.example @@ -0,0 +1,43 @@ +{ + "All":{ + "OIV": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "100IV: "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Rare": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "UltraRare": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Candy": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Event": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Unown": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "SponsoredRaid: "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" + }, + "Area1":{ + "OIV": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "100IV: "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Rare": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Candy": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Event": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Raid5": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Raid34": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" + }, + "Area2":{ + "100IV: "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Raid5": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" + }, + "Area2-SubArea1":{ + "OIV": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "100IV: "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Rare": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Candy": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Event": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Raid5": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Raid34": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" + }, + "Area2-SubArea2":{ + "OIV": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "100IV: "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Rare": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Candy": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Event": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Raid5": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY", + "Raid34": "XXXXXXXXXXXXXXXX/YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY" + } +} \ No newline at end of file diff --git a/data/base_stats.json b/data/base_stats.json index 2594649e5..f901da1a2 100644 --- a/data/base_stats.json +++ b/data/base_stats.json @@ -4010,9 +4010,9 @@ "height": 3.51 }, "384": { - "attack": 312, - "defense": 187, - "stamina": 210, + "attack": 284, + "defense": 170, + "stamina": 191, "type1": 16, "type2": 3, "legendary": true, diff --git a/data/weather_boosts.json b/data/weather_boosts.json index 367140d7a..2f39c9161 100644 --- a/data/weather_boosts.json +++ b/data/weather_boosts.json @@ -1,9 +1,9 @@ { - "1": [9, 11, 4], - "2": [10, 12, 6], - "3": [0, 5], - "4": [1, 16, 3], - "5": [15, 2, 13], - "6": [14, 8], - "7": [17, 7] + "1": [10, 12, 5], + "2": [11, 13, 7], + "3": [1, 6], + "4": [2, 17, 4], + "5": [16, 3, 14], + "6": [15, 9], + "7": [18, 8] } \ No newline at end of file diff --git a/filters.json.example b/filters.json.example index 390afa011..b35ed9b16 100644 --- a/filters.json.example +++ b/filters.json.example @@ -17,6 +17,30 @@ "monsters":["Bulbasaur"], "quick_moves":["Vine Whip","Tackle"], "charge_moves":["Sludge Bomb","Seed Bomb"] + "weather": [ "Clear", 2 ], + }, + "Rare": { + + }, + "0IV": { + "min_iv": 0.0, "max_iv": 0.1 + }, + "100IV": { + "min_iv": 99.0, "max_iv": 100 + }, + "Unown": { + "monsters": [ 201 ] + }, + "Event-0": { + "monsters": [ 254, 257, 260, 281, 286, 287, 288, 289, 297, 308, 310, 317, 318, 320, 321, 326, 340, 342, 349, 350, 354, 356, 365 ] + }, + "Event-1+2": { + "monsters": [ 252, 253, 255, 256, 258, 259, 261, 262, 264, 266, 267, 268, 269, 270, 271, 272, 274, 275, 276, 277, 278, 279, 280, 282, 283, 284, 285, 290, 291, 292, 293, 294, 295, 296, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 309, 311, 312, 313, 314, 316, 319, 322, 323, 324, 325, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 341, 343, 344, 345, 346, 347, 348, 351, 352, 353, 355, 357, 358, 359, 360, 361, 362, 363, 364, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 112, 113, 135, 136, 229 ], + "min_iv": 90, "max_iv": 100 + }, + "Event-3": { + "monsters": [ 263, 265, 273, 315 ], + "min_iv": 99, "max_iv": 100 } } }, @@ -52,6 +76,12 @@ "filters":{ "filter_by_lvl_example":{ "min_egg_lvl": 0, "max_egg_lvl": 5 + "gym_park_contains": [ "^(?!.*None)" ], + "gym_sponsor_index_contains": ["^(?!.*0)"], + }, + "Raid5": { + "min_egg_lvl": 5, "max_egg_lvl": 5, + "is_missing_info": false } } }, @@ -66,6 +96,30 @@ "filter_raid_lvl_and_teams":{ "min_raid_lvl": 0, "max_raid_lvl": 5, "current_teams":["Valor","Instinct","Mystic"] + "weather": [ "Clear", 2 ], + "gym_park_contains": [ "^(?!.*None)" ], + "gym_sponsor_index_contains": ["^(?!.*0)"], + "geofences": [ "Central Park" ], + "custom_dts": { "key1": "value1", "key2": "value2" }, + "is_missing_info": false + }, + "Raid5": { + "min_raid_lvl": 5, "max_raid_lvl": 5 + }, + "Raid34": { + "min_raid_lvl": 3, "max_raid_lvl": 4 + }, + "SponsoredRaid" : { + "gym_sponsor_index_contains": [ ".*" ] + } + } + }, + "weather":{ + "enabled": false, + "defaults": { + }, + "filters": { + "filter-name" : { } } } diff --git a/geofence.txt.example b/geofence.txt.example index 475b2008a..bfeef48e5 100644 --- a/geofence.txt.example +++ b/geofence.txt.example @@ -1,5 +1,9 @@ -[Central Park] +[Area1] 40.801206,-73.958520 40.767827,-73.982835 40.763798,-73.972808 -40.797343,-73.948385 \ No newline at end of file +40.797343,-73.948385 +[Area2-SubArea1] + +[Area2-SubArea2] + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1787e355d..db944ff3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ flask==0.12.2 gevent==1.2.2 googlemaps==2.5.1 pytz==2017.3 -portalocker==1.1.0 \ No newline at end of file +portalocker==1.1.0 +shapely>=1.3.0 diff --git a/start_pokealarm.py b/start_pokealarm.py index b109c2078..6c87b7708 100644 --- a/start_pokealarm.py +++ b/start_pokealarm.py @@ -140,7 +140,7 @@ def parse_settings(root_path): action='append', default=[], help='Names of Manager processes to start.') parser.add_argument( - '-k', '--key', type=parse_unicode, action='append', default=[None], + '-k', '--key', type=str, action='append', default=[None], help='Specify a Google API Key to use.') parser.add_argument( '-f', '--filters', type=parse_unicode, action='append', @@ -185,6 +185,10 @@ def parse_settings(root_path): parser.add_argument( '-tz', '--timezone', type=str, action='append', default=[None], help='Timezone used for notifications. Ex: "America/Los_Angeles"') + parser.add_argument( + '-api', '--channel_id', type=parse_unicode, action='append', + default=['channel_id.json'], + help='Translate Filter set and Geofence to Discord API key. default: channel_id.json') args = parser.parse_args() @@ -201,10 +205,10 @@ def parse_settings(root_path): config['DEBUG'] = args.debug # Check to make sure that the same number of arguments are included - for arg in [args.key, args.filters, args.alarms, args.rules, + for arg in [args.filters, args.alarms, args.rules, args.geofences, args.location, args.locale, args.units, args.cache_type, args.timelimit, args.max_attempts, - args.timezone]: + args.timezone, args.channel_id]: if len(arg) > 1: # Remove defaults from the list arg.pop(0) size = len(arg) @@ -240,8 +244,7 @@ def parse_settings(root_path): config['UNITS'] = get_from_list(args.units, m_ct, args.units[0]) m = Manager( name=args.manager_name[m_ct], - google_key=get_from_list( - args.key, m_ct, args.key[0]), + google_key=args.key, locale=get_from_list(args.locale, m_ct, args.locale[0]), units=get_from_list(args.units, m_ct, args.units[0]), timezone=get_from_list(args.timezone, m_ct, args.timezone[0]), @@ -256,7 +259,8 @@ def parse_settings(root_path): geofence_file=get_from_list( args.geofences, m_ct, args.geofences[0]), alarm_file=get_from_list(args.alarms, m_ct, args.alarms[0]), - debug=config['DEBUG'] + debug=config['DEBUG'], + channel_id_file=get_from_list(args.channel_id, m_ct, args.channel_id[0]) ) parse_rules_file(m, get_from_list(args.rules, m_ct, args.rules[0])) if m.get_name() not in managers: diff --git a/tools/webhook_test.py b/tools/webhook_test.py index d45951e05..e7c0d021b 100644 --- a/tools/webhook_test.py +++ b/tools/webhook_test.py @@ -6,6 +6,8 @@ import os import portalocker import pickle +from random import randint + truthy = frozenset([ "yes", "Yes", "y", "Y", "true", "True", "TRUE", "YES", "1", "!0" @@ -16,7 +18,8 @@ "2": "pokestop", "3": "gym", "4": "egg", - "5": "raid" + "5": "raid", + "6": "weather" } teams = { @@ -70,8 +73,8 @@ def set_init(webhook_type): "pokemon_id": 149, "pokemon_level": 30, "player_level": 30, - "latitude": 37.7876146, - "longitude": -122.390624, + "latitude": 38.556814, + "longitude": -121.725527, "encounter_id": current_time, "cp_multiplier": 0.7317000031471252, "form": None, @@ -89,7 +92,7 @@ def set_init(webhook_type): "spawn_end": 3264, "verified": False, "weather": 0, - "boosted_weather": 0 + "boosted_weather": None, } } elif webhook_type == whtypes["2"]: @@ -125,10 +128,13 @@ def set_init(webhook_type): "type": "raid", "message": { "gym_id": 0, - "gym_name": "unknown", + "name": "Test gym", + "team": 1, + "park": None, + "sponsor": 4, "level": 5, - "latitude": 37.7876146, - "longitude": -122.390624 + "latitude": 38.414232, + "longitude": -121.379004, } } elif webhook_type == whtypes["5"]: @@ -136,17 +142,34 @@ def set_init(webhook_type): "type": "raid", "message": { "gym_id": 0, - "gym_name": "unknown", + "name": "Test gym", + "team": 1, + "park": None, + "sponsor": 2, + "weather": 5, "pokemon_id": 150, "cp": 12345, "move_1": 123, "move_2": 123, "level": 5, - "latitude": 37.7876146, - "longitude": -122.390624, + "latitude": 38.414232, + "longitude": -121.379004, "weather": 0 } } + elif webhook_type == whtypes["6"]: + payloadr = { + "type": "weather", + "message": { + "s2_cell_id": 0, + 'time_changed': current_time, + 'coords': [[38.25522067755094,-122.08374449567627],[38.179280284460866,-122.08374449567626],[38.20693488934502,-121.99280779590266],[38.282892940885574,-121.99280779590266]], + 'condition': randint(1, 6), + 'alert_severity': 0, + 'warn': 1, + 'day': 1, + } + } return payloadr