from __future__ import annotations import shlex import logging from datetime import datetime from typing import Optional, Dict, List, Any, Union, cast import time from urllib.parse import urlparse from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Message, CallbackQuery from telegram.ext import ( Application, CommandHandler, MessageHandler, ConversationHandler, CallbackQueryHandler, CallbackContext, filters, ) from telegram.error import TelegramError from apscheduler.schedulers.asyncio import AsyncIOScheduler from sqlalchemy import select from app.core.config import settings from app.tasks.senders import send_post_task from app.tasks.celery_app import celery_app from celery import shared_task from app.db.session import async_session_maker from app.models.channel import Channel from app.models.post import PostType from app.services.templates import ( render_template_by_name, list_templates, count_templates, create_template, delete_template, required_variables_of_template, ) from jinja2 import TemplateError # Настройка логирования logger = logging.getLogger(__name__) # Константы MAX_MESSAGE_LENGTH = 4096 PAGE_SIZE = 8 SESSION_TIMEOUT = 3600 # 1 час ALLOWED_URL_SCHEMES = ('http', 'https', 't.me') # Состояния диалога ( CHOOSE_CHANNEL, CHOOSE_TYPE, CHOOSE_FORMAT, ENTER_TEXT, ENTER_MEDIA, EDIT_KEYBOARD, CONFIRM_SEND, ENTER_SCHEDULE, SELECT_TEMPLATE, PREVIEW_VARS, PREVIEW_CONFIRM, TPL_NEW_NAME, TPL_NEW_TYPE, TPL_NEW_FORMAT, TPL_NEW_CONTENT, TPL_NEW_KB ) = range(16) # In-memory сессии с метаданными session: Dict[int, Dict[str, Any]] = {} def validate_url(url: str) -> bool: """Проверка безопасности URL. Args: url: Строка URL для проверки Returns: bool: True если URL безопасен, False в противном случае """ try: result = urlparse(url) return all([ result.scheme in ALLOWED_URL_SCHEMES, result.netloc, len(url) < 2048 # Максимальная длина URL ]) except Exception as e: logger.warning(f"URL validation failed: {e}") return False def validate_message_length(text: str) -> bool: """Проверка длины сообщения согласно лимитам Telegram. Args: text: Текст для проверки Returns: bool: True если длина в пределах лимита """ return len(text) <= MAX_MESSAGE_LENGTH def update_session_activity(uid: int) -> None: """Обновление времени последней активности в сессии.""" if uid in session: session[uid]['last_activity'] = time.time() def cleanup_old_sessions() -> None: """Периодическая очистка старых сессий.""" current_time = time.time() for uid in list(session.keys()): if current_time - session[uid].get('last_activity', 0) > SESSION_TIMEOUT: logger.info(f"Cleaning up session for user {uid}") del session[uid] def parse_template_invocation(s: str) -> tuple[str, dict]: """Разбор строки вызова шаблона. Args: s: Строка в формате #template_name key1=value1 key2=value2 Returns: tuple: (имя_шаблона, словарь_параметров) Raises: ValueError: Если неверный формат строки """ s = s.strip() if not s.startswith("#"): raise ValueError("Имя шаблона должно начинаться с #") parts = shlex.split(s) if not parts: raise ValueError("Пустой шаблон") name = parts[0][1:] args: dict[str, str] = {} for tok in parts[1:]: if "=" in tok: k, v = tok.split("=", 1) args[k.strip()] = v.strip() return name, args def parse_key_value_lines(text: str) -> dict: """Парсинг строк формата key=value. Args: text: Строки в формате key=value или key="quoted value" Returns: dict: Словарь параметров """ text = (text or "").strip() if not text: return {} out = {} if "\n" in text: for line in text.splitlines(): if "=" in line: k, v = line.split("=", 1) v = v.strip().strip('"') if k.strip(): # Проверка на пустой ключ out[k.strip()] = v else: try: for tok in shlex.split(text): if "=" in tok: k, v = tok.split("=", 1) if k.strip(): # Проверка на пустой ключ out[k.strip()] = v except ValueError as e: logger.warning(f"Error parsing key-value line: {e}") return out async def choose_type(update: Update, context: CallbackContext) -> int: """Обработчик выбора типа поста.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: return ConversationHandler.END user_id = query.from_user.id type_choice = query.data.split(":")[1] if user_id not in session: session[user_id] = {} session[user_id]["type"] = type_choice update_session_activity(user_id) await query.edit_message_text("Выберите формат сообщения") return CHOOSE_FORMAT async def choose_format(update: Update, context: CallbackContext) -> int: """Обработчик выбора формата сообщения.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: return ConversationHandler.END user_id = query.from_user.id format_choice = query.data.split(":")[1] if user_id not in session: session[user_id] = {} session[user_id]["format"] = format_choice update_session_activity(user_id) await query.edit_message_text("Введите текст сообщения") return ENTER_TEXT async def enter_text(update: Update, context: CallbackContext) -> int: """Обработчик ввода текста сообщения.""" message = cast(Message, update.message) if not message or not message.text: return ConversationHandler.END user_id = message.from_user.id text = message.text if not validate_message_length(text): await message.reply_text("Слишком длинное сообщение") return ENTER_TEXT if user_id not in session: session[user_id] = {} session[user_id]["text"] = text update_session_activity(user_id) await message.reply_text("Текст сохранен. Введите ID медиафайла или пропустите") return ENTER_MEDIA async def enter_media(update: Update, context: CallbackContext) -> int: """Обработчик ввода медиафайла.""" message = cast(Message, update.message) if not message or not message.text: return ConversationHandler.END user_id = message.from_user.id if user_id is None: return ConversationHandler.END media_id = message.text if user_id not in session: session[user_id] = {} session[user_id]["media_id"] = media_id if media_id != "skip" else None update_session_activity(user_id) await message.reply_text("Введите клавиатуру или пропустите") return EDIT_KEYBOARD async def edit_keyboard(update: Update, context: CallbackContext) -> int: """Обработчик редактирования клавиатуры.""" message = cast(Message, update.message) if not message or not message.text: return ConversationHandler.END user_id = message.from_user.id if user_id is None: return ConversationHandler.END keyboard_text = message.text if user_id not in session: session[user_id] = {} if keyboard_text != "skip": try: keyboard_data = parse_key_value_lines(keyboard_text) if keyboard_text else {} session[user_id]["keyboard"] = keyboard_data except ValueError as e: await message.reply_text(f"Ошибка разбора клавиатуры: {e}") return EDIT_KEYBOARD update_session_activity(user_id) await message.reply_text("Подтвердите отправку") return CONFIRM_SEND async def confirm_send(update: Update, context: CallbackContext) -> int: """Обработчик подтверждения отправки.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data or not query.message: return ConversationHandler.END user = update.effective_user if not user: await query.answer("Ошибка: пользователь не определен") return ConversationHandler.END try: user_id = user.id choice = query.data.split(":", 1)[1] if choice == "yes": data = session.get(user_id) if not data: await query.edit_message_text("Ошибка: сессия потеряна") return ConversationHandler.END try: post_type = data.get("type") if not post_type or not isinstance(post_type, str): raise ValueError("Неверный тип поста") payload = build_payload( ptype=post_type, text=data.get("text"), media_file_id=data.get("media_id"), parse_mode=data.get("format"), keyboard=data.get("keyboard"), ) # Отправляем задачу через Celery task = celery_app.send_task( 'app.tasks.senders.send_post_task', args=[payload] ) await query.edit_message_text( f"Пост поставлен в очередь\nID задачи: {task.id}" ) except ValueError as e: await query.edit_message_text(f"Ошибка валидации: {e}") return ConversationHandler.END except Exception as e: logger.error(f"Ошибка при отправке поста: {e}") await query.edit_message_text("Произошла ошибка при отправке поста") return ConversationHandler.END else: await query.edit_message_text("Отправка отменена") if user_id in session: del session[user_id] return ConversationHandler.END except Exception as e: logger.error(f"Ошибка при подтверждении отправки: {e}") await query.answer("Произошла ошибка") return ConversationHandler.END async def preview_collect_vars(update: Update, context: CallbackContext) -> int: """Сбор переменных для предпросмотра шаблона.""" message = cast(Message, update.message) if not message or not message.text: return ConversationHandler.END user_id = message.from_user.id if user_id is None: return ConversationHandler.END vars_text = message.text try: template_vars = parse_key_value_lines(vars_text) if user_id not in session: session[user_id] = {} session[user_id]["template_vars"] = template_vars update_session_activity(user_id) await message.reply_text("Переменные сохранены. Подтвердите предпросмотр") return PREVIEW_CONFIRM except ValueError as e: await message.reply_text(f"Ошибка разбора переменных: {e}") return PREVIEW_VARS async def preview_confirm(update: Update, context: CallbackContext) -> int: """Подтверждение предпросмотра шаблона.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data or not query.message: return ConversationHandler.END user_id = query.from_user.id choice = query.data.split(":")[1] if not isinstance(context.user_data, dict): context.user_data = {} data = session.get(user_id) if not data: await query.edit_message_text("Ошибка: сессия потеряна") return ConversationHandler.END if choice == "use": try: template_name = data.get("template_name") template_vars = data.get("template_vars", {}) if not template_name: raise ValueError("Имя шаблона не задано") # Создаем контекст для шаблонизатора template_context = { "user_id": user_id, "vars": template_vars, "context": dict(context.user_data) if context.user_data else {} } rendered = await render_template_by_name(template_name, template_vars, template_context) if user_id not in session: session[user_id] = {} session[user_id].update(rendered) update_session_activity(user_id) await query.edit_message_text("Шаблон применен. Проверьте параметры отправки") return CONFIRM_SEND except Exception as e: logger.error(f"Ошибка при применении шаблона: {e}") await query.edit_message_text(f"Ошибка применения шаблона: {e}") return ConversationHandler.END else: await query.edit_message_text("Редактирование отменено") return ConversationHandler.END def create_template_dict(data: dict, user_id: int) -> dict: """Создание словаря параметров для создания шаблона. Args: data: Исходные данные user_id: ID пользователя Returns: dict: Подготовленные данные для создания шаблона """ template_data = { "owner_id": user_id, "name": data.get("name"), "title": data.get("name"), "content": data.get("content"), "keyboard_tpl": data.get("keyboard"), "type_": data.get("type"), "parse_mode": data.get("format") } # Проверяем обязательные поля required_fields = ["name", "content", "type_"] missing_fields = [f for f in required_fields if not template_data.get(f)] if missing_fields: raise ValueError(f"Не хватает обязательных полей: {', '.join(missing_fields)}") return template_data async def tpl_new_start(update: Update, context: CallbackContext) -> int: """Начало создания нового шаблона.""" message = cast(Message, update.message) if not message or not message.from_user: return ConversationHandler.END user_id = message.from_user.id session[user_id] = {} update_session_activity(user_id) await message.reply_text("Введите имя нового шаблона") return TPL_NEW_NAME async def tpl_new_name(update: Update, context: CallbackContext) -> int: """Ввод имени нового шаблона.""" message = cast(Message, update.message) if not message or not message.text or not message.from_user: return ConversationHandler.END user_id = message.from_user.id name = message.text.strip() if not name or " " in name: await message.reply_text("Недопустимое имя шаблона") return TPL_NEW_NAME if user_id not in session: session[user_id] = {} session[user_id]["name"] = name update_session_activity(user_id) await message.reply_text("Выберите тип шаблона") return TPL_NEW_TYPE async def tpl_new_type(update: Update, context: CallbackContext) -> int: """Выбор типа нового шаблона.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: return ConversationHandler.END user_id = query.from_user.id type_choice = query.data.split(":")[1] session[user_id]["type"] = type_choice update_session_activity(user_id) await query.edit_message_text("Выберите формат сообщения") return TPL_NEW_FORMAT async def tpl_new_format(update: Update, context: CallbackContext) -> int: """Выбор формата нового шаблона.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: return ConversationHandler.END user_id = query.from_user.id format_choice = query.data.split(":")[1] session[user_id]["format"] = format_choice update_session_activity(user_id) await query.edit_message_text("Введите содержимое шаблона") return TPL_NEW_CONTENT async def tpl_new_content(update: Update, context: CallbackContext) -> int: """Ввод содержимого нового шаблона.""" message = cast(Message, update.message) if not message or not message.text or not message.from_user: return ConversationHandler.END user_id = message.from_user.id content = message.text if not validate_message_length(content): await message.reply_text("Слишком длинный шаблон") return TPL_NEW_CONTENT if user_id not in session: session[user_id] = {} session[user_id]["content"] = content update_session_activity(user_id) await message.reply_text("Введите клавиатуру или пропустите") return TPL_NEW_KB async def list_user_templates(update: Update, context: CallbackContext) -> None: """Вывод списка доступных шаблонов.""" message = cast(Message, update.message) if not message or not message.from_user: return None try: user_id = message.from_user.id if context and not isinstance(context.user_data, dict): context.user_data = {} context.user_data["tpl_page"] = 0 templates = await list_templates(owner_id=user_id) if not templates: await message.reply_text("Нет доступных шаблонов") return None text = "Доступные шаблоны:\n\n" for tpl in templates: # Используем явное приведение типа для доступа к атрибутам tpl_name = getattr(tpl, 'name', 'Без имени') tpl_type = getattr(tpl, 'type', 'Неизвестный тип') text += f"• {tpl_name} ({tpl_type})\n" await message.reply_text(text) except Exception as e: logger.error(f"Ошибка при загрузке шаблонов: {e}") await message.reply_text("Ошибка при загрузке списка шаблонов") @shared_task def send_post_async(data: dict) -> None: """Отправка поста из данных сессии в фоновом режиме. Args: data: Данные поста """ post_type = data.get("type") if not post_type or not isinstance(post_type, str): raise ValueError("Неверный тип поста") payload = build_payload( ptype=post_type, text=data.get("text"), media_file_id=data.get("media_id"), parse_mode=data.get("format"), keyboard=data.get("keyboard"), ) celery_app.send_task('app.tasks.senders.send_post_task', args=[payload]) @shared_task(bind=True) def send_post(self, data: dict) -> None: """Отправка поста из данных сессии. Args: data: Данные сессии """ try: post_type = data.get("type") if not post_type or not isinstance(post_type, str): raise ValueError("Неверный тип поста") payload = build_payload( ptype=post_type, text=data.get("text"), media_file_id=data.get("media_id"), parse_mode=data.get("format"), keyboard=data.get("keyboard"), ) celery_app.send_task('app.tasks.senders.send_post_task', args=[payload]) except Exception as e: logger.error(f"Ошибка при отправке поста: {e}") raise async def create_template_with_data(data: dict, user_id: int) -> None: """Создание нового шаблона из данных сессии. Args: data: Данные сессии user_id: ID пользователя """ name = data.get("name") content = data.get("content") keyboard = data.get("keyboard") tpl_type = data.get("type") parse_mode = data.get("format") if not name or not content or not tpl_type: raise ValueError("Не хватает обязательных данных для шаблона") await create_template( owner_id=user_id, name=name, title=name, content=content, keyboard_tpl=keyboard, type_=tpl_type, parse_mode=parse_mode ) async def handle_template_kb(update: Update, context: CallbackContext) -> int: """Обработка клавиатуры шаблона.""" message = cast(Message, update.message) user = update.effective_user if not message or not message.text or not user: return ConversationHandler.END user_id = user.id kb_text = message.text data = session.get(user_id) if not data: await message.reply_text("Ошибка: сессия потеряна") return ConversationHandler.END if kb_text != "skip": try: keyboard = parse_key_value_lines(kb_text) if kb_text else {} if not isinstance(data, dict): data = {} data["keyboard"] = keyboard session[user_id] = data except ValueError as e: await message.reply_text(f"Ошибка разбора клавиатуры: {e}") return TPL_NEW_KB try: if not data.get("name") or not data.get("content") or not data.get("type"): await message.reply_text("Отсутствуют обязательные данные шаблона") return TPL_NEW_KB await create_template( owner_id=user_id, name=data["name"], title=data["name"], content=data["content"], keyboard_tpl=data.get("keyboard"), type_=data["type"], parse_mode=data.get("format") ) await message.reply_text("Шаблон создан успешно") except ValueError as e: await message.reply_text(f"Ошибка создания шаблона: {e}") return TPL_NEW_KB except Exception as e: logger.error(f"Неожиданная ошибка при создании шаблона: {e}") await message.reply_text("Произошла ошибка при создании шаблона") return TPL_NEW_KB if user_id in session: del session[user_id] return ConversationHandler.END async def handle_template_pagination(update: Update, context: CallbackContext) -> None: """Обработка пагинации в списке шаблонов.""" query = cast(CallbackQuery, update.callback_query) if not query or not isinstance(query.data, str): return None try: parts = query.data.split(":", 1) if len(parts) != 2: await query.answer("Некорректный формат страницы") return None try: page = max(0, int(parts[1])) except ValueError: await query.answer("Некорректный номер страницы") return None user = update.effective_user if not user or not user.id: await query.answer("Ошибка: пользователь не определен") return None # Получаем шаблоны пользователя templates = await list_templates(owner_id=user.id) if not templates: msg = cast(Optional[Message], query.message) if msg and hasattr(msg, 'edit_text'): await msg.edit_text("У вас нет шаблонов") else: await query.answer("У вас нет шаблонов") return None # Вычисляем диапазон для текущей страницы start = page * PAGE_SIZE end = start + PAGE_SIZE page_templates = templates[start:end] if not page_templates: text = "Нет шаблонов на этой странице" else: text = "Доступные шаблоны:\n\n" for tpl in page_templates: tpl_name = getattr(tpl, 'name', 'Без имени') tpl_type = getattr(tpl, 'type', 'Неизвестный тип') text += f"• {tpl_name} ({tpl_type})\n" # Создаем кнопки навигации keyboard: List[List[InlineKeyboardButton]] = [[]] if page > 0: keyboard[0].append(InlineKeyboardButton("⬅️", callback_data=f"page:{page-1}")) if len(templates) > end: keyboard[0].append(InlineKeyboardButton("➡️", callback_data=f"page:{page+1}")) markup = InlineKeyboardMarkup(keyboard) msg = cast(Optional[Message], query.message) if not msg or not hasattr(msg, 'edit_text'): await query.answer("Не удалось обновить сообщение") return None try: await msg.edit_text(text=text, reply_markup=markup) await query.answer() except TelegramError as e: logger.error(f"Ошибка при обновлении сообщения: {e}", exc_info=True) await query.answer("Не удалось обновить список") try: await query.edit_message_text(text=text, reply_markup=markup) except TelegramError as e: logger.error(f"Ошибка при обновлении сообщения: {e}") await query.answer("Не удалось обновить список шаблонов") except Exception as e: logger.error(f"Ошибка при обработке пагинации: {e}") await query.answer("Произошла ошибка") async def tpl_new_kb(update: Update, context: CallbackContext) -> int: """Ввод клавиатуры для нового шаблона.""" if not update.message or not update.message.text: return ConversationHandler.END user_id = update.message.from_user.id kb_text = update.message.text data = session.get(user_id) if not data: await update.message.reply_text("Ошибка: сессия потеряна") return ConversationHandler.END if kb_text != "skip": try: keyboard = parse_key_value_lines(kb_text) data["keyboard"] = keyboard except ValueError as e: await update.message.reply_text(f"Ошибка разбора клавиатуры: {e}") return TPL_NEW_KB try: await create_template( name=data["name"], content=data["content"], keyboard=data.get("keyboard"), type=data["type"], format=data["format"] ) await update.message.reply_text("Шаблон создан успешно") except Exception as e: await update.message.reply_text(f"Ошибка создания шаблона: {e}") del session[user_id] return ConversationHandler.END async def tpl_list(update: Update, context: CallbackContext) -> None: """Вывод списка доступных шаблонов.""" if not update.message: return try: templates = await list_templates() if not templates: await update.message.reply_text("Нет доступных шаблонов") return text = "Доступные шаблоны:\n\n" for tpl in templates: text += f"• {tpl.name} ({tpl.type})\n" await update.message.reply_text(text) except Exception as e: await update.message.reply_text(f"Ошибка загрузки шаблонов: {e}") return out # -------- Команды верхнего уровня --------- async def start(update: Update, context: CallbackContext) -> None: """Обработчик команды /start.""" if not update.effective_user or not update.message: return update_session_activity(update.effective_user.id) await update.message.reply_text( "Привет! Я редактор. Команды:\n" "/newpost — мастер поста\n" "/tpl_new — создать шаблон\n" "/tpl_list — список шаблонов" ) async def newpost(update: Update, context: CallbackContext) -> int: """Начало создания нового поста.""" if not update.effective_user or not update.message: return ConversationHandler.END uid = update.effective_user.id update_session_activity(uid) session[uid] = {'last_activity': time.time()} try: async with async_session_maker() as s: res = await s.execute(select(Channel).where(Channel.owner_id == uid).limit(50)) channels = list(res.scalars()) if not channels: await update.message.reply_text( "Пока нет каналов. Добавь через админку или команду /add_channel (в разработке)." ) return ConversationHandler.END kb = [ [InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")] for ch in channels ] await update.message.reply_text( "Выбери канал для публикации:", reply_markup=InlineKeyboardMarkup(kb) ) return CHOOSE_CHANNEL except Exception as e: logger.error(f"Error in newpost: {e}") await update.message.reply_text("Произошла ошибка. Попробуйте позже.") return ConversationHandler.END async def choose_channel(update: Update, context: CallbackContext) -> int: """Обработка выбора канала.""" if not update.callback_query or not update.effective_user: return ConversationHandler.END await update.callback_query.answer() uid = update.effective_user.id update_session_activity(uid) # Получаем необходимые объекты query = cast(Optional[CallbackQuery], update.callback_query) user = update.effective_user if not query or not isinstance(query.data, str) or not user or not user.id: return ConversationHandler.END msg = cast(Optional[Message], query.message) try: # Парсим данные из callback parts = query.data.split(":", 1) if len(parts) != 2: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: неверный формат данных") return ConversationHandler.END # Проверяем корректность ID канала try: ch_id = int(parts[1]) except ValueError: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: некорректный ID канала") return ConversationHandler.END # Сохраняем в сессию session[user.id] = session.get(user.id, {}) session[user.id]["channel_id"] = ch_id # Создаем клавиатуру выбора типа поста kb = [ [ InlineKeyboardButton("Текст", callback_data="type:text"), InlineKeyboardButton("Фото", callback_data="type:photo") ], [ InlineKeyboardButton("Видео", callback_data="type:video"), InlineKeyboardButton("GIF", callback_data="type:animation") ], ] # Отображаем сообщение с выбором типа if msg and hasattr(msg, 'edit_text'): await msg.edit_text( "Выберите тип поста:", reply_markup=InlineKeyboardMarkup(kb) ) return CHOOSE_TYPE except Exception as e: logger.error(f"Ошибка при выборе канала: {e}", exc_info=True) if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Произошла ошибка. Попробуйте заново.") return ConversationHandler.END # ... [Остальные функции обновляются аналогично] ... async def enter_schedule(update: Update, context: CallbackContext) -> int: """Обработка ввода времени для отложенной публикации.""" if not update.effective_user or not update.message or not update.message.text: return ConversationHandler.END uid = update.effective_user.id update_session_activity(uid) try: when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M") if when < datetime.now(): await update.message.reply_text( "Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM" ) return ENTER_SCHEDULE await _dispatch_with_eta(uid, when) await update.message.reply_text("Задача запланирована.") return ConversationHandler.END except ValueError: await update.message.reply_text( "Неверный формат даты. Используйте формат YYYY-MM-DD HH:MM" ) return ENTER_SCHEDULE except Exception as e: logger.error(f"Error scheduling post: {e}") await update.message.reply_text("Ошибка планирования. Попробуйте позже.") return ConversationHandler.END def build_payload( ptype: str, text: Optional[str] = None, media_file_id: Optional[str] = None, parse_mode: Optional[str] = None, keyboard: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Построение данных для отправки поста.""" payload: Dict[str, Any] = { "type": str(ptype), "text": text if text is not None else "", "parse_mode": str(parse_mode) if parse_mode is not None else "html", "keyboard": keyboard if keyboard is not None else {}, } if media_file_id: payload["media_file_id"] = media_file_id return payload async def _dispatch_with_eta(uid: int, when: datetime) -> None: """Отправка отложенного поста.""" data = session.get(uid) if not data: raise ValueError("Сессия потеряна") token = settings.editor_bot_token try: payload = build_payload( ptype=data.get("type"), text=data.get("text"), media_file_id=data.get("media_file_id"), parse_mode=data.get("parse_mode") or "HTML", keyboard=data.get("keyboard"), ) # Проверка длины сообщения if not validate_message_length(payload.get("text", "")): raise ValueError("Превышен максимальный размер сообщения") # Проверка URL в клавиатуре if keyboard := payload.get("keyboard"): for row in keyboard.get("rows", []): for btn in row: if "url" in btn and not validate_url(btn["url"]): raise ValueError(f"Небезопасный URL: {btn['url']}") celery_app.send_task( 'app.tasks.senders.send_post_task', args=[token, data["channel_id"], payload], eta=when ) except Exception as e: logger.error(f"Error in _dispatch_with_eta: {e}") raise def main(): """Инициализация и запуск бота.""" try: # Настройка логирования logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO ) # Инициализация планировщика scheduler = AsyncIOScheduler() scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30) scheduler.start() app = Application.builder().token(settings.editor_bot_token).build() # Регистрация обработчиков post_conv = ConversationHandler( entry_points=[CommandHandler("newpost", newpost)], states={ CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")], CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")], CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")], ENTER_TEXT: [ MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text), CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"), ], SELECT_TEMPLATE: [ CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"), CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"), CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"), CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), ], PREVIEW_VARS: [ MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars) ], PREVIEW_CONFIRM: [ CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"), CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), ], ENTER_MEDIA: [ MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media) ], EDIT_KEYBOARD: [ MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard) ], CONFIRM_SEND: [ CallbackQueryHandler(confirm_send, pattern=r"^send:") ], ENTER_SCHEDULE: [ MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule) ], }, fallbacks=[CommandHandler("start", start)], ) tpl_conv = ConversationHandler( entry_points=[CommandHandler("tpl_new", tpl_new_start)], states={ TPL_NEW_NAME: [ MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name) ], TPL_NEW_TYPE: [ CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:") ], TPL_NEW_FORMAT: [ CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:") ], TPL_NEW_CONTENT: [ MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content) ], TPL_NEW_KB: [ MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb) ], }, fallbacks=[CommandHandler("start", start)], ) app.add_handler(CommandHandler("start", start)) app.add_handler(post_conv) app.add_handler(tpl_conv) app.add_handler(CommandHandler("tpl_list", tpl_list)) # Запуск бота app.run_polling(allowed_updates=Update.ALL_TYPES) except Exception as e: logger.critical(f"Critical error in main: {e}") raise # -------- Вспомогательные функции для шаблонов --------- async def _render_tpl_list_message(message: Message, uid: int, page: int) -> int: """Отображение списка шаблонов с пагинацией через сообщение.""" try: total = await count_templates(uid) offset = page * PAGE_SIZE tpls = await list_templates(uid, limit=PAGE_SIZE, offset=offset) if not tpls: if page == 0: await message.reply_text("Шаблонов пока нет. Создай через /tpl_new.") return ENTER_TEXT else: return await _render_tpl_list_message(message, uid, 0) kb = [] for t in tpls: kb.append([ InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"), InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}") ]) nav = [] if page > 0: nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}")) if offset + PAGE_SIZE < total: nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}")) if nav: kb.append(nav) text = "📜 Список шаблонов:\n\n" if total > PAGE_SIZE: text += f"(Страница {page + 1})" await message.reply_text(text, reply_markup=InlineKeyboardMarkup(kb)) return ENTER_TEXT except Exception as e: logger.error(f"Error rendering template list for message: {e}") await message.reply_text("Ошибка при загрузке списка шаблонов") return ENTER_TEXT async def _render_tpl_list_query(query: CallbackQuery, uid: int, page: int) -> int: """Отображение списка шаблонов с пагинацией через callback query.""" try: total = await count_templates(uid) offset = page * PAGE_SIZE tpls = await list_templates(uid, limit=PAGE_SIZE, offset=offset) if not tpls: if page == 0: await query.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.") return ENTER_TEXT else: return await _render_tpl_list_query(query, uid, 0) kb = [] for t in tpls: kb.append([ InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"), InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}") ]) nav = [] if page > 0: nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}")) if offset + PAGE_SIZE < total: nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}")) if nav: kb.append(nav) text = "📜 Список шаблонов:\n\n" if total > PAGE_SIZE: text += f"(Страница {page + 1})" await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb)) return ENTER_TEXT except Exception as e: logger.error(f"Error rendering template list for query: {e}") await query.edit_message_text("Ошибка при загрузке списка шаблонов") return ENTER_TEXT async def _render_tpl_list(update: Update, uid: int, page: int) -> int: """Отображение списка шаблонов с пагинацией.""" try: if update.callback_query: return await _render_tpl_list_query(update.callback_query, uid, page) elif update.message: return await _render_tpl_list_message(update.message, uid, page) else: logger.error("Neither callback_query nor message found in update") return ENTER_TEXT except Exception as e: logger.error(f"Error in _render_tpl_list: {e}") return ENTER_TEXT kb = [] for t in tpls: kb.append([ InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"), InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}") ]) nav = [] if page > 0: nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}")) if offset + PAGE_SIZE < total: nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}")) if nav: kb.append(nav) kb.append([InlineKeyboardButton("Отмена", callback_data="tpl:cancel")]) text = f"Шаблоны (стр. {page+1}/{(total-1)//PAGE_SIZE + 1}):" if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb)) else: await q_or_msg.reply_text(text, reply_markup=InlineKeyboardMarkup(kb)) return SELECT_TEMPLATE except Exception as e: logger.error(f"Error rendering template list: {e}") if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text("Ошибка при загрузке списка шаблонов") else: await q_or_msg.reply_text("Ошибка при загрузке списка шаблонов") return ConversationHandler.END async def _apply_template_and_confirm_message(message: Message, uid: int, name: str, ctx_vars: dict) -> int: """Применение шаблона к текущему посту через сообщение.""" try: rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) session[uid].update({ "type": rendered["type"], "text": rendered["text"], "keyboard": {"rows": rendered["keyboard_rows"]} if rendered.get("keyboard_rows") else None, "parse_mode": rendered.get("parse_mode") or session[uid].get("parse_mode") or "HTML" }) kb = [ [InlineKeyboardButton("Отправить сейчас", callback_data="send:now")], [InlineKeyboardButton("Запланировать", callback_data="send:schedule")] ] markup = InlineKeyboardMarkup(kb) await message.reply_text( "Шаблон применён. Как публикуем?", reply_markup=markup ) return CONFIRM_SEND except Exception as e: logger.error(f"Error applying template via message: {e}") await message.reply_text("Ошибка при применении шаблона") return ConversationHandler.END async def _apply_template_and_confirm_query(query: CallbackQuery, uid: int, name: str, ctx_vars: dict) -> int: """Применение шаблона к текущему посту через callback query.""" try: rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) session[uid].update({ "type": rendered["type"], "text": rendered["text"], "keyboard": {"rows": rendered["keyboard_rows"]} if rendered.get("keyboard_rows") else None, "parse_mode": rendered.get("parse_mode") or session[uid].get("parse_mode") or "HTML" }) kb = [ [InlineKeyboardButton("Отправить сейчас", callback_data="send:now")], [InlineKeyboardButton("Запланировать", callback_data="send:schedule")] ] markup = InlineKeyboardMarkup(kb) await query.edit_message_text( "Шаблон применён. Как публикуем?", reply_markup=markup ) return CONFIRM_SEND except Exception as e: logger.error(f"Error applying template via query: {e}") await query.edit_message_text("Ошибка при применении шаблона") return ConversationHandler.END async def _apply_template_and_confirm(update: Update, uid: int, name: str, ctx_vars: dict) -> int: """Применение шаблона к текущему посту.""" try: if update.callback_query: return await _apply_template_and_confirm_query(update.callback_query, uid, name, ctx_vars) elif update.message: return await _apply_template_and_confirm_message(update.message, uid, name, ctx_vars) else: logger.error("Neither callback_query nor message found in update") return ConversationHandler.END except Exception as e: logger.error(f"Error in _apply_template_and_confirm: {e}") return ConversationHandler.END async def _render_preview_and_confirm_message(message: Message, uid: int, name: str, ctx_vars: dict) -> int: """Рендеринг предпросмотра шаблона через сообщение.""" try: rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) text = rendered["text"] parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML" preview_text = f"Предпросмотр:\n\n{text[:3500]}" await message.reply_text(preview_text, parse_mode=parse_mode) kb = [ [InlineKeyboardButton("✅ Использовать", callback_data="pv:use")], [InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")], [InlineKeyboardButton("Отмена", callback_data="tpl:cancel")] ] markup = InlineKeyboardMarkup(kb) await message.reply_text("Что дальше?", reply_markup=markup) return PREVIEW_CONFIRM except Exception as e: logger.error(f"Error in preview render via message: {e}") await message.reply_text("Ошибка при рендеринге шаблона") return ConversationHandler.END async def _render_preview_and_confirm_query(query: CallbackQuery, uid: int, name: str, ctx_vars: dict) -> int: """Рендеринг предпросмотра шаблона через callback query.""" try: rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) text = rendered["text"] parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML" preview_text = f"Предпросмотр:\n\n{text[:3500]}" await query.edit_message_text(preview_text, parse_mode=parse_mode) kb = [ [InlineKeyboardButton("✅ Использовать", callback_data="pv:use")], [InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")], [InlineKeyboardButton("Отмена", callback_data="tpl:cancel")] ] markup = InlineKeyboardMarkup(kb) if query.message: await query.message.reply_text("Что дальше?", reply_markup=markup) return PREVIEW_CONFIRM except Exception as e: logger.error(f"Error in preview render via query: {e}") await query.edit_message_text("Ошибка при рендеринге шаблона") return ConversationHandler.END async def _render_preview_and_confirm(update: Update, uid: int, name: str, ctx_vars: dict) -> int: """Рендеринг предпросмотра шаблона.""" try: if update.callback_query: return await _render_preview_and_confirm_query(update.callback_query, uid, name, ctx_vars) elif update.message: return await _render_preview_and_confirm_message(update.message, uid, name, ctx_vars) else: logger.error("Neither callback_query nor message found in update") return ConversationHandler.END except Exception as e: logger.error(f"Error in _render_preview_and_confirm: {e}") return ConversationHandler.END # -------- Обработчики шаблонов --------- async def choose_template_open(update: Update, context: CallbackContext) -> int: """Открытие списка шаблонов.""" query = cast(Optional[CallbackQuery], update.callback_query) if not query: return ConversationHandler.END await query.answer() user = update.effective_user if not user or not user.id: msg = cast(Optional[Message], query.message) if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: пользователь не определен") return ConversationHandler.END if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): context.user_data["tpl_page"] = 0 return await _render_tpl_list(update, user.id, page=0) async def choose_template_navigate(update: Update, context: CallbackContext) -> int: """Навигация по списку шаблонов.""" query = cast(Optional[CallbackQuery], update.callback_query) user = update.effective_user if not query or not query.data or not user or not user.id: return ConversationHandler.END try: await query.answer() msg = cast(Optional[Message], query.message) # Получаем номер страницы из callback data parts = query.data.split(":", 1) if len(parts) != 2: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: неверный формат данных") return ConversationHandler.END try: page = max(0, int(parts[1])) except ValueError: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: некорректный номер страницы") return ConversationHandler.END if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): context.user_data["tpl_page"] = page return await _render_tpl_list(update, user.id, page) except Exception as e: logger.error(f"Ошибка при навигации по шаблонам: {e}", exc_info=True) msg = cast(Optional[Message], query.message) if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Произошла ошибка при навигации") return ConversationHandler.END page = int(page_s) context.user_data["tpl_page"] = page return await _render_tpl_list(q, uid, page) async def choose_template_apply(update: Update, context: CallbackContext) -> int: """Применение выбранного шаблона.""" query = cast(Optional[CallbackQuery], update.callback_query) user = update.effective_user if not query or not isinstance(query.data, str) or not user or not user.id: return ConversationHandler.END try: await query.answer() msg = cast(Optional[Message], query.message) parts = query.data.split(":", 1) if len(parts) != 2: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: неверный формат данных") return ConversationHandler.END name = parts[1] # Получаем шаблон и проверяем переменные tpl = await render_template_by_name(owner_id=user.id, name=name, ctx={}) if not tpl: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: шаблон не найден") return ConversationHandler.END required = set(tpl.get("_required", [])) # Сохраняем данные в контекст if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): context.user_data["preview"] = { "name": name, "provided": {}, "missing": list(required) } if required: if msg and hasattr(msg, 'edit_text'): # Получаем первую необходимую переменную next_var = list(required)[0] await msg.edit_text( "Для этого шаблона требуются дополнительные параметры.\n" f"Пожалуйста, введите значение для параметра {next_var}:" ) return PREVIEW_VARS return await _apply_template_and_confirm(update, user.id, name, {}) except Exception as e: logger.error(f"Ошибка при применении шаблона: {e}", exc_info=True) msg = cast(Optional[Message], query.message if query else None) if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка при применении шаблона") return ConversationHandler.END await update.callback_query.edit_message_text( "Шаблон требует переменные: " + ", ".join(sorted(required)) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return PREVIEW_VARS return await _apply_template_and_confirm(update, uid, name, {}) except Exception as e: logger.error(f"Error applying template: {e}") await update.callback_query.edit_message_text("Ошибка при применении шаблона") return ConversationHandler.END async def choose_template_preview(update: Update, context: CallbackContext) -> int: """Предпросмотр шаблона.""" if (not update.callback_query or not update.effective_user or not context.user_data or not update.callback_query.data): return ConversationHandler.END await update.callback_query.answer() uid = update.effective_user.id try: name = update.callback_query.data.split(":")[1] tpl = await render_template_by_name(owner_id=uid, name=name, ctx={}) required = set(tpl.get("_required", [])) context.user_data["preview"] = { "name": name, "provided": {}, "missing": list(required) } if required: await update.callback_query.edit_message_text( "Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return PREVIEW_VARS return await _render_preview_and_confirm(update, uid, name, {}) except Exception as e: logger.error(f"Error previewing template: {e}") await update.callback_query.edit_message_text("Ошибка при предпросмотре шаблона") return ConversationHandler.END async def choose_template_cancel(update: Update, context: CallbackContext) -> int: """Отмена выбора шаблона.""" if not update.callback_query: return ConversationHandler.END await update.callback_query.answer() await update.callback_query.edit_message_text( "Отправь текст сообщения или введи #имя для шаблона." ) return ENTER_TEXT if __name__ == "__main__": main()