Compare commits

...

7 commits

6 changed files with 169 additions and 130 deletions

View file

@ -1,4 +1,4 @@
Copyright (c) 2023 Redume, DarkCat09 (Chechkenev Andrey)
Copyright (c) 2023-2024 Redume, DarkCat09 (Chechkenev Andrey)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,6 +1,5 @@
debug: false # debug logging
timeout: 2 # http requests timeout
ndigits: 3 # digits after floating point or after zeroes
coinapi_keys:
- key
- key2 # coinapi keys list

4
currency.json Normal file
View file

@ -0,0 +1,4 @@
{
"RUB": ["руб", "рубли", "рубля", "рублей", "рубль", "rub", "rouble", "roubles"],
"USD": ["доллары", "доллар", "доллара", "зеленых", "usd", "dollar", "dollars"]
}

85
function/convert.py Normal file
View file

@ -0,0 +1,85 @@
import yaml
import requests
import json
import re
from decimal import Decimal, ROUND_DOWN
from function.format_number import format_number
config = yaml.safe_load(open('config.yaml'))
coinapi_len = len(config['coinapi_keys'])
coinapi_active = [0]
class Converter:
def __init__(self):
self.amount: float = 1.0
self.conv_amount: float = 0.0
self.from_currency: str = ''
self.conv_currency: str = ''
def convert(self) -> None:
if not self.ddg():
self.coinapi()
number = Decimal(str(self.conv_amount))
self.conv_amount = format_number(number.quantize(Decimal('1.00'), rounding=ROUND_DOWN))
def ddg(self) -> bool:
res = requests.get('https://duckduckgo.com/js/spice/currency'
f'/{self.amount}'
f'/{self.from_currency}'
f'/{self.conv_currency}')
data = json.loads(re.findall(r'\(\s*(.*)\s*\);$', res.text)[0])
del data['terms']
del data['privacy']
del data['timestamp']
if len(data.get('to')) == 0:
return False
conv = data.get('to')[0]
conv_amount = conv.get('mid')
if conv_amount is None:
raise RuntimeError('Error when converting currency via DuckDuckGo')
self.conv_amount = float(conv_amount)
return True
def coinapi(self, depth: int = config['coinapi_keys']) -> None:
if depth <= 0:
raise RecursionError('Rate limit on all tokens')
resp = requests.get(
(
'https://rest.coinapi.io/v1/exchangerate'
f'/{self.from_currency}'
f'/{self.conv_currency}'
),
headers={
'X-CoinAPI-Key': config['coinapi_keys'][coinapi_active[0]],
},
timeout=config['timeout'],
)
if resp.status_code == 429:
rotate_token(config['coinapi_keys'], coinapi_active)
self.coinapi(depth - 1)
data = resp.json()
rate = data.get('rate')
if rate is None:
raise RuntimeError('Failed to get the exchange rate from CoinAPI')
self.conv_amount = float(rate * self.amount)
def rotate_token(lst, active) -> None:
active[0] = (active[0] + 1) % len(lst)

14
function/format_number.py Normal file
View file

@ -0,0 +1,14 @@
def format_number(number):
number_str = str(number)
if '.' in number_str:
integer_part, fractional_part = number_str.split('.')
else:
integer_part, fractional_part = number_str, ''
formatted_integer_part = '{:,}'.format(int(integer_part)).replace(',', ' ')
if fractional_part:
return formatted_integer_part + '.' + fractional_part
else:
return formatted_integer_part

191
main.py
View file

