import os import logging import time import errno import json from dotenv import load_dotenv import telebot from telebot import apihelper from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton import pymysql from datetime import datetime, timezone import sys from urllib.parse import urlparse import requests from io import BytesIO from flask import Flask, request from formatter import get_post_text, get_event_text from telegram_relay import configure_telebot_api, get_relay_url, bot_api_method_url from season_links import ( SEASON, callback_subscribe_post, callback_unsubscribe_post, callback_subscribe_event, callback_unsubscribe_event, parse_start_payload, parse_post_callback, parse_event_callback, ) # Создаем Flask app на верхнем уровне для экспорта app = Flask(__name__) # Загружаем .env рядом с этим файлом (не зависим от cwd gunicorn) _ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") load_dotenv(_ENV_PATH) load_dotenv() # и из cwd на всякий случай # Получаем данные из переменных окружения RESPONDER_BOT_TOKEN = os.getenv('RESPONDER_BOT_TOKEN') CHANNEL_ID = os.getenv('CHANNEL_ID') WEBHOOK_URL = os.getenv('WEBHOOK_URL') WEBHOOK_PORT = int(os.getenv('WEBHOOK_PORT', '8443')) WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET') if WEBHOOK_SECRET: WEBHOOK_SECRET = WEBHOOK_SECRET.strip().strip('"').strip("'") LOG_FILE = os.getenv('LOG_FILE', 'bot.log') RELAY_URL = os.getenv('RELAY_URL') # Настройка логирования logger = logging.getLogger('TG_bot') logger.setLevel(logging.INFO) # Отключаем propagate, чтобы сообщения не уходили в root logger logger.propagate = False # Очищаем существующие handlers, если они есть (на случай повторного импорта) if logger.handlers: logger.handlers.clear() formatter = logging.Formatter('[%(asctime)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) logger.addHandler(console_handler) class RetryFileHandler(logging.FileHandler): def emit(self, record): for _ in range(5): try: super().emit(record) return except (IOError, PermissionError): time.sleep(0.5) print(f"Failed to write to log file after 5 attempts: {record.msg}") file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8') file_handler.setFormatter(formatter) logger.addHandler(file_handler) # Логируем успешную настройку логирования (это должно попасть и в консоль, и в файл) logger.info(f"Логирование настроено. Файл лога: {LOG_FILE}") logger.info(f"Количество обработчиков: {len(logger.handlers)}") MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000)) MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000)) # Устанавливаем единое ограничение длины текста UNIFIED_MAX_LENGTH = 800 # Режим работы бота (ZILANT или VOLK) BOT_MODE = os.getenv('WORKMODE', 'ZILANT').upper() if BOT_MODE not in ('ZILANT', 'VOLK'): logger.warning(f"Неизвестный режим бота: {BOT_MODE}, используем ZILANT") BOT_MODE = 'ZILANT' logger.info(f"Режим работы бота: {BOT_MODE}") def get_welcome_text(): """Возвращает приветственный текст в зависимости от режима""" logger.info(f"starting get_welcome_text") if BOT_MODE == 'VOLK': return ( "🌟 Привет! 🌟\n\n" "Я - информационный бот ВОЛКа. " "Здесь можно подписаться на интересные события будущего конвента - и потом напомнить самому себе, " "что именно показалось интересным.\n\n" ) else: # ZILANT return ( "🌟 Привет! 🌟\n\n" "Я - информационный бот Зиланткона. " "Здесь можно подписаться на интересные события будущего Зиланта - и я напомню," "когда их можно будет включить в свой План Захвата Конвента.\n\n" ) def get_help_text(): """Возвращает текст справки в зависимости от режима""" logger.info(f"starting get_help_text") if BOT_MODE == 'VOLK': return ( "ℹ️ Подсказка информ-бота ВОЛКа\n\n" "Я запоминаю, кто чем интересовался, и позволяю " "вспомнить, что именно вызывало интерес.\n\n" "Доступные команды:\n" "• /start - начать работу с ботом (вы уже здесь!)\n" "• /help - показать эту справку\n" "• /home - перейти в основной канал ВОЛКа\n" "• /list - показать текущие подписки\n\n" "Используйте кнопки ниже для быстрого доступа:" ) else: # ZILANT return ( "ℹ️ Подсказка информ-бота Зиланткона\n\n" "Я запоминаю, кто чем интересовался, и напоминаю, " "когда это становится можно включить в План Захвата.\n\n" "Доступные команды:\n" "• /start - начать работу с ботом (вы уже здесь!)\n" "• /help - показать эту справку\n" "• /home - перейти в основной канал Зиланткона\n" "• /list - показать текущие подписки\n\n" "Используйте кнопки ниже для быстрого доступа:" ) # Извлекаем путь из WEBHOOK_URL if WEBHOOK_URL: try: parsed_url = urlparse(WEBHOOK_URL) WEBHOOK_PATH = parsed_url.path if not WEBHOOK_PATH: WEBHOOK_PATH = '/' logger.info(f"Извлечен путь вебхука: {WEBHOOK_PATH}") except Exception as e: logger.error(f"Ошибка парсинга WEBHOOK_URL: {e}") WEBHOOK_PATH = '/zil_bot' logger.info(f"Форсирован путь вебхука: {WEBHOOK_PATH}") else: WEBHOOK_PATH = '/zil_bot' logger.warning("WEBHOOK_URL не задан, используем путь по умолчанию") # Данные для подключения к MariaDB MDB_HOST = os.getenv('MDB_HOST') MDB_USER = os.getenv('MDB_USER') MDB_PW = os.getenv('MDB_PW') MDBASE = os.getenv('MDBASE') # Проверка обязательных переменных required_vars = { "RESPONDER_BOT_TOKEN": RESPONDER_BOT_TOKEN, "CHANNEL_ID": CHANNEL_ID, "WEBHOOK_URL": WEBHOOK_URL, "RELAY_URL": RELAY_URL, "MDB_HOST": MDB_HOST, "MDB_USER": MDB_USER, "MDB_PW": MDB_PW, "MDBASE": MDBASE } for name, value in required_vars.items(): if not value: logger.error(f"Требуется переменная окружения {name}") exit(1) # Настройка таймаутов для Telegram API apihelper.READ_TIMEOUT = 120 # Таймаут на чтение ответа (увеличен для больших сообщений) apihelper.CONNECT_TIMEOUT = 30 # Таймаут на подключение apihelper.RETRY_ON_TIMEOUT = True # Автоматические повторные попытки при таймауте # Весь Bot API — только через internal_relay relay = configure_telebot_api() logger.info(f"Telegram Bot API через ретранслятор: {relay}") # Создаем экземпляр бота bot = telebot.TeleBot(RESPONDER_BOT_TOKEN) logger.info(f"Сезон подписок SEASON={SEASON}") def get_user_name(user): """Форматирует имя пользователя для записи в БД""" # logger.info(f"starting get_user_name") if user.username: return f"@{user.username}" else: # Используем имя и фамилию, если username отсутствует name_parts = [] if user.first_name: name_parts.append(user.first_name) if user.last_name: name_parts.append(user.last_name) return ' '.join(name_parts) if name_parts else "Неизвестный пользователь" def format_user_info(user_id, user_name): """Форматирует информацию о пользователе для логов""" return f"{user_name} ({user_id})" def log_event(event_type, user_info, post_id=None, message=None): """Логирует событие с повторными попытками при блокировке файла""" timestamp = datetime.now(timezone.utc).strftime('[%Y-%m-%d %H:%M:%S]') log_line = f"{timestamp} [TG_bot] " if event_type == "subscribe": log_line += f"Пользователь {user_info} подписался на событие ID {post_id}" elif event_type == "unsubscribe": log_line += f"Пользователь {user_info} отписался от событие ID {post_id}" elif event_type == "list_request": log_line += f"Пользователь {user_info} запросил список подписок" elif event_type == "error": log_line += f"ОШИБКА: {message} (Пользователь {user_info})" elif event_type == "security": log_line += f"SECURITY: {message}" elif event_type == "system": log_line += f"SYSTEM: {message}" elif event_type == "info": log_line += f"ИНФО: {message} (Пользователь {user_info})" else: log_line += f"Неизвестное событие: {event_type} (Пользователь {user_info})" # Параметры повторных попыток intervals = [0.1, 0.2, 0.4, 0.8] # Интервалы между попытками max_attempts = len(intervals) + 1 # +1 для первой попытки attempt = 0 while attempt < max_attempts: try: with open(LOG_FILE, 'a', encoding='utf-8') as log_file: log_file.write(log_line + '\n') log_file.flush() # Обеспечить немедленную запись return except (IOError, OSError) as e: # Проверяем, является ли ошибка блокировкой файла if e.errno in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK, errno.EBUSY): if attempt < max_attempts - 1: time.sleep(intervals[attempt]) attempt += 1 continue # Для других ошибок сразу прерываем цикл break except Exception as e: # Обрабатываем все остальные исключения break # Все попытки провалились error_msg = f"Не удалось записать в лог после {max_attempts} попыток. Ошибка: {e}" sys.stderr.write(error_msg + '\n') sys.stderr.write(f"Событие: {log_line}\n") def create_db_connection(): """Создает подключение к базе данных MariaDB через pymysql""" logger.info(f"starting create_db_connection") try: conn = pymysql.connect( host=MDB_HOST, user=MDB_USER, password=MDB_PW, database=MDBASE, charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor, init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC ) return conn except pymysql.Error as e: error_msg = f"Ошибка подключения к базе данных: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return None def is_user_subscribed(post_id, user_id): """Проверяет, подписан ли пользователь уже на это событие""" logger.info(f"starting is_user_subscribed") conn = create_db_connection() if conn is None: return False try: with conn.cursor() as cursor: sql = """ SELECT COUNT(*) as count FROM marks WHERE tg_post_id = %s AND tg_user_id = %s """ cursor.execute(sql, (post_id, user_id)) result = cursor.fetchone() return result['count'] > 0 except pymysql.Error as e: error_msg = f"Ошибка при проверке подписки: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return False finally: conn.close() def is_user_subscribed_evt(event_id, user_id): """Проверяет, подписан ли пользователь уже на это событие (таблица events)""" logger.info(f"starting is_user_subscribed_evt") conn = create_db_connection() if conn is None: return False try: with conn.cursor() as cursor: sql = """ SELECT COUNT(*) as count FROM marks_evt WHERE tg_event_id = %s AND tg_user_id = %s """ cursor.execute(sql, (event_id, user_id)) result = cursor.fetchone() return result['count'] > 0 except pymysql.Error as e: error_msg = f"Ошибка при проверке подписки на событие: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return False finally: conn.close() def get_user_subscriptions(user_id): """Возвращает список событий, на которые подписан пользователь, с shortname""" logger.info(f"starting get_user_subscriptions") conn = create_db_connection() if conn is None: return [] try: with conn.cursor() as cursor: sql = """ SELECT m.tg_post_id, COALESCE(p.shortname, '') as shortname FROM marks m LEFT JOIN posts p ON m.tg_post_id = p.tg_message_id WHERE m.tg_user_id = %s ORDER BY m.created_at DESC """ cursor.execute(sql, (user_id,)) result = cursor.fetchall() return [{'post_id': row['tg_post_id'], 'shortname': row['shortname']} for row in result] except pymysql.Error as e: error_msg = f"Ошибка при получении подписок: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return [] finally: conn.close() def get_user_subscriptions_evt(user_id): """Возвращает список событий, на которые подписан пользователь, с информацией о статусе""" logger.info(f"starting get_user_subscriptions_evt") conn = create_db_connection() if conn is None: return [] try: with conn.cursor() as cursor: sql = """ SELECT m.tg_event_id, COALESCE(e.name, '') as name, e.is_posted_tg, e.is_visible FROM marks_evt m LEFT JOIN events e ON m.tg_event_id = e.tg_message_id WHERE m.tg_user_id = %s ORDER BY m.created_at DESC """ cursor.execute(sql, (user_id,)) result = cursor.fetchall() return [{'event_id': row['tg_event_id'], 'name': row['name'], 'is_posted_tg': row['is_posted_tg'], 'is_visible': row['is_visible']} for row in result] except pymysql.Error as e: error_msg = f"Ошибка при получении подписок на события: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return [] finally: conn.close() def get_linked_events_for_post(post_id): """Возвращает список опубликованных мероприятий, связанных с анонсом через таблицу interlinks""" logger.info(f"starting get_linked_events_for_post") conn = create_db_connection() if conn is None: return [] try: with conn.cursor() as cursor: sql = """ SELECT e.name, e.tg_message_id FROM interlinks i JOIN events e ON i.event_id = e.id JOIN posts p ON i.post_id = p.id WHERE p.tg_message_id = %s AND e.name IS NOT NULL AND e.name != '' AND e.is_posted_tg = 1 ORDER BY e.name """ cursor.execute(sql, (post_id,)) result = cursor.fetchall() return [{'name': row['name'], 'tg_message_id': row['tg_message_id']} for row in result] except pymysql.Error as e: error_msg = f"Ошибка при получении связанных мероприятий: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return [] finally: conn.close() def save_mark_to_db(post_id, user_id, user_name): """Сохраняет отметку пользователя в базе данных""" logger.info(f"starting save_mark_to_db") conn = create_db_connection() if conn is None: return False try: with conn.cursor() as cursor: # Используем INSERT IGNORE для предотвращения дубликатов sql = """ INSERT IGNORE INTO marks (tg_post_id, tg_user_id, tg_user_name, created_at) VALUES (%s, %s, %s, UTC_TIMESTAMP()) """ cursor.execute(sql, (post_id, user_id, user_name)) conn.commit() if cursor.rowcount > 0: logger.info(f"Добавлена запись: событие={post_id}, пользователь={user_id}, имя={user_name}") user_info = format_user_info(user_id, user_name) log_event("subscribe", user_info, post_id) return True else: logger.info(f"Запись уже существует: событие={post_id}, пользователь={user_id}") # Добавляем запись в лог о попытке повторной подписки user_info = format_user_info(user_id, user_name) log_event("info", user_info, post_id, f"Попытка повторной подписки: событие={post_id}, пользователь={user_info}") return False except pymysql.Error as e: error_msg = f"Ошибка при сохранении в БД: {e}" logger.error(error_msg) user_info = format_user_info(user_id, user_name) log_event("error", user_info, post_id, error_msg) return False finally: conn.close() def save_mark_to_db_evt(event_id, user_id, user_name): """Сохраняет отметку пользователя в базе данных для событий (таблица marks_evt)""" logger.info(f"starting save_mark_to_db_evt") conn = create_db_connection() if conn is None: return False try: with conn.cursor() as cursor: sql = """ INSERT IGNORE INTO marks_evt (tg_event_id, tg_user_id, tg_user_name, created_at) VALUES (%s, %s, %s, UTC_TIMESTAMP()) """ cursor.execute(sql, (event_id, user_id, user_name)) conn.commit() if cursor.rowcount > 0: logger.info(f"Добавлена запись: событие={event_id}, пользователь={user_id}, имя={user_name}") user_info = format_user_info(user_id, user_name) log_event("subscribe", user_info, event_id) return True else: logger.info(f"Запись уже существует: событие={event_id}, пользователь={user_id}") user_info = format_user_info(user_id, user_name) log_event("info", user_info, event_id, f"Попытка повторной подписки: событие={event_id}, пользователь={user_info}") return False except pymysql.Error as e: error_msg = f"Ошибка при сохранении в БД (events): {e}" logger.error(error_msg) user_info = format_user_info(user_id, user_name) log_event("error", user_info, event_id, error_msg) return False finally: conn.close() def remove_mark_from_db(post_id, user_id, user_name): """Удаляет отметку пользователя из базы данных""" logger.info(f"starting remove_mark_from_db") conn = create_db_connection() if conn is None: return False try: with conn.cursor() as cursor: sql = """ DELETE FROM marks WHERE tg_post_id = %s AND tg_user_id = %s """ cursor.execute(sql, (post_id, user_id)) conn.commit() if cursor.rowcount > 0: logger.info(f"Удалена запись: событие={post_id}, пользователь={user_id}") user_info = format_user_info(user_id, user_name) log_event("unsubscribe", user_info, post_id) return True else: logger.info(f"Запись не найдена: событие={post_id}, пользователь={user_id}") # Добавляем запись в лог о попытке отписки без подписки user_info = format_user_info(user_id, user_name) log_event("info", user_info, post_id, f"Попытка отписки без подписки: событие={post_id}, пользователь={user_info}") return False except pymysql.Error as e: error_msg = f"Ошибка при удалении из БД: {e}" logger.error(error_msg) user_info = format_user_info(user_id, user_name) log_event("error", user_info, post_id, error_msg) return False finally: conn.close() def remove_mark_from_db_evt(event_id, user_id, user_name): """Удаляет отметку пользователя из базы данных для событий (таблица marks_evt)""" logger.info(f"starting remove_mark_from_db_evt") conn = create_db_connection() if conn is None: return False try: with conn.cursor() as cursor: sql = """ DELETE FROM marks_evt WHERE tg_event_id = %s AND tg_user_id = %s """ cursor.execute(sql, (event_id, user_id)) conn.commit() if cursor.rowcount > 0: logger.info(f"Удалена запись: событие={event_id}, пользователь={user_id}") user_info = format_user_info(user_id, user_name) log_event("unsubscribe", user_info, event_id) return True else: logger.info(f"Запись не найдена: событие={event_id}, пользователь={user_id}") user_info = format_user_info(user_id, user_name) log_event("info", user_info, event_id, f"Попытка отписки без подписки: событие={event_id}, пользователь={user_info}") return False except pymysql.Error as e: error_msg = f"Ошибка при удалении из БД (events): {e}" logger.error(error_msg) user_info = format_user_info(user_id, user_name) log_event("error", user_info, event_id, error_msg) return False finally: conn.close() def format_channel_link(post_id=None): """Форматирует ссылку на событие в канале или на сам канал""" logger.info(f"starting format_channel_link") if not CHANNEL_ID: return "" if post_id: if CHANNEL_ID.startswith('@'): return f"https://t.me/{CHANNEL_ID[1:]}/{post_id}" elif CHANNEL_ID.startswith('-100'): return f"https://t.me/c/{CHANNEL_ID[4:]}/{post_id}" else: return f"https://t.me/c/{CHANNEL_ID}/{post_id}" else: if CHANNEL_ID.startswith('@'): return f"https://t.me/{CHANNEL_ID[1:]}" elif CHANNEL_ID.startswith('-100'): return f"https://t.me/c/{CHANNEL_ID[4:]}" else: return f"https://t.me/c/{CHANNEL_ID}" def get_last_channel_message_id(): """Максимальный tg_message_id среди опубликованных постов и событий (текущая БД).""" conn = create_db_connection() if conn is None: return None try: with conn.cursor() as cursor: cursor.execute( """ SELECT MAX(mid) AS last_id FROM ( SELECT MAX(tg_message_id) AS mid FROM posts WHERE tg_message_id IS NOT NULL AND tg_message_id > 0 UNION ALL SELECT MAX(tg_message_id) AS mid FROM events WHERE tg_message_id IS NOT NULL AND tg_message_id > 0 ) t """ ) row = cursor.fetchone() last_id = row["last_id"] if row else None return int(last_id) if last_id else None except pymysql.Error as e: logger.error(f"Ошибка получения последнего message_id канала: {e}") return None finally: conn.close() def get_return_to_channel_link(): """Ссылка на последнее известное сообщение в канале или на сам канал.""" last_id = get_last_channel_message_id() if last_id: return format_channel_link(last_id) return format_channel_link() def create_season_ended_keyboard(): keyboard = InlineKeyboardMarkup() keyboard.row( InlineKeyboardButton("↩ Вернуться в канал", url=get_return_to_channel_link()) ) return keyboard def send_season_ended_message(chat_id): """Ответ на клик по подписке прошлого сезона (или без префикса SEASON).""" logger.info(f"send_season_ended_message chat_id={chat_id}, current SEASON={SEASON}") text = ( "Этот сезон уже завершён.\n\n" "Подписки и кнопки из прошлых анонсов больше не работают. " "Актуальные события — в канале." ) bot.send_message( chat_id, text, reply_markup=create_season_ended_keyboard(), ) def create_help_keyboard(): """Создает клавиатуру для справки""" logger.info(f"starting create_help_keyboard") keyboard = InlineKeyboardMarkup() keyboard.row( InlineKeyboardButton("📜 Подписки", callback_data="cmd_list"), InlineKeyboardButton("📢 Канал", url=format_channel_link()) ) return keyboard def create_main_keyboard(): """Создает основную клавиатуру для /start без параметров""" logger.info(f"starting create_main_keyboard") keyboard = InlineKeyboardMarkup() keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("📢 Канал", url=format_channel_link()) ) return keyboard def create_manage_keyboard(post_id, is_subscribed): """Создает клавиатуру для управления подпиской на посты""" logger.info(f"starting create_manage_keyboard") keyboard = InlineKeyboardMarkup() channel_link = format_channel_link(post_id) if is_subscribed: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❌ Отписаться", callback_data=callback_unsubscribe_post(post_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) else: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("✔ Подписаться", callback_data=callback_subscribe_post(post_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) return keyboard def create_manage_keyboard_evt(event_id, is_subscribed): """Создает клавиатуру для управления подпиской на события""" logger.info(f"starting create_manage_keyboard_evt") keyboard = InlineKeyboardMarkup() channel_link = format_channel_link(event_id) if is_subscribed: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❌ Отписаться", callback_data=callback_unsubscribe_event(event_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) else: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("✔ Подписаться", callback_data=callback_subscribe_event(event_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) return keyboard def get_post_data(tg_message_id): """Получает данные о событии по его tg_message_id""" logger.info(f"starting get_post_data") conn = create_db_connection() if conn is None: return None try: with conn.cursor() as cursor: sql = """ SELECT vk_post_id, COALESCE(text, '') AS text, COALESCE(image_url, '') AS image_url, COALESCE(vk_post_url, '') AS vk_post_url, COALESCE(poll_question, '') AS poll_question, COALESCE(poll_options, '[]') AS poll_options, poll_multiple, is_poll, COALESCE(shortname, '') AS shortname FROM posts WHERE tg_message_id = %s """ cursor.execute(sql, (tg_message_id,)) return cursor.fetchone() except pymysql.Error as e: error_msg = f"Ошибка при получении данных о событии: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return None finally: conn.close() def get_event_data(tg_message_id): """Получает данные о событии из таблица events по его tg_message_id""" logger.info(f"starting get_event_data") conn = create_db_connection() if conn is None: return None try: with conn.cursor() as cursor: sql = """ SELECT about_social_picture, COALESCE(name, '') AS name, COALESCE(about, '') AS about FROM events WHERE tg_message_id = %s """ cursor.execute(sql, (tg_message_id,)) return cursor.fetchone() except pymysql.Error as e: error_msg = f"Ошибка при получении данных о событии (events): {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return None finally: conn.close() def format_post_message(tg_message_id): """Форматирует сообщение о событии с помощью модуля formatter""" logger.info(f"starting format_post_message") post_data = get_post_data(tg_message_id) if not post_data: error_msg = f"Событие с tg_message_id={tg_message_id} не найдено в базе." logger.error(error_msg) log_event("error", "system", message=error_msg) return { 'text': "❌ Событие не найдено в базе данных. Пожалуйста, сообщите администратору.", 'image_url': None } try: # Подготавливаем данные для formatter vk_post_id = post_data['vk_post_id'] or 0 text = post_data['text'] or "" image_url = post_data['image_url'] or "" vk_post_url = post_data['vk_post_url'] or "" poll_question = post_data['poll_question'] or "" poll_options = post_data['poll_options'] or "[]" poll_multiple = bool(post_data['poll_multiple']) if post_data['poll_multiple'] is not None else False post_tuple = ( vk_post_id, text, image_url, vk_post_url, poll_question, poll_options, poll_multiple, False, # is_event False # Добавленный элемент со значением False ) # Форматируем событие с единым ограничением длины formatted = get_post_text( post_tuple, max_caption_length=UNIFIED_MAX_LENGTH, max_text_length=UNIFIED_MAX_LENGTH ) # Используем caption для сообщений с изображениями, text_message для текстовых if image_url: base_text = formatted.get('caption') or formatted.get('base_text') or "❌ Не удалось подготовить текст о событии." else: base_text = formatted.get('text_message') or formatted.get('base_text') or "❌ Не удалось подготовить текст о событии." # Дополнительная обрезка на случай, если форматтер не обрезал текст if len(base_text) > UNIFIED_MAX_LENGTH: base_text = base_text[:UNIFIED_MAX_LENGTH - 3] + "..." return { 'text': base_text, 'image_url': image_url if image_url else None } except Exception as e: error_msg = f"Ошибка при форматировании текста о событии: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return { 'text': "❌ Произошла ошибка при форматировании текста о событии. Пожалуйста, попробуйте позже.", 'image_url': None } def format_event_message(tg_message_id): """Форматирует сообщение о событии из таблицы events""" logger.info(f"starting format_event_message") event_data = get_event_data(tg_message_id) if not event_data: error_msg = f"Событие с tg_message_id={tg_message_id} не найдено в базе events." logger.error(error_msg) log_event("error", "system", message=error_msg) return { 'text': "❌ Событие не найдено в базе данных. Пожалуйста, сообщите администратору.", 'image_url': None } try: name = event_data['name'] or "" about = event_data['about'] or "" image_url = event_data['about_social_picture'] or "" # Форматируем текст события formatted_about = get_event_text(about, UNIFIED_MAX_LENGTH) # Формируем итоговый текст if name and formatted_about: text = f"{name}\n{formatted_about}" elif name: text = f"{name}" elif formatted_about: text = formatted_about else: text = "❌ Не удалось подготовить текст о событии." # Дополнительная обрезка на случай, если текст слишком длинный if len(text) > UNIFIED_MAX_LENGTH: text = text[:UNIFIED_MAX_LENGTH - 3] + "..." return { 'text': text, 'image_url': image_url if image_url else None } except Exception as e: error_msg = f"Ошибка при форматировании текста о событии (events): {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return { 'text': "❌ Произошла ошибка при форматировании текста о событии. Пожалуйста, попробуйте позже.", 'image_url': None } def download_image(image_url): """Загружает изображение по URL""" logger.info(f"starting download_image") try: response = requests.get(image_url, timeout=10) response.raise_for_status() return BytesIO(response.content) except Exception as e: logger.error(f"Ошибка загрузки изображения: {e}") return None def send_post_message(chat_id, text, image_url=None, reply_markup=None): """Отправляет сообщение с изображением или текстом с повторными попытками при таймауте""" logger.info(f"starting send_post_message") # Параметры для повторных попыток при таймауте max_retries = 5 retry_interval = 2 # Дополнительная обрезка текста if len(text) > UNIFIED_MAX_LENGTH: text = text[:UNIFIED_MAX_LENGTH - 3] + "..." if image_url: # Загружаем изображение image = download_image(image_url) if image: # Отправляем фото с подписью с повторными попытками for attempt in range(1, max_retries + 1): try: bot.send_photo( chat_id, photo=image, caption=text, reply_markup=reply_markup, parse_mode='HTML' ) logger.info(f"Сообщение с изображением успешно отправлено (попытка {attempt})") return True except Exception as e: error_str = str(e) # Проверяем, является ли это таймаутом if 'timeout' in error_str.lower() or 'timed out' in error_str.lower(): if attempt < max_retries: logger.warning(f"Таймаут при отправке фото (попытка {attempt}/{max_retries}), повтор через {retry_interval} сек...") time.sleep(retry_interval) continue else: logger.error(f"Таймаут при отправке фото после {max_retries} попыток: {e}") return False else: # Для других ошибок не повторяем logger.error(f"Ошибка отправки фото: {e}") return False return False else: logger.warning(f"Не удалось загрузить изображение: {image_url}") # Если изображение отсутствует или не загружено, отправляем текст с повторными попытками for attempt in range(1, max_retries + 1): try: bot.send_message( chat_id, text, reply_markup=reply_markup, parse_mode='HTML' ) logger.info(f"Текстовое сообщение успешно отправлено (попытка {attempt})") return True except Exception as e: error_str = str(e) # Проверяем, является ли это таймаутом if 'timeout' in error_str.lower() or 'timed out' in error_str.lower(): if attempt < max_retries: logger.warning(f"Таймаут при отправке сообщения (попытка {attempt}/{max_retries}), повтор через {retry_interval} сек...") time.sleep(retry_interval) continue else: logger.error(f"Таймаут при отправке сообщения после {max_retries} попыток: {e}") return False else: # Для других ошибок не повторяем logger.error(f"Ошибка отправки сообщения: {e}") return False return False def send_direct_message(user_id, message_text): """Отправляет сообщение пользователю по ID с обработкой ошибок и повторными попытками при таймауте""" logger.info(f"starting send_direct_message") # Параметры для повторных попыток при таймауте max_retries = 5 retry_interval = 2 try: # Проверяем, что сообщение не пустое if not message_text or not message_text.strip(): error_msg = "Пустое сообщение" logger.warning(error_msg) log_event("error", f"user_{user_id}", message=error_msg) return False, error_msg # Отправляем сообщение с повторными попытками for attempt in range(1, max_retries + 1): try: bot.send_message( user_id, message_text, parse_mode='HTML' ) # Логируем успешную отправку log_event("info", f"user_{user_id}", message=f"Сообщение отправлено: {message_text[:50]}...") logger.info(f"Сообщение успешно отправлено пользователю {user_id} (попытка {attempt})") return True, "Сообщение отправлено успешно" except Exception as e: error_str = str(e) # Проверяем, является ли это таймаутом if 'timeout' in error_str.lower() or 'timed out' in error_str.lower(): if attempt < max_retries: logger.warning(f"Таймаут при отправке сообщения пользователю {user_id} (попытка {attempt}/{max_retries}), повтор через {retry_interval} сек...") time.sleep(retry_interval) continue else: error_msg = f"Таймаут при отправке сообщения после {max_retries} попыток: {e}" logger.error(error_msg) log_event("error", f"user_{user_id}", message=error_msg) return False, f"Таймаут при отправке сообщения. Попробуйте позже." else: # Для других ошибок выходим из цикла и обрабатываем дальше break except telebot.apihelper.ApiTelegramException as e: error_msg = f"Ошибка Telegram API: {e}" logger.error(error_msg) log_event("error", f"user_{user_id}", message=error_msg) # Определяем тип ошибки для более информативного ответа if e.error_code == 403: return False, f"Пользователь {user_id} заблокировал бота или не начал с ним диалог. Попросите пользователя отправить команду /start боту." elif e.error_code == 400: return False, f"Неверный ID пользователя: {user_id}. Проверьте правильность ID." elif e.error_code == 429: return False, f"Превышен лимит отправки сообщений для пользователя {user_id}. Попробуйте позже." elif e.error_code == 404: return False, f"Пользователь {user_id} не найден. Проверьте правильность ID." else: return False, f"Ошибка Telegram API (код {e.error_code}): {e.description}" except Exception as e: error_msg = f"Неожиданная ошибка при отправке сообщения: {e}" logger.error(error_msg) log_event("error", f"user_{user_id}", message=error_msg) return False, f"Неожиданная ошибка: {str(e)}" def send_management_message(chat_id, post_id, user_id, user_name): """Отправляет сообщение для управления подпиской на посты""" logger.info(f"starting send_management_message") try: # Проверяем статус подписки is_subscribed = is_user_subscribed(post_id, user_id) # Форматируем текст о событии post_data = format_post_message(post_id) # Создаем клавиатуру управления keyboard = create_manage_keyboard(post_id, is_subscribed) # Отправляем сообщение send_post_message( chat_id, post_data['text'], image_url=post_data['image_url'], reply_markup=keyboard ) except Exception as e: error_msg = f"Ошибка отправки сообщения управления: {e}" logger.error(error_msg) user_info = format_user_info(user_id, user_name) log_event("error", user_info, post_id, error_msg) def send_management_message_evt(chat_id, event_id, user_id, user_name): """Отправляет сообщение для управления подпиской на события""" logger.info(f"starting send_management_message_evt") try: # Проверяем статус подписки is_subscribed = is_user_subscribed_evt(event_id, user_id) # Форматируем текст о событии event_data = format_event_message(event_id) # Создаем клавиатуру управления keyboard = create_manage_keyboard_evt(event_id, is_subscribed) # Отправляем сообщение send_post_message( chat_id, event_data['text'], image_url=event_data['image_url'], reply_markup=keyboard ) except Exception as e: error_msg = f"Ошибка отправки сообщения управления (events): {e}" logger.error(error_msg) user_info = format_user_info(user_id, user_name) log_event("error", user_info, event_id, error_msg) @bot.message_handler(commands=['start']) def handle_start(message): """Обработчик команды /start с параметром""" logger.info(f"starting handle_start") try: # Извлекаем аргументы из команды /start args = message.text.split() user = message.from_user user_id = user.id user_name = get_user_name(user) if len(args) > 1: kind, entity_id, is_current = parse_start_payload(args[1]) if not is_current: send_season_ended_message(message.chat.id) return if kind == "post" and entity_id: send_management_message(message.chat.id, entity_id, user_id, user_name) return if kind == "event" and entity_id: send_management_message_evt(message.chat.id, entity_id, user_id, user_name) return # Неизвестный payload текущего сезона send_season_ended_message(message.chat.id) return # Команда /start без параметров welcome_text = get_welcome_text() # Отправляем приветственное сообщение с повторными попытками при таймауте max_retries = 5 retry_interval = 2 for attempt in range(1, max_retries + 1): try: bot.send_message( message.chat.id, welcome_text, reply_markup=create_main_keyboard(), parse_mode='HTML' ) logger.info(f"Приветственное сообщение отправлено (попытка {attempt})") break except Exception as e: error_str = str(e) if 'timeout' in error_str.lower() or 'timed out' in error_str.lower(): if attempt < max_retries: logger.warning(f"Таймаут при отправке приветственного сообщения (попытка {attempt}/{max_retries}), повтор через {retry_interval} сек...") time.sleep(retry_interval) continue else: logger.error(f"Таймаут при отправке приветственного сообщения после {max_retries} попыток: {e}") raise else: raise except Exception as e: error_msg = f"Ошибка в обработке /start: {e}" logger.error(error_msg) user_info = format_user_info(message.from_user.id, get_user_name(message.from_user)) if message.from_user else "unknown" log_event("error", user_info, message=error_msg) @bot.message_handler(commands=['home']) def handle_home(message): """Обработчик команды /home - переход в основной канал""" logger.info(f"starting handle_home") try: channel_link = format_channel_link() channel_name = "ВОЛКа" if BOT_MODE == 'VOLK' else "Зиланткона" bot.send_message( message.chat.id, f"📢 Основной канал {channel_name}\n\n", reply_markup=InlineKeyboardMarkup().row( InlineKeyboardButton("📢 Перейти в канал", url=channel_link) ), parse_mode='HTML' ) except Exception as e: error_msg = f"Ошибка в обработке /home: {e}" logger.error(error_msg) user_info = format_user_info(message.from_user.id, get_user_name(message.from_user)) if message.from_user else "unknown" log_event("error", user_info, message=error_msg) @bot.message_handler(commands=['help']) def handle_help(message): """Обработчик команды /help""" logger.info(f"starting handle_help") try: help_text = get_help_text() bot.send_message( message.chat.id, help_text, reply_markup=create_help_keyboard(), parse_mode='HTML' ) except Exception as e: error_msg = f"Ошибка в обработке /help: {e}" logger.error(error_msg) user_info = format_user_info(message.from_user.id, get_user_name(message.from_user)) if message.from_user else "unknown" log_event("error", user_info, message=error_msg) @bot.message_handler(commands=['list']) def handle_list(message): """Обработчик команды /list""" logger.info(f"starting handle_list") try: user_id = message.from_user.id user_name = get_user_name(message.from_user) # Получаем подписки на посты и события post_subscriptions = get_user_subscriptions(user_id) event_subscriptions = get_user_subscriptions_evt(user_id) if BOT_MODE == 'ZILANT' else [] # Формируем ответ response = "📋 Ваши подписки\n\n" # Добавляем подписки на посты if post_subscriptions: response += "📝 Анонсы:\n" for sub in post_subscriptions: post_id = sub['post_id'] shortname = sub['shortname'] post_link = format_channel_link(post_id) # Используем shortname если есть, иначе ID события if shortname: display_name = shortname else: # Получаем vk_post_id для отображения post_data = get_post_data(post_id) vk_post_id = post_data['vk_post_id'] if post_data else "N/A" display_name = f"Анонс ВК {vk_post_id}" # Названия постов выводим подчеркнутыми и жирным шрифтом response += f"• {display_name}\n" # Получаем связанные мероприятия (только опубликованные) - только для ZILANT if BOT_MODE == 'ZILANT': linked_events = get_linked_events_for_post(post_id) if linked_events: for event in linked_events: event_link = format_channel_link(event['tg_message_id']) response += f" └─ {event['name']}\n" response += "\n" # Добавляем подписки на события (только для ZILANT) if BOT_MODE == 'ZILANT' and event_subscriptions: response += "🎭 Мероприятия:\n" for sub in event_subscriptions: event_id = sub['event_id'] name = sub['name'] is_posted_tg = sub.get('is_posted_tg', False) is_visible = sub.get('is_visible', False) # Используем name если есть, иначе ID события display_name = name if name else f"Мероприятие {event_id}" # Проверяем статус события if is_posted_tg and is_visible: # Опубликованное и видимое событие - с ссылкой event_link = format_channel_link(event_id) response += f"• {display_name}\n" else: # Неопубликованное или невидимое событие - без ссылки и зачеркнутым response += f"• {display_name}\n" # Если нет подписок if not post_subscriptions and not event_subscriptions: response += "Вы пока не подписаны ни на одно событие.\n\n" bot.send_message( message.chat.id, response, reply_markup=create_main_keyboard(), parse_mode='HTML', disable_web_page_preview=True ) # Логируем запрос списка подписок user_info = format_user_info(user_id, user_name) log_event("list_request", user_info) except Exception as e: error_msg = f"Ошибка в обработке /list: {e}" logger.error(error_msg) user_info = format_user_info(message.from_user.id, get_user_name(message.from_user)) if message.from_user else "unknown" log_event("error", user_info, message=error_msg) @bot.callback_query_handler(func=lambda call: True) def handle_callback(call): """Обработчик нажатий на кнопки""" logger.info(f"starting handle_callback") try: # Обработка командных кнопок if call.data.startswith("cmd_"): command = call.data[4:] if command == "help": help_text = get_help_text() bot.send_message( call.message.chat.id, help_text, reply_markup=create_help_keyboard(), parse_mode='HTML' ) elif command == "list": user = call.from_user user_id = user.id user_name = get_user_name(user) # Получаем подписки на посты и события post_subscriptions = get_user_subscriptions(user_id) event_subscriptions = get_user_subscriptions_evt(user_id) if BOT_MODE == 'ZILANT' else [] # Формируем ответ response = "📋 Ваши подписки\n\n" # Добавляем подписки на посты if post_subscriptions: response += "📝 Анонсы:\n" for sub in post_subscriptions: post_id = sub['post_id'] shortname = sub['shortname'] post_link = format_channel_link(post_id) # Используем shortname если есть, иначе ID события if shortname: display_name = shortname else: # Получаем vk_post_id для отображения post_data = get_post_data(post_id) vk_post_id = post_data['vk_post_id'] if post_data else "N/A" display_name = f"Анонс ВК {vk_post_id}" # Названия постов выводим подчеркнутыми и жирным шрифтом response += f"• {display_name}\n" # Получаем связанные мероприятия (только опубликованные) - только для ZILANT if BOT_MODE == 'ZILANT': linked_events = get_linked_events_for_post(post_id) if linked_events: for event in linked_events: event_link = format_channel_link(event['tg_message_id']) response += f" └─ {event['name']}\n" response += "\n" # Добавляем подписки на события (только для ZILANT) if BOT_MODE == 'ZILANT' and event_subscriptions: response += "🎭 Мероприятия:\n" for sub in event_subscriptions: event_id = sub['event_id'] name = sub['name'] is_posted_tg = sub.get('is_posted_tg', False) is_visible = sub.get('is_visible', False) # Используем name если есть, иначе ID события display_name = name if name else f"Мероприятие {event_id}" # Проверяем статус события if is_posted_tg and is_visible: # Опубликованное и видимое событие - с ссылкой event_link = format_channel_link(event_id) response += f"• {display_name}\n" else: # Неопубликованное или невидимое событие - без ссылки и зачеркнутым response += f"• {display_name}\n" # Если нет подписок if not post_subscriptions and not event_subscriptions: response += "Вы пока не подписаны ни на одно событие.\n\n" bot.send_message( call.message.chat.id, response, reply_markup=create_main_keyboard(), parse_mode='HTML', disable_web_page_preview=True ) # Логируем запрос списка подписок user_info = format_user_info(user_id, user_name) log_event("list_request", user_info) elif command == "back": # Возврат к главному меню welcome_text = get_welcome_text() bot.send_message( call.message.chat.id, welcome_text, reply_markup=create_main_keyboard(), parse_mode='HTML' ) # Подтверждаем получение callback bot.answer_callback_query(call.id) return # Обработка кнопок управления подпиской на посты action, post_id, is_current = parse_post_callback(call.data) if action and post_id: if not is_current: bot.answer_callback_query(call.id, "Этот сезон уже завершён") send_season_ended_message(call.message.chat.id) return user = call.from_user user_id = user.id user_name = get_user_name(user) if not get_post_data(post_id): bot.answer_callback_query(call.id, "Анонс не найден") bot.send_message( call.message.chat.id, "❌ Анонс не найден в базе данных текущего сезона.", reply_markup=create_season_ended_keyboard(), ) return if action == "subscribe": save_mark_to_db(post_id, user_id, user_name) result_text = "✅ Вы успешно подписались!" else: remove_mark_from_db(post_id, user_id, user_name) result_text = "✅ Вы успешно отписались!" # Обновляем сообщение с новым статусом try: # Получаем текущие данные о событии post_data = format_post_message(post_id) text = post_data['text'] # Создаем новую клавиатуру is_subscribed = action == "subscribe" keyboard = create_manage_keyboard(post_id, is_subscribed) # Для сообщений с изображением if call.message.content_type == 'photo': # Получаем file_id существующего изображения file_id = call.message.photo[-1].file_id # Редактируем подпись к изображению bot.edit_message_caption( chat_id=call.message.chat.id, message_id=call.message.message_id, caption=text, reply_markup=keyboard, parse_mode='HTML' ) else: # Редактируем текстовое сообщение bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.message_id, text=text, reply_markup=keyboard, parse_mode='HTML' ) # Отправляем отдельное сообщение о результате bot.answer_callback_query(call.id, result_text) except Exception as e: logger.error(f"Ошибка обновления сообщения: {e}") bot.answer_callback_query(call.id, f"{result_text} Но не удалось обновить сообщение.") return # Обработка кнопок управления подпиской на события action, event_id, is_current = parse_event_callback(call.data) if action and event_id: if not is_current: bot.answer_callback_query(call.id, "Этот сезон уже завершён") send_season_ended_message(call.message.chat.id) return user = call.from_user user_id = user.id user_name = get_user_name(user) if not get_event_data(event_id): bot.answer_callback_query(call.id, "Событие не найдено") bot.send_message( call.message.chat.id, "❌ Событие не найдено в базе данных текущего сезона.", reply_markup=create_season_ended_keyboard(), ) return if action == "subscribe": save_mark_to_db_evt(event_id, user_id, user_name) result_text = "✅ Вы успешно подписались!" else: remove_mark_from_db_evt(event_id, user_id, user_name) result_text = "✅ Вы успешно отписались!" try: event_data = format_event_message(event_id) text = event_data['text'] is_subscribed = action == "subscribe" keyboard = create_manage_keyboard_evt(event_id, is_subscribed) if call.message.content_type == 'photo': bot.edit_message_caption( chat_id=call.message.chat.id, message_id=call.message.message_id, caption=text, reply_markup=keyboard, parse_mode='HTML' ) else: bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.message_id, text=text, reply_markup=keyboard, parse_mode='HTML' ) bot.answer_callback_query(call.id, result_text) except Exception as e: logger.error(f"Ошибка обновления сообщения (events): {e}") bot.answer_callback_query(call.id, f"{result_text} Но не удалось обновить сообщение.") return # Подтверждаем получение callback для любых других нажатий bot.answer_callback_query(call.id) except Exception as e: error_msg = f"Ошибка обработки callback: {e}" logger.error(error_msg) user_info = format_user_info(call.from_user.id, get_user_name(call.from_user)) if call.from_user else "unknown" log_event("error", user_info, message=error_msg) bot.answer_callback_query(call.id, "Произошла ошибка. Пожалуйста, попробуйте позже.") def setup_webhook(): """ Регистрация webhook через internal_relay. Важно: relay перехватывает setWebhook и уносит secret_token в Telegram через extra_params. Параметры нужно слать как application/x-www-form-urlencoded (так делает echobot/PTB и так разбирает relay после фикса 0.1.1). """ logger.info("starting setup_webhook") if not WEBHOOK_SECRET: raise RuntimeError( "WEBHOOK_SECRET не задан — без него Telegram не будет присылать " "X-Telegram-Bot-Api-Secret-Token" ) if not WEBHOOK_URL: raise RuntimeError("WEBHOOK_URL не задан") api_url = bot_api_method_url(RESPONDER_BOT_TOKEN, "setWebhook") # Все значения — строки (form-urlencoded), как ожидает parse_qs на relay. # allowed_updates обязательно: если не указать, Telegram сохраняет прежнюю # настройку — часто без callback_query, и тогда кнопки «Подписаться»/«Справка» молчат. form = { "url": WEBHOOK_URL, "secret_token": WEBHOOK_SECRET, "max_connections": "40", "allowed_updates": json.dumps([ "message", "edited_message", "callback_query", ]), } logger.info( f"setWebhook через relay: delivery_url={WEBHOOK_URL}, " f"secret_len={len(WEBHOOK_SECRET)}, relay={get_relay_url()}" ) last_error = None for attempt in range(1, 6): try: resp = requests.post( api_url, data=form, timeout=60, ) try: data = resp.json() except Exception: data = {"ok": False, "description": (resp.text or "")[:500]} logger.info(f"setWebhook ответ HTTP {resp.status_code}: {data}") desc = str(data.get("description", "")) if isinstance(data, dict) else "" if resp.status_code == 429 or "retry after" in desc.lower(): raise RuntimeError(desc or f"HTTP {resp.status_code}") if not (isinstance(data, dict) and data.get("ok")): raise RuntimeError(f"setWebhook failed: HTTP {resp.status_code} {data}") logger.info( f"Вебхук установлен через ретранслятор {get_relay_url()}: " f"локальная доставка {WEBHOOK_URL}" ) logger.info(f"Секретный токен: установлен (len={len(WEBHOOK_SECRET)})") logger.info(f"Прослушивание порта: {WEBHOOK_PORT}") logger.info(f"Путь вебхука: {WEBHOOK_PATH}") log_event("system", "system", message=f"Вебхук установлен на {WEBHOOK_URL}") return except Exception as e: last_error = e retry_after = _webhook_retry_after(e) if retry_after is not None and attempt < 5: logger.warning( f"setup_webhook: попытка {attempt}/5 — {e}; ждём {retry_after} с" ) time.sleep(retry_after) continue break error_msg = f"Ошибка настройка вебхука: {last_error}" logger.error(error_msg) log_event("error", "system", message=error_msg) raise RuntimeError(error_msg) from last_error def _webhook_retry_after(exc): """Извлекает секунды ожидания из 429 / 'retry after N', иначе None.""" text = str(exc) if "429" not in text and "Too Many Requests" not in text and "retry after" not in text.lower(): return None import re match = re.search(r"retry after (\d+)", text, re.IGNORECASE) if match: return max(int(match.group(1)), 1) + 1 return 2 def _read_telegram_secret_header(): """Читает секрет из заголовка (Flask headers и сырой WSGI environ).""" return ( request.headers.get("X-Telegram-Bot-Api-Secret-Token") or request.environ.get("HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN") ) # Используем путь из WEBHOOK_URL @app.route(WEBHOOK_PATH, methods=['POST']) def webhook(): logger.info("starting webhook") received_secret = _read_telegram_secret_header() if received_secret != WEBHOOK_SECRET: related = [ k for k in request.headers.keys() if "secret" in k.lower() or "telegram" in k.lower() or k.lower().startswith("x-") ] if not received_secret: logger.warning( "Секретный токен отсутствует в заголовке X-Telegram-Bot-Api-Secret-Token; " f"IP={request.remote_addr}; related_headers={related}" ) else: logger.warning( "Секретный токен не совпадает " f"(получен len={len(received_secret)}, ожидается len={len(WEBHOOK_SECRET or '')}); " f"related_headers={related}" ) log_event("security", "system", message="Попытка доступа с неверным секретным токеном") return "Unauthorized", 401 json_data = request.get_json() try: update = telebot.types.Update.de_json(json_data) bot.process_new_updates([update]) return "OK", 200 except Exception as e: error_msg = f"Ошибка обработки вебхука: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return "Internal Server Error", 500 @app.route('/send_message', methods=['POST']) @app.route(f'{WEBHOOK_PATH}/send_message', methods=['POST']) def send_message_api(): """API endpoint для отправки сообщений через бота""" logger.info(f"starting send_message_api") # Проверяем секретный токен для защиты API api_token = request.headers.get('X-API-Secret-Token') if api_token != WEBHOOK_SECRET: logger.warning("Неверный API токен!") log_event("security", "system", message="Попытка доступа к API с неверным токеном") return {"success": False, "error": "Unauthorized", "error_code": "INVALID_TOKEN"}, 401 try: data = request.get_json() if not data: return {"success": False, "error": "Отсутствуют данные", "error_code": "NO_DATA"}, 400 user_id = data.get('user_id') message_text = data.get('message') if not user_id: return {"success": False, "error": "Не указан user_id", "error_code": "NO_USER_ID"}, 400 if not message_text: return {"success": False, "error": "Не указан текст сообщения", "error_code": "NO_MESSAGE"}, 400 # Проверяем, что user_id является числом try: user_id = int(user_id) except (ValueError, TypeError): return {"success": False, "error": "user_id должен быть числом", "error_code": "INVALID_USER_ID"}, 400 # Отправляем сообщение success, result_message = send_direct_message(user_id, message_text) if success: return {"success": True, "message": result_message, "user_id": user_id}, 200 else: return {"success": False, "error": result_message, "user_id": user_id, "error_code": "TELEGRAM_ERROR"}, 400 except Exception as e: error_msg = f"Ошибка в API отправки сообщений: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) return {"success": False, "error": "Внутренняя ошибка сервера", "error_code": "INTERNAL_ERROR"}, 500 if __name__ == '__main__': # Логируем запуск бота logger.info(f"starting main") log_event("system", "system", message="Бот запущен") # Настраиваем вебхук try: setup_webhook() except Exception: exit(1) # Логируем запуск Flask logger.info(f"starting Flask") log_event("system", "system", message=f"Flask приложение запущено на порту {WEBHOOK_PORT}, путь: {WEBHOOK_PATH}") # Запускаем Flask приложение app.run(host='0.0.0.0', port=WEBHOOK_PORT)