From 1359a151bca4e1ac21b540e4e5bab29b562f1b08 Mon Sep 17 00:00:00 2001 From: naxim Date: Thu, 24 Oct 2024 22:19:30 +0300 Subject: [PATCH] start proj --- Dockerfile | 14 ++++++ bot/auth/auth.py | 42 +++++++++++++++++ bot/auth/crypto.py | 28 +++++++++++ bot/handlers/users.py | 82 ++++++++++++++++++++++++++++++++ bot/utils/otask_api.py | 104 +++++++++++++++++++++++++++++++++++++++++ database/db_main.py | 57 ++++++++++++++++++++++ database/prepare.py | 10 ++++ docker-compose.yml | 11 +++++ reminder_bot.db | Bin 0 -> 12288 bytes requirements.txt | 10 ++++ secret.key | 1 + settings.py | 10 ++++ start.py | 71 ++++++++++++++++++++++++++++ 13 files changed, 440 insertions(+) create mode 100644 Dockerfile create mode 100644 bot/auth/auth.py create mode 100644 bot/auth/crypto.py create mode 100644 bot/handlers/users.py create mode 100644 bot/utils/otask_api.py create mode 100644 database/db_main.py create mode 100644 database/prepare.py create mode 100644 docker-compose.yml create mode 100644 reminder_bot.db create mode 100644 requirements.txt create mode 100644 secret.key create mode 100644 settings.py create mode 100644 start.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c6aa1a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.10 + +WORKDIR /app + +COPY ./requirements.txt /app/requirements.txt + +RUN pip install --upgrade pip + +RUN pip install --no-cache-dir -r requirements.txt + +ENV TZ=Europe/Moscow +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +COPY . . diff --git a/bot/auth/auth.py b/bot/auth/auth.py new file mode 100644 index 0000000..4c547a0 --- /dev/null +++ b/bot/auth/auth.py @@ -0,0 +1,42 @@ +import aiohttp # Для выполнения HTTP-запросов +from loguru import logger +from database.db_main import User, session +from datetime import datetime +from .crypto import decrypt_password + +# Функция для отправки POST-запроса к API и получения токена +async def get_token_from_api(email, password): + url = "https://api.otask.ru/api/v1/auth/login" + headers = { + "Content-Type": "application/json", + "Accept": "application/json" + } + body = { + "email": email, + "password": password + } + + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=body) as response: + if response.status == 200: + return await response.json() # Возвращаем токен, если запрос успешен + else: + logger.error(f"Failed to get token. Status code: {response.status}") + return None + + +# Функция для обновления токена +async def update_token(user: User): + # Выполняем запрос на обновление токена + new_tokens = await get_token_from_api(user.name, decrypt_password(user.password)) # Получаем новый токен + if new_tokens: + user.token = new_tokens['token'] + user.refresh_token = new_tokens['refresh_token'] + user.expires_in = new_tokens['expires_in'] # Если у вас есть это поле в модели + user.last_token_update = datetime.now() # Обновляем дату последнего обновления токена + session.commit() + logger.info(f"Токен обновлён для пользователя: {user.telegram_id}") + else: + logger.warning(f"Не удалось обновить токен для пользователя: {user.telegram_id}") + + diff --git a/bot/auth/crypto.py b/bot/auth/crypto.py new file mode 100644 index 0000000..9fa70b8 --- /dev/null +++ b/bot/auth/crypto.py @@ -0,0 +1,28 @@ +from cryptography.fernet import Fernet + + +PATH_TO_FILE_CRYPT = "./secret.key" + + +# Функция для генерации ключа шифрования и записи его в файл +def generate_key_to_file(): + key = Fernet.generate_key() + with open(PATH_TO_FILE_CRYPT, 'wb') as key_file: + key_file.write(key) + +# Функция для чтения ключа из файла +def load_key_from_file() -> bytes: + with open(PATH_TO_FILE_CRYPT, 'rb') as key_file: + return key_file.read() + +# Функция для шифрования пароля +def encrypt_password(password: str) -> str: + fernet = Fernet(load_key_from_file()) + encrypted_password = fernet.encrypt(password.encode()) + return encrypted_password.decode() + +# Функция для расшифрования пароля +def decrypt_password(encrypted_password: str) -> str: + fernet = Fernet(load_key_from_file()) + decrypted_password = fernet.decrypt(encrypted_password.encode()) + return decrypted_password.decode() diff --git a/bot/handlers/users.py b/bot/handlers/users.py new file mode 100644 index 0000000..b429940 --- /dev/null +++ b/bot/handlers/users.py @@ -0,0 +1,82 @@ +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import Message +from loguru import logger +from datetime import datetime +from bot.auth.auth import get_token_from_api +from database.db_main import User, session # Модель и сессия базы данных +from aiogram import F, Router +from database.db_main import UserManager + +router = Router(name=__name__) +user_manager = UserManager() +# Определяем состояния для машины состояний +class AuthState(StatesGroup): + waiting_for_login = State() + waiting_for_password = State() + + +# Команда /start для запуска регистрации +@router.message(Command(commands=["start"])) +async def start_command(message: Message, state: FSMContext): + user = user_manager.get_user_by_telegram_id(message.from_user.id) + if not user: + # Если пользователь не зарегистрирован, запускаем процесс регистрации + prompt_message = await message.answer("Введите почту:") + # Сохраняем ID сообщения-подсказки в состоянии + await state.update_data(prompt_message_id=prompt_message.message_id) + await state.set_state(AuthState.waiting_for_login) # Устанавливаем состояние ожидания логина + else: + await message.answer("Вы уже зарегистрированы.") + + +# Обработка ввода логина +@router.message(AuthState.waiting_for_login) +async def process_login(message: Message, state: FSMContext): + await state.update_data(login=message.text) # Сохраняем логин в состоянии + await message.delete() # Удаляем сообщение с логином + + # Удаляем сообщение с подсказкой + data = await state.get_data() + prompt_message_id = data.get("prompt_message_id") + if prompt_message_id: + await message.bot.delete_message(message.chat.id, prompt_message_id) + + # Отправляем новое сообщение с просьбой ввести пароль и сохраняем его ID + password_prompt_message = await message.answer("Введите пароль:") + await state.update_data(password_prompt_message_id=password_prompt_message.message_id) # Сохраняем ID нового сообщения + await state.set_state(AuthState.waiting_for_password) # Переход в состояние ожидания пароля + + +# Обработка ввода пароля +@router.message(AuthState.waiting_for_password) +async def process_password(message: Message, state: FSMContext): + user_data = await state.get_data() # Получаем данные из состояния + + login = user_data['login'] + password = message.text + await message.delete() # Удаляем сообщение с паролем + + # Удаляем сообщение с подсказкой для ввода пароля + password_prompt_message_id = user_data.get("password_prompt_message_id") + if password_prompt_message_id: + await message.bot.delete_message(message.chat.id, password_prompt_message_id) + + # Отправляем запрос на получение токена + token_response = await get_token_from_api(login, password) + + + if token_response: + user_manager.save_user( + telegram_id=message.from_user.id, + login=login, + token_response=token_response, + password=password + ) + + await message.answer("Вы успешно зарегистрированы и токен сохранен!") + else: + await message.answer("Не удалось получить токен. Проверьте логин и пароль.") + + await state.clear() \ No newline at end of file diff --git a/bot/utils/otask_api.py b/bot/utils/otask_api.py new file mode 100644 index 0000000..b7dad33 --- /dev/null +++ b/bot/utils/otask_api.py @@ -0,0 +1,104 @@ +from datetime import datetime + +import aiohttp # Для выполнения HTTP-запросов +from loguru import logger + + +async def get_user_teams(token): + url = "https://api.otask.ru/api/v1/teams" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json" + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status == 200: + teams = await response.json() + # logger.info(teams) + slugs = [] + for group in teams['data']['teams']: + # logger.info(group) + # logger.info(group['slug']) + slugs.append(group['slug']) + logger.info(slugs) + return slugs + else: + print(f"Failed to get teams. Status code: {response.status}") + return None + + +async def get_client_name(token, ws_slug, client_id): + """Получаем имя клиента по его идентификатору (client_id).""" + + url = f"https://api.otask.ru/api/v1/ws/{ws_slug}/clients" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + clients = data.get('data', {}).get('clients', []) + + # Ищем клиента по client_id в списке клиентов + for client in clients: + if client.get('id') == client_id: + return client + else: + logger.error(f"Failed to get client info for workspace {ws_slug}. Status code: {response.status}") + + return None + + +async def get_regular_projects(token): + slugs = await get_user_teams(token) # Получаем все слуги команд пользователя + regular_projects = [] # Список для хранения регулярных проектов + + for slug in slugs: + url = f"https://api.otask.ru/api/v1/ws/{slug}/panel/projects" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + params = { + "is_regular": "true" + } + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, params=params) as response: + if response.status == 200: + data = await response.json() # Получаем все проекты + # Извлекаем проекты из ответа + projects = data.get('data', {}).get('projects', []).get('data', []) + for p in projects: + if p['end_at'] is not None: + end_at = datetime.fromisoformat(p['end_at'].replace("Z", "+00:00")) + difference_in_days = end_at.date().day - datetime.utcnow().date().day + if difference_in_days == 0: # Наступила дата платежа + project_info = { + 'name': p.get('name', 'Неизвестный проект'), + 'amount': p.get('amount', 'Неизвестная сумма')} + + client_id = p.get('client_id','') + if client_id: + client = await get_client_name(token, slug, client_id) + + project_info['first_name'] = client.get('first_name', 'Неизвестный клиент') + project_info["description"] = '' + if client['description']: + project_info["description"] = client["description"] + regular_projects.append(project_info) + else: + pass # логика для регулярного проекта в котором нет даты платежа + # Фильтруем регулярные проекты + else: + logger.error(f"Failed to get projects for workspace {slug}. Status code: {response.status}") + + return regular_projects diff --git a/database/db_main.py b/database/db_main.py new file mode 100644 index 0000000..1818918 --- /dev/null +++ b/database/db_main.py @@ -0,0 +1,57 @@ +from sqlalchemy import Column, Integer, String, DateTime, create_engine +from sqlalchemy.orm import declarative_base, sessionmaker +from datetime import datetime +from typing import Optional, List +from bot.auth.crypto import encrypt_password + +Base = declarative_base() +class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + telegram_id = Column(Integer, unique=True, nullable=False) + name = Column(String, nullable=False) + token = Column(String, nullable=False) + refresh_token = Column(String, nullable=False) + expires_in = Column(String, nullable=False) + password = Column(String, nullable=False) + last_token_update = Column(DateTime, default=datetime.now()) + +# Подключение к базе данных SQLite +engine = create_engine('sqlite:///reminder_bot.db') +Base.metadata.create_all(engine) +Session = sessionmaker(bind=engine) +session = Session() + + + +class UserManager: + def __init__(self): + self.session = Session() + + def save_user(self, telegram_id: int, login: str, token_response, password: str): + """Сохраняет нового пользователя в базу данных""" + new_user = User( + telegram_id=telegram_id, + name=login, + token=token_response['token'], + refresh_token=token_response['refresh_token'], + expires_in=token_response['expires_in'], + password=self.encrypt_password(password) + ) + self.session.add(new_user) + self.session.commit() + + def get_all_users(self) -> List[User]: + """Получает всех пользователей из базы данных""" + return self.session.query(User).all() + + def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]: + """Получает пользователя по telegram_id""" + return self.session.query(User).filter_by(telegram_id=telegram_id).first() + + @staticmethod + def encrypt_password(password: str) -> str: + return encrypt_password(password) + + def close(self): + self.session.close() \ No newline at end of file diff --git a/database/prepare.py b/database/prepare.py new file mode 100644 index 0000000..12690e7 --- /dev/null +++ b/database/prepare.py @@ -0,0 +1,10 @@ +import sqlite3 + +from .db_main import UsersDB +from settings import DB_PATH + +con = sqlite3.connect(DB_PATH) +db = UsersDB(con) + +db._drop_tables() +db._create_table() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dde51c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + bot: + build: . + command: python -u start.py + restart: always + container_name: reminder + volumes: + - ./:/app + +volumes: + reminder_vol: diff --git a/reminder_bot.db b/reminder_bot.db new file mode 100644 index 0000000000000000000000000000000000000000..b6c9d8c68c062128c7580e214203382164561c68 GIT binary patch literal 12288 zcmeI1O^=&K7=ZCQAKg`w?QNy@!Z9m#9l#jdE3Fg^Hik7|W59-s1qRz-FxX&Yka}p; zn_%!u(YAH2*w_9HR4-|INB4NroVZ^UpJ z`VuM>pvN!_LC~E%7xVlo74nl?uPS-DtG_SZflA}T{lk~g-h=1RgXf38z3&3h4+sDP zfB+x>2mk_r03ZMe00MvjAn+Lwcyd;_dvtVE_+E<*)3eu+y^1bA_9&@I%1u}%8=WS6 z(F8v{*mo?L733zMMQ4lN_!kJI^TWtMO5meZA3o|H8B zys5zt9qV}4rU-0LX~GZRSnBb?@$ufBqqCy|T1l;beKuz18_R8Ow4sU^R6Q~=D>NN8VtsFBQ7pmB<5ZN#IPYqx zkS2~koF%LqY=vxE$#W{WN;#jvTJtqWbem<~otH(L#RPer#|ROl&l2ohU-MZnRR-IR zhT(}Z3{ld12@9?hR=(OsJ^c)cf8phL&na;48I8e$w z-Esw*OhkH|=51w7R`R*%zL4hgUNlfGzo)rSBeRW^W^q&%))OmH5wwdh)qztPGt7u+ z&@!rX(`pcH>Sko}6=@E*ei*JOa*?LwE;Sa6FE$W#G>N<+Mf8Gl4IxZ>*iCf7?8e)8 zb<*gp#VM5qHHpr@t*NS=Z?iEva0#s+(7k{f>c*mjZf8biFk?~~*8)q_W8IZeLd1u? zEnQOus@l>?WnPx%XBdLvCn$1);V@b|Me?Ik!U+T?>d%zDT({#(-IQFnU;p^~NsLu7 z!~evt@fCr(>KdQTk!xJba(7<4=IgSBN}}X)n9<@W1ry?dKYr6SM99eGL%6mw=KZc6 z)i+DFU(@?(1t%g#!n{U^);i%Jz#B%DD%UV+L4G3FZamj3u5pCA@bUg^I*AyZ%QAe; zcLcO<>3qz2IOX`-`N|Ti!-_InE*px+iEUHtwn(NH$G%ZzNH+~@d{h@E=}FZxF%AuS z?7$(<-u9|;iXkPUT164;S-)@(y7jMDkAC>&_y2=cGN1BSt=jS#n_RW3nei_9+A7bA zgtqRBs)`v{7#S{~3hKwPYB{eil0GJet;NZ}nsiNkh3SZoSRGAUCp`vNDkHb!^#e8f zG*;bwu5VlAq2+U8H1MVh7sjE9n93M!6CAUs>&uMlQ*_&rN$$?0??FY)Prt*V~lwNo4^)o`SY)DQCK+~VO2= timedelta(days=4): + await update_token(user) + regular_projects = await get_regular_projects(user.token) + for project in regular_projects: + await bot.send_message(user.telegram_id, + f"Регулярный платеж:\n" + f"{project['name']}\n" + f"{project['amount']}\n" + f"{project['first_name']}\n" + f"{project['description']}\n") + # else: + # await bot.send_message(user.telegram_id, "Нет проектов с регулярными платежами на данный момент.") + + +def should_send_notifications(): + current_time = datetime.now().strftime("%H:%M") + return current_time in NOTIFICATION_TIMES + + +# Функция для проверки, нужно ли отправлять уведомления в текущее время +async def scheduler(): + while True: + if should_send_notifications(): + await send_notifications() + await asyncio.sleep(45) + + +# Асинхронная функция для запуска polling и планировщика +async def main(): + # Запуск планировщика + asyncio.create_task(scheduler()) # Параллельно запускаем планировщик + logger.info("Планировщик запущен") + # Запуск polling бота + await dp.start_polling(bot) + logger.info("Polling бота запущен") + + +if __name__ == '__main__': + # Запуск основного цикла событий + asyncio.run(main())