@ -1,130 +1,79 @@
#!/usr/bin/env python3
import asyncio
import hashlib
import json
import string
from aiogram import Bot, Dispatcher, types
import aiohttp
import requests
import re
import logging
import asyncio
import yaml
from aiogram import Dispatcher, types, Bot
from aiogram.filters import CommandStart
import hashlib
import aiohttp
# Constants
DDG_URL = 'https://duckduckgo.com/js/spice/currency'
COINAPI_URL = 'https://rest.coinapi.io/v1/exchangerate'
import json
import re
from word2number import w2n
from function.convert import Converter
config = yaml.safe_load(open("config.yaml"))
log = logging.getLogger('shirino')
handler = logging.StreamHandler()
fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s')
handler.setFormatter(fmt)
log.addHandler(handler)
if config['debug']:
handler.setLevel(logging.DEBUG)
log.setLevel(logging.DEBUG)
coinapi_len = len(config['coinapi_keys'])
coinapi_active = [0]
dp = Dispatcher()
class CurrencyConverter:
def __init__(self):
self.amount = 1.0
self.conv_amount = 0.0
self.from_currency = 'RUB'
self.conv_currency = 'USD'
@dp.message()
async def message_conv(message: types.Message):
with open('currency.json', 'r', encoding='utf-8') as f:
currency_json = json.load(f)
def convert(self):
if not self.ddgapi():
self.coinapi()
text = message.text.lower()
args = text.split()
str_amount = f'{self.conv_amount}'
point = str_amount.find(".")
after_point = str_amount[point + 1:]
number_match = re.match(r'\d+\.?\d*|\w+', args[0])
if number_match:
number_text = number_match.group(0)
fnz = min(
(
after_point.index(i)
for i in string.digits[1:]
if i in after_point
),
default=-1,
)
if fnz == -1:
try:
amount = float(number_text)
except ValueError:
try:
amount = float(w2n.word_to_num(number_text))
except ValueError:
await message.reply("Не удалось распознать числовое значение.")
return
else:
await message.reply("Не удалось найти числовое значение.")
return
ndigits = fnz + config['ndigits']
if len(args) == 4:
source_currency_alias = args[1]
target_currency_alias = args[3]
elif len(args) == 3:
source_currency_alias = args[0]
target_currency_alias = args[2]
else:
await message.reply("Не удалось определить исходную и целевую валюту.")
return
self.conv_amount = round(self.conv_amount, ndigits)
source_currency_code = None
target_currency_code = None
def ddgapi(self):
res = requests.get(f'{DDG_URL}/{self.amount}/{self.from_currency}/{self.conv_currency}')
data = json.loads(re.findall(r'\(\s*(.*)\s*\);$', res.text)[0])
for currency_code, aliases in currency_json.items():
if source_currency_alias in aliases:
source_currency_code = currency_code
if target_currency_alias in aliases:
target_currency_code = currency_code
del data['terms']
del data['privacy']
del data['timestamp']
if not source_currency_code or not target_currency_code or amount is None:
await message.reply("Не удалось найти сумму или валюты по указанным данным.")
return
log.debug(data)
conv = Converter()
conv.amount = amount
conv.from_currency = source_currency_code.upper()
conv.conv_currency = target_currency_code.upper()
conv.convert()
if len(data.get('to')) == 0:
return False
conv = data.get('to')[0]
conv_amount = conv.get("mid")
if conv_amount is None:
raise RuntimeError('Ошибка при конвертации валюты через DuckDuckGo')
log.debug(conv)
log.debug(conv_amount)
self.conv_amount = float(conv_amount)
return True
def coinapi(self, depth: int = config['coinapi_keys']):
if depth <= 0:
raise RecursionError('Рейтлимит на всех токенах')
resp = requests.get(
(
f'{COINAPI_URL}/{self.from_currency}'
f'/{self.conv_currency}'
),
headers={
'X-CoinAPI-Key': config['coinapi_keys'][coinapi_active[0]],
},
timeout=config['timeout'],
)
if resp.status_code == 429:
log.warning('CoinAPI ratelimited, rotating token')
rotate_token(config['coinapi_keys'], coinapi_active)
self.coinapi(depth - 1)
data = resp.json()
rate = data.get('rate')
if rate is None:
raise RuntimeError('Не удалось получить курс валюты от CoinAPI')
self.conv_amount = float(rate * self.amount)
result = f'{conv.amount} {conv.from_currency} = {conv.conv_amount} {conv.conv_currency}'
await message.reply(result)
def rotate_token(lst, active):
active[0] = (active[0] + 1) % len(lst)
async def inline_reply(result_id: str, title: str, description: str or None, inline_query: types.InlineQuery):
async def inline_reply(result_id: str, title: str, description: str or None, inline_query: types.InlineQuery) -> None:
article = [None]
article[0] = types.InlineQueryResultArticle(
id=result_id,
@ -143,22 +92,22 @@ async def inline_reply(result_id: str, title: str, description: str or None, inl
@dp.inline_query()
async def currency(inline_query: types.InlineQuery):
async def currency(inline_query: types.InlineQuery) -> None:
global result
query = inline_query.query
args = query.split()
result_id = hashlib.md5(query.encode()).hexdigest()
conv = CurrencyConverter()
conv = Converter()
try:
log.debug(len(args))
if len(args) <= 1:
await inline_reply(result_id,
"Требуется 2, либо 3 аргумента",
"2 or 3 arguments are required",
f"@shirino_bot USD RUB \n@shirino_bot 12 USD RUB", inline_query)
if len(args) == 3:
conv.amount = float(args[0])
conv.amount = float(args[0].replace(',', '.'))
conv.from_currency = args[1].upper()
conv.conv_currency = args[2].upper()
conv.convert()
@ -172,35 +121,23 @@ async def currency(inline_query: types.InlineQuery):
f'{conv.conv_amount} {conv.conv_currency}'
)
except aiohttp.client_exceptions.ClientError as ex:
except aiohttp.client_exceptions.ClientError:
await inline_reply(result_id,
"Rate-limit от API Telegram, повторите запрос позже",
"Rate-limit from the Telegram API, repeat the request later",
None,
inline_query)
log.debug(ex)
await asyncio.sleep(1)
except Exception as ex:
log.debug(ex)
await inline_reply(result_id, "Неверный формат данных",
except Exception:
await inline_reply(result_id, "Invalid data format",
"@shirino_bot USD RUB \n@shirino_bot 12 USD RUB",
inline_query)
await inline_reply(result_id, result, None, inline_query)
@dp.message(CommandStart())
async def start(message: types.Message):
await message.answer("Привет! Бот может показывать курс обмена криптовалюты и фиатной валюты. "
"Бот используется с помощью инлайн-команд: "
"`@shirino_bot 12 usd rub` или `@shirino_bot usd rub`"
"\n\nИсходный код опубликован на [Github](https://github.com/redume/shirino)",
parse_mode="markdown")
async def main() -> None:
log.debug(config)
bot = Bot(config['telegram_token'])
await dp.start_polling(bot)