commit ad9b6e1a079b179c7f39d1ef6ac6819de44d6d27 Author: smolkik-code Date: Fri Jan 2 17:29:03 2026 +0700 Initial commit: Love Date Bot - Telegram бот для отслеживания особых дат diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..70a8a30 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TELEGRAM_BOT_TOKEN= +NOTIFY_HOUR=9 +NOTIFY_MINUTE=0 +MONTHLY_REPORT_DAY=1 +TIMEZONE=Asia/Krasnoyarsk \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02dc238 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Environment +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +env/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Data +data/ +logs/ +*.db +*.sqlite3 + +# Docker +*.dockerignore +docker-compose.override.yml + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ea07edc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Руководство по внесению вклада + +## Как помочь проекту + +### Сообщение об ошибках +1. Проверьте, не создан ли уже такой issue +2. Используйте шаблон для багов +3. Приложите логи и шаги для воспроизведения + +### Предложение новых функций +1. Опишите идею подробно +2. Объясните, зачем это нужно пользователям +3. Предложите реализацию (если есть идеи) + +### Код +1. Следуйте стилю кода проекта +2. Добавляйте комментарии к сложным местам +3. Пишите тесты для новой функциональности +4. Обновляйте документацию + +## Установка для разработки +```bash +git clone https://github.com/ваш-username/love-date-bot.git +cd love-date-bot +python -m venv venv +source venv/bin/activate # Linux/Mac +# или +venv\Scripts\activate # Windows +pip install -r requirements.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eaa6c94 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-alpine + +WORKDIR /app + +# Устанавливаем системные зависимости +RUN apk add --no-cache gcc musl-dev linux-headers tzdata + +# Устанавливаем временную зону +ENV TZ=Asia/Krasnoyarsk +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# Копируем зависимости и устанавливаем +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходный код +COPY bot.py . + +# Создаем пользователя для безопасности +RUN adduser -D -u 1000 botuser && \ + mkdir -p /app/data /app/logs && \ + chown -R botuser:botuser /app + +USER botuser + +# Запускаем бота +CMD ["python", "bot.py"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba3f0bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 smolkik-code + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7744942 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.PHONY: help build run stop restart logs clean shell test + +help: + @echo "Доступные команды:" + @echo " make build - Собрать Docker образ" + @echo " make run - Запустить контейнер (в фоне)" + @echo " make up - Запустить и смотреть логи" + @echo " make stop - Остановить контейнер" + @echo " make restart - Перезапустить контейнер" + @echo " make logs - Показать логи" + @echo " make clean - Остановить и удалить контейнер" + @echo " make shell - Войти в контейнер" + @echo " make test - Запустить тесты" + +build: + docker-compose build + +run: + docker-compose up -d + +up: + docker-compose up + +stop: + docker-compose down + +restart: stop run + +logs: + docker-compose logs -f + +clean: + docker-compose down -v + docker system prune -f + +shell: + docker exec -it love-date-bot /bin/sh + +test: + docker-compose run --rm lovebot python -c "import sys; print('Python OK:', sys.version)" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1715b19 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# ❤️ Love Date Bot +![Python](https://img.shields.io/badge/python-3.11+-blue.svg) +![Telegram](https://img.shields.io/badge/Telegram-Bot-blue.svg) +![Docker](https://img.shields.io/badge/docker-✓-blue.svg) +![License](https://img.shields.io/badge/license-MIT-green.svg) + +Telegram-бот для отслеживания особых дат и отношений. Бот помогает считать дни, месяцы и годы с важных событий, учитывает високосные годы и отправляет ежедневные напоминания. + +## ✨ Возможности + +- 📅 **Добавление дат** - сохранение особых дат (знакомство, свадьба и т.д.) +- 📊 **Статистика** - подробная информация о прошедшем времени +- ⏰ **Ежедневные уведомления** - автоматические напоминания в 09:00 +- 📈 **Ежемесячные отчеты** - статистика 1 числа каждого месяца +- 🎉 **Годовщины** - автоматическое поздравление с годовщинами +- 📱 **Удобные команды** - простой интерфейс в Telegram +- 🐳 **Docker поддержка** - легкий запуск в контейнере + +## 🚀 Быстрый старт + +### 1. Создание бота в Telegram +1. Откройте [@BotFather](https://t.me/botfather) в Telegram +2. Используйте команду `/newbot` +3. Следуйте инструкциям для создания бота +4. Скопируйте полученный токен + +### 2. Клонирование репозитория +```bash +git clone +cd love-date-bot +``` +### 3. Настройка окружения +Создайте файл `.env` в корне проекта: +```bash +TELEGRAM_BOT_TOKEN=ваш_токен_бота +NOTIFY_HOUR=9 +NOTIFY_MINUTE=0 +MONTHLY_REPORT_DAY=1 +TIMEZONE=Asia/Krasnoyarsk +``` +### 4. Запуск с Docker +```bash +docker-compose build --no-cache +docker-compose up -d +``` +### 5. Запуск без Docker +```bash +# Установка зависимостей +pip install -r requirements.txt +# Создание папок для данных +mkdir -p data logs +# Запуск бота +python bot.py +``` +### ⚙️ Настройка +Переменные окружения (.env) +Переменная Описание Значение по умолчанию +TELEGRAM_BOT_TOKEN Токен бота Telegram (обязательно) +NOTIFY_HOUR Час ежедневных уведомлений 9 +NOTIFY_MINUTE Минута ежедневных уведомлений 0 +MONTHLY_REPORT_DAY День месяца для отчетов 1 +TIMEZONE Часовой пояс бота Asia/Krasnoyarsk + + +### Файл данных +Данные хранятся в `data/love_dates.json` в формате: +```json +{ + "user_id_1": { + "Название даты 1": "2020-03-01", + "Название даты 2": "2022-06-15" + }, + "user_id_2": { + "Другая дата": "2021-01-01" + } +} +``` +### 🔧 Управление через Makefile +```bash +make build # Собрать Docker образ +make run # Запустить контейнер (в фоне) +make up # Запустить и смотреть логи +make stop # Остановить контейнер +make restart # Перезапустить контейнер +make logs # Просмотр логов в реальном времени +make clean # Остановить и удалить контейнер +make shell # Войти в контейнер +``` +### 📊 Особенности реализации +1. Учет високосных годов +2. Бот корректно обрабатывает 29 февраля и считает дни с учетом високосных годов. + +### Точный расчет времени +1. Рассчитывает точное количество лет, месяцев и дней +2. Учитывает разное количество дней в месяцах +3. Корректно обрабатывает переходы через год + +### Автоматические уведомления +1. Ежедневно в 09:00 - напоминание о всех датах +2. 1 числа каждого месяца - ежемесячный отчет +3. В день годовщины - специальное поздравление + +### 📈 Планы развития +1. Поддержка нескольких языков +2. Интеграция с календарями (Google Calendar, iCal) +3. Напоминания о подарках к датам \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..e11a4ce --- /dev/null +++ b/bot.py @@ -0,0 +1,639 @@ +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() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d8870e5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + lovebot: + build: . + container_name: love-date-bot + restart: unless-stopped + volumes: + - ./data:/app/data + - ./logs:/app/logs + env_file: + - .env + environment: + - PYTHONUNBUFFERED=1 + - TZ=${TIMEZONE:-Asia/Krasnoyarsk} + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f82c371 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-telegram-bot[job-queue]==20.7 +python-dotenv==1.0.0 +pytz==2023.3 \ No newline at end of file