aboutsummaryrefslogtreecommitdiff
path: root/currency.liteonly.py
diff options
context:
space:
mode:
Diffstat (limited to 'currency.liteonly.py')
-rw-r--r--currency.liteonly.py205
1 files changed, 205 insertions, 0 deletions
diff --git a/currency.liteonly.py b/currency.liteonly.py
new file mode 100644
index 0000000..7288676
--- /dev/null
+++ b/currency.liteonly.py
@@ -0,0 +1,205 @@
+"""
+Based on currency.py - Sopel Currency Conversion Plugin
+Copyright 2013, Elsie Powell, embolalia.com
+Copyright 2019, Mikkel Jeppesen
+Licensed under the Eiffel Forum License 2.
+
+Original currency.py: https://sopel.chat
+"""
+
+import logging
+import re
+import time
+import urllib.parse
+
+import requests
+
+
+# Which data provider to use (some of which require no API key)
+FIAT_PROVIDER = 'exchangerate.host'
+
+FIXER_IO_KEY = None
+FIXER_USE_SSL = False
+
+
+FIAT_PROVIDERS = {
+ 'exchangerate.host': 'https://api.exchangerate.host/latest?base=EUR',
+ 'fixer.io': '//data.fixer.io/api/latest?base=EUR&access_key={}',
+}
+CRYPTO_URL = 'https://api.coingecko.com/api/v3/exchange_rates'
+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 = logging.getLogger(__name__)
+UNSUPPORTED_CURRENCY = "Sorry, {} isn't currently supported."
+USAGE = "Usage: .cur 100 usd in btc cad eur"
+UNRECOGNIZED_INPUT = f"Sorry, I didn't understand the input. {USAGE}"
+
+rates = {}
+rates_updated = 0.0
+
+
+class FixerError(Exception):
+ """A Fixer.io API Error Exception"""
+ def __init__(self, status):
+ super().__init__("FixerError: {}".format(status))
+
+
+class UnsupportedCurrencyError(Exception):
+ """A currency is currently not supported by the API"""
+ def __init__(self, currency):
+ super().__init__(currency)
+
+
+def build_reply(amount, base, target, out_string):
+ rate_raw = get_rate(base, target)
+ rate = float(rate_raw)
+ result = float(rate * amount)
+
+ digits = 0
+ # up to 10 (8+2) digits precision when result is less than 1
+ # as smaller results need more precision
+ while digits < 8 and 1 / 10**digits > result:
+ digits += 1
+
+ digits += 2
+
+ out_string += ' {value:,.{precision}f} {currency},'.format(
+ value=result, precision=digits, currency=target
+ )
+
+ return out_string
+
+
+def exchange(match):
+ """Show the exchange rate between two currencies"""
+ if not match:
+ return UNRECOGNIZED_INPUT
+
+ # Try and update rates. Rate-limiting is done in update_rates()
+ try:
+ update_rates()
+ except requests.exceptions.RequestException as err:
+ traceback.print_exc()
+ return "Something went wrong while I was getting the exchange rate."
+ except (KeyError, ValueError) as err:
+ traceback.print_exc()
+ return "Error: Could not update exchange rates. Try again later."
+ except FixerError as err:
+ traceback.print_exc()
+ return 'Sorry, something went wrong with Fixer'
+
+ query = match.string
+ amount_in, base, _, *targets = query.split()
+
+ try:
+ amount = float(amount_in)
+ except ValueError:
+ return UNRECOGNIZED_INPUT
+ except OverflowError:
+ return "Sorry, input amount was out of range."
+
+ if not amount:
+ return "Zero is zero, no matter what country you're in."
+
+ out_string = '{} {} is'.format(amount_in, base.upper())
+
+ unsupported_currencies = []
+ for target in targets:
+ try:
+ out_string = build_reply(amount, base.upper(), target.upper(),
+ out_string)
+ except ValueError:
+ return "Error: Raw rate wasn't a float"
+ except KeyError as err:
+ return "Error: Invalid rates"
+ except UnsupportedCurrencyError as cur:
+ unsupported_currencies.append(cur)
+
+ if unsupported_currencies:
+ out_string = out_string + ' (unsupported:'
+ for target in unsupported_currencies:
+ out_string = out_string + ' {},'.format(target)
+ out_string = out_string[0:-1] + ')'
+ else:
+ out_string = out_string[0:-1]
+
+ return out_string
+
+
+def get_rate(base, target):
+ base = base.upper()
+ target = target.upper()
+
+ if base not in rates:
+ raise UnsupportedCurrencyError(base)
+
+ if target not in rates:
+ raise UnsupportedCurrencyError(target)
+
+ return (1 / rates[base]) * rates[target]
+
+
+def update_rates():
+ global rates, 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_crypto = response.json()
+
+ # Update fiat rates
+ if FIXER_IO_KEY is not None:
+ assert FIAT_PROVIDER == 'fixer.io'
+
+ proto = 'https:' if FIXER_USE_SSL else 'http:'
+ response = requests.get(
+ proto +
+ FIAT_PROVIDERS['fixer.io'].format(
+ urllib.parse.quote(FIXER_IO_KEY)
+ )
+ )
+
+ if not response.json()['success']:
+ raise FixerError('Fixer.io request failed with error: {}'.format(
+ response.json()['error']
+ ))
+ else:
+ LOGGER.debug('Updating fiat rates from %s', FIAT_PROVIDER)
+ response = requests.get(FIAT_PROVIDERS[FIAT_PROVIDER])
+
+ response.raise_for_status()
+ rates_fiat = response.json()
+
+ rates = rates_fiat['rates']
+ rates['EUR'] = 1.0 # Put this here to make logic easier
+
+ eur_btc_rate = 1 / rates_crypto['rates']['eur']['value']
+
+ for rate in rates_crypto['rates']:
+ if rate.upper() not in rates:
+ rates[rate.upper()] = (rates_crypto['rates'][rate]['value'] *
+ eur_btc_rate)
+
+ # if an error aborted the operation prematurely, we want the next call to
+ # retry updating rates therefore we'll update the stored timestamp at the
+ # last possible moment
+ rates_updated = time.time()
+
+
+@register_command('cur', 'currency', 'exchange')
+def exchange_cmd(irc, hostmask, is_admin, args):
+ """Show the exchange rate between two currencies."""
+ if param := args[1].strip():
+ msg = exchange(EXCHANGE_REGEX.match(param))
+ else:
+ msg = f"No search term. {USAGE}"
+
+ irc.msg(args[0], f'{hostmask[0]}: {msg}')