Refactor Dockerfile and main.py for improved environment variable handling and logging; update requirements.txt for package specifications
This commit is contained in:
+433
-248
@@ -5,26 +5,84 @@ from bs4 import BeautifulSoup
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, ReplyKeyboardMarkup, KeyboardButton
|
||||
from aiogram.types import Message, KeyboardButton
|
||||
from aiogram.utils.keyboard import ReplyKeyboardBuilder
|
||||
import logging
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
# Загрузка переменных окружения
|
||||
load_dotenv()
|
||||
|
||||
# Настройки
|
||||
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "YOUR_TOKEN_HERE")
|
||||
ALLOWED_CHAT_IDS = list(map(int, os.getenv("ALLOWED_CHAT_IDS", "").split(","))) if os.getenv("ALLOWED_CHAT_IDS") else []
|
||||
CHECK_INTERVAL = 120 # 2 минуты
|
||||
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "120"))
|
||||
|
||||
# Параметры для запроса
|
||||
VOE_CITY_ID = int(os.getenv("VOE_CITY_ID", "VOE_CITY_ID"))
|
||||
VOE_STREET_ID = int(os.getenv("VOE_STREET_ID", "VOE_STREET_ID"))
|
||||
VOE_HOUSE_ID = int(os.getenv("VOE_HOUSE_ID", "VOE_HOUSE_ID"))
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Глобальные переменные
|
||||
bot = Bot(token=TELEGRAM_TOKEN)
|
||||
dp = Dispatcher()
|
||||
last_schedule = None
|
||||
last_schedule: Optional[List[Dict]] = None
|
||||
last_notification_time: Dict[str, datetime] = {}
|
||||
|
||||
|
||||
def parse_html(html: str):
|
||||
# ============================================================================
|
||||
# УТИЛИТЫ
|
||||
# ============================================================================
|
||||
|
||||
def format_time_duration(minutes: int) -> str:
|
||||
"""Форматирует время из минут в часы и минуты"""
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
|
||||
if hours == 0:
|
||||
return f"{mins}м"
|
||||
elif mins == 0:
|
||||
return f"{hours}ч"
|
||||
return f"{hours}ч {mins}м"
|
||||
|
||||
|
||||
def get_day_statistics(day_blocks: List[Dict]) -> Dict[str, int]:
|
||||
"""Получает статистику по дню"""
|
||||
total_minutes = 0
|
||||
confirmed_minutes = 0
|
||||
possible_minutes = 0
|
||||
|
||||
for block in day_blocks:
|
||||
for half in [block["first_half"], block["second_half"]]:
|
||||
if half["status"] == "off":
|
||||
total_minutes += 30
|
||||
if half["confirmed"]:
|
||||
confirmed_minutes += 30
|
||||
else:
|
||||
possible_minutes += 30
|
||||
|
||||
return {
|
||||
"total": total_minutes,
|
||||
"confirmed": confirmed_minutes,
|
||||
"possible": possible_minutes
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ПАРСИНГ ДАННЫХ
|
||||
# ============================================================================
|
||||
|
||||
def parse_html(html: str) -> List[Dict]:
|
||||
"""Парсит HTML с графиком отключений"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
cells = soup.select(".disconnection-detailed-table-cell.cell")
|
||||
|
||||
@@ -46,28 +104,29 @@ def parse_html(html: str):
|
||||
left = hour_block.select_one(".half.left")
|
||||
right = hour_block.select_one(".half.right")
|
||||
|
||||
def parse_half(half, force_off=False):
|
||||
def parse_half(half, force_off: bool = False) -> Dict:
|
||||
if not half:
|
||||
return {"status": "unknown", "queue": None, "confirmed": None}
|
||||
|
||||
if force_off:
|
||||
status = "off"
|
||||
else:
|
||||
status = "off" if force_off else "unknown"
|
||||
if not force_off:
|
||||
classes = half.get("class", [])
|
||||
if "has_disconnection" in classes:
|
||||
status = "off"
|
||||
elif "no_disconnection" in classes:
|
||||
status = "on"
|
||||
else:
|
||||
status = "unknown"
|
||||
|
||||
queue = None
|
||||
confirmed = None
|
||||
disconnection_div = half.select_one(".disconnection")
|
||||
|
||||
if disconnection_div and disconnection_div.has_attr("title"):
|
||||
title = disconnection_div["title"]
|
||||
if "Номер черги" in title:
|
||||
queue = title.split(":")[-1].strip()
|
||||
try:
|
||||
queue = title.split(":")[-1].strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
disconnection_classes = disconnection_div.get("class", [])
|
||||
if "disconnection_confirm_1" in disconnection_classes:
|
||||
@@ -75,7 +134,7 @@ def parse_html(html: str):
|
||||
elif "disconnection_confirm_0" in disconnection_classes:
|
||||
confirmed = False
|
||||
|
||||
if force_off and not confirmed and not queue:
|
||||
if force_off and confirmed is None:
|
||||
if 'confirm_1' in cell_classes:
|
||||
confirmed = True
|
||||
elif 'confirm_0' in cell_classes:
|
||||
@@ -101,12 +160,13 @@ def parse_html(html: str):
|
||||
return schedule
|
||||
|
||||
|
||||
def get_voe_html(city_id, street_id, house_id):
|
||||
def get_voe_html(city_id: int, street_id: int, house_id: int) -> str:
|
||||
"""Получает HTML с сайта VOE"""
|
||||
url = "https://www.voe.com.ua/disconnection/detailed?ajax_form=1&_wrapper_format=drupal_ajax"
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; PowerBot/1.0)"
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
data = {
|
||||
"search_type": 0,
|
||||
@@ -119,184 +179,208 @@ def get_voe_html(city_id, street_id, house_id):
|
||||
"_triggering_element_value": "Показати",
|
||||
"_drupal_ajax": 1,
|
||||
}
|
||||
r = requests.post(url, headers=headers, data=data)
|
||||
r.raise_for_status()
|
||||
resp_json = json.loads(r.text)
|
||||
insert_html = next((i["data"] for i in resp_json if i.get("command") == "insert"), None)
|
||||
if not insert_html:
|
||||
raise ValueError("HTML не найден в ответе")
|
||||
return insert_html
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, data=data, timeout=10)
|
||||
response.raise_for_status()
|
||||
resp_json = response.json()
|
||||
|
||||
insert_html = next(
|
||||
(item["data"] for item in resp_json if item.get("command") == "insert"),
|
||||
None
|
||||
)
|
||||
|
||||
if not insert_html:
|
||||
raise ValueError("HTML не найден в ответе")
|
||||
|
||||
return insert_html
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Ошибка запроса VOE: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ФОРМАТИРОВАНИЕ СООБЩЕНИЙ
|
||||
# ============================================================================
|
||||
|
||||
def get_main_keyboard():
|
||||
"""Создает главную клавиатуру с кнопками"""
|
||||
"""Создает главную клавиатуру"""
|
||||
builder = ReplyKeyboardBuilder()
|
||||
builder.row(
|
||||
KeyboardButton(text="📊 Графік"),
|
||||
KeyboardButton(text="🔄 Перевірити")
|
||||
KeyboardButton(text="🔄 Оновити")
|
||||
)
|
||||
builder.row(
|
||||
KeyboardButton(text="📅 Сьогодні"),
|
||||
KeyboardButton(text="📅 Завтра")
|
||||
)
|
||||
builder.row(KeyboardButton(text="ℹ️ Про бота"))
|
||||
return builder.as_markup(resize_keyboard=True)
|
||||
|
||||
|
||||
def format_schedule_message(schedule, days_to_show=2):
|
||||
"""Форматирует расписание для отправки в Telegram"""
|
||||
message = f"⚡️ <b>Графік відключень світла</b>\n"
|
||||
message += f"🕐 Оновлено: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
def format_schedule_message(schedule: List[Dict], days_to_show: int = 2) -> str:
|
||||
"""Форматирует полный график на несколько дней"""
|
||||
lines = [
|
||||
"⚡️ <b>Графік відключень світла</b>",
|
||||
f"🕐 Оновлено: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
|
||||
"─" * 30,
|
||||
""
|
||||
]
|
||||
|
||||
start_date = datetime.now()
|
||||
|
||||
for day in range(days_to_show):
|
||||
for day in range(min(days_to_show, 2)):
|
||||
day_blocks = [b for b in schedule if b["day"] == day]
|
||||
if not day_blocks:
|
||||
continue
|
||||
|
||||
date_str = (start_date + timedelta(days=day)).strftime('%d.%m')
|
||||
day_name = "🌅 Сьогодні" if day == 0 else "🌄 Завтра"
|
||||
date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y')
|
||||
day_name = "🌅 <b>Сьогодні</b>" if day == 0 else "🌄 <b>Завтра</b>"
|
||||
|
||||
message += f"{'─' * 32}\n"
|
||||
message += f"📅 <b>{day_name} ({date_str})</b>\n"
|
||||
message += f"{'─' * 32}\n\n"
|
||||
lines.append(f"{day_name} ({date_str})")
|
||||
|
||||
# Группируем последовательные отключения
|
||||
current_time = None
|
||||
# Статистика
|
||||
stats = get_day_statistics(day_blocks)
|
||||
if stats["total"] > 0:
|
||||
lines.append(f"⏱ Всього: <code>{format_time_duration(stats['total'])}</code>")
|
||||
if stats["confirmed"] > 0:
|
||||
lines.append(f"🔴 Підтверджено: <code>{format_time_duration(stats['confirmed'])}</code>")
|
||||
if stats["possible"] > 0:
|
||||
lines.append(f"🟠 Можливо: <code>{format_time_duration(stats['possible'])}</code>")
|
||||
else:
|
||||
lines.append("🟢 <b>Відключень немає!</b>")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Детальный список отключений
|
||||
disconnections = []
|
||||
current_status = None
|
||||
start_time = None
|
||||
current_confirmed = None
|
||||
current_queue = None
|
||||
start_time = None
|
||||
|
||||
disconnection_periods = []
|
||||
|
||||
for i, block in enumerate(day_blocks):
|
||||
for block in day_blocks:
|
||||
hour = block["hour"]
|
||||
|
||||
# Проверяем обе половины часа
|
||||
for half_idx, half in enumerate([block["first_half"], block["second_half"]]):
|
||||
time_str = f"{hour:02d}:00" if half_idx == 0 else f"{hour:02d}:30"
|
||||
|
||||
if half["status"] == "off":
|
||||
if current_status != "off":
|
||||
# Начало нового периода отключения
|
||||
start_time = time_str
|
||||
current_confirmed = half["confirmed"]
|
||||
current_queue = half["queue"]
|
||||
current_status = "off"
|
||||
else:
|
||||
if current_status == "off":
|
||||
# Конец периода отключения
|
||||
icon = "🔴" if current_confirmed is True else "🟠"
|
||||
queue_text = f" • Черга {current_queue}" if current_queue else ""
|
||||
disconnection_periods.append(f"{icon} <code>{start_time} - {time_str}</code>{queue_text}")
|
||||
icon = "🔴" if current_confirmed else "🟠"
|
||||
queue_text = f" (Ч{current_queue})" if current_queue else ""
|
||||
disconnections.append(f"{icon} <code>{start_time} - {time_str}</code>{queue_text}")
|
||||
current_status = half["status"]
|
||||
|
||||
# Если день закончился на отключении
|
||||
if current_status == "off":
|
||||
icon = "🔴" if current_confirmed is True else "🟠"
|
||||
queue_text = f" • Черга {current_queue}" if current_queue else ""
|
||||
icon = "🔴" if current_confirmed else "🟠"
|
||||
queue_text = f" (Ч{current_queue})" if current_queue else ""
|
||||
next_hour = (day_blocks[-1]["hour"] + 1) % 24
|
||||
end_time = f"{next_hour:02d}:00"
|
||||
disconnection_periods.append(f"{icon} <code>{start_time} - {end_time}</code>{queue_text}")
|
||||
disconnections.append(f"{icon} <code>{start_time} - {end_time}</code>{queue_text}")
|
||||
|
||||
# Выводим результат
|
||||
if disconnection_periods:
|
||||
for period in disconnection_periods:
|
||||
message += period + "\n"
|
||||
else:
|
||||
message += "🟢 <b>Відключень немає!</b>\n"
|
||||
if disconnections:
|
||||
for idx, disc in enumerate(disconnections, 1):
|
||||
lines.append(f"{idx}. {disc}")
|
||||
|
||||
message += "\n"
|
||||
lines.append("")
|
||||
|
||||
message += f"{'─' * 32}\n"
|
||||
message += "💡 <i>Легенда:</i>\n"
|
||||
message += "🔴 — Підтверджено\n"
|
||||
message += "🟠 — Можливо\n"
|
||||
message += "🟢 — Світло є\n"
|
||||
lines.append("<i>🔴 = підтверджено • 🟠 = можливо • 🟢 = світло</i>")
|
||||
|
||||
return message
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_single_day_schedule(schedule, day_num):
|
||||
"""Форматирует расписание для одного дня"""
|
||||
start_date = datetime.now()
|
||||
date_str = (start_date + timedelta(days=day_num)).strftime('%d.%m')
|
||||
day_name = "🌅 Сьогодні" if day_num == 0 else "🌄 Завтра"
|
||||
|
||||
message = f"{'─' * 32}\n"
|
||||
message += f"📅 <b>{day_name} ({date_str})</b>\n"
|
||||
message += f"{'─' * 32}\n\n"
|
||||
|
||||
day_blocks = [b for b in schedule if b["day"] == day_num]
|
||||
def format_single_day_schedule(schedule: List[Dict], day: int) -> str:
|
||||
"""Форматирует график на один день"""
|
||||
day_blocks = [b for b in schedule if b["day"] == day]
|
||||
if not day_blocks:
|
||||
return message + "❓ <i>Немає даних</i>"
|
||||
return "❌ Немає даних для цього дня"
|
||||
|
||||
# Создаем визуальную шкалу времени
|
||||
message += "⏰ <b>Часова шкала:</b>\n\n"
|
||||
start_date = datetime.now()
|
||||
date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y')
|
||||
day_name = "🟠 <b>Сьогодні</b>" if day == 0 else "🔶 <b>Завтра</b>"
|
||||
|
||||
# Выводим по 4 часа в строке для читаемости
|
||||
for start_hour in range(0, 24, 4):
|
||||
line = ""
|
||||
for h in range(start_hour, min(start_hour + 4, 24)):
|
||||
block = day_blocks[h] if h < len(day_blocks) else None
|
||||
if not block:
|
||||
line += f"{h:02d}|❓❓ "
|
||||
continue
|
||||
|
||||
first = block["first_half"]
|
||||
second = block["second_half"]
|
||||
|
||||
def get_icon(half_data):
|
||||
if half_data["status"] == "off":
|
||||
return "🔴" if half_data["confirmed"] is True else "🟠"
|
||||
elif half_data["status"] == "on":
|
||||
return "🟢"
|
||||
return "❓"
|
||||
|
||||
first_icon = "●" if first["status"] == "off" else "·"
|
||||
second_icon = "●" if second["status"] == "off" else "·"
|
||||
|
||||
line += f"<code>{h:02d}|{first_icon}{second_icon}</code> "
|
||||
lines = [
|
||||
f"{day_name} • {date_str}",
|
||||
""
|
||||
]
|
||||
|
||||
# Статистика
|
||||
lines.append("<b>📊 Статистика</b>")
|
||||
stats = get_day_statistics(day_blocks)
|
||||
|
||||
if stats["total"] == 0:
|
||||
lines.append("└ 🟢 <b>Відключень немає!</b>")
|
||||
else:
|
||||
total_time = format_time_duration(stats["total"])
|
||||
lines.append(f"├ ⏱ Всього: <code>{total_time}</code>")
|
||||
|
||||
message += line + "\n"
|
||||
if stats["confirmed"] > 0:
|
||||
confirmed_time = format_time_duration(stats["confirmed"])
|
||||
lines.append(f"├ 🔴 Підтверджено: <code>{confirmed_time}</code>")
|
||||
|
||||
if stats["possible"] > 0:
|
||||
possible_time = format_time_duration(stats["possible"])
|
||||
lines.append(f"└ 🟠 Можливо: <code>{possible_time}</code>")
|
||||
else:
|
||||
lines.append(f"└ 🟢 Решта часу світло")
|
||||
|
||||
message += "\n<i>● = відключено, · = світло</i>\n\n"
|
||||
lines.append("")
|
||||
|
||||
# Детальний список відключень
|
||||
message += "📋 <b>Детально:</b>\n\n"
|
||||
# Детальный список отключений
|
||||
disconnections = []
|
||||
current_status = None
|
||||
start_time = None
|
||||
current_confirmed = None
|
||||
current_queue = None
|
||||
|
||||
has_disconnections = False
|
||||
for block in day_blocks:
|
||||
hour = block["hour"]
|
||||
first = block["first_half"]
|
||||
second = block["second_half"]
|
||||
|
||||
if first["status"] == "off":
|
||||
has_disconnections = True
|
||||
icon = "🔴" if first["confirmed"] is True else "🟠"
|
||||
status = "підтв." if first["confirmed"] is True else "можл."
|
||||
queue = f"Ч{first['queue']}" if first['queue'] else "—"
|
||||
message += f"{icon} <code>{hour:02d}:00-{hour:02d}:30</code> • {status} • {queue}\n"
|
||||
|
||||
if second["status"] == "off":
|
||||
has_disconnections = True
|
||||
icon = "🔴" if second["confirmed"] is True else "🟠"
|
||||
status = "підтв." if second["confirmed"] is True else "можл."
|
||||
queue = f"Ч{second['queue']}" if second['queue'] else "—"
|
||||
message += f"{icon} <code>{hour:02d}:30-{hour+1:02d}:00</code> • {status} • {queue}\n"
|
||||
for half_idx, half in enumerate([block["first_half"], block["second_half"]]):
|
||||
time_str = f"{hour:02d}:00" if half_idx == 0 else f"{hour:02d}:30"
|
||||
|
||||
if half["status"] == "off":
|
||||
if current_status != "off":
|
||||
start_time = time_str
|
||||
current_confirmed = half["confirmed"]
|
||||
current_queue = half["queue"]
|
||||
current_status = "off"
|
||||
else:
|
||||
if current_status == "off":
|
||||
icon = "🔴" if current_confirmed else "🟠"
|
||||
queue_text = f" (Ч.{current_queue})" if current_queue else ""
|
||||
disconnections.append(f"{icon} <code>{start_time} - {time_str}</code>{queue_text}")
|
||||
current_status = half["status"]
|
||||
|
||||
if not has_disconnections:
|
||||
message += "🟢 <b>Відключень немає!</b>\n"
|
||||
# Если день закончился на отключении
|
||||
if current_status == "off":
|
||||
icon = "🔴" if current_confirmed else "🟠"
|
||||
queue_text = f" (Ч.{current_queue})" if current_queue else ""
|
||||
next_hour = (day_blocks[-1]["hour"] + 1) % 24
|
||||
end_time = f"{next_hour:02d}:00"
|
||||
disconnections.append(f"{icon} <code>{start_time} - {end_time}</code>{queue_text}")
|
||||
|
||||
message += f"\n{'─' * 32}\n"
|
||||
message += "<i>🔴 підтв. • 🟠 можл. • 🟢 світло</i>"
|
||||
if disconnections:
|
||||
lines.append("<b>⚡️ Розклад відключень</b>")
|
||||
for idx, disc in enumerate(disconnections, 1):
|
||||
lines.append(f"{idx}. {disc}")
|
||||
|
||||
return message
|
||||
lines.append("")
|
||||
lines.append("<i>🔴 підтверджено • 🟠 можливо • 🟢 світло</i>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def schedules_differ(old_schedule, new_schedule):
|
||||
"""Проверяет, отличаются ли графики"""
|
||||
def schedules_differ(old_schedule: Optional[List[Dict]], new_schedule: Optional[List[Dict]]) -> bool:
|
||||
"""Проверяет отличия между графиками"""
|
||||
if old_schedule is None or new_schedule is None:
|
||||
return True
|
||||
|
||||
@@ -304,7 +388,6 @@ def schedules_differ(old_schedule, new_schedule):
|
||||
return True
|
||||
|
||||
for old, new in zip(old_schedule, new_schedule):
|
||||
# Сравниваем только первые 2 дня
|
||||
if old["day"] >= 2:
|
||||
break
|
||||
|
||||
@@ -315,256 +398,358 @@ def schedules_differ(old_schedule, new_schedule):
|
||||
return False
|
||||
|
||||
|
||||
async def send_to_all_users(message_text: str):
|
||||
# ============================================================================
|
||||
# УВЕДОМЛЕНИЯ
|
||||
# ============================================================================
|
||||
|
||||
async def send_to_all_users(message_text: str, parse_mode: str = "HTML"):
|
||||
"""Отправляет сообщение всем пользователям"""
|
||||
if not ALLOWED_CHAT_IDS:
|
||||
logger.warning("Нет допущенных ID чатов для отправки уведомлений")
|
||||
return
|
||||
|
||||
for chat_id in ALLOWED_CHAT_IDS:
|
||||
try:
|
||||
await bot.send_message(chat_id, message_text, parse_mode="HTML")
|
||||
logger.info(f"Сообщение отправлено пользователю {chat_id}")
|
||||
await bot.send_message(chat_id, message_text, parse_mode=parse_mode)
|
||||
logger.info(f"✅ Сообщение отправлено пользователю {chat_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки пользователю {chat_id}: {e}")
|
||||
logger.error(f"❌ Ошибка отправки пользователю {chat_id}: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
async def check_schedule():
|
||||
"""Проверяет график и отправляет уведомления при изменениях"""
|
||||
"""Проверяет график и отправляет уведомления"""
|
||||
global last_schedule
|
||||
|
||||
try:
|
||||
logger.info("Проверка графика...")
|
||||
logger.info("🔍 Проверка графика...")
|
||||
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
|
||||
new_schedule = parse_html(html)
|
||||
|
||||
if schedules_differ(last_schedule, new_schedule):
|
||||
logger.info("Обнаружены изменения в графике!")
|
||||
logger.info("✨ Обнаружены изменения!")
|
||||
message = format_schedule_message(new_schedule, days_to_show=2)
|
||||
|
||||
if last_schedule is None:
|
||||
pass # Ничего не делать, если изменений нет
|
||||
else:
|
||||
# Изменения в графике
|
||||
if last_schedule is not None:
|
||||
await send_to_all_users(f"🔄 <b>Графік оновлено!</b>\n\n{message}")
|
||||
|
||||
last_schedule = new_schedule
|
||||
else:
|
||||
logger.info("Изменений нет")
|
||||
logger.info("✓ Графік без змін")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке графика: {e}")
|
||||
await send_to_all_users(f"❌ <b>Помилка!</b>\n\nНе вдалося отримати графік: {str(e)}")
|
||||
logger.error(f"❌ Ошибка при проверке графика: {e}")
|
||||
|
||||
|
||||
async def check_upcoming_disconnections():
|
||||
"""Проверяет предстоящие отключения и включения, отправляет предупреждения"""
|
||||
global last_schedule
|
||||
"""Проверяет предстоящие события и отправляет предупреждения за 5 минут"""
|
||||
global last_notification_time
|
||||
|
||||
if last_schedule is None:
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
current_time = now.time()
|
||||
|
||||
# Проверяем только текущий день
|
||||
today_blocks = [b for b in last_schedule if b["day"] == 0]
|
||||
|
||||
# Создаем список всех переходов (off -> on или on -> off)
|
||||
transitions = []
|
||||
prev_status = None
|
||||
|
||||
for block in today_blocks:
|
||||
hour = block["hour"]
|
||||
|
||||
# Проверяем первую половину часа (XX:00 - XX:30)
|
||||
first_half_start = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
time_until_first = (first_half_start - now).total_seconds() / 60
|
||||
|
||||
# Если до события осталось 5 минут (с погрешностью ±1 минута)
|
||||
if 4 <= time_until_first <= 6:
|
||||
first = block["first_half"]
|
||||
for half_idx, half in enumerate([block["first_half"], block["second_half"]]):
|
||||
minute = 0 if half_idx == 0 else 30
|
||||
time_str = f"{hour:02d}:{minute:02d}"
|
||||
|
||||
# ОТКЛЮЧЕНИЕ
|
||||
if first["status"] == "off":
|
||||
icon = "🔴" if first["confirmed"] is True else "🟠"
|
||||
status_text = "підтверджено" if first["confirmed"] is True else "можливе"
|
||||
queue_info = f" (Черга {first['queue']})" if first["queue"] else ""
|
||||
current_status = half["status"]
|
||||
|
||||
# Если статус изменился - это переход
|
||||
if prev_status is not None and prev_status != current_status:
|
||||
transitions.append({
|
||||
"hour": hour,
|
||||
"minute": minute,
|
||||
"time_str": time_str,
|
||||
"from_status": prev_status,
|
||||
"to_status": current_status,
|
||||
"confirmed": half.get("confirmed"),
|
||||
"queue": half.get("queue")
|
||||
})
|
||||
|
||||
prev_status = current_status
|
||||
|
||||
# Проверяем переходы
|
||||
for transition in transitions:
|
||||
event_time = now.replace(
|
||||
hour=transition["hour"],
|
||||
minute=transition["minute"],
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
|
||||
time_until = (event_time - now).total_seconds() / 60
|
||||
notification_key = f"{transition['hour']}:{transition['minute']}_{transition['to_status']}"
|
||||
|
||||
# Если за 5 минут до события (±1 минута) и еще не отправляли
|
||||
if 4 <= time_until <= 6:
|
||||
# Проверяем, не отправляли ли уже уведомление сегодня
|
||||
if notification_key in last_notification_time:
|
||||
last_notif_time = last_notification_time[notification_key]
|
||||
if last_notif_time.date() == now.date():
|
||||
continue # Уже отправляли сегодня
|
||||
|
||||
# Переход на ОТКЛЮЧЕНИЕ (on -> off)
|
||||
if transition["from_status"] == "on" and transition["to_status"] == "off":
|
||||
icon = "🔴" if transition["confirmed"] else "🟠"
|
||||
status = "підтверджено" if transition["confirmed"] else "можливе"
|
||||
queue_info = f" (Черга {transition['queue']})" if transition['queue'] else ""
|
||||
|
||||
warning = (
|
||||
f"⚠️ <b>УВАГА! ВІДКЛЮЧЕННЯ</b>\n\n"
|
||||
f"Через 5 хвилин відключення {icon}\n"
|
||||
f"Час: {hour:02d}:00 - {hour:02d}:30\n"
|
||||
f"Статус: {status_text}{queue_info}"
|
||||
f"Через ~5 хвилин\n"
|
||||
f"Час: <code>{transition['time_str']}</code>\n"
|
||||
f"Статус: {icon} {status}{queue_info}"
|
||||
)
|
||||
|
||||
await send_to_all_users(warning)
|
||||
logger.info(f"Отправлено предупреждение о отключении в {hour:02d}:00")
|
||||
last_notification_time[notification_key] = now
|
||||
logger.info(f"📢 Відправлено попередження про ВІДКЛЮЧЕННЯ в {transition['time_str']}")
|
||||
|
||||
# ВКЛЮЧЕНИЕ
|
||||
elif first["status"] == "on":
|
||||
# Проверяем, было ли отключение до этого
|
||||
prev_block = today_blocks[today_blocks.index(block) - 1] if today_blocks.index(block) > 0 else None
|
||||
was_off = False
|
||||
|
||||
if prev_block:
|
||||
was_off = prev_block["second_half"]["status"] == "off"
|
||||
|
||||
if was_off:
|
||||
warning = (
|
||||
f"✅ <b>УВАГА! ВКЛЮЧЕННЯ</b>\n\n"
|
||||
f"Через 5 хвилин світло має з'явитися 🟢\n"
|
||||
f"Час: {hour:02d}:00"
|
||||
)
|
||||
await send_to_all_users(warning)
|
||||
logger.info(f"Отправлено предупреждение о включении в {hour:02d}:00")
|
||||
|
||||
# Проверяем вторую половину часа (XX:30 - XX+1:00)
|
||||
second_half_start = now.replace(hour=hour, minute=30, second=0, microsecond=0)
|
||||
time_until_second = (second_half_start - now).total_seconds() / 60
|
||||
|
||||
if 4 <= time_until_second <= 6:
|
||||
second = block["second_half"]
|
||||
|
||||
# ОТКЛЮЧЕНИЕ
|
||||
if second["status"] == "off":
|
||||
icon = "🔴" if second["confirmed"] is True else "🟠"
|
||||
status_text = "підтверджено" if second["confirmed"] is True else "можливе"
|
||||
queue_info = f" (Черга {second['queue']})" if second["queue"] else ""
|
||||
|
||||
# Переход на ВКЛЮЧЕНИЕ (off -> on)
|
||||
elif transition["from_status"] == "off" and transition["to_status"] == "on":
|
||||
warning = (
|
||||
f"⚠️ <b>УВАГА! ВІДКЛЮЧЕННЯ</b>\n\n"
|
||||
f"Через 5 хвилин відключення {icon}\n"
|
||||
f"Час: {hour:02d}:30 - {hour+1:02d}:00\n"
|
||||
f"Статус: {status_text}{queue_info}"
|
||||
f"✅ <b>УВАГА! ВКЛЮЧЕННЯ</b>\n\n"
|
||||
f"Через ~5 хвилин буде світло\n"
|
||||
f"Час: <code>{transition['time_str']}</code>"
|
||||
)
|
||||
await send_to_all_users(warning)
|
||||
logger.info(f"Отправлено предупреждение о отключении в {hour:02d}:30")
|
||||
|
||||
# ВКЛЮЧЕНИЕ
|
||||
elif second["status"] == "on":
|
||||
# Проверяем, было ли отключение в первой половине
|
||||
was_off = block["first_half"]["status"] == "off"
|
||||
|
||||
if was_off:
|
||||
warning = (
|
||||
f"✅ <b>УВАГА! ВКЛЮЧЕННЯ</b>\n\n"
|
||||
f"Через 5 хвилин світло має з'явитися 🟢\n"
|
||||
f"Час: {hour:02d}:30"
|
||||
)
|
||||
await send_to_all_users(warning)
|
||||
logger.info(f"Отправлено предупреждение о включении в {hour:02d}:30")
|
||||
await send_to_all_users(warning)
|
||||
last_notification_time[notification_key] = now
|
||||
logger.info(f"📢 Відправлено попередження про ВКЛЮЧЕННЯ в {transition['time_str']}")
|
||||
|
||||
|
||||
async def monitoring_loop():
|
||||
"""Основной цикл мониторинга"""
|
||||
# Первая проверка сразу при запуске
|
||||
await check_schedule()
|
||||
|
||||
# Затем проверяем каждые 2 минуты
|
||||
while True:
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
await check_schedule()
|
||||
await check_upcoming_disconnections()
|
||||
try:
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
await check_schedule()
|
||||
await check_upcoming_disconnections()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ОБРАБОТЧИКИ КОМАНД
|
||||
# ============================================================================
|
||||
|
||||
@dp.message(Command("start"))
|
||||
async def cmd_start(message: Message):
|
||||
"""Обработчик команды /start"""
|
||||
"""Обработчик /start"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"👋 <b>Вітаю!</b>\n\n"
|
||||
"Я відстежую зміни в графіку відключень світла та попереджаю за 5 хвилин до відключення.\n\n"
|
||||
"👋 <b>Ласкаво просимо!</b>\n\n"
|
||||
"🤖 <b>Бот для моніторингу графіку відключень світла</b>\n\n"
|
||||
"✨ <b>Можливості:</b>\n"
|
||||
"• 📊 Перегляд графіку на сьогодні і завтра\n"
|
||||
"• 🔔 Автоматичні сповіщення за 5 хвилин до подій\n"
|
||||
"• 🔄 Моніторинг змін графіку\n\n"
|
||||
"Використовуйте кнопки нижче 👇",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@dp.message(lambda message: message.text == "📊 Графік")
|
||||
@dp.message(lambda msg: msg.text == "ℹ️ Про бота")
|
||||
async def cmd_info(message: Message):
|
||||
"""Показывает информацию о боте"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"<b>ℹ️ Про бота</b>\n\n"
|
||||
"🚀 <b>Версія:</b> 2.1 (Оптимізована)\n"
|
||||
"⚡ <b>Функціональність:</b>\n"
|
||||
"├ Моніторинг графіку 24/7\n"
|
||||
"├ Сповіщення за 5 хвилин\n"
|
||||
"├ Детальна статистика дня\n"
|
||||
"└ Красива візуалізація\n\n"
|
||||
"🔐 <b>Безпека:</b> Використовуються .env файли\n"
|
||||
"💾 <b>Джерело:</b> voe.com.ua",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@dp.message(Command("schedule"))
|
||||
async def cmd_schedule(message: Message):
|
||||
"""Показывает текущий график (2 дня)"""
|
||||
"""Показывает полный график"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
await message.answer("❌ У вас немає доступу.")
|
||||
return
|
||||
|
||||
try:
|
||||
await message.answer("⏳ Завантаження графіку...")
|
||||
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
|
||||
schedule = parse_html(html)
|
||||
text = format_schedule_message(schedule, days_to_show=2)
|
||||
await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard())
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Помилка: {str(e)}", reply_markup=get_main_keyboard())
|
||||
await message.answer(
|
||||
f"❌ <b>Помилка:</b> {str(e)}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@dp.message(lambda message: message.text == "📅 Сьогодні")
|
||||
@dp.message(Command("today"))
|
||||
async def cmd_today(message: Message):
|
||||
"""Показывает график на сегодня"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
await message.answer("❌ У вас немає доступу.")
|
||||
return
|
||||
|
||||
try:
|
||||
await message.answer("⏳ Завантаження графіку сьогодні...")
|
||||
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
|
||||
schedule = parse_html(html)
|
||||
text = format_single_day_schedule(schedule, 0)
|
||||
await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard())
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Помилка: {str(e)}", reply_markup=get_main_keyboard())
|
||||
await message.answer(
|
||||
f"❌ <b>Помилка:</b> {str(e)}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@dp.message(lambda message: message.text == "📅 Завтра")
|
||||
@dp.message(Command("tomorrow"))
|
||||
async def cmd_tomorrow(message: Message):
|
||||
"""Показывает график на завтра"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
await message.answer("❌ У вас немає доступу.")
|
||||
return
|
||||
|
||||
try:
|
||||
await message.answer("⏳ Завантаження графіку завтра...")
|
||||
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
|
||||
schedule = parse_html(html)
|
||||
text = format_single_day_schedule(schedule, 1)
|
||||
await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard())
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Помилка: {str(e)}", reply_markup=get_main_keyboard())
|
||||
await message.answer(
|
||||
f"❌ <b>Помилка:</b> {str(e)}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@dp.message(lambda message: message.text == "🔄 Перевірити")
|
||||
@dp.message(Command("check"))
|
||||
async def cmd_check(message: Message):
|
||||
"""Принудительная проверка графика"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
await message.answer("❌ У вас немає доступу.")
|
||||
return
|
||||
|
||||
await message.answer("🔄 Перевіряю графік...", reply_markup=get_main_keyboard())
|
||||
|
||||
# Выполняем проверку и отправляем результат
|
||||
try:
|
||||
await message.answer("🔄 <b>Перевіряю графік...</b>", parse_mode="HTML")
|
||||
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
|
||||
new_schedule = parse_html(html)
|
||||
|
||||
if schedules_differ(last_schedule, new_schedule):
|
||||
result_message = "✅ <b>Знайдено зміни в графіку!</b>\n\n"
|
||||
result_message += format_schedule_message(new_schedule, days_to_show=2)
|
||||
await message.answer(result_message, parse_mode="HTML", reply_markup=get_main_keyboard())
|
||||
else:
|
||||
result_message = "✅ <b>Графік без змін</b>\n\n"
|
||||
result_message += format_schedule_message(new_schedule, days_to_show=2)
|
||||
await message.answer(result_message, parse_mode="HTML", reply_markup=get_main_keyboard())
|
||||
prefix = "✅ <b>Знайдено зміни!</b>\n\n" if schedules_differ(last_schedule, new_schedule) else "✓ <b>Графік без змін</b>\n\n"
|
||||
result = prefix + format_schedule_message(new_schedule, days_to_show=2)
|
||||
|
||||
await message.answer(result, parse_mode="HTML", reply_markup=get_main_keyboard())
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Помилка при перевірці: {str(e)}", reply_markup=get_main_keyboard())
|
||||
await message.answer(
|
||||
f"❌ <b>Помилка:</b> {str(e)}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
@dp.message()
|
||||
async def handle_text(message: Message):
|
||||
"""Обработчик текстовых сообщений и кнопок"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
return
|
||||
|
||||
text = message.text
|
||||
|
||||
# Кнопка "Графік"
|
||||
if text == "📊 Графік":
|
||||
await cmd_schedule(message)
|
||||
|
||||
# Кнопка "Сьогодні"
|
||||
elif text == "📅 Сьогодні":
|
||||
await cmd_today(message)
|
||||
|
||||
# Кнопка "Завтра"
|
||||
elif text == "📅 Завтра":
|
||||
await cmd_tomorrow(message)
|
||||
|
||||
# Кнопка "Оновити"
|
||||
elif text == "🔄 Оновити":
|
||||
await cmd_check(message)
|
||||
|
||||
# Кнопка "Про бота"
|
||||
elif text == "ℹ️ Про бота":
|
||||
await cmd_info(message)
|
||||
|
||||
# Неизвестная команда
|
||||
else:
|
||||
await message.answer(
|
||||
"❓ <b>Команда не розпізнана</b>\n\n"
|
||||
"Використовуйте кнопки на клавіатурі або команди:\n"
|
||||
"/start • /today • /tomorrow • /schedule • /check",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ГЛАВНАЯ ФУНКЦИЯ
|
||||
# ============================================================================
|
||||
|
||||
async def main():
|
||||
"""Главная функция"""
|
||||
logger.info("Запуск бота...")
|
||||
logger.info("=" * 50)
|
||||
logger.info("🚀 ЗАПУСК БОТА V2.1 (ОПТИМІЗОВАНА ВЕРСІЯ)")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# Запускаем мониторинг в фоне
|
||||
if not TELEGRAM_TOKEN or TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "YOUR_TOKEN_HERE")
|
||||
logger.error("❌ TELEGRAM_TOKEN не конфігурований! Напишіть токен в .env файл")
|
||||
return
|
||||
|
||||
if not ALLOWED_CHAT_IDS:
|
||||
logger.error("❌ ALLOWED_CHAT_IDS не конфігуровані! Напишіть ID в .env файл")
|
||||
return
|
||||
|
||||
logger.info(f"🔌 Дозволені чати: {ALLOWED_CHAT_IDS}")
|
||||
logger.info(f"⏱ Інтервал перевірки: {CHECK_INTERVAL} сек")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# Запускаем мониторинг
|
||||
monitoring_task = asyncio.create_task(monitoring_loop())
|
||||
|
||||
# Запускаем бота
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("⏹ Бот зупинений користувачем")
|
||||
finally:
|
||||
monitoring_task.cancel()
|
||||
await bot.session.close()
|
||||
logger.info("✓ Підключення закрито")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("⏹ Завершено")
|
||||
Reference in New Issue
Block a user