From e5c3f79d2ebf40e210af21fdc5601d22d11059f7 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Sun, 17 Aug 2025 14:15:46 +0900 Subject: [PATCH] bug fix --- Dockerfile.base | 11 + app/bots/editor/keyboards.py | 6 +- app/bots/editor/oop_app.py | 6 +- app/bots/editor/states.py | 1 - app/bots/editor/wizard.py | 42 +- app/bots/editor_bot.py | 1121 +++++++++++++++++++++++++++++++--- app/bots/editor_bot.py.new | 395 ------------ requirements.txt | 3 +- 8 files changed, 1074 insertions(+), 511 deletions(-) create mode 100644 Dockerfile.base delete mode 100644 app/bots/editor_bot.py.new diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..1a09805 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 diff --git a/app/bots/editor/keyboards.py b/app/bots/editor/keyboards.py index edc3253..47fbc84 100644 --- a/app/bots/editor/keyboards.py +++ b/app/bots/editor/keyboards.py @@ -82,9 +82,9 @@ class KbBuilder: return InlineKeyboardMarkup(rows) @staticmethod - def tpl_confirm_delete(tpl_id: int) -> InlineKeyboardMarkup: + def tpl_list_actions(tpl_id: int) -> InlineKeyboardMarkup: rows = [ - [InlineKeyboardButton("Да, удалить", callback_data=f"tpldelok:{tpl_id}")], - [InlineKeyboardButton("Отмена", callback_data="tpl:cancel")], + [InlineKeyboardButton("Удалить", callback_data=f"tpldelok:{tpl_id}")], + [InlineKeyboardButton("Назад", callback_data="tpl:cancel")], ] return InlineKeyboardMarkup(rows) diff --git a/app/bots/editor/oop_app.py b/app/bots/editor/oop_app.py index e6ea851..b1a4723 100644 --- a/app/bots/editor/oop_app.py +++ b/app/bots/editor/oop_app.py @@ -59,10 +59,7 @@ def build_app() -> Application: States.TPL_NEW_FORMAT: [CallbackQueryHandler(wizard.tpl_new_format, pattern=r"^tplfmt:")], States.TPL_NEW_CONTENT: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_content)], States.TPL_NEW_KB: [MessageHandler(filters.TEXT & ~filters.COMMAND, wizard.tpl_new_kb)], - States.TPL_CONFIRM_DELETE: [ - CallbackQueryHandler(wizard.tpl_delete_ok, pattern=r"^tpldelok:"), - CallbackQueryHandler(wizard.choose_template_cancel, pattern=r"^tpl:cancel$"), - ], + }, fallbacks=[CommandHandler("start", wizard.start)], ) @@ -71,6 +68,7 @@ def build_app() -> Application: app.add_handler(post_conv) app.add_handler(tpl_conv) app.add_handler(CommandHandler("tpl_list", wizard.tpl_list)) + app.add_handler(CallbackQueryHandler(wizard.tpl_delete_ok, pattern=r"^tpldelok:")) return app diff --git a/app/bots/editor/states.py b/app/bots/editor/states.py index c377995..db0a54a 100644 --- a/app/bots/editor/states.py +++ b/app/bots/editor/states.py @@ -21,4 +21,3 @@ class States(IntEnum): TPL_NEW_FORMAT = 13 TPL_NEW_CONTENT = 14 TPL_NEW_KB = 15 - TPL_CONFIRM_DELETE = 16 diff --git a/app/bots/editor/wizard.py b/app/bots/editor/wizard.py index 8238ce5..dea458c 100644 --- a/app/bots/editor/wizard.py +++ b/app/bots/editor/wizard.py @@ -392,19 +392,31 @@ class EditorWizard: return -1 async def tpl_list(self, update: Update, context: CallbackContext): + if not update.effective_user: + return -1 + uid = update.effective_user.id + + if update.callback_query and update.callback_query.data: + q = update.callback_query + if not q: + return -1 + await q.answer() + page = int(q.data.split(":", 1)[1]) if ":" in q.data else 0 + return await self._render_tpl_list(q, uid, page) + + if not context or not context.user_data: + return -1 context.user_data["tpl_page"] = 0 - return await self._render_tpl_list(update.message, update.effective_user.id, page=0) - - async def tpl_confirm_delete(self, update: Update, context: CallbackContext): - from .keyboards import KbBuilder # локальный импорт уже есть, просто используем метод - q = update.callback_query - await q.answer() - tpl_id = int(q.data.split(":", 1)[1]) - await q.edit_message_text("Удалить шаблон?", reply_markup=KbBuilder.tpl_confirm_delete(tpl_id)) - return States.TPL_CONFIRM_DELETE + return await self._render_tpl_list(update.message, uid, page=0) async def tpl_delete_ok(self, update: Update, context: CallbackContext): + if not update.callback_query or not update.effective_user: + return -1 + q = update.callback_query + if not q.data: + return -1 + await q.answer() uid = update.effective_user.id tpl_id = int(q.data.split(":", 1)[1]) @@ -417,7 +429,7 @@ class EditorWizard: async def _render_preview_and_confirm(self, q_or_msg, uid: int, name: str, ctx_vars: dict): rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) text = rendered["text"] - parse_mode = rendered.get("parse_mode") or self.sessions.get(uid).parse_mode or "HTML" + parse_mode = rendered.get("parse_mode") or (self.sessions.get(uid).parse_mode if self.sessions.get(uid) else None) or "HTML" if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode) @@ -454,12 +466,13 @@ class EditorWizard: return token = settings.editor_bot_token payload = build_payload( - ptype=s.type, - text=s.text, + ptype=str(s.type or "text"), + text=s.text or "", media_file_id=s.media_file_id, parse_mode=s.parse_mode or "HTML", keyboard=s.keyboard, ) + from app.tasks.senders import send_post_task send_post_task.delay(token, s.channel_id, payload) await qmsg.edit_message_text("Отправка запущена.") @@ -467,12 +480,13 @@ class EditorWizard: s = self.sessions.get(uid) token = settings.editor_bot_token payload = build_payload( - ptype=s.type, - text=s.text, + ptype=str(s.type or "text"), + text=s.text or "", media_file_id=s.media_file_id, parse_mode=s.parse_mode or "HTML", keyboard=s.keyboard, ) + from app.tasks.senders import send_post_task send_post_task.apply_async(args=[token, s.channel_id, payload], eta=when) @staticmethod diff --git a/app/bots/editor_bot.py b/app/bots/editor_bot.py index 7d286f4..5b000ac 100644 --- a/app/bots/editor_bot.py +++ b/app/bots/editor_bot.py @@ -2,11 +2,11 @@ from __future__ import annotations import shlex import logging from datetime import datetime -from typing import Optional, Dict, List, Any +from typing import Optional, Dict, List, Any, Union, cast import time from urllib.parse import urlparse -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Message, CallbackQuery from telegram.ext import ( Application, CommandHandler, MessageHandler, ConversationHandler, CallbackQueryHandler, CallbackContext, filters, @@ -18,6 +18,8 @@ from sqlalchemy import select from app.core.config import settings from app.tasks.senders import send_post_task +from app.tasks.celery_app import celery_app +from celery import shared_task from app.db.session import async_session_maker from app.models.channel import Channel from app.models.post import PostType @@ -42,7 +44,7 @@ ALLOWED_URL_SCHEMES = ('http', 'https', 't.me') CHOOSE_CHANNEL, CHOOSE_TYPE, CHOOSE_FORMAT, ENTER_TEXT, ENTER_MEDIA, EDIT_KEYBOARD, CONFIRM_SEND, ENTER_SCHEDULE, SELECT_TEMPLATE, PREVIEW_VARS, PREVIEW_CONFIRM, - TPL_NEW_NAME, TPL_NEW_TYPE, TPL_NEW_FORMAT, TPL_NEW_CONTENT, TPL_NEW_KB, TPL_CONFIRM_DELETE, + TPL_NEW_NAME, TPL_NEW_TYPE, TPL_NEW_FORMAT, TPL_NEW_CONTENT, TPL_NEW_KB ) = range(16) # In-memory сессии с метаданными @@ -148,6 +150,641 @@ def parse_key_value_lines(text: str) -> dict: out[k.strip()] = v except ValueError as e: logger.warning(f"Error parsing key-value line: {e}") + return out + +async def choose_type(update: Update, context: CallbackContext) -> int: + """Обработчик выбора типа поста.""" + query = cast(CallbackQuery, update.callback_query) + if not query or not query.data: + return ConversationHandler.END + + user_id = query.from_user.id + type_choice = query.data.split(":")[1] + + if user_id not in session: + session[user_id] = {} + session[user_id]["type"] = type_choice + update_session_activity(user_id) + + await query.edit_message_text("Выберите формат сообщения") + return CHOOSE_FORMAT + +async def choose_format(update: Update, context: CallbackContext) -> int: + """Обработчик выбора формата сообщения.""" + query = cast(CallbackQuery, update.callback_query) + if not query or not query.data: + return ConversationHandler.END + + user_id = query.from_user.id + format_choice = query.data.split(":")[1] + + if user_id not in session: + session[user_id] = {} + session[user_id]["format"] = format_choice + update_session_activity(user_id) + + await query.edit_message_text("Введите текст сообщения") + return ENTER_TEXT + +async def enter_text(update: Update, context: CallbackContext) -> int: + """Обработчик ввода текста сообщения.""" + message = cast(Message, update.message) + if not message or not message.text: + return ConversationHandler.END + + user_id = message.from_user.id + text = message.text + + if not validate_message_length(text): + await message.reply_text("Слишком длинное сообщение") + return ENTER_TEXT + + if user_id not in session: + session[user_id] = {} + session[user_id]["text"] = text + update_session_activity(user_id) + + await message.reply_text("Текст сохранен. Введите ID медиафайла или пропустите") + return ENTER_MEDIA + +async def enter_media(update: Update, context: CallbackContext) -> int: + """Обработчик ввода медиафайла.""" + message = cast(Message, update.message) + if not message or not message.text: + return ConversationHandler.END + + user_id = message.from_user.id + if user_id is None: + return ConversationHandler.END + + media_id = message.text + + if user_id not in session: + session[user_id] = {} + session[user_id]["media_id"] = media_id if media_id != "skip" else None + update_session_activity(user_id) + + await message.reply_text("Введите клавиатуру или пропустите") + return EDIT_KEYBOARD + +async def edit_keyboard(update: Update, context: CallbackContext) -> int: + """Обработчик редактирования клавиатуры.""" + message = cast(Message, update.message) + if not message or not message.text: + return ConversationHandler.END + + user_id = message.from_user.id + if user_id is None: + return ConversationHandler.END + + keyboard_text = message.text + + if user_id not in session: + session[user_id] = {} + + if keyboard_text != "skip": + try: + keyboard_data = parse_key_value_lines(keyboard_text) if keyboard_text else {} + session[user_id]["keyboard"] = keyboard_data + except ValueError as e: + await message.reply_text(f"Ошибка разбора клавиатуры: {e}") + return EDIT_KEYBOARD + + update_session_activity(user_id) + await message.reply_text("Подтвердите отправку") + return CONFIRM_SEND + +async def confirm_send(update: Update, context: CallbackContext) -> int: + """Обработчик подтверждения отправки.""" + query = cast(CallbackQuery, update.callback_query) + if not query or not query.data or not query.message: + return ConversationHandler.END + + user = update.effective_user + if not user: + await query.answer("Ошибка: пользователь не определен") + return ConversationHandler.END + + try: + user_id = user.id + choice = query.data.split(":", 1)[1] + + if choice == "yes": + data = session.get(user_id) + if not data: + await query.edit_message_text("Ошибка: сессия потеряна") + return ConversationHandler.END + + try: + post_type = data.get("type") + if not post_type or not isinstance(post_type, str): + raise ValueError("Неверный тип поста") + + payload = build_payload( + ptype=post_type, + text=data.get("text"), + media_file_id=data.get("media_id"), + parse_mode=data.get("format"), + keyboard=data.get("keyboard"), + ) + # Отправляем задачу через Celery + task = celery_app.send_task( + 'app.tasks.senders.send_post_task', + args=[payload] + ) + await query.edit_message_text( + f"Пост поставлен в очередь\nID задачи: {task.id}" + ) + except ValueError as e: + await query.edit_message_text(f"Ошибка валидации: {e}") + return ConversationHandler.END + except Exception as e: + logger.error(f"Ошибка при отправке поста: {e}") + await query.edit_message_text("Произошла ошибка при отправке поста") + return ConversationHandler.END + else: + await query.edit_message_text("Отправка отменена") + + if user_id in session: + del session[user_id] + return ConversationHandler.END + + except Exception as e: + logger.error(f"Ошибка при подтверждении отправки: {e}") + await query.answer("Произошла ошибка") + return ConversationHandler.END + +async def preview_collect_vars(update: Update, context: CallbackContext) -> int: + """Сбор переменных для предпросмотра шаблона.""" + message = cast(Message, update.message) + if not message or not message.text: + return ConversationHandler.END + + user_id = message.from_user.id + if user_id is None: + return ConversationHandler.END + + vars_text = message.text + + try: + template_vars = parse_key_value_lines(vars_text) + if user_id not in session: + session[user_id] = {} + session[user_id]["template_vars"] = template_vars + update_session_activity(user_id) + + await message.reply_text("Переменные сохранены. Подтвердите предпросмотр") + return PREVIEW_CONFIRM + except ValueError as e: + await message.reply_text(f"Ошибка разбора переменных: {e}") + return PREVIEW_VARS + +async def preview_confirm(update: Update, context: CallbackContext) -> int: + """Подтверждение предпросмотра шаблона.""" + query = cast(CallbackQuery, update.callback_query) + if not query or not query.data or not query.message: + return ConversationHandler.END + + user_id = query.from_user.id + choice = query.data.split(":")[1] + + if not isinstance(context.user_data, dict): + context.user_data = {} + + data = session.get(user_id) + if not data: + await query.edit_message_text("Ошибка: сессия потеряна") + return ConversationHandler.END + + if choice == "use": + try: + template_name = data.get("template_name") + template_vars = data.get("template_vars", {}) + + if not template_name: + raise ValueError("Имя шаблона не задано") + + # Создаем контекст для шаблонизатора + template_context = { + "user_id": user_id, + "vars": template_vars, + "context": dict(context.user_data) if context.user_data else {} + } + + rendered = await render_template_by_name(template_name, template_vars, template_context) + if user_id not in session: + session[user_id] = {} + session[user_id].update(rendered) + update_session_activity(user_id) + + await query.edit_message_text("Шаблон применен. Проверьте параметры отправки") + return CONFIRM_SEND + except Exception as e: + logger.error(f"Ошибка при применении шаблона: {e}") + await query.edit_message_text(f"Ошибка применения шаблона: {e}") + return ConversationHandler.END + else: + await query.edit_message_text("Редактирование отменено") + return ConversationHandler.END + +def create_template_dict(data: dict, user_id: int) -> dict: + """Создание словаря параметров для создания шаблона. + + Args: + data: Исходные данные + user_id: ID пользователя + + Returns: + dict: Подготовленные данные для создания шаблона + """ + template_data = { + "owner_id": user_id, + "name": data.get("name"), + "title": data.get("name"), + "content": data.get("content"), + "keyboard_tpl": data.get("keyboard"), + "type_": data.get("type"), + "parse_mode": data.get("format") + } + + # Проверяем обязательные поля + required_fields = ["name", "content", "type_"] + missing_fields = [f for f in required_fields if not template_data.get(f)] + if missing_fields: + raise ValueError(f"Не хватает обязательных полей: {', '.join(missing_fields)}") + + return template_data + +async def tpl_new_start(update: Update, context: CallbackContext) -> int: + """Начало создания нового шаблона.""" + message = cast(Message, update.message) + if not message or not message.from_user: + return ConversationHandler.END + + user_id = message.from_user.id + session[user_id] = {} + update_session_activity(user_id) + + await message.reply_text("Введите имя нового шаблона") + return TPL_NEW_NAME + +async def tpl_new_name(update: Update, context: CallbackContext) -> int: + """Ввод имени нового шаблона.""" + message = cast(Message, update.message) + if not message or not message.text or not message.from_user: + return ConversationHandler.END + + user_id = message.from_user.id + name = message.text.strip() + + if not name or " " in name: + await message.reply_text("Недопустимое имя шаблона") + return TPL_NEW_NAME + + if user_id not in session: + session[user_id] = {} + session[user_id]["name"] = name + update_session_activity(user_id) + + await message.reply_text("Выберите тип шаблона") + return TPL_NEW_TYPE + +async def tpl_new_type(update: Update, context: CallbackContext) -> int: + """Выбор типа нового шаблона.""" + query = cast(CallbackQuery, update.callback_query) + if not query or not query.data: + return ConversationHandler.END + + user_id = query.from_user.id + type_choice = query.data.split(":")[1] + + session[user_id]["type"] = type_choice + update_session_activity(user_id) + + await query.edit_message_text("Выберите формат сообщения") + return TPL_NEW_FORMAT + +async def tpl_new_format(update: Update, context: CallbackContext) -> int: + """Выбор формата нового шаблона.""" + query = cast(CallbackQuery, update.callback_query) + if not query or not query.data: + return ConversationHandler.END + + user_id = query.from_user.id + format_choice = query.data.split(":")[1] + + session[user_id]["format"] = format_choice + update_session_activity(user_id) + + await query.edit_message_text("Введите содержимое шаблона") + return TPL_NEW_CONTENT + +async def tpl_new_content(update: Update, context: CallbackContext) -> int: + """Ввод содержимого нового шаблона.""" + message = cast(Message, update.message) + if not message or not message.text or not message.from_user: + return ConversationHandler.END + + user_id = message.from_user.id + content = message.text + + if not validate_message_length(content): + await message.reply_text("Слишком длинный шаблон") + return TPL_NEW_CONTENT + + if user_id not in session: + session[user_id] = {} + session[user_id]["content"] = content + update_session_activity(user_id) + + await message.reply_text("Введите клавиатуру или пропустите") + return TPL_NEW_KB + +async def list_user_templates(update: Update, context: CallbackContext) -> None: + """Вывод списка доступных шаблонов.""" + message = cast(Message, update.message) + if not message or not message.from_user: + return None + + try: + user_id = message.from_user.id + if context and not isinstance(context.user_data, dict): + context.user_data = {} + context.user_data["tpl_page"] = 0 + + templates = await list_templates(owner_id=user_id) + if not templates: + await message.reply_text("Нет доступных шаблонов") + return None + + text = "Доступные шаблоны:\n\n" + for tpl in templates: + # Используем явное приведение типа для доступа к атрибутам + tpl_name = getattr(tpl, 'name', 'Без имени') + tpl_type = getattr(tpl, 'type', 'Неизвестный тип') + text += f"• {tpl_name} ({tpl_type})\n" + + await message.reply_text(text) + except Exception as e: + logger.error(f"Ошибка при загрузке шаблонов: {e}") + await message.reply_text("Ошибка при загрузке списка шаблонов") + +@shared_task +def send_post_async(data: dict) -> None: + """Отправка поста из данных сессии в фоновом режиме. + + Args: + data: Данные поста + """ + post_type = data.get("type") + if not post_type or not isinstance(post_type, str): + raise ValueError("Неверный тип поста") + + payload = build_payload( + ptype=post_type, + text=data.get("text"), + media_file_id=data.get("media_id"), + parse_mode=data.get("format"), + keyboard=data.get("keyboard"), + ) + celery_app.send_task('app.tasks.senders.send_post_task', args=[payload]) + +@shared_task(bind=True) +def send_post(self, data: dict) -> None: + """Отправка поста из данных сессии. + + Args: + data: Данные сессии + """ + try: + post_type = data.get("type") + if not post_type or not isinstance(post_type, str): + raise ValueError("Неверный тип поста") + + payload = build_payload( + ptype=post_type, + text=data.get("text"), + media_file_id=data.get("media_id"), + parse_mode=data.get("format"), + keyboard=data.get("keyboard"), + ) + celery_app.send_task('app.tasks.senders.send_post_task', args=[payload]) + except Exception as e: + logger.error(f"Ошибка при отправке поста: {e}") + raise + +async def create_template_with_data(data: dict, user_id: int) -> None: + """Создание нового шаблона из данных сессии. + + Args: + data: Данные сессии + user_id: ID пользователя + """ + name = data.get("name") + content = data.get("content") + keyboard = data.get("keyboard") + tpl_type = data.get("type") + parse_mode = data.get("format") + + if not name or not content or not tpl_type: + raise ValueError("Не хватает обязательных данных для шаблона") + + await create_template( + owner_id=user_id, + name=name, + title=name, + content=content, + keyboard_tpl=keyboard, + type_=tpl_type, + parse_mode=parse_mode + ) + +async def handle_template_kb(update: Update, context: CallbackContext) -> int: + """Обработка клавиатуры шаблона.""" + message = cast(Message, update.message) + user = update.effective_user + + if not message or not message.text or not user: + return ConversationHandler.END + + user_id = user.id + kb_text = message.text + + data = session.get(user_id) + if not data: + await message.reply_text("Ошибка: сессия потеряна") + return ConversationHandler.END + + if kb_text != "skip": + try: + keyboard = parse_key_value_lines(kb_text) if kb_text else {} + if not isinstance(data, dict): + data = {} + data["keyboard"] = keyboard + session[user_id] = data + except ValueError as e: + await message.reply_text(f"Ошибка разбора клавиатуры: {e}") + return TPL_NEW_KB + + try: + if not data.get("name") or not data.get("content") or not data.get("type"): + await message.reply_text("Отсутствуют обязательные данные шаблона") + return TPL_NEW_KB + + await create_template( + owner_id=user_id, + name=data["name"], + title=data["name"], + content=data["content"], + keyboard_tpl=data.get("keyboard"), + type_=data["type"], + parse_mode=data.get("format") + ) + await message.reply_text("Шаблон создан успешно") + except ValueError as e: + await message.reply_text(f"Ошибка создания шаблона: {e}") + return TPL_NEW_KB + except Exception as e: + logger.error(f"Неожиданная ошибка при создании шаблона: {e}") + await message.reply_text("Произошла ошибка при создании шаблона") + return TPL_NEW_KB + + if user_id in session: + del session[user_id] + return ConversationHandler.END + +async def handle_template_pagination(update: Update, context: CallbackContext) -> None: + """Обработка пагинации в списке шаблонов.""" + query = cast(CallbackQuery, update.callback_query) + if not query or not isinstance(query.data, str): + return None + + try: + parts = query.data.split(":", 1) + if len(parts) != 2: + await query.answer("Некорректный формат страницы") + return None + + try: + page = max(0, int(parts[1])) + except ValueError: + await query.answer("Некорректный номер страницы") + return None + + user = update.effective_user + if not user or not user.id: + await query.answer("Ошибка: пользователь не определен") + return None + + # Получаем шаблоны пользователя + templates = await list_templates(owner_id=user.id) + if not templates: + msg = cast(Optional[Message], query.message) + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("У вас нет шаблонов") + else: + await query.answer("У вас нет шаблонов") + return None + + # Вычисляем диапазон для текущей страницы + start = page * PAGE_SIZE + end = start + PAGE_SIZE + page_templates = templates[start:end] + + if not page_templates: + text = "Нет шаблонов на этой странице" + else: + text = "Доступные шаблоны:\n\n" + for tpl in page_templates: + tpl_name = getattr(tpl, 'name', 'Без имени') + tpl_type = getattr(tpl, 'type', 'Неизвестный тип') + text += f"• {tpl_name} ({tpl_type})\n" + + # Создаем кнопки навигации + keyboard: List[List[InlineKeyboardButton]] = [[]] + if page > 0: + keyboard[0].append(InlineKeyboardButton("⬅️", callback_data=f"page:{page-1}")) + if len(templates) > end: + keyboard[0].append(InlineKeyboardButton("➡️", callback_data=f"page:{page+1}")) + + markup = InlineKeyboardMarkup(keyboard) + msg = cast(Optional[Message], query.message) + + if not msg or not hasattr(msg, 'edit_text'): + await query.answer("Не удалось обновить сообщение") + return None + + try: + await msg.edit_text(text=text, reply_markup=markup) + await query.answer() + except TelegramError as e: + logger.error(f"Ошибка при обновлении сообщения: {e}", exc_info=True) + await query.answer("Не удалось обновить список") + + try: + await query.edit_message_text(text=text, reply_markup=markup) + except TelegramError as e: + logger.error(f"Ошибка при обновлении сообщения: {e}") + await query.answer("Не удалось обновить список шаблонов") + + except Exception as e: + logger.error(f"Ошибка при обработке пагинации: {e}") + await query.answer("Произошла ошибка") + +async def tpl_new_kb(update: Update, context: CallbackContext) -> int: + """Ввод клавиатуры для нового шаблона.""" + if not update.message or not update.message.text: + return ConversationHandler.END + + user_id = update.message.from_user.id + kb_text = update.message.text + + data = session.get(user_id) + if not data: + await update.message.reply_text("Ошибка: сессия потеряна") + return ConversationHandler.END + + if kb_text != "skip": + try: + keyboard = parse_key_value_lines(kb_text) + data["keyboard"] = keyboard + except ValueError as e: + await update.message.reply_text(f"Ошибка разбора клавиатуры: {e}") + return TPL_NEW_KB + + try: + await create_template( + name=data["name"], + content=data["content"], + keyboard=data.get("keyboard"), + type=data["type"], + format=data["format"] + ) + await update.message.reply_text("Шаблон создан успешно") + except Exception as e: + await update.message.reply_text(f"Ошибка создания шаблона: {e}") + + del session[user_id] + return ConversationHandler.END + +async def tpl_list(update: Update, context: CallbackContext) -> None: + """Вывод списка доступных шаблонов.""" + if not update.message: + return + + try: + templates = await list_templates() + if not templates: + await update.message.reply_text("Нет доступных шаблонов") + return + + text = "Доступные шаблоны:\n\n" + for tpl in templates: + text += f"• {tpl.name} ({tpl.type})\n" + + await update.message.reply_text(text) + except Exception as e: + await update.message.reply_text(f"Ошибка загрузки шаблонов: {e}") return out @@ -155,6 +792,9 @@ def parse_key_value_lines(text: str) -> dict: async def start(update: Update, context: CallbackContext) -> None: """Обработчик команды /start.""" + if not update.effective_user or not update.message: + return + update_session_activity(update.effective_user.id) await update.message.reply_text( "Привет! Я редактор. Команды:\n" @@ -165,6 +805,9 @@ async def start(update: Update, context: CallbackContext) -> None: async def newpost(update: Update, context: CallbackContext) -> int: """Начало создания нового поста.""" + if not update.effective_user or not update.message: + return ConversationHandler.END + uid = update.effective_user.id update_session_activity(uid) @@ -198,16 +841,43 @@ async def newpost(update: Update, context: CallbackContext) -> int: async def choose_channel(update: Update, context: CallbackContext) -> int: """Обработка выбора канала.""" - q = update.callback_query - await q.answer() - + if not update.callback_query or not update.effective_user: + return ConversationHandler.END + + await update.callback_query.answer() uid = update.effective_user.id update_session_activity(uid) + # Получаем необходимые объекты + query = cast(Optional[CallbackQuery], update.callback_query) + user = update.effective_user + + if not query or not isinstance(query.data, str) or not user or not user.id: + return ConversationHandler.END + + msg = cast(Optional[Message], query.message) + try: - ch_id = int(q.data.split(":")[1]) - session[uid]["channel_id"] = ch_id - + # Парсим данные из callback + parts = query.data.split(":", 1) + if len(parts) != 2: + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка: неверный формат данных") + return ConversationHandler.END + + # Проверяем корректность ID канала + try: + ch_id = int(parts[1]) + except ValueError: + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка: некорректный ID канала") + return ConversationHandler.END + + # Сохраняем в сессию + session[user.id] = session.get(user.id, {}) + session[user.id]["channel_id"] = ch_id + + # Создаем клавиатуру выбора типа поста kb = [ [ InlineKeyboardButton("Текст", callback_data="type:text"), @@ -218,24 +888,29 @@ async def choose_channel(update: Update, context: CallbackContext) -> int: InlineKeyboardButton("GIF", callback_data="type:animation") ], ] - await q.edit_message_text( - "Тип поста:", - reply_markup=InlineKeyboardMarkup(kb) - ) + + # Отображаем сообщение с выбором типа + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text( + "Выберите тип поста:", + reply_markup=InlineKeyboardMarkup(kb) + ) + return CHOOSE_TYPE - except ValueError: - await q.edit_message_text("Ошибка: неверный формат ID канала") - return ConversationHandler.END except Exception as e: - logger.error(f"Error in choose_channel: {e}") - await q.edit_message_text("Произошла ошибка. Попробуйте заново.") + logger.error(f"Ошибка при выборе канала: {e}", exc_info=True) + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Произошла ошибка. Попробуйте заново.") return ConversationHandler.END # ... [Остальные функции обновляются аналогично] ... async def enter_schedule(update: Update, context: CallbackContext) -> int: """Обработка ввода времени для отложенной публикации.""" + if not update.effective_user or not update.message or not update.message.text: + return ConversationHandler.END + uid = update.effective_user.id update_session_activity(uid) @@ -243,7 +918,9 @@ async def enter_schedule(update: Update, context: CallbackContext) -> int: when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M") if when < datetime.now(): - await update.message.reply_text("Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM") + await update.message.reply_text( + "Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM" + ) return ENTER_SCHEDULE await _dispatch_with_eta(uid, when) @@ -260,6 +937,24 @@ async def enter_schedule(update: Update, context: CallbackContext) -> int: await update.message.reply_text("Ошибка планирования. Попробуйте позже.") return ConversationHandler.END +def build_payload( + ptype: str, + text: Optional[str] = None, + media_file_id: Optional[str] = None, + parse_mode: Optional[str] = None, + keyboard: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Построение данных для отправки поста.""" + payload: Dict[str, Any] = { + "type": str(ptype), + "text": text if text is not None else "", + "parse_mode": str(parse_mode) if parse_mode is not None else "html", + "keyboard": keyboard if keyboard is not None else {}, + } + if media_file_id: + payload["media_file_id"] = media_file_id + return payload + async def _dispatch_with_eta(uid: int, when: datetime) -> None: """Отправка отложенного поста.""" data = session.get(uid) @@ -287,7 +982,8 @@ async def _dispatch_with_eta(uid: int, when: datetime) -> None: if "url" in btn and not validate_url(btn["url"]): raise ValueError(f"Небезопасный URL: {btn['url']}") - send_post_task.apply_async( + celery_app.send_task( + 'app.tasks.senders.send_post_task', args=[token, data["channel_id"], payload], eta=when ) @@ -370,10 +1066,6 @@ def main(): TPL_NEW_KB: [ MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb) ], - TPL_CONFIRM_DELETE: [ - CallbackQueryHandler(tpl_delete_ok, pattern=r"^tpldelok:"), - CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), - ], }, fallbacks=[CommandHandler("start", start)], ) @@ -392,8 +1084,8 @@ def main(): # -------- Вспомогательные функции для шаблонов --------- -async def _render_tpl_list(q_or_msg: Update | CallbackContext, uid: int, page: int) -> int: - """Отображение списка шаблонов с пагинацией.""" +async def _render_tpl_list_message(message: Message, uid: int, page: int) -> int: + """Отображение списка шаблонов с пагинацией через сообщение.""" try: total = await count_templates(uid) offset = page * PAGE_SIZE @@ -401,13 +1093,93 @@ async def _render_tpl_list(q_or_msg: Update | CallbackContext, uid: int, page: i if not tpls: if page == 0: - if hasattr(q_or_msg, "edit_message_text"): - await q_or_msg.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.") - else: - await q_or_msg.reply_text("Шаблонов пока нет. Создай через /tpl_new.") + await message.reply_text("Шаблонов пока нет. Создай через /tpl_new.") return ENTER_TEXT else: - return await _render_tpl_list(q_or_msg, uid, 0) + return await _render_tpl_list_message(message, uid, 0) + + kb = [] + for t in tpls: + kb.append([ + InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"), + InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}") + ]) + + nav = [] + if page > 0: + nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}")) + if offset + PAGE_SIZE < total: + nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}")) + if nav: + kb.append(nav) + + text = "📜 Список шаблонов:\n\n" + if total > PAGE_SIZE: + text += f"(Страница {page + 1})" + + await message.reply_text(text, reply_markup=InlineKeyboardMarkup(kb)) + return ENTER_TEXT + + except Exception as e: + logger.error(f"Error rendering template list for message: {e}") + await message.reply_text("Ошибка при загрузке списка шаблонов") + return ENTER_TEXT + +async def _render_tpl_list_query(query: CallbackQuery, uid: int, page: int) -> int: + """Отображение списка шаблонов с пагинацией через callback query.""" + try: + total = await count_templates(uid) + offset = page * PAGE_SIZE + tpls = await list_templates(uid, limit=PAGE_SIZE, offset=offset) + + if not tpls: + if page == 0: + await query.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.") + return ENTER_TEXT + else: + return await _render_tpl_list_query(query, uid, 0) + + kb = [] + for t in tpls: + kb.append([ + InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"), + InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}") + ]) + + nav = [] + if page > 0: + nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}")) + if offset + PAGE_SIZE < total: + nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}")) + if nav: + kb.append(nav) + + text = "📜 Список шаблонов:\n\n" + if total > PAGE_SIZE: + text += f"(Страница {page + 1})" + + await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb)) + return ENTER_TEXT + + except Exception as e: + logger.error(f"Error rendering template list for query: {e}") + await query.edit_message_text("Ошибка при загрузке списка шаблонов") + return ENTER_TEXT + +async def _render_tpl_list(update: Update, uid: int, page: int) -> int: + """Отображение списка шаблонов с пагинацией.""" + try: + if update.callback_query: + return await _render_tpl_list_query(update.callback_query, uid, page) + elif update.message: + return await _render_tpl_list_message(update.message, uid, page) + else: + logger.error("Neither callback_query nor message found in update") + return ENTER_TEXT + + except Exception as e: + logger.error(f"Error in _render_tpl_list: {e}") + return ENTER_TEXT kb = [] for t in tpls: @@ -440,8 +1212,8 @@ async def _render_tpl_list(q_or_msg: Update | CallbackContext, uid: int, page: i await q_or_msg.reply_text("Ошибка при загрузке списка шаблонов") return ConversationHandler.END -async def _apply_template_and_confirm(q_or_msg: Union[CallbackQuery, Message], uid: int, name: str, ctx_vars: dict) -> int: - """Применение шаблона к текущему посту.""" +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({ @@ -457,40 +1229,68 @@ async def _apply_template_and_confirm(q_or_msg: Union[CallbackQuery, Message], u ] markup = InlineKeyboardMarkup(kb) - if isinstance(q_or_msg, CallbackQuery): - await q_or_msg.edit_message_text( - "Шаблон применён. Как публикуем?", - reply_markup=markup - ) - else: - await cast(Message, q_or_msg).reply_text( - "Шаблон применён. Как публикуем?", - reply_markup=markup - ) - + await message.reply_text( + "Шаблон применён. Как публикуем?", + reply_markup=markup + ) return CONFIRM_SEND except Exception as e: - logger.error(f"Error applying template: {e}") - if isinstance(q_or_msg, CallbackQuery): - await q_or_msg.edit_message_text("Ошибка при применении шаблона") - else: - await cast(Message, q_or_msg).reply_text("Ошибка при применении шаблона") + logger.error(f"Error applying template via message: {e}") + await message.reply_text("Ошибка при применении шаблона") return ConversationHandler.END -async def _render_preview_and_confirm(q_or_msg: Update | CallbackContext, uid: int, name: str, ctx_vars: dict) -> int: - """Рендеринг предпросмотра шаблона.""" +async def _apply_template_and_confirm_query(query: CallbackQuery, uid: int, name: str, ctx_vars: dict) -> int: + """Применение шаблона к текущему посту через callback query.""" + try: + rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) + session[uid].update({ + "type": rendered["type"], + "text": rendered["text"], + "keyboard": {"rows": rendered["keyboard_rows"]} if rendered.get("keyboard_rows") else None, + "parse_mode": rendered.get("parse_mode") or session[uid].get("parse_mode") or "HTML" + }) + + kb = [ + [InlineKeyboardButton("Отправить сейчас", callback_data="send:now")], + [InlineKeyboardButton("Запланировать", callback_data="send:schedule")] + ] + markup = InlineKeyboardMarkup(kb) + + await query.edit_message_text( + "Шаблон применён. Как публикуем?", + reply_markup=markup + ) + return CONFIRM_SEND + + except Exception as e: + logger.error(f"Error applying template via query: {e}") + await query.edit_message_text("Ошибка при применении шаблона") + return ConversationHandler.END + +async def _apply_template_and_confirm(update: Update, uid: int, name: str, ctx_vars: dict) -> int: + """Применение шаблона к текущему посту.""" + try: + if update.callback_query: + return await _apply_template_and_confirm_query(update.callback_query, uid, name, ctx_vars) + elif update.message: + return await _apply_template_and_confirm_message(update.message, uid, name, ctx_vars) + else: + logger.error("Neither callback_query nor message found in update") + return ConversationHandler.END + except Exception as e: + logger.error(f"Error in _apply_template_and_confirm: {e}") + return ConversationHandler.END + +async def _render_preview_and_confirm_message(message: Message, uid: int, name: str, ctx_vars: dict) -> int: + """Рендеринг предпросмотра шаблона через сообщение.""" try: rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) text = rendered["text"] parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML" preview_text = f"Предпросмотр:\n\n{text[:3500]}" - - if hasattr(q_or_msg, "edit_message_text"): - await q_or_msg.edit_message_text(preview_text, parse_mode=parse_mode) - else: - await q_or_msg.reply_text(preview_text, parse_mode=parse_mode) + await message.reply_text(preview_text, parse_mode=parse_mode) kb = [ [InlineKeyboardButton("✅ Использовать", callback_data="pv:use")], @@ -498,91 +1298,226 @@ async def _render_preview_and_confirm(q_or_msg: Update | CallbackContext, uid: i [InlineKeyboardButton("Отмена", callback_data="tpl:cancel")] ] markup = InlineKeyboardMarkup(kb) - - if hasattr(q_or_msg, "reply_text"): - await q_or_msg.reply_text("Что дальше?", reply_markup=markup) - elif hasattr(q_or_msg, "message") and q_or_msg.message: - await q_or_msg.message.reply_text("Что дальше?", reply_markup=markup) + await message.reply_text("Что дальше?", reply_markup=markup) return PREVIEW_CONFIRM except Exception as e: - logger.error(f"Error in preview render: {e}") - if hasattr(q_or_msg, "edit_message_text"): - await q_or_msg.edit_message_text("Ошибка при рендеринге шаблона") + logger.error(f"Error in preview render via message: {e}") + await message.reply_text("Ошибка при рендеринге шаблона") + return ConversationHandler.END + +async def _render_preview_and_confirm_query(query: CallbackQuery, uid: int, name: str, ctx_vars: dict) -> int: + """Рендеринг предпросмотра шаблона через callback query.""" + try: + rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars) + text = rendered["text"] + parse_mode = rendered.get("parse_mode") or session.get(uid, {}).get("parse_mode") or "HTML" + + preview_text = f"Предпросмотр:\n\n{text[:3500]}" + await query.edit_message_text(preview_text, parse_mode=parse_mode) + + kb = [ + [InlineKeyboardButton("✅ Использовать", callback_data="pv:use")], + [InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")], + [InlineKeyboardButton("Отмена", callback_data="tpl:cancel")] + ] + markup = InlineKeyboardMarkup(kb) + if query.message: + await query.message.reply_text("Что дальше?", reply_markup=markup) + + return PREVIEW_CONFIRM + + except Exception as e: + logger.error(f"Error in preview render via query: {e}") + await query.edit_message_text("Ошибка при рендеринге шаблона") + return ConversationHandler.END + +async def _render_preview_and_confirm(update: Update, uid: int, name: str, ctx_vars: dict) -> int: + """Рендеринг предпросмотра шаблона.""" + try: + if update.callback_query: + return await _render_preview_and_confirm_query(update.callback_query, uid, name, ctx_vars) + elif update.message: + return await _render_preview_and_confirm_message(update.message, uid, name, ctx_vars) else: - await q_or_msg.reply_text("Ошибка при рендеринге шаблона") + logger.error("Neither callback_query nor message found in update") + return ConversationHandler.END + except Exception as e: + logger.error(f"Error in _render_preview_and_confirm: {e}") return ConversationHandler.END # -------- Обработчики шаблонов --------- async def choose_template_open(update: Update, context: CallbackContext) -> int: """Открытие списка шаблонов.""" - q = update.callback_query - await q.answer() - uid = update.effective_user.id - context.user_data["tpl_page"] = 0 - return await _render_tpl_list(q, uid, page=0) + query = cast(Optional[CallbackQuery], update.callback_query) + if not query: + return ConversationHandler.END + + await query.answer() + user = update.effective_user + if not user or not user.id: + msg = cast(Optional[Message], query.message) + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка: пользователь не определен") + return ConversationHandler.END + + if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): + context.user_data["tpl_page"] = 0 + + return await _render_tpl_list(update, user.id, page=0) async def choose_template_navigate(update: Update, context: CallbackContext) -> int: """Навигация по списку шаблонов.""" - q = update.callback_query - await q.answer() - uid = update.effective_user.id - _, page_s = q.data.split(":") + query = cast(Optional[CallbackQuery], update.callback_query) + user = update.effective_user + + if not query or not query.data or not user or not user.id: + return ConversationHandler.END + + try: + await query.answer() + msg = cast(Optional[Message], query.message) + + # Получаем номер страницы из callback data + parts = query.data.split(":", 1) + if len(parts) != 2: + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка: неверный формат данных") + return ConversationHandler.END + + try: + page = max(0, int(parts[1])) + except ValueError: + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка: некорректный номер страницы") + return ConversationHandler.END + + if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): + context.user_data["tpl_page"] = page + + return await _render_tpl_list(update, user.id, page) + + except Exception as e: + logger.error(f"Ошибка при навигации по шаблонам: {e}", exc_info=True) + msg = cast(Optional[Message], query.message) + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Произошла ошибка при навигации") + return ConversationHandler.END page = int(page_s) context.user_data["tpl_page"] = page return await _render_tpl_list(q, uid, page) async def choose_template_apply(update: Update, context: CallbackContext) -> int: """Применение выбранного шаблона.""" - q = update.callback_query - await q.answer() - uid = update.effective_user.id + query = cast(Optional[CallbackQuery], update.callback_query) + user = update.effective_user + + if not query or not isinstance(query.data, str) or not user or not user.id: + return ConversationHandler.END + try: - name = q.data.split(":")[1] - tpl = await render_template_by_name(owner_id=uid, name=name, ctx={}) + await query.answer() + msg = cast(Optional[Message], query.message) + + parts = query.data.split(":", 1) + if len(parts) != 2: + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка: неверный формат данных") + return ConversationHandler.END + + name = parts[1] + + # Получаем шаблон и проверяем переменные + tpl = await render_template_by_name(owner_id=user.id, name=name, ctx={}) + if not tpl: + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка: шаблон не найден") + return ConversationHandler.END + required = set(tpl.get("_required", [])) + + # Сохраняем данные в контекст + if isinstance(context, CallbackContext) and isinstance(context.user_data, dict): + context.user_data["preview"] = { + "name": name, + "provided": {}, + "missing": list(required) + } + if required: - context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)} - await q.edit_message_text( + if msg and hasattr(msg, 'edit_text'): + # Получаем первую необходимую переменную + next_var = list(required)[0] + await msg.edit_text( + "Для этого шаблона требуются дополнительные параметры.\n" + f"Пожалуйста, введите значение для параметра {next_var}:" + ) + return PREVIEW_VARS + + return await _apply_template_and_confirm(update, user.id, name, {}) + + except Exception as e: + logger.error(f"Ошибка при применении шаблона: {e}", exc_info=True) + msg = cast(Optional[Message], query.message if query else None) + if msg and hasattr(msg, 'edit_text'): + await msg.edit_text("Ошибка при применении шаблона") + return ConversationHandler.END + await update.callback_query.edit_message_text( "Шаблон требует переменные: " + ", ".join(sorted(required)) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return PREVIEW_VARS - return await _apply_template_and_confirm(q, uid, name, {}) + + return await _apply_template_and_confirm(update, uid, name, {}) except Exception as e: logger.error(f"Error applying template: {e}") - await q.edit_message_text("Ошибка при применении шаблона") + await update.callback_query.edit_message_text("Ошибка при применении шаблона") return ConversationHandler.END async def choose_template_preview(update: Update, context: CallbackContext) -> int: """Предпросмотр шаблона.""" - q = update.callback_query - await q.answer() + if (not update.callback_query or not update.effective_user or not context.user_data + or not update.callback_query.data): + return ConversationHandler.END + + await update.callback_query.answer() uid = update.effective_user.id + try: - name = q.data.split(":")[1] + name = update.callback_query.data.split(":")[1] tpl = await render_template_by_name(owner_id=uid, name=name, ctx={}) required = set(tpl.get("_required", [])) - context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)} + context.user_data["preview"] = { + "name": name, + "provided": {}, + "missing": list(required) + } + if required: - await q.edit_message_text( + await update.callback_query.edit_message_text( "Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return PREVIEW_VARS - return await _render_preview_and_confirm(q, uid, name, {}) + + return await _render_preview_and_confirm(update, uid, name, {}) + except Exception as e: logger.error(f"Error previewing template: {e}") - await q.edit_message_text("Ошибка при предпросмотре шаблона") + await update.callback_query.edit_message_text("Ошибка при предпросмотре шаблона") return ConversationHandler.END async def choose_template_cancel(update: Update, context: CallbackContext) -> int: """Отмена выбора шаблона.""" - q = update.callback_query - await q.answer() - await q.edit_message_text("Отправь текст сообщения или введи #имя для шаблона.") + if not update.callback_query: + return ConversationHandler.END + + await update.callback_query.answer() + await update.callback_query.edit_message_text( + "Отправь текст сообщения или введи #имя для шаблона." + ) return ENTER_TEXT if __name__ == "__main__": diff --git a/app/bots/editor_bot.py.new b/app/bots/editor_bot.py.new deleted file mode 100644 index b5e45d3..0000000 --- a/app/bots/editor_bot.py.new +++ /dev/null @@ -1,395 +0,0 @@ -from __future__ import annotations -import shlex -import logging -from datetime import datetime -from typing import Optional, Dict, List, Any -import time -from urllib.parse import urlparse - -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import ( - Application, CommandHandler, MessageHandler, ConversationHandler, - CallbackQueryHandler, CallbackContext, filters, -) -from telegram.error import TelegramError -from apscheduler.schedulers.asyncio import AsyncIOScheduler - -from sqlalchemy import select - -from app.core.config import settings -from app.tasks.senders import send_post_task -from app.db.session import async_session_maker -from app.models.channel import Channel -from app.models.post import PostType -from app.services.templates import ( - render_template_by_name, list_templates, count_templates, - create_template, delete_template, required_variables_of_template, -) -from jinja2 import TemplateError - -# Настройка логирования -logger = logging.getLogger(__name__) - -# Константы -MAX_MESSAGE_LENGTH = 4096 -PAGE_SIZE = 8 -SESSION_TIMEOUT = 3600 # 1 час -ALLOWED_URL_SCHEMES = ('http', 'https', 't.me') - -# Состояния диалога -( - CHOOSE_CHANNEL, CHOOSE_TYPE, CHOOSE_FORMAT, ENTER_TEXT, ENTER_MEDIA, EDIT_KEYBOARD, - CONFIRM_SEND, ENTER_SCHEDULE, SELECT_TEMPLATE, - PREVIEW_VARS, PREVIEW_CONFIRM, - TPL_NEW_NAME, TPL_NEW_TYPE, TPL_NEW_FORMAT, TPL_NEW_CONTENT, TPL_NEW_KB, TPL_CONFIRM_DELETE, -) = range(16) - -# In-memory сессии с метаданными -session: Dict[int, Dict[str, Any]] = {} - -def validate_url(url: str) -> bool: - """Проверка безопасности URL. - - Args: - url: Строка URL для проверки - - Returns: - bool: True если URL безопасен, False в противном случае - """ - try: - result = urlparse(url) - return all([ - result.scheme in ALLOWED_URL_SCHEMES, - result.netloc, - len(url) < 2048 # Максимальная длина URL - ]) - except Exception as e: - logger.warning(f"URL validation failed: {e}") - return False - -def validate_message_length(text: str) -> bool: - """Проверка длины сообщения согласно лимитам Telegram. - - Args: - text: Текст для проверки - - Returns: - bool: True если длина в пределах лимита - """ - return len(text) <= MAX_MESSAGE_LENGTH - -def update_session_activity(uid: int) -> None: - """Обновление времени последней активности в сессии.""" - if uid in session: - session[uid]['last_activity'] = time.time() - -def cleanup_old_sessions() -> None: - """Периодическая очистка старых сессий.""" - current_time = time.time() - for uid in list(session.keys()): - if current_time - session[uid].get('last_activity', 0) > SESSION_TIMEOUT: - logger.info(f"Cleaning up session for user {uid}") - del session[uid] - -def parse_template_invocation(s: str) -> tuple[str, dict]: - """Разбор строки вызова шаблона. - - Args: - s: Строка в формате #template_name key1=value1 key2=value2 - - Returns: - tuple: (имя_шаблона, словарь_параметров) - - Raises: - ValueError: Если неверный формат строки - """ - s = s.strip() - if not s.startswith("#"): - raise ValueError("Имя шаблона должно начинаться с #") - parts = shlex.split(s) - if not parts: - raise ValueError("Пустой шаблон") - name = parts[0][1:] - args: dict[str, str] = {} - for tok in parts[1:]: - if "=" in tok: - k, v = tok.split("=", 1) - args[k.strip()] = v.strip() - return name, args - -def parse_key_value_lines(text: str) -> dict: - """Парсинг строк формата key=value. - - Args: - text: Строки в формате key=value или key="quoted value" - - Returns: - dict: Словарь параметров - """ - text = (text or "").strip() - if not text: - return {} - - out = {} - if "\n" in text: - for line in text.splitlines(): - if "=" in line: - k, v = line.split("=", 1) - v = v.strip().strip('"') - if k.strip(): # Проверка на пустой ключ - out[k.strip()] = v - else: - try: - for tok in shlex.split(text): - if "=" in tok: - k, v = tok.split("=", 1) - if k.strip(): # Проверка на пустой ключ - out[k.strip()] = v - except ValueError as e: - logger.warning(f"Error parsing key-value line: {e}") - - return out - -# -------- Команды верхнего уровня --------- - -async def start(update: Update, context: CallbackContext) -> None: - """Обработчик команды /start.""" - update_session_activity(update.effective_user.id) - await update.message.reply_text( - "Привет! Я редактор. Команды:\n" - "/newpost — мастер поста\n" - "/tpl_new — создать шаблон\n" - "/tpl_list — список шаблонов" - ) - -async def newpost(update: Update, context: CallbackContext) -> int: - """Начало создания нового поста.""" - uid = update.effective_user.id - update_session_activity(uid) - - session[uid] = {'last_activity': time.time()} - - try: - async with async_session_maker() as s: - res = await s.execute(select(Channel).where(Channel.owner_id == uid).limit(50)) - channels = list(res.scalars()) - - if not channels: - await update.message.reply_text( - "Пока нет каналов. Добавь через админку или команду /add_channel (в разработке)." - ) - return ConversationHandler.END - - kb = [ - [InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")] - for ch in channels - ] - await update.message.reply_text( - "Выбери канал для публикации:", - reply_markup=InlineKeyboardMarkup(kb) - ) - return CHOOSE_CHANNEL - - except Exception as e: - logger.error(f"Error in newpost: {e}") - await update.message.reply_text("Произошла ошибка. Попробуйте позже.") - return ConversationHandler.END - -async def choose_channel(update: Update, context: CallbackContext) -> int: - """Обработка выбора канала.""" - q = update.callback_query - await q.answer() - - uid = update.effective_user.id - update_session_activity(uid) - - try: - ch_id = int(q.data.split(":")[1]) - session[uid]["channel_id"] = ch_id - - kb = [ - [ - InlineKeyboardButton("Текст", callback_data="type:text"), - InlineKeyboardButton("Фото", callback_data="type:photo") - ], - [ - InlineKeyboardButton("Видео", callback_data="type:video"), - InlineKeyboardButton("GIF", callback_data="type:animation") - ], - ] - await q.edit_message_text( - "Тип поста:", - reply_markup=InlineKeyboardMarkup(kb) - ) - return CHOOSE_TYPE - - except ValueError: - await q.edit_message_text("Ошибка: неверный формат ID канала") - return ConversationHandler.END - except Exception as e: - logger.error(f"Error in choose_channel: {e}") - await q.edit_message_text("Произошла ошибка. Попробуйте заново.") - return ConversationHandler.END - -# ... [Остальные функции обновляются аналогично] ... - -async def enter_schedule(update: Update, context: CallbackContext) -> int: - """Обработка ввода времени для отложенной публикации.""" - uid = update.effective_user.id - update_session_activity(uid) - - try: - when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M") - - if when < datetime.now(): - await update.message.reply_text( - "Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM" - ) - return ENTER_SCHEDULE - - await _dispatch_with_eta(uid, when) - await update.message.reply_text("Задача запланирована.") - return ConversationHandler.END - - except ValueError: - await update.message.reply_text( - "Неверный формат даты. Используйте формат YYYY-MM-DD HH:MM" - ) - return ENTER_SCHEDULE - except Exception as e: - logger.error(f"Error scheduling post: {e}") - await update.message.reply_text("Ошибка планирования. Попробуйте позже.") - return ConversationHandler.END - -async def _dispatch_with_eta(uid: int, when: datetime) -> None: - """Отправка отложенного поста.""" - data = session.get(uid) - if not data: - raise ValueError("Сессия потеряна") - - token = settings.editor_bot_token - try: - payload = build_payload( - ptype=data.get("type"), - text=data.get("text"), - media_file_id=data.get("media_file_id"), - parse_mode=data.get("parse_mode") or "HTML", - keyboard=data.get("keyboard"), - ) - - # Проверка длины сообщения - if not validate_message_length(payload.get("text", "")): - raise ValueError("Превышен максимальный размер сообщения") - - # Проверка URL в клавиатуре - if keyboard := payload.get("keyboard"): - for row in keyboard.get("rows", []): - for btn in row: - if "url" in btn and not validate_url(btn["url"]): - raise ValueError(f"Небезопасный URL: {btn['url']}") - - send_post_task.apply_async( - args=[token, data["channel_id"], payload], - eta=when - ) - - except Exception as e: - logger.error(f"Error in _dispatch_with_eta: {e}") - raise - -def main(): - """Инициализация и запуск бота.""" - try: - # Настройка логирования - logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO - ) - - # Инициализация планировщика - scheduler = AsyncIOScheduler() - scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30) - scheduler.start() - - app = Application.builder().token(settings.editor_bot_token).build() - - # Регистрация обработчиков - post_conv = ConversationHandler( - entry_points=[CommandHandler("newpost", newpost)], - states={ - CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")], - CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")], - CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")], - ENTER_TEXT: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text), - CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"), - ], - SELECT_TEMPLATE: [ - CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"), - CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"), - CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"), - CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), - ], - PREVIEW_VARS: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars) - ], - PREVIEW_CONFIRM: [ - CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"), - CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), - ], - ENTER_MEDIA: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media) - ], - EDIT_KEYBOARD: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard) - ], - CONFIRM_SEND: [ - CallbackQueryHandler(confirm_send, pattern=r"^send:") - ], - ENTER_SCHEDULE: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule) - ], - }, - fallbacks=[CommandHandler("start", start)], - ) - - tpl_conv = ConversationHandler( - entry_points=[CommandHandler("tpl_new", tpl_new_start)], - states={ - TPL_NEW_NAME: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name) - ], - TPL_NEW_TYPE: [ - CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:") - ], - TPL_NEW_FORMAT: [ - CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:") - ], - TPL_NEW_CONTENT: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content) - ], - TPL_NEW_KB: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb) - ], - TPL_CONFIRM_DELETE: [ - CallbackQueryHandler(tpl_delete_ok, pattern=r"^tpldelok:"), - CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), - ], - }, - fallbacks=[CommandHandler("start", start)], - ) - - app.add_handler(CommandHandler("start", start)) - app.add_handler(post_conv) - app.add_handler(tpl_conv) - app.add_handler(CommandHandler("tpl_list", tpl_list)) - - # Запуск бота - app.run_polling(allowed_updates=Update.ALL_TYPES) - - except Exception as e: - logger.critical(f"Critical error in main: {e}") - raise - -if __name__ == "__main__": - main() diff --git a/requirements.txt b/requirements.txt index 9b70123..52c535c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ httpx loguru wget redis -jinja2 \ No newline at end of file +jinja2 +APScheduler>=3.10.0 \ No newline at end of file