Initial commit: Love Date Bot - Telegram бот для отслеживания особых дат
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal 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
52
.gitignore
vendored
Normal 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
29
CONTRIBUTING.md
Normal 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
27
Dockerfile
Normal 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
21
LICENSE
Normal 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
40
Makefile
Normal 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
106
README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# ❤️ Love Date Bot
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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
639
bot.py
Normal 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
18
docker-compose.yml
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
python-telegram-bot[job-queue]==20.7
|
||||
python-dotenv==1.0.0
|
||||
pytz==2023.3
|
||||
Reference in New Issue
Block a user