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 .env .
# Запуск бота
CMD ["python", "-u", "main.py"]
+429 -244
View File
@@ -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
lines = [
f"{day_name}{date_str}",
""
]
first = block["first_half"]
second = block["second_half"]
# Статистика
lines.append("<b>📊 Статистика</b>")
stats = get_day_statistics(day_blocks)
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 ""
if stats["total"] == 0:
lines.append("└ 🟢 <b>Відключень немає!</b>")
else:
total_time = format_time_duration(stats["total"])
lines.append(f"├ ⏱ Всього: <code>{total_time}</code>")
first_icon = "" if first["status"] == "off" else "·"
second_icon = "" if second["status"] == "off" else "·"
if stats["confirmed"] > 0:
confirmed_time = format_time_duration(stats["confirmed"])
lines.append(f"├ 🔴 Підтверджено: <code>{confirmed_time}</code>")
line += f"<code>{h:02d}|{first_icon}{second_icon}</code> "
if stats["possible"] > 0:
possible_time = format_time_duration(stats["possible"])
lines.append(f"└ 🟠 Можливо: <code>{possible_time}</code>")
else:
lines.append(f"└ 🟢 Решта часу світло")
message += line + "\n"
lines.append("")
message += "\n<i>● = відключено, · = світло</i>\n\n"
# Детальный список отключений
disconnections = []
current_status = None
start_time = None
current_confirmed = None
current_queue = None
# Детальний список відключень
message += "📋 <b>Детально:</b>\n\n"
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"
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 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"
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
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}"
# Если до события осталось 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 "можливе"
queue_info = f" (Черга {first['queue']})" if first["queue"] else ""
# Если статус изменился - это переход
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")
# ВКЛЮЧЕНИЕ
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 ""
last_notification_time[notification_key] = now
logger.info(f"📢 Відправлено попередження про ВІДКЛЮЧЕННЯ в {transition['time_str']}")
# Переход на ВКЛЮЧЕНИЕ (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")
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())
except Exception as e:
await message.answer(f"❌ Помилка при перевірці: {str(e)}", 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"❌ <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("⏹ Завершено")
+5 -3
View File
@@ -1,3 +1,5 @@
bs4
requests
aiogram
requests>=2.31.0
beautifulsoup4>=4.12.0
aiogram>=3.3.0
python-dotenv>=1.0.0
aiohttp>=3.9.0