diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 9976991cf9..a1d7168e75 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,3 +1,5 @@ +Please check configuration at http://jsonlint.com/ before posting an issue. + ### Expected Behavior diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 6dceaf0918..02aaacab4f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -51,6 +51,7 @@ * matheussampaio * Abraxas000 * lucasfevi + * pokepal * Moonlight-Angel * mjmadsen * nikofil diff --git a/README.md b/README.md index 20a34967e8..171ee4f355 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ -# PokemonGo-Bot +# PokemonGo-Bot (Working) PokemonGo bot is a project created by the [PokemonGoF](https://github.com/PokemonGoF) team. The project is currently setup in two main branches. `dev` and `master`. - -## Where to get the dll/so ? -You need grab them from internet. +## Please submit PR to [Dev branch](https://github.com/PokemonGoF/PokemonGo-Bot/tree/dev) We use [Slack](https://slack.com) as a web chat. [Click here to join the chat!](https://pokemongo-bot.herokuapp.com) +You can count on the community in #help channel. ## Table of Contents - [Features](#features) @@ -64,10 +63,11 @@ To ensure that all updates are documented - [@eggins](https://github.com/eggins) ## Credits - [tejado](https://github.com/tejado) many thanks for the API +- [U6 Group](http://pgoapi.com) for the U6 - [Mila432](https://github.com/Mila432/Pokemon_Go_API) for the login secrets - [elliottcarlson](https://github.com/elliottcarlson) for the Google Auth PR - [AeonLucid](https://github.com/AeonLucid/POGOProtos) for improved protos - [AHAAAAAAA](https://github.com/AHAAAAAAA/PokemonGo-Map) for parts of the s2sphere stuff -[![Analytics](https://ga-beacon.appspot.com/UA-81468120-1/welcome-page-dev)](https://github.com/igrigorik/ga-beacon) +[![Analytics](https://ga-beacon.appspot.com/UA-81468120-1/welcome-page-master)](https://github.com/igrigorik/ga-beacon) diff --git a/configs/config.json.cluster.example b/configs/config.json.cluster.example index 8d0d8f854f..b32eb4f668 100644 --- a/configs/config.json.cluster.example +++ b/configs/config.json.cluster.example @@ -4,6 +4,7 @@ "password": "YOUR_PASSWORD", "location": "SOME_LOCATION", "gmapkey": "GOOGLE_MAPS_API_KEY", + "libencrypt_location": "", "tasks": [ { "type": "HandleSoftBan" diff --git a/configs/config.json.map.example b/configs/config.json.map.example index e665d4c6da..1079c999f9 100644 --- a/configs/config.json.map.example +++ b/configs/config.json.map.example @@ -4,6 +4,7 @@ "password": "YOUR_PASSWORD", "location": "SOME_LOCATION", "gmapkey": "GOOGLE_MAPS_API_KEY", + "libencrypt_location": "", "tasks": [ { "type": "HandleSoftBan" diff --git a/configs/config.json.path.example b/configs/config.json.path.example index afd1e3afeb..94a9fdba07 100644 --- a/configs/config.json.path.example +++ b/configs/config.json.path.example @@ -4,6 +4,7 @@ "password": "YOUR_PASSWORD", "location": "SOME_LOCATION", "gmapkey": "GOOGLE_MAPS_API_KEY", + "libencrypt_location": "", "tasks": [ { "type": "HandleSoftBan" diff --git a/configs/config.json.pokemon.example b/configs/config.json.pokemon.example index 7cad1ac066..1d428a6ae7 100644 --- a/configs/config.json.pokemon.example +++ b/configs/config.json.pokemon.example @@ -4,6 +4,7 @@ "password": "YOUR_PASSWORD", "location": "SOME_LOCATION", "gmapkey": "GOOGLE_MAPS_API_KEY", + "libencrypt_location": "", "tasks": [ { "type": "HandleSoftBan" diff --git a/configs/path.example.json b/configs/path.json.example similarity index 100% rename from configs/path.example.json rename to configs/path.json.example diff --git a/pokecli.py b/pokecli.py index d428026276..55afa55399 100644 --- a/pokecli.py +++ b/pokecli.py @@ -42,6 +42,12 @@ from pokemongo_bot.health_record import BotEvent from pokemongo_bot.plugin_loader import PluginLoader +try: + from demjson import jsonlint +except ImportError: + # Run `pip install -r requirements.txt` to fix this + jsonlint = None + if sys.version_info >= (2, 7, 9): ssl._create_default_https_context = ssl._create_unverified_context @@ -104,7 +110,7 @@ def main(): 'api_error', sender=bot, level='info', - formatted='Log logged in, reconnecting in {:s}'.format(wait_time) + formatted='Log logged in, reconnecting in {:d}'.format(wait_time) ) time.sleep(wait_time) except ServerBusyOrOfflineException: @@ -162,16 +168,28 @@ def init_config(): # If config file exists, load variables from json load = {} + def _json_loader(filename): + try: + with open(filename, 'rb') as data: + load.update(json.load(data)) + except ValueError: + if jsonlint: + with open(filename, 'rb') as data: + lint = jsonlint() + rc = lint.main(['-v', filename]) + + logger.critical('Error with configuration file') + sys.exit(-1) + # Select a config file code parser.add_argument("-cf", "--config", help="Config File to use") config_arg = parser.parse_known_args() and parser.parse_known_args()[0].config or None + if config_arg and os.path.isfile(config_arg): - with open(config_arg) as data: - load.update(json.load(data)) + _json_loader(config_arg) elif os.path.isfile(config_file): logger.info('No config argument specified, checking for /configs/config.json') - with open(config_file) as data: - load.update(json.load(data)) + _json_loader(config_file) else: logger.info('Error: No /configs/config.json or specified config') diff --git a/pokemongo_bot/__init__.py b/pokemongo_bot/__init__.py index 661c7b80ce..c15569119b 100644 --- a/pokemongo_bot/__init__.py +++ b/pokemongo_bot/__init__.py @@ -9,6 +9,8 @@ import re import sys import time +import Queue +import threading from geopy.geocoders import GoogleV3 from pgoapi import PGoApi @@ -69,6 +71,11 @@ def __init__(self, config): # Make our own copy of the workers for this instance self.workers = [] + # Theading setup for file writing + self.web_update_queue = Queue.Queue(maxsize=1) + self.web_update_thread = threading.Thread(target=self.update_web_location_worker) + self.web_update_thread.start() + def start(self): self._setup_event_system() self._setup_logging() @@ -225,10 +232,12 @@ def _register_events(self): 'iv_display', ) ) + self.event_manager.register_event('no_pokeballs') self.event_manager.register_event( 'pokemon_catch_rate', parameters=( 'catch_rate', + 'ball_name', 'berry_name', 'berry_count' ) @@ -237,25 +246,28 @@ def _register_events(self): 'threw_berry', parameters=( 'berry_name', + 'ball_name', 'new_catch_rate' ) ) self.event_manager.register_event( 'threw_pokeball', parameters=( - 'pokeball', + 'ball_name', 'success_percentage', 'count_left' ) ) self.event_manager.register_event( - 'pokemon_fled', + 'pokemon_capture_failed', parameters=('pokemon',) ) self.event_manager.register_event( 'pokemon_vanished', parameters=('pokemon',) ) + self.event_manager.register_event('pokemon_not_in_range') + self.event_manager.register_event('pokemon_inventory_full') self.event_manager.register_event( 'pokemon_caught', parameters=( @@ -976,7 +988,15 @@ def heartbeat(self): request.get_player() request.check_awarded_badges() request.call() - self.update_web_location() # updates every tick + try: + self.web_update_queue.put_nowait(True) # do this outside of thread every tick + except Queue.Full: + pass + + def update_web_location_worker(self): + while True: + self.web_update_queue.get() + self.update_web_location() def get_inventory_count(self, what): response_dict = self.get_inventory() diff --git a/pokemongo_bot/cell_workers/pokemon_catch_worker.py b/pokemongo_bot/cell_workers/pokemon_catch_worker.py index d676e9f0e2..a0711b49d6 100644 --- a/pokemongo_bot/cell_workers/pokemon_catch_worker.py +++ b/pokemongo_bot/cell_workers/pokemon_catch_worker.py @@ -1,13 +1,49 @@ # -*- coding: utf-8 -*- import time -from pokemongo_bot.human_behaviour import (normalized_reticle_size, sleep, - spin_modifier) from pokemongo_bot.base_task import BaseTask +from pokemongo_bot.human_behaviour import normalized_reticle_size, sleep, spin_modifier + + +CATCH_STATUS_SUCCESS = 1 +CATCH_STATUS_FAILED = 2 +CATCH_STATUS_VANISHED = 3 + +ENCOUNTER_STATUS_SUCCESS = 1 +ENCOUNTER_STATUS_NOT_IN_RANGE = 5 +ENCOUNTER_STATUS_POKEMON_INVENTORY_FULL = 7 + +ITEM_POKEBALL = 1 +ITEM_GREATBALL = 2 +ITEM_ULTRABALL = 3 +ITEM_RAZZBERRY = 701 + +LOGIC_TO_FUNCTION = { + 'or': lambda x, y: x or y, + 'and': lambda x, y: x and y +} + + +class Pokemon(object): + + def __init__(self, pokemon_list, pokemon_data): + self.num = int(pokemon_data['pokemon_id']) - 1 + self.name = pokemon_list[int(self.num)]['Name'] + self.cp = pokemon_data['cp'] + self.attack = pokemon_data.get('individual_attack', 0) + self.defense = pokemon_data.get('individual_defense', 0) + self.stamina = pokemon_data.get('individual_stamina', 0) + + @property + def iv(self): + return round((self.attack + self.defense + self.stamina) / 45.0, 2) + + @property + def iv_display(self): + return '{}/{}/{}'.format(self.attack, self.defense, self.stamina) + class PokemonCatchWorker(BaseTask): - BAG_FULL = 'bag_full' - NO_POKEBALLS = 'no_pokeballs' def __init__(self, pokemon, bot): self.pokemon = pokemon @@ -22,444 +58,63 @@ def __init__(self, pokemon, bot): self.response_key = '' self.response_status_key = '' + ############################################################################ + # public methods + ############################################################################ + def work(self, response_dict=None): - encounter_id = self.pokemon['encounter_id'] + response_dict = response_dict or self.create_encounter_api_call() + # validate response if not response_dict: - response_dict = self.create_encounter_api_call() - - if response_dict and 'responses' in response_dict: - if self.response_key in response_dict['responses']: - if self.response_status_key in response_dict['responses'][self.response_key]: - if response_dict['responses'][self.response_key][self.response_status_key] is 1: - cp = 0 - if 'wild_pokemon' in response_dict['responses'][self.response_key] or 'pokemon_data' in \ - response_dict['responses'][self.response_key]: - if self.response_key == 'ENCOUNTER': - pokemon = response_dict['responses'][self.response_key]['wild_pokemon'] - else: - pokemon = response_dict['responses'][self.response_key] - - catch_rate = response_dict['responses'][self.response_key]['capture_probability'][ - 'capture_probability'] # 0 = pokeballs, 1 great balls, 3 ultra balls - - if 'pokemon_data' in pokemon and 'cp' in pokemon['pokemon_data']: - pokemon_data = pokemon['pokemon_data'] - cp = pokemon_data['cp'] - - individual_attack = pokemon_data.get("individual_attack", 0) - individual_stamina = pokemon_data.get("individual_stamina", 0) - individual_defense = pokemon_data.get("individual_defense", 0) - - iv_display = '{}/{}/{}'.format( - individual_attack, - individual_defense, - individual_stamina - ) - - pokemon_potential = self.pokemon_potential(pokemon_data) - pokemon_num = int(pokemon_data['pokemon_id']) - 1 - pokemon_name = self.pokemon_list[int(pokemon_num)]['Name'] - - msg = 'A wild {pokemon} appeared! [CP {cp}] [Potential {iv}] [S/A/D {iv_display}]' - self.emit_event( - 'pokemon_appeared', - formatted=msg, - data={ - 'pokemon': pokemon_name, - 'cp': cp, - 'iv': pokemon_potential, - 'iv_display': iv_display, - } - ) - - pokemon_data['name'] = pokemon_name - # Simulate app - sleep(3) - - if not self.should_capture_pokemon(pokemon_name, cp, pokemon_potential, response_dict): - return False - - flag_VIP = False - # @TODO, use the best ball in stock to catch VIP (Very Important Pokemon: Configurable) - if self.check_vip_pokemon(pokemon_name, cp, pokemon_potential): - self.emit_event( - 'vip_pokemon', - formatted='This is a VIP pokemon. Catch!!!' - ) - flag_VIP=True - - items_stock = self.bot.current_inventory() - berry_id = 701 # @ TODO: use better berries if possible - berries_count = self.bot.item_inventory_count(berry_id) - while True: - # pick the most simple ball from stock - pokeball = 1 # start from 1 - PokeBalls - berry_used = False - - if flag_VIP: - if(berries_count>0 and catch_rate[pokeball-1] < 0.9): - success_percentage = '{0:.2f}'.format(catch_rate[pokeball-1]*100) - self.emit_event( - 'pokemon_catch_rate', - level='debug', - formatted="Catch rate of {catch_rate} is low. Maybe will throw {berry_name} ({berry_count} left)", - data={ - 'catch_rate': success_percentage, - 'berry_name': self.item_list[str(berry_id)], - 'berry_count': berries_count - } - ) - # Out of all pokeballs! Let's don't waste berry. - if items_stock[1] == 0 and items_stock[2] == 0 and items_stock[3] == 0: - break - - # Use the berry to catch - response_dict = self.api.use_item_capture( - item_id=berry_id, - encounter_id=encounter_id, - spawn_point_id=self.spawn_point_guid - ) - if response_dict and response_dict['status_code'] is 1 and 'item_capture_mult' in response_dict['responses']['USE_ITEM_CAPTURE']: - for i in range(len(catch_rate)): - if 'item_capture_mult' in response_dict['responses']['USE_ITEM_CAPTURE']: - catch_rate[i] = catch_rate[i] * response_dict['responses']['USE_ITEM_CAPTURE']['item_capture_mult'] - success_percentage = '{0:.2f}'.format(catch_rate[pokeball-1]*100) - berries_count = berries_count -1 - berry_used = True - self.emit_event( - 'threw_berry', - formatted="Threw a {berry_name}! Catch rate now: {new_catch_rate}", - data={ - "berry_name": self.item_list[str(berry_id)], - "new_catch_rate": success_percentage - } - ) - else: - if response_dict['status_code'] is 1: - self.emit_event( - 'softban', - level='warning', - formatted='Failed to use berry. You may be softbanned.' - ) - else: - self.emit_event( - 'threw_berry_failed', - formatted='Unknown response when throwing berry: {status_code}.', - data={ - 'status_code': response_dict['status_code'] - } - ) - - #use the best ball to catch - current_type = pokeball - #debug use normal ball - while current_type < 3: - current_type += 1 - if catch_rate[pokeball-1] < 0.9 and items_stock[current_type] > 0: - # if current ball chance to catch is under 90%, and player has better ball - then use it - pokeball = current_type # use better ball - else: - # If we have a lot of berries (than the great ball), we prefer use a berry first! - if catch_rate[pokeball-1] < 0.42 and items_stock[pokeball+1]+30 < berries_count: - # If it's not the VIP type, we don't want to waste our ultra ball if no balls left. - if items_stock[1] == 0 and items_stock[2] == 0: - break - - success_percentage = '{0:.2f}'.format(catch_rate[pokeball-1]*100) - self.emit_event( - 'pokemon_catch_rate', - level='debug', - formatted="Catch rate of {catch_rate} is low. Maybe will throw {berry_name} ({berry_count} left)", - data={ - 'catch_rate': success_percentage, - 'berry_name': self.item_list[str(berry_id)], - 'berry_count': berries_count-1 - } - ) - response_dict = self.api.use_item_capture(item_id=berry_id, - encounter_id=encounter_id, - spawn_point_id=self.spawn_point_guid - ) - if response_dict and response_dict['status_code'] is 1 and 'item_capture_mult' in response_dict['responses']['USE_ITEM_CAPTURE']: - for i in range(len(catch_rate)): - if 'item_capture_mult' in response_dict['responses']['USE_ITEM_CAPTURE']: - catch_rate[i] = catch_rate[i] * response_dict['responses']['USE_ITEM_CAPTURE']['item_capture_mult'] - success_percentage = '{0:.2f}'.format(catch_rate[pokeball-1]*100) - berries_count = berries_count -1 - berry_used = True - self.emit_event( - 'threw_berry', - formatted="Threw a {berry_name}! Catch rate now: {new_catch_rate}", - data={ - "berry_name": self.item_list[str(berry_id)], - "new_catch_rate": success_percentage - } - ) - else: - if response_dict['status_code'] is 1: - self.emit_event( - 'softban', - level='warning', - formatted='Failed to use berry. You may be softbanned.' - ) - else: - self.emit_event( - 'threw_berry_failed', - formatted='Unknown response when throwing berry: {status_code}.', - data={ - 'status_code': response_dict['status_code'] - } - ) - - else: - #We don't have many berry to waste, pick a good ball first. Save some berry for future VIP pokemon - current_type = pokeball - while current_type < 2: - current_type += 1 - if catch_rate[pokeball-1] < 0.35 and items_stock[current_type] > 0: - # if current ball chance to catch is under 35%, and player has better ball - then use it - pokeball = current_type # use better ball - - #if the rate is still low and we didn't throw a berry before use berry - if catch_rate[pokeball-1] < 0.35 and berries_count > 0 and berry_used == False: - # If it's not the VIP type, we don't want to waste our ultra ball if no balls left. - if items_stock[1] == 0 and items_stock[2] == 0: - break - - success_percentage = '{0:.2f}'.format(catch_rate[pokeball-1]*100) - self.emit_event( - 'pokemon_catch_rate', - level='debug', - formatted="Catch rate of {catch_rate} is low. Throwing {berry_name} ({berry_count} left)", - data={ - 'catch_rate': success_percentage, - 'berry_name': self.item_list[str(berry_id)], - 'berry_count': berries_count-1 - } - ) - response_dict = self.api.use_item_capture(item_id=berry_id, - encounter_id=encounter_id, - spawn_point_id=self.spawn_point_guid - ) - if response_dict and response_dict['status_code'] is 1 and 'item_capture_mult' in response_dict['responses']['USE_ITEM_CAPTURE']: - for i in range(len(catch_rate)): - if 'item_capture_mult' in response_dict['responses']['USE_ITEM_CAPTURE']: - catch_rate[i] = catch_rate[i] * response_dict['responses']['USE_ITEM_CAPTURE']['item_capture_mult'] - success_percentage = '{0:.2f}'.format(catch_rate[pokeball-1]*100) - berries_count = berries_count -1 - berry_used = True - self.emit_event( - 'threw_berry', - formatted="Threw a {berry_name}! Catch rate now: {new_catch_rate}", - data={ - "berry_name": self.item_list[str(berry_id)], - "new_catch_rate": success_percentage - } - ) - else: - if response_dict['status_code'] is 1: - self.emit_event( - 'softban', - level='warning', - formatted='Failed to use berry. You may be softbanned.' - ) - else: - self.emit_event( - 'threw_berry_failed', - formatted='Unknown response when throwing berry: {status_code}.', - data={ - 'status_code': response_dict['status_code'] - } - ) - - # Re-check if berry is used, find a ball for a good capture rate - current_type=pokeball - while current_type < 2: - current_type += 1 - if catch_rate[pokeball-1] < 0.35 and items_stock[current_type] > 0: - pokeball = current_type # use better ball - - # This is to avoid rare case that a berry has ben throwed <0.42 - # and still picking normal pokeball (out of stock) -> error - if items_stock[1] == 0 and items_stock[2] > 0: - pokeball = 2 - - # Add this logic to avoid Pokeball = 0, Great Ball = 0, Ultra Ball = X - # And this logic saves Ultra Balls if it's a weak trash pokemon - if catch_rate[pokeball-1]<0.30 and items_stock[3]>0: - pokeball = 3 - - items_stock[pokeball] -= 1 - success_percentage = '{0:.2f}'.format(catch_rate[pokeball - 1] * 100) - self.emit_event( - 'threw_pokeball', - formatted='Used {pokeball}, with chance {success_percentage} ({count_left} left)', - data={ - 'pokeball': self.item_list[str(pokeball)], - 'success_percentage': success_percentage, - 'count_left': items_stock[pokeball] - } - ) - id_list1 = self.count_pokemon_inventory() - - reticle_size_parameter = normalized_reticle_size(self.config.catch_randomize_reticle_factor) - spin_modifier_parameter = spin_modifier(self.config.catch_randomize_spin_factor) - - response_dict = self.api.catch_pokemon( - encounter_id=encounter_id, - pokeball=pokeball, - normalized_reticle_size=reticle_size_parameter, - spawn_point_id=self.spawn_point_guid, - hit_pokemon=1, - spin_modifier=spin_modifier_parameter, - normalized_hit_position=1 - ) - - if response_dict and \ - 'responses' in response_dict and \ - 'CATCH_POKEMON' in response_dict['responses'] and \ - 'status' in response_dict['responses']['CATCH_POKEMON']: - status = response_dict['responses'][ - 'CATCH_POKEMON']['status'] - if status is 2: - self.emit_event( - 'pokemon_fled', - formatted="{pokemon} fled.", - data={'pokemon': pokemon_name} - ) - sleep(2) - continue - if status is 3: - self.emit_event( - 'pokemon_vanished', - formatted="{pokemon} vanished!", - data={'pokemon': pokemon_name} - ) - if success_percentage == 100: - self.softban = True - if status is 1: - self.bot.metrics.captured_pokemon(pokemon_name, cp, iv_display, pokemon_potential) - - self.emit_event( - 'pokemon_caught', - formatted='Captured {pokemon}! [CP {cp}] [Potential {iv}] [{iv_display}] [+{exp} exp]', - data={ - 'pokemon': pokemon_name, - 'cp': cp, - 'iv': pokemon_potential, - 'iv_display': iv_display, - 'exp': sum(response_dict['responses']['CATCH_POKEMON']['capture_award']['xp']) - } - ) - self.bot.softban = False - - if (self.config.evolve_captured - and (self.config.evolve_captured[0] == 'all' - or pokemon_name in self.config.evolve_captured)): - id_list2 = self.count_pokemon_inventory() - # No need to capture this even for metrics, player stats includes it. - pokemon_to_transfer = list(set(id_list2) - set(id_list1)) - - # TODO dont throw RuntimeError, do something better - if len(pokemon_to_transfer) == 0: - raise RuntimeError( - 'Trying to evolve 0 pokemons!') - response_dict = self.api.evolve_pokemon(pokemon_id=pokemon_to_transfer[0]) - status = response_dict['responses']['EVOLVE_POKEMON']['result'] - if status == 1: - self.emit_event( - 'pokemon_evolved', - formatted="{pokemon} evolved!", - data={'pokemon': pokemon_name} - ) - else: - self.emit_event( - 'pokemon_evolve_fail', - formatted="Failed to evolve {pokemon}!", - data={'pokemon': pokemon_name} - ) - break - time.sleep(5) - - def count_pokemon_inventory(self): - # don't use cached bot.get_inventory() here - # because we need to have actual information in capture logic - response_dict = self.api.get_inventory() - - id_list = [] - callback = lambda pokemon: id_list.append(pokemon['id']) - self._foreach_pokemon_in_inventory(response_dict, callback) - return id_list - - def _foreach_pokemon_in_inventory(self, response_dict, callback): + return False try: - reduce(dict.__getitem__, [ - "responses", "GET_INVENTORY", "inventory_delta", "inventory_items"], response_dict) + responses = response_dict['responses'] + response = responses[self.response_key] + if response[self.response_status_key] != ENCOUNTER_STATUS_SUCCESS: + if response[self.response_status_key] == ENCOUNTER_STATUS_NOT_IN_RANGE: + self.emit_event('pokemon_not_in_range', formatted='Pokemon went out of range!') + elif response[self.response_status_key] == ENCOUNTER_STATUS_POKEMON_INVENTORY_FULL: + self.emit_event('pokemon_inventory_full', formatted='Your Pokemon inventory is full! Could not catch!') + return False except KeyError: - pass - else: - for item in response_dict['responses']['GET_INVENTORY']['inventory_delta']['inventory_items']: - try: - reduce(dict.__getitem__, [ - "inventory_item_data", "pokemon_data"], item) - except KeyError: - pass - else: - pokemon = item['inventory_item_data']['pokemon_data'] - if not pokemon.get('is_egg', False): - callback(pokemon) - - def pokemon_potential(self, pokemon_data): - total_iv = 0 - iv_stats = ['individual_attack', 'individual_defense', 'individual_stamina'] - - for individual_stat in iv_stats: - try: - total_iv += pokemon_data[individual_stat] - except: - pokemon_data[individual_stat] = 0 - continue - - return round((total_iv / 45.0), 2) - - def should_capture_pokemon(self, pokemon_name, cp, iv, response_dict): - catch_config = self._get_catch_config_for(pokemon_name) - cp_iv_logic = catch_config.get('logic') - if not cp_iv_logic: - cp_iv_logic = self._get_catch_config_for('any').get('logic', 'and') - - catch_results = { - 'cp': False, - 'iv': False, - } - - if catch_config.get('never_catch', False): return False - if catch_config.get('always_catch', False): - return True - - catch_cp = catch_config.get('catch_above_cp', 0) - if cp > catch_cp: - catch_results['cp'] = True + # get pokemon data + pokemon_data = response['wild_pokemon']['pokemon_data'] if 'wild_pokemon' in response else response['pokemon_data'] + pokemon = Pokemon(self.pokemon_list, pokemon_data) - catch_iv = catch_config.get('catch_above_iv', 0) - if iv > catch_iv: - catch_results['iv'] = True - - logic_to_function = { - 'or': lambda x, y: x or y, - 'and': lambda x, y: x and y - } + # skip ignored pokemon + if not self._should_catch_pokemon(pokemon): + return False - return logic_to_function[cp_iv_logic](*catch_results.values()) + # log encounter + self.emit_event( + 'pokemon_appeared', + formatted='A wild {pokemon} appeared! [CP {cp}] [Potential {iv}] [A/D/S {iv_display}]', + data={ + 'pokemon': pokemon.name, + 'cp': pokemon.cp, + 'iv': pokemon.iv, + 'iv_display': pokemon.iv_display, + } + ) + + # simulate app + sleep(3) + + # check for VIP pokemon + is_vip = self._is_vip_pokemon(pokemon) + if is_vip: + self.emit_event('vip_pokemon', formatted='This is a VIP pokemon. Catch!!!') + + # catch that pokemon! + encounter_id = self.pokemon['encounter_id'] + catch_rate_by_ball = [0] + response['capture_probability']['capture_probability'] # offset so item ids match indces + self._do_catch(pokemon, encounter_id, catch_rate_by_ball, is_vip=is_vip) - def _get_catch_config_for(self, pokemon): - catch_config = self.config.catch.get(pokemon) - if not catch_config: - catch_config = self.config.catch.get('any') - return catch_config + # simulate app + time.sleep(5) def create_encounter_api_call(self): encounter_id = self.pokemon['encounter_id'] @@ -491,29 +146,273 @@ def create_encounter_api_call(self): ) return request.call() - def check_vip_pokemon(self,pokemon, cp, iv): + ############################################################################ + # helpers + ############################################################################ + + def _pokemon_matches_config(self, config, pokemon, default_logic='and'): + pokemon_config = config.get(pokemon.name, config.get('any')) + + if not pokemon_config: + return False - vip_name = self.config.vips.get(pokemon) - if vip_name == {}: - return True - else: - catch_config = self.config.vips.get("any") - if not catch_config: - return False - cp_iv_logic = catch_config.get('logic', 'or') catch_results = { 'cp': False, 'iv': False, } - catch_cp = catch_config.get('catch_above_cp', 0) - if cp > catch_cp: + if pokemon_config.get('never_catch', False): + return False + + if pokemon_config.get('always_catch', False): + return True + + catch_cp = pokemon_config.get('catch_above_cp', 0) + if pokemon.cp > catch_cp: catch_results['cp'] = True - catch_iv = catch_config.get('catch_above_iv', 0) - if iv > catch_iv: + + catch_iv = pokemon_config.get('catch_above_iv', 0) + if pokemon.iv > catch_iv: catch_results['iv'] = True - logic_to_function = { - 'or': lambda x, y: x or y, - 'and': lambda x, y: x and y - } - return logic_to_function[cp_iv_logic](*catch_results.values()) + + return LOGIC_TO_FUNCTION[pokemon_config.get('logic', default_logic)](*catch_results.values()) + + def _should_catch_pokemon(self, pokemon): + return self._pokemon_matches_config(self.config.catch, pokemon) + + def _is_vip_pokemon(self, pokemon): + # having just a name present in the list makes them vip + if self.config.vips.get(pokemon.name) == {}: + return True + return self._pokemon_matches_config(self.config.vips, pokemon, default_logic='or') + + def _get_current_pokemon_ids(self): + # don't use cached bot.get_inventory() here because we need to have actual information in capture logic + response_dict = self.api.get_inventory() + + try: + inventory_items = response_dict['responses']['GET_INVENTORY']['inventory_delta']['inventory_items'] + except KeyError: + return [] # no items + + id_list = [] + for item in inventory_items: + try: + pokemon = item['inventory_item_data']['pokemon_data'] + except KeyError: + continue + + # ignore eggs + if pokemon.get('is_egg'): + continue + + id_list.append(pokemon['id']) + + return id_list + + def _pct(self, rate_by_ball): + return '{0:.2f}'.format(rate_by_ball * 100) + + def _use_berry(self, berry_id, berry_count, encounter_id, catch_rate_by_ball, current_ball): + new_catch_rate_by_ball = [] + self.emit_event( + 'pokemon_catch_rate', + level='debug', + formatted='Catch rate of {catch_rate} with {ball_name} is low. Throwing {berry_name} (have {berry_count})', + data={ + 'catch_rate': self._pct(catch_rate_by_ball[current_ball]), + 'ball_name': self.item_list[str(current_ball)], + 'berry_name': self.item_list[str(berry_id)], + 'berry_count': berry_count + } + ) + + response_dict = self.api.use_item_capture( + item_id=berry_id, + encounter_id=encounter_id, + spawn_point_id=self.spawn_point_guid + ) + responses = response_dict['responses'] + + if response_dict and response_dict['status_code'] == 1: + + # update catch rates using multiplier + if 'item_capture_mult' in responses['USE_ITEM_CAPTURE']: + for rate in catch_rate_by_ball: + new_catch_rate_by_ball.append(rate * responses['USE_ITEM_CAPTURE']['item_capture_mult']) + self.emit_event( + 'threw_berry', + formatted="Threw a {berry_name}! Catch rate with {ball_name} is now: {new_catch_rate}", + data={ + 'berry_name': self.item_list[str(berry_id)], + 'ball_name': self.item_list[str(current_ball)], + 'new_catch_rate': self._pct(catch_rate_by_ball[current_ball]) + } + ) + + # softban? + else: + self.emit_event( + 'softban', + level='warning', + formatted='Failed to use berry. You may be softbanned.' + ) + + # unknown status code + else: + self.emit_event( + 'threw_berry_failed', + formatted='Unknown response when throwing berry: {status_code}.', + data={ + 'status_code': response_dict['status_code'] + } + ) + + return new_catch_rate_by_ball + + def _do_catch(self, pokemon, encounter_id, catch_rate_by_ball, is_vip=False): + # settings that may be exposed at some point + berry_id = ITEM_RAZZBERRY + maximum_ball = ITEM_ULTRABALL if is_vip else ITEM_GREATBALL + ideal_catch_rate_before_throw = 0.9 if is_vip else 0.35 + + berry_count = self.bot.item_inventory_count(berry_id) + items_stock = self.bot.current_inventory() + + while True: + + # find lowest available ball + current_ball = ITEM_POKEBALL + while items_stock[current_ball] == 0 and current_ball < maximum_ball: + current_ball += 1 + if items_stock[current_ball] == 0: + self.emit_event('no_pokeballs', formatted='No usable pokeballs found!') + break + + # check future ball count + num_next_balls = 0 + next_ball = current_ball + while next_ball < maximum_ball: + next_ball += 1 + num_next_balls += items_stock[next_ball] + + # check if we've got berries to spare + berries_to_spare = berry_count > 0 if is_vip else berry_count > num_next_balls + 30 + + # use a berry if we are under our ideal rate and have berries to spare + used_berry = False + if catch_rate_by_ball[current_ball] < ideal_catch_rate_before_throw and berries_to_spare: + catch_rate_by_ball = self._use_berry(berry_id, berry_count, encounter_id, catch_rate_by_ball, current_ball) + berry_count -= 1 + used_berry = True + + # pick the best ball to catch with + best_ball = current_ball + while best_ball < maximum_ball: + best_ball += 1 + if catch_rate_by_ball[current_ball] < ideal_catch_rate_before_throw and items_stock[best_ball] > 0: + # if current ball chance to catch is under our ideal rate, and player has better ball - then use it + current_ball = best_ball + + # if the rate is still low and we didn't throw a berry before, throw one + if catch_rate_by_ball[current_ball] < ideal_catch_rate_before_throw and berry_count > 0 and not used_berry: + catch_rate_by_ball = self._use_berry(berry_id, berry_count, encounter_id, catch_rate_by_ball, current_ball) + berry_count -= 1 + + # get current pokemon list before catch + pokemon_before_catch = self._get_current_pokemon_ids() + + # try to catch pokemon! + items_stock[current_ball] -= 1 + self.emit_event( + 'threw_pokeball', + formatted='Used {ball_name}, with chance {success_percentage} ({count_left} left)', + data={ + 'ball_name': self.item_list[str(current_ball)], + 'success_percentage': self._pct(catch_rate_by_ball[current_ball]), + 'count_left': items_stock[current_ball] + } + ) + + reticle_size_parameter = normalized_reticle_size(self.config.catch_randomize_reticle_factor) + spin_modifier_parameter = spin_modifier(self.config.catch_randomize_spin_factor) + + response_dict = self.api.catch_pokemon( + encounter_id=encounter_id, + pokeball=current_ball, + normalized_reticle_size=reticle_size_parameter, + spawn_point_id=self.spawn_point_guid, + hit_pokemon=1, + spin_modifier=spin_modifier_parameter, + normalized_hit_position=1 + ) + + try: + catch_pokemon_status = response_dict['responses']['CATCH_POKEMON']['status'] + except KeyError: + break + + # retry failed pokemon + if catch_pokemon_status == CATCH_STATUS_FAILED: + self.emit_event( + 'pokemon_capture_failed', + formatted='{pokemon} capture failed.. trying again!', + data={'pokemon': pokemon.name} + ) + sleep(2) + continue + + # abandon if pokemon vanished + elif catch_pokemon_status == CATCH_STATUS_VANISHED: + self.emit_event( + 'pokemon_vanished', + formatted='{pokemon} vanished!', + data={'pokemon': pokemon.name} + ) + if self._pct(catch_rate_by_ball[current_ball]) == 100: + self.bot.softban = True + + # pokemon caught! + elif catch_pokemon_status == CATCH_STATUS_SUCCESS: + self.bot.metrics.captured_pokemon(pokemon.name, pokemon.cp, pokemon.iv_display, pokemon.iv) + self.emit_event( + 'pokemon_caught', + formatted='Captured {pokemon}! [CP {cp}] [Potential {iv}] [{iv_display}] [+{exp} exp]', + data={ + 'pokemon': pokemon.name, + 'cp': pokemon.cp, + 'iv': pokemon.iv, + 'iv_display': pokemon.iv_display, + 'exp': sum(response_dict['responses']['CATCH_POKEMON']['capture_award']['xp']) + } + ) + self.bot.softban = False + + # evolve pokemon if necessary + if self.config.evolve_captured and (self.config.evolve_captured[0] == 'all' or pokemon.name in self.config.evolve_captured): + pokemon_after_catch = self._get_current_pokemon_ids() + pokemon_to_evolve = list(set(pokemon_after_catch) - set(pokemon_before_catch)) + + if len(pokemon_to_evolve) == 0: + break + + self._do_evolve(pokemon, pokemon_to_evolve[0]) + + break + + def _do_evolve(self, pokemon, new_pokemon_id): + response_dict = self.api.evolve_pokemon(pokemon_id=new_pokemon_id) + catch_pokemon_status = response_dict['responses']['EVOLVE_POKEMON']['result'] + + if catch_pokemon_status == 1: + self.emit_event( + 'pokemon_evolved', + formatted='{pokemon} evolved!', + data={'pokemon': pokemon.name} + ) + else: + self.emit_event( + 'pokemon_evolve_fail', + formatted='Failed to evolve {pokemon}!', + data={'pokemon': pokemon.name} + ) diff --git a/pokemongo_bot/plugin_loader.py b/pokemongo_bot/plugin_loader.py index 7a838bb209..f7e12a85a7 100644 --- a/pokemongo_bot/plugin_loader.py +++ b/pokemongo_bot/plugin_loader.py @@ -3,6 +3,8 @@ import importlib import re import requests +import zipfile +import shutil class PluginLoader(object): folder_cache = [] @@ -20,10 +22,11 @@ def _get_correct_path(self, path): def load_plugin(self, plugin): github_plugin = GithubPlugin(plugin) if github_plugin.is_valid_plugin(): - if not github_plugin.is_already_downloaded(): - github_plugin.download() + if not github_plugin.is_already_installed(): + github_plugin.install() + + correct_path = github_plugin.get_plugin_folder() - correct_path = github_plugin.get_local_destination() else: correct_path = self._get_correct_path(plugin) @@ -42,6 +45,8 @@ def get_class(self, namespace_class): return getattr(my_module, class_name) class GithubPlugin(object): + PLUGINS_FOLDER = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'plugins') + def __init__(self, plugin_name): self.plugin_name = plugin_name self.plugin_parts = self.get_github_parts() @@ -62,18 +67,45 @@ def get_github_parts(self): return parts + def get_installed_version(self): + if not self.is_already_installed(): + return None + + filename = os.path.join(self.get_plugin_folder(), '.sha') + print filename + with open(filename) as file: + return file.read().strip() + def get_local_destination(self): parts = self.plugin_parts if parts is None: raise Exception('Not a valid github plugin') file_name = '{}_{}_{}.zip'.format(parts['user'], parts['repo'], parts['sha']) - full_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'plugins', file_name) + full_path = os.path.join(self.PLUGINS_FOLDER, file_name) return full_path - def is_already_downloaded(self): - file_path = self.get_local_destination() - return os.path.isfile(file_path) + def is_already_installed(self): + file_path = self.get_plugin_folder() + if not os.path.isdir(file_path): + return False + + sha_file = os.path.join(file_path, '.sha') + + if not os.path.isfile(sha_file): + return False + + with open(sha_file) as file: + content = file.read().strip() + + if content != self.plugin_parts['sha']: + return False + + return True + + def get_plugin_folder(self): + folder_name = '{}_{}'.format(self.plugin_parts['user'], self.plugin_parts['repo']) + return os.path.join(self.PLUGINS_FOLDER, folder_name) def get_github_download_url(self): parts = self.plugin_parts @@ -83,6 +115,24 @@ def get_github_download_url(self): github_url = 'https://github.com/{}/{}/archive/{}.zip'.format(parts['user'], parts['repo'], parts['sha']) return github_url + def install(self): + self.download() + self.extract() + + def extract(self): + dest = self.get_plugin_folder() + with zipfile.ZipFile(self.get_local_destination(), "r") as z: + z.extractall(dest) + + github_folder = os.path.join(dest, '{}-{}'.format(self.plugin_parts['repo'], self.plugin_parts['sha'])) + new_folder = os.path.join(dest, '{}'.format(self.plugin_parts['repo'])) + shutil.move(github_folder, new_folder) + + with open(os.path.join(dest, '.sha'), 'w') as file: + file.write(self.plugin_parts['sha']) + + os.remove(self.get_local_destination()) + def download(self): url = self.get_github_download_url() dest = self.get_local_destination() diff --git a/pokemongo_bot/test/plugin_loader_test.py b/pokemongo_bot/test/plugin_loader_test.py index 0b0f7da9d1..ed285ede67 100644 --- a/pokemongo_bot/test/plugin_loader_test.py +++ b/pokemongo_bot/test/plugin_loader_test.py @@ -11,6 +11,8 @@ from pokemongo_bot.plugin_loader import PluginLoader, GithubPlugin from pokemongo_bot.test.resources.plugin_fixture import FakeTask +PLUGIN_PATH = os.path.realpath(os.path.join(os.path.abspath(os.path.dirname(__file__)), '..', 'plugins')) + class PluginLoaderTest(unittest.TestCase): def setUp(self): self.plugin_loader = PluginLoader() @@ -26,31 +28,39 @@ def test_load_zip(self): package_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'resources', 'plugin_fixture_test.zip') self.plugin_loader.load_plugin(package_path) loaded_class = self.plugin_loader.get_class('plugin_fixture_test.FakeTask') - self.assertEqual(loaded_class({}, {}).work(), 'FakeTask') + self.assertEqual(loaded_class({}, {}).work(), 'FakeTaskZip') self.plugin_loader.remove_path(package_path) - def copy_zip(self): - zip_fixture = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'resources', 'plugin_fixture_test.zip') - dest_path = os.path.realpath(os.path.join(os.path.abspath(os.path.dirname(__file__)), '..', 'plugins', 'org_repo_sha.zip')) - shutil.copyfile(zip_fixture, dest_path) + def copy_plugin(self): + package_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'resources', 'plugin_fixture') + dest_path = os.path.join(PLUGIN_PATH, 'org_repo', 'plugin_fixture_tests') + shutil.copytree(package_path, os.path.join(dest_path)) + with open(os.path.join(os.path.dirname(dest_path), '.sha'), 'w') as file: + file.write('testsha') return dest_path def test_load_github_already_downloaded(self): - dest_path = self.copy_zip() - self.plugin_loader.load_plugin('org/repo#sha') - loaded_class = self.plugin_loader.get_class('plugin_fixture_test.FakeTask') + dest_path = self.copy_plugin() + self.plugin_loader.load_plugin('org/repo#testsha') + loaded_class = self.plugin_loader.get_class('plugin_fixture_tests.FakeTask') self.assertEqual(loaded_class({}, {}).work(), 'FakeTask') self.plugin_loader.remove_path(dest_path) - os.remove(dest_path) + shutil.rmtree(os.path.dirname(dest_path)) + + def copy_zip(self): + zip_name = 'test-pgo-plugin-2d54eddde33061be9b329efae0cfb9bd58842655.zip' + fixture_zip = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'resources', zip_name) + zip_dest = os.path.join(PLUGIN_PATH, 'org_test-pgo-plugin_2d54eddde33061be9b329efae0cfb9bd58842655.zip') + shutil.copyfile(fixture_zip, zip_dest) @mock.patch.object(GithubPlugin, 'download', copy_zip) def test_load_github_not_downloaded(self): - self.plugin_loader.load_plugin('org/repo#sha') - loaded_class = self.plugin_loader.get_class('plugin_fixture_test.FakeTask') - self.assertEqual(loaded_class({}, {}).work(), 'FakeTask') - dest_path = os.path.realpath(os.path.join(os.path.abspath(os.path.dirname(__file__)), '..', 'plugins', 'org_repo_sha.zip')) - self.plugin_loader.remove_path(dest_path) - os.remove(dest_path) + self.plugin_loader.load_plugin('org/test-pgo-plugin#2d54eddde33061be9b329efae0cfb9bd58842655') + loaded_class = self.plugin_loader.get_class('test-pgo-plugin.PrintText') + self.assertEqual(loaded_class({}, {}).work(), 'PrintText') + dest_path = os.path.join(PLUGIN_PATH, 'org_test-pgo-plugin') + self.plugin_loader.remove_path(os.path.join(dest_path, 'test-pgo-plugin')) + shutil.rmtree(dest_path) class GithubPluginTest(unittest.TestCase): def test_get_github_parts_for_valid_github(self): @@ -65,10 +75,25 @@ def test_get_github_parts_for_invalid_github(self): self.assertFalse(GithubPlugin('foo').is_valid_plugin()) self.assertFalse(GithubPlugin('/Users/foo/bar.zip').is_valid_plugin()) + def test_get_installed_version(self): + github_plugin = GithubPlugin('org/repo#my-version') + src_fixture = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'resources', 'plugin_sha') + dest = github_plugin.get_plugin_folder() + shutil.copytree(src_fixture, dest) + actual = github_plugin.get_installed_version() + shutil.rmtree(dest) + self.assertEqual('my-version', actual) + + def test_get_plugin_folder(self): + github_plugin = GithubPlugin('org/repo#sha') + expected = os.path.join(PLUGIN_PATH, 'org_repo') + actual = github_plugin.get_plugin_folder() + self.assertEqual(actual, expected) + def test_get_local_destination(self): github_plugin = GithubPlugin('org/repo#sha') path = github_plugin.get_local_destination() - expected = os.path.realpath(os.path.join(os.path.abspath(os.path.dirname(__file__)), '..', 'plugins', 'org_repo_sha.zip')) + expected = os.path.join(PLUGIN_PATH, 'org_repo_sha.zip') self.assertEqual(path, expected) def test_get_github_download_url(self): @@ -77,13 +102,47 @@ def test_get_github_download_url(self): expected = 'https://github.com/org/repo/archive/sha.zip' self.assertEqual(url, expected) - def test_is_already_downloaded_not_downloaded(self): + def test_is_already_installed_not_installed(self): + github_plugin = GithubPlugin('org/repo#sha') + self.assertFalse(github_plugin.is_already_installed()) + + def test_is_already_installed_version_mismatch(self): github_plugin = GithubPlugin('org/repo#sha') - self.assertFalse(github_plugin.is_already_downloaded()) + plugin_folder = github_plugin.get_plugin_folder() + os.mkdir(plugin_folder) + with open(os.path.join(plugin_folder, '.sha'), 'w') as file: + file.write('sha2') - def test_is_already_downloaded_downloaded(self): + actual = github_plugin.is_already_installed() + shutil.rmtree(plugin_folder) + self.assertFalse(actual) + + def test_is_already_installed_installed(self): github_plugin = GithubPlugin('org/repo#sha') - dest = github_plugin.get_local_destination() - open(dest, 'a').close() - self.assertTrue(github_plugin.is_already_downloaded()) - os.remove(dest) + plugin_folder = github_plugin.get_plugin_folder() + os.mkdir(plugin_folder) + with open(os.path.join(plugin_folder, '.sha'), 'w') as file: + file.write('sha') + + actual = github_plugin.is_already_installed() + shutil.rmtree(plugin_folder) + self.assertTrue(actual) + + def test_extract(self): + github_plugin = GithubPlugin('org/test-pgo-plugin#2d54eddde33061be9b329efae0cfb9bd58842655') + src = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'resources', 'test-pgo-plugin-2d54eddde33061be9b329efae0cfb9bd58842655.zip') + zip_dest = github_plugin.get_local_destination() + shutil.copyfile(src, zip_dest) + github_plugin.extract() + plugin_folder = github_plugin.get_plugin_folder() + os.path.isdir(plugin_folder) + sub_folder = os.path.join(plugin_folder, 'test-pgo-plugin') + os.path.isdir(sub_folder) + sha_file = os.path.join(github_plugin.get_plugin_folder(), '.sha') + os.path.isfile(sha_file) + + with open(sha_file) as file: + content = file.read().strip() + self.assertEqual(content, '2d54eddde33061be9b329efae0cfb9bd58842655') + + shutil.rmtree(plugin_folder) diff --git a/pokemongo_bot/test/resources/plugin_fixture_test.zip b/pokemongo_bot/test/resources/plugin_fixture_test.zip index 335d95e522..78828798c3 100644 Binary files a/pokemongo_bot/test/resources/plugin_fixture_test.zip and b/pokemongo_bot/test/resources/plugin_fixture_test.zip differ diff --git a/pokemongo_bot/test/resources/plugin_sha/.sha b/pokemongo_bot/test/resources/plugin_sha/.sha new file mode 100644 index 0000000000..eaf604c9ac --- /dev/null +++ b/pokemongo_bot/test/resources/plugin_sha/.sha @@ -0,0 +1 @@ +my-version diff --git a/pokemongo_bot/test/resources/test-pgo-plugin-2d54eddde33061be9b329efae0cfb9bd58842655.zip b/pokemongo_bot/test/resources/test-pgo-plugin-2d54eddde33061be9b329efae0cfb9bd58842655.zip new file mode 100644 index 0000000000..a692ac3f08 Binary files /dev/null and b/pokemongo_bot/test/resources/test-pgo-plugin-2d54eddde33061be9b329efae0cfb9bd58842655.zip differ diff --git a/requirements.txt b/requirements.txt index aabf40937d..f6a22a0233 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ gpxpy==1.1.1 mock==2.0.0 timeout-decorator==0.3.2 raven==5.23.0 +demjson==2.2.4