from __future__ import annotations from datetime import datetime from typing import Dict, List from telegram import Update from telegram.ext import CallbackContext 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.services.templates import ( render_template_by_name, list_templates, count_templates, create_template, delete_template, required_variables_of_template, ) from jinja2 import TemplateError from .states import States from .session import SessionStore from .messages import MessageParsers from .keyboards import KbBuilder # Заглушка для build_payload, если сервиса нет try: from app.services.telegram import build_payload # type: ignore except Exception: # pragma: no cover def build_payload(ptype: str, text: str | None, media_file_id: str | None, parse_mode: str | None, keyboard: dict | None) -> dict: return { "type": ptype, "text": text, "media_file_id": media_file_id, "parse_mode": parse_mode, "keyboard": keyboard, } PAGE_SIZE = 8 class EditorWizard: """Инкапсулирует весь сценарий мастера и управление шаблонами.""" def __init__(self, sessions: SessionStore) -> None: self.sessions = sessions # ---------- Команды верхнего уровня ---------- async def start(self, update: Update, context: CallbackContext): await update.message.reply_text( "Привет! Я редактор.\n" "Команды: /newpost — мастер поста, /tpl_new — создать шаблон, /tpl_list — список шаблонов." ) async def newpost(self, update: Update, context: CallbackContext): uid = update.effective_user.id s = self.sessions.get(uid) # инициализация async with async_session_maker() as db: res = await db.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 -1 await update.message.reply_text("Выбери канал для публикации:", reply_markup=KbBuilder.channels(channels)) return States.CHOOSE_CHANNEL # ---------- Выбор канала/типа/формата ---------- async def choose_channel(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id s = self.sessions.get(uid) ch_id = int(q.data.split(":", 1)[1]) s.channel_id = ch_id await q.edit_message_text("Тип поста:", reply_markup=KbBuilder.post_types()) return States.CHOOSE_TYPE async def choose_type(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id s = self.sessions.get(uid) s.type = q.data.split(":", 1)[1] await q.edit_message_text("Выбери формат разметки (по умолчанию HTML):", reply_markup=KbBuilder.parse_modes()) return States.CHOOSE_FORMAT async def choose_format(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id s = self.sessions.get(uid) s.parse_mode = q.data.split(":", 1)[1] if s.type == "text": await q.edit_message_text("Отправь текст сообщения или выбери шаблон:", reply_markup=KbBuilder.templates_list([], 0, 0, PAGE_SIZE)) # Доп. сообщение с кнопкой «Выбрать шаблон» await q.message.reply_text("Нажми «Выбрать шаблон»", reply_markup=None) return States.ENTER_TEXT else: await q.edit_message_text( "Пришли медиадескриптор (file_id) и, при желании, подпись.\nФормат: FILE_ID|Подпись" ) return States.ENTER_MEDIA # ---------- Ввод текста/медиа ---------- async def enter_text(self, update: Update, context: CallbackContext): uid = update.effective_user.id s = self.sessions.get(uid) text = update.message.text or "" if text.strip().startswith("#"): # Вызов шаблона: #name key=val ... try: name, ctx_vars = MessageParsers.parse_template_invocation(text) except ValueError: await update.message.reply_text("Не распознал шаблон. Пример: #promo title='Привет' url=https://x.y") return States.ENTER_TEXT tpl_meta = await render_template_by_name(owner_id=uid, name=name, ctx={}) required = set(tpl_meta.get("_required", [])) missing = sorted(list(required - set(ctx_vars.keys()))) context.user_data["preview"] = {"name": name, "provided": ctx_vars, "missing": missing} if missing: await update.message.reply_text( "Не хватает переменных: " + ", ".join(missing) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return States.PREVIEW_VARS # все есть — применяем return await self._apply_template_and_confirm(update, uid, name, ctx_vars) s.text = text await update.message.reply_text( "Добавить кнопки? Пришлите строки вида: Заголовок|URL или '-' чтобы пропустить." ) return States.EDIT_KEYBOARD async def enter_media(self, update: Update, context: CallbackContext): uid = update.effective_user.id s = self.sessions.get(uid) parts = (update.message.text or "").split("|", 1) s.media_file_id = parts[0].strip() s.text = parts[1].strip() if len(parts) > 1 else None await update.message.reply_text("Добавить кнопки? Пришлите строки: Текст|URL. Или '-' чтобы пропустить.") return States.EDIT_KEYBOARD # ---------- Работа со списком шаблонов (кнопка) ---------- async def choose_template_open(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id context.user_data["tpl_page"] = 0 return await self._render_tpl_list(q, uid, page=0) async def choose_template_navigate(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id page = int(q.data.split(":", 1)[1]) context.user_data["tpl_page"] = page return await self._render_tpl_list(q, uid, page) async def _render_tpl_list(self, q_or_msg, uid: int, page: int): total = await count_templates(uid) items = await list_templates(uid, limit=PAGE_SIZE, offset=page * PAGE_SIZE) if not items: text = "Шаблонов пока нет. Создай через /tpl_new." if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text(text) else: await q_or_msg.reply_text(text) return States.ENTER_TEXT markup = KbBuilder.templates_list(items, page, total, PAGE_SIZE) 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=markup) else: await q_or_msg.reply_text(text, reply_markup=markup) return States.SELECT_TEMPLATE async def choose_template_apply(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id name = q.data.split(":", 1)[1] tpl_meta = await render_template_by_name(owner_id=uid, name=name, ctx={}) required = set(tpl_meta.get("_required", [])) if required: context.user_data["preview"] = {"name": name, "provided": {}, "missing": sorted(list(required))} await q.edit_message_text( "Шаблон требует переменные: " + ", ".join(sorted(list(required))) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return States.PREVIEW_VARS return await self._apply_template_and_confirm(q, uid, name, {}) async def choose_template_preview(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id name = q.data.split(":", 1)[1] meta = await render_template_by_name(owner_id=uid, name=name, ctx={}) required = set(meta.get("_required", [])) context.user_data["preview"] = {"name": name, "provided": {}, "missing": sorted(list(required))} if required: await q.edit_message_text( "Для предпросмотра нужны переменные: " + ", ".join(sorted(list(required))) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) return States.PREVIEW_VARS return await self._render_preview_and_confirm(q, uid, name, {}) async def choose_template_cancel(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() await q.edit_message_text("Отправь текст сообщения или введи #имя для шаблона.") return States.ENTER_TEXT # ---------- Предпросмотр: сбор переменных / подтверждение ---------- async def preview_collect_vars(self, update: Update, context: CallbackContext): uid = update.effective_user.id data = context.user_data.get("preview", {}) provided: Dict[str, str] = dict(data.get("provided", {})) missing = set(data.get("missing", [])) provided.update(MessageParsers.parse_key_value_lines(update.message.text)) still_missing = sorted(list(missing - set(provided.keys()))) if still_missing: context.user_data["preview"] = {"name": data.get("name"), "provided": provided, "missing": still_missing} await update.message.reply_text( "Ещё не хватает: " + ", ".join(still_missing) + "\nПришли оставшиеся значения в формате key=value." ) return States.PREVIEW_VARS context.user_data["preview"] = {"name": data.get("name"), "provided": provided, "missing": []} return await self._render_preview_and_confirm(update.message, uid, data.get("name"), provided) async def preview_confirm(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id action = q.data.split(":", 1)[1] data = context.user_data.get("preview", {}) name = data.get("name") provided = data.get("provided", {}) if action == "use": return await self._apply_template_and_confirm(q, uid, name, provided) # edit variables meta = await render_template_by_name(owner_id=uid, name=name, ctx={}) required = set(meta.get("_required", [])) missing = sorted(list(required - set(provided.keys()))) if missing: await q.edit_message_text( "Ещё требуются: " + ", ".join(missing) + "\nПришли значения в формате key=value (по строкам или в одну строку)." ) else: await q.edit_message_text("Измени значения переменных и пришли заново (key=value ...).") context.user_data["preview"] = {"name": name, "provided": provided, "missing": missing} return States.PREVIEW_VARS # ---------- Редактор клавиатуры / подтверждение отправки ---------- async def edit_keyboard(self, update: Update, context: CallbackContext): uid = update.effective_user.id s = self.sessions.get(uid) raw = (update.message.text or "").strip() if raw == "-": s.keyboard = None else: buttons = [] for line in raw.splitlines(): if "|" in line: t, u = line.split("|", 1) buttons.append((t.strip(), u.strip())) keyboard_rows = [[{"text": t, "url": u}] for t, u in buttons] if buttons else None s.keyboard = {"rows": keyboard_rows} if keyboard_rows else None await update.message.reply_text("Как публикуем?", reply_markup=KbBuilder.send_confirm()) return States.CONFIRM_SEND async def confirm_send(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id action = q.data.split(":", 1)[1] if action == "now": await self._dispatch_now(uid, q) self.sessions.drop(uid) return -1 else: await q.edit_message_text("Укажи время в формате YYYY-MM-DD HH:MM (Asia/Seoul)") return States.ENTER_SCHEDULE async def enter_schedule(self, update: Update, context: CallbackContext): uid = update.effective_user.id when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M") await self._dispatch_with_eta(uid, when) await update.message.reply_text("Задача запланирована.") self.sessions.drop(uid) return -1 # ---------- Создание/удаление шаблонов ---------- async def tpl_new_start(self, update: Update, context: CallbackContext): uid = update.effective_user.id context.user_data["tpl"] = {"owner_id": uid} await update.message.reply_text("Создание шаблона. Введи короткое имя (латиница/цифры), которым будешь вызывать: #имя") return States.TPL_NEW_NAME async def tpl_new_name(self, update: Update, context: CallbackContext): name = (update.message.text or "").strip() context.user_data["tpl"]["name"] = name await update.message.reply_text("Выбери тип шаблона:", reply_markup=KbBuilder.tpl_types()) return States.TPL_NEW_TYPE async def tpl_new_type(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() t = q.data.split(":", 1)[1] context.user_data["tpl"]["type"] = t await q.edit_message_text("Выбери формат разметки для этого шаблона:", reply_markup=KbBuilder.tpl_formats()) return States.TPL_NEW_FORMAT async def tpl_new_format(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() fmt = q.data.split(":", 1)[1] context.user_data["tpl"]["parse_mode"] = fmt await q.edit_message_text( "Введи Jinja2‑шаблон текста.\n\n" "Если выбрал MarkdownV2, экранируй пользовательские значения фильтром {{ var|mdv2 }}.\n" "Для HTML используй {{ var|html }} при необходимости." ) return States.TPL_NEW_CONTENT async def tpl_new_content(self, update: Update, context: CallbackContext): context.user_data["tpl"]["content"] = update.message.text await update.message.reply_text( "Добавь кнопки (по желанию). Пришли строки вида: Текст|URL, можно несколько строк. Либо '-' чтобы пропустить." ) return States.TPL_NEW_KB async def tpl_new_kb(self, update: Update, context: CallbackContext): uid = update.effective_user.id raw = (update.message.text or "").strip() kb_tpl = None if raw == "-" else self._parse_kb_lines(raw) data = context.user_data.get("tpl", {}) parse_mode = data.get("parse_mode") or "HTML" tpl_id = await create_template( owner_id=uid, name=data.get("name"), title=None, type_=data.get("type"), content=data.get("content"), keyboard_tpl=kb_tpl, parse_mode=parse_mode, ) req = required_variables_of_template(data.get("content") or "", kb_tpl or []) extra = f"\nТребуемые переменные: {', '.join(sorted(req))}" if req else "" await update.message.reply_text(f"Шаблон сохранён (id={tpl_id}). Используй: #{data.get('name')} key=value ...{extra}") context.user_data.pop("tpl", None) return -1 async def tpl_list(self, update: Update, context: CallbackContext): 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 async def tpl_delete_ok(self, update: Update, context: CallbackContext): q = update.callback_query await q.answer() uid = update.effective_user.id tpl_id = int(q.data.split(":", 1)[1]) ok = await delete_template(owner_id=uid, tpl_id=tpl_id) await q.edit_message_text("Удалено" if ok else "Не найдено") return -1 # ---------- Внутренние утилиты ---------- 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" if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode) else: await q_or_msg.reply_text(f"Предпросмотр:\n\n{text[:3500]}", parse_mode=parse_mode) # кнопки отдельным сообщением при необходимости if hasattr(q_or_msg, "message") and q_or_msg.message: await q_or_msg.message.reply_text("Что дальше?", reply_markup=KbBuilder.preview_actions()) else: # если это message, просто второй месседж if not hasattr(q_or_msg, "edit_message_text"): await q_or_msg.reply_text("Что дальше?", reply_markup=KbBuilder.preview_actions()) return States.PREVIEW_CONFIRM async def _apply_template_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) s = self.sessions.get(uid) s.type = rendered["type"] s.text = rendered["text"] s.keyboard = {"rows": rendered["keyboard_rows"]} if rendered["keyboard_rows"] else None s.parse_mode = rendered.get("parse_mode") or s.parse_mode or "HTML" if hasattr(q_or_msg, "edit_message_text"): await q_or_msg.edit_message_text("Шаблон применён. Как публикуем?", reply_markup=KbBuilder.send_confirm()) else: await q_or_msg.reply_text("Шаблон применён. Как публикуем?", reply_markup=KbBuilder.send_confirm()) return States.CONFIRM_SEND async def _dispatch_now(self, uid: int, qmsg): s = self.sessions.get(uid) if not s or not s.channel_id: await qmsg.edit_message_text("Сессия потеряна.") return token = settings.editor_bot_token payload = build_payload( ptype=s.type, text=s.text, media_file_id=s.media_file_id, parse_mode=s.parse_mode or "HTML", keyboard=s.keyboard, ) send_post_task.delay(token, s.channel_id, payload) await qmsg.edit_message_text("Отправка запущена.") async def _dispatch_with_eta(self, uid: int, when: datetime): s = self.sessions.get(uid) token = settings.editor_bot_token payload = build_payload( ptype=s.type, text=s.text, media_file_id=s.media_file_id, parse_mode=s.parse_mode or "HTML", keyboard=s.keyboard, ) send_post_task.apply_async(args=[token, s.channel_id, payload], eta=when) @staticmethod def _parse_kb_lines(raw: str) -> list[dict]: rows: list[dict] = [] for line in raw.splitlines(): if "|" in line: t, u = line.split("|", 1) rows.append({"text": t.strip(), "url": u.strip()}) return rows