Refactor Dockerfile and main.py for improved environment variable handling and logging; update requirements.txt for package specifications

This commit is contained in:
2025-11-12 23:37:08 +01:00
parent cc6e7fe4f1
commit 6370ad4cb2
3 changed files with 439 additions and 252 deletions
+1 -1
View File
@@ -8,6 +8,6 @@ RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода # Копирование кода
COPY main.py . COPY main.py .
COPY .env .
# Запуск бота # Запуск бота
CMD ["python", "-u", "main.py"] CMD ["python", "-u", "main.py"]
+433 -248
View File
@@ -5,26 +5,84 @@ from bs4 import BeautifulSoup
from datetime import datetime, timedelta from datetime import datetime, timedelta
from aiogram import Bot, Dispatcher, types from aiogram import Bot, Dispatcher, types
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import Message, ReplyKeyboardMarkup, KeyboardButton from aiogram.types import Message, KeyboardButton
from aiogram.utils.keyboard import ReplyKeyboardBuilder from aiogram.utils.keyboard import ReplyKeyboardBuilder
import logging 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") 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 [] 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__) logger = logging.getLogger(__name__)
# Глобальные переменные # Глобальные переменные
bot = Bot(token=TELEGRAM_TOKEN) bot = Bot(token=TELEGRAM_TOKEN)
dp = Dispatcher() 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") soup = BeautifulSoup(html, "html.parser")
cells = soup.select(".disconnection-detailed-table-cell.cell") cells = soup.select(".disconnection-detailed-table-cell.cell")
@@ -46,28 +104,29 @@ def parse_html(html: str):
left = hour_block.select_one(".half.left") left = hour_block.select_one(".half.left")
right = hour_block.select_one(".half.right") 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: if not half:
return {"status": "unknown", "queue": None, "confirmed": None} return {"status": "unknown", "queue": None, "confirmed": None}
if force_off: status = "off" if force_off else "unknown"
status = "off" if not force_off:
else:
classes = half.get("class", []) classes = half.get("class", [])
if "has_disconnection" in classes: if "has_disconnection" in classes:
status = "off" status = "off"
elif "no_disconnection" in classes: elif "no_disconnection" in classes:
status = "on" status = "on"
else:
status = "unknown"
queue = None queue = None
confirmed = None confirmed = None
disconnection_div = half.select_one(".disconnection") disconnection_div = half.select_one(".disconnection")
if disconnection_div and disconnection_div.has_attr("title"): if disconnection_div and disconnection_div.has_attr("title"):
title = disconnection_div["title"] title = disconnection_div["title"]
if "Номер черги" in title: if "Номер черги" in title:
queue = title.split(":")[-1].strip() try:
queue = title.split(":")[-1].strip()
except:
pass
disconnection_classes = disconnection_div.get("class", []) disconnection_classes = disconnection_div.get("class", [])
if "disconnection_confirm_1" in disconnection_classes: if "disconnection_confirm_1" in disconnection_classes:
@@ -75,7 +134,7 @@ def parse_html(html: str):
elif "disconnection_confirm_0" in disconnection_classes: elif "disconnection_confirm_0" in disconnection_classes:
confirmed = False confirmed = False
if force_off and not confirmed and not queue: if force_off and confirmed is None:
if 'confirm_1' in cell_classes: if 'confirm_1' in cell_classes:
confirmed = True confirmed = True
elif 'confirm_0' in cell_classes: elif 'confirm_0' in cell_classes:
@@ -101,12 +160,13 @@ def parse_html(html: str):
return schedule 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" url = "https://www.voe.com.ua/disconnection/detailed?ajax_form=1&_wrapper_format=drupal_ajax"
headers = { headers = {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest", "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 = { data = {
"search_type": 0, "search_type": 0,
@@ -119,184 +179,208 @@ def get_voe_html(city_id, street_id, house_id):
"_triggering_element_value": "Показати", "_triggering_element_value": "Показати",
"_drupal_ajax": 1, "_drupal_ajax": 1,
} }
r = requests.post(url, headers=headers, data=data)
r.raise_for_status() try:
resp_json = json.loads(r.text) response = requests.post(url, headers=headers, data=data, timeout=10)
insert_html = next((i["data"] for i in resp_json if i.get("command") == "insert"), None) response.raise_for_status()
if not insert_html: resp_json = response.json()
raise ValueError("HTML не найден в ответе")
return insert_html 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(): def get_main_keyboard():
"""Создает главную клавиатуру с кнопками""" """Создает главную клавиатуру"""
builder = ReplyKeyboardBuilder() builder = ReplyKeyboardBuilder()
builder.row( builder.row(
KeyboardButton(text="📊 Графік"), KeyboardButton(text="📊 Графік"),
KeyboardButton(text="🔄 Перевірити") KeyboardButton(text="🔄 Оновити")
) )
builder.row( builder.row(
KeyboardButton(text="📅 Сьогодні"), KeyboardButton(text="📅 Сьогодні"),
KeyboardButton(text="📅 Завтра") KeyboardButton(text="📅 Завтра")
) )
builder.row(KeyboardButton(text="️ Про бота"))
return builder.as_markup(resize_keyboard=True) return builder.as_markup(resize_keyboard=True)
def format_schedule_message(schedule, days_to_show=2): def format_schedule_message(schedule: List[Dict], days_to_show: int = 2) -> str:
"""Форматирует расписание для отправки в Telegram""" """Форматирует полный график на несколько дней"""
message = f"⚡️ <b>Графік відключень світла</b>\n" lines = [
message += f"🕐 Оновлено: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n\n" "⚡️ <b>Графік відключень світла</b>",
f"🕐 Оновлено: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
"" * 30,
""
]
start_date = datetime.now() 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] day_blocks = [b for b in schedule if b["day"] == day]
if not day_blocks: if not day_blocks:
continue continue
date_str = (start_date + timedelta(days=day)).strftime('%d.%m') date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y')
day_name = "🌅 Сьогодні" if day == 0 else "🌄 Завтра" day_name = "🌅 <b>Сьогодні</b>" if day == 0 else "🌄 <b>Завтра</b>"
message += f"{'' * 32}\n" lines.append(f"{day_name} ({date_str})")
message += f"📅 <b>{day_name} ({date_str})</b>\n"
message += f"{'' * 32}\n\n"
# Группируем последовательные отключения # Статистика
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 current_status = None
start_time = None
current_confirmed = None current_confirmed = None
current_queue = None current_queue = None
start_time = None
disconnection_periods = [] for block in day_blocks:
for i, block in enumerate(day_blocks):
hour = block["hour"] hour = block["hour"]
# Проверяем обе половины часа
for half_idx, half in enumerate([block["first_half"], block["second_half"]]): 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" time_str = f"{hour:02d}:00" if half_idx == 0 else f"{hour:02d}:30"
if half["status"] == "off": if half["status"] == "off":
if current_status != "off": if current_status != "off":
# Начало нового периода отключения
start_time = time_str start_time = time_str
current_confirmed = half["confirmed"] current_confirmed = half["confirmed"]
current_queue = half["queue"] current_queue = half["queue"]
current_status = "off" current_status = "off"
else: else:
if current_status == "off": if current_status == "off":
# Конец периода отключения icon = "🔴" if current_confirmed else "🟠"
icon = "🔴" if current_confirmed is True else "🟠" queue_text = f"{current_queue})" if current_queue else ""
queue_text = f" • Черга {current_queue}" if current_queue else "" disconnections.append(f"{icon} <code>{start_time} - {time_str}</code>{queue_text}")
disconnection_periods.append(f"{icon} <code>{start_time} - {time_str}</code>{queue_text}")
current_status = half["status"] current_status = half["status"]
# Если день закончился на отключении # Если день закончился на отключении
if current_status == "off": if current_status == "off":
icon = "🔴" if current_confirmed is True else "🟠" icon = "🔴" if current_confirmed else "🟠"
queue_text = f" • Черга {current_queue}" if current_queue else "" queue_text = f" {current_queue})" if current_queue else ""
next_hour = (day_blocks[-1]["hour"] + 1) % 24 next_hour = (day_blocks[-1]["hour"] + 1) % 24
end_time = f"{next_hour:02d}:00" 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 disconnections:
if disconnection_periods: for idx, disc in enumerate(disconnections, 1):
for period in disconnection_periods: lines.append(f"{idx}. {disc}")
message += period + "\n"
else:
message += "🟢 <b>Відключень немає!</b>\n"
message += "\n" lines.append("")
message += f"{'' * 32}\n" lines.append("<i>🔴 = підтверджено • 🟠 = можливо • 🟢 = світло</i>")
message += "💡 <i>Легенда:</i>\n"
message += "🔴 — Підтверджено\n"
message += "🟠 — Можливо\n"
message += "🟢 — Світло є\n"
return message return "\n".join(lines)
def format_single_day_schedule(schedule, day_num): def format_single_day_schedule(schedule: List[Dict], day: int) -> str:
"""Форматирует расписание для одного дня""" """Форматирует график на один день"""
start_date = datetime.now() day_blocks = [b for b in schedule if b["day"] == day]
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]
if not day_blocks: if not day_blocks:
return message + "❓ <i>Немає даних</i>" return "❌ Немає даних для цього дня"
# Создаем визуальную шкалу времени start_date = datetime.now()
message += "⏰ <b>Часова шкала:</b>\n\n" date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y')
day_name = "🟠 <b>Сьогодні</b>" if day == 0 else "🔶 <b>Завтра</b>"
# Выводим по 4 часа в строке для читаемости lines = [
for start_hour in range(0, 24, 4): f"{day_name}{date_str}",
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}|❓❓ " lines.append("<b>📊 Статистика</b>")
continue stats = get_day_statistics(day_blocks)
first = block["first_half"] if stats["total"] == 0:
second = block["second_half"] lines.append("└ 🟢 <b>Відключень немає!</b>")
else:
def get_icon(half_data): total_time = format_time_duration(stats["total"])
if half_data["status"] == "off": lines.append(f"├ ⏱ Всього: <code>{total_time}</code>")
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> "
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: for block in day_blocks:
hour = block["hour"] hour = block["hour"]
first = block["first_half"]
second = block["second_half"]
if first["status"] == "off": for half_idx, half in enumerate([block["first_half"], block["second_half"]]):
has_disconnections = True time_str = f"{hour:02d}:00" if half_idx == 0 else f"{hour:02d}:30"
icon = "🔴" if first["confirmed"] is True else "🟠"
status = "підтв." if first["confirmed"] is True else "можл." if half["status"] == "off":
queue = f"Ч{first['queue']}" if first['queue'] else "" if current_status != "off":
message += f"{icon} <code>{hour:02d}:00-{hour:02d}:30</code> • {status}{queue}\n" start_time = time_str
current_confirmed = half["confirmed"]
if second["status"] == "off": current_queue = half["queue"]
has_disconnections = True current_status = "off"
icon = "🔴" if second["confirmed"] is True else "🟠" else:
status = "підтв." if second["confirmed"] is True else "можл." if current_status == "off":
queue = f"Ч{second['queue']}" if second['queue'] else "" icon = "🔴" if current_confirmed else "🟠"
message += f"{icon} <code>{hour:02d}:30-{hour+1:02d}:00</code> • {status}{queue}\n" 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" if disconnections:
message += "<i>🔴 підтв. • 🟠 можл. • 🟢 світло</i>" 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: if old_schedule is None or new_schedule is None:
return True return True
@@ -304,7 +388,6 @@ def schedules_differ(old_schedule, new_schedule):
return True return True
for old, new in zip(old_schedule, new_schedule): for old, new in zip(old_schedule, new_schedule):
# Сравниваем только первые 2 дня
if old["day"] >= 2: if old["day"] >= 2:
break break
@@ -315,256 +398,358 @@ def schedules_differ(old_schedule, new_schedule):
return False 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: for chat_id in ALLOWED_CHAT_IDS:
try: try:
await bot.send_message(chat_id, message_text, parse_mode="HTML") await bot.send_message(chat_id, message_text, parse_mode=parse_mode)
logger.info(f"Сообщение отправлено пользователю {chat_id}") logger.info(f"Сообщение отправлено пользователю {chat_id}")
except Exception as e: 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(): async def check_schedule():
"""Проверяет график и отправляет уведомления при изменениях""" """Проверяет график и отправляет уведомления"""
global last_schedule global last_schedule
try: try:
logger.info("Проверка графика...") logger.info("🔍 Проверка графика...")
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
new_schedule = parse_html(html) new_schedule = parse_html(html)
if schedules_differ(last_schedule, new_schedule): if schedules_differ(last_schedule, new_schedule):
logger.info("Обнаружены изменения в графике!") logger.info("Обнаружены изменения!")
message = format_schedule_message(new_schedule, days_to_show=2) message = format_schedule_message(new_schedule, days_to_show=2)
if last_schedule is None: if last_schedule is not None:
pass # Ничего не делать, если изменений нет
else:
# Изменения в графике
await send_to_all_users(f"🔄 <b>Графік оновлено!</b>\n\n{message}") await send_to_all_users(f"🔄 <b>Графік оновлено!</b>\n\n{message}")
last_schedule = new_schedule last_schedule = new_schedule
else: else:
logger.info("Изменений нет") logger.info("✓ Графік без змін")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при проверке графика: {e}") logger.error(f"Ошибка при проверке графика: {e}")
await send_to_all_users(f"❌ <b>Помилка!</b>\n\nНе вдалося отримати графік: {str(e)}")
async def check_upcoming_disconnections(): async def check_upcoming_disconnections():
"""Проверяет предстоящие отключения и включения, отправляет предупреждения""" """Проверяет предстоящие события и отправляет предупреждения за 5 минут"""
global last_schedule global last_notification_time
if last_schedule is None: if last_schedule is None:
return return
now = datetime.now() now = datetime.now()
current_time = now.time()
# Проверяем только текущий день
today_blocks = [b for b in last_schedule if b["day"] == 0] 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: for block in today_blocks:
hour = block["hour"] hour = block["hour"]
# Проверяем первую половину часа (XX:00 - XX:30) for half_idx, half in enumerate([block["first_half"], block["second_half"]]):
first_half_start = now.replace(hour=hour, minute=0, second=0, microsecond=0) minute = 0 if half_idx == 0 else 30
time_until_first = (first_half_start - now).total_seconds() / 60 time_str = f"{hour:02d}:{minute:02d}"
# Если до события осталось 5 минут (с погрешностью ±1 минута)
if 4 <= time_until_first <= 6:
first = block["first_half"]
# ОТКЛЮЧЕНИЕ current_status = half["status"]
if first["status"] == "off":
icon = "🔴" if first["confirmed"] is True else "🟠" # Если статус изменился - это переход
status_text = "підтверджено" if first["confirmed"] is True else "можливе" if prev_status is not None and prev_status != current_status:
queue_info = f" (Черга {first['queue']})" if first["queue"] else "" 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 = ( warning = (
f"⚠️ <b>УВАГА! ВІДКЛЮЧЕННЯ</b>\n\n" f"⚠️ <b>УВАГА! ВІДКЛЮЧЕННЯ</b>\n\n"
f"Через 5 хвилин відключення {icon}\n" f"Через ~5 хвилин\n"
f"Час: {hour:02d}:00 - {hour:02d}:30\n" f"Час: <code>{transition['time_str']}</code>\n"
f"Статус: {status_text}{queue_info}" f"Статус: {icon} {status}{queue_info}"
) )
await send_to_all_users(warning) await send_to_all_users(warning)
logger.info(f"Отправлено предупреждение о отключении в {hour:02d}:00") last_notification_time[notification_key] = now
logger.info(f"📢 Відправлено попередження про ВІДКЛЮЧЕННЯ в {transition['time_str']}")
# ВКЛЮЧЕНИЕ # Переход на ВКЛЮЧЕНИЕ (off -> on)
elif first["status"] == "on": elif transition["from_status"] == "off" and transition["to_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 ""
warning = ( warning = (
f"⚠️ <b>УВАГА! ВІДКЛЮЧЕННЯ</b>\n\n" f" <b>УВАГА! ВКЛЮЧЕННЯ</b>\n\n"
f"Через 5 хвилин відключення {icon}\n" f"Через ~5 хвилин буде світло\n"
f"Час: {hour:02d}:30 - {hour+1:02d}:00\n" f"Час: <code>{transition['time_str']}</code>"
f"Статус: {status_text}{queue_info}"
) )
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: await send_to_all_users(warning)
warning = ( last_notification_time[notification_key] = now
f"✅ <b>УВАГА! ВКЛЮЧЕННЯ</b>\n\n" logger.info(f"📢 Відправлено попередження про ВКЛЮЧЕННЯ в {transition['time_str']}")
f"Через 5 хвилин світло має з'явитися 🟢\n"
f"Час: {hour:02d}:30"
)
await send_to_all_users(warning)
logger.info(f"Отправлено предупреждение о включении в {hour:02d}:30")
async def monitoring_loop(): async def monitoring_loop():
"""Основной цикл мониторинга""" """Основной цикл мониторинга"""
# Первая проверка сразу при запуске
await check_schedule() await check_schedule()
# Затем проверяем каждые 2 минуты
while True: while True:
await asyncio.sleep(CHECK_INTERVAL) try:
await check_schedule() await asyncio.sleep(CHECK_INTERVAL)
await check_upcoming_disconnections() await check_schedule()
await check_upcoming_disconnections()
except Exception as e:
logger.error(f"Ошибка в цикле мониторинга: {e}")
await asyncio.sleep(5)
# ============================================================================
# ОБРАБОТЧИКИ КОМАНД
# ============================================================================
@dp.message(Command("start")) @dp.message(Command("start"))
async def cmd_start(message: Message): async def cmd_start(message: Message):
"""Обработчик команды /start""" """Обработчик /start"""
if message.chat.id not in ALLOWED_CHAT_IDS: if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer("❌ У вас немає доступу до цього бота.") await message.answer("❌ У вас немає доступу до цього бота.")
return return
await message.answer( await message.answer(
"👋 <b>Вітаю!</b>\n\n" "👋 <b>Ласкаво просимо!</b>\n\n"
"Я відстежую зміни в графіку відключень світла та попереджаю за 5 хвилин до відключення.\n\n" "🤖 <b>Бот для моніторингу графіку відключень світла</b>\n\n"
"✨ <b>Можливості:</b>\n"
"• 📊 Перегляд графіку на сьогодні і завтра\n"
"• 🔔 Автоматичні сповіщення за 5 хвилин до подій\n"
"• 🔄 Моніторинг змін графіку\n\n"
"Використовуйте кнопки нижче 👇", "Використовуйте кнопки нижче 👇",
parse_mode="HTML", parse_mode="HTML",
reply_markup=get_main_keyboard() 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")) @dp.message(Command("schedule"))
async def cmd_schedule(message: Message): async def cmd_schedule(message: Message):
"""Показывает текущий график (2 дня)""" """Показывает полный график"""
if message.chat.id not in ALLOWED_CHAT_IDS: if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer("❌ У вас немає доступу до цього бота.") await message.answer("❌ У вас немає доступу.")
return return
try: try:
await message.answer("⏳ Завантаження графіку...")
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
schedule = parse_html(html) schedule = parse_html(html)
text = format_schedule_message(schedule, days_to_show=2) text = format_schedule_message(schedule, days_to_show=2)
await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard()) await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard())
except Exception as e: 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): async def cmd_today(message: Message):
"""Показывает график на сегодня""" """Показывает график на сегодня"""
if message.chat.id not in ALLOWED_CHAT_IDS: if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer("❌ У вас немає доступу до цього бота.") await message.answer("❌ У вас немає доступу.")
return return
try: try:
await message.answer("⏳ Завантаження графіку сьогодні...")
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
schedule = parse_html(html) schedule = parse_html(html)
text = format_single_day_schedule(schedule, 0) text = format_single_day_schedule(schedule, 0)
await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard()) await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard())
except Exception as e: 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): async def cmd_tomorrow(message: Message):
"""Показывает график на завтра""" """Показывает график на завтра"""
if message.chat.id not in ALLOWED_CHAT_IDS: if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer("❌ У вас немає доступу до цього бота.") await message.answer("❌ У вас немає доступу.")
return return
try: try:
await message.answer("⏳ Завантаження графіку завтра...")
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
schedule = parse_html(html) schedule = parse_html(html)
text = format_single_day_schedule(schedule, 1) text = format_single_day_schedule(schedule, 1)
await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard()) await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard())
except Exception as e: 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")) @dp.message(Command("check"))
async def cmd_check(message: Message): async def cmd_check(message: Message):
"""Принудительная проверка графика""" """Принудительная проверка графика"""
if message.chat.id not in ALLOWED_CHAT_IDS: if message.chat.id not in ALLOWED_CHAT_IDS:
await message.answer("❌ У вас немає доступу до цього бота.") await message.answer("❌ У вас немає доступу.")
return return
await message.answer("🔄 Перевіряю графік...", reply_markup=get_main_keyboard())
# Выполняем проверку и отправляем результат
try: try:
await message.answer("🔄 <b>Перевіряю графік...</b>", parse_mode="HTML")
html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
new_schedule = parse_html(html) new_schedule = parse_html(html)
if schedules_differ(last_schedule, new_schedule): prefix = "✅ <b>Знайдено зміни!</b>\n\n" if schedules_differ(last_schedule, new_schedule) else "✓ <b>Графік без змін</b>\n\n"
result_message = "✅ <b>Знайдено зміни в графіку!</b>\n\n" result = prefix + format_schedule_message(new_schedule, days_to_show=2)
result_message += format_schedule_message(new_schedule, days_to_show=2)
await message.answer(result_message, parse_mode="HTML", reply_markup=get_main_keyboard()) await message.answer(result, 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())
except Exception as e: 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(): 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()) monitoring_task = asyncio.create_task(monitoring_loop())
# Запускаем бота
try: try:
await dp.start_polling(bot) await dp.start_polling(bot)
except KeyboardInterrupt:
logger.info("⏹ Бот зупинений користувачем")
finally: finally:
monitoring_task.cancel() monitoring_task.cancel()
await bot.session.close() await bot.session.close()
logger.info("✓ Підключення закрито")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("⏹ Завершено")
+5 -3
View File
@@ -1,3 +1,5 @@
bs4 requests>=2.31.0
requests beautifulsoup4>=4.12.0
aiogram aiogram>=3.3.0
python-dotenv>=1.0.0
aiohttp>=3.9.0