590 lines
24 KiB
Python
590 lines
24 KiB
Python
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
|
||
|
||
# -------- Вспомогательные функции для шаблонов ---------
|
||
|
||
async def _render_tpl_list(q_or_msg: Update | CallbackContext, uid: int, page: int) -> int:
|
||
"""Отображение списка шаблонов с пагинацией."""
|
||
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:
|
||
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.")
|
||
return ENTER_TEXT
|
||
else:
|
||
return await _render_tpl_list(q_or_msg, 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)
|
||
kb.append([InlineKeyboardButton("Отмена", callback_data="tpl:cancel")])
|
||
|
||
text = f"Шаблоны (стр. {page+1}/{(total-1)//PAGE_SIZE + 1}):"
|
||
if hasattr(q_or_msg, "edit_message_text"):
|
||
await q_or_msg.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb))
|
||
else:
|
||
await q_or_msg.reply_text(text, reply_markup=InlineKeyboardMarkup(kb))
|
||
return SELECT_TEMPLATE
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error rendering template list: {e}")
|
||
if hasattr(q_or_msg, "edit_message_text"):
|
||
await q_or_msg.edit_message_text("Ошибка при загрузке списка шаблонов")
|
||
else:
|
||
await q_or_msg.reply_text("Ошибка при загрузке списка шаблонов")
|
||
return ConversationHandler.END
|
||
|
||
async def _apply_template_and_confirm(q_or_msg: Union[CallbackQuery, Message], uid: int, name: str, ctx_vars: dict) -> int:
|
||
"""Применение шаблона к текущему посту."""
|
||
try:
|
||
rendered = await render_template_by_name(owner_id=uid, name=name, ctx=ctx_vars)
|
||
session[uid].update({
|
||
"type": rendered["type"],
|
||
"text": rendered["text"],
|
||
"keyboard": {"rows": rendered["keyboard_rows"]} if rendered.get("keyboard_rows") else None,
|
||
"parse_mode": rendered.get("parse_mode") or session[uid].get("parse_mode") or "HTML"
|
||
})
|
||
|
||
kb = [
|
||
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
|
||
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")]
|
||
]
|
||
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
|
||
)
|
||
|
||
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("Ошибка при применении шаблона")
|
||
return ConversationHandler.END
|
||
|
||
async def _render_preview_and_confirm(q_or_msg: Update | CallbackContext, 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)
|
||
|
||
kb = [
|
||
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
|
||
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
|
||
[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)
|
||
|
||
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("Ошибка при рендеринге шаблона")
|
||
else:
|
||
await q_or_msg.reply_text("Ошибка при рендеринге шаблона")
|
||
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)
|
||
|
||
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(":")
|
||
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
|
||
try:
|
||
name = q.data.split(":")[1]
|
||
tpl = await render_template_by_name(owner_id=uid, name=name, ctx={})
|
||
required = set(tpl.get("_required", []))
|
||
if required:
|
||
context.user_data["preview"] = {"name": name, "provided": {}, "missing": list(required)}
|
||
await q.edit_message_text(
|
||
"Шаблон требует переменные: " + ", ".join(sorted(required)) +
|
||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||
)
|
||
return PREVIEW_VARS
|
||
return await _apply_template_and_confirm(q, uid, name, {})
|
||
except Exception as e:
|
||
logger.error(f"Error applying template: {e}")
|
||
await q.edit_message_text("Ошибка при применении шаблона")
|
||
return ConversationHandler.END
|
||
|
||
async def choose_template_preview(update: Update, context: CallbackContext) -> int:
|
||
"""Предпросмотр шаблона."""
|
||
q = update.callback_query
|
||
await q.answer()
|
||
uid = update.effective_user.id
|
||
try:
|
||
name = q.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)}
|
||
if required:
|
||
await q.edit_message_text(
|
||
"Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) +
|
||
"\nПришли значения в формате key=value (по строкам или в одну строку)."
|
||
)
|
||
return PREVIEW_VARS
|
||
return await _render_preview_and_confirm(q, uid, name, {})
|
||
except Exception as e:
|
||
logger.error(f"Error previewing template: {e}")
|
||
await q.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("Отправь текст сообщения или введи #имя для шаблона.")
|
||
return ENTER_TEXT
|
||
|
||
if __name__ == "__main__":
|
||
main()
|