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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

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

193
main.py
View file

@ -1,130 +1,79 @@
#!/usr/bin/env python3 from aiogram import Bot, Dispatcher, types
import asyncio
import hashlib
import json
import string
import aiohttp import asyncio
import requests
import re
import logging
import yaml import yaml
from aiogram import Dispatcher, types, Bot import hashlib
from aiogram.filters import CommandStart import aiohttp
# Constants import json
DDG_URL = 'https://duckduckgo.com/js/spice/currency' import re
COINAPI_URL = 'https://rest.coinapi.io/v1/exchangerate'
from word2number import w2n
from function.convert import Converter
config = yaml.safe_load(open("config.yaml")) 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() dp = Dispatcher()
class CurrencyConverter: @dp.message()
def __init__(self): async def message_conv(message: types.Message):
self.amount = 1.0 with open('currency.json', 'r', encoding='utf-8') as f:
self.conv_amount = 0.0 currency_json = json.load(f)
self.from_currency = 'RUB'
self.conv_currency = 'USD'
def convert(self): text = message.text.lower()
if not self.ddgapi(): args = text.split()
self.coinapi()
str_amount = f'{self.conv_amount}' number_match = re.match(r'\d+\.?\d*|\w+', args[0])
point = str_amount.find(".") if number_match:
after_point = str_amount[point + 1:] number_text = number_match.group(0)
fnz = min( try:
( amount = float(number_text)
after_point.index(i) except ValueError:
for i in string.digits[1:] try:
if i in after_point amount = float(w2n.word_to_num(number_text))
), except ValueError:
default=-1, await message.reply("Не удалось распознать числовое значение.")
) return
else:
await message.reply("Не удалось найти числовое значение.")
return
if fnz == -1: if len(args) == 4:
return 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
ndigits = fnz + config['ndigits'] source_currency_code = None
target_currency_code = None
self.conv_amount = round(self.conv_amount, ndigits) 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
def ddgapi(self): if not source_currency_code or not target_currency_code or amount is None:
res = requests.get(f'{DDG_URL}/{self.amount}/{self.from_currency}/{self.conv_currency}') await message.reply("Не удалось найти сумму или валюты по указанным данным.")
data = json.loads(re.findall(r'\(\s*(.*)\s*\);$', res.text)[0]) return
del data['terms'] conv = Converter()
del data['privacy'] conv.amount = amount
del data['timestamp'] conv.from_currency = source_currency_code.upper()
conv.conv_currency = target_currency_code.upper()
conv.convert()
log.debug(data) result = f'{conv.amount} {conv.from_currency} = {conv.conv_amount} {conv.conv_currency}'
await message.reply(result)
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)
def rotate_token(lst, active): async def inline_reply(result_id: str, title: str, description: str or None, inline_query: types.InlineQuery) -> None:
active[0] = (active[0] + 1) % len(lst)
async def inline_reply(result_id: str, title: str, description: str or None, inline_query: types.InlineQuery):
article = [None] article = [None]
article[0] = types.InlineQueryResultArticle( article[0] = types.InlineQueryResultArticle(
id=result_id, id=result_id,
@ -143,22 +92,22 @@ async def inline_reply(result_id: str, title: str, description: str or None, inl
@dp.inline_query() @dp.inline_query()
async def currency(inline_query: types.InlineQuery): async def currency(inline_query: types.InlineQuery) -> None:
global result
query = inline_query.query query = inline_query.query
args = query.split() args = query.split()
result_id = hashlib.md5(query.encode()).hexdigest() result_id = hashlib.md5(query.encode()).hexdigest()
conv = CurrencyConverter() conv = Converter()
try: try:
log.debug(len(args))
if len(args) <= 1: if len(args) <= 1:
await inline_reply(result_id, 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) f"@shirino_bot USD RUB \n@shirino_bot 12 USD RUB", inline_query)
if len(args) == 3: if len(args) == 3:
conv.amount = float(args[0]) conv.amount = float(args[0].replace(',', '.'))
conv.from_currency = args[1].upper() conv.from_currency = args[1].upper()
conv.conv_currency = args[2].upper() conv.conv_currency = args[2].upper()
conv.convert() conv.convert()
@ -172,35 +121,23 @@ async def currency(inline_query: types.InlineQuery):
f'{conv.conv_amount} {conv.conv_currency}' f'{conv.conv_amount} {conv.conv_currency}'
) )
except aiohttp.client_exceptions.ClientError as ex: except aiohttp.client_exceptions.ClientError:
await inline_reply(result_id, await inline_reply(result_id,
"Rate-limit от API Telegram, повторите запрос позже", "Rate-limit from the Telegram API, repeat the request later",
None, None,
inline_query) inline_query)
log.debug(ex)
await asyncio.sleep(1) await asyncio.sleep(1)
except Exception as ex: except Exception:
log.debug(ex) await inline_reply(result_id, "Invalid data format",
await inline_reply(result_id, "Неверный формат данных",
"@shirino_bot USD RUB \n@shirino_bot 12 USD RUB", "@shirino_bot USD RUB \n@shirino_bot 12 USD RUB",
inline_query) inline_query)
await inline_reply(result_id, result, None, 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: async def main() -> None:
log.debug(config)
bot = Bot(config['telegram_token']) bot = Bot(config['telegram_token'])
await dp.start_polling(bot) await dp.start_polling(bot)