import logging import asyncio import json import os from datetime import datetime, date, time, timedelta from telegram import Update from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes from dotenv import load_dotenv import calendar import sys # Настройка путей BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, 'data') LOG_DIR = os.path.join(BASE_DIR, 'logs') # Создаем директории если их нет os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True) # Пути к файлам DATA_FILE = os.path.join(DATA_DIR, 'love_dates.json') LOG_FILE = os.path.join(LOG_DIR, 'bot.log') # Настройка логирования logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger(__name__) # Загрузка переменных окружения load_dotenv() TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') if not TOKEN: logger.error("Токен бота не найден! Проверьте файл .env") sys.exit(1) # Чтение настроек из переменных окружения NOTIFY_HOUR = int(os.getenv('NOTIFY_HOUR', '9')) NOTIFY_MINUTE = int(os.getenv('NOTIFY_MINUTE', '0')) NOTIFY_TIME = time(NOTIFY_HOUR, NOTIFY_MINUTE) MONTHLY_REPORT_DAY = int(os.getenv('MONTHLY_REPORT_DAY', '1')) logger.info(f"Настройки бота: уведомления в {NOTIFY_HOUR}:{NOTIFY_MINUTE:02d}, отчет 1 числа") # === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ === def load_data(): """Загружает данные из JSON файла""" try: if os.path.exists(DATA_FILE): with open(DATA_FILE, "r", encoding="utf-8") as f: data = json.load(f) logger.info(f"Данные загружены из {DATA_FILE}") return data except FileNotFoundError: logger.info(f"Файл {DATA_FILE} не найден, создан новый") return {} except json.JSONDecodeError as e: logger.error(f"Ошибка чтения JSON: {e}") return {} except Exception as e: logger.error(f"Ошибка загрузки данных: {e}") return {} return {} def save_data(data): """Сохраняет данные в JSON файл""" try: with open(DATA_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) logger.debug(f"Данные сохранены в {DATA_FILE}") except Exception as e: logger.error(f"Ошибка сохранения данных: {e}") def is_leap_year(year): """Проверка на високосный год""" return calendar.isleap(year) def calculate_precise_difference(start_date, end_date): """ Рассчитывает точную разницу между датами с учётом високосных годов Возвращает: годы, месяцы, дни, общее количество дней """ if start_date > end_date: start_date, end_date = end_date, start_date total_days = (end_date - start_date).days # Рассчитываем годы years = end_date.year - start_date.year # Рассчитываем месяцы if end_date.month < start_date.month: years -= 1 months = 12 - start_date.month + end_date.month else: months = end_date.month - start_date.month # Рассчитываем дни if end_date.day < start_date.day: months -= 1 # Получаем количество дней в предыдущем месяце if end_date.month == 1: prev_month = 12 prev_year = end_date.year - 1 else: prev_month = end_date.month - 1 prev_year = end_date.year days_in_prev_month = calendar.monthrange(prev_year, prev_month)[1] days = days_in_prev_month - start_date.day + end_date.day else: days = end_date.day - start_date.day # Корректировка если месяцы стали отрицательными if months < 0: years -= 1 months += 12 return years, months, days, total_days def calculate_full_months(start_date, end_date): """Рассчитывает полные месяцы между датами""" if start_date > end_date: return 0 full_months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) # Если текущий день меньше начального, вычитаем месяц if end_date.day < start_date.day: full_months -= 1 return max(0, full_months) def format_month_count(months): """Форматирует количество месяцев с правильным окончанием""" if months % 10 == 1 and months % 100 != 11: return f"{months} месяц" elif 2 <= months % 10 <= 4 and (months % 100 < 10 or months % 100 >= 20): return f"{months} месяца" else: return f"{months} месяцев" def format_year_count(years): """Форматирует количество лет с правильным окончанием""" if years % 10 == 1 and years % 100 != 11: return f"{years} год" elif 2 <= years % 10 <= 4 and (years % 100 < 10 or years % 100 >= 20): return f"{years} года" else: return f"{years} лет" def format_day_count(days): """Форматирует количество дней с правильным окончанием""" if days % 10 == 1 and days % 100 != 11: return f"{days} день" elif 2 <= days % 10 <= 4 and (days % 100 < 10 or days % 100 >= 20): return f"{days} дня" else: return f"{days} дней" # === ОБРАБОТЧИКИ КОМАНД === async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /start""" logger.info(f"Команда /start от пользователя {update.effective_chat.id}") await update.message.reply_text( "💖 Привет! Я бот для отслеживания особых дат 💖\n\n" "📌 Добавить дату: /adddate <название> <ГГГГ-ММ-ДД>\n" "🔎 Посмотреть все даты: /mydates\n" "📊 Статистика по дате: /stats <название>\n" "🗑 Удалить дату: /remove <название>\n" "ℹ️ Помощь: /help\n\n" "⏰ Ежедневные уведомления в 09:00\n" "📅 Ежемесячные отчеты 1 числа" ) async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /help""" await update.message.reply_text( "📋 Доступные команды:\n\n" "/start - Начать работу с ботом\n" "/help - Показать это сообщение\n" "/adddate <название> <ГГГГ-ММ-ДД> - Добавить новую дату\n" "/mydates - Показать все ваши даты\n" "/stats <название> - Подробная статистика по дате\n" "/remove <название> - Удалить дату\n\n" "Пример:\n" "/adddate Знакомство 2020-02-29\n" "/stats Знакомство\n\n" "📅 Бот учитывает високосные годы и корректно считает месяцы!" ) async def add_date(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /adddate""" user_id = str(update.effective_chat.id) if len(context.args) < 2: await update.message.reply_text("❌ Неверный формат!\nИспользуйте: /adddate <название> <ГГГГ-ММ-ДД>\nПример: /adddate Свадьба 2023-06-15") return # Название даты может состоять из нескольких слов date_str = context.args[-1] label = " ".join(context.args[:-1]).strip() if not label: await update.message.reply_text("❌ Укажите название даты!\nПример: /adddate Первое свидание 2020-03-01") return try: d = datetime.strptime(date_str, "%Y-%m-%d").date() today = date.today() # Проверка, что дата не в будущем if d > today: await update.message.reply_text("❌ Дата не может быть в будущем!") return data = load_data() if user_id not in data: data[user_id] = {} # Проверяем, существует ли уже дата с таким названием if label in data[user_id]: await update.message.reply_text(f"⚠️ Дата '{label}' уже существует! Используйте другое название.") return # Сохраняем дату data[user_id][label] = d.isoformat() save_data(data) # Рассчитываем сколько прошло времени years, months, days, total_days = calculate_precise_difference(d, today) # Формируем сообщение о високосном годе leap_info = "" if is_leap_year(d.year): leap_info = "\n⭐ Это был високосный год!" if d.month == 2 and d.day == 29: leap_info = "\n🎉 Особенная дата - 29 февраля високосного года!" await update.message.reply_text( f"✅ Дата '{label}' успешно сохранена!\n" f"📅 {d.strftime('%d.%m.%Y')}{leap_info}\n" f"⏳ Уже прошло: {format_year_count(years)}, {format_month_count(months)}, {format_day_count(days)}\n" f"📊 Всего дней: {format_day_count(total_days)}" ) logger.info(f"Пользователь {user_id} добавил дату '{label}': {d}") except ValueError: await update.message.reply_text("❌ Ошибка в формате даты!\nИспользуйте: ГГГГ-ММ-ДД\nПример: 2020-03-01") except Exception as e: logger.error(f"Ошибка в add_date: {e}") await update.message.reply_text("❌ Произошла ошибка при сохранении даты") async def my_dates(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /mydates""" user_id = str(update.effective_chat.id) data = load_data() logger.info(f"Данные для пользователя {user_id}: {json.dumps(data.get(user_id, {}), ensure_ascii=False)}") if user_id not in data or not data[user_id]: await update.message.reply_text("📭 У вас пока нет сохранённых дат.\nДобавьте первую: /adddate <название> <дата>") return today = date.today() message = "📋 Ваши особые даты:\n\n" for label, date_str in data[user_id].items(): logger.info(f"Обработка: '{label}' -> '{date_str}'") try: d = datetime.strptime(date_str, "%Y-%m-%d").date() years, months, days, total_days = calculate_precise_difference(d, today) full_months = calculate_full_months(d, today) # Иконки для особых дат icon = "⭐" if is_leap_year(d.year) else "•" if d.month == 2 and d.day == 29: icon = "🎉" message += f"{icon} {label}\n" message += f" 📅 {d.strftime('%d.%m.%Y')}\n" message += f" ⏳ {format_year_count(years)}, {format_month_count(months)}, {format_day_count(days)}\n" message += f" 📈 Всего: {format_day_count(total_days)} ({format_month_count(full_months)} полных)\n\n" except Exception as e: logger.error(f"Ошибка обработки даты {label}: {e}") message += f"⚠️ {label} - ошибка формата\n\n" message += "📊 Подробная статистика: /stats <название>" try: await update.message.reply_text(message) except Exception as e: logger.error(f"Ошибка отправки my_dates: {e}") await update.message.reply_text("❌ Ошибка при формировании списка дат") async def stats_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /stats""" user_id = str(update.effective_chat.id) if not context.args: await update.message.reply_text("❌ Укажите название даты: /stats <название>") return label = " ".join(context.args).strip() data = load_data() if user_id not in data: await update.message.reply_text("❌ У вас нет сохранённых дат!\nДобавьте первую: /adddate <название> <дата>") return # Поиск даты (регистронезависимый) found_label = None for stored_label in data[user_id].keys(): if stored_label.lower() == label.lower(): found_label = stored_label break if not found_label: await update.message.reply_text(f"❌ Дата '{label}' не найдена!\nПосмотреть все даты: /mydates") return date_str = data[user_id][found_label] d = datetime.strptime(date_str, "%Y-%m-%d").date() today = date.today() years, months, days, total_days = calculate_precise_difference(d, today) full_months = calculate_full_months(d, today) # Рассчитываем следующий годовщину next_anniversary = d.replace(year=today.year) if next_anniversary < today: next_anniversary = d.replace(year=today.year + 1) elif next_anniversary == today: next_anniversary = d.replace(year=today.year + 1) days_to_anniversary = (next_anniversary - today).days # Рассчитываем до следующего месяца if today.day >= d.day: next_month_date = today.replace(day=min(d.day, calendar.monthrange(today.year, today.month)[1])) if next_month_date <= today: if today.month == 12: next_month_date = date(today.year + 1, 1, min(d.day, calendar.monthrange(today.year + 1, 1)[1])) else: next_month_date = date(today.year, today.month + 1, min(d.day, calendar.monthrange(today.year, today.month + 1)[1])) else: next_month_date = today.replace(day=min(d.day, calendar.monthrange(today.year, today.month)[1])) days_to_month = (next_month_date - today).days # Информация о високосном годе leap_info = "" if is_leap_year(d.year): leap_info = "\n⭐ Это был високосный год!" if d.month == 2 and d.day == 29: leap_info = "\n🎉 Особенная дата - 29 февраля високосного года!" # Расчет следующего 29 февраля next_feb29 = None if d.month == 2 and d.day == 29: year = today.year while True: if is_leap_year(year) and date(year, 2, 29) > today: next_feb29 = date(year, 2, 29) break year += 1 message = f"📊 Статистика: {found_label}\n\n" message += f"📅 Дата: {d.strftime('%d.%m.%Y')}{leap_info}\n\n" message += f"⏳ Прошло времени:\n" message += f" • {format_year_count(years)}, {format_month_count(months)}, {format_day_count(days)}\n" message += f" • {format_month_count(full_months)} полностью\n" message += f" • {format_day_count(total_days)} всего\n\n" message += f"📈 Ближайшие события:\n" if days_to_anniversary == 0: message += f" 🎉 СЕГОДНЯ ГОДОВЩИНА! {format_year_count(years+1)} вместе! 🥳\n" else: message += f" • До годовщины: {format_day_count(days_to_anniversary)}\n" if days_to_month == 0 and days_to_anniversary != 0: message += f" 🎉 СЕГОДНЯ МЕСЯЧНАЯ ГОДОВЩИНА! {format_month_count(full_months+1)} вместе! 💫\n" elif days_to_month > 0: message += f" • До месячной годовщины: {format_day_count(days_to_month)}\n" if next_feb29: days_to_feb29 = (next_feb29 - today).days message += f" • До след. 29 февраля: {format_day_count(days_to_feb29)}\n" await update.message.reply_text(message) async def remove_date(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик команды /remove""" user_id = str(update.effective_chat.id) if not context.args: await update.message.reply_text("❌ Укажите название даты: /remove <название>") return label = " ".join(context.args).strip() data = load_data() if user_id not in data: await update.message.reply_text("❌ У вас нет сохранённых дат!") return # Поиск даты (регистронезависимый) found_label = None for stored_label in data[user_id].keys(): if stored_label.lower() == label.lower(): found_label = stored_label break if not found_label: await update.message.reply_text(f"❌ Дата '{label}' не найдена!") return del data[user_id][found_label] # Если у пользователя больше нет дат, удаляем его запись if not data[user_id]: del data[user_id] save_data(data) await update.message.reply_text(f"✅ Дата '{found_label}' успешно удалена!") # === ФОНОВЫЕ ЗАДАЧИ === async def daily_reminder(app): """Ежедневные уведомления""" logger.info("Запущена задача ежедневных уведомлений") last_notification_day = None while True: try: now = datetime.now() current_time = now.time() today = now.date() # Проверяем время для ежедневных уведомлений (09:00) if (current_time.hour == NOTIFY_TIME.hour and current_time.minute == NOTIFY_TIME.minute and last_notification_day != today): last_notification_day = today data = load_data() logger.info(f"Отправка ежедневных уведомлений на {today}") for user_id, dates in data.items(): if not dates: continue message = f"💝 Доброе утро! {today.strftime('%d.%m.%Y')}\n\n" for label, date_str in dates.items(): d = datetime.strptime(date_str, "%Y-%m-%d").date() years, months, days, total_days = calculate_precise_difference(d, today) full_months = calculate_full_months(d, today) message += f"📅 {label}\n" message += f" ⏳ {format_year_count(years)}, {format_month_count(months)}, {format_day_count(days)}\n" # Проверка на годовщину if days == 0 and months == 0 and years > 0: message += f" 🎊 СЕГОДНЯ {format_year_count(years)} ВМЕСТЕ! 🥳\n" # Проверка на месячную годовщину elif days == 0 and months > 0: message += f" 🎉 СЕГОДНЯ {format_month_count(full_months)} ВМЕСТЕ! 💫\n" message += f" 📊 Всего: {format_day_count(total_days)}\n\n" message += "💖 Хорошего дня! 💖" try: await app.bot.send_message(chat_id=int(user_id), text=message) await asyncio.sleep(0.3) except Exception as e: logger.error(f"Ошибка отправки для {user_id}: {e}") await asyncio.sleep(60) await asyncio.sleep(30) except Exception as e: logger.error(f"Ошибка в daily_reminder: {e}") await asyncio.sleep(60) async def monthly_report(app): """Ежемесячные отчеты 1 числа""" logger.info("Запущена задача ежемесячных отчетов") last_report_month = None while True: try: now = datetime.now() today = now.date() # Проверяем, наступило ли 1 число нового месяца if (today.day == MONTHLY_REPORT_DAY and (last_report_month is None or last_report_month != now.month)): last_report_month = now.month data = load_data() logger.info(f"Отправка ежемесячных отчетов за {now.strftime('%B %Y')}") for user_id, dates in data.items(): if not dates: continue message = f"📊 Ежемесячный отчет на {today.strftime('%d.%m.%Y')}\n\n" for label, date_str in dates.items(): d = datetime.strptime(date_str, "%Y-%m-%d").date() full_months = calculate_full_months(d, today) years, months, days, total_days = calculate_precise_difference(d, today) message += f"📅 {label}\n" message += f" Начало: {d.strftime('%d.%m.%Y')}\n" message += f" Полных месяцев: {format_month_count(full_months)}\n" message += f" Всего дней: {format_day_count(total_days)}\n\n" message += "💖 Пусть каждый месяц будет счастливым! 💖" try: await app.bot.send_message(chat_id=int(user_id), text=message) await asyncio.sleep(0.3) except Exception as e: logger.error(f"Ошибка отправки месячного отчета для {user_id}: {e}") await asyncio.sleep(60 * 60 * 24) await asyncio.sleep(60 * 60) except Exception as e: logger.error(f"Ошибка в monthly_report: {e}") await asyncio.sleep(60 * 60) # === ОБРАБОТЧИК ОШИБОК === async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): """Обработчик ошибок""" logger.error(f"Ошибка при обработке обновления: {context.error}") if update and update.effective_chat: try: await context.bot.send_message( chat_id=update.effective_chat.id, text="😔 Произошла ошибка при обработке запроса. Попробуйте позже." ) except Exception as e: logger.error(f"Не удалось отправить сообщение об ошибке: {e}") # === СТАРТ И ЗАВЕРШЕНИЕ === async def on_startup(app): """Запуск фоновых задач при старте""" logger.info("=" * 50) logger.info("Запуск Love Date Bot") logger.info(f"Токен: {TOKEN[:10]}...") logger.info(f"Рабочая директория: {os.getcwd()}") logger.info(f"Файл данных: {DATA_FILE}") logger.info(f"Файл логов: {LOG_FILE}") logger.info("=" * 50) # Загружаем и проверяем данные data = load_data() user_count = len(data) date_count = sum(len(dates) for dates in data.values()) logger.info(f"Загружено {user_count} пользователей с {date_count} датами") # Запускаем фоновые задачи asyncio.create_task(daily_reminder(app)) asyncio.create_task(monthly_report(app)) logger.info("Бот запущен и готов к работе!") async def on_shutdown(app): """Действия при завершении работы""" logger.info("Бот завершает работу...") save_data(load_data()) # Сохраняем данные перед выходом logger.info("Данные сохранены") def main(): try: logger.info("Инициализация бота...") # Проверка интернет-соединения try: import socket socket.create_connection(("api.telegram.org", 443), timeout=10) logger.info("✓ Соединение с Telegram API доступно") except Exception as e: logger.error(f"✗ Нет соединения с Telegram API: {e}") app = ApplicationBuilder() \ .token(TOKEN) \ .post_init(on_startup) \ .post_shutdown(on_shutdown) \ .build() # Регистрация обработчиков команд app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("help", help_command)) app.add_handler(CommandHandler("adddate", add_date)) app.add_handler(CommandHandler("mydates", my_dates)) app.add_handler(CommandHandler("stats", stats_command)) app.add_handler(CommandHandler("remove", remove_date)) # Регистрация обработчика ошибок app.add_error_handler(error_handler) logger.info("Запуск бота в режиме polling...") app.run_polling( drop_pending_updates=True, allowed_updates=Update.ALL_TYPES, close_loop=False, pool_timeout=30, connect_timeout=30, read_timeout=30 ) except Exception as e: logger.error(f"Критическая ошибка при запуске бота: {e}") sys.exit(1) if __name__ == "__main__": main()