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