From 476df64cc3dee2f601d3dcc1aa2d3452087061e8 Mon Sep 17 00:00:00 2001 From: gnatykdm Date: Tue, 5 Aug 2025 16:28:48 +0200 Subject: [PATCH 1/2] task-reminder+time_manager.py --- telegram_bot_project/bot/buttons.py | 12 +++- telegram_bot_project/bot/callbacks.py | 39 +++++++++- telegram_bot_project/bot/commands.py | 2 + telegram_bot_project/bot/fallbacks.py | 2 + telegram_bot_project/bot/scheduler.py | 86 ++++++++++++++++++++++- telegram_bot_project/bot/timer_manager.py | 18 +++++ telegram_bot_project/config.py | 4 +- telegram_bot_project/main.py | 16 ++++- telegram_bot_project/messages.py | 12 +++- telegram_bot_project/service/focus.py | 1 + telegram_bot_project/service/idea.py | 1 + telegram_bot_project/service/myday.py | 1 + telegram_bot_project/service/routine.py | 1 + telegram_bot_project/service/smtp.py | 1 + telegram_bot_project/service/task.py | 68 +++++++++++++++++- telegram_bot_project/service/user.py | 1 + telegram_bot_project/states.py | 4 +- 17 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 telegram_bot_project/bot/timer_manager.py diff --git a/telegram_bot_project/bot/buttons.py b/telegram_bot_project/bot/buttons.py index b1958ea..f307a2b 100644 --- a/telegram_bot_project/bot/buttons.py +++ b/telegram_bot_project/bot/buttons.py @@ -53,8 +53,8 @@ def idea_reply_keyboard() -> ReplyKeyboardMarkup: def task_reply_keyboard() -> InlineKeyboardMarkup: inline_markup = InlineKeyboardMarkup(inline_keyboard=[], row_width=2) - button_yes: InlineKeyboardButton = InlineKeyboardButton(text=BUTTON_YES, callback_data="yes_task") - button_no: InlineKeyboardButton = InlineKeyboardButton(text=BUTTON_NO, callback_data="no_task") + button_yes: InlineKeyboardButton = InlineKeyboardButton(text=BUTTON_YES_BTN, callback_data="yes_task") + button_no: InlineKeyboardButton = InlineKeyboardButton(text=BUTTON_NO_BTN, callback_data="no_task") inline_markup.inline_keyboard.append([button_yes, button_no]) return inline_markup @@ -195,3 +195,11 @@ def focus_title_ask_keyboard() -> InlineKeyboardMarkup: return inline_markup +def work_buttons_keyboard(task_id: int) -> InlineKeyboardMarkup: + inline_markup: InlineKeyboardMarkup = InlineKeyboardMarkup(inline_keyboard=[], row_width=2) + + start_work_btn: InlineKeyboardButton = InlineKeyboardButton(text=START_WORK_BTN, callback_data=f"complete_task:{task_id}") + cancel_work_btn: InlineKeyboardButton = InlineKeyboardButton(text=CANCEL_WORK_BTN, callback_data=f"cancel_task:{task_id}") + + inline_markup.inline_keyboard.append([start_work_btn, cancel_work_btn]) + return inline_markup \ No newline at end of file diff --git a/telegram_bot_project/bot/callbacks.py b/telegram_bot_project/bot/callbacks.py index 1d008ef..c88719c 100644 --- a/telegram_bot_project/bot/callbacks.py +++ b/telegram_bot_project/bot/callbacks.py @@ -1,5 +1,7 @@ # bot/callbacks.py from typing import Optional + +from aiogram import types from aiogram.fsm.context import FSMContext from bot.buttons import * @@ -179,4 +181,39 @@ async def callback_focus_title(callback_query: types.CallbackQuery, state: FSMCo await callback_query.message.answer(MESSAGES[language]["SAVED_FOCUS_MSG"], reply_markup=focus_menu_keyboard()) await state.clear() case _: - await callback_query.message.answer(MESSAGES[language]["FOCUS_INVALID"], reply_markup=focus_menu_keyboard()) \ No newline at end of file + await callback_query.message.answer(MESSAGES[language]["FOCUS_INVALID"], reply_markup=focus_menu_keyboard()) + +async def callback_work_buttons(callback_query: types.CallbackQuery, state: FSMContext) -> None: + await callback_query.answer() + + user_id: int = callback_query.from_user.id + user_find: Optional[dict] = await UserService.get_user_by_id(user_id) + if not user_find: + await callback_query.message.answer(MESSAGES['ENGLISH']["AUTHORIZATION_PROBLEM"]) + return + +async def callback_task_menu(callback_query: types.CallbackQuery) -> None: + await callback_query.answer() + + user_id: int = callback_query.from_user.id + user_find: Optional[dict] = await UserService.get_user_by_id(user_id) + language: str = await UserService.get_user_language(user_id) + if not language: + language = 'ENGLISH' + if not user_find: + await callback_query.message.answer(MESSAGES['ENGLISH']["AUTHORIZATION_PROBLEM"]) + return + + data = callback_query.data + action, task_id_str = data.split(":") + task_id = int(task_id_str) + + match action: + case "complete_task": + await TaskService.update_started_status(task_id) + await callback_query.message.answer(MESSAGES[language]['REMIND_WORK_CANCEL'], + reply_markup=menu_reply_keyboard()) + case "cancel_task": + await TaskService.update_started_status(task_id) + await callback_query.message.answer(MESSAGES[language]['REMIND_WORK_CANCEL'], + reply_markup=menu_reply_keyboard()) \ No newline at end of file diff --git a/telegram_bot_project/bot/commands.py b/telegram_bot_project/bot/commands.py index d62037d..729241e 100644 --- a/telegram_bot_project/bot/commands.py +++ b/telegram_bot_project/bot/commands.py @@ -1,4 +1,5 @@ # bot/commands.py +from aiogram import types from aiogram.fsm.context import FSMContext from service.focus import FocusService @@ -7,6 +8,7 @@ from service.task import TaskService from bot.buttons import * from states import DialogStates +from messages import MESSAGES from service.myday import MyDayService # Start Command Handler diff --git a/telegram_bot_project/bot/fallbacks.py b/telegram_bot_project/bot/fallbacks.py index c0a899e..b74656f 100644 --- a/telegram_bot_project/bot/fallbacks.py +++ b/telegram_bot_project/bot/fallbacks.py @@ -1,4 +1,6 @@ # bot/fallbacks.py +from aiogram import types + from bot.handlers import * from states import DialogStates diff --git a/telegram_bot_project/bot/scheduler.py b/telegram_bot_project/bot/scheduler.py index 0330506..bedb6c2 100644 --- a/telegram_bot_project/bot/scheduler.py +++ b/telegram_bot_project/bot/scheduler.py @@ -1,12 +1,17 @@ # bot/scheduler.py -from datetime import time +from datetime import time, datetime, timedelta from aiogram import Bot +from aiogram.fsm.context import FSMContext +from aiogram.fsm.storage.memory import MemoryStorage from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from messages import send_morning_message, send_evening_message +from bot.buttons import work_buttons_keyboard +from service.task import TaskService from service.user import UserService scheduler: AsyncIOScheduler = AsyncIOScheduler() +notified_task_ids = set() def initialize_scheduler(): scheduler.start() @@ -35,3 +40,82 @@ async def schedule_all_users_jobs(bot: Bot): update_user_job(user["id"], user["wake_time"], bot, job_type="wake") if user["sleep_time"]: update_user_job(user["id"], user["sleep_time"], bot, job_type="sleep") + +async def check_upcoming_tasks(bot: Bot): + now = datetime.now() + print("[TASK CHECK] - Checking for upcoming tasks at {}".format(now.strftime('%Y-%m-%d %H:%M:%S'))) + + tasks = await TaskService.get_all_upcoming_tasks() + + for task in tasks: + task_time = task.get("start_time") + if not task_time: + continue + + task_id = task["id"] + user_id = task["user_id"] + task_name = task["task_name"] + language: str = await UserService.get_user_language(user_id) + + time_diff = (task_time - now).total_seconds() + time_after_start = (now - task_time).total_seconds() + + print(f"[DEBUG] - Task: '{task_name}' | user_id: {user_id} | time_diff: {time_diff:.2f} sec | start_time: {task_time.strftime('%H:%M:%S')}") + + if 1790 <= time_diff <= 1810: + print(f"[NOTIFY] - User {user_id} - Task '{task_name}' starts in 30 min") + await send_task_notification(language, bot, user_id, task_name, 30) + elif 890 <= time_diff <= 910: + print(f"[NOTIFY] - User {user_id} - Task '{task_name}' starts in 15 min") + await send_task_notification(language, bot, user_id, task_name, 15) + elif 290 <= time_diff <= 310: + print(f"[NOTIFY] - User {user_id} - Task '{task_name}' starts in 5 min") + await send_task_notification(language, bot, user_id, task_name, 5) + elif -60 <= time_diff <= 60: + print(f"[NOTIFY] - User {user_id} - Task '{task_name}' starts now") + await send_task_notification(language, bot, user_id, task_name, 0, task_id) + + if time_after_start >= 0: + started = await TaskService.get_started_status(task_id) + print(f"[DEBUG REMINDER] Task {task_id}, started: {started}, time_after_start: {time_after_start:.2f}") + + for target_minute in [5, 10, 15]: + lower = target_minute * 60 - 60 + upper = target_minute * 60 + 60 + if lower <= time_after_start <= upper: + reminder_key = (task_id, f"late_{target_minute}") + if reminder_key not in notified_task_ids: + print(f"[REMIND] - Sending reminder for task {task_id} after {target_minute} min") + await send_task_notification(language, bot, user_id, task_name, target_minute, task_id) + notified_task_ids.add(reminder_key) + + +async def send_task_notification(language: str, bot: Bot, user_id: int, task_name: str, minutes: int, task_id: int): + messages = { + "UKRAINIAN": { + "soon": f"⏰ Завдання *\"{task_name}\"* почнеться через {minutes} хвилин!", + "now": f"🚀 Завдання *\"{task_name}\"* починається прямо зараз!\nПочинаємо?", + "reminder": f"⚠️ Нагадування: завдання *\"{task_name}\"* мало початися {minutes} хвилин тому, але не розпочате." + }, + "ENGLISH": { + "soon": f"⏰ Task *\"{task_name}\"* will start in {minutes} minutes!", + "now": f"🚀 Task *\"{task_name}\"* is starting right now!\nStart it?", + "reminder": f"⚠️ Reminder: task *\"{task_name}\"* was supposed to start {minutes} minutes ago but not started yet." + } + } + + lang_key = language.upper() + message_set = messages.get(lang_key, messages["ENGLISH"]) + + if minutes == 0: + text = message_set["now"] + elif minutes in [5, 10, 15]: + text = message_set["reminder"] + else: + text = message_set["soon"] + + try: + await bot.send_message(user_id, text, reply_markup=work_buttons_keyboard(task_id)) + print(f"[BOT] Sent message to {user_id}: {text}") + except Exception as e: + print(f"[ERROR] Failed to send message to {user_id}: {e}") \ No newline at end of file diff --git a/telegram_bot_project/bot/timer_manager.py b/telegram_bot_project/bot/timer_manager.py new file mode 100644 index 0000000..273c560 --- /dev/null +++ b/telegram_bot_project/bot/timer_manager.py @@ -0,0 +1,18 @@ +from datetime import datetime +from typing import Dict + +active_timers: Dict[int, datetime] = {} +active_work: Dict[int, str] = {} + +def start_timer(user_id: int): + active_timers[user_id] = datetime.now() + +def stop_timer(user_id: int) -> int: + start_time = active_timers.pop(user_id, None) + if start_time: + elapsed = (datetime.now() - start_time).total_seconds() + return int(elapsed) + return 0 + +def is_timer_running(user_id: int) -> bool: + return user_id in active_timers \ No newline at end of file diff --git a/telegram_bot_project/config.py b/telegram_bot_project/config.py index 16b94fd..1eaa36d 100644 --- a/telegram_bot_project/config.py +++ b/telegram_bot_project/config.py @@ -1,3 +1,4 @@ +# config.py import os import logging from dotenv import load_dotenv @@ -41,4 +42,5 @@ class SmtpData: smtp_subject: str = os.getenv("SMTP_MESSAGE_SUBJECT") def get_smtp_data() -> SmtpData: - return SmtpData() \ No newline at end of file + return SmtpData() + diff --git a/telegram_bot_project/main.py b/telegram_bot_project/main.py index 99baf62..0cb9f4c 100644 --- a/telegram_bot_project/main.py +++ b/telegram_bot_project/main.py @@ -1,5 +1,5 @@ +# main.py import asyncio -from datetime import datetime from aiogram import Bot, Dispatcher, F from aiogram.filters import Command from aiogram.fsm.storage.memory import MemoryStorage @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from config import TOKEN -from bot.scheduler import initialize_scheduler, schedule_all_users_jobs +from bot.scheduler import initialize_scheduler, schedule_all_users_jobs, check_upcoming_tasks from bot.commands import * from bot.callbacks import * from bot.fallbacks import * @@ -262,6 +262,10 @@ async def callback_focus_save(callback_query: CallbackQuery, state: FSMContext): async def callback_focus_title_save(callback_query: CallbackQuery, state: FSMContext): await callback_focus_title(callback_query, state) +@dp.callback_query(F.data.startswith(("complete_task", "cancel_task"))) +async def callback_task_status(callback_query: CallbackQuery) -> None: + await callback_task_menu(callback_query) + @dp.message() async def process_fallback(message: Message, state: FSMContext): await fallback(message, state) @@ -269,6 +273,7 @@ async def process_fallback(message: Message, state: FSMContext): # Main Function async def main(): bot = Bot(token=TOKEN) + storage = dp.storage scheduler: AsyncIOScheduler = initialize_scheduler() scheduler.add_job( @@ -278,6 +283,13 @@ async def main(): minute='0' ) + scheduler.add_job( + check_upcoming_tasks, + trigger='interval', + minutes=1, + args={bot} + ) + await schedule_all_users_jobs(bot) await dp.start_polling(bot) diff --git a/telegram_bot_project/messages.py b/telegram_bot_project/messages.py index a2a2a41..483b6fc 100644 --- a/telegram_bot_project/messages.py +++ b/telegram_bot_project/messages.py @@ -1,5 +1,6 @@ +# messages.py from typing import Any -from aiogram import types, Bot +from aiogram import Bot from service.user import UserService from service.routine import RoutineService @@ -122,6 +123,7 @@ "LANGUAGE_INVALID": "❌ Некоректний вибір мови. Спробуйте ще раз. 🔢", "DELETE_FOCUS_SESSION_MSG": "Вкажіть номер сесії, яку ви хочете видалити.", "FOCUS_DELETED": "✅ Фокус-сесію №{} з назвою \"{}\" успішно видалено.", + "REMIND_WORK_CANCEL": "Нагадування для цього завдання було вимкнено", }, "ENGLISH": { "START_MSG": ( @@ -234,6 +236,7 @@ "FOCUS_LIST_TITLE": "🧠 Your Focus Sessions", "NO_FOCUS_SESSIONS": "😕 No focus sessions found.", "DELETE_FOCUS_SESSION_MSG": "Provide a number of session which you want to delete.", + "REMIND_WORK_CANCEL": "Reminders for this task was deactivated", "LANGUAGE_ASK": ( "🌐 Choose your language. \n" "Select an option below. 📚" @@ -258,8 +261,8 @@ MENU_BUTTON: str = "🏠 Main Menu" UPDATE_IDEA_BUTTON: str = "🆙 Update Idea" ALL_IDEAS: str = "🔍 View All Ideas" -BUTTON_YES: str = "👍 Yes" -BUTTON_NO: str = "🙅 No" +BUTTON_YES_BTN: str = "👍 Yes" +BUTTON_NO_BTN: str = "🙅 No" BUTTON_DELETE_TASK = "🗑️ Delete Task" BUTTON_EDIT_TASK = "✏️ Edit Task" BUTTON_TOGGLE_STATUS = "✅ Mark Complete" @@ -291,6 +294,9 @@ ALL_FOCUSES_BTN = "All Focuses" DELETE_FOCUS = "Delete" +START_WORK_BTN = "Start" +CANCEL_WORK_BTN = "Cancel" + USER_FEEDBACK_MAIL_TEXT = """ 📬 New feedback received! diff --git a/telegram_bot_project/service/focus.py b/telegram_bot_project/service/focus.py index 431465d..f0631f8 100644 --- a/telegram_bot_project/service/focus.py +++ b/telegram_bot_project/service/focus.py @@ -1,3 +1,4 @@ +# service/focus.py from sqlalchemy import text from config import get_session from typing import Optional diff --git a/telegram_bot_project/service/idea.py b/telegram_bot_project/service/idea.py index 6e4a0e2..3c7febb 100644 --- a/telegram_bot_project/service/idea.py +++ b/telegram_bot_project/service/idea.py @@ -1,3 +1,4 @@ +# service/idea.py from sqlalchemy import text from typing import Optional from config import get_session diff --git a/telegram_bot_project/service/myday.py b/telegram_bot_project/service/myday.py index b8ea5fb..2f27ef8 100644 --- a/telegram_bot_project/service/myday.py +++ b/telegram_bot_project/service/myday.py @@ -1,3 +1,4 @@ +# service/myday.py from sqlalchemy import text from config import get_session from typing import Any, Optional diff --git a/telegram_bot_project/service/routine.py b/telegram_bot_project/service/routine.py index 0d299e0..0ddd54d 100644 --- a/telegram_bot_project/service/routine.py +++ b/telegram_bot_project/service/routine.py @@ -1,3 +1,4 @@ +# service/routine.py from sqlalchemy import text from config import get_session from typing import Any, List, Optional diff --git a/telegram_bot_project/service/smtp.py b/telegram_bot_project/service/smtp.py index 190dae2..84d0439 100644 --- a/telegram_bot_project/service/smtp.py +++ b/telegram_bot_project/service/smtp.py @@ -1,3 +1,4 @@ +# service/smtp.py import aiosmtplib from datetime import datetime from email.mime.text import MIMEText diff --git a/telegram_bot_project/service/task.py b/telegram_bot_project/service/task.py index 39f541e..7850c7c 100644 --- a/telegram_bot_project/service/task.py +++ b/telegram_bot_project/service/task.py @@ -1,7 +1,8 @@ from sqlalchemy import text from config import get_session from typing import Any, List, Optional -from datetime import datetime +from datetime import datetime, timedelta + class TaskService: @staticmethod @@ -78,6 +79,37 @@ async def get_user_tasks(user_id: int) -> List[dict]: for task in tasks ] + @staticmethod + async def get_all_upcoming_tasks() -> List[dict]: + async with get_session() as session: + now = datetime.now() + past = now - timedelta(minutes=20) + future = now + timedelta(minutes=31) + result = await session.execute( + text( + """ + SELECT id, user_id, task_name, status, start_time, creation_date + FROM tasks + WHERE start_time IS NOT NULL + AND start_time BETWEEN :past AND :future + AND status = FALSE + """ + ), + {"past": past, "future": future} + ) + tasks = result.fetchall() + return [ + { + "id": task.id, + "user_id": task.user_id, + "task_name": task.task_name, + "status": task.status, + "start_time": task.start_time, + "creation_date": task.creation_date + } + for task in tasks + ] + @staticmethod async def update_task(task_id: int, task_name: Optional[str] = None, status: Optional[bool] = None, @@ -145,3 +177,37 @@ async def toggle_task_status(task_id: int) -> bool: updated = result.scalar_one_or_none() await session.commit() return updated is not None + + @staticmethod + async def get_started_status(task_id: int) -> bool: + async with get_session() as session: + result: Any = await session.execute( + text( + """ + SELECT started + FROM tasks + WHERE id = :task_id + """ + ), + {"task_id": task_id} + ) + started = result.scalar_one_or_none() + print(f"[DEBUG DB] get_started_status({task_id}) -> {started}") + return bool(started) + + @staticmethod + async def update_started_status(task_id): + async with get_session() as session: + result: Any = await session.execute( + text( + """ + UPDATE tasks + SET started = TRUE + WHERE id = :task_id""" + ), + {"task_id": task_id} + ) + + await session.commit() + return result.rowcount == 1 + diff --git a/telegram_bot_project/service/user.py b/telegram_bot_project/service/user.py index 61fe0b5..157004a 100644 --- a/telegram_bot_project/service/user.py +++ b/telegram_bot_project/service/user.py @@ -1,3 +1,4 @@ +# service/user.py from sqlalchemy import text from config import get_session from typing import Optional diff --git a/telegram_bot_project/states.py b/telegram_bot_project/states.py index 18152cc..3dab3c1 100644 --- a/telegram_bot_project/states.py +++ b/telegram_bot_project/states.py @@ -1,3 +1,4 @@ +# states.py from aiogram.fsm.state import StatesGroup, State class DialogStates(StatesGroup): @@ -20,4 +21,5 @@ class DialogStates(StatesGroup): update_morning_routine_id = State() feedback_message = State() provide_title_focusing = State() - delete_focus = State() \ No newline at end of file + delete_focus = State() + start_work = State() \ No newline at end of file From 99513473a5f817139f5012bee8744c25ecda5203 Mon Sep 17 00:00:00 2001 From: gnatykdm Date: Tue, 5 Aug 2025 16:32:18 +0200 Subject: [PATCH 2/2] schema-update --- sql/schema.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sql/schema.sql b/sql/schema.sql index 965b74e..1cea854 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -37,7 +37,8 @@ CREATE TABLE tasks ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, task_name VARCHAR(255) NOT NULL, status BOOLEAN NOT NULL DEFAULT FALSE, - start_time TIMESTAMP, + start_time TIMESTAMP UNIQUE NOT NULL, + started BOOLEAN DEFAULT FALSE NOT NULL, creation_date TIMESTAMP NOT NULL DEFAULT NOW() );