Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

currency: Moved to different API, support multiple target currencies, cache rates #1430

Merged
merged 1 commit into from
May 1, 2019
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 186 additions & 72 deletions sopel/modules/currency.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,109 +2,223 @@
"""
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
"""
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
dgw marked this conversation as resolved.
Show resolved Hide resolved
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'
Duckle29 marked this conversation as resolved.
Show resolved Hide resolved
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 = {}
Duckle29 marked this conversation as resolved.
Show resolved Hide resolved
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):
Duckle29 marked this conversation as resolved.
Show resolved Hide resolved
"""
| 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):
dgw marked this conversation as resolved.
Show resolved Hide resolved
"""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']:
Duckle29 marked this conversation as resolved.
Show resolved Hide resolved
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:
Duckle29 marked this conversation as resolved.
Show resolved Hide resolved
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())
kwaaak marked this conversation as resolved.
Show resolved Hide resolved

for to in others:
try:
out_string = build_reply(amount, of.upper(), to.upper(), out_string)
except ValueError:
kwaaak marked this conversation as resolved.
Show resolved Hide resolved
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)