Initial commit: Love Date Bot - Telegram бот для отслеживания особых дат

This commit is contained in:
2026-01-02 17:29:03 +07:00
commit 8a7bb13508
10 changed files with 940 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
TELEGRAM_BOT_TOKEN=
NOTIFY_HOUR=9
NOTIFY_MINUTE=0
MONTHLY_REPORT_DAY=1
TIMEZONE=Asia/Krasnoyarsk

52
.gitignore vendored Normal file
View File

@@ -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

29
CONTRIBUTING.md Normal file
View File

@@ -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

27
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

40
Makefile Normal file
View File

@@ -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)"

106
README.md Normal file
View File

@@ -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. Напоминания о подарках к датам

639
bot.py Normal file
View File

@@ -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()

18
docker-compose.yml Normal file
View File

@@ -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"

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
python-telegram-bot[job-queue]==20.7
python-dotenv==1.0.0
pytz==2023.3