diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 4f38e41..2480c13 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -365,9 +365,6 @@ jobs:
for file in "${changed_files[@]}"; do
case "$file" in
- dtek_notif/*)
- add_service dtek_notif
- ;;
errorpages/*)
add_service errorpages
;;
@@ -444,21 +441,6 @@ jobs:
for service in "${services[@]}"; do
case "$service" in
- dtek_notif)
- image="${REGISTRY}/forust/dtek-notif"
- set_tags
- build_args=()
- for tag in "${tags[@]}"; do
- build_args+=(-t "${image}:${tag}")
- done
- docker build \
- --cache-from "type=registry,ref=${image}:buildcache" \
- --cache-to "type=registry,ref=${image}:buildcache,mode=max" \
- "${build_args[@]}" dtek_notif
- for tag in "${tags[@]}"; do
- docker push "${image}:${tag}"
- done
- ;;
errorpages)
image="${REGISTRY}/forust/error-pages"
set_tags
diff --git a/dtek_notif/Dockerfile b/dtek_notif/Dockerfile
deleted file mode 100644
index 7860cf1..0000000
--- a/dtek_notif/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM python:3.9-alpine
-
-WORKDIR /app
-
-# Установка зависимостей
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-
-# Копирование кода
-COPY main.py .
-COPY .env .
-# Запуск бота
-CMD ["python", "-u", "main.py"]
\ No newline at end of file
diff --git a/dtek_notif/LICENSE b/dtek_notif/LICENSE
deleted file mode 100644
index ee6256c..0000000
--- a/dtek_notif/LICENSE
+++ /dev/null
@@ -1,373 +0,0 @@
-Mozilla Public License Version 2.0
-==================================
-
-1. Definitions
---------------
-
-1.1. "Contributor"
- means each individual or legal entity that creates, contributes to
- the creation of, or owns Covered Software.
-
-1.2. "Contributor Version"
- means the combination of the Contributions of others (if any) used
- by a Contributor and that particular Contributor's Contribution.
-
-1.3. "Contribution"
- means Covered Software of a particular Contributor.
-
-1.4. "Covered Software"
- means Source Code Form to which the initial Contributor has attached
- the notice in Exhibit A, the Executable Form of such Source Code
- Form, and Modifications of such Source Code Form, in each case
- including portions thereof.
-
-1.5. "Incompatible With Secondary Licenses"
- means
-
- (a) that the initial Contributor has attached the notice described
- in Exhibit B to the Covered Software; or
-
- (b) that the Covered Software was made available under the terms of
- version 1.1 or earlier of the License, but not also under the
- terms of a Secondary License.
-
-1.6. "Executable Form"
- means any form of the work other than Source Code Form.
-
-1.7. "Larger Work"
- means a work that combines Covered Software with other material, in
- a separate file or files, that is not Covered Software.
-
-1.8. "License"
- means this document.
-
-1.9. "Licensable"
- means having the right to grant, to the maximum extent possible,
- whether at the time of the initial grant or subsequently, any and
- all of the rights conveyed by this License.
-
-1.10. "Modifications"
- means any of the following:
-
- (a) any file in Source Code Form that results from an addition to,
- deletion from, or modification of the contents of Covered
- Software; or
-
- (b) any new file in Source Code Form that contains any Covered
- Software.
-
-1.11. "Patent Claims" of a Contributor
- means any patent claim(s), including without limitation, method,
- process, and apparatus claims, in any patent Licensable by such
- Contributor that would be infringed, but for the grant of the
- License, by the making, using, selling, offering for sale, having
- made, import, or transfer of either its Contributions or its
- Contributor Version.
-
-1.12. "Secondary License"
- means either the GNU General Public License, Version 2.0, the GNU
- Lesser General Public License, Version 2.1, the GNU Affero General
- Public License, Version 3.0, or any later versions of those
- licenses.
-
-1.13. "Source Code Form"
- means the form of the work preferred for making modifications.
-
-1.14. "You" (or "Your")
- means an individual or a legal entity exercising rights under this
- License. For legal entities, "You" includes any entity that
- controls, is controlled by, or is under common control with You. For
- purposes of this definition, "control" means (a) the power, direct
- or indirect, to cause the direction or management of such entity,
- whether by contract or otherwise, or (b) ownership of more than
- fifty percent (50%) of the outstanding shares or beneficial
- ownership of such entity.
-
-2. License Grants and Conditions
---------------------------------
-
-2.1. Grants
-
-Each Contributor hereby grants You a world-wide, royalty-free,
-non-exclusive license:
-
-(a) under intellectual property rights (other than patent or trademark)
- Licensable by such Contributor to use, reproduce, make available,
- modify, display, perform, distribute, and otherwise exploit its
- Contributions, either on an unmodified basis, with Modifications, or
- as part of a Larger Work; and
-
-(b) under Patent Claims of such Contributor to make, use, sell, offer
- for sale, have made, import, and otherwise transfer either its
- Contributions or its Contributor Version.
-
-2.2. Effective Date
-
-The licenses granted in Section 2.1 with respect to any Contribution
-become effective for each Contribution on the date the Contributor first
-distributes such Contribution.
-
-2.3. Limitations on Grant Scope
-
-The licenses granted in this Section 2 are the only rights granted under
-this License. No additional rights or licenses will be implied from the
-distribution or licensing of Covered Software under this License.
-Notwithstanding Section 2.1(b) above, no patent license is granted by a
-Contributor:
-
-(a) for any code that a Contributor has removed from Covered Software;
- or
-
-(b) for infringements caused by: (i) Your and any other third party's
- modifications of Covered Software, or (ii) the combination of its
- Contributions with other software (except as part of its Contributor
- Version); or
-
-(c) under Patent Claims infringed by Covered Software in the absence of
- its Contributions.
-
-This License does not grant any rights in the trademarks, service marks,
-or logos of any Contributor (except as may be necessary to comply with
-the notice requirements in Section 3.4).
-
-2.4. Subsequent Licenses
-
-No Contributor makes additional grants as a result of Your choice to
-distribute the Covered Software under a subsequent version of this
-License (see Section 10.2) or under the terms of a Secondary License (if
-permitted under the terms of Section 3.3).
-
-2.5. Representation
-
-Each Contributor represents that the Contributor believes its
-Contributions are its original creation(s) or it has sufficient rights
-to grant the rights to its Contributions conveyed by this License.
-
-2.6. Fair Use
-
-This License is not intended to limit any rights You have under
-applicable copyright doctrines of fair use, fair dealing, or other
-equivalents.
-
-2.7. Conditions
-
-Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
-in Section 2.1.
-
-3. Responsibilities
--------------------
-
-3.1. Distribution of Source Form
-
-All distribution of Covered Software in Source Code Form, including any
-Modifications that You create or to which You contribute, must be under
-the terms of this License. You must inform recipients that the Source
-Code Form of the Covered Software is governed by the terms of this
-License, and how they can obtain a copy of this License. You may not
-attempt to alter or restrict the recipients' rights in the Source Code
-Form.
-
-3.2. Distribution of Executable Form
-
-If You distribute Covered Software in Executable Form then:
-
-(a) such Covered Software must also be made available in Source Code
- Form, as described in Section 3.1, and You must inform recipients of
- the Executable Form how they can obtain a copy of such Source Code
- Form by reasonable means in a timely manner, at a charge no more
- than the cost of distribution to the recipient; and
-
-(b) You may distribute such Executable Form under the terms of this
- License, or sublicense it under different terms, provided that the
- license for the Executable Form does not attempt to limit or alter
- the recipients' rights in the Source Code Form under this License.
-
-3.3. Distribution of a Larger Work
-
-You may create and distribute a Larger Work under terms of Your choice,
-provided that You also comply with the requirements of this License for
-the Covered Software. If the Larger Work is a combination of Covered
-Software with a work governed by one or more Secondary Licenses, and the
-Covered Software is not Incompatible With Secondary Licenses, this
-License permits You to additionally distribute such Covered Software
-under the terms of such Secondary License(s), so that the recipient of
-the Larger Work may, at their option, further distribute the Covered
-Software under the terms of either this License or such Secondary
-License(s).
-
-3.4. Notices
-
-You may not remove or alter the substance of any license notices
-(including copyright notices, patent notices, disclaimers of warranty,
-or limitations of liability) contained within the Source Code Form of
-the Covered Software, except that You may alter any license notices to
-the extent required to remedy known factual inaccuracies.
-
-3.5. Application of Additional Terms
-
-You may choose to offer, and to charge a fee for, warranty, support,
-indemnity or liability obligations to one or more recipients of Covered
-Software. However, You may do so only on Your own behalf, and not on
-behalf of any Contributor. You must make it absolutely clear that any
-such warranty, support, indemnity, or liability obligation is offered by
-You alone, and You hereby agree to indemnify every Contributor for any
-liability incurred by such Contributor as a result of warranty, support,
-indemnity or liability terms You offer. You may include additional
-disclaimers of warranty and limitations of liability specific to any
-jurisdiction.
-
-4. Inability to Comply Due to Statute or Regulation
----------------------------------------------------
-
-If it is impossible for You to comply with any of the terms of this
-License with respect to some or all of the Covered Software due to
-statute, judicial order, or regulation then You must: (a) comply with
-the terms of this License to the maximum extent possible; and (b)
-describe the limitations and the code they affect. Such description must
-be placed in a text file included with all distributions of the Covered
-Software under this License. Except to the extent prohibited by statute
-or regulation, such description must be sufficiently detailed for a
-recipient of ordinary skill to be able to understand it.
-
-5. Termination
---------------
-
-5.1. The rights granted under this License will terminate automatically
-if You fail to comply with any of its terms. However, if You become
-compliant, then the rights granted under this License from a particular
-Contributor are reinstated (a) provisionally, unless and until such
-Contributor explicitly and finally terminates Your grants, and (b) on an
-ongoing basis, if such Contributor fails to notify You of the
-non-compliance by some reasonable means prior to 60 days after You have
-come back into compliance. Moreover, Your grants from a particular
-Contributor are reinstated on an ongoing basis if such Contributor
-notifies You of the non-compliance by some reasonable means, this is the
-first time You have received notice of non-compliance with this License
-from such Contributor, and You become compliant prior to 30 days after
-Your receipt of the notice.
-
-5.2. If You initiate litigation against any entity by asserting a patent
-infringement claim (excluding declaratory judgment actions,
-counter-claims, and cross-claims) alleging that a Contributor Version
-directly or indirectly infringes any patent, then the rights granted to
-You by any and all Contributors for the Covered Software under Section
-2.1 of this License shall terminate.
-
-5.3. In the event of termination under Sections 5.1 or 5.2 above, all
-end user license agreements (excluding distributors and resellers) which
-have been validly granted by You or Your distributors under this License
-prior to termination shall survive termination.
-
-************************************************************************
-* *
-* 6. Disclaimer of Warranty *
-* ------------------------- *
-* *
-* Covered Software is provided under this License on an "as is" *
-* basis, without warranty of any kind, either expressed, implied, or *
-* statutory, including, without limitation, warranties that the *
-* Covered Software is free of defects, merchantable, fit for a *
-* particular purpose or non-infringing. The entire risk as to the *
-* quality and performance of the Covered Software is with You. *
-* Should any Covered Software prove defective in any respect, You *
-* (not any Contributor) assume the cost of any necessary servicing, *
-* repair, or correction. This disclaimer of warranty constitutes an *
-* essential part of this License. No use of any Covered Software is *
-* authorized under this License except under this disclaimer. *
-* *
-************************************************************************
-
-************************************************************************
-* *
-* 7. Limitation of Liability *
-* -------------------------- *
-* *
-* Under no circumstances and under no legal theory, whether tort *
-* (including negligence), contract, or otherwise, shall any *
-* Contributor, or anyone who distributes Covered Software as *
-* permitted above, be liable to You for any direct, indirect, *
-* special, incidental, or consequential damages of any character *
-* including, without limitation, damages for lost profits, loss of *
-* goodwill, work stoppage, computer failure or malfunction, or any *
-* and all other commercial damages or losses, even if such party *
-* shall have been informed of the possibility of such damages. This *
-* limitation of liability shall not apply to liability for death or *
-* personal injury resulting from such party's negligence to the *
-* extent applicable law prohibits such limitation. Some *
-* jurisdictions do not allow the exclusion or limitation of *
-* incidental or consequential damages, so this exclusion and *
-* limitation may not apply to You. *
-* *
-************************************************************************
-
-8. Litigation
--------------
-
-Any litigation relating to this License may be brought only in the
-courts of a jurisdiction where the defendant maintains its principal
-place of business and such litigation shall be governed by laws of that
-jurisdiction, without reference to its conflict-of-law provisions.
-Nothing in this Section shall prevent a party's ability to bring
-cross-claims or counter-claims.
-
-9. Miscellaneous
-----------------
-
-This License represents the complete agreement concerning the subject
-matter hereof. If any provision of this License is held to be
-unenforceable, such provision shall be reformed only to the extent
-necessary to make it enforceable. Any law or regulation which provides
-that the language of a contract shall be construed against the drafter
-shall not be used to construe this License against a Contributor.
-
-10. Versions of the License
----------------------------
-
-10.1. New Versions
-
-Mozilla Foundation is the license steward. Except as provided in Section
-10.3, no one other than the license steward has the right to modify or
-publish new versions of this License. Each version will be given a
-distinguishing version number.
-
-10.2. Effect of New Versions
-
-You may distribute the Covered Software under the terms of the version
-of the License under which You originally received the Covered Software,
-or under the terms of any subsequent version published by the license
-steward.
-
-10.3. Modified Versions
-
-If you create software not governed by this License, and you want to
-create a new license for such software, you may create and use a
-modified version of this License if you rename the license and remove
-any references to the name of the license steward (except to note that
-such modified license differs from this License).
-
-10.4. Distributing Source Code Form that is Incompatible With Secondary
-Licenses
-
-If You choose to distribute Source Code Form that is Incompatible With
-Secondary Licenses under the terms of this version of the License, the
-notice described in Exhibit B of this License must be attached.
-
-Exhibit A - Source Code Form License Notice
--------------------------------------------
-
- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-
-If it is not possible or desirable to put the notice in a particular
-file, then You may include the notice in a location (such as a LICENSE
-file in a relevant directory) where a recipient would be likely to look
-for such a notice.
-
-You may add additional accurate notices of copyright ownership.
-
-Exhibit B - "Incompatible With Secondary Licenses" Notice
----------------------------------------------------------
-
- This Source Code Form is "Incompatible With Secondary Licenses", as
- defined by the Mozilla Public License, v. 2.0.
diff --git a/dtek_notif/docker-compose.yaml b/dtek_notif/docker-compose.yaml
deleted file mode 100644
index e45fa7c..0000000
--- a/dtek_notif/docker-compose.yaml
+++ /dev/null
@@ -1,15 +0,0 @@
-services:
- dtek_notif:
- build:
- context: .
- dockerfile: Dockerfile
- image: gcr.forust.xyz/forust/dtek-notif:latest
- pull_policy: build
- restart: unless-stopped
- environment:
- - TZ=Europe/Kyiv
- dns:
- - 1.1.1.1
- - 8.8.8.8
- networks:
- - default
diff --git a/dtek_notif/main.py b/dtek_notif/main.py
deleted file mode 100644
index 2f82980..0000000
--- a/dtek_notif/main.py
+++ /dev/null
@@ -1,748 +0,0 @@
-import asyncio
-import contextlib
-import logging
-import os
-from datetime import datetime, timedelta
-
-import requests
-from aiogram import Bot, Dispatcher
-from aiogram.filters import Command
-from aiogram.types import KeyboardButton, Message
-from aiogram.utils.keyboard import ReplyKeyboardBuilder
-from bs4 import BeautifulSoup
-from dotenv import load_dotenv
-
-# Загрузка переменных окружения
-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 = 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, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
-logger = logging.getLogger(__name__)
-
-# Глобальные переменные
-bot = Bot(token=TELEGRAM_TOKEN)
-dp = Dispatcher()
-last_schedule: list[dict] | None = None
-last_notification_time: dict[str, datetime] = {}
-
-
-# ============================================================================
-# УТИЛИТЫ
-# ============================================================================
-
-
-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 с графиком отключений (логика от 15.11.2024)"""
- 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
-
- # Проверка подтверждённости отключения для всего часа
- cell_confirmed = None
- if 'confirm_1' in cell_classes:
- cell_confirmed = True
- elif 'confirm_0' in cell_classes:
- cell_confirmed = False
-
- # Проверка половин часа
- left = hour_block.select_one('.half.left')
- right = hour_block.select_one('.half.right')
-
- def parse_half(half, is_full_hour_off: bool, cell_confirmed: bool | None = None) -> dict:
- """Парсит половину часа"""
- if not half:
- return {'status': 'on', 'queue': None, 'confirmed': None}
-
- half_classes = half.get('class', [])
-
- # Если вся ячейка full_hour - используем статус ячейки
- if is_full_hour_off:
- return {'status': 'off', 'queue': None, 'confirmed': cell_confirmed}
-
- # Определяем статус половины
- if 'has_disconnection' in half_classes:
- status = 'off'
- elif 'no_disconnection' in half_classes:
- status = 'on'
- else:
- status = 'on' # По умолчанию считаем включенным
-
- # Если выключено - ищем подробности
- queue = None
- confirmed = None
-
- if status == 'off':
- disconnection_div = half.select_one('.disconnection')
- if disconnection_div:
- # Ищем номер черги в title
- if disconnection_div.has_attr('title'):
- title = disconnection_div['title']
- if 'Номер черги' in title or 'Номер черги:' in title:
- with contextlib.suppress(BaseException):
- queue = title.split(':')[-1].strip()
-
- # Определяем подтверждение
- disc_classes = disconnection_div.get('class', [])
- if 'disconnection_confirm_1' in disc_classes:
- confirmed = True
- elif 'disconnection_confirm_0' in disc_classes:
- confirmed = False
-
- return {'status': status, 'queue': queue, 'confirmed': confirmed}
-
- first_half_data = parse_half(left, full_hour_off, cell_confirmed)
- second_half_data = parse_half(right, full_hour_off, cell_confirmed)
-
- schedule.append(
- {
- 'hour': current_hour,
- 'day': current_day,
- 'first_half': first_half_data,
- 'second_half': second_half_data,
- }
- )
-
- current_hour += 1
- if current_hour >= 24:
- current_hour = 0
- current_day += 1
-
- return schedule
-
-
-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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
- }
- 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,
- }
-
- 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='🔄 Оновити'))
- builder.row(KeyboardButton(text='📅 Сьогодні'), KeyboardButton(text='📅 Завтра'))
- builder.row(KeyboardButton(text='ℹ️ Про бота'))
- return builder.as_markup(resize_keyboard=True)
-
-
-def format_schedule_message(schedule: list[dict], days_to_show: int = 2) -> str:
- """Форматирует полный график на несколько дней"""
- lines = [
- '⚡️ Графік відключень світла',
- f'🕐 Оновлено: {datetime.now().strftime("%d.%m.%Y %H:%M:%S")}',
- '─' * 30,
- '',
- ]
-
- start_date = datetime.now()
-
- 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.%Y')
- day_name = '🌅 Сьогодні' if day == 0 else '🌄 Завтра'
-
- lines.append(f'{day_name} ({date_str})')
-
- # Статистика
- stats = get_day_statistics(day_blocks)
- if stats['total'] > 0:
- lines.append(f'⏱ Всього: {format_time_duration(stats["total"])}')
- if stats['confirmed'] > 0:
- lines.append(f'🔴 Підтверджено: {format_time_duration(stats["confirmed"])}')
- if stats['possible'] > 0:
- lines.append(f'🟠 Можливо: {format_time_duration(stats["possible"])}')
- else:
- lines.append('🟢 Відключень немає!')
-
- lines.append('')
-
- # Детальный список отключений
- disconnections = []
- current_status = None
- start_time = None
- current_confirmed = None
- current_queue = None
-
- 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 else '🟠'
- queue_text = f' (Ч{current_queue})' if current_queue else ''
- disconnections.append(f'{icon} {start_time} - {time_str}{queue_text}')
- current_status = half['status']
-
- # Если день закончился на отключении
- 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} {start_time} - {end_time}{queue_text}')
-
- if disconnections:
- for idx, disc in enumerate(disconnections, 1):
- lines.append(f'{idx}. {disc}')
-
- lines.append('')
-
- lines.append('🔴 = підтверджено • 🟠 = можливо • 🟢 = світло')
-
- return '\n'.join(lines)
-
-
-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 '❌ Немає даних для цього дня'
-
- start_date = datetime.now()
- date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y')
- day_name = '🟠 Сьогодні' if day == 0 else '🔶 Завтра'
-
- lines = [f'{day_name} • {date_str}', '']
-
- # Статистика
- lines.append('📊 Статистика')
- stats = get_day_statistics(day_blocks)
-
- if stats['total'] == 0:
- lines.append('└ 🟢 Відключень немає!')
- else:
- total_time = format_time_duration(stats['total'])
- lines.append(f'├ ⏱ Всього: {total_time}')
-
- if stats['confirmed'] > 0:
- confirmed_time = format_time_duration(stats['confirmed'])
- lines.append(f'├ 🔴 Підтверджено: {confirmed_time}')
-
- if stats['possible'] > 0:
- possible_time = format_time_duration(stats['possible'])
- lines.append(f'└ 🟠 Можливо: {possible_time}')
- else:
- lines.append('└ 🟢 Решта часу світло')
-
- lines.append('')
-
- # Детальный список отключений
- disconnections = []
- current_status = None
- start_time = None
- current_confirmed = None
- current_queue = None
-
- 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 else '🟠'
- queue_text = f' (Ч.{current_queue})' if current_queue else ''
- disconnections.append(f'{icon} {start_time} - {time_str}{queue_text}')
- current_status = half['status']
-
- # Если день закончился на отключении
- 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} {start_time} - {end_time}{queue_text}')
-
- if disconnections:
- lines.append('⚡️ Розклад відключень')
- for idx, disc in enumerate(disconnections, 1):
- lines.append(f'{idx}. {disc}')
-
- lines.append('')
- lines.append('🔴 підтверджено • 🟠 можливо • 🟢 світло')
-
- return '\n'.join(lines)
-
-
-def schedules_differ(old_schedule: list[dict] | None, new_schedule: list[dict] | None) -> bool:
- """Проверяет отличия между графиками"""
- 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, strict=False):
- 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, 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=parse_mode)
- logger.info(f'✅ Сообщение отправлено пользователю {chat_id}')
- except Exception as e:
- logger.error(f'❌ Ошибка отправки пользователю {chat_id}: {e}')
- await asyncio.sleep(0.5)
-
-
-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 not None:
- await send_to_all_users(f'🔄 Графік оновлено!\n\n{message}')
-
- last_schedule = new_schedule
- else:
- logger.info('✓ Графік без змін')
-
- except Exception as e:
- logger.error(f'❌ Ошибка при проверке графика: {e}')
-
-
-async def check_upcoming_disconnections():
- """Проверяет предстоящие события и отправляет предупреждения за 5 минут"""
- global last_notification_time
-
- if last_schedule is None:
- return
-
- now = datetime.now()
- 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']
-
- 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}'
-
- current_status = half['status']
-
- # Если статус изменился - это переход
- if prev_status is not None and prev_status != current_status:
- transitions.append(
- {
- 'hour': hour,
- 'minute': minute,
- 'time_str': time_str,
- 'from_status': prev_status,
- 'to_status': current_status,
- 'confirmed': half.get('confirmed'),
- 'queue': half.get('queue'),
- }
- )
-
- prev_status = current_status
-
- # Проверяем переходы
- for transition in transitions:
- event_time = now.replace(hour=transition['hour'], minute=transition['minute'], second=0, microsecond=0)
-
- time_until = (event_time - now).total_seconds() / 60
- notification_key = f'{transition["hour"]}:{transition["minute"]}_{transition["to_status"]}'
-
- # Если за 5 минут до события (±1 минута) и еще не отправляли
- if 4 <= time_until <= 6:
- # Проверяем, не отправляли ли уже уведомление сегодня
- if notification_key in last_notification_time:
- last_notif_time = last_notification_time[notification_key]
- if last_notif_time.date() == now.date():
- continue # Уже отправляли сегодня
-
- # Переход на ОТКЛЮЧЕНИЕ (on -> off)
- if transition['from_status'] == 'on' and transition['to_status'] == 'off':
- icon = '🔴' if transition['confirmed'] else '🟠'
- status = 'підтверджено' if transition['confirmed'] else 'можливе'
- queue_info = f' (Черга {transition["queue"]})' if transition['queue'] else ''
-
- warning = (
- f'⚠️ УВАГА! ВІДКЛЮЧЕННЯ\n\n'
- f'Через ~5 хвилин\n'
- f'Час: {transition["time_str"]}\n'
- f'Статус: {icon} {status}{queue_info}'
- )
-
- await send_to_all_users(warning)
- 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'✅ УВАГА! ВКЛЮЧЕННЯ\n\n'
- f'Через ~5 хвилин буде світло\n'
- f'Час: {transition["time_str"]}'
- )
-
- await send_to_all_users(warning)
- last_notification_time[notification_key] = now
- logger.info(f'📢 Відправлено попередження про ВКЛЮЧЕННЯ в {transition["time_str"]}')
-
-
-async def monitoring_loop():
- """Основной цикл мониторинга"""
- await check_schedule()
-
- while True:
- 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"""
- if message.chat.id not in ALLOWED_CHAT_IDS:
- await message.answer('❌ У вас немає доступу до цього бота.')
- return
-
- await message.answer(
- '👋 Ласкаво просимо!\n\n'
- '🤖 Бот для моніторингу графіку відключень світла\n\n'
- '✨ Можливості:\n'
- '• 📊 Перегляд графіку на сьогодні і завтра\n'
- '• 🔔 Автоматичні сповіщення за 5 хвилин до подій\n'
- '• 🔄 Моніторинг змін графіку\n\n'
- 'Використовуйте кнопки нижче 👇',
- parse_mode='HTML',
- reply_markup=get_main_keyboard(),
- )
-
-
-@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(
- 'ℹ️ Про бота\n\n'
- '🚀 Версія: 2.2 (Стабільна)\n\n'
- '📝 Реліз-ноути:\n'
- '├ 15.11.2024: Адаптація під оновлену логіку сайту VOE\n'
- '├ Виправлено парсинг half.left та half.right\n'
- '├ Покращено визначення підтвердження відключень\n'
- '└ Оптимізовано обробку статусу для всієї години\n\n'
- '⚡ Функціональність:\n'
- '├ Моніторинг графіку 24/7\n'
- '├ Сповіщення за 5 хвилин\n'
- '├ Детальна статистика дня\n'
- '└ Красива візуалізація\n\n'
- '🔐 Безпека: Використовуються .env файли\n'
- '💾 Джерело: voe.com.ua',
- parse_mode='HTML',
- reply_markup=get_main_keyboard(),
- )
-
-
-@dp.message(Command('schedule'))
-async def cmd_schedule(message: Message):
- """Показывает полный график"""
- if message.chat.id not in ALLOWED_CHAT_IDS:
- 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)}', parse_mode='HTML', reply_markup=get_main_keyboard())
-
-
-@dp.message(Command('today'))
-async def cmd_today(message: Message):
- """Показывает график на сегодня"""
- if message.chat.id not in ALLOWED_CHAT_IDS:
- 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)}', parse_mode='HTML', reply_markup=get_main_keyboard())
-
-
-@dp.message(Command('tomorrow'))
-async def cmd_tomorrow(message: Message):
- """Показывает график на завтра"""
- if message.chat.id not in ALLOWED_CHAT_IDS:
- 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())
- # await message.answer("❌ Функція тимчасово недоступна. Чекаємо на оновлення сайту", parse_mode="HTML", reply_markup=get_main_keyboard())
- except Exception as e:
- await message.answer(f'❌ Помилка: {str(e)}', parse_mode='HTML', reply_markup=get_main_keyboard())
-
-
-@dp.message(Command('check'))
-async def cmd_check(message: Message):
- """Принудительная проверка графика"""
- if message.chat.id not in ALLOWED_CHAT_IDS:
- await message.answer('❌ У вас немає доступу.')
- return
-
- try:
- await message.answer('🔄 Перевіряю графік...', parse_mode='HTML')
- html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID)
- new_schedule = parse_html(html)
-
- prefix = (
- '✅ Знайдено зміни!\n\n'
- if schedules_differ(last_schedule, new_schedule)
- else '✓ Графік без змін\n\n'
- )
- result = prefix + format_schedule_message(new_schedule, days_to_show=2)
-
- await message.answer(result, parse_mode='HTML', reply_markup=get_main_keyboard())
- except Exception as e:
- await message.answer(f'❌ Помилка: {str(e)}', 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(
- '❓ Команда не розпізнана\n\n'
- 'Використовуйте кнопки на клавіатурі або команди:\n'
- '/start • /today • /tomorrow • /schedule • /check',
- parse_mode='HTML',
- reply_markup=get_main_keyboard(),
- )
-
-
-# ============================================================================
-# ГЛАВНАЯ ФУНКЦИЯ
-# ============================================================================
-
-
-async def main():
- """Главная функция"""
- logger.info('=' * 50)
- logger.info('ЗАПУСК БОТА V2.2 (stable 2.2, 15.11.2025)')
- logger.info('=' * 50)
-
- if not TELEGRAM_TOKEN or os.getenv('TELEGRAM_TOKEN', 'YOUR_TOKEN_HERE') == TELEGRAM_TOKEN:
- 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: {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__':
- try:
- asyncio.run(main())
- except KeyboardInterrupt:
- logger.info('⏹ Завершено')
diff --git a/dtek_notif/pyproject.toml b/dtek_notif/pyproject.toml
deleted file mode 100644
index 4017156..0000000
--- a/dtek_notif/pyproject.toml
+++ /dev/null
@@ -1,7 +0,0 @@
-[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
deleted file mode 100644
index 72e2a35..0000000
--- a/dtek_notif/requirements.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-requests>=2.31.0
-beautifulsoup4>=4.12.0
-aiogram>=3.3.0
-python-dotenv>=1.0.0
-aiohttp>=3.9.0