Passed
Push — master ( 0b2c08...8eb80d )
by dgw
02:17 queued 11s
created

sopel.modules.currency.update_rates()   B

Complexity

Conditions 6

Size

Total Lines 32
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
dl 0
loc 32
rs 8.4426
c 0
b 0
f 0
cc 6
nop 1
1
# coding=utf-8
2
"""
3
currency.py - Sopel Currency Conversion Module
4
Copyright 2013, Elsie Powell, embolalia.com
5
Copyright 2019, Mikkel Jeppesen
6
Licensed under the Eiffel Forum License 2.
7
8
https://sopel.chat
9
"""
10
from __future__ import unicode_literals, absolute_import, print_function, division
11
12
import re
13
import time
14
15
import requests
16
17
from sopel.config.types import StaticSection, ValidatedAttribute
18
from sopel.logger import get_logger
19
from sopel.module import commands, example, NOLIMIT, rule
20
21
22
FIAT_URL = 'https://api.exchangeratesapi.io/latest?base=EUR'
23
FIXER_URL = 'http://data.fixer.io/api/latest?base=EUR&access_key={}'
24
CRYPTO_URL = 'https://api.coingecko.com/api/v3/exchange_rates'
25
EXCHANGE_REGEX = re.compile(r'''
26
    ^(\d+(?:\.\d+)?)                                            # Decimal number
27
    \s*([a-zA-Z]{3})                                            # 3-letter currency code
28
    \s+(?:in|as|of|to)\s+                                       # preposition
29
    (([a-zA-Z]{3}$)|([a-zA-Z]{3})\s)+$                          # one or more 3-letter currency code
30
''', re.VERBOSE)
31
LOGGER = get_logger(__name__)
32
UNSUPPORTED_CURRENCY = "Sorry, {} isn't currently supported."
33
UNRECOGNIZED_INPUT = "Sorry, I didn't understand the input."
34
35
rates = {}
36
rates_updated = 0.0
37
38
39
class CurrencySection(StaticSection):
40
    fixer_io_key = ValidatedAttribute('fixer_io_key', default=None)
41
    """Optional API key for Fixer.io (increases currency support)"""
42
    auto_convert = ValidatedAttribute('auto_convert', parse=bool, default=False)
43
    """Whether to convert currencies without an explicit command"""
44
45
46
def configure(config):
47
    """
48
    | name | example | purpose |
49
    | ---- | ------- | ------- |
50
    | auto\\_convert | False | Whether to convert currencies without an explicit command |
51
    | fixer\\_io\\_key | 0123456789abcdef0123456789abcdef | Optional API key for Fixer.io (increases currency support) |
52
    """
53
    config.define_section('currency', CurrencySection, validate=False)
54
    config.currency.configure_setting('fixer_io_key', 'Optional API key for Fixer.io (leave blank to use exchangeratesapi.io):')
55
    config.currency.configure_setting('auto_convert', 'Whether to convert currencies without an explicit command?')
56
57
58
def setup(bot):
59
    bot.config.define_section('currency', CurrencySection)
60
61
62
class FixerError(Exception):
63
    """A Fixer.io API Error Exception"""
64
    def __init__(self, status):
65
        super(FixerError, self).__init__("FixerError: {}".format(status))
66
67
68
class UnsupportedCurrencyError(Exception):
69
    """A currency is currently not supported by the API"""
70
    def __init__(self, currency):
71
        super(UnsupportedCurrencyError, self).__init__(currency)
72
73
74
def update_rates(bot):
75
    global rates, rates_updated
76
77
    # If we have data that is less than 24h old, return
78
    if time.time() - rates_updated < 24 * 60 * 60:
79
        return
80
81
    # Update crypto rates
82
    response = requests.get(CRYPTO_URL)
83
    response.raise_for_status()
84
    rates_crypto = response.json()
85
86
    # Update fiat rates
87
    if bot.config.currency.fixer_io_key is not None:
88
        response = requests.get(FIXER_URL.format(bot.config.currency.fixer_io_key))
89
        if not response.json()['success']:
90
            raise FixerError('Fixer.io request failed with error: {}'.format(response.json()['error']))
91
    else:
92
        response = requests.get(FIAT_URL)
93
94
    response.raise_for_status()
95
    rates_fiat = response.json()
96
    rates_updated = time.time()
97
98
    rates = rates_fiat['rates']
99
    rates['EUR'] = 1.0  # Put this here to make logic easier
100
101
    eur_btc_rate = 1 / rates_crypto['rates']['eur']['value']
