From ad9b6e1a079b179c7f39d1ef6ac6819de44d6d27 Mon Sep 17 00:00:00 2001 From: smolkik-code Date: Fri, 2 Jan 2026 17:29:03 +0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Love=20Date=20Bot=20-=20Tel?= =?UTF-8?q?egram=20=D0=B1=D0=BE=D1=82=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BE=D1=81=D0=BE=D0=B1=D1=8B=D1=85=20=D0=B4=D0=B0=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + .gitignore | 52 ++++ CONTRIBUTING.md | 29 ++ Dockerfile | 27 ++ LICENSE | 21 ++ Makefile | 40 +++ README.md | 106 ++++++++ bot.py | 639 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 18 ++ requirements.txt | 3 + 10 files changed, 940 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 bot.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt 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