diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd692dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +/TODO.txt +/venv/ +/.idea +/.env +/.gitignore +/.git diff --git a/Dockerfile b/Dockerfile index b1e7bae..b058ca9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,23 @@ FROM python:3.11.9-slim LABEL authors="UdoChudo" +# Установим необходимые пакеты +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Установим рабочую директорию WORKDIR /app + +# Скопируем файлы проекта COPY . /app -RUN pip install gunicorn -RUN pip install --no-cache-dir -r requests.txt + +# Установим зависимости проекта +RUN pip install --no-cache-dir -r requirements.txt + +# Откроем порт для нашего приложения EXPOSE 5000 ENV FLASK_APP=telezab.py -CMD ["gunicorn", "--bind", "0.0.0.0:5000", "telezab:app"] \ No newline at end of file +# Запуск Gunicorn +CMD ["python3", "telezab.py"] \ No newline at end of file diff --git a/requests.txt b/requirements.txt similarity index 59% rename from requests.txt rename to requirements.txt index 4fc9a12..5664b0c 100644 --- a/requests.txt +++ b/requirements.txt @@ -1,23 +1,25 @@ -anyio==4.4.0 +aiohttp==3.9.5 +aiosignal==1.3.1 +attrs==23.2.0 blinker==1.8.2 -certifi==2024.6.2 +certifi==2024.7.4 charset-normalizer==3.3.2 click==8.1.7 colorama==0.4.6 Flask==3.0.3 -h11==0.14.0 -httpcore==1.0.5 -httpx==0.27.0 +frozenlist==1.4.1 idna==3.7 itsdangerous==2.2.0 Jinja2==3.1.4 MarkupSafe==2.1.5 +multidict==6.0.5 packaging==24.1 -pyTelegramBotAPI==4.19.2 +pika==1.3.2 +pyTelegramBotAPI==4.21.0 python-dotenv==1.0.1 -python-telegram-bot==21.3 +pyzabbix==1.3.1 requests==2.32.3 -sniffio==1.3.1 telebot==0.0.5 urllib3==2.2.2 Werkzeug==3.0.3 +yarl==1.9.4 diff --git a/telezab.py b/telezab.py index 021e7c7..4128625 100644 --- a/telezab.py +++ b/telezab.py @@ -14,7 +14,7 @@ import pika import json from concurrent.futures import ThreadPoolExecutor from pyzabbix import ZabbixAPI -import requests # Добавлено для имитации POST запроса +import requests # Load environment variables load_dotenv() @@ -27,19 +27,72 @@ if DEBUG_LOGGING: else: log_level = 'INFO' +# Чтение переменных окружения +ENABLE_CONSOLE_LOGGING = os.getenv('ENABLE_CONSOLE_LOGGING', 'true').lower() in ['true', '1', 'yes'] +ENABLE_WSGI_LOGGING = os.getenv('ENABLE_WSGI_LOGGING', 'true').lower() in ['true', '1', 'yes'] +ENABLE_FILE_LOGGING_CONSOLE = os.getenv('ENABLE_FILE_LOGGING_CONSOLE', 'false').lower() in ['true', '1', 'yes'] +ENABLE_FILE_LOGGING_FLASK = os.getenv('ENABLE_FILE_LOGGING_FLASK', 'false').lower() in ['true', '1', 'yes'] + +# Определение путей для файлов логирования +LOG_PATH_CONSOLE = os.getenv('LOG_PATH_CONSOLE', 'logs/console.log') +LOG_PATH_FLASK = os.getenv('LOG_PATH_FLASK', 'logs/flask.log') + +# Создание директории для логов, если она не существует +os.makedirs(os.path.dirname(LOG_PATH_CONSOLE), exist_ok=True) +os.makedirs(os.path.dirname(LOG_PATH_FLASK), exist_ok=True) + +# Определение обработчиков на основе переменных окружения +handlers = {} +if ENABLE_CONSOLE_LOGGING: + handlers['console'] = { + 'class': 'logging.StreamHandler', + 'stream': 'ext://sys.stdout', # Вывод в консоль + 'formatter': 'console', + } +if ENABLE_WSGI_LOGGING: + handlers['wsgi'] = { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', # Логирование ошибок WSGI + 'formatter': 'flask', + } +if ENABLE_FILE_LOGGING_CONSOLE: + handlers['file_console'] = { + 'class': 'logging.FileHandler', + 'filename': LOG_PATH_CONSOLE, + 'formatter': 'console', + } +if ENABLE_FILE_LOGGING_FLASK: + handlers['file_flask'] = { + 'class': 'logging.FileHandler', + 'filename': LOG_PATH_FLASK, + 'formatter': 'flask', + } + dictConfig({ 'version': 1, - 'formatters': {'default': { - 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', - }}, - 'handlers': {'wsgi': { - 'class': 'logging.StreamHandler', - 'stream': 'ext://flask.logging.wsgi_errors_stream', - 'formatter': 'default' - }}, + 'formatters': { + 'default': { + 'format': '[%(asctime)s] %(levelname)s %(module)s: %(message)s [Location: %(extra)s]', + }, + 'console': { + 'format': '[Location: console] [%(asctime)s] %(levelname)s %(module)s: %(message)s ', + }, + 'flask': { + 'format': '[Location: flask] [%(asctime)s] %(levelname)s %(module)s: %(message)s ', + }, + }, + 'handlers': handlers, + 'loggers': { + 'werkzeug': { # Flask логер + 'level': 'INFO', + 'handlers': ['console'] if ENABLE_CONSOLE_LOGGING else [], + 'propagate': False, + 'formatter': 'flask', + } + }, 'root': { - 'level': log_level, - 'handlers': ['wsgi'] + 'level': 'INFO', + 'handlers': [handler for handler in handlers.keys()], } }) @@ -257,6 +310,11 @@ def cancel_settings_timer(chat_id): user_timers[chat_id].cancel() del user_timers[chat_id] +def reset_settings_timer(chat_id): + if chat_id in user_timers: + user_timers[chat_id].cancel() + start_settings_timer(chat_id) + def transition_to_notification_mode(chat_id): set_user_state(chat_id, NOTIFICATION_MODE) bot.send_message(chat_id, "Вы были автоматически переведены в режим получения уведомлений.") @@ -303,6 +361,7 @@ def handle_menu_selection(message): text = message.text.strip().lower() if user_states.get(chat_id, NOTIFICATION_MODE) == SETTINGS_MODE: + reset_settings_timer(chat_id) handle_settings_menu_selection(message) else: if text == 'регистрация': @@ -323,6 +382,8 @@ def handle_settings_menu_selection(message): chat_id = message.chat.id text = message.text.strip().lower() + reset_settings_timer(chat_id) + if text == 'подписаться': handle_subscribe(message) elif text == 'отписаться': @@ -535,9 +596,18 @@ def process_add_region(message): @bot.callback_query_handler(func=lambda call: call.data.startswith("replace_") or call.data.startswith("reactivate_")) def handle_region_action(call): - action, region_id, region_name = call.data.split("_", 2) + parts = call.data.split("_", 2) + action = parts[0] + region_id = parts[1] + region_name = parts[2] if len(parts) > 2 else None chat_id = call.message.chat.id + if not region_name: + bot.send_message(chat_id, "Ошибка: Недостаточно данных для выполнения действия.") + bot.answer_callback_query(call.id) # Завершение обработки callback + bot.delete_message(chat_id, call.message.message_id) + return show_settings_menu(chat_id) + with db_lock: conn = sqlite3.connect('telezab.db') cursor = conn.cursor() @@ -556,6 +626,7 @@ def handle_region_action(call): conn.commit() conn.close() + bot.answer_callback_query(call.id) # Завершение обработки callback show_settings_menu(chat_id) def process_remove_region(message): @@ -654,16 +725,19 @@ def handle_active_regions(message): bot.send_message(chat_id, f"Активные регионы:\n{regions_list}") show_settings_menu(chat_id) + # RabbitMQ configuration RABBITMQ_HOST = os.getenv('RABBITMQ_HOST', 'localhost') RABBITMQ_QUEUE = 'telegram_notifications' + def rabbitmq_connection(): connection = pika.BlockingConnection(pika.ConnectionParameters(RABBITMQ_HOST)) channel = connection.channel() channel.queue_declare(queue=RABBITMQ_QUEUE, durable=True) return connection, channel + def send_to_queue(message): connection, channel = rabbitmq_connection() channel.basic_publish( @@ -675,6 +749,7 @@ def send_to_queue(message): )) connection.close() + async def consume_from_queue(): connection, channel = rabbitmq_connection() @@ -693,6 +768,7 @@ async def consume_from_queue(): connection.close() + async def send_message(chat_id, message, is_notification=False): try: if is_notification: @@ -712,14 +788,17 @@ async def send_message(chat_id, message, is_notification=False): if is_notification: rate_limit_semaphore.release() + async def send_notification_message(chat_id, message): await send_message(chat_id, message, is_notification=True) + async def run_in_executor(func, *args): loop = asyncio.get_event_loop() with ThreadPoolExecutor() as pool: return await loop.run_in_executor(pool, func, *args) + async def check_telegram_api(): try: async with aiohttp.ClientSession() as session: @@ -731,6 +810,7 @@ async def check_telegram_api(): except Exception as e: app.logger.error(f"Error checking Telegram API: {e}") + @app.route('/webhook', methods=['POST']) def webhook(): data = request.get_json() @@ -777,6 +857,7 @@ def webhook(): return jsonify({"status": "success"}), 200 + def format_message(data): return (f"Zabbix Alert\n" f"Host: {data['host']}\n" @@ -784,6 +865,7 @@ def format_message(data): f"Trigger: {data['trigger']}\n" f"Value: {data['value']}") + # Handle active triggers def handle_active_triggers(message): chat_id = message.chat.id @@ -794,6 +876,7 @@ def handle_active_triggers(message): markup = create_region_markup(regions, start_index, regions_per_page) bot.send_message(chat_id, "По какому региону хотите получить активные проблемы:", reply_markup=markup) + def create_region_markup(regions, start_index, regions_per_page): markup = telebot.types.InlineKeyboardMarkup() end_index = min(start_index + regions_per_page, len(regions)) @@ -812,18 +895,22 @@ def create_region_markup(regions, start_index, regions_per_page): markup.row(*row_buttons) return markup + @bot.callback_query_handler(func=lambda call: call.data.startswith("region_")) def handle_region_selection(call): region_id = call.data.split("_")[1] chat_id = call.message.chat.id - # Mocking the Zabbix triggers for the given region_id - triggers = get_mocked_zabbix_triggers(region_id) + # Получение триггеров из реального Zabbix API + triggers = get_zabbix_triggers(region_id) if not triggers: bot.send_message(chat_id, "Нет активных проблем по указанному региону за последние 24 часа.") else: bot.send_message(chat_id, triggers, parse_mode="Markdown") + bot.answer_callback_query(call.id) # Завершение обработки callback + + @bot.callback_query_handler(func=lambda call: call.data.startswith("prev_") or call.data.startswith("next_")) def handle_pagination(call): direction, index = call.data.split("_") @@ -839,22 +926,25 @@ def handle_pagination(call): markup = create_region_markup(regions, start_index, regions_per_page) bot.edit_message_reply_markup(call.message.chat.id, call.message.message_id, reply_markup=markup) -def get_mocked_zabbix_triggers(region_id): - # Mocked Zabbix triggers - triggers = [ - { - "triggerid": "1", - "description": f"Проблема {region_id}-1", - "priority": "4", - "hosts": [{"hostid": region_id, "name": f"Хост {region_id}-1"}] - }, - { - "triggerid": "2", - "description": f"Проблема {region_id}-2", - "priority": "5", - "hosts": [{"hostid": region_id, "name": f"Хост {region_id}-2"}] - } - ] + bot.answer_callback_query(call.id) # Завершение обработки callback + + +# Функция для получения активных триггеров из Zabbix API +def get_zabbix_triggers(region_id): + zapi = ZabbixAPI(ZABBIX_URL) + zapi.login(api_token=ZABBIX_API_TOKEN) + + # Получение триггеров уровня "Высокая" и "Авария" за последние 24 часа + triggers = zapi.trigger.get( + output=["triggerid", "description", "priority"], + selectHosts=["hostid", "name"], + filter={"priority": ["4", "5"], "value": "1"}, + search={"host": region_id}, + only_true=1, + active=1, + withLastEventUnacknowledged=1, + limit=100 + ) priority_map = { '4': 'Высокая', @@ -872,6 +962,7 @@ def get_mocked_zabbix_triggers(region_id): return "\n\n---\n\n".join(trigger_messages) + # Test functions for admin def simulate_event(message): chat_id = message.chat.id @@ -888,12 +979,13 @@ def simulate_event(message): app.logger.info(f"Response from webhook: {response.status_code} - {response.text}") bot.send_message(chat_id, f"Тестовое событие отправлено. Статус ответа: {response.status_code}") + def simulate_triggers(message): chat_id = message.chat.id regions = ["12", "19", "35", "40"] trigger_messages = [] for region_id in regions: - triggers = get_mocked_zabbix_triggers(region_id) + triggers = get_zabbix_triggers(region_id) if triggers: trigger_messages.append(f"Регион {region_id}:\n{triggers}") @@ -902,9 +994,11 @@ def simulate_triggers(message): else: bot.send_message(chat_id, "Нет активных проблем по указанным регионам за последние 24 часа.") + def run_polling(): bot.polling(none_stop=True, interval=0) + if __name__ == '__main__': init_db()