diff --git a/.gitignore b/.gitignore
index 66b9cbf..a848f67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,5 @@ traefik/letsencrypt/acme.json
edu_master/volumes/
dockmon/dockmon_data/
portainer_data/*
-adguardhome/*
\ No newline at end of file
+adguardhome/*
+.python-version
\ No newline at end of file
diff --git a/dtek_notif/Dockerfile b/dtek_notif/Dockerfile
new file mode 100644
index 0000000..2656973
--- /dev/null
+++ b/dtek_notif/Dockerfile
@@ -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"]
\ No newline at end of file
diff --git a/dtek_notif/dtek-docker-compose.yaml b/dtek_notif/dtek-docker-compose.yaml
new file mode 100644
index 0000000..dfc57ab
--- /dev/null
+++ b/dtek_notif/dtek-docker-compose.yaml
@@ -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
\ No newline at end of file
diff --git a/dtek_notif/main.py b/dtek_notif/main.py
new file mode 100644
index 0000000..1102f0b
--- /dev/null
+++ b/dtek_notif/main.py
@@ -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"⚡️ Графік відключень світла\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"📅 {day_name} ({date_str})\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"✨ Бот запущено!\n\n{message}")
+ else:
+ # Изменения в графике
+ await send_to_all_users(f"🔄 Графік оновлено!\n\n{message}")
+
+ last_schedule = new_schedule
+ else:
+ logger.info("Изменений нет")
+
+ except Exception as e:
+ logger.error(f"Ошибка при проверке графика: {e}")
+ await send_to_all_users(f"❌ Помилка!\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(
+ "👋 Вітаю!\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())
\ No newline at end of file
diff --git a/dtek_notif/pyproject.toml b/dtek_notif/pyproject.toml
new file mode 100644
index 0000000..4017156
--- /dev/null
+++ b/dtek_notif/pyproject.toml
@@ -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 = []
diff --git a/dtek_notif/requirements.txt b/dtek_notif/requirements.txt
new file mode 100644
index 0000000..7bf17e3
--- /dev/null
+++ b/dtek_notif/requirements.txt
@@ -0,0 +1,3 @@
+bs4
+requests
+aiogram
\ No newline at end of file
diff --git a/nextcloud-docker-compose.yaml b/nextcloud-docker-compose.yaml
index 7527510..c81e701 100644
--- a/nextcloud-docker-compose.yaml
+++ b/nextcloud-docker-compose.yaml
@@ -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
@@ -50,4 +50,4 @@ networks:
external: false
volumes: # If you want to store the data on a different drive, see https://github.com/nextcloud/all-in-one#how-to-store-the-filesinstallation-on-a-separate-drive
nextcloud_aio_mastercontainer:
- name: nextcloud_aio_mastercontainer # This line is not allowed to be changed as otherwise the built-in backup solution will not work
\ No newline at end of file
+ name: nextcloud_aio_mastercontainer # This line is not allowed to be changed as otherwise the built-in backup solution will not work
diff --git a/traefik/dynamic/services.yml b/traefik/dynamic/services.yml
index def712d..8ecc275 100644
--- a/traefik/dynamic/services.yml
+++ b/traefik/dynamic/services.yml
@@ -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