|
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
|
|
|
|