diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a12fac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +**/*.db +**/logs \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7dd18cb --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +BOT_TOKEN=your_telegram_bot_token +DATABASE_NAME=database \ No newline at end of file diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 9b37780..c5bdc65 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -12,20 +12,41 @@ permissions: jobs: black-test: runs-on: ubuntu-latest - container: python:3.11 + container: python:3.13-alpine steps: - uses: actions/checkout@v3 - name: Install dependencies run: pip install black - name: Check code formatting with black - run: black ./ --check --verbose --diff + run: black ./tfinance --check --verbose --diff flake8-test: runs-on: ubuntu-latest - container: python:3.11 + container: python:3.13-alpine steps: - uses: actions/checkout@v3 - name: Install dependencies run: pip install -r requirements/test.txt - name: Check code formatting with flake8 - run: flake8 ./ \ No newline at end of file + run: flake8 ./tfinance + + prod-deploy: + if: github.ref == 'refs/heads/main' + needs: [ black-test, flake8-test ] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Deploy to server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USERNAME }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + cd ~/TFinance + git reset --hard origin/main + git pull origin main + docker compose down -v + docker compose up --build -d diff --git a/.gitignore b/.gitignore index 4d257f8..732e2c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.idea -/logs -/data.db -/safety_key.py -stocks.json \ No newline at end of file +logs +sqlite +*.db +stocks.json +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6c25353 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-alpine + +COPY ./requirements /requirements +RUN pip install --no-cache-dir -r requirements/prod.txt +RUN rm -rf requirements + +COPY ./tfinance /tfinance/ +WORKDIR /tfinance + +ENTRYPOINT ["python", "main.py"] \ No newline at end of file diff --git a/README.md b/README.md index 6eb4225..1b3d400 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Проект: Telegram bot TFinace TFinance - телеграм бот, позволяющий получить доступ к курсу -акций. На понравившиеся вам акции вы можете подписаться и включить ежедненую -рассылку их курса. Также вы можете попытать удачу и сыграть в игру, предугодав +акций. На понравившиеся вам акции вы можете подписаться и включить ежедневную +рассылку их курса. Также вы можете попытать удачу и сыграть в игру, предугадав будущий курс, в случае удачи вы заработаете 1 очко в криптоигре.

Задачи, которые мы выполнили:

