diff --git a/.gitignore b/.gitignore index 723ef36..32ec512 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ -.idea \ No newline at end of file +.idea +.vscode + +__pycache__ +.mypy_cache + +venv +.env diff --git a/LICENSE b/LICENSE index dbc1b68..ecd20b0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2023 Redume +Copyright (c) 2023 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..519550e --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Shirino + +## Что это? +Бот для телеграма. Выводит курс валют. +https://t.me/Shirino_bot + +## Хочу запустить +Получите токен бота в телеграме и токен CoinAPI. +Вставьте в файл `.env` в формате: +``` +COINAPI_KEY=тут для койнапи +TELEGRAM_TOKEN=тут для телеграма +``` + +В энв файл ещё можно такие переменные добавить: +``` +DEBUG=false или true, включает отладочные логи +TIMEOUT=таймаут для библиотеки requests, в секундах (2 по дефолту) +``` + +## Хочу сделать пулл-реквест +Ставьте pylint и mypy для статической проверки кода. +Конфиги уже есть в репозитории. +После проверок можете открывать PR. diff --git a/main.py b/main.py index dddc6bf..8a799f2 100644 --- a/main.py +++ b/main.py @@ -1,103 +1,184 @@ -import requests +#!/usr/bin/env python3 + import json import hashlib -from aiogram import Bot, types -from aiogram.dispatcher import Dispatcher -from aiogram.utils import executor +import logging +from typing import Any, Dict, List, Optional + +import requests + +from pydantic import BaseSettings + +from aiogram import Bot # type: ignore +from aiogram.dispatcher import Dispatcher # type: ignore +from aiogram.utils import executor # type: ignore + +from aiogram.types import InlineQuery # type: ignore +from aiogram.types import InlineQueryResultArticle +from aiogram.types import InputTextMessageContent -bot = Bot(token="5193950006:AAGU8elNfNB9FocVSIb4DnqoEvQk70Mqh5E") +# Constants +DDG_URL = 'https://duckduckgo.com/js/spice/currency' +COINAPI_URL = 'https://rest.coinapi.io/v1/exchangerate' +# --- + + +# Config from .env +class Settings(BaseSettings): + debug: bool + timeout: int = 2 + coinapi_key: str + telegram_token: str + + class Config: + env_file = '.env' + env_file_encoding = 'utf-8' + + +settings = Settings() # type: ignore +# --- + + +# Logging +log = logging.getLogger('shirino') +handler = logging.StreamHandler() +fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s') + +handler.setFormatter(fmt) +log.addHandler(handler) + +if settings.debug: + handler.setLevel(logging.DEBUG) + log.setLevel(logging.DEBUG) +# --- + + +bot = Bot(token=settings.telegram_token) dp = Dispatcher(bot) -class TypeDict: - def __init__(self): - self.amount = None - self.from_amount = None - self.from_currency = None - self.to_currency = None +class CurrencyConverter: - @staticmethod - def get_currency(self, amount, from_currency, to_currency): - if amount is None: - amount = "1" + def __init__(self) -> None: - try: - page = requests.get(f"https://duckduckgo.com/js/spice/currency/{amount}/{from_currency}/{to_currency}") - page = page.text.replace('ddg_spice_currency(', "").replace(');', "") - page = json.loads(page) + self.amount = 1.0 + self.conv_amount = 0.0 + self.from_currency = '' + self.conv_currency = '' - if page["headers"]["description"].find("ERROR") != -1: - print(from_currency, to_currency) - crypto = requests.get(f"https://rest.coinapi.io/v1/exchangerate/{from_currency.upper()}/{to_currency.upper()}", headers={ - "X-CoinAPI-Key": "8A465920-C233-4EE2-860B-A0AF9EC21FFF" - }).json() + def convert(self) -> None: + """Currency conversion""" - print(crypto) + if not self.ddgapi(): + self.coinapi() - self.from_amount = crypto.get("rate") - return crypto.get("rate") + def ddgapi(self) -> bool: + """Get data from DuckDuckGo's currency API - except KeyError: - print("blyat slomal") - return None + Returns: + `False` if the currency does not exist, + `True` on successful conversion + """ - return page.get("conversion") + # API request + resp = requests.get( + ( + f'{DDG_URL}/{self.amount}/{self.from_currency}' + f'/{self.conv_currency}' + ), + timeout=settings.timeout, + ) - @staticmethod - def is_num(value): - return value.isdecimal() or value.replace('.', '', 1).isdecimal() + log.debug(resp.text) + # Parsing JSON data + data: Dict[str, Any] = json.loads( + resp.text + .replace('ddg_spice_currency(', '') + .replace(');', '') + ) -type_dict = TypeDict() + log.debug(data) + + # If the currency does not exist + descr = data.get('headers', {}).get('description', '') + if descr.find('ERROR') != -1: + return False + + # Otherwise + conv: Dict[str, str] = data.get('conversion', {}) + self.conv_amount = float(conv.get('converted-amount', 0)) + + log.debug(conv) + + return True + + def coinapi(self) -> None: + """Get data from CoinAPI (for cryptocurrencies)""" + + resp = requests.get( + ( + f'{COINAPI_URL}/{self.from_currency}' + f'/{self.conv_currency}' + ), + headers={ + 'X-CoinAPI-Key': settings.coinapi_key, + }, + timeout=settings.timeout, + ) + + data: Dict[str, Any] = resp.json() + self.conv_amount = float(data.get('rate', 0)) @dp.inline_handler() -async def currency(query: types.InlineQuery): - text = query.query.split(" ") - result_id: str = hashlib.md5(query.query.encode()).hexdigest() +async def currency(inline_query: InlineQuery) -> None: - if text == ['']: - return + query = inline_query.query + article: List[Optional[InlineQueryResultArticle]] = [None] - for i in range(len(text)): - if type_dict.is_num(text[i]): - continue + text = query.split() + len_ = len(text) - if text[i].find(",") != -1: - text[i] = text[i].replace(",", ".") + result_id = hashlib.md5(query.encode()).hexdigest() + conv = CurrencyConverter() try: - if type_dict.is_num(text[0]): - res, crypto_rate = type_dict.get_currency(text[0], text[1], text[2]) + if len_ == 3: + conv.amount = float(text[0]) + conv.from_currency = text[1].upper() + conv.conv_currency = text[2].upper() + conv.convert() + elif len_ == 2: + conv.from_currency = text[0].upper() + conv.conv_currency = text[1].upper() + conv.convert() else: - res, crypto_rate = type_dict.get_currency(None, text[0], text[1]) - except Exception: - return + raise ValueError('Надо 2 или 3 аргумента') - if res is None: - return + result = ( + f'{conv.amount} {conv.from_currency} = ' + f'{conv.conv_amount} {conv.conv_currency}' + ) - print(res) + except Exception as ex: + result = f'{type(ex).__name__}: {ex}' - from_amount = res.get('from_amount', res['from-amount']) - from_currency_symbol = res.get('from_currency_symbol', res['from-currency-symbol']) - converted_amount = res.get('converted_amount', res['converted-amount']) - to_currency_symbol = res.get('to_currency_symbol', res['to-currency-symbol']) - - result = f"{from_amount} {from_currency_symbol} = {converted_amount} {to_currency_symbol}" - - if crypto_rate: - result += f" | Crypto Rate: {crypto_rate}" - - article = [types.InlineQueryResultArticle( + article[0] = InlineQueryResultArticle( id=result_id, title=result, - input_message_content=types.InputTextMessageContent( - message_text=result - ))] + input_message_content=InputTextMessageContent( + message_text=result, + ), + ) + + await inline_query.answer( + article, + cache_time=1, + is_personal=True, + ) - await query.answer(article, cache_time=1, is_personal=True) executor.start_polling(dp, skip_updates=True) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..192ff32 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +check_untyped_defs = True +warn_return_any = True +warn_unreachable = True +show_error_codes = True diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..d554eae --- /dev/null +++ b/pylintrc @@ -0,0 +1,193 @@ +[MAIN] +analyse-fallback-blocks=no +clear-cache-post-run=no +extension-pkg-allow-list= +extension-pkg-whitelist= +fail-on= +fail-under=10 +ignore=CVS +ignore-paths= +ignore-patterns=^\.# +ignored-modules= +jobs=4 +limit-inference-results=100 +load-plugins= +persistent=yes +py-version=3.10 +recursive=no +suggestion-mode=yes +unsafe-load-any-extension=no + +[BASIC] +argument-naming-style=snake_case +attr-naming-style=snake_case +bad-names=foo, + bar, + baz, + toto, + tutu, + tata +bad-names-rgxs= +class-attribute-naming-style=any +class-const-naming-style=UPPER_CASE +class-naming-style=PascalCase +const-naming-style=UPPER_CASE +docstring-min-length=-1 +function-naming-style=snake_case +good-names=i, + j, + k, + ex, + Run, + _ +good-names-rgxs= +include-naming-hint=no +inlinevar-naming-style=any +method-naming-style=snake_case +module-naming-style=snake_case +name-group= +no-docstring-rgx=^_ +property-classes=abc.abstractproperty +variable-naming-style=snake_case + +[CLASSES] +check-protected-access-in-special-methods=no +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make +valid-classmethod-first-arg=cls +valid-metaclass-classmethod-first-arg=mcs + +[DESIGN] +exclude-too-few-public-methods= +ignored-parents= +max-args=5 +max-attributes=7 +max-bool-expr=5 +max-branches=12 +max-locals=15 +max-parents=7 +max-public-methods=20 +max-returns=6 +max-statements=50 +min-public-methods=0 + +[EXCEPTIONS] +overgeneral-exceptions=builtins.BaseException,builtins.Exception + +[FORMAT] +expected-line-ending-format= +ignore-long-lines=^\s*(# )??$ +indent-after-paren=4 +indent-string=' ' +max-line-length=100 +max-module-lines=1000 +single-line-class-stmt=no +single-line-if-stmt=no + +[IMPORTS] +allow-any-import-level= +allow-reexport-from-package=no +allow-wildcard-with-all=no +deprecated-modules= +ext-import-graph= +import-graph= +int-import-graph= +known-standard-library= +known-third-party=enchant +preferred-modules= + +[LOGGING] +logging-format-style=old +logging-modules=logging + +[MESSAGES CONTROL] +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED +disable=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + broad-exception-caught +enable=c-extension-no-member + +[METHOD_ARGS] +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + +[MISCELLANEOUS] +notes=FIXME, + XXX, + TODO +notes-rgx= + +[REFACTORING] +max-nested-blocks=5 +never-returning-functions=sys.exit,argparse.parse_error + +[REPORTS] +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) +msg-template= +reports=no +score=yes + +[SIMILARITIES] +ignore-comments=yes +ignore-docstrings=yes +ignore-imports=yes +ignore-signatures=yes +min-similarity-lines=4 + +[SPELLING] +max-spelling-suggestions=4 +spelling-dict= +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: +spelling-ignore-words= +spelling-private-dict-file= +spelling-store-unknown-words=no + +[STRING] +check-quote-consistency=no +check-str-concat-over-line-jumps=no + +[TYPECHECK] +contextmanager-decorators=contextlib.contextmanager +generated-members= +ignore-none=yes +ignore-on-opaque-inference=yes +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace +missing-member-hint=yes +missing-member-hint-distance=1 +missing-member-max-choices=1 +mixin-class-rgx=.*[Mm]ixin +signature-mutators= + +[VARIABLES] +additional-builtins= +allow-global-unused-variables=yes +allowed-redefined-builtins= +callbacks=cb_, + _cb +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ +ignored-argument-names=_.*|^ignored_|^unused_ +init-import=no +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..230d49c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests==2.31.0 +aiogram==2.25.1 +pydantic[dotenv]==1.10.8