from __future__ import annotations import shlex import logging from datetime import datetime from typing import Optional, Dict, List, Any import time from urllib.parse import urlparse from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup 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.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, TPL_CONFIRM_DELETE, ) = 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 start(update: Update, context: CallbackContext) -> None: """Обработчик команды /start.""" 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: """Начало создания нового поста.""" 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: """Обработка выбора канала.""" q = update.callback_query await q.answer() uid = update.effective_user.id update_session_activity(uid) try: ch_id = int(q.data.split(":")[1]) session[uid]["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") ], ] await q.edit_message_text( "Тип поста:", reply_markup=InlineKeyboardMarkup(kb) ) return CHOOSE_TYPE except ValueError: await q.edit_message_text("Ошибка: неверный формат ID канала") return ConversationHandler.END except Exception as e: logger.error(f"Error in choose_channel: {e}") await q.edit_message_text("Произошла ошибка. Попробуйте заново.") return ConversationHandler.END # ... [Остальные функции обновляются аналогично] ... async def enter_schedule(update: Update, context: CallbackContext) -> int: """Обработка ввода времени для отложенной публикации.""" 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 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']}") send_post_task.apply_async( 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) ], TPL_CONFIRM_DELETE: [ CallbackQueryHandler(tpl_delete_ok, pattern=r"^tpldelok:"), CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), ], }, 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(q_or_msg: Update | CallbackContext, 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: if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.") else: await q_or_msg.reply_text("Шаблонов пока нет. Создай через /tpl_new.") return ENTER_TEXT else: return await _render_tpl_list(q_or_msg, 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) 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(q_or_msg: Union[CallbackQuery, 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) if isinstance(q_or_msg, CallbackQuery): await q_or_msg.edit_message_text( "Шаблон применён. Как публикуем?", reply_markup=markup ) else: await cast(Message, q_or_msg).reply_text( "Шаблон применён. Как публикуем?", reply_markup=markup ) return CONFIRM_SEND except Exception as e: logger.error(f"Error applying template: {e}") if isinstance(q_or_msg, CallbackQuery): await q_or_msg.edit_message_text("Ошибка при применении шаблона") else: await cast(Message, q_or_msg).reply_text("Ошибка при применении шаблона") return ConversationHandler.END async def _render_preview_and_confirm(q_or_msg: Update | CallbackContext, 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]}" if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text(preview_text, parse_mode=parse_mode) else: await q_or_msg.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) if hasattr(q_or_msg, "reply_text"): await q_or_msg.reply_text("Что дальше?", reply_markup=markup) elif hasattr(q_or_msg, "message") and q_or_msg.message: await q_or_msg.message.reply_text("Что дальше?", reply_markup=markup) return PREVIEW_CONFIRM except Exception as e: logger.error(f"Error in preview render: {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 choose_template_open(update: Update, context: CallbackContext) -> int: """Открытие списка шаблонов.""" q = update.callback_query await q.answer() uid = update.effective_user.id context.user_data["tpl_page"] = 0 return await _render_tpl_list(q, uid, page=0) async def choose_template_navigate(update: Update, context: CallbackContext) -> int: """Навигация по списку шаблонов.""" q = update.callback_query await q.answer() uid = update.effective_user.id _, page_s = q.data.split(":") 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: """Применение выбранного шаблона.""" q = update.callback_query await q.answer() uid = update.effective_user.id try: name = q.data.split(":")[1] tpl = await render_template_by_name(owner_id=uid, name=name, ctx={}) required = set(tpl.get("_required", [])) if required: context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)} await q.edit_message_text( "Шаблон требует переменные: " + ", ".join(sorted(required)) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return PREVIEW_VARS return await _apply_template_and_confirm(q, uid, name, {}) except Exception as e: logger.error(f"Error applying template: {e}") await q.edit_message_text("Ошибка при применении шаблона") return ConversationHandler.END async def choose_template_preview(update: Update, context: CallbackContext) -> int: """Предпросмотр шаблона.""" q = update.callback_query await q.answer() uid = update.effective_user.id try: name = q.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 q.edit_message_text( "Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return PREVIEW_VARS return await _render_preview_and_confirm(q, uid, name, {}) except Exception as e: logger.error(f"Error previewing template: {e}") await q.edit_message_text("Ошибка при предпросмотре шаблона") return ConversationHandler.END async def choose_template_cancel(update: Update, context: CallbackContext) -> int: """Отмена выбора шаблона.""" q = update.callback_query await q.answer() await q.edit_message_text("Отправь текст сообщения или введи #имя для шаблона.") return ENTER_TEXT if __name__ == "__main__": main()