Add DTEK notification bot with Docker configuration and requirements
This commit is contained in:
@@ -10,3 +10,4 @@ edu_master/volumes/
|
||||
dockmon/dockmon_data/
|
||||
portainer_data/*
|
||||
adguardhome/*
|
||||
.python-version
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Установка зависимостей
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копирование кода
|
||||
COPY main.py .
|
||||
|
||||
# Запуск бота
|
||||
CMD ["python", "-u", "main.py"]
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
dtek_notif:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Europe/Kyiv
|
||||
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
networks:
|
||||
- default
|
||||
@@ -0,0 +1,319 @@
|
||||
import requests
|
||||
import json
|
||||
import asyncio
|
||||
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
|
||||
import logging
|
||||
|
||||
# Настройки
|
||||
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 минуты
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Глобальные переменные
|
||||
bot = Bot(token=TELEGRAM_TOKEN)
|
||||
dp = Dispatcher()
|
||||
last_schedule = None
|
||||
|
||||
|
||||
def parse_html(html: str):
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
cells = soup.select(".disconnection-detailed-table-cell.cell")
|
||||
|
||||
schedule = []
|
||||
current_hour = 0
|
||||
current_day = 0
|
||||
|
||||
for cell in cells:
|
||||
if 'legend' in cell.get('class', []) or 'head' in cell.get('class', []):
|
||||
continue
|
||||
|
||||
cell_classes = cell.get('class', [])
|
||||
full_hour_off = 'has_disconnection' in cell_classes and 'full_hour' in cell_classes
|
||||
|
||||
hour_block = cell.select_one(".hour_block")
|
||||
if not hour_block:
|
||||
continue
|
||||
|
||||
left = hour_block.select_one(".half.left")
|
||||
right = hour_block.select_one(".half.right")
|
||||
|
||||
def parse_half(half, force_off=False):
|
||||
if not half:
|
||||
return {"status": "unknown", "queue": None, "confirmed": None}
|
||||
|
||||
if force_off:
|
||||
status = "off"
|
||||
else:
|
||||
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()
|
||||
|
||||
disconnection_classes = disconnection_div.get("class", [])
|
||||
if "disconnection_confirm_1" in disconnection_classes:
|
||||
confirmed = True
|
||||
elif "disconnection_confirm_0" in disconnection_classes:
|
||||
confirmed = False
|
||||
|
||||
if force_off and not confirmed and not queue:
|
||||
if 'confirm_1' in cell_classes:
|
||||
confirmed = True
|
||||
elif 'confirm_0' in cell_classes:
|
||||
confirmed = False
|
||||
|
||||
return {"status": status, "queue": queue, "confirmed": confirmed}
|
||||
|
||||
left_data = parse_half(left, force_off=full_hour_off)
|
||||
right_data = parse_half(right, force_off=full_hour_off)
|
||||
|
||||
schedule.append({
|
||||
"hour": current_hour,
|
||||
"day": current_day,
|
||||
"first_half": left_data,
|
||||
"second_half": right_data,
|
||||
})
|
||||
|
||||
current_hour += 1
|
||||
if current_hour >= 24:
|
||||
current_hour = 0
|
||||
current_day += 1
|
||||
|
||||
return schedule
|
||||
|
||||
|
||||
def get_voe_html(city_id, street_id, house_id):
|
||||
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)"
|
||||
}
|
||||
data = {
|
||||
"search_type": 0,
|
||||
"city_id": city_id,
|
||||
"street_id": street_id,
|
||||
"house_id": house_id,
|
||||
"form_build_id": "form-Irv5aHw1R2FT_Ik2apyHOZ47hTH5xPNH_LQnBrmpSTc",
|
||||
"form_id": "disconnection_detailed_search_form",
|
||||
"_triggering_element_name": "search",
|
||||
"_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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
start_date = datetime.now()
|
||||
|
||||
for day in range(days_to_show):
|
||||
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 "Завтра"
|
||||
|
||||
message += f"📅 <b>{day_name} ({date_str})</b>\n"
|
||||
message += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
|
||||
for block in day_blocks:
|
||||
first = block["first_half"]
|
||||
second = block["second_half"]
|
||||
|
||||
def get_icon(half_data):
|
||||
if half_data["status"] == "off":
|
||||
if half_data["confirmed"] is True:
|
||||
return "🔴" # Точно відключено
|
||||
elif half_data["confirmed"] is False:
|
||||
return "🟠" # Можливо відключено
|
||||
return "🔴"
|
||||
elif half_data["status"] == "on":
|
||||
return "🟢"
|
||||
return "⚪️"
|
||||
|
||||
first_icon = get_icon(first)
|
||||
second_icon = get_icon(second)
|
||||
|
||||
# Форматируем время
|
||||
hour_start = f"{block['hour']:02d}:00"
|
||||
hour_mid = f"{block['hour']:02d}:30"
|
||||
hour_end = f"{block['hour']+1:02d}:00"
|
||||
|
||||
# Информация о черге
|
||||
queue_info = ""
|
||||
if first["queue"] or second["queue"]:
|
||||
queue = first["queue"] or second["queue"]
|
||||
queue_info = f" | Черга {queue}"
|
||||
|
||||
message += f"{hour_start}-{hour_mid} {first_icon} {hour_mid}-{hour_end} {second_icon}{queue_info}\n"
|
||||
|
||||
message += "\n"
|
||||
|
||||
message += "🟢 - Світло є\n"
|
||||
message += "🔴 - Відключення підтверджено\n"
|
||||
message += "🟠 - Можливе відключення\n"
|
||||
message += "⚪️ - Інформація відсутня"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def schedules_differ(old_schedule, new_schedule):
|
||||
"""Проверяет, отличаются ли графики"""
|
||||
if old_schedule is None or new_schedule is None:
|
||||
return True
|
||||
|
||||
if len(old_schedule) != len(new_schedule):
|
||||
return True
|
||||
|
||||
for old, new in zip(old_schedule, new_schedule):
|
||||
# Сравниваем только первые 2 дня
|
||||
if old["day"] >= 2:
|
||||
break
|
||||
|
||||
if (old["first_half"] != new["first_half"] or
|
||||
old["second_half"] != new["second_half"]):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def send_to_all_users(message_text: str):
|
||||
"""Отправляет сообщение всем пользователям"""
|
||||
for chat_id in ALLOWED_CHAT_IDS:
|
||||
try:
|
||||
await bot.send_message(chat_id, message_text, parse_mode="HTML")
|
||||
logger.info(f"Сообщение отправлено пользователю {chat_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки пользователю {chat_id}: {e}")
|
||||
|
||||
|
||||
async def check_schedule():
|
||||
"""Проверяет график и отправляет уведомления при изменениях"""
|
||||
global last_schedule
|
||||
|
||||
try:
|
||||
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("Обнаружены изменения в графике!")
|
||||
message = format_schedule_message(new_schedule, days_to_show=2)
|
||||
|
||||
if last_schedule is None:
|
||||
# Первый запуск
|
||||
await send_to_all_users(f"✨ <b>Бот запущено!</b>\n\n{message}")
|
||||
else:
|
||||
# Изменения в графике
|
||||
await send_to_all_users(f"🔄 <b>Графік оновлено!</b>\n\n{message}")
|
||||
|
||||
last_schedule = new_schedule
|
||||
else:
|
||||
logger.info("Изменений нет")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке графика: {e}")
|
||||
await send_to_all_users(f"❌ <b>Помилка!</b>\n\nНе вдалося отримати графік: {str(e)}")
|
||||
|
||||
|
||||
async def monitoring_loop():
|
||||
"""Основной цикл мониторинга"""
|
||||
# Первая проверка сразу при запуске
|
||||
await check_schedule()
|
||||
|
||||
# Затем проверяем каждые 2 минуты
|
||||
while True:
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
await check_schedule()
|
||||
|
||||
|
||||
@dp.message(Command("start"))
|
||||
async def cmd_start(message: Message):
|
||||
"""Обработчик команды /start"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
"👋 <b>Вітаю!</b>\n\n"
|
||||
"Я буду відстежувати зміни в графіку відключень світла.\n\n"
|
||||
"Доступні команди:\n"
|
||||
"/schedule - Показати поточний графік\n"
|
||||
"/check - Перевірити графік зараз",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@dp.message(Command("schedule"))
|
||||
async def cmd_schedule(message: Message):
|
||||
"""Показывает текущий график"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
return
|
||||
|
||||
try:
|
||||
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")
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Помилка: {str(e)}")
|
||||
|
||||
|
||||
@dp.message(Command("check"))
|
||||
async def cmd_check(message: Message):
|
||||
"""Принудительная проверка графика"""
|
||||
if message.chat.id not in ALLOWED_CHAT_IDS:
|
||||
await message.answer("❌ У вас немає доступу до цього бота.")
|
||||
return
|
||||
|
||||
await message.answer("🔄 Перевіряю графік...")
|
||||
await check_schedule()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Главная функция"""
|
||||
logger.info("Запуск бота...")
|
||||
|
||||
# Запускаем мониторинг в фоне
|
||||
monitoring_task = asyncio.create_task(monitoring_loop())
|
||||
|
||||
# Запускаем бота
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
monitoring_task.cancel()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
[project]
|
||||
name = "dtek-notif"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = []
|
||||
@@ -0,0 +1,3 @@
|
||||
bs4
|
||||
requests
|
||||
aiogram
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
environment: # Is needed when using any of the options below
|
||||
AIO_DISABLE_BACKUP_SECTION: false # Setting this to true allows to hide the backup section in the AIO interface. See https://github.com/nextcloud/all-in-one#how-to-disable-the-backup-section
|
||||
APACHE_PORT: 11000 # Is needed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
|
||||
APACHE_IP_BINDING: 127.0.0.1 # Should be set when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) that is running on the same host. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
|
||||
APACHE_IP_BINDING: 0.0.0.0 # Should be set when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) that is running on the same host. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
|
||||
APACHE_ADDITIONAL_NETWORK: traefik-proxy # (Optional) Connect the apache container to an additional docker network. Needed when behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else) running in a different docker network on same server. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md
|
||||
BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Allows to adjust borgs retention policy. See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy
|
||||
COLLABORA_SECCOMP_DISABLED: false # Setting this to true allows to disable Collabora's Seccomp feature. See https://github.com/nextcloud/all-in-one#how-to-disable-collaboras-seccomp-feature
|
||||
|
||||
@@ -52,7 +52,7 @@ http:
|
||||
|
||||
# Dockmon
|
||||
dockmon:
|
||||
rule: "Host(`dcokmon.workstation`)"
|
||||
rule: "Host(`dockmon.workstation`)"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: dockmon
|
||||
@@ -96,7 +96,7 @@ http:
|
||||
- url: "http://adguardhome:3000"
|
||||
|
||||
# Nextcloud AIO Interface
|
||||
nextcloud:
|
||||
nextcloud-aio:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "https://nextcloud-aio-mastercontainer:8080"
|
||||
@@ -149,14 +149,14 @@ http:
|
||||
stsSeconds: 31536000
|
||||
customFrameOptionsValue: "SAMEORIGIN"
|
||||
|
||||
# nextcloud-secure-headers:
|
||||
# headers:
|
||||
# hostsProxyHeaders:
|
||||
# - "X-Forwarded-Host"
|
||||
# referrerPolicy: "same-origin"
|
||||
nextcloud-secure-headers:
|
||||
headers:
|
||||
hostsProxyHeaders:
|
||||
- "X-Forwarded-Host"
|
||||
referrerPolicy: "same-origin"
|
||||
|
||||
nextcloud-chain:
|
||||
chain:
|
||||
middlewares:
|
||||
- redirect-https
|
||||
# - nextcloud-secure-headers
|
||||
- nextcloud-secure-headers
|
||||
|
||||
Reference in New Issue
Block a user