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
# Режим работы бота (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():
"""Возвращает приветственный текст в зависимости от режима"""
if BOT_MODE == 'VOLK':
return (
"🌟 Привет! 🌟\n\n"
"Я - информационный бот ВОЛКа. "
"Здесь можно подписаться на интересные события будущего конвента - и потом напомнить самому себе, "
"что именно показалось интересным.\n\n"
)
else: # ZILANT
return (
"🌟 Привет! 🌟\n\n"
"Я - информационный бот Зиланткона. "
"Здесь можно подписаться на интересные события будущего Зиланта - и я напомню,"
"когда их можно будет включить в свой План Захвата Конвента.\n\n"
)
def 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'
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 = get_welcome_text()
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()
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"""
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"""
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):
"""Обработчик нажатий на кнопки"""
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
# Обработка кнопок управления подпиской на посты
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)