diff --git a/.gitignore b/.gitignore index d306159..4bb8eee 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__ .mypy_cache +.DS_Store venv -config.yaml \ No newline at end of file +config.yaml + +CertSSL \ No newline at end of file diff --git a/config_sample.yaml b/config_sample.yaml index 9e09ebb..fd02a56 100644 --- a/config_sample.yaml +++ b/config_sample.yaml @@ -1,4 +1,6 @@ -debug: false # debug logging -timeout: 2 # http requests timeout -telegram_token: # telegram bot token -kekkai_instance: 'https://kekkai-api.redume.su/' \ No newline at end of file +webhook: + secret_token: 'jTDnK6nuEZ654b' + base_url: 'example.com' + path: '/webhook' +telegram_token: +kekkai_instance: 'https://kekkai-api.redume.su' \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index a00bb48..c17e976 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,4 +2,19 @@ services: shirino: build: . image: ghcr.io/shirino/shirino:latest - restart: unless-stopped \ No newline at end of file + restart: unless-stopped + volumes: + - './config.yaml:/config.yaml' + + nginx: + image: nginx:latest + ports: + - '80:80' + - '443:443' + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./CertSSL:/etc/nginx/ssl + +volumes: + shirino: + driver: local \ No newline at end of file diff --git a/function/get_chart.py b/function/get_chart.py deleted file mode 100644 index 5c24938..0000000 --- a/function/get_chart.py +++ /dev/null @@ -1,27 +0,0 @@ -import yaml -import requests -from http import HTTPStatus - -config = yaml.safe_load(open('config.yaml')) - -def get_chart(from_currency: str, conv_currency: str) -> (dict, None): - try: - response = requests.get(f'{config["kekkai_instance"]}/api/getChart/week/', params={ - 'from_currency': from_currency, - 'conv_currency': conv_currency - }, timeout=3) - - if not HTTPStatus(response.status_code).is_success: - return None - - try: - data = response.json() - return data.get('message', None) - except ValueError: - return None - except requests.exceptions.ConnectionError: - print("API connection error.") - return None - except requests.exceptions.RequestException as e: - print(f"There was an error: {e}") - return None diff --git a/functions/convert.py b/functions/convert.py new file mode 100644 index 0000000..8d1931a --- /dev/null +++ b/functions/convert.py @@ -0,0 +1,70 @@ +import json +import re +from datetime import datetime +from decimal import Decimal +from http import HTTPStatus + +import aiohttp +import yaml + +from utils.format_number import format_number + +config = yaml.safe_load(open('config.yaml')) + +class Converter: + def __init__(self): + self.amount: float = 1.0 + self.conv_amount: float = 0.0 + self.from_currency: str = '' + self.conv_currency: str = '' + + async def convert(self) -> None: + if not await self.kekkai(): + await self.ddg() + + self.conv_amount = format_number(Decimal(self.conv_amount)) + + + async def kekkai(self) -> bool: + date = datetime.today().strftime('%Y-%m-%d') + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session: + async with session.get(f'{config['kekkai_instance']}/api/getRate/', params={ + 'from_currency': self.from_currency, + 'conv_currency': self.conv_currency, + 'date': date, + 'conv_amount': self.amount + }) as res: + if not HTTPStatus(res.status).is_success: + return False + + data = await res.json() + self.conv_amount = data.get('conv_amount', 0.0) + + return True + + + async def ddg(self) -> None: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session: + async with session.get( + 'https://duckduckgo.com/js/spice/currency/' + f'{self.amount}/{self.from_currency}/{self.conv_currency}' + ) as res: + + data_text = await res.text() + + data = json.loads(re.findall(r'\(\s*(.*)\s*\);$', data_text)[0]) + + for key in ['terms', 'privacy', 'timestamp']: + data.pop(key, None) + + if not data.get('to'): + raise RuntimeError('Failed to get the exchange rate from DuckDuckGo') + + 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) diff --git a/functions/create_chart.py b/functions/create_chart.py new file mode 100644 index 0000000..117dd4d --- /dev/null +++ b/functions/create_chart.py @@ -0,0 +1,19 @@ +import yaml +import aiohttp + +from http import HTTPStatus + +config = yaml.safe_load(open('config.yaml')) + +async def create_chart(from_currency: str, conv_currency: str) -> (dict, None): + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session: + async with session.get(f'{config["kekkai_instance"]}/api/getChart/month/', params={ + 'from_currency': from_currency, + 'conv_currency': conv_currency + }) as res: + if not HTTPStatus(res.status).is_success: + return None + + data = await res.json() + + return data.get('message', None) diff --git a/main.py b/main.py index 4889b47..bc807dd 100644 --- a/main.py +++ b/main.py @@ -1,104 +1,128 @@ -from aiogram import Bot, Dispatcher, types +from functions.convert import Converter +from utils.format_number import format_number +from utils.inline_query import reply +from functions.create_chart import create_chart import yaml -import asyncio + +from aiohttp import web + +from aiogram import Bot, Dispatcher, Router, types +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application +from aiogram.filters import CommandStart import hashlib -from function.get_chart import get_chart -from utils.convert import Converter -from utils.format_number import format_number -from function.inline_query import reply - -dp = Dispatcher() config = yaml.safe_load(open('config.yaml')) +bot = Bot(token=config['telegram_token'], default=DefaultBotProperties(parse_mode=ParseMode.HTML)) -@dp.inline_query() +router = Router() + +@router.message(CommandStart()) +async def start(message: types.Message): + get_bot = await bot.get_me() + await message.reply( + 'Shirino is a telegram bot for converting fiat or cryptocurrency. ' + 'The example of use occurs via inline query:\n' + f'`@{get_bot.username} USD RUB` \n' + f'`@{get_bot.username} 12 USD RUB` \n\n' + '[Source Code](https://github.com/Redume/Shirino)', + parse_mode='markdown' + ) + + +@router.inline_query() async def currency(query: types.InlineQuery) -> None: - global from_currency, conv_currency + text = query.query.lower() + args = text.split() + result_id = hashlib.md5(text.encode()).hexdigest() - try: - text = query.query.lower() - args = text.split() - result_id = hashlib.md5(text.encode()).hexdigest() + get_bot = await bot.get_me() - conv = Converter() + if len(args) < 2: + return await reply(result_id, + [("2 or 3 arguments are required.", + f'@{get_bot.username} USD RUB \n' + f'@{get_bot.username} 12 USD RUB', + None, None)], + query) - if len(args) < 2: - return await reply(result_id, - [("2 or 3 arguments are required.", - '@shirino_bot USD RUB \n' - '@shirino_bot 12 USD RUB', - None, None)], - query) + conv = Converter() - if len(args) == 3: + from_currency, conv_currency = '', '' + + if len(args) == 3: + try: conv.amount = float(args[0].replace(',', '.')) - from_currency = args[1] - conv_currency = args[2] - elif len(args) == 2: - from_currency = args[0] - conv_currency = args[1] - else: - return await reply(result_id, - [ - ( - 'The source and target currency could not be determined.', - None, - None - ) - ], - query) + if conv.amount < 0: + return await reply(result_id, [("Negative amounts are not supported.", None, None)], query) - if not from_currency or not conv_currency: - return await reply(result_id, [('The currency exchange rate could not be found.', None, None)], query) + except ValueError: + return await reply(result_id, [("Please enter a valid number for the amount.", + f'@{get_bot.username} USD RUB \n' + f'@{get_bot.username} 12 USD RUB', + None, None)], query) - conv.from_currency = from_currency.upper() - conv.conv_currency = conv_currency.upper() - conv.convert() + from_currency = args[1] + conv_currency = args[2] + elif len(args) == 2: + from_currency = args[0] + conv_currency = args[1] + else: + return await reply(result_id, + [( + 'The source and target currency could not be determined.', + None, None + )], + query) - chart = get_chart(from_currency, conv_currency) + conv.from_currency = from_currency.upper() + conv.conv_currency = conv_currency.upper() + try: + await conv.convert() + except RuntimeError as e: + return await reply(result_id, [('The currency exchange rate could not be determined', None, None)], query) - if not chart: - return await reply(result_id, - [ - ( - f'{format_number(conv.amount)} {conv.from_currency} ' - f'= {conv.conv_amount} {conv.conv_currency}' \ - f'\n{f'[График]({chart})' if chart else ''}', - None, - chart - ) - ], - query) + chart = await create_chart(from_currency, conv_currency) - await reply(result_id, - [ - ( - f'{format_number(conv.amount)} {conv.from_currency} ' - f'= {conv.conv_amount} {conv.conv_currency}' \ - f'\n{f'[График]({chart})' if chart else ''}', - None, - chart - ), - ( - f'{format_number(conv.amount)} {conv.from_currency} ' - f'= {conv.conv_amount} {conv.conv_currency}', - None, - None - ) - ], - query) + message = f'{format_number(conv.amount)} {conv.from_currency} = {conv.conv_amount} {conv.conv_currency}' - except Exception as e: - print(e) + results = [(message, None, None)] + + if chart: + results.insert(0, (f'{message}\n[График]({chart})', None, chart)) + + await reply(result_id, results, query) +async def on_startup(bot: Bot) -> None: + await bot.set_webhook( + f"{config['webhook']['base_url']}{config['webhook']['path']}", + secret_token=config['webhook']['secret_token'], + allowed_updates=['inline_query', 'message'] + ) -async def main() -> None: - bot = Bot(config['telegram_token']) - await dp.start_polling(bot) + +def main() -> None: + dp = Dispatcher() + + dp.include_router(router) + dp.startup.register(on_startup) + + app = web.Application() + webhook_requests_handler = SimpleRequestHandler( + dispatcher=dp, + bot=bot, + secret_token=config['webhook']['secret_token'] + ) + webhook_requests_handler.register(app, path=config['webhook']['path']) + + setup_application(app, dp, bot=bot) + + web.run_app(app, host='0.0.0.0', port=443) if __name__ == '__main__': - asyncio.run(main()) + main() diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..f70b053 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,29 @@ +events {} + +http { + server { + listen 80; + server_name example.com; + + return 301 https://$host$request_uri$is_args$args; + } + + server { + listen 443 ssl; + http2 on; + server_name example.com; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + location /webhook { + proxy_pass http://shirino:443; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_buffering off; + } + } +} diff --git a/requirements.txt b/requirements.txt index 4d7e12c..21259b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ aiohttp~=3.9.5 -requests~=2.32.3 PyYAML~=6.0.1 aiogram~=3.15.0 \ No newline at end of file diff --git a/utils/convert.py b/utils/convert.py deleted file mode 100644 index c7cee9e..0000000 --- a/utils/convert.py +++ /dev/null @@ -1,78 +0,0 @@ -import yaml -import requests - -import json -import re - -from datetime import datetime -from http import HTTPStatus - -from decimal import Decimal, ROUND_DOWN -from utils.format_number import format_number - -config = yaml.safe_load(open('config.yaml')) - - -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.kekkai() and not self.ddg(): - raise RuntimeError('Failed to convert via Kekkai or DDG') - - number = Decimal(str(self.conv_amount)) - self.conv_amount = format_number(number.quantize(Decimal('1.00'), rounding=ROUND_DOWN)) - - - def ddg(self) -> bool: - try: - res = requests.get( - f'https://duckduckgo.com/js/spice/currency/' - f'{self.amount}/{self.from_currency}/{self.conv_currency}', - timeout=3 - ) - - data = json.loads(re.findall(r'\(\s*(.*)\s*\);$', res.text)[0]) - - for key in ['terms', 'privacy', 'timestamp']: - data.pop(key, None) - - if not data.get('to'): - return False - - self.conv_amount = float(data['to'][0].get('mid', 0.0)) - return True - - except (requests.exceptions.RequestException, json.JSONDecodeError, IndexError) as e: - print(f"Error when requesting DDG: {e}") - return False - - def kekkai(self) -> bool: - date = datetime.today().strftime('%Y-%m-%d') - try: - res = requests.get( - f"{config['kekkai_instance']}/api/getRate/", - params={ - 'from_currency': self.from_currency, - 'conv_currency': self.conv_currency, - 'date': date, - 'conv_amount': self.amount - }, - timeout=3 - ) - - if res.status_code != HTTPStatus.OK: - return False - - data = res.json() - self.conv_amount = data.get('conv_amount', 0.0) - return True - - except (requests.exceptions.ConnectionError, json.JSONDecodeError) as e: - print(f"Error when querying Kekkai: {e}") - return False diff --git a/utils/format_number.py b/utils/format_number.py index 8d475b9..7759bab 100644 --- a/utils/format_number.py +++ b/utils/format_number.py @@ -1,14 +1,14 @@ +from decimal import Decimal + def format_number(number): - number_str = str(number) + number = Decimal(str(number)) - if '.' in number_str: - integer_part, fractional_part = number_str.split('.') - else: - integer_part, fractional_part = number_str, '' + formatted_integer_part = '{:,.0f}'.format(number // 1).replace(',', ' ') + fractional_part = f"{number % 1:.30f}".split('.')[1] - formatted_integer_part = '{:,}'.format(int(integer_part)).replace(',', ' ') - - if fractional_part: - return formatted_integer_part + '.' + fractional_part - else: - return formatted_integer_part + if int(fractional_part) == 0: + return formatted_integer_part + '' + + significant_start = next((i for i, char in enumerate(fractional_part) if char != '0'), len(fractional_part)) + result_fractional = fractional_part[:significant_start + 3] + return formatted_integer_part + '.' + result_fractional diff --git a/function/inline_query.py b/utils/inline_query.py similarity index 88% rename from function/inline_query.py rename to utils/inline_query.py index 5aed88f..282ad95 100644 --- a/function/inline_query.py +++ b/utils/inline_query.py @@ -1,7 +1,6 @@ -import re - from aiogram import types +import re async def reply(result_id: str, args: list, query: types.InlineQuery) -> None: if not args: @@ -17,7 +16,7 @@ async def reply(result_id: str, args: list, query: types.InlineQuery) -> None: article = types.InlineQueryResultArticle( id=f"{result_id}_{idx}", - title=re.sub(r'\[([^\[]+)\]\([^\)]+\)', '', title).replace('График', ''), + title=re.sub(r'\bГрафик\b|\[([^\]]+)\]\([^)]+\)', '', title, flags=re.IGNORECASE), thumbnail_url=img, description=description, input_message_content=types.InputTextMessageContent(