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 0000000..b6c9d8c Binary files /dev/null and b/reminder_bot.db differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3ec4ee5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +httpx==0.27 +aiogram==3.13.1 +python-telegram-bot==21.6 +requests +loguru==0.7.2 +SQLAlchemy==2.0.20 +aioschedule==0.5.2 +asyncpg==0.27.0 +aiohttp==3.10.10 +cryptography \ No newline at end of file diff --git a/secret.key b/secret.key new file mode 100644 index 0000000..53ec922 --- /dev/null +++ b/secret.key @@ -0,0 +1 @@ +a5u-uciZjHWkeb21ggv3slYfZQyDYy3hvPPX0NnMRGI= \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..0a3d2d4 --- /dev/null +++ b/settings.py @@ -0,0 +1,10 @@ +import os + +BOT_TOKEN = "8013236729:AAGJ8rT-6PH44SUd7j2NbZt3PJ2-iFsFzlA" +ROOTDIR = os.path.abspath(__file__).replace('/settings.py', '') +DB_PATH = os.path.join(ROOTDIR, 'db.sqlite3') +NOTIFICATION_TIMES = [ + "09:00", + "12:00", + "15:00" +] diff --git a/start.py b/start.py new file mode 100644 index 0000000..d0bcdb4 --- /dev/null +++ b/start.py @@ -0,0 +1,71 @@ +import asyncio +from datetime import datetime, timedelta +from math import trunc + +from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage +from loguru import logger + +from bot.handlers import users +from bot.utils.otask_api import get_regular_projects +from bot.auth.auth import update_token +from bot.auth.crypto import generate_key_to_file +from database.db_main import UserManager +from settings import BOT_TOKEN, NOTIFICATION_TIMES + +# Инициализация бота и диспетчера +bot = Bot(token=BOT_TOKEN) + +storage = MemoryStorage() # Хранилище для машины состояний +dp = Dispatcher(storage=storage) +dp.include_routers(users.router) +user_manager = UserManager() + +# генерирует новый ключ шифрования для паролей +# generate_key_to_file() + +# Функция для запуска уведомлений +# Функция для отправки уведомлений пользователям +async def send_notifications(): + users_query = user_manager.get_all_users() + for user in users_query: + if (datetime.now() - user.last_token_update) > 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())