import os import logging import time import errno from dotenv import load_dotenv import telebot 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 # Создаем Flask app на верхнем уровне для экспорта app = Flask(__name__) # Загружаем переменные окружения из файла .env load_dotenv() # Настройка логирования logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO ) logger = logging.getLogger(__name__) # Получаем данные из переменных окружения 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') LOG_FILE = os.getenv('LOG_FILE', 'bot.log') MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000)) MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000)) # Устанавливаем единое ограничение длины текста UNIFIED_MAX_LENGTH = 800 # Извлекаем путь из 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' 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, "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) # Создаем экземпляр бота bot = telebot.TeleBot(RESPONDER_BOT_TOKEN) def get_user_name(user): """Форматирует имя пользователя для записи в БД""" 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""" 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): """Проверяет, подписан ли пользователь уже на это событие""" 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)""" 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""" 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): """Возвращает список событий, на которые подписан пользователь, с информацией о статусе""" 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""" 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): """Сохраняет отметку пользователя в базе данных""" 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)""" 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): """Удаляет отметку пользователя из базы данных""" 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)""" 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): """Форматирует ссылку на событие в канале или на сам канал""" 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 create_help_keyboard(): """Создает клавиатуру для справки""" keyboard = InlineKeyboardMarkup() keyboard.row( InlineKeyboardButton("📜 Подписки", callback_data="cmd_list"), InlineKeyboardButton("📢 Канал", url=format_channel_link()) ) return keyboard def create_main_keyboard(): """Создает основную клавиатуру для /start без параметров""" keyboard = InlineKeyboardMarkup() keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("📢 Канал", url=format_channel_link()) ) return keyboard def create_manage_keyboard(post_id, is_subscribed): """Создает клавиатуру для управления подпиской на посты""" keyboard = InlineKeyboardMarkup() channel_link = format_channel_link(post_id) if is_subscribed: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❌ Отписаться", callback_data=f"unsubscribe_{post_id}"), InlineKeyboardButton("↪ Назад", url=channel_link) ) else: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("✔ Подписаться", callback_data=f"subscribe_{post_id}"), InlineKeyboardButton("↪ Назад", url=channel_link) ) return keyboard def create_manage_keyboard_evt(event_id, is_subscribed): """Создает клавиатуру для управления подпиской на события""" keyboard = InlineKeyboardMarkup() channel_link = format_channel_link(event_id) if is_subscribed: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❌ Отписаться", callback_data=f"unsubscribe_evt_{event_id}"), InlineKeyboardButton("↪ Назад", url=channel_link) ) else: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("✔ Подписаться", callback_data=f"subscribe_evt_{event_id}"), InlineKeyboardButton("↪ Назад", url=channel_link) ) return keyboard def get_post_data(tg_message_id): """Получает данные о событии по его tg_message_id""" 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""" 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""" 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""" 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""" 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): """Отправляет сообщение с изображением или текстом""" try: # Дополнительная обрезка текста if len(text) > UNIFIED_MAX_LENGTH: text = text[:UNIFIED_MAX_LENGTH - 3] + "..." if image_url: # Загружаем изображение image = download_image(image_url) if image: # Отправляем фото с подписью bot.send_photo( chat_id, photo=image, caption=text, reply_markup=reply_markup, parse_mode='HTML' ) return True else: logger.warning(f"Не удалось загрузить изображение: {image_url}") # Если изображение отсутствует или не загружено, отправляем текст bot.send_message( chat_id, text, reply_markup=reply_markup, parse_mode='HTML' ) return True except Exception as e: logger.error(f"Ошибка отправки сообщения: {e}") return False def send_direct_message(user_id, message_text): """Отправляет сообщение пользователю по ID с обработкой ошибок""" 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 # Отправляем сообщение 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}") return True, "Сообщение отправлено успешно" 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): """Отправляет сообщение для управления подпиской на посты""" 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): """Отправляет сообщение для управления подпиской на события""" 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 с параметром""" try: # Извлекаем аргументы из команды /start args = message.text.split() user = message.from_user user_id = user.id user_name = get_user_name(user) if len(args) > 1: # Обработка команды подписки на посты if args[1].startswith('post_'): post_id = args[1].split('_')[1] # Отправляем сообщение управления подпиской send_management_message(message.chat.id, post_id, user_id, user_name) return # Обработка команды подписки на события elif args[1].startswith('event_'): event_id = args[1].split('_')[1] # Отправляем сообщение управления подпиской send_management_message_evt(message.chat.id, event_id, user_id, user_name) return # Команда /start без параметров welcome_text = ( "🌟 Привет! 🌟\n\n" "Я - информационный бот Зиланткона. " "Здесь можно подписаться на интересные события будущего Зиланта - и я напомню," "когда их можно будет включить в свой План Захвата Конвента.\n\n" ) bot.send_message( message.chat.id, welcome_text, reply_markup=create_main_keyboard(), parse_mode='HTML' ) 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 - переход в основной канал""" try: channel_link = format_channel_link() bot.send_message( message.chat.id, "📢 Основной канал Зиланткона\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""" try: help_text = ( "ℹ️ Подсказка информ-бота Зиланткона\n\n" "Я запоминаю, кто чем интересовался, и напоминаю, " "когда это становится можно включить в План Захвата.\n\n" "Доступные команды:\n" "• /start - начать работу с ботом (вы уже здесь!)\n" "• /help - показать эту справку\n" "• /home - перейти в основной канал Зиланткона\n" "• /list - показать текущие подписки\n\n" "Используйте кнопки ниже для быстрого доступа:" ) 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""" 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) # Формируем ответ 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" # Получаем связанные мероприятия (только опубликованные) 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" # Добавляем подписки на события if 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): """Обработчик нажатий на кнопки""" try: # Обработка командных кнопок if call.data.startswith("cmd_"): command = call.data[4:] if command == "help": help_text = ( "ℹ️ Подсказка информ-бота Зиланткона\n\n" "Я запоминаю, кто чем интересовался, и напоминаю, " "когда это становится можно включить в План Захвата.\n\n" "Доступные команды:\n" "• /start - начать работу с ботом (вы уже здесь!)\n" "• /help - показать эту справку\n" "• /home - перейти в основной канал Зиланткона\n" "• /list - показать текущие подписки\n\n" "Используйте кнопки ниже для быстрого доступа:" ) 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) # Формируем ответ 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" # Получаем связанные мероприятия (только опубликованные) 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" # Добавляем подписки на события if 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 = ( "🌟 Привет! 🌟\n\n" "Я - информационный бот Зиланткона. " "Здесь можно подписаться на интересные события будущего Зиланта - и я напомню," "когда их можно будет включить в свой План Захвата Конвента.\n\n" ) 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 # Обработка кнопок управления подпиской на посты if (call.data.startswith("subscribe_") or call.data.startswith("unsubscribe_")) and not call.data.startswith(("subscribe_evt_", "unsubscribe_evt_")): user = call.from_user user_id = user.id user_name = get_user_name(user) # Разделяем данные callback parts = call.data.split('_', 1) action = parts[0] post_id = parts[1] # Выполняем действие 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 # Обработка кнопок управления подпиской на события if call.data.startswith("subscribe_evt_") or call.data.startswith("unsubscribe_evt_"): user = call.from_user user_id = user.id user_name = get_user_name(user) parts = call.data.split('_', 2) action = parts[0] event_id = parts[2] # Выполняем действие 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': # Получаем 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"Ошибка обновления сообщения (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(): """Настройка вебхука""" try: # Удаляем предыдущий вебхук bot.remove_webhook() # Устанавливаем новый вебхук bot.set_webhook( url=WEBHOOK_URL, secret_token=WEBHOOK_SECRET, max_connections=40 ) logger.info(f"Вебхук установлен: {WEBHOOK_URL}") logger.info(f"Секретный токен: {'установлен' if WEBHOOK_SECRET else 'не установлен'}") logger.info(f"Прослушивание порта: {WEBHOOK_PORT}") logger.info(f"Путь вебхука: {WEBHOOK_PATH}") # Логируем успешную настройку вебхука log_event("system", "system", message=f"Вебхук установлен на {WEBHOOK_URL}") except Exception as e: error_msg = f"Ошибка настройка вебхука: {e}" logger.error(error_msg) log_event("error", "system", message=error_msg) exit(1) # Используем путь из WEBHOOK_URL @app.route(WEBHOOK_PATH, methods=['POST']) def webhook(): if request.headers.get('X-Telegram-Bot-Api-Secret-Token') != WEBHOOK_SECRET: logger.warning("Неверный секретный токен!") 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 для отправки сообщений через бота""" # Проверяем секретный токен для защиты 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__': # Логируем запуск бота log_event("system", "system", message="Бот запущен") # Настраиваем вебхук setup_webhook() # Логируем запуск Flask log_event("system", "system", message=f"Flask приложение запущено на порту {WEBHOOK_PORT}, путь: {WEBHOOK_PATH}") # Запускаем Flask приложение app.run(host='0.0.0.0', port=WEBHOOK_PORT)