Files
postbot/app/bots/editor/wizard.py
2025-08-17 11:44:54 +09:00

486 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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