Files
Zilant2025/evtg_publish.py
T
2025-12-21 15:06:30 +03:00

412 lines
20 KiB
Python

import os
import logging
import asyncio
import pymysql
import time
import html
from datetime import datetime, timezone, timedelta
from telegram import Bot, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.error import TelegramError, RetryAfter
from dotenv import load_dotenv
from formatter import get_event_text
# Загрузка переменных окружения
load_dotenv()
# Настройки из переменных окружения
BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
CHANNEL_ID = os.getenv('CHANNEL_ID')
MDB_HOST = os.getenv('MDB_HOST')
MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE')
DESC_PREFIX = os.getenv('DESC_PREFIX')
PZK_PREFIX = os.getenv('PZK_PREFIX')
LOG_FILE = os.getenv('LOG_FILE', 'evtg_publisher.log')
EVENT_POST_DELAY = int(os.getenv('EVENT_POST_DELAY', 0)) # Задержка в минутах
WORKMODE = os.getenv('WORKMODE', 'ZILANT') # Режим работы: ZILANT или VOLK
# Параметры длины сообщений
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
# Режим публикации без звука
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
# Использование бота подписки
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
# Настройка логирования
logger = logging.getLogger('TG_EV_post')
logger.setLevel(logging.INFO)
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)
def format_tags(tags_str):
"""Форматирование тегов с добавлением #"""
if not tags_str:
return ""
tags = tags_str.split()
formatted_tags = []
for tag in tags:
if len(tag) > 1 and not tag.startswith('#'):
formatted_tags.append(f"#{tag}")
else:
formatted_tags.append(tag)
return " ".join(formatted_tags)
def prepare_text(text):
"""Подготовка текстовых полей с экранированием HTML-сущностей"""
if not text:
return ""
# Экранируем специальные символы HTML
text = html.escape(str(text))
return text
async def tg_post_event(bot, event_data):
"""Публикация одного события"""
try:
# Форматирование текста
tags = format_tags(event_data['tags'])
unit_name = prepare_text(event_data['unit_name'] or "")
name = prepare_text(event_data['name'] or "")
# Получаем и подготавливаем описание события
about_raw = event_data['about']
# Формируем базовый текст (теги + название площадки + название события)
caption_parts = []
if tags:
caption_parts.append(tags)
if unit_name:
caption_parts.append(unit_name)
if name:
caption_parts.append(f"<b>{name}</b>")
base_text = "\n".join(caption_parts)
base_text_length = len(base_text)
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
number = event_data['number']
placeholder_message_id = '0' * 20 # Заполнитель для message_id
# Формируем ссылки в зависимости от USE_SUBSCRIPTION_BOT
if USE_SUBSCRIPTION_BOT:
links_line_placeholder = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{placeholder_message_id}">Подписка</a>'
else:
links_line_placeholder = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>'
links_line_length = len(links_line_placeholder)
# Определяем максимальную длину в зависимости от типа сообщения
image_url = event_data['about_social_picture']
has_image = image_url and image_url.strip()
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
# Вычисляем доступную длину для текста события
available_length = max_length - base_text_length - links_line_length - 3 # -3 для символов переноса строки
if available_length < 0:
available_length = 0
# Получаем обработанный текст с учетом доступной длины
about = get_event_text(about_raw, available_length) if about_raw else ""
# Формируем первоначальный текст
initial_text = f"{base_text}\n\n{about}" if about else base_text
# Логируем текст для отладки
logger.info(f"Текст для публикации события номер {event_data['number']}: {initial_text}")
# Публикация первоначального сообщения без ссылок
if has_image:
try:
message = await bot.send_photo(
chat_id=CHANNEL_ID,
photo=image_url,
caption=initial_text,
parse_mode="HTML",
disable_notification=PUBLISH_SILENTLY
)
except TelegramError as e:
logger.warning(f"Не удалось отправить изображение для события номер {event_data['number']}: {e}. Отправляем текстовое сообщение.")
logger.error(f"Полная информация об ошибке Telegram при отправке изображения для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=initial_text,
parse_mode="HTML",
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True # Отключаем предпросмотр ссылок
)
else:
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=initial_text,
parse_mode="HTML",
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True # Отключаем предпросмотр ссылок
)
# Получаем ID сообщения для использования в ссылке подписки
message_id = message.message_id
# Формируем финальный текст с ссылками
if USE_SUBSCRIPTION_BOT:
links_line = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{message_id}">Подписка</a>'
else:
links_line = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>'
final_text = f"{initial_text}\n\n{links_line}"
await asyncio.sleep(2)
# Редактируем сообщение, добавляя ссылки
if has_image:
try:
await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode="HTML"
)
except TelegramError as e:
logger.warning(f"Не удалось отредактировать подпись для события номер {event_data['number']}: {e}. Пробуем отредактировать текстовое сообщение.")
logger.error(f"Полная информация об ошибке Telegram при редактировании подписи для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode="HTML",
disable_web_page_preview=True # Отключаем предпросмотр ссылок
)
else:
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode="HTML",
disable_web_page_preview=True # Отключаем предпросмотр ссылок
)
return message_id # Возвращаем только message_id
except RetryAfter as e:
# Обработка ошибки FloodWait (429)
logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}: {e}")
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
logger.error(f"Полная информация об ошибке RetryAfter для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
raise # Пробрасываем исключение выше для обработки на уровне цикла
except TelegramError as e:
logger.error(f"Ошибка Telegram при публикации события номер {event_data['number']}: {e}")
logger.error(f"Полная информация об ошибке Telegram для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
return None
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации события номер {event_data['number']}: {e}")
return None
async def tg_post_event_by_id(id_event):
"""Публикация события по ID"""
logger.info(f"Запуск публикации для события ID {id_event}")
conn = None
try:
conn = pymysql.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
# Проверка времени изменения только в режиме ZILANT
with conn.cursor() as cursor:
if WORKMODE == "ZILANT":
# Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
cursor.execute("""
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
FROM events
WHERE id_event = %s
AND marked_to_publication = True
AND is_posted_tg = False
AND modified <= %s
""", (id_event, modified_before))
event = cursor.fetchone()
if not event:
logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад")
return
else:
# В режиме VOLK проверка времени не выполняется
cursor.execute("""
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
FROM events
WHERE id_event = %s
AND marked_to_publication = True
AND is_posted_tg = False
""", (id_event,))
event = cursor.fetchone()
if not event:
logger.info(f"Событие ID {id_event} не найдено или уже опубликовано")
return
old_message_id = event.get('tg_message_id') # Сохраняем старый ID сообщения
bot = Bot(token=BOT_TOKEN)
tg_message_id = await tg_post_event(bot, event) # Получаем новый message_id
if tg_message_id:
# Используем UTC время для записи в базу данных
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Обновляем подписки если был предыдущий message_id
if old_message_id:
cursor.execute("""
UPDATE marks_evt
SET tg_event_id = %s
WHERE tg_event_id = %s
""", (tg_message_id, old_message_id))
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
cursor.execute("""
UPDATE events
SET is_posted_tg = True,
tg_message_id = %s,
tg_posted_date = %s
WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event))
conn.commit()
logger.info(f"Событие номер {event['number']} успешно опубликовано")
else:
logger.error(f"Не удалось опубликовать событие ID {id_event}")
except RetryAfter as e:
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
raise e
except Exception as e:
logger.error(f"Ошибка при публикации: {e}")
logger.error(f"Полная информация об ошибке в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
finally:
if conn:
conn.close()
async def tg_post_all_events():
"""Публикация всех неопубликованных событий (максимум 10 за один вызов)"""
logger.info("Запуск скрипта публикации всех событий")
conn = None
try:
conn = pymysql.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cursor:
# Проверка времени изменения только в режиме ZILANT
if WORKMODE == "ZILANT":
# Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
cursor.execute("""
SELECT id_event
FROM events
WHERE marked_to_publication = True
AND is_posted_tg = False
AND modified <= %s
LIMIT 5
""", (modified_before,))
else:
# В режиме VOLK проверка времени не выполняется
cursor.execute("""
SELECT id_event
FROM events
WHERE marked_to_publication = True
AND is_posted_tg = False
LIMIT 5
""")
events = cursor.fetchall()
event_count = 0
for event in events:
try:
await tg_post_event_by_id(event['id_event'])
event_count += 1
# Задержка между публикациями разных событий
if event_count < len(events):
await asyncio.sleep(2)
except RetryAfter as e:
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_all_events для события ID {event['id_event']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
break
except Exception as e:
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
logger.error(f"Полная информация об ошибке в tg_post_all_events для события ID {event['id_event']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
# Продолжаем публикацию следующих событий, несмотря на ошибку
logger.info(f"Опубликовано событий в этом запуске: {event_count}")
except Exception as e:
logger.error(f"Ошибка при публикации: {e}")
finally:
if conn:
conn.close()
logger.info("Завершение работы скрипта")
if __name__ == "__main__":
asyncio.run(tg_post_all_events())