@@ -23,7 +23,7 @@ TFinance - телеграм бот, позволяющий получить до
  • Использовали команду /unfollow для отписки от акции.
  • Использовали команду /stock для вывода курса акции.
  • Использовали команду /stocks для вывода акций с биржи.
  • -
  • Использовали команду /daily для подписки на ежедненую расслылку курсов +
  • Использовали команду /daily для подписки на ежедневную рассылку курсов избранных акций.
  • Получение списка акций с биржи с помощью http-запросов.
  • diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3ce0109 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + tfinance_bot: + build: . + container_name: tfinance_bot + env_file: + - ./.env + restart: unless-stopped + volumes: + - ./etc_tfinance/logs:/tfinance/logs + - ./etc_tfinance/sqlite:/tfinance/sqlite diff --git a/pyproject.toml b/pyproject.toml index 276515c..7bdbcf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,12 @@ [tool.flake8] -max-line-length = 89 +max-line-length = 79 inline-quotes="double" import-order-style="google" -extend-ignore = [ - "R503", - "R502", - "F401", - "N802", -] exclude = [ ".git", "__pycache__", - "docs/source/conf.py", "venv", ] [tool.black] -line-length = 89 \ No newline at end of file +line-length = 79 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1525017..04a9c77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ matplotlib>=3.5.3 +python-dotenv>=1.0.1 python-telegram-bot>=21.1.1 python-telegram-bot[job-queue] -pytz>=2024.1 requests>=2.31.0 yfinance>=0.2.38 \ No newline at end of file diff --git a/requirements/prod.txt b/requirements/prod.txt index 1525017..04a9c77 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,6 +1,6 @@ matplotlib>=3.5.3 +python-dotenv>=1.0.1 python-telegram-bot>=21.1.1 python-telegram-bot[job-queue] -pytz>=2024.1 requests>=2.31.0 yfinance>=0.2.38 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt index 25ee9fe..85a115b 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,6 +1,10 @@ flake8>=6.1.0 +flake8-all-not-strings>=0.0.2 flake8-commas>=2.1.0 flake8-comments>=0.1.2 +flake8-dunder-all>=0.4.1 +flake8-encodings>=0.5.1 +flake8-eradicate>=1.5.0 flake8-print>=5.0.0 Flake8-pyproject>=1.2.3 flake8-quotes>=3.3.2 diff --git a/blast.py b/tfinance/blast.py similarity index 90% rename from blast.py rename to tfinance/blast.py index 14db945..42a1cbd 100644 --- a/blast.py +++ b/tfinance/blast.py @@ -11,7 +11,8 @@ async def notify_assignees(context: CallbackContext): db = Database() - # Перебираем всех пользователей и рассылаем каждому курсы их избранных акций. + # Перебираем всех пользователей и + # рассылаем каждому курсы их избранных акций. for user in db.get_users(): if db.check_user_daily_notify(user): if user.favourite_stocks: @@ -35,3 +36,9 @@ async def daily(update: Update, _): else: await update.message.reply_text("Ежедневная рассылка включена") db.user_daily_notify(user) + + +__all__ = [ + "daily", + "notify_assignees", +] diff --git a/tfinance/config.py b/tfinance/config.py new file mode 100644 index 0000000..da9b843 --- /dev/null +++ b/tfinance/config.py @@ -0,0 +1,12 @@ +import os +from zoneinfo import ZoneInfo + +from dotenv import load_dotenv + +load_dotenv() + + +BOT_TOKEN = os.getenv("BOT_TOKEN") +DATABASE_NAME = os.getenv("DATABASE_NAME") + +TIMEZONE = ZoneInfo("Europe/Moscow") diff --git a/database.py b/tfinance/database.py similarity index 93% rename from database.py rename to tfinance/database.py index e3c9998..ac22a7a 100644 --- a/database.py +++ b/tfinance/database.py @@ -1,6 +1,8 @@ import sqlite3 +from pathlib import Path from models import User +from config import DATABASE_NAME def singleton(cls): @@ -22,7 +24,11 @@ class Database: def __init__(self): # Подключение к БД с отключенной проверкой потока. - self.con = sqlite3.connect("data.db", check_same_thread=False) + Path("sqlite").mkdir(exist_ok=True) + self.con = sqlite3.connect( + f"sqlite/{DATABASE_NAME}.db", + check_same_thread=False, + ) self.cur = self.con.cursor() self.setup() @@ -111,7 +117,8 @@ def add_prediction(self, user: User, stock_name: str, updown: str): if predictions[0]: prediction = f"{predictions[0]} {prediction}" self.cur.execute( - f"UPDATE users SET prediction = ' {prediction}' WHERE id = {user.id}", + f"UPDATE users SET prediction = ' {prediction}' " + f"WHERE id = {user.id}", ) self.con.commit() @@ -145,7 +152,9 @@ def delete_predictions(self, user: User): :param user: Экземпляр класса User с данными об этом пользователе. :return: None """ - self.cur.execute(f"UPDATE users SET prediction = '' WHERE id = {user.id}") + self.cur.execute( + f"UPDATE users SET prediction = '' WHERE id = {user.id}", + ) self.con.commit() def select_stock(self, user: User, stock_name: str): @@ -161,11 +170,12 @@ def select_stock(self, user: User, stock_name: str): if selected_stocks: stock_name = f"{selected_stocks} {stock_name}" self.cur.execute( - f"UPDATE users SET selected_stock = '{stock_name}' WHERE id = {user.id}", + f"UPDATE users SET selected_stock = '{stock_name}' " + f"WHERE id = {user.id}", ) self.con.commit() - def get_selected_stock_byid(self, user: User, message_id) -> str: + def get_selected_stock_byid(self, user: User, message_id) -> str | None: """ Получить название акции по id сообщения с игрой. :param user: Экземпляр класса User с данными об этом пользователе. @@ -179,6 +189,7 @@ def get_selected_stock_byid(self, user: User, message_id) -> str: for i in data.split(): if str(message_id) == i.split(":")[-1]: return i.split(":")[0] + return None def get_selected_stocks(self, user: User) -> list: """ @@ -253,7 +264,8 @@ def add_favourites_stocks(self, user: User, stock_name: str): if stocks and stocks[0]: stock_name = f"{stocks[0]} {stock_name}" self.cur.execute( - f"UPDATE users SET favourites_stocks = '{stock_name}' WHERE id = {user.id}", + f"UPDATE users SET favourites_stocks = '{stock_name}' " + f"WHERE id = {user.id}", ) self.con.commit() @@ -299,7 +311,8 @@ def remove_favourites_stock(self, user: User, stock_name: str): else: a = f"'{' '.join(a)}'" self.cur.execute( - f"UPDATE users SET favourites_stocks = {a} WHERE id = {user.id}", + f"UPDATE users SET favourites_stocks = {a} " + f"WHERE id = {user.id}", ) self.con.commit() @@ -310,7 +323,8 @@ def user_daily_notify(self, user: User): :return: None """ self.cur.execute( - f"UPDATE users SET daily_notify = NOT daily_notify WHERE id = {user.id}", + f"UPDATE users SET daily_notify = NOT daily_notify " + f"WHERE id = {user.id}", ) self.con.commit() @@ -339,3 +353,8 @@ def add_point(self, user: User): ) user.points += 1 self.con.commit() + + +__all__ = [ + "Database", +] diff --git a/exceptions.py b/tfinance/exceptions.py similarity index 78% rename from exceptions.py rename to tfinance/exceptions.py index 186abcc..74ae891 100644 --- a/exceptions.py +++ b/tfinance/exceptions.py @@ -14,3 +14,11 @@ class EmptyDataFrameError(Exception): # Пустой дата фрейм class WrongPeriodError(Exception): # Неверный период pass + + +__all__ = [ + "StockSelectedAlready", + "PredictionAlreadySet", + "EmptyDataFrameError", + "WrongPeriodError", +] diff --git a/functions.py b/tfinance/functions.py similarity index 91% rename from functions.py rename to tfinance/functions.py index cc77e8a..b5b751c 100644 --- a/functions.py +++ b/tfinance/functions.py @@ -13,3 +13,8 @@ def create_user(update: Update) -> User: user_data["language_code"], user_data["is_bot"], ) + + +__all__ = [ + "create_user", +] diff --git a/game.py b/tfinance/game.py similarity index 89% rename from game.py rename to tfinance/game.py index 926e9d5..c683c93 100644 --- a/game.py +++ b/tfinance/game.py @@ -1,8 +1,22 @@ -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram.ext import ContextTypes, CallbackContext +from telegram import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + Update, +) +from telegram.ext import ( + ContextTypes, + CallbackContext, + ConversationHandler, + CommandHandler, + CallbackQueryHandler, +) from database import Database -from exceptions import EmptyDataFrameError, PredictionAlreadySet, StockSelectedAlready +from exceptions import ( + EmptyDataFrameError, + PredictionAlreadySet, + StockSelectedAlready, +) from functions import create_user from graphics.visualize import check_stock_prices, do_stock_image from models import User @@ -17,12 +31,14 @@ async def game_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): # Сохраняем id сообщения для возможности одновременной message_id = str(int(update.message.message_id) + 2) # Игры на многих акциях. - # Прибавляем 2 т.к. отправляем 2 сообщения: фото и приписку к нему с клавиатурой. + # Прибавляем 2 т.к. отправляем 2 сообщения: + # фото и приписку к нему с клавиатурой. try: # Проверка на наличие аргументов. if not context.args: await update.message.reply_text( - "Неправильно введена команда! Попробуйте: /game [индекс акции]", + "Неправильно введена команда! " + "Попробуйте: /game [индекс акции]", ) # Проверка: была ли выбрана акция до этого? Избегаем читерства. if db.check_selected_stocks(user): @@ -80,7 +96,8 @@ async def game_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): ) db.remove_selected_stock(user, message_id) - # Возвращаем 1, чтобы показать ConversationHandler'у состояние, в котором находимся. + # Возвращаем 1, чтобы показать ConversationHandler'у состояние, + # в котором находимся. return 1 @@ -194,3 +211,21 @@ async def game_results(context: CallbackContext): # Удаляем пройденные прогнозы db.delete_predictions(user) user.prediction = db.get_predictions(user) + + +game_handler = ConversationHandler( + entry_points=[CommandHandler("game", game_menu)], + states={ + 1: [ + CallbackQueryHandler(higher_game, pattern="^1$"), + CallbackQueryHandler(lower_game, pattern="^2$"), + ], + }, + fallbacks=[CommandHandler("game", game_menu)], +) + + +__all__ = [ + "game_handler", + "game_results", +] diff --git a/graphics/visualize.py b/tfinance/graphics/visualize.py similarity index 89% rename from graphics/visualize.py rename to tfinance/graphics/visualize.py index d76e988..dc87888 100644 --- a/graphics/visualize.py +++ b/tfinance/graphics/visualize.py @@ -5,7 +5,7 @@ from exceptions import EmptyDataFrameError, WrongPeriodError -time_periods = { +TIME_PERIODS = { "1d": "за 1 день", "5d": "за 5 дней", "1mo": "за 1 месяц", @@ -27,7 +27,7 @@ def do_stock_image(stock_name, period="1mo"): :param period: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max :return: Картинка в байтовом формате. """ - if period not in time_periods: + if period not in TIME_PERIODS: raise WrongPeriodError # Забираем данные из Yahoo Finance. stock = yf.download(stock_name, period=period) @@ -38,7 +38,10 @@ def do_stock_image(stock_name, period="1mo"): # Создаем полотно с графиком. stock["Close"].plot(grid=True) - plt.title(f"Курс акции {stock_name} {time_periods.get(period)}", fontsize=14) + plt.title( + f"Курс акции {stock_name} {TIME_PERIODS.get(period)}", + fontsize=14, + ) plt.gca().set(ylabel="Price USD") # Записываем полученный график в байты и возвращаем полученное изображение. @@ -60,3 +63,9 @@ def check_stock_prices(stock_name) -> bool: curr_price = stock["Close"][-1] return curr_price > prev_price + + +__all__ = [ + "check_stock_prices", + "do_stock_image", +] diff --git a/main.py b/tfinance/main.py similarity index 82% rename from main.py rename to tfinance/main.py index d2c7f03..71219f8 100644 --- a/main.py +++ b/tfinance/main.py @@ -1,20 +1,15 @@ -# -------------------------- Основной файл приложения -------------------------- # -# --------------- Импорт необходимых библиотек, функций, классов --------------- # +# ------------------------ Основной файл приложения ------------------------ # +# ------------- Импорт необходимых библиотек, функций, классов ------------- # # Встроенные библиотеки. import datetime import logging -import os from pathlib import Path -import warnings -import pytz from telegram import Update # Работа с telegram-bot-api. from telegram.ext import ( - CallbackQueryHandler, CommandHandler, - ConversationHandler, Application, ContextTypes, ) @@ -26,26 +21,31 @@ from database import Database from exceptions import EmptyDataFrameError, WrongPeriodError from functions import create_user -from game import game_menu, game_results, higher_game, lower_game +from game import game_handler, game_results from graphics.visualize import do_stock_image -from safety_key import TOKEN from stock import check_stock, get_all_stocks, load_stocks +from config import BOT_TOKEN, TIMEZONE +__all__ = [] # Запускаем логирование -logs_path = Path(f"{os.getcwd()}/logs") -if not logs_path.exists(): - logs_path.mkdir(exist_ok=True) +Path("logs").mkdir(exist_ok=True) logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.WARNING, - filename=f"{logs_path}/tfinance_main.log", + level=logging.INFO, + handlers=[ + logging.FileHandler( + datetime.datetime.now( + tz=TIMEZONE, + ).strftime("logs/%Y-%m-%d_%H-%M-%S.log"), + encoding="utf-8", + ), + logging.StreamHandler(), + ], ) +logging.getLogger("httpx").setLevel(logging.WARNING) logger = logging.getLogger(__name__) -# Отключаем предупреждения пользователей библиотек -warnings.simplefilter("ignore") - # Получение списка необходимых акций по команде /stocks [args]. # Обработчик команды /stocks. @@ -86,7 +86,8 @@ async def get_stock_image(update: Update, context: ContextTypes.DEFAULT_TYPE): ) except WrongPeriodError: await update.message.reply_text( - "Неверный период. Доступные периоды: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max", + "Неверный период. " + "Доступные периоды: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max", ) except EmptyDataFrameError: await update.message.reply_text( @@ -152,7 +153,8 @@ async def follow(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("Акция не найдена") else: await update.message.reply_text( - "Неверный способ ввода. /follow [индекс акции]. Например: /follow AAPL", + "Неверный способ ввода. " + "/follow [индекс акции]. Например: /follow AAPL", ) @@ -191,7 +193,6 @@ async def stats(update: Update, _): ) -# Основной цикл, активирующийся при запуске. def main(): # Получение и сохранение списка всех акций в stocks.json. try: @@ -199,39 +200,26 @@ def main(): except Exception as e: logging.error(e) - application = Application.builder().token(TOKEN).build() + application = Application.builder().token(BOT_TOKEN).build() job_queue = application.job_queue - # Ежедневные задачи. job_queue.run_daily( notify_assignees, datetime.time( hour=8, - tzinfo=pytz.timezone("Europe/Moscow"), + tzinfo=TIMEZONE, ), ) job_queue.run_daily( game_results, datetime.time( hour=3, - tzinfo=pytz.timezone("Europe/Moscow"), + tzinfo=TIMEZONE, ), ) - # Обработчик для игры. - game_handler = ConversationHandler( - entry_points=[CommandHandler("game", game_menu)], - states={ - 1: [ - CallbackQueryHandler(higher_game, pattern="^1$"), - CallbackQueryHandler(lower_game, pattern="^2$"), - ], - }, - fallbacks=[CommandHandler("game", game_menu)], - ) application.add_handler(game_handler) - # Регистрируем обработчик команд. application.add_handler(CommandHandler("daily", daily)) application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("help", help_msg)) @@ -242,7 +230,6 @@ def main(): application.add_handler(CommandHandler("stocks", get_list_stocks)) application.add_handler(CommandHandler("stats", stats)) - # Обработка сообщений. application.run_polling(allowed_updates=Update.ALL_TYPES) diff --git a/models.py b/tfinance/models.py similarity index 96% rename from models.py rename to tfinance/models.py index a60b081..1fb3ef0 100644 --- a/models.py +++ b/tfinance/models.py @@ -21,3 +21,8 @@ def __init__( self.prediction = prediction self.selected_stock = selected_stock self.points: int = 0 + + +__all__ = [ + "User", +] diff --git a/stock.py b/tfinance/stock.py similarity index 79% rename from stock.py rename to tfinance/stock.py index 8124627..b6ddc9f 100644 --- a/stock.py +++ b/tfinance/stock.py @@ -14,7 +14,7 @@ # Загрузка списка всех акций. def load_stocks(file_name: str) -> list[dict[str, str]]: - path = Path(f"{Path.cwd()}/{file_name}") + path = Path(f"{file_name}") if path.exists(): with path.open() as f: return json.load(f) @@ -25,8 +25,7 @@ def load_stocks(file_name: str) -> list[dict[str, str]]: def check_stock(stock_name: str) -> bool: try: stock = yf.download(stock_name, period="1mo") - if stock["Close"][-2]: - return True + return bool(stock["Close"][-2]) except Exception as e: logging.exception(e) return False @@ -34,7 +33,7 @@ def check_stock(stock_name: str) -> bool: # Сохранение списка акций в json. def save_stocks(file_name: str, stocks: list): - with Path(f"{Path.cwd()}/{file_name}").open("w") as f: + with Path(file_name).open("w") as f: json.dump(stocks, f) @@ -54,5 +53,18 @@ def get_all_stocks(): .get("table") .get("rows") ) - stocks = [{"symbol": i.get("symbol"), "name": i.get("name")} for i in stocks] + stocks = [ + { + "symbol": i.get("symbol"), + "name": i.get("name"), + } + for i in stocks + ] save_stocks("stocks.json", stocks) + + +__all__ = [ + "check_stock", + "get_all_stocks", + "load_stocks", +]