102
103
    for rate in rates_crypto['rates']:
104
        if rate.upper() not in rates:
105
            rates[rate.upper()] = rates_crypto['rates'][rate]['value'] * eur_btc_rate
106
107
108
def get_rate(base, target):
109
    base = base.upper()
110
    target = target.upper()
111
112
    if base not in rates:
113
        raise UnsupportedCurrencyError(base)
114
115
    if target not in rates:
116
        raise UnsupportedCurrencyError(target)
117
118
    return (1 / rates[base]) * rates[target]
119
120
121
def exchange(bot, match):
122
    """Show the exchange rate between two currencies"""
123
    if not match:
124
        bot.reply(UNRECOGNIZED_INPUT)
125
        return NOLIMIT
126
127
    try:
128
        update_rates(bot)  # Try and update rates. Rate-limiting is done in update_rates()
129
    except requests.exceptions.RequestException as err:
130
        bot.reply("Something went wrong while I was getting the exchange rate.")
131
        LOGGER.error("Error in GET request: {}".format(err))
132
        return NOLIMIT
133
    except ValueError:
134
        bot.reply("Error: Got malformed data.")
135
        LOGGER.error("Invalid json on update_rates")
136
        return NOLIMIT
137
    except FixerError as err:
138
        bot.reply('Sorry, something went wrong with Fixer')
139
        LOGGER.error(err)
140
        return NOLIMIT
141
142
    query = match.string
143
144
    targets = query.split()
145
    amount = targets.pop(0)
146
    base = targets.pop(0)
147
    targets.pop(0)
148
149
    # TODO: Use this instead after dropping Python 2 support
150
    # amount, base, _, *targets = query.split()
151
152
    try:
153
        amount = float(amount)
154
    except ValueError:
155
        bot.reply(UNRECOGNIZED_INPUT)
156
        return NOLIMIT
157
    except OverflowError:
158
        bot.reply("Sorry, input amount was out of range.")
159
        return NOLIMIT
160
161
    if not amount:
162
        bot.reply("Zero is zero, no matter what country you're in.")
163
        return NOLIMIT
164
165
    out_string = '{} {} is'.format(amount, base.upper())
166
167
    unsupported_currencies = []
168
    for target in targets:
169
        try:
170
            out_string = build_reply(amount, base.upper(), target.upper(), out_string)
171
        except ValueError:
172
            LOGGER.error("Raw rate wasn't a float")
173
            return NOLIMIT
174
        except KeyError as err:
175
            bot.reply("Error: Invalid rates")
176
            LOGGER.error("No key: {} in json".format(err))
177
            return NOLIMIT
178
        except UnsupportedCurrencyError as cur:
179
            unsupported_currencies.append(cur)
180
181
    if unsupported_currencies:
182
        out_string = out_string + ' (unsupported:'
183
        for target in unsupported_currencies:
184
            out_string = out_string + ' {},'.format(target)
185
        out_string = out_string[0:-1] + ')'
186
    else:
187
        out_string = out_string[0:-1]
188
189
    bot.reply(out_string)
190
191
192
@commands('cur', 'currency', 'exchange')
193
@example('.cur 100 usd in btc cad eur',
194
         r'100\.0 USD is [\d\.]+ BTC, [\d\.]+ CAD, [\d\.]+ EUR',
195
         re=True)
196
@example('.cur 100 usd in btc cad eur can aux',
197
         r'100\.0 USD is [\d\.]+ BTC, [\d\.]+ CAD, [\d\.]+ EUR, \(unsupported: CAN, AUX\)',
198
         re=True)
199
def exchange_cmd(bot, trigger):
200
    if not trigger.group(2):
201
        return bot.reply("No search term. Usage: {}cur 100 usd in btc cad eur"
202
                         .format(bot.config.core.help_prefix))
203
204
    match = EXCHANGE_REGEX.match(trigger.group(2))
205
    exchange(bot, match)
206
207
208
@rule(EXCHANGE_REGEX)
209
@example('100 usd in btc cad eur')
210
def exchange_re(bot, trigger):
211
    if bot.config.currency.auto_convert:
212
        match = EXCHANGE_REGEX.match(trigger)
213
        exchange(bot, match)
214
215
216
def build_reply(amount, base, target, out_string):
217
    rate_raw = get_rate(base, target)
218
    rate = float(rate_raw)
219
    result = float(rate * amount)
220
221
    if target == 'BTC':
222
        return out_string + ' {:.5f} {},'.format(result, target)
223
224
    return out_string + ' {:.2f} {},'.format(result, target)
225