diff --git a/sopel/modules/currency.py b/sopel/modules/currency.py index 5d7488c25b..43c77e3289 100644 --- a/sopel/modules/currency.py +++ b/sopel/modules/currency.py @@ -2,6 +2,7 @@ """ currency.py - Sopel Currency Conversion Module Copyright 2013, Elsie Powell, embolalia.com +Copyright 2019, Mikkel Jeppesen Licensed under the Eiffel Forum License 2. https://sopel.chat @@ -9,102 +10,215 @@ from __future__ import unicode_literals, absolute_import, print_function, division import re +import time -from requests import get +import requests -from sopel.module import commands, example, NOLIMIT +from sopel.config.types import StaticSection, ValidatedAttribute +from sopel.logger import get_logger +from sopel.module import commands, example, NOLIMIT, rule -# The Canadian central bank has better exchange rate data than the Fed, the -# Bank of England, or the European Central Bank. Who knew? -base_url = 'https://www.bankofcanada.ca/valet/observations/FX{}CAD/json' -regex = re.compile(r''' - (\d+(?:\.\d+)?) # Decimal number - \s*([a-zA-Z]{3}) # 3-letter currency code - \s+(?:in|as|of|to)\s+ # preposition - ([a-zA-Z]{3}) # 3-letter currency code - ''', re.VERBOSE) +FIAT_URL = 'https://api.exchangeratesapi.io/latest?base=EUR' +FIXER_URL = 'http://data.fixer.io/api/latest?base=EUR&access_key={}' +CRYPTO_URL = 'https://apiv2.bitcoinaverage.com/indices/global/ticker/short?crypto=BTC' +EXCHANGE_REGEX = re.compile(r''' + ^(\d+(?:\.\d+)?) # Decimal number + \s*([a-zA-Z]{3}) # 3-letter currency code + \s+(?:in|as|of|to)\s+ # preposition + (([a-zA-Z]{3}$)|([a-zA-Z]{3})\s)+$ # one or more 3-letter currency code +''', re.VERBOSE) +LOGGER = get_logger(__name__) +UNSUPPORTED_CURRENCY = "Sorry, {} isn't currently supported." +UNRECOGNIZED_INPUT = "Sorry, I didn't understand the input." +rates_fiat_json = {} +rates_btc_json = {} +rates_updated = 0.0 -def get_rate(code): - code = code.upper() - if code == 'CAD': - return 1, 'Canadian Dollar' - elif code == 'BTC': - btc_rate = get('https://apiv2.bitcoinaverage.com/indices/global/ticker/BTCCAD') - rates = btc_rate.json() - return 1 / rates['averages']['day'], 'Bitcoin—24hr average' - data = get(base_url.format(code)) - name = data.json()['seriesDetail']['FX{}CAD'.format(code)]['description'] - name = name.split(" to Canadian")[0] - json = data.json()['observations'] - for element in reversed(json): - if 'v' in element['FX{}CAD'.format(code)]: - return 1 / float(element['FX{}CAD'.format(code)]['v']), name +class CurrencySection(StaticSection): + fixer_io_key = ValidatedAttribute('fixer_io_key', default=None) + """Optional API key for Fixer.io (increases currency support)""" + auto_convert = ValidatedAttribute('auto_convert', parse=bool, default=False) + """Whether to convert currencies without an explicit command""" -@commands('cur', 'currency', 'exchange') -@example('.cur 20 EUR in USD') -def exchange(bot, trigger): +def configure(config): + """ + | name | example | purpose | + | ---- | ------- | ------- | + | auto\\_convert | False | Whether to convert currencies without an explicit command | + | fixer\\_io\\_key | 0123456789abcdef0123456789abcdef | Optional API key for Fixer.io (increases currency support) | + """ + config.define_section('currency', CurrencySection, validate=False) + config.currency.configure_setting('fixer_io_key', 'Optional API key for Fixer.io (leave blank to use exchangeratesapi.io):') + config.currency.configure_setting('auto_convert', 'Whether to convert currencies without an explicit command?') + + +def setup(bot): + bot.config.define_section('currency', CurrencySection) + + +class FixerError(Exception): + """A Fixer.io API Error Exception""" + def __init__(self, status): + super(FixerError, self).__init__("FixerError: {}".format(status)) + + +class UnsupportedCurrencyError(Exception): + """A currency is currently not supported by the API""" + def __init__(self, currency): + super(UnsupportedCurrencyError, self).__init__(currency) + + +def update_rates(bot): + global rates_fiat_json, rates_btc_json, rates_updated + + # If we have data that is less than 24h old, return + if time.time() - rates_updated < 24 * 60 * 60: + return + + # Update crypto rates + response = requests.get(CRYPTO_URL) + response.raise_for_status() + rates_btc_json = response.json() + + # Update fiat rates + if bot.config.currency.fixer_io_key is not None: + response = requests.get(FIXER_URL.format(bot.config.currency.fixer_io_key)) + if not response.json()['success']: + raise FixerError('Fixer.io request failed with error: {}'.format(response.json()['error'])) + else: + response = requests.get(FIAT_URL) + + response.raise_for_status() + rates_fiat_json = response.json() + rates_updated = time.time() + rates_fiat_json['rates']['EUR'] = 1.0 # Put this here to make logic easier + + +def btc_rate(code, reverse=False): + search = 'BTC{}'.format(code) + + if search in rates_btc_json: + rate = rates_btc_json[search]['averages']['day'] + else: + raise UnsupportedCurrencyError(code) + + if reverse: + return 1 / rate + else: + return rate + + +def get_rate(of, to): + of = of.upper() + to = to.upper() + + if of == 'BTC': + return btc_rate(to, False) + elif to == 'BTC': + return btc_rate(of, True) + + if of not in rates_fiat_json['rates']: + raise UnsupportedCurrencyError(of) + + if to not in rates_fiat_json['rates']: + raise UnsupportedCurrencyError(to) + + return (1 / rates_fiat_json['rates'][of]) * rates_fiat_json['rates'][to] + + +def exchange(bot, match): """Show the exchange rate between two currencies""" - if not trigger.group(2): - return bot.reply("No search term. An example: .cur 20 EUR in USD") - match = regex.match(trigger.group(2)) if not match: - # It's apologetic, because it's using Canadian data. - bot.reply("Sorry, I didn't understand the input.") + bot.reply(UNRECOGNIZED_INPUT) return NOLIMIT - amount, of, to = match.groups() try: - amount = float(amount) - except ValueError: - bot.reply("Sorry, I didn't understand the input.") - except OverflowError: - bot.reply("Sorry, input amount was out of range.") - display(bot, amount, of, to) - - -def display(bot, amount, of, to): - if not amount: - bot.reply("Zero is zero, no matter what country you're in.") - try: - of_rate, of_name = get_rate(of) - if not of_name: - bot.reply("Unknown currency: %s" % of) - return - to_rate, to_name = get_rate(to) - if not to_name: - bot.reply("Unknown currency: %s" % to) - return - except Exception: # TODO: Be specific + update_rates(bot) # Try and update rates. Rate-limiting is done in update_rates() + except requests.exceptions.RequestException as err: bot.reply("Something went wrong while I was getting the exchange rate.") + LOGGER.error("Error in GET request: {}".format(err)) + return NOLIMIT + except ValueError: + bot.reply("Error: Got malformed data.") + LOGGER.error("Invalid json on update_rates") + return NOLIMIT + except FixerError as err: + bot.reply('Sorry, something went wrong with Fixer') + LOGGER.error(err) return NOLIMIT - result = amount / of_rate * to_rate - bot.say("{:.2f} {} ({}) = {:.2f} {} ({})".format(amount, of.upper(), of_name, - result, to.upper(), to_name)) + query = match.string + others = query.split() + amount = others.pop(0) + of = others.pop(0) + others.pop(0) -@commands('btc', 'bitcoin') -@example('.btc 20 EUR') -def bitcoin(bot, trigger): - """Convert between Bitcoin and a fiat currency""" - # if 2 args, 1st is number and 2nd is currency. If 1 arg, it's either the number or the currency. - to = trigger.group(4) - amount = trigger.group(3) - if not to: - to = trigger.group(3) or 'USD' - amount = 1 + # TODO: Use this instead after dropping Python 2 support + # amount, of, _, *others = query.split() try: amount = float(amount) except ValueError: - bot.reply("Sorry, I didn't understand the input.") - return NOLIMIT + bot.reply(UNRECOGNIZED_INPUT) except OverflowError: bot.reply("Sorry, input amount was out of range.") + + if not amount: + bot.reply("Zero is zero, no matter what country you're in.") return NOLIMIT - display(bot, amount, 'BTC', to) + out_string = '{} {} is'.format(amount, of.upper()) + + for to in others: + try: + out_string = build_reply(amount, of.upper(), to.upper(), out_string) + except ValueError: + LOGGER.error("Raw rate wasn't a float") + return NOLIMIT + except KeyError as err: + bot.reply("Error: Invalid rates") + LOGGER.error("No key: {} in json".format(err)) + return NOLIMIT + except UnsupportedCurrencyError as cur: + bot.reply(UNSUPPORTED_CURRENCY.format(cur)) + return NOLIMIT + + bot.reply(out_string[0:-1]) + + +@commands('cur', 'currency', 'exchange') +@example('.cur 100 usd in btc cad eur', + r'100\.0 USD is [\d\.]+ BTC, [\d\.]+ CAD, [\d\.]+ EUR', + re=True) +@example('.cur 3 can in one day', 'Sorry, CAN isn\'t currently supported.') +def exchange_cmd(bot, trigger): + if not trigger.group(2): + return bot.reply("No search term. Usage: {}cur 100 usd in btc cad eur" + .format(bot.config.core.help_prefix)) + + match = EXCHANGE_REGEX.match(trigger.group(2)) + exchange(bot, match) + + +@rule(EXCHANGE_REGEX) +@example('100 usd in btc cad eur') +def exchange_re(bot, trigger): + if bot.config.currency.auto_convert: + match = EXCHANGE_REGEX.match(trigger) + exchange(bot, match) + + +def build_reply(amount, of, to, out_string): + rate_raw = get_rate(of, to) + rate = float(rate_raw) + result = float(rate * amount) + + if to == 'BTC': + return out_string + ' {:.5f} {},'.format(result, to) + + return out_string + ' {:.2f} {},'.format(result, to)