Files
love-date-bot/bot.py

639 lines
27 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()