diff --git a/alembic.ini b/alembic.ini index fe839b3..0dc7b92 100644 --- a/alembic.ini +++ b/alembic.ini @@ -5,7 +5,7 @@ # this is typically a path given in POSIX (e.g. forward slashes) # format, relative to the token %(here)s which refers to the location of this # ini file -script_location = %(here)s/migrations +script_location = migrations # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # Uncomment the line below if you want the files to be prepended with date and time diff --git a/app/api/routes/templates.py b/app/api/routes/templates.py index 98862e8..1b9dfe8 100644 --- a/app/api/routes/templates.py +++ b/app/api/routes/templates.py @@ -1,77 +1,3 @@ -# # app/api/routes/templates.py -# from __future__ import annotations -# from fastapi import APIRouter, Depends, HTTPException, Query -# from sqlalchemy import select, or_ -# from sqlalchemy.ext.asyncio import AsyncSession - -# from app.db.session import get_async_session -# from app.models.templates import Template -# from app.api.schemas.template import TemplateIn, TemplateOut -# from app.services.templates import count_templates - -# router = APIRouter(prefix="/templates", tags=["templates"]) - -# # Заглушка аутентификации -# async def get_owner_id(x_user_id: int | None = None): -# return x_user_id or 0 - -# @router.post("/", response_model=TemplateOut) -# async def create_tpl( -# data: TemplateIn, -# owner_id: int = Depends(get_owner_id), -# s: AsyncSession = Depends(get_async_session), -# ): -# tpl = Template(owner_id=owner_id, **data.model_dump()) -# s.add(tpl) -# await s.commit() -# await s.refresh(tpl) -# return tpl - -# @router.get("/", response_model=list[TemplateOut]) -# async def list_tpls( -# owner_id: int = Depends(get_owner_id), -# limit: int = Query(20, ge=1, le=100), -# offset: int = Query(0, ge=0), -# q: str | None = Query(default=None), -# s: AsyncSession = Depends(get_async_session), -# ): -# stmt = select(Template).where( -# Template.owner_id == owner_id, -# Template.is_archived.is_(False), -# ) -# if q: -# like = f"%{q}%" -# stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like))) -# stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset) -# res = await s.execute(stmt) -# return list(res.scalars()) - -# @router.get("/count") -# async def count_tpls( -# owner_id: int = Depends(get_owner_id), -# q: str | None = None, -# ): -# total = await count_templates(owner_id, q) -# return {"total": total} - -# @router.delete("/{tpl_id}") -# async def delete_tpl( -# tpl_id: int, -# owner_id: int = Depends(get_owner_id), -# s: AsyncSession = Depends(get_async_session), -# ): -# res = await s.execute(select(Template).where( -# Template.id == tpl_id, -# Template.owner_id == owner_id -# )) -# tpl = res.scalars().first() -# if not tpl: -# raise HTTPException(404, "not found") -# await s.delete(tpl) -# await s.commit() -# return {"ok": True} - - # app/api/routes/templates.py from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query diff --git a/app/bots/editor/handlers.py b/app/bots/editor/handlers.py new file mode 100644 index 0000000..985e983 --- /dev/null +++ b/app/bots/editor/handlers.py @@ -0,0 +1,1585 @@ +from __future__ import annotations + +import re +from datetime import datetime +from urllib.parse import urlparse +from typing import Optional, Dict, List, Any, Union, cast, Literal, TypedDict, TypeAlias, Awaitable, Callable + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Message +from telegram.ext import ConversationHandler, ContextTypes +from telegram.constants import ParseMode +from sqlalchemy import select, delete, or_ + +from .states import BotStates +from app.models.templates import Template, TemplateVisibility, PostStatus +from app.models.post import PostType +from app.db.session import async_session_maker + +async def choose_template_navigate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработчик навигации по списку шаблонов.""" + query = update.callback_query + if not query or not query.data: + return BotStates.SELECT_TEMPLATE + + await query.answer() + data = query.data + + if data == "back": + # Вернуться в предыдущее меню + kb = [] + kb.append([InlineKeyboardButton(text="📝 Создать пост", callback_data="post:new")]) + kb.append([InlineKeyboardButton(text="📋 Мои шаблоны", callback_data="tpl:list")]) + kb.append([InlineKeyboardButton(text="🔧 Управление", callback_data="settings")]) + + reply_markup = InlineKeyboardMarkup(kb) + await query.edit_message_text( + text="Выберите действие:", + reply_markup=reply_markup + ) + return BotStates.MAIN_MENU + + # Обработка выбора шаблона + template_parts = data.split(":") + if len(template_parts) == 3 and template_parts[0] == "tpl" and template_parts[1] == "select": + try: + template_id = int(template_parts[2]) + async with async_session_maker() as session: + stmt = select(Template).where(Template.id == template_id) + template = (await session.execute(stmt)).scalar_one_or_none() + + if template: + if context.user_data is None: + context.user_data = {} + + context.user_data["current_template"] = { + "id": template.id, + "name": template.name, + "query": query + } + + # Показать предпросмотр и действия + kb = [] + kb.append([InlineKeyboardButton(text="✅ Использовать", callback_data="tpl:use")]) + kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:choose")]) + + reply_markup = InlineKeyboardMarkup(kb) + preview = f"Шаблон: {template.name}\n\n{template.content}" + + await query.edit_message_text( + text=preview, + reply_markup=reply_markup + ) + return BotStates.TEMPLATE_PREVIEW + except (ValueError, TypeError): + pass + + return BotStates.SELECT_TEMPLATE + +async def choose_template_apply(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Применить выбранный шаблон.""" + query = update.callback_query + if not query: + return BotStates.TEMPLATE_PREVIEW + + await query.answer() + if context.user_data is None: + context.user_data = {} + template = context.user_data.get("current_template") + + if not template: + await query.edit_message_text( + text="Ошибка: шаблон не выбран", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:choose") + ]]) + ) + return BotStates.SELECT_TEMPLATE + + # Подготовка переменных для шаблона + kb = [] + kb.append([InlineKeyboardButton(text="✅ Подтвердить", callback_data="tpl:confirm")]) + kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:preview")]) + + reply_markup = InlineKeyboardMarkup(kb) + text = "Укажите значения для переменных шаблона:" + + await query.edit_message_text(text=text, reply_markup=reply_markup) + return BotStates.TEMPLATE_VARS + +async def choose_channel_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработчик выбора канала.""" + query = update.callback_query + if not query: + return BotStates.SELECT_TEMPLATE + + template = context.user_data.get("current_template") if context.user_data else None + + if not template: + await query.edit_message_text( + text="Ошибка: шаблон не выбран", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:choose") + ]]) + ) + return BotStates.SELECT_TEMPLATE + + # Подготовка переменных для шаблона + kb = [] + kb.append([InlineKeyboardButton(text="✅ Подтвердить", callback_data="tpl:confirm")]) + kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:preview")]) + + reply_markup = InlineKeyboardMarkup(kb) + text = "Укажите значения для переменных шаблона:" + + await query.edit_message_text(text=text, reply_markup=reply_markup) + return BotStates.TEMPLATE_VARS + +async def choose_template_preview(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Предпросмотр шаблона.""" + query = update.callback_query + if not query: + return BotStates.TEMPLATE_PREVIEW + + await query.answer() + if context.user_data is None: + context.user_data = {} + template = context.user_data.get("current_template") + + if not template: + await query.edit_message_text( + text="Ошибка: шаблон не выбран", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:choose") + ]]) + ) + return BotStates.SELECT_TEMPLATE + + # Показать предпросмотр и действия + kb = [] + kb.append([InlineKeyboardButton(text="✅ Использовать", callback_data="tpl:use")]) + kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:choose")]) + + reply_markup = InlineKeyboardMarkup(kb) + preview = f"Шаблон: {template['name']}\n\n{template['content']}" + + await query.edit_message_text( + text=preview, + reply_markup=reply_markup + ) + return BotStates.TEMPLATE_PREVIEW + +async def choose_template_cancel(update: Update, context: CallbackContext) -> int: + """Отмена выбора шаблона.""" + query = update.callback_query + if query: + await query.answer() + + if context.user_data is None: + context.user_data = {} + if "current_template" in context.user_data: + del context.user_data["current_template"] + + await choose_template_open(update, context) + + return BotStates.SELECT_TEMPLATE + +async def preview_collect_vars(update: Update, context: CallbackContext) -> int: + """Сбор переменных для шаблона.""" + query = update.callback_query + if not query: + return BotStates.TEMPLATE_VARS + + await query.answer() + # Здесь будет логика сбора переменных + return BotStates.TEMPLATE_VARS + +async def preview_confirm(update: Update, context: CallbackContext) -> int: + """Подтверждение использования шаблона.""" + query = update.callback_query + if not query: + return BotStates.TEMPLATE_VARS + + await query.answer() + if context.user_data is None: + context.user_data = {} + template = context.user_data.get("current_template") + + if not template: + await query.edit_message_text( + text="Ошибка: шаблон не выбран", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton(text="🔙 Назад", callback_data="tpl:choose") + ]]) + ) + return BotStates.SELECT_TEMPLATE + + # Применяем шаблон и возвращаемся к созданию поста + context.user_data["current_post"] = { + "type": template["type"], + "content": template["content"], + "keyboard": template["keyboard"] + } + + await query.edit_message_text( + text="Шаблон применен. Выберите канал для публикации:", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton(text="Выбрать канал", callback_data="channel:select") + ]]) + ) + return BotStates.CHANNEL_SELECT_BOT + +async def choose_template_open(update: Update, context: CallbackContext) -> int: + """Открыть список шаблонов для выбора.""" + query = update.callback_query + if query: + await query.answer() + + kb = [] + user_data = context.user_data or {} + user_id = user_data.get("user_id") + + async with async_session_maker() as session: + # Получаем все публичные шаблоны и приватные шаблоны пользователя + conditions = [Template.visibility == TemplateVisibility.public] + if user_id: + conditions.append(Template.owner_id == user_id) + + stmt = select(Template).where(or_(*conditions)) + templates = (await session.execute(stmt)).scalars().all() + + for template in templates: + kb.append([ + InlineKeyboardButton( + text=f"📝 {template.name}", + callback_data=f"tpl:select:{template.id}" + ) + ]) + + kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="back")]) + + reply_markup = InlineKeyboardMarkup(kb) + text = "Выберите шаблон из списка:" + + if query: + await query.edit_message_text(text=text, reply_markup=reply_markup) + elif update.message: + await update.message.reply_text(text=text, reply_markup=reply_markup) + + return BotStates.SELECT_TEMPLATE + +def validate_url(url: str | None) -> bool: + """ + Проверка корректности URL. + + Args: + url: URL для проверки + + Returns: + True если URL корректен, False в противном случае + """ + if not url: + return False + + try: + result = urlparse(url) + return bool(result.scheme in ('http', 'https') and result.netloc) + except Exception: + return False + +async def tpl_new_start(update: Update, context: CallbackContext) -> int: + """Начало создания нового шаблона.""" + message = update.effective_message + if not message: + return BotStates.CONVERSATION_END + + await message.reply_text( + "📝 Создание нового шаблона\n\n" + "Введите название шаблона (максимум 100 символов):" + ) + return BotStates.TPL_NEW_NAME + +async def tpl_new_name(update: Update, context: CallbackContext) -> int: + """Обработка ввода имени шаблона.""" + message = update.effective_message + if not message or not message.text: + return BotStates.CONVERSATION_END + + name = message.text.strip() + if len(name) > 100: + await message.reply_text("❌ Слишком длинное название (максимум 100 символов)") + return BotStates.TPL_NEW_NAME + + if not isinstance(context.user_data, dict): + context.user_data = {} + + context.user_data["template_name"] = name + + keyboard = [ + [{"text": "📝 Текст", "callback_data": "type:text"}], + [{"text": "🖼 Фото", "callback_data": "type:photo"}], + [{"text": "🎥 Видео", "callback_data": "type:video"}], + [{"text": "🎭 Анимация", "callback_data": "type:animation"}] + ] + markup = create_inline_keyboard(keyboard) + + await message.reply_text( + "📋 Выберите тип шаблона:", + reply_markup=markup + ) + return BotStates.TPL_NEW_TYPE + +async def tpl_new_type(update: Update, context: CallbackContext) -> int: + """Обработка выбора типа шаблона.""" + query = update.callback_query + if not query or not query.data or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + await query.answer() + type_name = query.data.split(":", 1)[1] + + context.user_data["template_type"] = type_name + + keyboard = [ + [{"text": "HTML", "callback_data": "format:html"}], + [{"text": "Markdown", "callback_data": "format:markdown"}], + [{"text": "Простой текст", "callback_data": "format:plain"}] + ] + markup = create_inline_keyboard(keyboard) + + await query.edit_message_text( + "📋 Выберите формат текста:", + reply_markup=markup + ) + return BotStates.TPL_NEW_FORMAT + +async def tpl_new_format(update: Update, context: CallbackContext) -> int: + """Обработка выбора формата текста.""" + query = update.callback_query + if not query or not query.data or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + await query.answer() + format_name = query.data.split(":", 1)[1] + + context.user_data["template_format"] = format_name + + await query.edit_message_text( + "📝 Введите содержимое шаблона\n\n" + "Используйте следующий синтаксис для переменных:\n" + "{{variable_name}}\n\n" + "Например: Здравствуйте, {{name}}!" + ) + return BotStates.TPL_NEW_CONTENT + +async def tpl_new_content(update: Update, context: CallbackContext) -> int: + """Обработка ввода содержимого шаблона.""" + message = update.effective_message + if not message or not message.text or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + context.user_data["template_content"] = message.text + + await message.reply_text( + "⌨️ Введите разметку клавиатуры в формате:\n" + "Кнопка 1 -> http://example1.com\n" + "Кнопка 2 -> http://example2.com\n\n" + "Или /skip чтобы пропустить" + ) + return BotStates.TPL_NEW_KB + +async def tpl_new_kb(update: Update, context: CallbackContext) -> int: + """Обработка ввода клавиатуры для шаблона.""" + message = update.effective_message + if not message or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + if message.text == "/skip": + keyboard_tpl = None + else: + # Парсим разметку клавиатуры + lines = validate_text(message.text) + keyboard_tpl = [] + row = [] + + try: + for line in lines: + if not line: + continue + + parts = line.split("->", 1) + if len(parts) != 2: + raise ValueError("Неверный формат кнопки") + + text = parts[0].strip() + url = parts[1].strip() + + # Проверяем URL + if not validate_url(url): + raise ValueError(f"Недопустимый URL: {url}") + + row.append({"text": text, "url": url}) + + if len(row) >= 2: + keyboard_tpl.append(row) + row = [] + + if row: + keyboard_tpl.append(row) + + except ValueError as e: + await message.reply_text(f"❌ Ошибка в разметке клавиатуры: {str(e)}") + return BotStates.TPL_NEW_KB + + # Сохраняем шаблон + try: + template = Template( + owner_id=message.from_user.id if message.from_user else 0, + name=context.user_data["template_name"], + title=context.user_data["template_name"], + content=context.user_data["template_content"], + type=context.user_data["template_type"], + parse_mode=context.user_data["template_format"].upper(), + keyboard_tpl=keyboard_tpl if keyboard_tpl else None + ) + + async with async_session_maker() as session: + session.add(template) + await session.commit() + + await message.reply_text("✅ Шаблон успешно создан!") + + except Exception as e: + await message.reply_text(f"❌ Ошибка при сохранении шаблона: {str(e)}") + + return BotStates.CONVERSATION_END + +async def tpl_list(update: Update, context: CallbackContext) -> int: + """Просмотр списка шаблонов.""" + message = update.effective_message + if not message: + return BotStates.CONVERSATION_END + + try: + async with async_session_maker() as session: + # Получаем шаблоны пользователя + user_id = message.from_user.id if message.from_user else 0 + result = await session.execute( + select(Template).where(Template.owner_id == user_id) + ) + templates = list(result.scalars()) + + if not templates: + await message.reply_text( + "📂 У вас пока нет шаблонов\n\n" + "Используйте /new_template чтобы создать" + ) + return BotStates.CONVERSATION_END + + # Формируем список + text = "📂 Ваши шаблоны:\n\n" + for tpl in templates: + text += f"📑 {tpl.name} ({tpl.type})\n" + + await message.reply_text(text) + + except Exception as e: + await message.reply_text("❌ Ошибка при получении списка шаблонов") + + return BotStates.CONVERSATION_END + +from telegram import ( + Update, + InlineKeyboardMarkup, + InlineKeyboardButton, + Message, + Chat, +) +from telegram.constants import MessageType, ParseMode, ChatType +from telegram.ext import CallbackContext, ConversationHandler + +from sqlalchemy import select +from app.models.bot import Bot as BotModel +from app.models.channel import Channel as ChannelModel +from app.bots.editor.states import BotStates + +# Message states and types +TYPE_PHOTO = "photo" +TYPE_VIDEO = "video" +TYPE_ANIMATION = "animation" + +# Type definitions +ButtonData = Dict[str, str] # {'text': str, 'url': str} | {'text': str, 'callback_data': str} +UserData = Dict[str, Any] # Type alias for telegram context user_data + +# Message type mappings +TYPE_MAP = { + TYPE_PHOTO: 'фотографию', + TYPE_VIDEO: 'видео', + TYPE_ANIMATION: 'анимацию' +} + +def get_media_type_text(post_type: str | None) -> str: + """Get human-readable media type description.""" + if not post_type: + return 'медиафайл' + return TYPE_MAP.get(post_type, 'медиафайл') + +def create_keyboard_button(text: str, url: str | None = None, callback_data: str | None = None) -> InlineKeyboardButton: + """Create an InlineKeyboardButton with either url or callback_data.""" + if url: + return InlineKeyboardButton(text=text, url=url) + return InlineKeyboardButton(text=text, callback_data=callback_data or text) + +def create_inline_markup(buttons: list[list[ButtonData]]) -> InlineKeyboardMarkup: + """ + Create an inline keyboard from button data. + + Args: + buttons: List of button rows, each containing dicts with 'text' and either 'url' or 'callback_data' + + Returns: + Formatted InlineKeyboardMarkup ready to use in messages + """ + keyboard = [] + for row in buttons: + kb_row = [] + for btn in row: + text = btn.get('text', '') + url = btn.get('url') + callback_data = btn.get('callback_data', text) + kb_row.append(create_keyboard_button(text, url, callback_data)) + if kb_row: + keyboard.append(kb_row) + + return InlineKeyboardMarkup(keyboard) + +# Message states and types +TYPE_PHOTO = "photo" +TYPE_VIDEO = "video" +TYPE_ANIMATION = "animation" + +MediaType = Literal[TYPE_PHOTO, TYPE_VIDEO, TYPE_ANIMATION] + +type_map: Dict[str, str] = { + TYPE_PHOTO: 'фотографию', + TYPE_VIDEO: 'видео', + TYPE_ANIMATION: 'анимацию' +} + +# Helper functions +def extract_text(msg: Message | None) -> str | None: + """Extract text from message.""" + try: + return msg.text if msg and msg.text else None + except AttributeError: + return None + +def validate_text(text: str | None) -> list[str]: + """Validate and split text into lines, removing empty lines.""" + if not text: + return [] + + try: + return [line for line in text.strip().split("\n") if line.strip()] + except (AttributeError, Exception): + return [] + +def validate_keyboard_data(data: Any) -> list[list[dict[str, str]]]: + """ + Validate and normalize keyboard data. + + Args: + data: Raw keyboard data dictionary that should contain 'keyboard' key + with a list of button rows + + Returns: + List of valid keyboard rows, where each row is a list of button dictionaries + with at least a 'text' key + """ + if not data or not isinstance(data, dict): + return [] + + keyboard = data.get('keyboard', []) + if not keyboard or not isinstance(keyboard, list): + return [] + + result = [] + for row in keyboard: + if isinstance(row, list): + valid_row = [] + for btn in row: + if isinstance(btn, dict) and 'text' in btn: + valid_row.append(btn) + if valid_row: + result.append(valid_row) + + return result + +def create_inline_keyboard(buttons: list[list[dict[str, str]]]) -> InlineKeyboardMarkup: + """ + Create an inline keyboard from validated button data. + + Args: + buttons: List of button rows, each containing button data dictionaries + + Returns: + InlineKeyboardMarkup object ready to be sent with a message + """ + keyboard = [] + for row in buttons: + kb_row = [] + for btn in row: + text = btn.get('text', '') + callback_data = btn.get('callback_data', text) + url = btn.get('url') + if url: + kb_row.append(InlineKeyboardButton(text=text, url=url)) + else: + kb_row.append(InlineKeyboardButton(text=text, callback_data=callback_data)) + if kb_row: + keyboard.append(kb_row) + + return InlineKeyboardMarkup(keyboard) + +def extract_chat_info(msg: Message | None) -> tuple[int | None, str | None]: + """Extract chat info from message.""" + try: + chat = msg.chat if msg else None + if chat and chat.type == ChatType.CHANNEL.value: + return chat.id, chat.title + except AttributeError: + pass + return None, None + +def is_valid_url(text: str | None) -> bool: + """Check if text is a valid URL.""" + try: + if not text: + return False + result = urlparse(text.strip()) + return bool(result.scheme and result.netloc) + except (AttributeError, Exception): + return False + +# User data and keyboard types +UserData = Dict[str, Any] +KeyboardType = List[List[Dict[str, str]]] + +def get_message_media_type(msg: Message) -> str | None: + """Get media type from message.""" + if not msg: + return None + + # Check message contents + if msg.photo: + return TYPE_PHOTO + elif msg.video: + return TYPE_VIDEO + elif msg.animation: + return TYPE_ANIMATION + return None + +def get_message_text(msg: Message) -> str | None: + """Get text content from message.""" + return msg.text if msg and msg.text else None + +def get_message_media_text(msg: Message) -> str: + """Get human-readable media type description.""" + media_type = get_message_media_type(msg) + if not media_type: + return 'медиафайл' + return type_map.get(media_type, 'медиафайл') + +def get_media_type_str(msg: Message) -> str: + """Get media type as string.""" + media_type = get_message_media_type(msg) + if not media_type: + return 'медиафайл' + return type_map.get(media_type, 'медиафайл') + +def get_post_type_text(post_type: str | None) -> str: + """Get human-readable text for media type.""" + if not post_type: + return 'медиафайл' + return type_map.get(str(post_type), 'медиафайл') + +def check_media_type(msg: Message) -> str | None: + """Check message media type.""" + if not msg: + return None + + if msg.photo: + return TYPE_PHOTO + elif msg.video: + return TYPE_VIDEO + elif msg.animation: + return TYPE_ANIMATION + return None + +def get_channel_info(msg: Message) -> tuple[int | None, str | None]: + """Extract channel info from message.""" + if not msg or not msg.chat: + return None, None + + chat = msg.chat + if not isinstance(chat, Chat) or chat.type != ChatType.CHANNEL.value: + return None, None + + return chat.id, chat.title + +def process_message_text(text: str | None) -> list[str]: + """Process and split message text into lines.""" + if not text: + return [] + + try: + return [line for line in text.strip().split("\n") if line] + except AttributeError: + return [] + +def process_keyboard_data(data: Any) -> list[list[dict[str, str]]]: + """Process and validate keyboard data.""" + if not data or not isinstance(data, dict): + return [] + + keyboard = data.get('keyboard', []) + if not isinstance(keyboard, list): + return [] + + return [ + [btn for btn in row if isinstance(btn, dict)] + for row in keyboard + if isinstance(row, list) + ] + +def count_keyboard_buttons(data: Any) -> int: + """Count total buttons in keyboard data.""" + keyboard = process_keyboard_data(data) + return sum(len(row) for row in keyboard) + +def extract_message_text(msg: Message) -> str | None: + """Extract text content from message.""" + return msg.text if msg and hasattr(msg, 'text') else None + +def check_url(text: str) -> bool: + """Check if text is a valid URL.""" + if not text: + return False + + try: + result = urlparse(text) + return bool(result.scheme and result.netloc) + except Exception: + return False + +from app.models.bot import Bot as BotModel +from app.models.channel import Channel as ChannelModel +from app.bots.editor.states import BotStates + + +# Type hints +PostType = Union[MessageType.PHOTO, MessageType.VIDEO, MessageType.ANIMATION, str, None] +UserData = Dict[str, Any] + +type_map = { + MessageType.PHOTO: 'фотографию', + MessageType.VIDEO: 'видео', + MessageType.ANIMATION: 'анимацию' +} + +from app.db.session import async_session_maker +from app.models.bot import Bot as BotModel +from app.models.channel import Channel as ChannelModel +from app.services.telegram import validate_bot_token + +from ..states.base import BotStates +from .messages import Messages +from .session import UserSession + + +async def start(update: Update, context: CallbackContext) -> int: + """ + Обработчик команды /start. Показывает приветственное сообщение. + """ + message = update.effective_message + if not message: + return BotStates.CONVERSATION_END + + await message.reply_text(Messages.WELCOME_MESSAGE) + return BotStates.CONVERSATION_END + + +async def help_command(update: Update, context: CallbackContext) -> int: + """ + Обработчик команды /help. Показывает справочное сообщение. + """ + message = update.effective_message + if not message: + return BotStates.CONVERSATION_END + + await message.reply_text(Messages.HELP_MESSAGE) + return BotStates.CONVERSATION_END + + +async def newpost(update: Update, context: CallbackContext) -> int: + """ + Начало создания нового поста. + Команда: /newpost + """ + message = update.effective_message + user = update.effective_user + if not message or not user: + return BotStates.CONVERSATION_END + + try: + # Получаем список каналов пользователя + async with async_session_maker() as session: + result = await session.execute( + select(ChannelModel).where(ChannelModel.owner_id == user.id) + ) + channels = list(result.scalars()) + + if not channels: + await message.reply_text( + "❌ У вас нет добавленных каналов.\n" + "Используйте /add_channel чтобы добавить канал." + ) + return BotStates.CONVERSATION_END + + # Создаем клавиатуру выбора канала + keyboard = [] + for channel in channels: + if channel.bot: + title = f"{channel.title or channel.chat_id} (@{channel.bot.username})" + else: + title = str(channel.chat_id) + keyboard.append([ + InlineKeyboardButton( + title, + callback_data=f"channel:{channel.id}" + ) + ]) + markup = InlineKeyboardMarkup(keyboard) + + await message.reply_text( + "📢 Выберите канал для публикации:", + reply_markup=markup + ) + return BotStates.CHOOSE_CHANNEL + + except Exception as e: + await message.reply_text( + "❌ Произошла ошибка при получении списка каналов." + ) + return BotStates.CONVERSATION_END + + +async def choose_channel(update: Update, context: CallbackContext) -> int: + """ + Обработка выбора канала. + """ + query = update.callback_query + if not query or not query.data: + return BotStates.CONVERSATION_END + + try: + await query.answer() + channel_id = int(query.data.split(":", 1)[1]) + + # Сохраняем выбранный канал + if isinstance(context.user_data, dict): + context.user_data["channel_id"] = channel_id + + # Показываем выбор типа поста + keyboard = [ + [InlineKeyboardButton("📝 Текст", callback_data="type:text")], + [InlineKeyboardButton("🖼️ Фото", callback_data="type:photo")], + [InlineKeyboardButton("🎥 Видео", callback_data="type:video")], + [InlineKeyboardButton("📹 Анимация", callback_data="type:animation")], + ] + markup = InlineKeyboardMarkup(keyboard) + + await query.edit_message_text( + "📝 Выберите тип поста:", + reply_markup=markup + ) + return BotStates.CHOOSE_TYPE + + except ValueError: + await query.edit_message_text("❌ Неверный формат данных") + return BotStates.CONVERSATION_END + except Exception as e: + await query.edit_message_text("❌ Произошла ошибка.") + return BotStates.CONVERSATION_END + + +async def choose_type(update: Update, context: CallbackContext) -> int: + """ + Обработка выбора типа поста. + """ + query = update.callback_query + if not query or not query.data or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + try: + await query.answer() + post_type = query.data.split(":", 1)[1] + context.user_data["post_type"] = post_type + + # Спрашиваем формат текста + keyboard = [ + [InlineKeyboardButton("📄 Обычный текст", callback_data="fmt:plain")], + [InlineKeyboardButton("🔠 Markdown", callback_data="fmt:markdown")], + [InlineKeyboardButton("🌐 HTML", callback_data="fmt:html")], + ] + markup = InlineKeyboardMarkup(keyboard) + + await query.edit_message_text( + "📝 Выберите формат текста:", + reply_markup=markup + ) + return BotStates.CHOOSE_FORMAT + + except ValueError: + await query.edit_message_text("❌ Неверный формат данных") + return BotStates.CONVERSATION_END + except Exception as e: + await query.edit_message_text("❌ Произошла ошибка.") + return BotStates.CONVERSATION_END + + +async def choose_format(update: Update, context: CallbackContext) -> int: + """ + Обработка выбора формата текста. + """ + query = update.callback_query + if not query or not query.data or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + try: + await query.answer() + text_format = query.data.split(":", 1)[1] + context.user_data["text_format"] = text_format + + keyboard = [ + [InlineKeyboardButton("📋 Из шаблона", callback_data="tpl:choose")] + ] + markup = InlineKeyboardMarkup(keyboard) + + await query.edit_message_text( + "📝 Отправьте текст поста\n" + "Или выберите шаблон:", + reply_markup=markup + ) + return BotStates.ENTER_TEXT + + except ValueError: + await query.edit_message_text("❌ Неверный формат данных") + return BotStates.CONVERSATION_END + except Exception as e: + await query.edit_message_text("❌ Произошла ошибка.") + return BotStates.CONVERSATION_END + + +async def enter_text(update: Update, context: CallbackContext) -> int: + """ + Обработка ввода текста поста. + """ + message = update.effective_message + if not message or not message.text or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + text = message.text + post_type = context.user_data.get("post_type") + + if not post_type: + await message.reply_text("❌ Ошибка: тип поста не выбран") + return BotStates.CONVERSATION_END + + context.user_data["text"] = text + + if post_type == "text": + # Для текстового поста сразу переходим к клавиатуре + await message.reply_text( + "⌨️ Отправьте разметку клавиатуры в формате:\n" + "Кнопка 1 -> http://example1.com\n" + "Кнопка 2 -> http://example2.com\n\n" + "Или /skip чтобы пропустить" + ) + return BotStates.EDIT_KEYBOARD + else: + # Для медиа постов запрашиваем файл + msg = { + "photo": "🖼️ Отправьте фотографию", + "video": "🎥 Отправьте видео", + "animation": "📹 Отправьте анимацию" + } + await message.reply_text(msg.get(post_type, "❌ Неизвестный тип поста")) + return BotStates.ENTER_MEDIA + + +async def add_bot_start(update: Update, context: CallbackContext) -> int: + """ + Начало процесса добавления нового бота. + Команда: /add_bot + """ + message = update.effective_message + if not message: + return BotStates.CONVERSATION_END + + await message.reply_text( + "🤖 Отправьте токен бота, полученный от @BotFather\n" + "Или /cancel для отмены" + ) + return BotStates.BOT_TOKEN + +async def add_bot_token(update: Update, context: CallbackContext) -> int: + """ + Обработка токена бота. + """ + message = update.effective_message + user = update.effective_user + if not message or not message.text or not user: + return BotStates.CONVERSATION_END + + token = message.text.strip() + + # Валидация токена + is_valid, username, bot_id = await validate_bot_token(token) + if not is_valid or not username or not bot_id: + await message.reply_text( + "❌ Неверный токен бота. Проверьте токен и попробуйте снова.\n" + "Или /cancel для отмены" + ) + return BotStates.BOT_TOKEN + + try: + # Проверяем, не добавлен ли уже этот бот + async with async_session_maker() as session: + existing = await session.execute( + select(BotModel).where(BotModel.bot_id == bot_id) + ) + if existing.scalar_one_or_none(): + await message.reply_text( + "❌ Этот бот уже добавлен в систему." + ) + return BotStates.CONVERSATION_END + + # Сохраняем бота + new_bot = BotModel( + owner_id=user.id, + bot_id=bot_id, + username=username, + token=token, + ) + session.add(new_bot) + await session.commit() + + await message.reply_text( + f"✅ Бот @{username} успешно добавлен!\n\n" + "Теперь вы можете:\n" + "1. Добавить каналы через /add_channel\n" + "2. Создать шаблоны через /tpl_new\n" + "3. Начать создание поста через /newpost" + ) + return BotStates.CONVERSATION_END + + except Exception as e: + await message.reply_text( + "❌ Произошла ошибка при добавлении бота. Попробуйте позже." + ) + return BotStates.CONVERSATION_END + +async def list_bots(update: Update, context: CallbackContext) -> None: + """ + Список ботов пользователя. + Команда: /bots + """ + message = update.effective_message + user = update.effective_user + if not message or not user: + return + + try: + async with async_session_maker() as session: + result = await session.execute( + select(BotModel).where(BotModel.owner_id == user.id) + ) + bots = list(result.scalars()) + + if not bots: + await message.reply_text( + "🤖 У вас пока нет добавленных ботов.\n" + "Используйте /add_bot чтобы добавить бота." + ) + return + + text = "🤖 Ваши боты:\n\n" + for bot in bots: + text += f"@{bot.username}\n" + + await message.reply_text(text) + + except Exception as e: + await message.reply_text( + "❌ Произошла ошибка при получении списка ботов." + ) + + +async def enter_media(update: Update, context: CallbackContext) -> int: + """ + Обработка загрузки медиафайла. + + Args: + update: Telegram update + context: Контекст с данными пользователя + + Returns: + Следующее состояние разговора + """ + message = update.effective_message + if not message or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + # Проверяем тип медиа + post_type = context.user_data.get("post_type") + file_id = None + + if post_type == TYPE_PHOTO and message.photo: + # Берем самую большую версию фото + file_id = message.photo[-1].file_id + elif post_type == TYPE_VIDEO and message.video: + file_id = message.video.file_id + elif post_type == TYPE_ANIMATION and message.animation: + file_id = message.animation.file_id + + if not file_id: + media_type = get_media_type_text(post_type) + await message.reply_text( + f"❌ Пожалуйста, отправьте {media_type}" + ) + return BotStates.ENTER_MEDIA + + # Сохраняем файл + context.user_data["media_file_id"] = file_id + + # Переходим к клавиатуре + await message.reply_text( + "⌨️ Отправьте разметку клавиатуры в формате:\n" + "Кнопка 1 -> http://example1.com\n" + "Кнопка 2 -> http://example2.com\n\n" + "Или /skip чтобы пропустить" + ) + return BotStates.EDIT_KEYBOARD + + +async def edit_keyboard(update: Update, context: CallbackContext) -> int: + """ + Обработка разметки клавиатуры. + + Args: + update: Telegram update объект + context: Контекст обработчика с user_data типа UserData + + Returns: + Следующее состояние разговора + """ + message = update.effective_message + if not message or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + text = message.text + if not text: + await message.reply_text("❌ Отправьте текст разметки клавиатуры или /skip для пропуска") + return BotStates.EDIT_KEYBOARD + + # Пропускаем клавиатуру + if text == "/skip": + context.user_data["keyboard"] = None + else: + # Парсим разметку + keyboard: list[list[ButtonData]] = [] + row: list[ButtonData] = [] + try: + # Обрабатываем каждую строку разметки + for line in text.strip().split("\n"): + if not line: + continue + + # Разбираем строку на текст и URL + parts = line.split("->", 1) + if len(parts) != 2: + raise ValueError("Неверный формат кнопки. Используйте: Текст -> URL") + + label = parts[0].strip() + url = parts[1].strip() + + # Проверяем URL + if not validate_url(url): + raise ValueError(f"Недопустимый URL: {url}") + + # Проверяем длину текста кнопки + if len(label) > 64: + raise ValueError(f"Текст кнопки слишком длинный (макс. 64 символа): {label}") + + # Добавляем кнопку в текущий ряд + row.append({"text": label, "url": url}) + + # Переход на новую строку после 2 кнопок + if len(row) >= 2: # Максимум 2 кнопки в ряду + keyboard.append(row) + row = [] + + # Добавляем оставшиеся кнопки + if row: + keyboard.append(row) + + # Проверяем общее количество кнопок + total_buttons = sum(len(row) for row in keyboard) + if total_buttons > 10: + raise ValueError("Слишком много кнопок (максимум 10)") + + context.user_data["keyboard"] = keyboard + + except ValueError as e: + await message.reply_text(f"❌ Ошибка в разметке клавиатуры: {str(e)}") + return BotStates.EDIT_KEYBOARD + + # Готовим предпросмотр + if not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + preview_text = context.user_data.get("text", "") + post_type = context.user_data.get("post_type") + + # Обрезаем длинный текст + if len(preview_text) > 50: + preview_text = preview_text[:47] + "..." + + # Создаем клавиатуру выбора действия + keyboard_data = [ + [{"text": "✅ Отправить сейчас", "callback_data": "send:now"}], + [{"text": "⏰ Отложить", "callback_data": "send:schedule"}] + ] + markup = create_inline_keyboard(keyboard_data) + + # Информация о медиафайле + media_info = "" + if post_type: + media_info = f"\n📎 Прикреплен файл: {get_media_type_text(post_type)}" + + # Информация о клавиатуре + kb_info = "" + keyboard = context.user_data.get("keyboard", []) + if keyboard and isinstance(keyboard, list): + try: + kb_count = sum(len(row) for row in keyboard) + kb_info = f"\n⌨️ Клавиатура: {kb_count} кнопок" + except (TypeError, AttributeError): + pass + + await message.reply_text( + f"📝 Предпросмотр:\n\n{preview_text}\n{media_info}{kb_info}\n\n" + "Выберите действие:", + reply_markup=markup + ) + return BotStates.CONFIRM_SEND + + +async def confirm_send(update: Update, context: CallbackContext) -> int: + """ + Обработка подтверждения отправки. + """ + query = update.callback_query + if not query or not query.data or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + try: + await query.answer() + action = query.data.split(":", 1)[1] + + if action == "now": + # TODO: Отправка поста + await query.edit_message_text( + "✅ Пост успешно отправлен!" + ) + return BotStates.CONVERSATION_END + + elif action == "schedule": + await query.edit_message_text( + "⏰ Отправьте дату и время публикации в формате:\n" + "ДД.ММ.ГГГГ ЧЧ:ММ\n" + "Например: 31.12.2025 23:59" + ) + return BotStates.ENTER_SCHEDULE + + else: + await query.edit_message_text("❌ Неизвестное действие") + return BotStates.CONVERSATION_END + + except (ValueError, AttributeError): + await query.edit_message_text("❌ Неверный формат данных") + return BotStates.CONVERSATION_END + except Exception: + await query.edit_message_text("❌ Произошла ошибка") + return BotStates.CONVERSATION_END + + +async def enter_schedule(update: Update, context: CallbackContext) -> int: + """ + Обработка времени отложенной публикации. + + Args: + update: Telegram update + context: Контекст с данными пользователя + + Returns: + Следующее состояние разговора + """ + message = update.effective_message + if not message or not message.text: + return BotStates.CONVERSATION_END + + try: + # Парсим дату и время + text = message.text.strip() + dt = datetime.strptime(text, "%d.%m.%Y %H:%M") + now = datetime.now() + + if dt <= now: + await message.reply_text( + "❌ Дата публикации должна быть в будущем" + ) + return BotStates.ENTER_SCHEDULE + + if isinstance(context.user_data, dict): + context.user_data["schedule"] = dt.isoformat() + + # TODO: Сохранение отложенного поста + + await message.reply_text( + f"✅ Пост запланирован на {dt.strftime('%d.%m.%Y %H:%M')}" + ) + return BotStates.CONVERSATION_END + + except ValueError: + await message.reply_text( + "❌ Неверный формат даты и времени.\n" + "Используйте формат ДД.ММ.ГГГГ ЧЧ:ММ" + ) + return BotStates.ENTER_SCHEDULE + + return BotStates.CONVERSATION_END + +async def add_channel_start(update: Update, context: CallbackContext) -> int: + """ + Начало процесса добавления канала. + Команда: /add_channel + """ + message = update.effective_message + user = update.effective_user + if not message or not user: + return BotStates.CONVERSATION_END + + try: + # Получаем список ботов пользователя + async with async_session_maker() as session: + result = await session.execute( + select(BotModel).where(BotModel.owner_id == user.id) + ) + bots = list(result.scalars()) + + if not bots: + await message.reply_text( + "❌ Сначала добавьте бота через /add_bot" + ) + return BotStates.CONVERSATION_END + + # Создаем клавиатуру выбора бота + keyboard = [] + for bot in bots: + keyboard.append([ + InlineKeyboardButton( + f"@{bot.username}", + callback_data=f"addch_bot:{bot.id}" + ) + ]) + markup = InlineKeyboardMarkup(keyboard) + + await message.reply_text( + "🤖 Выберите бота для добавления канала:", + reply_markup=markup + ) + return BotStates.CHANNEL_SELECT_BOT + + except Exception as e: + await message.reply_text( + "❌ Произошла ошибка. Попробуйте позже." + ) + return BotStates.CONVERSATION_END + +async def add_channel_bot_selected(update: Update, context: CallbackContext) -> int: + """ + Обработка выбора бота для канала. + """ + query = update.callback_query + user = update.effective_user + if not query or not query.data or not user: + return BotStates.CONVERSATION_END + + try: + await query.answer() + bot_id = int(query.data.split(":", 1)[1]) + + # Сохраняем выбранного бота в контекст + if isinstance(context.user_data, dict): + context.user_data["selected_bot_id"] = bot_id + + await query.edit_message_text( + "📢 Добавьте бота в канал как администратора и отправьте:\n\n" + "1. Username канала (например @channel)\n" + "2. Или ID канала (например -100...)\n" + "3. Или перешлите любое сообщение из канала\n\n" + "Или /cancel для отмены" + ) + return BotStates.CHANNEL_INPUT + + except ValueError: + await query.edit_message_text("❌ Неверный формат данных") + return BotStates.CONVERSATION_END + except Exception as e: + await query.edit_message_text("❌ Произошла ошибка. Попробуйте позже.") + return BotStates.CONVERSATION_END + +async def add_channel_input(update: Update, context: CallbackContext) -> int: + """ + Обработка ввода канала. + """ + message = update.effective_message + user = update.effective_user + if not message or not user or not isinstance(context.user_data, dict): + return BotStates.CONVERSATION_END + + bot_id = context.user_data.get("selected_bot_id") + if not bot_id: + await message.reply_text("❌ Ошибка: не выбран бот") + return BotStates.CONVERSATION_END + + try: + # Определяем ID канала + channel_id: Optional[int] = None + + # Проверяем информацию о канале + channel_id, channel_title = extract_chat_info(message) + + # Если не нашли, пробуем из текста + if not channel_id: + text = extract_text(message) + if text: + if text.startswith('@'): + # TODO: Получить chat_id по username через Bot API + pass + elif text.startswith('-100'): + try: + channel_id = int(text) + except ValueError: + pass + + if not channel_id: + await message.reply_text( + "❌ Не удалось определить ID канала.\n" + "Попробуйте переслать сообщение из канала\n" + "Или /cancel для отмены" + ) + return BotStates.CHANNEL_INPUT + + # Проверяем права бота в канале + async with async_session_maker() as session: + bot_result = await session.execute( + select(BotModel).where(BotModel.id == bot_id) + ) + bot = bot_result.scalar_one_or_none() + if not bot: + await message.reply_text("❌ Ошибка: бот не найден") + return BotStates.CONVERSATION_END + + # TODO: Проверить права бота в канале через Bot API + + # Проверяем, не добавлен ли уже канал + existing = await session.execute( + select(ChannelModel).where( + ChannelModel.chat_id == channel_id, + ChannelModel.bot_id == bot_id + ) + ) + if existing.scalar_one_or_none(): + await message.reply_text("❌ Этот канал уже добавлен") + return BotStates.CONVERSATION_END + + # Сохраняем канал + new_channel = ChannelModel( + owner_id=user.id, + bot_id=bot_id, + chat_id=channel_id, + title=channel_title if channel_title else None + ) + session.add(new_channel) + await session.commit() + + await message.reply_text( + "✅ Канал успешно добавлен!\n" + "Теперь вы можете создать пост через /newpost" + ) + return BotStates.CONVERSATION_END + + except Exception as e: + await message.reply_text( + "❌ Произошла ошибка при добавлении канала.\n" + "Убедитесь, что бот добавлен в канал как администратор." + ) + return BotStates.CONVERSATION_END + +async def list_channels(update: Update, context: CallbackContext) -> None: + """ + Список каналов пользователя. + Команда: /channels + """ + message = update.effective_message + user = update.effective_user + if not message or not user: + return + + try: + async with async_session_maker() as session: + result = await session.execute( + select(ChannelModel) + .where(ChannelModel.owner_id == user.id) + .order_by(ChannelModel.bot_id) + ) + channels = list(result.scalars()) + + if not channels: + await message.reply_text( + "📢 У вас пока нет добавленных каналов.\n" + "Используйте /add_channel чтобы добавить канал." + ) + return + + text = "📢 Ваши каналы:\n\n" + current_bot_id = None + + for channel in channels: + if channel.bot_id != current_bot_id: + current_bot_id = channel.bot_id + if channel.bot: + text += f"\n🤖 Бот @{channel.bot.username}:\n" + + title = channel.title or str(channel.chat_id) + text += f"- {title}\n" + + await message.reply_text(text) + + except Exception as e: + await message.reply_text( + "❌ Произошла ошибка при получении списка каналов." + ) diff --git a/app/bots/editor/handlers/__init__.py b/app/bots/editor/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bots/editor/handlers/base.py b/app/bots/editor/handlers/base.py new file mode 100644 index 0000000..ecf3031 --- /dev/null +++ b/app/bots/editor/handlers/base.py @@ -0,0 +1,43 @@ +"""Базовые обработчики.""" +from telegram import Update +from telegram.ext import ContextTypes, ConversationHandler + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработчик команды /start.""" + if not update.message: + return ConversationHandler.END + + await update.message.reply_text( + "Привет! Я бот для управления постами.\n" + "Для создания шаблона используйте /newtemplate\n" + "Для создания поста используйте /newpost\n" + "Для просмотра шаблонов /templates\n" + "Для помощи /help" + ) + return ConversationHandler.END + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработчик команды /help.""" + if not update.message: + return ConversationHandler.END + + await update.message.reply_text( + "Доступные команды:\n" + "/start - начать работу с ботом\n" + "/newtemplate - создать новый шаблон\n" + "/templates - просмотреть существующие шаблоны\n" + "/newpost - создать новый пост\n" + "/cancel - отменить текущую операцию" + ) + return ConversationHandler.END + +async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Отмена текущей операции.""" + if not update.message: + return ConversationHandler.END + + await update.message.reply_text( + "Операция отменена.", + reply_markup=None + ) + return ConversationHandler.END diff --git a/app/bots/editor/handlers/posts.py b/app/bots/editor/handlers/posts.py new file mode 100644 index 0000000..29be458 --- /dev/null +++ b/app/bots/editor/handlers/posts.py @@ -0,0 +1,744 @@ +"""Обработчики для работы с постами.""" +from datetime import datetime +from logging import getLogger +import re +from typing import Dict, Any, Optional, cast, Union + +from telegram import ( + Update, + Message, + CallbackQuery, + ReplyKeyboardMarkup, + InlineKeyboardMarkup, + InlineKeyboardButton +) +from telegram.ext import ( + ContextTypes, + ConversationHandler +) +from telegram.helpers import escape_markdown +from telegram.constants import ChatAction, ParseMode +from telegram.error import BadRequest, Forbidden, TelegramError + +from ..session import UserSession, SessionStore +from ..states import BotStates +from ..keyboards import KbBuilder +from app.models.post import Post, PostType +from app.services.template import TemplateService +from app.services.channels import ChannelService +from app.services.telegram import PostService +from ..messages import MessageType + +logger = getLogger(__name__) + +def parse_key_value_lines(text: str) -> Dict[str, str]: + """Парсит строки в формате 'ключ = значение' в словарь.""" + if not text: + return {} + + result = {} + for line in text.split('\n'): + if '=' not in line: + continue + key, value = map(str.strip, line.split('=', 1)) + if key: + result[key] = value + return result + +logger = getLogger(__name__) + +logger = getLogger(__name__) + +async def newpost(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Начало создания нового поста.""" + message = update.effective_message + user = update.effective_user + + if not message or not user: + return ConversationHandler.END + + try: + # Создаем новую сессию + session = SessionStore.get_instance().get(user.id) + session.clear() + + # Загружаем список каналов пользователя + channels = await ChannelService.get_user_channels(user.id) + if not channels: + await message.reply_text( + "У вас нет добавленных каналов. Используйте /add_channel чтобы добавить." + ) + return ConversationHandler.END + + kb = KbBuilder.channels(channels) + await message.reply_text( + "Выберите канал для публикации:", + reply_markup=kb + ) + return BotStates.CHOOSE_CHANNEL + + except Exception as e: + logger.error(f"Error in newpost: {e}") + await message.reply_text("Произошла ошибка при создании поста") + return ConversationHandler.END + +async def choose_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработка выбора канала.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + await message.edit_text("Неверный формат данных") + return ConversationHandler.END + + channel_id = int(query.data.replace("channel:", "")) + + # Проверяем существование канала + channel = await ChannelService.get_channel(channel_id) + if not channel: + await message.edit_text("Канал не найден") + return ConversationHandler.END + + session = SessionStore.get_instance().get(query.from_user.id) + session.channel_id = channel_id + + kb = KbBuilder.post_types() + await message.edit_text( + "Выберите тип поста:", + reply_markup=kb + ) + return BotStates.CHOOSE_TYPE + + except Exception as e: + logger.error(f"Error in choose_channel: {e}") + if query.message: + await query.message.edit_text("Произошла ошибка при выборе канала") + return ConversationHandler.END + +async def choose_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработка выбора типа поста.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + await message.edit_text("Неверный формат данных") + return ConversationHandler.END + + post_type = PostType(query.data.replace("type:", "")) + + session = SessionStore.get_instance().get(query.from_user.id) + session.type = post_type + + kb = KbBuilder.parse_modes() + await message.edit_text( + "Выберите формат текста:", + reply_markup=kb + ) + return BotStates.CHOOSE_FORMAT + + except Exception as e: + logger.error(f"Error in choose_type: {e}") + if query.message: + await query.message.edit_text("Произошла ошибка при выборе типа поста") + return ConversationHandler.END + +async def choose_format(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработка выбора формата текста.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + await message.edit_text("Неверный формат данных") + return ConversationHandler.END + + parse_mode = query.data.replace("fmt:", "") + if parse_mode not in [ParseMode.HTML, ParseMode.MARKDOWN_V2]: + await message.edit_text("Неизвестный формат текста") + return ConversationHandler.END + + session = SessionStore.get_instance().get(query.from_user.id) + session.parse_mode = parse_mode + + kb = KbBuilder.text_input_options() + await message.edit_text( + "Введите текст поста или выберите шаблон:", + reply_markup=kb + ) + return BotStates.ENTER_TEXT + + except Exception as e: + logger.error(f"Error in choose_format: {e}") + if query.message: + await query.message.edit_text("Произошла ошибка при выборе формата") + return ConversationHandler.END + +async def enter_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработка ввода текста поста.""" + message = update.effective_message + user = update.effective_user + + if not message or not user: + return ConversationHandler.END + + text = message.text + if not text: + await message.reply_text("Пожалуйста, введите текст поста") + return BotStates.ENTER_TEXT + + try: + session = SessionStore.get_instance().get(user.id) + session.text = text + + if session.type == MessageType.TEXT: + await message.reply_text( + "Введите клавиатуру в формате:\n" + "текст кнопки = ссылка\n\n" + "Или отправьте 'skip' чтобы пропустить" + ) + return BotStates.EDIT_KEYBOARD + + await message.reply_text( + "Отправьте фото/видео/gif для поста" + ) + return BotStates.ENTER_MEDIA + + except Exception as e: + logger.error(f"Error in enter_text: {e}") + await message.reply_text("Произошла ошибка при сохранении текста") + return ConversationHandler.END + +async def choose_template_open(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Открытие выбора шаблона.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + user_id = query.from_user.id + + templates = await TemplateService.list_user_templates(user_id) + if not templates: + await message.edit_text( + "У вас нет шаблонов. Создайте новый с помощью /newtemplate" + ) + return BotStates.ENTER_TEXT + + total = len(templates) + if total == 0: + await message.edit_text("Список шаблонов пуст") + return BotStates.ENTER_TEXT + + user_data = context.user_data + if not user_data: + user_data = {} + context.user_data = user_data + + page = user_data.get("tpl_page", 0) + items = templates[page * KbBuilder.PAGE_SIZE:(page + 1) * KbBuilder.PAGE_SIZE] + + kb = KbBuilder.templates_list(items, page, total) + await message.edit_text( + "Выберите шаблон:", + reply_markup=kb + ) + return BotStates.SELECT_TEMPLATE + + except Exception as e: + logger.error(f"Error in choose_template_open: {e}") + if query.message: + await query.message.edit_text("Произошла ошибка при загрузке шаблонов") + return ConversationHandler.END + +async def choose_template_apply(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Применение выбранного шаблона.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + await message.edit_text("Неверный формат данных") + return BotStates.SELECT_TEMPLATE + + template_id = query.data.replace("tpluse:", "") + + template = await TemplateService.get_template(template_id) + if not template: + await message.edit_text("Шаблон не найден") + return BotStates.ENTER_TEXT + + session = SessionStore.get_instance().get(query.from_user.id) + session.template_id = template_id + session.text = template.content + + if "{" in template.content and "}" in template.content: + # Шаблон содержит переменные + await message.edit_text( + "Введите значения для переменных в формате:\n" + "переменная = значение" + ) + return BotStates.PREVIEW_VARS + + # Нет переменных, можно сразу показать предпросмотр + kb = KbBuilder.preview_confirm() + + post_data = session.to_dict() + await PostService.preview_post(message, post_data) + + await message.reply_text( + "Предпросмотр поста. Выберите действие:", + reply_markup=kb + ) + return BotStates.PREVIEW_CONFIRM + + except Exception as e: + logger.error(f"Error in choose_template_apply: {e}") + if query.message: + await query.message.edit_text( + "Произошла ошибка при применении шаблона" + ) + return BotStates.ENTER_TEXT + + except Exception as e: + logger.error(f"Ошибка при применении шаблона: {e}") + await query.message.edit_text( + f"Ошибка при применении шаблона: {str(e)}" + ) + return BotStates.ENTER_TEXT + +async def choose_template_preview(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Предпросмотр шаблона.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + await message.edit_text("Неверный формат данных") + return BotStates.SELECT_TEMPLATE + + template_id = query.data.replace("tplprev:", "") + template = await TemplateService.get_template(template_id) + + if not template: + await message.edit_text("Шаблон не найден") + return BotStates.SELECT_TEMPLATE + + await message.edit_text( + f"Предпросмотр шаблона:\n\n{template.content}", + parse_mode=template.parse_mode + ) + return BotStates.SELECT_TEMPLATE + + except Exception as e: + logger.error(f"Error in choose_template_preview: {e}") + if query.message: + await query.message.edit_text( + "Произошла ошибка при предпросмотре шаблона" + ) + return BotStates.SELECT_TEMPLATE + +async def choose_template_navigate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Навигация по страницам шаблонов.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + return BotStates.SELECT_TEMPLATE + + # Получаем номер страницы + page = int(query.data.replace("tplpage:", "")) + + user_data = context.user_data + if not user_data: + user_data = {} + context.user_data = user_data + user_data["tpl_page"] = page + + # Перестраиваем список для новой страницы + templates = await TemplateService.list_user_templates(query.from_user.id) + total = len(templates) + items = templates[page * KbBuilder.PAGE_SIZE:(page + 1) * KbBuilder.PAGE_SIZE] + + kb = KbBuilder.templates_list(items, page, total) + await message.edit_reply_markup(reply_markup=kb) + return BotStates.SELECT_TEMPLATE + + except Exception as e: + logger.error(f"Error in choose_template_navigate: {e}") + if query.message: + await query.message.edit_text("Произошла ошибка при смене страницы") + return ConversationHandler.END + +async def choose_template_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Отмена выбора шаблона.""" + query = update.callback_query + if not query: + return ConversationHandler.END + + await query.answer() + await query.message.edit_text( + "Введите текст поста:" + ) + return BotStates.ENTER_TEXT + +async def preview_collect_vars(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Сбор значений переменных для шаблона.""" + message = update.message + if not message or not message.from_user or not message.text: + return ConversationHandler.END + + try: + variables = parse_key_value_lines(message.text) + session = SessionStore.get_instance().get(message.from_user.id) + if not session.template_id: + await message.reply_text("Шаблон не выбран") + return BotStates.ENTER_TEXT + + template = await TemplateService.get_template(session.template_id) + if not template: + await message.reply_text("Шаблон не найден") + return BotStates.ENTER_TEXT + + # Подставляем значения переменных + text = template.content + for var, value in variables.items(): + text = text.replace(f"{{{var}}}", value) + + session.text = text + post_data = session.to_dict() + + kb = KbBuilder.preview_confirm() + await PostService.preview_post(message, post_data) + + await message.reply_text( + "Предпросмотр поста. Выберите действие:", + reply_markup=kb + ) + return BotStates.PREVIEW_CONFIRM + + except ValueError as e: + await message.reply_text( + f"Ошибка в формате переменных: {str(e)}\n" + "Используйте формат:\n" + "переменная = значение" + ) + return BotStates.PREVIEW_VARS + except Exception as e: + logger.error(f"Error in preview_collect_vars: {e}") + await message.reply_text( + "Произошла ошибка при обработке переменных" + ) + return BotStates.PREVIEW_VARS + +async def preview_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Подтверждение предпросмотра поста.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + await message.edit_text("Неверный формат данных") + return BotStates.PREVIEW_CONFIRM + + action = query.data.replace("pv:", "") + if action == "edit": + await message.edit_text( + "Введите текст поста:" + ) + return BotStates.ENTER_TEXT + + session = SessionStore.get_instance().get(query.from_user.id) + + if not session.type: + await message.edit_text("Ошибка: не выбран тип поста") + return ConversationHandler.END + + if session.type == MessageType.TEXT: + await message.edit_text( + "Введите клавиатуру в формате:\n" + "текст кнопки = ссылка\n\n" + "Или отправьте 'skip' чтобы пропустить" + ) + return BotStates.EDIT_KEYBOARD + + await message.edit_text( + "Отправьте фото/видео/gif для поста" + ) + return BotStates.ENTER_MEDIA + + except Exception as e: + logger.error(f"Error in preview_confirm: {e}") + if query.message: + await query.message.edit_text( + "Произошла ошибка при обработке предпросмотра" + ) + return ConversationHandler.END + +async def enter_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработка медиафайла.""" + message = update.message + if not message or not message.from_user: + return ConversationHandler.END + + try: + session = SessionStore.get_instance().get(message.from_user.id) + + if message.photo: + session.media_file_id = message.photo[-1].file_id + elif message.video: + session.media_file_id = message.video.file_id + elif message.animation: + session.media_file_id = message.animation.file_id + elif message.document: + session.media_file_id = message.document.file_id + else: + await message.reply_text( + "Пожалуйста, отправьте фото, видео или GIF" + ) + return BotStates.ENTER_MEDIA + + # Показываем предпросмотр + kb = KbBuilder.preview_confirm() + post_data = session.to_dict() + await PostService.preview_post(message, post_data) + + await message.reply_text( + "Предпросмотр поста. Выберите действие:", + reply_markup=kb + ) + return BotStates.PREVIEW_CONFIRM + + except Exception as e: + logger.error(f"Error in enter_media: {e}") + await message.reply_text( + "Произошла ошибка при обработке файла" + ) + return ConversationHandler.END + session.media_id = message.animation.file_id + + await message.reply_text( + "Введите клавиатуру в формате:\n" + "текст кнопки = ссылка\n\n" + "Или отправьте 'skip' чтобы пропустить" + ) + return BotStates.EDIT_KEYBOARD + +async def edit_keyboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработка клавиатуры поста.""" + message = update.message + if not message or not message.from_user or not message.text: + return ConversationHandler.END + + try: + kb_text = message.text.strip() + session = SessionStore.get_instance().get(message.from_user.id) + + if kb_text.lower() != "skip": + keyboard = parse_key_value_lines(kb_text) + session.keyboard = {"rows": []} + for text, url in keyboard.items(): + session.keyboard["rows"].append([{"text": text, "url": url}]) + + # Показываем предпросмотр поста + post_data = session.to_dict() + await PostService.preview_post(message, post_data) + + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("Отправить", callback_data="send:now"), + InlineKeyboardButton("Отложить", callback_data="send:schedule") + ] + ]) + + await message.reply_text( + "Выберите действие:", + reply_markup=keyboard + ) + return BotStates.CONFIRM_SEND + + except ValueError as e: + await message.reply_text( + f"Ошибка в формате клавиатуры: {e}\n" + "Используйте формат:\n" + "текст кнопки = ссылка" + ) + return BotStates.EDIT_KEYBOARD + except Exception as e: + logger.error(f"Error in edit_keyboard: {e}") + await message.reply_text( + "Произошла ошибка при обработке клавиатуры" + ) + return ConversationHandler.END + +async def confirm_send(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Подтверждение отправки поста.""" + query = update.callback_query + if not query or not query.from_user: + return ConversationHandler.END + + await query.answer() + + try: + message = cast(Message, query.message) + if not query.data: + await message.edit_text("Неверный формат данных") + return BotStates.CONFIRM_SEND + + action = query.data.replace("send:", "") + if action == "schedule": + await message.edit_text( + "Введите дату и время для отложенной публикации в формате:\n" + "ДД.ММ.ГГГГ ЧЧ:ММ" + ) + return BotStates.ENTER_SCHEDULE + + session = SessionStore.get_instance().get(query.from_user.id) + + # Отправляем пост сейчас + post_data = session.to_dict() + + message = query.message + if not message: + return BotStates.PREVIEW_CONFIRM + + if not session.channel_id: + await context.bot.send_message( + chat_id=message.chat.id, + text="Канал не выбран", + reply_markup=KbBuilder.go_back() + ) + return BotStates.PREVIEW_CONFIRM + + post = await PostService.create_post(context.bot, session.channel_id, post_data) + if post: + await context.bot.edit_message_text( + chat_id=message.chat.id, + message_id=message.message_id, + text="Пост успешно отправлен!" + ) + SessionStore.get_instance().drop(query.from_user.id) + return ConversationHandler.END + + await context.bot.send_message( + chat_id=message.chat.id, + text="Ошибка при отправке поста. Попробуйте позже.", + reply_markup=KbBuilder.go_back() + ) + return BotStates.PREVIEW_CONFIRM + + except Exception as e: + logger.error(f"Error in confirm_send: {e}") + message = query.message + if message: + await context.bot.edit_message_text( + chat_id=message.chat.id, + message_id=message.message_id, + text="Произошла ошибка при отправке поста" + ) + return BotStates.PREVIEW_CONFIRM + return ConversationHandler.END + + session = get_session_store().get_or_create(query.from_user.id) + + try: + # Отправляем пост + await schedule_post(session, schedule_time=None) + await query.message.edit_text("Пост успешно отправлен!") + + # Очищаем сессию + get_session_store().drop(query.from_user.id) + return ConversationHandler.END + + except Exception as e: + logger.error(f"Ошибка при отправке поста: {e}") + await query.message.edit_text( + f"Ошибка при отправке поста: {str(e)}" + ) + return BotStates.CONFIRM_SEND + +async def enter_schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + """Обработка времени для отложенной публикации.""" + message = update.message + if not message or not message.from_user or not message.text: + return ConversationHandler.END + + try: + schedule_text = message.text.strip() + if not schedule_text: + await message.reply_text( + "Некорректный формат даты.\n" + "Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ" + ) + return BotStates.ENTER_SCHEDULE + + try: + schedule_time = datetime.strptime(schedule_text, "%d.%m.%Y %H:%M") + if schedule_time <= datetime.now(): + await message.reply_text( + "Нельзя указать время в прошлом.\n" + "Введите время в будущем." + ) + return BotStates.ENTER_SCHEDULE + + session = SessionStore.get_instance().get(message.from_user.id) + if not session.channel_id: + await message.reply_text("Не выбран канал для публикации") + return ConversationHandler.END + + # Отправляем отложенный пост + post_data = session.to_dict() + post_data["schedule_time"] = schedule_time + await PostService.create_post(context.bot, session.channel_id, post_data) + await message.reply_text("Пост запланирован!") + + # Очищаем сессию + SessionStore.get_instance().drop(message.from_user.id) + return ConversationHandler.END + + except ValueError: + await message.reply_text( + "Некорректный формат даты.\n" + "Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ" + ) + return BotStates.ENTER_SCHEDULE + + except Exception as e: + logger.error(f"Error in enter_schedule: {e}") + await message.reply_text( + "Произошла ошибка при обработке времени публикации" + ) + return ConversationHandler.END diff --git a/app/bots/editor/handlers/templates.py b/app/bots/editor/handlers/templates.py new file mode 100644 index 0000000..a731198 --- /dev/null +++ b/app/bots/editor/handlers/templates.py @@ -0,0 +1,146 @@ +"""Обработчики для работы с шаблонами.""" +from typing import Optional, Dict, Any +from telegram import Update, Message +from telegram.ext import ContextTypes, ConversationHandler + +from app.bots.editor.states import BotStates +from app.bots.editor.session import get_session_store +from ..keyboards import template_type_keyboard, get_templates_keyboard +from ..utils.parsers import parse_key_value_lines +from ..utils.validation import validate_template_name + +async def start_template_creation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates: + """Начало создания шаблона.""" + if not update.message: + return BotStates.CONVERSATION_END + + message = update.message + await message.reply_text( + "Выберите тип шаблона:", + reply_markup=template_type_keyboard() + ) + return BotStates.TPL_TYPE + +async def handle_template_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates: + """Обработка выбора типа шаблона.""" + if not update.callback_query: + return BotStates.CONVERSATION_END + + query = update.callback_query + await query.answer() + + tpl_type = query.data + user_id = query.from_user.id + + session_store = get_session_store() + session = session_store.get_or_create(user_id) + session.type = tpl_type + + await query.message.edit_text("Введите название шаблона:") + return BotStates.TPL_NAME + +async def handle_template_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates: + """Обработка ввода имени шаблона.""" + if not update.message: + return BotStates.CONVERSATION_END + + message = update.message + user_id = message.from_user.id + name = message.text.strip() + + if not validate_template_name(name): + await message.reply_text( + "Некорректное имя шаблона. Используйте только буквы, цифры и знаки - _" + ) + return BotStates.TPL_NAME + + session = get_session_store().get_or_create(user_id) + session.template_name = name + + await message.reply_text("Введите текст шаблона:") + return BotStates.TPL_TEXT + +async def handle_template_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates: + """Обработка текста шаблона.""" + if not update.message: + return BotStates.CONVERSATION_END + + message = update.message + user_id = message.from_user.id + text = message.text.strip() + + session = get_session_store().get_or_create(user_id) + session.text = text + + await message.reply_text( + "Введите клавиатуру в формате:\n" + "текст кнопки = ссылка\n\n" + "Или отправьте 'skip' чтобы пропустить" + ) + return BotStates.TPL_NEW_KB + +async def handle_template_keyboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates: + """Обработка клавиатуры шаблона.""" + if not update.message: + return BotStates.CONVERSATION_END + + message = update.message + user_id = message.from_user.id + kb_text = message.text.strip() + + session = get_session_store().get_or_create(user_id) + + if kb_text != "skip": + try: + keyboard = parse_key_value_lines(kb_text) + session.keyboard = keyboard + except ValueError as e: + await message.reply_text(f"Ошибка разбора клавиатуры: {e}") + return BotStates.TPL_NEW_KB + + try: + template_data = { + "owner_id": user_id, + "name": session.template_name, + "title": session.template_name, + "content": session.text, + "type": session.type, + "parse_mode": session.parse_mode or "HTML", + "keyboard_tpl": session.keyboard + } + await create_template(template_data) + await message.reply_text("Шаблон успешно создан") + + # Очищаем сессию + get_session_store().drop(user_id) + return BotStates.CONVERSATION_END + + except ValueError as e: + await message.reply_text(f"Ошибка создания шаблона: {e}") + return BotStates.TPL_NEW_KB + except Exception as e: + logger.error(f"Неожиданная ошибка при создании шаблона: {e}") + await message.reply_text("Произошла непредвиденная ошибка при создании шаблона") + return BotStates.TPL_NEW_KB + +async def list_templates(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates: + """Список шаблонов.""" + if not update.message: + return BotStates.CONVERSATION_END + + message = update.message + user_id = message.from_user.id + + templates = await get_user_templates(user_id) + if not templates: + await message.reply_text("У вас пока нет шаблонов") + return BotStates.CONVERSATION_END + + page = context.user_data.get("tpl_page", 0) + keyboard = get_templates_keyboard(templates, page) + + await message.reply_text( + "Выберите шаблон:", + reply_markup=keyboard + ) + return BotStates.TPL_SELECT diff --git a/app/bots/editor/keyboards.py b/app/bots/editor/keyboards.py index 47fbc84..1a12e62 100644 --- a/app/bots/editor/keyboards.py +++ b/app/bots/editor/keyboards.py @@ -1,53 +1,207 @@ from __future__ import annotations -from typing import Iterable, List, Tuple, Optional +from typing import Iterable, List, Optional, Any from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from .messages import MessageType + +def template_type_keyboard() -> InlineKeyboardMarkup: + """Возвращает клавиатуру выбора типа шаблона.""" + return KbBuilder.template_type_keyboard() + +def get_templates_keyboard(templates: List[Any], page: int = 0) -> InlineKeyboardMarkup: + """Возвращает клавиатуру со списком шаблонов.""" + return KbBuilder.get_templates_keyboard(templates, page) + class KbBuilder: + """Строитель клавиатур для различных состояний бота.""" + + PAGE_SIZE = 8 + @staticmethod def channels(channels: Iterable) -> InlineKeyboardMarkup: - rows = [[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")] - for ch in channels] + """Клавиатура выбора канала.""" + rows = [ + [InlineKeyboardButton( + ch.title or str(ch.chat_id), + callback_data=f"channel:{ch.id}" + )] + for ch in channels + ] return InlineKeyboardMarkup(rows) @staticmethod def post_types() -> InlineKeyboardMarkup: + """Клавиатура выбора типа поста.""" rows = [ - [InlineKeyboardButton("Текст", callback_data="type:text"), - InlineKeyboardButton("Фото", callback_data="type:photo")], - [InlineKeyboardButton("Видео", callback_data="type:video"), - InlineKeyboardButton("GIF", callback_data="type:animation")], + [ + InlineKeyboardButton("📝 Текст", callback_data=f"type:{MessageType.TEXT.value}"), + InlineKeyboardButton("📷 Фото", callback_data=f"type:{MessageType.PHOTO.value}") + ], + [ + InlineKeyboardButton("🎥 Видео", callback_data=f"type:{MessageType.VIDEO.value}"), + InlineKeyboardButton("🎬 GIF", callback_data=f"type:{MessageType.ANIMATION.value}") + ], ] return InlineKeyboardMarkup(rows) @staticmethod def parse_modes() -> InlineKeyboardMarkup: - rows = [[InlineKeyboardButton("HTML", callback_data="fmt:HTML"), - InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2")]] - return InlineKeyboardMarkup(rows) - - @staticmethod - def send_confirm() -> InlineKeyboardMarkup: + """Клавиатура выбора формата текста.""" rows = [ - [InlineKeyboardButton("Отправить сейчас", callback_data="send:now")], - [InlineKeyboardButton("Запланировать", callback_data="send:schedule")], + [ + InlineKeyboardButton("HTML", callback_data="fmt:HTML"), + InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2") + ] ] return InlineKeyboardMarkup(rows) @staticmethod - def templates_list(items: List, page: int, total: int, page_size: int) -> InlineKeyboardMarkup: - rows: List[List[InlineKeyboardButton]] = [] - for t in items: - rows.append([ - InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"), - InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}") - ]) + def template_type_keyboard() -> InlineKeyboardMarkup: + """Клавиатура выбора типа шаблона.""" + rows = [ + [ + InlineKeyboardButton("📝 Текст", callback_data=f"tpl_type:{MessageType.TEXT.value}"), + InlineKeyboardButton("📷 Фото", callback_data=f"tpl_type:{MessageType.PHOTO.value}") + ], + [ + InlineKeyboardButton("🎥 Видео", callback_data=f"tpl_type:{MessageType.VIDEO.value}"), + InlineKeyboardButton("🎬 GIF", callback_data=f"tpl_type:{MessageType.ANIMATION.value}") + ] + ] + return InlineKeyboardMarkup(rows) + @staticmethod + def get_templates_keyboard(templates: List[Any], page: int = 0) -> InlineKeyboardMarkup: + """Клавиатура списка шаблонов с пагинацией.""" + start_idx = page * KbBuilder.PAGE_SIZE + end_idx = start_idx + KbBuilder.PAGE_SIZE + page_templates = templates[start_idx:end_idx] + + rows = [] + for template in page_templates: + rows.append([ + InlineKeyboardButton( + f"{template.name} ({template.type})", + callback_data=f"template:{template.id}" + ) + ]) + + nav_row = [] + if page > 0: + nav_row.append(InlineKeyboardButton("◀️ Назад", callback_data=f"page:{page-1}")) + if end_idx < len(templates): + nav_row.append(InlineKeyboardButton("Вперед ▶️", callback_data=f"page:{page+1}")) + + if nav_row: + rows.append(nav_row) + + return InlineKeyboardMarkup(rows) + @staticmethod + def go_back() -> InlineKeyboardMarkup: + """Клавиатура с кнопкой назад.""" + return InlineKeyboardMarkup([ + [InlineKeyboardButton("« Назад", callback_data="back")] + ]) + + @staticmethod + def text_input_options() -> InlineKeyboardMarkup: + """Клавиатура при вводе текста.""" + rows = [ + [InlineKeyboardButton("📋 Использовать шаблон", callback_data="tpl:choose")], + [InlineKeyboardButton("⌨️ Добавить клавиатуру", callback_data="kb:add")], + [InlineKeyboardButton("❌ Отмена", callback_data="cancel")] + ] + return InlineKeyboardMarkup(rows) + + @staticmethod + def send_confirm() -> InlineKeyboardMarkup: + """Клавиатура подтверждения отправки.""" + rows = [ + [InlineKeyboardButton("📤 Отправить сейчас", callback_data="send:now")], + [InlineKeyboardButton("⏰ Запланировать", callback_data="send:schedule")], + [InlineKeyboardButton("✏️ Редактировать", callback_data="send:edit")], + [InlineKeyboardButton("❌ Отмена", callback_data="send:cancel")] + ] + return InlineKeyboardMarkup(rows) + + @staticmethod + def preview_confirm() -> InlineKeyboardMarkup: + """Клавиатура после предпросмотра.""" + rows = [ + [InlineKeyboardButton("✅ Использовать", callback_data="pv:use")], + [InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")], + [InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")] + ] + return InlineKeyboardMarkup(rows) + + @staticmethod + def templates_list( + items: List, + page: int, + total: int, + show_delete: bool = False + ) -> InlineKeyboardMarkup: + """ + Клавиатура списка шаблонов с пагинацией. + + Args: + items: Список шаблонов на текущей странице + page: Номер текущей страницы + total: Общее количество шаблонов + show_delete: Показывать ли кнопку удаления + """ + rows: List[List[InlineKeyboardButton]] = [] + + for t in items: + row = [ + InlineKeyboardButton( + (t.title or t.name), + callback_data=f"tpluse:{t.name}" + ), + InlineKeyboardButton( + "👁 Предпросмотр", + callback_data=f"tplprev:{t.name}" + ) + ] + if show_delete: + row.append(InlineKeyboardButton( + "🗑", + callback_data=f"tpldel:{t.name}" + )) + rows.append(row) + + # Навигация nav: List[InlineKeyboardButton] = [] if page > 0: - nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}")) - if (page + 1) * page_size < total: - nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}")) + nav.append(InlineKeyboardButton( + "◀️ Назад", + callback_data=f"tplpage:{page-1}" + )) + if (page + 1) * KbBuilder.PAGE_SIZE < total: + nav.append(InlineKeyboardButton( + "Вперёд ▶️", + callback_data=f"tplpage:{page+1}" + )) + + if nav: + rows.append(nav) + + # Кнопка отмены + rows.append([InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")]) + + return InlineKeyboardMarkup(rows) + + @staticmethod + def template_delete_confirm(name: str) -> InlineKeyboardMarkup: + """Клавиатура подтверждения удаления шаблона.""" + rows = [ + [ + InlineKeyboardButton("✅ Удалить", callback_data=f"tpldelok:{name}"), + InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel") + ] + ] + return InlineKeyboardMarkup(rows) if nav: rows.append(nav) diff --git a/app/bots/editor/messages.py b/app/bots/editor/messages.py index 609b375..90d9050 100644 --- a/app/bots/editor/messages.py +++ b/app/bots/editor/messages.py @@ -1,31 +1,111 @@ from __future__ import annotations import shlex -from typing import Dict +from enum import Enum +from typing import Dict, Optional + + +class MessageType(Enum): + TEXT = "text" + PHOTO = "photo" + VIDEO = "video" + ANIMATION = "animation" + + +class Messages: + # Команды + WELCOME_MESSAGE = ( + "👋 Привет! Я редактор постов для каналов.\n\n" + "🤖 Сначала добавьте бота через /add_bot\n" + "📢 Затем добавьте каналы через /add_channel\n" + "📝 Потом можно:\n" + "- Создать пост: /newpost\n" + "- Управлять шаблонами: /tpl_new, /tpl_list\n" + "- Посмотреть список ботов: /bots\n" + "- Посмотреть список каналов: /channels\n\n" + "❓ Справка: /help" + ) + + HELP_MESSAGE = ( + "📖 Справка по командам:\n\n" + "Управление ботами и каналами:\n" + "/add_bot - Добавить нового бота\n" + "/bots - Список ваших ботов\n" + "/add_channel - Добавить канал\n" + "/channels - Список ваших каналов\n\n" + "Управление постами:\n" + "/newpost - Создать новый пост\n\n" + "Управление шаблонами:\n" + "/tpl_new - Создать шаблон\n" + "/tpl_list - Список ваших шаблонов" + ) + + START = ("👋 Привет! Я редактор постов. Доступные команды:\n" + "/newpost — создать новый пост\n" + "/tpl_new — создать шаблон\n" + "/tpl_list — список шаблонов") + + # Ошибки + ERROR_SESSION_EXPIRED = "❌ Сессия истекла. Начните заново с /newpost" + ERROR_INVALID_FORMAT = "❌ Неверный формат. Попробуйте еще раз" + ERROR_NO_CHANNELS = "❌ У вас нет каналов. Добавьте канал через админку" + ERROR_TEMPLATE_NOT_FOUND = "❌ Шаблон не найден" + ERROR_TEMPLATE_CREATE = "❌ Ошибка при создании шаблона: {error}" + ERROR_INVALID_KEYBOARD = "❌ Неверный формат клавиатуры" + + # Создание поста + SELECT_CHANNEL = "📢 Выберите канал для публикации:" + SELECT_TYPE = "📝 Выберите тип поста:" + SELECT_FORMAT = "🔤 Выберите формат текста:" + ENTER_TEXT = "✏️ Введите текст сообщения\nИли используйте #имя_шаблона для применения шаблона" + ENTER_MEDIA = "📎 Отправьте {media_type}" + ENTER_KEYBOARD = "⌨️ Введите клавиатуру в формате:\nтекст|url\nтекст2|url2\n\nИли отправьте 'skip' для пропуска" + + # Шаблоны + TEMPLATE_LIST = "📜 Список шаблонов (стр. {page}/{total_pages}):" + TEMPLATE_CREATE_NAME = "📝 Введите имя для нового шаблона:" + TEMPLATE_CREATE_TYPE = "📌 Выберите тип шаблона:" + TEMPLATE_CREATE_FORMAT = "🔤 Выберите формат текста:" + TEMPLATE_CREATE_CONTENT = "✏️ Введите содержимое шаблона:" + TEMPLATE_CREATE_KEYBOARD = "⌨️ Введите клавиатуру или отправьте 'skip':" + TEMPLATE_CREATED = "✅ Шаблон успешно создан" + TEMPLATE_DELETED = "🗑 Шаблон удален" + + # Отправка + CONFIRM_SEND = "📤 Как отправить пост?" + ENTER_SCHEDULE = "📅 Введите дату и время для публикации в формате YYYY-MM-DD HH:MM" + POST_SCHEDULED = "✅ Пост запланирован на {datetime}" + POST_SENT = "✅ Пост отправлен" class MessageParsers: @staticmethod def parse_template_invocation(s: str) -> tuple[str, Dict[str, str]]: """ - Пример: "#promo title='Hi' url=https://x.y" - -> ("promo", {"title":"Hi", "url":"https://x.y"}) + Парсит вызов шаблона вида: "#promo title='Hi' url=https://x.y" + Возвращает: ("promo", {"title":"Hi", "url":"https://x.y"}) """ s = (s or "").strip() if not s.startswith("#"): raise ValueError("not a template invocation") - parts = shlex.split(s) - name = parts[0][1:] - args: Dict[str, str] = {} - for tok in parts[1:]: - if "=" in tok: - k, v = tok.split("=", 1) - args[k] = v - return name, args + + try: + parts = shlex.split(s) + 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().strip('"\'') + return name, args + + except ValueError as e: + raise ValueError(f"Ошибка парсинга шаблона: {e}") @staticmethod def parse_key_value_lines(text: str) -> Dict[str, str]: """ - Поддерживает: + Парсит переменные в форматах: - построчно: key=value key2="quoted value" @@ -35,17 +115,60 @@ class MessageParsers: text = (text or "").strip() if not text: return {} - if "\n" in text: - out: Dict[str, str] = {} + + try: + if "\n" in text: + # Построчный формат + out: Dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if "=" in line: + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"\'') + return out + else: + # Однострочный формат + out: Dict[str, str] = {} + for tok in shlex.split(text): + if "=" in tok: + k, v = tok.split("=", 1) + out[k.strip()] = v.strip() + return out + + except ValueError as e: + raise ValueError(f"Ошибка парсинга переменных: {e}") + + @staticmethod + def parse_keyboard(text: str) -> Optional[Dict]: + """ + Парсит клавиатуру в формате: + текст1|url1 + текст2|url2 + + Возвращает: + { + "rows": [ + [{"text": "текст1", "url": "url1"}], + [{"text": "текст2", "url": "url2"}] + ] + } + """ + text = (text or "").strip() + if not text or text.lower() == "skip": + return None + + try: + rows = [] for line in text.splitlines(): - if "=" in line: - k, v = line.split("=", 1) - out[k.strip()] = v.strip().strip('"') - return out - - out: Dict[str, str] = {} - for tok in shlex.split(text): - if "=" in tok: - k, v = tok.split("=", 1) - out[k] = v + line = line.strip() + if "|" in line: + text, url = line.split("|", 1) + rows.append([{ + "text": text.strip(), + "url": url.strip() + }]) + return {"rows": rows} if rows else None + + except Exception as e: + raise ValueError(f"Ошибка парсинга клавиатуры: {e}") return out diff --git a/app/bots/editor/oop_app.py b/app/bots/editor/oop_app.py index b1a4723..b1ee9c5 100644 --- a/app/bots/editor/oop_app.py +++ b/app/bots/editor/oop_app.py @@ -6,7 +6,7 @@ from telegram.ext import ( ) from app.core.config import settings -from .states import States +from .states import BotStates as States from .session import SessionStore from .wizard import EditorWizard diff --git a/app/bots/editor/router.py b/app/bots/editor/router.py new file mode 100644 index 0000000..61ad11d --- /dev/null +++ b/app/bots/editor/router.py @@ -0,0 +1,114 @@ +"""Маршрутизация команд бота.""" +from telegram.ext import ( + Application, CommandHandler, MessageHandler, CallbackQueryHandler, + ConversationHandler, filters +) + +from .states import BotStates +from .handlers.base import start, help_command, cancel +from .handlers.templates import ( + start_template_creation, + handle_template_type, + handle_template_name, + handle_template_text, + handle_template_keyboard, + list_templates +) +from .handlers.posts import ( + newpost, + choose_channel, + choose_type, + choose_format, + enter_text, + choose_template_open, + choose_template_apply, + choose_template_preview, + choose_template_navigate, + choose_template_cancel, + preview_collect_vars, + preview_confirm, + enter_media, + edit_keyboard, + confirm_send, + enter_schedule +) + +def register_handlers(app: Application) -> None: + """Регистрация обработчиков команд.""" + # Базовые команды + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("help", help_command)) + + # Шаблоны + template_handler = ConversationHandler( + entry_points=[CommandHandler("newtemplate", start_template_creation)], + states={ + BotStates.TPL_TYPE: [ + CallbackQueryHandler(handle_template_type) + ], + BotStates.TPL_NAME: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_name) + ], + BotStates.TPL_TEXT: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_text) + ], + BotStates.TPL_NEW_KB: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_keyboard) + ] + }, + fallbacks=[CommandHandler("cancel", cancel)] + ) + app.add_handler(template_handler) + + # Создание поста + post_handler = ConversationHandler( + entry_points=[CommandHandler("newpost", newpost)], + states={ + BotStates.CHOOSE_CHANNEL: [ + CallbackQueryHandler(choose_channel, pattern=r"^channel:") + ], + BotStates.CHOOSE_TYPE: [ + CallbackQueryHandler(choose_type, pattern=r"^type:") + ], + BotStates.CHOOSE_FORMAT: [ + CallbackQueryHandler(choose_format, pattern=r"^fmt:") + ], + BotStates.ENTER_TEXT: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text), + CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$") + ], + BotStates.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$") + ], + BotStates.PREVIEW_VARS: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars) + ], + BotStates.PREVIEW_CONFIRM: [ + CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"), + CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$") + ], + BotStates.ENTER_MEDIA: [ + MessageHandler( + filters.PHOTO | filters.VIDEO | filters.ANIMATION & ~filters.COMMAND, + enter_media + ) + ], + BotStates.EDIT_KEYBOARD: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard) + ], + BotStates.CONFIRM_SEND: [ + CallbackQueryHandler(confirm_send, pattern=r"^send:") + ], + BotStates.ENTER_SCHEDULE: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule) + ] + }, + fallbacks=[CommandHandler("cancel", cancel)] + ) + app.add_handler(post_handler) + + # Просмотр шаблонов + app.add_handler(CommandHandler("templates", list_templates)) diff --git a/app/bots/editor/session.py b/app/bots/editor/session.py index 266351f..9514c96 100644 --- a/app/bots/editor/session.py +++ b/app/bots/editor/session.py @@ -1,47 +1,150 @@ from __future__ import annotations import time +import logging from dataclasses import dataclass, field -from typing import Any, Dict, Optional +from datetime import datetime +from typing import Any, Dict, Optional, List +from threading import Lock +from app.bots.editor.messages import MessageType +from app.models.post import PostType + +logger = logging.getLogger(__name__) DEFAULT_TTL = 60 * 60 # 1 час +# Тип сообщения, используемый в сессии +SessionType = MessageType | PostType + @dataclass class UserSession: + """Сессия пользователя при создании поста.""" + + # Основные данные поста channel_id: Optional[int] = None - type: Optional[str] = None # text/photo/video/animation - parse_mode: Optional[str] = None # HTML/MarkdownV2 + type: Optional[SessionType] = None + parse_mode: Optional[str] = None # HTML/MarkdownV2 text: Optional[str] = None media_file_id: Optional[str] = None - keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]} + keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]} + + # Данные шаблона + template_name: Optional[str] = None + template_id: Optional[str] = None + template_vars: Dict[str, str] = field(default_factory=dict) + missing_vars: List[str] = field(default_factory=list) + + # Метаданные отправки + schedule_time: Optional[datetime] = None + + def update(self, data: Dict[str, Any]) -> None: + """Обновляет поля сессии из словаря.""" + for key, value in data.items(): + if hasattr(self, key): + setattr(self, key, value) + + # Метаданные last_activity: float = field(default_factory=time.time) - + state: Optional[int] = None + def touch(self) -> None: + """Обновляет время последней активности.""" self.last_activity = time.time() + + def clear(self) -> None: + """Очищает все данные сессии.""" + self.channel_id = None + self.type = None + self.parse_mode = None + self.text = None + self.media_file_id = None + self.keyboard = None + self.template_name = None + self.template_vars.clear() + self.missing_vars.clear() + self.state = None + self.touch() + + def is_complete(self) -> bool: + """Проверяет, заполнены ли все необходимые поля.""" + if not self.channel_id or not self.type: + return False + + if self.type == MessageType.TEXT: + return bool(self.text) + else: + return bool(self.text and self.media_file_id) + + def to_dict(self) -> Dict[str, Any]: + """Конвертирует сессию в словарь для отправки.""" + return { + "type": self.type.value if self.type else None, + "text": self.text, + "media_file_id": self.media_file_id, + "parse_mode": self.parse_mode or "HTML", + "keyboard": self.keyboard, + "template_id": self.template_id, + "template_name": self.template_name, + "template_vars": self.template_vars + } + + as_dict = to_dict class SessionStore: - """Простое и быстрое in-memory хранилище с авто-очисткой.""" - + """Thread-safe хранилище сессий с автоочисткой.""" + + _instance: Optional["SessionStore"] = None + def __init__(self, ttl: int = DEFAULT_TTL) -> None: self._data: Dict[int, UserSession] = {} self._ttl = ttl - + self._lock = Lock() + + @classmethod + def get_instance(cls) -> "SessionStore": + """Возвращает глобальный экземпляр.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + def get(self, uid: int) -> UserSession: - s = self._data.get(uid) - if not s: - s = UserSession() - self._data[uid] = s - s.touch() - self._cleanup() - return s - + """Получает или создает сессию пользователя.""" + with self._lock: + s = self._data.get(uid) + if not s: + s = UserSession() + self._data[uid] = s + s.touch() + self._cleanup() + return s + def drop(self, uid: int) -> None: - self._data.pop(uid, None) - - def _cleanup(self) -> None: - now = time.time() - for uid in list(self._data.keys()): - if now - self._data[uid].last_activity > self._ttl: + """Удаляет сессию пользователя.""" + with self._lock: + if uid in self._data: + logger.info(f"Dropping session for user {uid}") del self._data[uid] + + def _cleanup(self) -> None: + """Удаляет истекшие сессии.""" + now = time.time() + expired = [] + + for uid, session in self._data.items(): + if now - session.last_activity > self._ttl: + expired.append(uid) + + for uid in expired: + logger.info(f"Session expired for user {uid}") + del self._data[uid] + + def get_active_count(self) -> int: + """Возвращает количество активных сессий.""" + return len(self._data) + + +def get_session_store() -> SessionStore: + """Возвращает глобальный экземпляр хранилища сессий.""" + return SessionStore.get_instance() diff --git a/app/bots/editor/states.py b/app/bots/editor/states.py index db0a54a..9a2757b 100644 --- a/app/bots/editor/states.py +++ b/app/bots/editor/states.py @@ -1,23 +1,88 @@ -from __future__ import annotations -from enum import IntEnum +"""Состояния бота редактора.""" +from enum import IntEnum, auto +from typing import Dict - -class States(IntEnum): - CHOOSE_CHANNEL = 0 - CHOOSE_TYPE = 1 - CHOOSE_FORMAT = 2 - ENTER_TEXT = 3 - ENTER_MEDIA = 4 - EDIT_KEYBOARD = 5 - CONFIRM_SEND = 6 - ENTER_SCHEDULE = 7 - - SELECT_TEMPLATE = 8 - PREVIEW_VARS = 9 - PREVIEW_CONFIRM = 10 - - TPL_NEW_NAME = 11 - TPL_NEW_TYPE = 12 - TPL_NEW_FORMAT = 13 - TPL_NEW_CONTENT = 14 - TPL_NEW_KB = 15 +class BotStates(IntEnum): + """Состояния для ConversationHandler.""" + + # Общие состояния + CONVERSATION_END = -1 + START = 1 + MAIN_MENU = 2 + + # Состояния создания шаблона + TPL_TYPE = 10 + TPL_NAME = 11 + TPL_TEXT = 12 + TPL_NEW_KB = 13 + TPL_SELECT = 14 + TPL_NEW_NAME = 15 + TPL_NEW_TYPE = 16 + TPL_NEW_FORMAT = 17 + TPL_NEW_CONTENT = 18 + TEMPLATE_PREVIEW = 19 + TEMPLATE_VARS = 20 + + # Состояния создания поста + CREATE_POST = 30 + CHOOSE_CHANNEL = 31 # Выбор канала + CHOOSE_TYPE = 32 # Выбор типа поста (текст/фото/видео/gif) + CHOOSE_FORMAT = 33 # Выбор формата текста (HTML/Markdown) + ENTER_TEXT = 34 # Ввод текста поста + ENTER_MEDIA = 35 # Загрузка медиафайла + EDIT_KEYBOARD = 36 # Редактирование клавиатуры + CONFIRM_SEND = 37 # Подтверждение отправки + ENTER_SCHEDULE = 38 # Ввод времени для отложенной публикации + SELECT_TEMPLATE = 39 # Выбор шаблона + PREVIEW_VARS = 40 # Ввод значений для переменных + PREVIEW_CONFIRM = 41 # Подтверждение предпросмотра + + # Состояния работы с каналами + CHANNEL_NAME = 50 + CHANNEL_DESC = 51 + CHANNEL_INVITE = 52 + + # Состояния управления ботами и каналами + BOT_TOKEN = 60 # Ввод токена бота + CHANNEL_ID = 61 # Ввод идентификатора канала + CHANNEL_TITLE = 62 # Ввод имени канала + CHANNEL_SELECT_BOT = 63 # Выбор бота для канала + + @classmethod + def get_description(cls, state: int) -> str: + """Возвращает описание состояния.""" + descriptions: Dict[int, str] = { + # Общие состояния + cls.CONVERSATION_END: "Завершение диалога", + + # Шаблоны + cls.TPL_TYPE: "Выбор типа шаблона", + cls.TPL_NAME: "Ввод имени шаблона", + cls.TPL_TEXT: "Ввод текста шаблона", + cls.TPL_NEW_KB: "Ввод клавиатуры шаблона", + cls.TPL_SELECT: "Выбор шаблона", + cls.TPL_NEW_CONTENT: "Ввод содержимого шаблона", + + # Посты + cls.CHOOSE_CHANNEL: "Выбор канала", + cls.CHOOSE_TYPE: "Выбор типа поста", + cls.CHOOSE_FORMAT: "Выбор формата", + cls.ENTER_TEXT: "Ввод текста", + cls.ENTER_MEDIA: "Загрузка медиа", + cls.EDIT_KEYBOARD: "Редактирование клавиатуры", + cls.CONFIRM_SEND: "Подтверждение отправки", + cls.ENTER_SCHEDULE: "Планирование публикации", + cls.SELECT_TEMPLATE: "Выбор шаблона", + cls.PREVIEW_VARS: "Ввод значений переменных", + cls.PREVIEW_CONFIRM: "Подтверждение предпросмотра", + + # Каналы и боты + cls.CHANNEL_NAME: "Ввод имени канала", + cls.CHANNEL_DESC: "Ввод описания канала", + cls.CHANNEL_INVITE: "Ввод инвайт-ссылки", + cls.BOT_TOKEN: "Ввод токена бота", + cls.CHANNEL_ID: "Ввод ID канала", + cls.CHANNEL_TITLE: "Ввод названия канала", + cls.CHANNEL_SELECT_BOT: "Выбор бота для канала", + } + return descriptions.get(state, f"Неизвестное состояние {state}") diff --git a/app/bots/editor/template.py b/app/bots/editor/template.py new file mode 100644 index 0000000..7d8124c --- /dev/null +++ b/app/bots/editor/template.py @@ -0,0 +1,49 @@ +"""Модуль для работы с шаблонами.""" +from __future__ import annotations + +from typing import Dict, Any + +from app.db.session import async_session_maker +from app.models.templates import Template + + +async def render_template_by_name( + name: str, + template_vars: Dict[str, Any], + context: Dict[str, Any], +) -> Dict[str, Any]: + """Рендеринг шаблона по имени. + + Args: + name: Имя шаблона + template_vars: Переменные для подстановки + context: Дополнительный контекст + + Returns: + Отрендеренные данные для поста + + Raises: + ValueError: Если шаблон не найден + """ + async with async_session_maker() as session: + stmt = Template.__table__.select().where(Template.__table__.c.name == name) + result = await session.execute(stmt) + template = result.scalar_one_or_none() + + if not template: + raise ValueError(f"Шаблон {name} не найден") + + text = template.content + keyboard = template.keyboard_tpl + + # Подстановка переменных + for key, value in template_vars.items(): + text = text.replace(f"{{${key}}}", str(value)) + + # Подготовка данных для отправки + return { + "type": template.type, + "text": text, + "keyboard": keyboard, + "parse_mode": template.parse_mode + } diff --git a/app/bots/editor/templates.py b/app/bots/editor/templates.py new file mode 100644 index 0000000..a8c9803 --- /dev/null +++ b/app/bots/editor/templates.py @@ -0,0 +1,24 @@ +"""Функции для работы с шаблонами.""" +from __future__ import annotations +from typing import Optional + +from sqlalchemy import select +from app.db.session import async_session_maker +from app.models.templates import Template + + +async def list_templates(owner_id: Optional[int] = None): + """Получение списка шаблонов.""" + async with async_session_maker() as session: + stmt = select(Template) + if owner_id: + stmt = stmt.filter(Template.owner_id == owner_id) + result = await session.execute(stmt) + return result.scalars().all() + +async def create_template(template_data: dict): + """Создание нового шаблона.""" + async with async_session_maker() as session: + template = Template(**template_data) + session.add(template) + await session.commit() diff --git a/app/bots/editor/utils/__init__.py b/app/bots/editor/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bots/editor/utils/parsers.py b/app/bots/editor/utils/parsers.py new file mode 100644 index 0000000..fdc22ec --- /dev/null +++ b/app/bots/editor/utils/parsers.py @@ -0,0 +1,56 @@ +"""Утилиты для парсинга данных.""" +from typing import Dict, Any, List, Set +import re +from urllib.parse import urlparse + +def extract_variables(text: str) -> Set[str]: + """Извлекает переменные из шаблона.""" + if not text: + return set() + return set(re.findall(r'\{([^}]+)\}', text)) + +def validate_url(url: str) -> bool: + """Проверяет валидность URL.""" + try: + parsed = urlparse(url) + return bool(parsed.scheme and parsed.netloc) + except Exception: + return False + +def parse_key_value_lines(text: str) -> Dict[str, Any]: + """Парсинг клавиатуры из текста формата 'текст = ссылка'.""" + keyboard = {"rows": []} + current_row = [] + + lines = text.strip().split('\n') + for line in lines: + line = line.strip() + if not line: + continue + + if line == "---": + if current_row: + keyboard["rows"].append(current_row) + current_row = [] + continue + + parts = line.split('=', 1) + if len(parts) != 2: + raise ValueError(f"Неверный формат строки: {line}") + + text, url = parts[0].strip(), parts[1].strip() + + # Проверка URL + try: + parsed = urlparse(url) + if not parsed.scheme or not parsed.netloc: + raise ValueError(f"Некорректный URL: {url}") + except Exception: + raise ValueError(f"Некорректный URL: {url}") + + current_row.append({"text": text, "url": url}) + + if current_row: + keyboard["rows"].append(current_row) + + return keyboard diff --git a/app/bots/editor/utils/validation.py b/app/bots/editor/utils/validation.py new file mode 100644 index 0000000..f23cf49 --- /dev/null +++ b/app/bots/editor/utils/validation.py @@ -0,0 +1,9 @@ +"""Утилиты для валидации данных.""" +import re +from typing import Optional + +def validate_template_name(name: str) -> bool: + """Проверка корректности имени шаблона.""" + if not name or len(name) > 50: + return False + return bool(re.match(r'^[\w\-]+$', name)) diff --git a/app/bots/editor/wizard.py b/app/bots/editor/wizard.py index dea458c..fdfc57c 100644 --- a/app/bots/editor/wizard.py +++ b/app/bots/editor/wizard.py @@ -1,27 +1,42 @@ from __future__ import annotations +import logging from datetime import datetime -from typing import Dict, List +from typing import Dict, List, Optional, Any -from telegram import Update +from telegram import Update, Message, InlineKeyboardMarkup, CallbackQuery from telegram.ext import CallbackContext +from telegram.error import TelegramError -from sqlalchemy import select +from sqlalchemy import select, and_ +from sqlalchemy.exc import SQLAlchemyError 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.bot import Bot +from app.models.templates import Template +from app.models.user import User +from .states import BotStates as States # Алиас для совместимости +from app.services.template import list_templates, create_template from app.services.templates import ( - render_template_by_name, list_templates, count_templates, - create_template, delete_template, required_variables_of_template, + render_template_by_name, count_templates, + required_variables_of_template, delete_template ) +from app.services.telegram import validate_bot_token from jinja2 import TemplateError - -from .states import States -from .session import SessionStore -from .messages import MessageParsers +from .session import SessionStore, UserSession +from .messages import Messages, MessageParsers, MessageType from .keyboards import KbBuilder +logger = logging.getLogger(__name__) + +MEDIA_TYPE_MAP = { + MessageType.PHOTO: "фото", + MessageType.VIDEO: "видео", + MessageType.ANIMATION: "GIF-анимацию" +} + # Заглушка для build_payload, если сервиса нет try: diff --git a/app/bots/editor_bot.py b/app/bots/editor_bot.py index 7b36beb..46a20c5 100644 --- a/app/bots/editor_bot.py +++ b/app/bots/editor_bot.py @@ -1,74 +1,191 @@ -from __future__ import annotations +"""Главный модуль бота редактора.""" +import time import shlex import logging -from datetime import datetime -from typing import Optional, Dict, List, Any, Union, cast -import time +from typing import cast, Optional, Dict, Any 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 datetime import datetime +import asyncio +from typing import Optional, Dict, Any from apscheduler.schedulers.asyncio import AsyncIOScheduler - -from sqlalchemy import select +from telegram import Update, Message, CallbackQuery, User, InlineKeyboardMarkup, InlineKeyboardButton +from telegram.ext import ( + Application, CommandHandler, MessageHandler, CallbackQueryHandler, + ContextTypes, ConversationHandler, filters +) 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 .editor.states import BotStates +from .editor.session import get_session_store, SessionStore +from .editor.router import register_handlers +from app.services.template import count_templates -from jinja2 import TemplateError +# Определение типа Context +Context = ContextTypes.DEFAULT_TYPE + +# Константы +DEFAULT_TTL = 3600 # 1 час + +# Настройка логирования +logger = logging.getLogger(__name__) + +async def main(): + """Запуск бота.""" + app = Application.builder().token(settings.editor_bot_token).build() + + # Регистрация обработчиков + register_handlers(app) + + # Запуск бота + await app.run_polling(allowed_updates=Update.ALL_TYPES) + +if __name__ == "__main__": + import asyncio + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Бот остановлен") + except Exception as e: + logger.error(f"Ошибка: {e}", exc_info=True) + finally: + # Убедимся, что все циклы закрыты + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.stop() + if not loop.is_closed(): + loop.close() + except Exception as e: + logger.error(f"Ошибка: {e}", exc_info=True) + +from app.core.config import settings +from .editor.session import SessionStore +from .editor.handlers.templates import ( + start_template_creation, + handle_template_type, + handle_template_name, + handle_template_text, + handle_template_keyboard, + list_templates +) +from .editor.handlers.posts import ( + newpost, + handle_post_template, + handle_post_channel, + handle_post_schedule +) +from .editor.states import BotStates # Настройка логирования 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. +async def cancel_handler(update: Update, context: Context) -> int: + """Отмена текущей операции.""" + message = cast(Message, update.message) + if not message or not message.from_user: + return BotStates.CONVERSATION_END - Args: - url: Строка URL для проверки - - Returns: - bool: True если URL безопасен, False в противном случае - """ + user_id = message.from_user.id + session_store = get_session_store() + session_store.drop(user_id) + + await message.reply_text("Операция отменена") + return BotStates.CONVERSATION_END + +def cleanup_old_sessions(): + """Очистка старых сессий.""" try: - result = urlparse(url) - return all([ - result.scheme in ALLOWED_URL_SCHEMES, - result.netloc, - len(url) < 2048 # Максимальная длина URL - ]) + now = time.time() + session_store = get_session_store() + for uid, session in list(session_store._data.items()): + if now - session.last_activity > DEFAULT_TTL: + del session_store._data[uid] except Exception as e: - logger.warning(f"URL validation failed: {e}") - return False + logger.error(f"Error cleaning up sessions: {e}") + +Context = ContextTypes.DEFAULT_TYPE +PREVIEW_VARS = BotStates.TEMPLATE_VARS + +# Convert state constants from BotStates +CONVERSATION_END = BotStates.CONVERSATION_END +START = BotStates.START +MAIN_MENU = BotStates.MAIN_MENU +SELECT_TEMPLATE = BotStates.SELECT_TEMPLATE +TEMPLATE_PREVIEW = BotStates.TEMPLATE_PREVIEW +TEMPLATE_VARS = BotStates.TEMPLATE_VARS +PREVIEW_CONFIRM = BotStates.PREVIEW_CONFIRM +TPL_NEW_NAME = BotStates.TPL_NEW_NAME +TPL_NEW_TYPE = BotStates.TPL_NEW_TYPE +TPL_NEW_FORMAT = BotStates.TPL_NEW_FORMAT +CREATE_POST = BotStates.CREATE_POST +CHOOSE_CHANNEL = BotStates.CHOOSE_CHANNEL +CHOOSE_TYPE = BotStates.CHOOSE_TYPE +CHOOSE_FORMAT = BotStates.CHOOSE_FORMAT +ENTER_TEXT = BotStates.ENTER_TEXT +ENTER_MEDIA = BotStates.ENTER_MEDIA +EDIT_KEYBOARD = BotStates.EDIT_KEYBOARD +CONFIRM_SEND = BotStates.CONFIRM_SEND +ENTER_SCHEDULE = BotStates.ENTER_SCHEDULE +BOT_TOKEN = BotStates.BOT_TOKEN +CHANNEL_ID = BotStates.CHANNEL_ID +CHANNEL_TITLE = BotStates.CHANNEL_TITLE +CHANNEL_SELECT_BOT = BotStates.CHANNEL_SELECT_BOT +TPL_NEW_CONTENT = BotStates.TPL_NEW_CONTENT +TPL_NEW_KB = BotStates.TPL_NEW_KB + +from telegram.ext import ( + filters, + CallbackContext +) +from telegram.error import TelegramError + +from typing import List, Optional +from celery import shared_task +from sqlalchemy import select + +from app.models.templates import Template +from app.models.channel import Channel +from app.models.post import PostType +from app.db.session import async_session_maker +from .editor.session import SessionStore, UserSession +from .editor.messages import MessageType +from .editor.router import register_handlers +from .editor.states import BotStates +from app.services.template import render_template_by_name, list_templates, create_template + +# Константы пагинации +PAGE_SIZE = 5 # Количество элементов на странице + +from typing import Optional +from app.core.config import settings +from app.tasks.celery_app import celery_app +from .editor.session import SessionStore +from .editor.router import register_handlers +from .editor.template import render_template_by_name +from .editor.states import BotStates + +# Константы +PAGE_SIZE = 5 # Количество элементов на странице +MAX_MESSAGE_LENGTH = 4096 + +# Глобальное хранилище сессий +_session_store: Optional[SessionStore] = None + +def get_session_store() -> SessionStore: + """Получить глобальное хранилище сессий""" + global _session_store + if _session_store is None: + _session_store = SessionStore() + return _session_store +SESSION_TIMEOUT = 3600 # 1 час +ALLOWED_URL_SCHEMES = {'http', 'https'} + +# Настройка логирования +logger = logging.getLogger(__name__) + + def validate_message_length(text: str) -> bool: """Проверка длины сообщения согласно лимитам Telegram. @@ -80,19 +197,21 @@ def validate_message_length(text: str) -> bool: 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 validate_url(url: str) -> bool: + """Проверяет URL на допустимость. + + Args: + url: URL для проверки + + Returns: + bool: True если URL допустимый + """ + try: + result = urlparse(url) + return bool(result.scheme and result.netloc and result.scheme in ALLOWED_URL_SCHEMES) + except Exception: + return False def parse_template_invocation(s: str) -> tuple[str, dict]: """Разбор строки вызова шаблона. @@ -152,7 +271,7 @@ def parse_key_value_lines(text: str) -> dict: logger.warning(f"Error parsing key-value line: {e}") return out -async def choose_type(update: Update, context: CallbackContext) -> int: +async def choose_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """Обработчик выбора типа поста.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: @@ -161,15 +280,15 @@ async def choose_type(update: Update, context: CallbackContext) -> int: 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) + session = get_session_store().get(user_id) + session.clear() + session.type = MessageType(type_choice) + session.touch() await query.edit_message_text("Выберите формат сообщения") - return CHOOSE_FORMAT + return BotStates.CHOOSE_FORMAT -async def choose_format(update: Update, context: CallbackContext) -> int: +async def choose_format(update: Update, context: Context) -> int: """Обработчик выбора формата сообщения.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: @@ -178,83 +297,78 @@ async def choose_format(update: Update, context: CallbackContext) -> int: 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) + session = get_session_store().get(user_id) + session.parse_mode = format_choice + session.touch() await query.edit_message_text("Введите текст сообщения") - return ENTER_TEXT + return BotStates.ENTER_TEXT -async def enter_text(update: Update, context: CallbackContext) -> int: +async def enter_text(update: Update, context: Context) -> int: """Обработчик ввода текста сообщения.""" message = cast(Message, update.message) if not message or not message.text: return ConversationHandler.END + if not message.from_user: + 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 + return BotStates.ENTER_TEXT - if user_id not in session: - session[user_id] = {} - session[user_id]["text"] = text - update_session_activity(user_id) + session = get_session_store().get(user_id) + session.text = text + session.touch() await message.reply_text("Текст сохранен. Введите ID медиафайла или пропустите") - return ENTER_MEDIA + return BotStates.ENTER_MEDIA -async def enter_media(update: Update, context: CallbackContext) -> int: +async def enter_media(update: Update, context: Context) -> 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 - + user = cast(User, message.from_user) + user_id = user.id 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) + session = get_session_store().get(user_id) + session.media_file_id = media_id if media_id != "skip" else None + session.touch() await message.reply_text("Введите клавиатуру или пропустите") - return EDIT_KEYBOARD + return BotStates.EDIT_KEYBOARD -async def edit_keyboard(update: Update, context: CallbackContext) -> int: +async def edit_keyboard(update: Update, context: Context) -> 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 + user = cast(User, message.from_user) + user_id = user.id keyboard_text = message.text - - if user_id not in session: - session[user_id] = {} + session = get_session_store().get(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 + session.keyboard = keyboard_data + session.touch() except ValueError as e: await message.reply_text(f"Ошибка разбора клавиатуры: {e}") - return EDIT_KEYBOARD + return BotStates.EDIT_KEYBOARD - update_session_activity(user_id) await message.reply_text("Подтвердите отправку") - return CONFIRM_SEND + return BotStates.CONFIRM_SEND -async def confirm_send(update: Update, context: CallbackContext) -> int: +async def confirm_send(update: Update, context: Context) -> int: """Обработчик подтверждения отправки.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data or not query.message: @@ -266,27 +380,20 @@ async def confirm_send(update: Update, context: CallbackContext) -> int: 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: + session = get_session_store().get(user.id) + if not session: 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("Неверный тип поста") + if not session.is_complete(): + await query.edit_message_text("Ошибка: не все поля заполнены") + return ConversationHandler.END - 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"), - ) + try: + payload = session.to_dict() # Отправляем задачу через Celery task = celery_app.send_task( 'app.tasks.senders.send_post_task', @@ -305,8 +412,11 @@ async def confirm_send(update: Update, context: CallbackContext) -> int: else: await query.edit_message_text("Отправка отменена") - if user_id in session: - del session[user_id] + # Очищаем сессию пользователя + if user := update.effective_user: + session = get_session_store().get(user.id) + if session: + session.clear() return ConversationHandler.END except Exception as e: @@ -314,32 +424,31 @@ async def confirm_send(update: Update, context: CallbackContext) -> int: await query.answer("Произошла ошибка") return ConversationHandler.END -async def preview_collect_vars(update: Update, context: CallbackContext) -> int: +async def preview_collect_vars(update: Update, context: Context) -> 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: + if not message.from_user: return ConversationHandler.END + user_id = message.from_user.id 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) + session = get_session_store().get(user_id) + session.template_vars = template_vars + session.touch() await message.reply_text("Переменные сохранены. Подтвердите предпросмотр") - return PREVIEW_CONFIRM + return BotStates.PREVIEW_CONFIRM except ValueError as e: await message.reply_text(f"Ошибка разбора переменных: {e}") - return PREVIEW_VARS + return BotStates.TEMPLATE_VARS -async def preview_confirm(update: Update, context: CallbackContext) -> int: +async def preview_confirm(update: Update, context: Context) -> int: """Подтверждение предпросмотра шаблона.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data or not query.message: @@ -348,18 +457,15 @@ async def preview_confirm(update: Update, context: CallbackContext) -> int: 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: + session = get_session_store().get(user_id) + if not session: await query.edit_message_text("Ошибка: сессия потеряна") return ConversationHandler.END if choice == "use": try: - template_name = data.get("template_name") - template_vars = data.get("template_vars", {}) + template_name = session.template_name + template_vars = session.template_vars if not template_name: raise ValueError("Имя шаблона не задано") @@ -371,14 +477,12 @@ async def preview_confirm(update: Update, context: CallbackContext) -> int: "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) + session.update(rendered) await query.edit_message_text("Шаблон применен. Проверьте параметры отправки") - return CONFIRM_SEND + return BotStates.CONFIRM_SEND except Exception as e: logger.error(f"Ошибка при применении шаблона: {e}") await query.edit_message_text(f"Ошибка применения шаблона: {e}") @@ -415,20 +519,20 @@ def create_template_dict(data: dict, user_id: int) -> dict: return template_data -async def tpl_new_start(update: Update, context: CallbackContext) -> int: +async def tpl_new_start(update: Update, context: Context) -> 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) + session = get_session_store().get(user_id) + session.clear() await message.reply_text("Введите имя нового шаблона") - return TPL_NEW_NAME + return BotStates.TPL_NEW_NAME -async def tpl_new_name(update: Update, context: CallbackContext) -> int: +async def tpl_new_name(update: Update, context: Context) -> int: """Ввод имени нового шаблона.""" message = cast(Message, update.message) if not message or not message.text or not message.from_user: @@ -439,68 +543,101 @@ async def tpl_new_name(update: Update, context: CallbackContext) -> int: if not name or " " in name: await message.reply_text("Недопустимое имя шаблона") - return TPL_NEW_NAME + return BotStates.TPL_NEW_NAME - if user_id not in session: - session[user_id] = {} - session[user_id]["name"] = name - update_session_activity(user_id) + session = get_session_store().get(user_id) + session.template_name = name - await message.reply_text("Выберите тип шаблона") - return TPL_NEW_TYPE + keyboard = [] + for post_type in PostType: + button = InlineKeyboardButton( + text=post_type.value.capitalize(), + callback_data=f"type:{post_type.value}" + ) + keyboard.append([button]) -async def tpl_new_type(update: Update, context: CallbackContext) -> int: + markup = InlineKeyboardMarkup(keyboard) + await message.reply_text("Выберите тип шаблона", reply_markup=markup) + return BotStates.TPL_NEW_TYPE + +async def tpl_new_type(update: Update, context: Context) -> int: """Выбор типа нового шаблона.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: return ConversationHandler.END - user_id = query.from_user.id + user = cast(User, query.from_user) type_choice = query.data.split(":")[1] - session[user_id]["type"] = type_choice - update_session_activity(user_id) + session = get_session_store().get(user.id) + session.type = PostType(type_choice) + session.touch() - await query.edit_message_text("Выберите формат сообщения") - return TPL_NEW_FORMAT + keyboard = [ + [InlineKeyboardButton("HTML", callback_data="format:HTML")], + [InlineKeyboardButton("Markdown", callback_data="format:MarkdownV2")] + ] + markup = InlineKeyboardMarkup(keyboard) -async def tpl_new_format(update: Update, context: CallbackContext) -> int: + await query.edit_message_text("Выберите формат сообщения", reply_markup=markup) + return BotStates.TPL_NEW_FORMAT + +async def tpl_new_format(update: Update, context: Context) -> int: """Выбор формата нового шаблона.""" query = cast(CallbackQuery, update.callback_query) if not query or not query.data: return ConversationHandler.END - user_id = query.from_user.id + user = cast(User, query.from_user) format_choice = query.data.split(":")[1] - session[user_id]["format"] = format_choice - update_session_activity(user_id) + session = get_session_store().get(user.id) + session.parse_mode = format_choice + session.touch() + + await query.edit_message_text( + "Теперь введите содержимое шаблона.\n\n" + f"Формат: {format_choice}\n" + "Поддерживаются переменные в формате {variable_name}" + ) + session.touch() await query.edit_message_text("Введите содержимое шаблона") - return TPL_NEW_CONTENT + return BotStates.TPL_NEW_CONTENT -async def tpl_new_content(update: Update, context: CallbackContext) -> int: +async def tpl_new_content(update: Update, context: Context) -> 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 + user = cast(User, message.from_user) content = message.text - if not validate_message_length(content): - await message.reply_text("Слишком длинный шаблон") - return TPL_NEW_CONTENT + if len(content) > 4096: # Максимальная длина сообщения в Telegram + await message.reply_text("Слишком длинный шаблон. Максимальная длина: 4096 символов") + return BotStates.TPL_NEW_CONTENT - if user_id not in session: - session[user_id] = {} - session[user_id]["content"] = content - update_session_activity(user_id) + session = get_session_store().get(user.id) + template_data = { + "name": session.template_name, + "title": session.template_name, + "content": content, + "type": session.type, + "owner_id": user.id, + "parse_mode": session.parse_mode, + "keyboard_tpl": session.keyboard + } - await message.reply_text("Введите клавиатуру или пропустите") - return TPL_NEW_KB + try: + await create_template(template_data) + await message.reply_text("Шаблон успешно сохранен") + return ConversationHandler.END + except ValueError as e: + await message.reply_text(f"Ошибка создания шаблона: {e}") + return BotStates.TPL_NEW_CONTENT -async def list_user_templates(update: Update, context: CallbackContext) -> None: +async def list_user_templates(update: Update, context: Context) -> None: """Вывод списка доступных шаблонов.""" message = cast(Message, update.message) if not message or not message.from_user: @@ -508,9 +645,11 @@ async def list_user_templates(update: Update, context: CallbackContext) -> 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 + if context and hasattr(context, 'user_data'): + if not isinstance(context.user_data, dict): + context.user_data = {} + if context.user_data is not None: + context.user_data["tpl_page"] = 0 templates = await list_templates(owner_id=user_id) if not templates: @@ -589,28 +728,31 @@ async def create_template_with_data(data: dict, user_id: int) -> None: 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 - ) + template_data = { + "owner_id": user_id, + "name": name, + "title": name, + "content": content, + "type": PostType(tpl_type), + "parse_mode": parse_mode or "HTML", + "keyboard_tpl": keyboard + } + + await create_template(template_data) -async def handle_template_kb(update: Update, context: CallbackContext) -> int: +async def handle_template_kb(update: Update, context: Context) -> int: """Обработка клавиатуры шаблона.""" message = cast(Message, update.message) - user = update.effective_user - - if not message or not message.text or not user: + if not message or not message.text: return ConversationHandler.END + + user = cast(User, message.from_user) user_id = user.id kb_text = message.text - data = session.get(user_id) + session_store = get_session_store() + data = cast(UserSession, session_store.get(user_id)) if not data: await message.reply_text("Ошибка: сессия потеряна") return ConversationHandler.END @@ -618,28 +760,26 @@ async def handle_template_kb(update: Update, context: CallbackContext) -> int: 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 + data.keyboard = keyboard except ValueError as e: await message.reply_text(f"Ошибка разбора клавиатуры: {e}") - return TPL_NEW_KB + return BotStates.TPL_NEW_KB try: - if not data.get("name") or not data.get("content") or not data.get("type"): + if not data.template_name or not data.text: await message.reply_text("Отсутствуют обязательные данные шаблона") - return TPL_NEW_KB + return BotStates.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") - ) + template_data = { + "owner_id": user_id, + "name": data.template_name, + "title": data.template_name, + "content": data.text, + "keyboard_tpl": data.keyboard, + "type_": str(data.type) if data.type else "text", + "parse_mode": data.parse_mode if data.parse_mode else "html" # html по умолчанию + } + await create_template(template_data) await message.reply_text("Шаблон создан успешно") except ValueError as e: await message.reply_text(f"Ошибка создания шаблона: {e}") @@ -647,155 +787,167 @@ async def handle_template_kb(update: Update, context: CallbackContext) -> int: except Exception as e: logger.error(f"Неожиданная ошибка при создании шаблона: {e}") await message.reply_text("Произошла ошибка при создании шаблона") - return TPL_NEW_KB + return BotStates.TPL_NEW_KB - if user_id in session: - del session[user_id] + session_store = get_session_store() + session_store.drop(user_id) return ConversationHandler.END -async def handle_template_pagination(update: Update, context: CallbackContext) -> None: +async def handle_template_pagination(update: Update, context: Context) -> int: """Обработка пагинации в списке шаблонов.""" + if not update.callback_query or not update.effective_user: + return BotStates.ENTER_TEXT query = cast(CallbackQuery, update.callback_query) if not query or not isinstance(query.data, str): - return None + return ConversationHandler.END try: parts = query.data.split(":", 1) if len(parts) != 2: await query.answer("Некорректный формат страницы") - return None + return ConversationHandler.END 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] + return BotStates.CONVERSATION_END + except Exception as e: + logger.error(f"Error in handle_template_pagination: {e}") + return BotStates.CONVERSATION_END + return BotStates.CONVERSATION_END + + user = update.effective_user + if not user or not user.id: + await query.answer("Ошибка: пользователь не определен") + return None - 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) + # Получаем шаблоны пользователя + templates = await list_templates(owner_id=user.id) + if not templates: 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("Не удалось обновить список") + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("У вас нет шаблонов") + else: + await query.answer("У вас нет шаблонов") + return None - try: - await query.edit_message_text(text=text, reply_markup=markup) - except TelegramError as e: - logger.error(f"Ошибка при обновлении сообщения: {e}") - await query.answer("Не удалось обновить список шаблонов") + # Вычисляем диапазон для текущей страницы + 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("Не удалось обновить список") except Exception as e: logger.error(f"Ошибка при обработке пагинации: {e}") await query.answer("Произошла ошибка") -async def tpl_new_kb(update: Update, context: CallbackContext) -> int: +async def tpl_new_kb(update: Update, context: Context) -> int: """Ввод клавиатуры для нового шаблона.""" - if not update.message or not update.message.text: + message = cast(Message, update.message) + if not message or not message.text: return ConversationHandler.END - user_id = update.message.from_user.id - kb_text = update.message.text + user = cast(User, message.from_user) + kb_text = message.text - data = session.get(user_id) - if not data: - await update.message.reply_text("Ошибка: сессия потеряна") + session = get_session_store().get(user.id) + if not session.type or not session.template_name: + await message.reply_text("Ошибка: сессия потеряна") return ConversationHandler.END if kb_text != "skip": try: keyboard = parse_key_value_lines(kb_text) - data["keyboard"] = keyboard + session.keyboard = keyboard except ValueError as e: - await update.message.reply_text(f"Ошибка разбора клавиатуры: {e}") - return TPL_NEW_KB + await message.reply_text(f"Ошибка разбора клавиатуры: {e}") + return BotStates.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}") + template_data = { + "owner_id": user.id, + "name": session.template_name, + "title": session.template_name, + "content": session.text, + "type": session.type, + "parse_mode": session.parse_mode or "HTML", + "keyboard_tpl": session.keyboard + } + await create_template(template_data) + await message.reply_text("Шаблон успешно создан") - del session[user_id] - return ConversationHandler.END + # Очищаем сессию после успешного создания + session_store = get_session_store() + session_store.drop(user.id) + + return BotStates.CONVERSATION_END + except ValueError as e: + await message.reply_text(f"Ошибка создания шаблона: {e}") + return BotStates.TPL_NEW_KB + except Exception as e: + logger.error(f"Неожиданная ошибка при создании шаблона: {e}") + await message.reply_text("Произошла непредвиденная ошибка при создании шаблона") + return BotStates.TPL_NEW_KB -async def tpl_list(update: Update, context: CallbackContext) -> None: +async def tpl_list(update: Update, context: Context) -> BotStates: """Вывод списка доступных шаблонов.""" if not update.message: - return + return BotStates.CONVERSATION_END try: templates = await list_templates() if not templates: await update.message.reply_text("Нет доступных шаблонов") - return + return BotStates.CONVERSATION_END text = "Доступные шаблоны:\n\n" for tpl in templates: text += f"• {tpl.name} ({tpl.type})\n" await update.message.reply_text(text) + return BotStates.MAIN_MENU except Exception as e: await update.message.reply_text(f"Ошибка загрузки шаблонов: {e}") - - return out + return BotStates.CONVERSATION_END # -------- Команды верхнего уровня --------- -async def start(update: Update, context: CallbackContext) -> None: +async def start(update: Update, context: Context) -> None: """Обработчик команды /start.""" if not update.effective_user or not update.message: return - update_session_activity(update.effective_user.id) + session = get_session_store().get(update.effective_user.id) + session.clear() await update.message.reply_text( "Привет! Я редактор. Команды:\n" "/newpost — мастер поста\n" @@ -803,15 +955,14 @@ async def start(update: Update, context: CallbackContext) -> None: "/tpl_list — список шаблонов" ) -async def newpost(update: Update, context: CallbackContext) -> int: +async def newpost(update: Update, context: Context) -> 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()} + session = get_session_store().get(uid) + session.touch() try: async with async_session_maker() as s: @@ -832,21 +983,23 @@ async def newpost(update: Update, context: CallbackContext) -> int: "Выбери канал для публикации:", reply_markup=InlineKeyboardMarkup(kb) ) - return CHOOSE_CHANNEL + return BotStates.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: +async def choose_channel(update: Update, context: Context) -> 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) + session = get_session_store().get(uid) + if session: + session.touch() # Получаем необходимые объекты query = cast(Optional[CallbackQuery], update.callback_query) @@ -874,8 +1027,8 @@ async def choose_channel(update: Update, context: CallbackContext) -> int: return ConversationHandler.END # Сохраняем в сессию - session[user.id] = session.get(user.id, {}) - session[user.id]["channel_id"] = ch_id + session = get_session_store().get(user.id) + session.channel_id = ch_id # Создаем клавиатуру выбора типа поста kb = [ @@ -896,7 +1049,7 @@ async def choose_channel(update: Update, context: CallbackContext) -> int: reply_markup=InlineKeyboardMarkup(kb) ) - return CHOOSE_TYPE + return BotStates.CHOOSE_TYPE except Exception as e: logger.error(f"Ошибка при выборе канала: {e}", exc_info=True) @@ -906,13 +1059,15 @@ async def choose_channel(update: Update, context: CallbackContext) -> int: # ... [Остальные функции обновляются аналогично] ... -async def enter_schedule(update: Update, context: CallbackContext) -> int: +async def enter_schedule(update: Update, context: Context) -> 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) + session = get_session_store().get(uid) + if session: + session.touch() try: when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M") @@ -921,7 +1076,7 @@ async def enter_schedule(update: Update, context: CallbackContext) -> int: await update.message.reply_text( "Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM" ) - return ENTER_SCHEDULE + return BotStates.ENTER_SCHEDULE await _dispatch_with_eta(uid, when) await update.message.reply_text("Задача запланирована.") @@ -931,7 +1086,7 @@ async def enter_schedule(update: Update, context: CallbackContext) -> int: await update.message.reply_text( "Неверный формат даты. Используйте формат YYYY-MM-DD HH:MM" ) - return ENTER_SCHEDULE + return BotStates.ENTER_SCHEDULE except Exception as e: logger.error(f"Error scheduling post: {e}") await update.message.reply_text("Ошибка планирования. Попробуйте позже.") @@ -957,18 +1112,19 @@ def build_payload( async def _dispatch_with_eta(uid: int, when: datetime) -> None: """Отправка отложенного поста.""" - data = session.get(uid) + session_store = get_session_store() + data = session_store.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"), + ptype=str(data.type) if data.type else "text", + text=data.text, + media_file_id=data.media_file_id, + parse_mode=data.parse_mode or "HTML", + keyboard=data.keyboard, ) # Проверка длины сообщения @@ -984,7 +1140,7 @@ async def _dispatch_with_eta(uid: int, when: datetime) -> None: celery_app.send_task( 'app.tasks.senders.send_post_task', - args=[token, data["channel_id"], payload], + args=[token, data.channel_id, payload], eta=when ) @@ -1007,36 +1163,36 @@ def init_application(): 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: [ + BotStates.CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")], + BotStates.CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")], + BotStates.CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")], + BotStates.ENTER_TEXT: [ MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text), CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"), ], - SELECT_TEMPLATE: [ + BotStates.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: [ + BotStates.TEMPLATE_VARS: [ MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars) ], - PREVIEW_CONFIRM: [ + BotStates.PREVIEW_CONFIRM: [ CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"), CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), ], - ENTER_MEDIA: [ + BotStates.ENTER_MEDIA: [ MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media) ], - EDIT_KEYBOARD: [ + BotStates.EDIT_KEYBOARD: [ MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard) ], - CONFIRM_SEND: [ + BotStates.CONFIRM_SEND: [ CallbackQueryHandler(confirm_send, pattern=r"^send:") ], - ENTER_SCHEDULE: [ + BotStates.ENTER_SCHEDULE: [ MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule) ], }, @@ -1047,23 +1203,23 @@ def init_application(): tpl_conv = ConversationHandler( entry_points=[CommandHandler("tpl_new", tpl_new_start)], states={ - TPL_NEW_NAME: [ + BotStates.TPL_NEW_NAME: [ MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name) ], - TPL_NEW_TYPE: [ - CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:") + BotStates.TPL_NEW_TYPE: [ + CallbackQueryHandler(tpl_new_type, pattern=r"^type:") ], - TPL_NEW_FORMAT: [ - CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:") + BotStates.TPL_NEW_FORMAT: [ + CallbackQueryHandler(tpl_new_format, pattern=r"^format:") ], - TPL_NEW_CONTENT: [ + BotStates.TPL_NEW_CONTENT: [ MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content) ], - TPL_NEW_KB: [ + BotStates.TPL_NEW_KB: [ MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb) ], }, - fallbacks=[CommandHandler("start", start)], + fallbacks=[CommandHandler("cancel", cancel_handler)], ) # Регистрация всех обработчиков @@ -1086,7 +1242,7 @@ async def _render_tpl_list_message(message: Message, uid: int, page: int) -> int if not tpls: if page == 0: await message.reply_text("Шаблонов пока нет. Создай через /tpl_new.") - return ENTER_TEXT + return BotStates.ENTER_TEXT else: return await _render_tpl_list_message(message, uid, 0) @@ -1115,7 +1271,7 @@ async def _render_tpl_list_message(message: Message, uid: int, page: int) -> int except Exception as e: logger.error(f"Error rendering template list for message: {e}") await message.reply_text("Ошибка при загрузке списка шаблонов") - return ENTER_TEXT + return BotStates.ENTER_TEXT async def _render_tpl_list_query(query: CallbackQuery, uid: int, page: int) -> int: """Отображение списка шаблонов с пагинацией через callback query.""" @@ -1127,7 +1283,7 @@ async def _render_tpl_list_query(query: CallbackQuery, uid: int, page: int) -> i if not tpls: if page == 0: await query.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.") - return ENTER_TEXT + return BotStates.ENTER_TEXT else: return await _render_tpl_list_query(query, uid, 0) @@ -1156,22 +1312,14 @@ async def _render_tpl_list_query(query: CallbackQuery, uid: int, page: int) -> i except Exception as e: logger.error(f"Error rendering template list for query: {e}") await query.edit_message_text("Ошибка при загрузке списка шаблонов") - return ENTER_TEXT + return BotStates.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 + offset = page * PAGE_SIZE + total = await count_templates(uid) + tpls = await list_templates(uid, offset=offset, limit=PAGE_SIZE) kb = [] for t in tpls: @@ -1190,30 +1338,30 @@ async def _render_tpl_list(update: Update, uid: int, page: int) -> int: 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 + msg = update.effective_message + if isinstance(msg, Message): + if isinstance(update.callback_query, CallbackQuery): + await msg.edit_text(text, reply_markup=InlineKeyboardMarkup(kb)) + else: + await msg.reply_text(text, reply_markup=InlineKeyboardMarkup(kb)) + return BotStates.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 + if update.effective_message: + await update.effective_message.reply_text("Ошибка при загрузке списка шаблонов") + return BotStates.ENTER_TEXT 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" - }) + rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid}) + session = get_session_store().get(uid) + session.type = rendered["type"] + session.text = rendered["text"] + session.keyboard = rendered.get("keyboard") + session.parse_mode = rendered.get("parse_mode", "HTML") + session.touch() kb = [ [InlineKeyboardButton("Отправить сейчас", callback_data="send:now")], @@ -1225,7 +1373,7 @@ async def _apply_template_and_confirm_message(message: Message, uid: int, name: "Шаблон применён. Как публикуем?", reply_markup=markup ) - return CONFIRM_SEND + return BotStates.CONFIRM_SEND except Exception as e: logger.error(f"Error applying template via message: {e}") @@ -1235,13 +1383,13 @@ async def _apply_template_and_confirm_message(message: Message, uid: int, name: 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" - }) + rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid}) + session = get_session_store().get(uid) + session.type = rendered["type"] + session.text = rendered["text"] + session.keyboard = rendered.get("keyboard") + session.parse_mode = rendered.get("parse_mode", "HTML") + session.touch() kb = [ [InlineKeyboardButton("Отправить сейчас", callback_data="send:now")], @@ -1253,7 +1401,7 @@ async def _apply_template_and_confirm_query(query: CallbackQuery, uid: int, name "Шаблон применён. Как публикуем?", reply_markup=markup ) - return CONFIRM_SEND + return BotStates.CONFIRM_SEND except Exception as e: logger.error(f"Error applying template via query: {e}") @@ -1277,9 +1425,10 @@ async def _apply_template_and_confirm(update: Update, uid: int, name: str, ctx_v 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) + rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid}) text = rendered["text"] - parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML" + user_session = get_session_store().get(uid) + parse_mode = rendered.get("parse_mode") or "HTML" preview_text = f"Предпросмотр:\n\n{text[:3500]}" await message.reply_text(preview_text, parse_mode=parse_mode) @@ -1292,7 +1441,7 @@ async def _render_preview_and_confirm_message(message: Message, uid: int, name: markup = InlineKeyboardMarkup(kb) await message.reply_text("Что дальше?", reply_markup=markup) - return PREVIEW_CONFIRM + return BotStates.PREVIEW_CONFIRM except Exception as e: logger.error(f"Error in preview render via message: {e}") @@ -1302,9 +1451,11 @@ async def _render_preview_and_confirm_message(message: Message, uid: int, name: 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) + rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid}) text = rendered["text"] - parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML" + session_store = get_session_store() + user_session = session_store.get(uid) + parse_mode = rendered.get("parse_mode") or (user_session.parse_mode if user_session else None) or "HTML" preview_text = f"Предпросмотр:\n\n{text[:3500]}" await query.edit_message_text(preview_text, parse_mode=parse_mode) @@ -1316,9 +1467,11 @@ async def _render_preview_and_confirm_query(query: CallbackQuery, uid: int, name ] markup = InlineKeyboardMarkup(kb) if query.message: - await query.message.reply_text("Что дальше?", reply_markup=markup) + message = query.message + if isinstance(message, Message): + await message.edit_text("Что дальше?", reply_markup=markup) - return PREVIEW_CONFIRM + return BotStates.PREVIEW_CONFIRM except Exception as e: logger.error(f"Error in preview render via query: {e}") @@ -1341,7 +1494,7 @@ async def _render_preview_and_confirm(update: Update, uid: int, name: str, ctx_v # -------- Обработчики шаблонов --------- -async def choose_template_open(update: Update, context: CallbackContext) -> int: +async def choose_template_open(update: Update, context: Context) -> int: """Открытие списка шаблонов.""" query = cast(Optional[CallbackQuery], update.callback_query) if not query: @@ -1354,13 +1507,12 @@ async def choose_template_open(update: Update, context: CallbackContext) -> int: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: пользователь не определен") return ConversationHandler.END - - if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): + + if hasattr(context, 'user_data') and context.user_data is not None: 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: +async def choose_template_navigate(update: Update, context: Context) -> int: """Навигация по списку шаблонов.""" query = cast(Optional[CallbackQuery], update.callback_query) user = update.effective_user @@ -1386,7 +1538,7 @@ async def choose_template_navigate(update: Update, context: CallbackContext) -> await msg.edit_text("Ошибка: некорректный номер страницы") return ConversationHandler.END - if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): + if context and context.user_data is not None: context.user_data["tpl_page"] = page return await _render_tpl_list(update, user.id, page) @@ -1401,7 +1553,7 @@ async def choose_template_navigate(update: Update, context: CallbackContext) -> context.user_data["tpl_page"] = page return await _render_tpl_list(q, uid, page) -async def choose_template_apply(update: Update, context: CallbackContext) -> int: +async def choose_template_apply(update: Update, context: Context) -> int: """Применение выбранного шаблона.""" query = cast(Optional[CallbackQuery], update.callback_query) user = update.effective_user @@ -1422,7 +1574,7 @@ async def choose_template_apply(update: Update, context: CallbackContext) -> int name = parts[1] # Получаем шаблон и проверяем переменные - tpl = await render_template_by_name(owner_id=user.id, name=name, ctx={}) + tpl = await render_template_by_name(name=name, template_vars={}, context={"user_id": user.id}) if not tpl: if msg and hasattr(msg, 'edit_text'): await msg.edit_text("Ошибка: шаблон не найден") @@ -1431,7 +1583,7 @@ async def choose_template_apply(update: Update, context: CallbackContext) -> int required = set(tpl.get("_required", [])) # Сохраняем данные в контекст - if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): + if context and context.user_data is not None: context.user_data["preview"] = { "name": name, "provided": {}, @@ -1447,7 +1599,7 @@ async def choose_template_apply(update: Update, context: CallbackContext) -> int f"{', '.join(sorted(required))}\n\n" f"Пожалуйста, введите значение для параметра {next_var}:" ) - return PREVIEW_VARS + return BotStates.TEMPLATE_VARS # Если переменных нет, применяем шаблон сразу return await _apply_template_and_confirm(update, user.id, name, {}) @@ -1459,7 +1611,7 @@ async def choose_template_apply(update: Update, context: CallbackContext) -> int await msg.edit_text("Ошибка при применении шаблона") return ConversationHandler.END -async def choose_template_preview(update: Update, context: CallbackContext) -> int: +async def choose_template_preview(update: Update, context: Context) -> int: """Предпросмотр шаблона.""" if (not update.callback_query or not update.effective_user or not context.user_data or not update.callback_query.data): @@ -1470,7 +1622,7 @@ async def choose_template_preview(update: Update, context: CallbackContext) -> i try: name = update.callback_query.data.split(":")[1] - tpl = await render_template_by_name(owner_id=uid, name=name, ctx={}) + tpl = await render_template_by_name(name=name, template_vars={}, context={"user_id": uid}) required = set(tpl.get("_required", [])) context.user_data["preview"] = { "name": name, @@ -1492,7 +1644,7 @@ async def choose_template_preview(update: Update, context: CallbackContext) -> i await update.callback_query.edit_message_text("Ошибка при предпросмотре шаблона") return ConversationHandler.END -async def choose_template_cancel(update: Update, context: CallbackContext) -> int: +async def choose_template_cancel(update: Update, context: Context) -> int: """Отмена выбора шаблона.""" if not update.callback_query: return ConversationHandler.END @@ -1557,7 +1709,11 @@ class BotApplication: # Запуск бота while not self._shutdown: try: - await self.app.updater.start_polling() + if self.app and self.app.updater: + await self.app.updater.start_polling() + else: + logger.error("Application or updater is not initialized") + await asyncio.sleep(1) except Exception as e: if not self._shutdown: logger.error(f"Polling error: {e}") diff --git a/app/bots/states/__init__.py b/app/bots/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bots/states/base.py b/app/bots/states/base.py index 882f6a1..e7d9f21 100644 --- a/app/bots/states/base.py +++ b/app/bots/states/base.py @@ -1,18 +1,9 @@ from abc import ABC, abstractmethod -from enum import IntEnum from telegram import Update from telegram.ext import CallbackContext +from ..editor.states import BotStates class State(ABC): @abstractmethod async def handle(self, update: Update, context: CallbackContext) -> int: - pass - -class BotStates(IntEnum): - CHOOSE_CHANNEL = 0 - CHOOSE_TYPE = 1 - ENTER_TEXT = 2 - ENTER_MEDIA = 3 - EDIT_KEYBOARD = 4 - CONFIRM_SEND = 5 - ENTER_SCHEDULE = 6 \ No newline at end of file + pass \ No newline at end of file diff --git a/app/models/bot.py b/app/models/bot.py index eacb81c..ba255b7 100644 --- a/app/models/bot.py +++ b/app/models/bot.py @@ -1,6 +1,6 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import ForeignKey, String, func, DateTime +from sqlalchemy import ForeignKey, String, func, DateTime, Column from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.session import Base @@ -8,6 +8,7 @@ class Bot(Base): __tablename__ = "bots" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + bot_id: Mapped[int] = mapped_column() # Telegram bot ID owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) name: Mapped[str] = mapped_column(String(64)) username: Mapped[str | None] = mapped_column(String(64)) @@ -15,3 +16,4 @@ class Bot(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) owner = relationship("User") + channels = relationship("Channel", back_populates="bot") diff --git a/app/models/channel.py b/app/models/channel.py index 446941c..ef108a5 100644 --- a/app/models/channel.py +++ b/app/models/channel.py @@ -3,7 +3,8 @@ from datetime import datetime from sqlalchemy import ForeignKey, String, BigInteger, Boolean, UniqueConstraint, func, DateTime from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.session import Base -from app.models.user import User # Добавляем импорт User +from app.models.user import User +from app.models.bot import Bot class Channel(Base): __tablename__ = "channels" @@ -11,12 +12,14 @@ class Channel(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) - chat_id: Mapped[int] = mapped_column(BigInteger, index=True) + bot_id: Mapped[int] = mapped_column(ForeignKey("bots.id", ondelete="CASCADE")) + chat_id: Mapped[int] = mapped_column(index=True) title: Mapped[str | None] = mapped_column(String(128)) username: Mapped[str | None] = mapped_column(String(64)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) owner = relationship("User") + bot = relationship("Bot", back_populates="channels") class BotChannel(Base): __tablename__ = "bot_channels" diff --git a/app/models/post.py b/app/models/post.py index d2931c4..2b1cba0 100644 --- a/app/models/post.py +++ b/app/models/post.py @@ -26,8 +26,8 @@ class Post(Base): bot_id: Mapped[Optional[int]] = mapped_column(ForeignKey("bots.id", ondelete="SET NULL"), nullable=True) channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) - type: Mapped[PostType] = mapped_column(Enum(PostType)) - text: Mapped[Optional[str]] = mapped_column(String(4096)) + type: Mapped[PostType] = mapped_column() + text: Mapped[str | None] = mapped_column(String(4096)) media_file_id: Mapped[Optional[str]] = mapped_column(String(512)) parse_mode: Mapped[Optional[str]] = mapped_column(String(16)) keyboard_id: Mapped[Optional[int]] = mapped_column(ForeignKey("keyboards.id", ondelete="SET NULL")) diff --git a/app/models/templates.py b/app/models/templates.py index 7bbbde4..8b12d16 100644 --- a/app/models/templates.py +++ b/app/models/templates.py @@ -8,6 +8,12 @@ from app.db.session import Base from app.models.post import PostType from enum import Enum as PyEnum +class PostStatus(str, PyEnum): + draft = "draft" + scheduled = "scheduled" + sent = "sent" + failed = "failed" + class TemplateVisibility(str, PyEnum): private = "private" org = "org" @@ -18,6 +24,58 @@ class Template(Base): __table_args__ = ( UniqueConstraint("owner_id", "name", name="uq_template_owner_name"), ) + + id: Mapped[int] = mapped_column(primary_key=True) + owner_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + name: Mapped[str] = mapped_column(String(100)) + title: Mapped[str] = mapped_column(String(200)) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + content: Mapped[str] = mapped_column(Text) + keyboard_tpl: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + parse_mode: Mapped[str] = mapped_column(String(20), default="HTML") + type: Mapped[PostType] = mapped_column(Enum(PostType)) + visibility: Mapped[TemplateVisibility] = mapped_column( + EnumType(TemplateVisibility), + default=TemplateVisibility.private + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + server_default=func.now(), + onupdate=func.now() + ) + + @classmethod + async def get_by_name(cls, session, name: str) -> Optional[Template]: + """Получение шаблона по имени.""" + stmt = cls.__table__.select().where(cls.__table__.c.name == name) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + async def render(self, template_vars: dict, context: dict) -> dict: + """Рендеринг шаблона. + + Args: + template_vars: Переменные для подстановки + context: Дополнительный контекст + + Returns: + Отрендеренные данные + """ + text = self.content + keyboard = self.keyboard_tpl + + # Подстановка переменных + for key, value in template_vars.items(): + text = text.replace(f"{{${key}}}", str(value)) + + # Подготовка данных для отправки + return { + "type": self.type, + "text": text, + "keyboard": keyboard, + "parse_mode": self.parse_mode + } id: Mapped[int] = mapped_column(primary_key=True) owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) diff --git a/app/models/user.py b/app/models/user.py index 5feec51..6b0189d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -8,7 +8,7 @@ class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - tg_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) + tg_user_id: Mapped[int] = mapped_column(unique=True, index=True) username: Mapped[str | None] = mapped_column(String(64)) role: Mapped[str] = mapped_column(String(16), default="user") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/services/channels.py b/app/services/channels.py new file mode 100644 index 0000000..af4fcc5 --- /dev/null +++ b/app/services/channels.py @@ -0,0 +1,93 @@ +"""Сервис для работы с каналами.""" +from typing import List, Optional +from sqlalchemy import select +from app.models.channel import Channel, BotChannel +from app.models.bot import Bot +from app.db.session import async_session_maker + +class ChannelService: + """Сервис для работы с каналами.""" + + @staticmethod + async def get_user_channels(user_id: int) -> List[Channel]: + """Получает список каналов пользователя.""" + async with async_session_maker() as session: + stmt = select(Channel).where(Channel.owner_id == user_id) + result = await session.execute(stmt) + return list(result.scalars().all()) + + @staticmethod + async def get_channel(channel_id: int) -> Optional[Channel]: + """Получает канал по ID.""" + async with async_session_maker() as session: + stmt = select(Channel).where(Channel.id == channel_id) + result = await session.execute(stmt) + return result.scalars().first() + + @staticmethod + async def get_bot_channels(bot_id: int) -> List[Channel]: + """Получает список каналов бота.""" + async with async_session_maker() as session: + stmt = select(Channel).where(Channel.bot_id == bot_id) + result = await session.execute(stmt) + return list(result.scalars().all()) + + @staticmethod + async def add_channel( + owner_id: int, + bot_id: int, + chat_id: int, + title: Optional[str] = None, + username: Optional[str] = None + ) -> Channel: + """Добавляет новый канал.""" + async with async_session_maker() as session: + channel = Channel( + owner_id=owner_id, + bot_id=bot_id, + chat_id=chat_id, + title=title, + username=username + ) + session.add(channel) + await session.commit() + await session.refresh(channel) + return channel + + @staticmethod + async def update_channel( + channel_id: int, + title: Optional[str] = None, + username: Optional[str] = None + ) -> bool: + """Обновляет данные канала.""" + async with async_session_maker() as session: + stmt = select(Channel).where(Channel.id == channel_id) + result = await session.execute(stmt) + channel = result.scalars().first() + + if not channel: + return False + + if title is not None: + channel.title = title + if username is not None: + channel.username = username + + await session.commit() + return True + + @staticmethod + async def delete_channel(channel_id: int) -> bool: + """Удаляет канал.""" + async with async_session_maker() as session: + stmt = select(Channel).where(Channel.id == channel_id) + result = await session.execute(stmt) + channel = result.scalars().first() + + if not channel: + return False + + await session.delete(channel) + await session.commit() + return True diff --git a/app/services/telegram.py b/app/services/telegram.py index ad442f1..7e5b8b3 100644 --- a/app/services/telegram.py +++ b/app/services/telegram.py @@ -1,19 +1,214 @@ -from typing import Iterable +from __future__ import annotations +import logging +from typing import Dict, Any, Optional, Iterable, Tuple -def make_keyboard_payload(buttons: Iterable[tuple[str, str]] | None): +from telegram import Bot, Message, InlineKeyboardMarkup +from telegram.error import InvalidToken, TelegramError + +logger = logging.getLogger(__name__) + +def make_keyboard_payload(buttons: Optional[Iterable[Tuple[str, str]]]) -> Optional[Dict]: + """ + Создает структуру inline-клавиатуры для API Telegram. + + Args: + buttons: Список кнопок в формате [(text, url), ...] + + Returns: + Dict в формате {"rows": [[{"text": text, "url": url}], ...]} + """ if not buttons: return None rows = [[{"text": t, "url": u}] for t, u in buttons] return {"rows": rows} +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 для отправки поста. + + Args: + ptype: Тип поста (text/photo/video/animation) + text: Текст сообщения + media_file_id: ID медиафайла в Telegram + parse_mode: Формат разметки (HTML/MarkdownV2) + keyboard: Inline клавиатура + + Returns: + Dict содержащий все необходимые поля для отправки + """ + 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 -def build_payload(ptype: str, text: str | None, media_file_id: str | None, - parse_mode: str | None, keyboard: dict | None) -> dict: - # ptype: "text" | "photo" | "video" | "animation" - return { - "type": ptype, - "text": text, - "media_file_id": media_file_id, - "parse_mode": parse_mode, - "keyboard": keyboard, - } \ No newline at end of file +async def validate_bot_token(token: str) -> Tuple[bool, Optional[str], Optional[int]]: + """ + Проверяет валидность токена бота и возвращает его username и ID. + + Args: + token: Токен бота для проверки + + Returns: + tuple[bool, Optional[str], Optional[int]]: (is_valid, username, bot_id) + """ + try: + bot = Bot(token) + me = await bot.get_me() + return True, me.username, me.id + except InvalidToken: + logger.warning(f"Invalid bot token provided: {token[:10]}...") + return False, None, None + except TelegramError as e: + logger.error(f"Telegram error while validating bot token: {e}") + return False, None, None + except Exception as e: + logger.exception(f"Unexpected error while validating bot token: {e}") + return False, None, None + finally: + if 'bot' in locals(): + await bot.close() + +def validate_message_length(text: str) -> bool: + """ + Проверяет длину сообщения на соответствие лимитам Telegram. + + Args: + text: Текст для проверки + + Returns: + bool: True если длина в пределах лимита + """ + return len(text) <= 4096 # Максимальная длина текста в Telegram + +def is_valid_webhook_url(url: str) -> bool: + """Проверяет соответствие URL требованиям Telegram для вебхуков. + + Args: + url: URL для проверки + + Returns: + bool: True если URL валидный, иначе False + """ + if not url: + return False + + return True # TODO: implement proper validation + + +class PostService: + """Сервис для работы с постами.""" + + @staticmethod + async def preview_post(message: Message, post_data: Dict[str, Any]) -> None: + """Показывает предпросмотр поста. + + Args: + message (Message): Telegram сообщение + post_data (Dict[str, Any]): Данные поста из сессии + """ + text = post_data.get('text', '') + parse_mode = post_data.get('parse_mode', 'HTML') + keyboard = post_data.get('keyboard') + + if keyboard: + # Создаем разметку клавиатуры + rows = keyboard.get('rows', []) + markup = InlineKeyboardMarkup(rows) if rows else None + else: + markup = None + + media_file_id = post_data.get('media_file_id') + if media_file_id: + # Отправляем медиафайл с подписью + try: + await message.reply_photo( + photo=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=markup + ) + except TelegramError as e: + # В случае ошибки отправляем только текст + logger.error(f"Error sending photo preview: {e}") + await message.reply_text( + text=text, + parse_mode=parse_mode, + reply_markup=markup + ) + else: + # Отправляем только текст + await message.reply_text( + text=text, + parse_mode=parse_mode, + reply_markup=markup + ) + + @staticmethod + async def create_post(bot: Bot, chat_id: int, post_data: Dict[str, Any]) -> bool: + """Создает новый пост в канале. + + Args: + bot (Bot): Экземпляр бота + chat_id (int): ID канала + post_data (Dict[str, Any]): Данные поста + + Returns: + bool: Успешность создания + """ + try: + text = post_data.get('text', '') + parse_mode = post_data.get('parse_mode', 'HTML') + keyboard = post_data.get('keyboard') + + if keyboard: + rows = keyboard.get('rows', []) + markup = InlineKeyboardMarkup(rows) if rows else None + else: + markup = None + + media_file_id = post_data.get('media_file_id') + if media_file_id: + await bot.send_photo( + chat_id=chat_id, + photo=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=markup + ) + else: + await bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + reply_markup=markup + ) + return True + + except TelegramError as e: + logger.error(f"Error creating post: {e}") + return False +def validate_url(url: str) -> bool: + """Проверяет соответствие URL требованиям. + + Args: + url (str): URL для проверки + + Returns: + bool: True если URL соответствует требованиям + """ + return ( + url.startswith("https://") and + not url.startswith("https://telegram.org") and + len(url) <= 512 + ) \ No newline at end of file diff --git a/app/services/template.py b/app/services/template.py new file mode 100644 index 0000000..7f7e031 --- /dev/null +++ b/app/services/template.py @@ -0,0 +1,121 @@ +"""Логика работы с шаблонами.""" +from typing import Dict, Any, Optional, List + +from sqlalchemy import select +from app.db.session import async_session_maker +from app.models.templates import Template +from app.models.post import PostType +from app.bots.editor.messages import MessageType + +class TemplateService: + @staticmethod + async def list_user_templates(owner_id: int) -> List[Template]: + """Получить список шаблонов пользователя.""" + async with async_session_maker() as session: + query = select(Template).where(Template.owner_id == owner_id) + result = await session.execute(query) + return list(result.scalars()) + + @staticmethod + async def get_template(template_id: str) -> Optional[Template]: + """Получить шаблон по ID.""" + async with async_session_maker() as session: + query = select(Template).where(Template.id == template_id) + result = await session.execute(query) + return result.scalar_one_or_none() + + +async def list_templates(owner_id: Optional[int] = None, limit: Optional[int] = None, offset: Optional[int] = None) -> list[Template]: + """Получить список всех шаблонов. + + Args: + owner_id: Опциональный ID владельца + + Returns: + List[Template]: Список шаблонов + """ + async with async_session_maker() as session: + query = Template.__table__.select() + if owner_id is not None: + query = query.where(Template.__table__.c.owner_id == owner_id) + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + result = await session.execute(query) + return list(result.scalars()) + +async def create_template(template_data: Dict[str, Any]) -> Template: + """Создать новый шаблон. + + Args: + template_data: Данные шаблона + + Returns: + Template: Созданный шаблон + """ + async with async_session_maker() as session: + template = Template(**template_data) + session.add(template) + await session.commit() + return template + +async def render_template_by_name( + name: str, + template_vars: Dict[str, Any], + context: Dict[str, Any], +) -> Dict[str, Any]: + """Рендеринг шаблона по имени. + + Args: + name: Имя шаблона + template_vars: Переменные для подстановки + context: Дополнительный контекст + + Returns: + Dict[str, Any]: Отрендеренные данные для поста + """ + async with async_session_maker() as session: + stmt = Template.__table__.select().where(Template.__table__.c.name == name) + result = await session.execute(stmt) + template = result.scalar_one_or_none() + + if not template: + raise ValueError(f"Шаблон {name} не найден") + + text = template.content + keyboard = template.keyboard_tpl + + # Подстановка переменных + for key, value in template_vars.items(): + text = text.replace(f"{{${key}}}", str(value)) + + # Проверяем тип и конвертируем в MessageType + message_type = MessageType.TEXT + if template.type == PostType.photo: + message_type = MessageType.PHOTO + elif template.type == PostType.video: + message_type = MessageType.VIDEO + + return { + "type": message_type, + "text": text, + "keyboard": keyboard, + "parse_mode": template.parse_mode + } + +async def count_templates(owner_id: Optional[int] = None) -> int: + """Посчитать количество шаблонов. + + Args: + owner_id: Опциональный ID владельца + + Returns: + int: Количество шаблонов + """ + async with async_session_maker() as session: + query = Template.__table__.select() + if owner_id is not None: + query = query.where(Template.__table__.c.owner_id == owner_id) + result = await session.execute(query) + return len(list(result.scalars())) diff --git a/bin/migrate.sh b/bin/migrate.sh new file mode 100755 index 0000000..144c110 --- /dev/null +++ b/bin/migrate.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "Creating migration..." +docker compose run --rm api bash -c "alembic revision --autogenerate -m 'Add description column to templates'" + +echo "Applying migration..." +docker compose run --rm api bash -c "alembic upgrade head"