500 lines
22 KiB
Python
500 lines
22 KiB
Python
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):
|
||
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, 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])
|
||
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 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)
|
||
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=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("Отправка запущена.")
|
||
|
||
async def _dispatch_with_eta(self, uid: int, when: datetime):
|
||
s = self.sessions.get(uid)
|
||
token = settings.editor_bot_token
|
||
payload = build_payload(
|
||
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
|
||
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
|