Files
postbot/app/bots/editor_bot.py
2025-08-17 14:15:46 +09:00

1525 lines
61 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
import shlex
import logging
from datetime import datetime
from typing import Optional, Dict, List, Any, Union, cast
import time
from urllib.parse import urlparse
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Message, CallbackQuery
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.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
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
) = 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 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
# -------- Команды верхнего уровня ---------
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"
"/newpost — мастер поста\n"
"/tpl_new — создать шаблон\n"
"/tpl_list — список шаблонов"
)
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)
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:
"""Обработка выбора канала."""
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:
# Парсим данные из 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"),
InlineKeyboardButton("Фото", callback_data="type:photo")
],
[
InlineKeyboardButton("Видео", callback_data="type:video"),
InlineKeyboardButton("GIF", callback_data="type:animation")
],
]
# Отображаем сообщение с выбором типа
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text(
"Выберите тип поста:",
reply_markup=InlineKeyboardMarkup(kb)
)
return CHOOSE_TYPE
except Exception as e:
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)
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
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)
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']}")
celery_app.send_task(
'app.tasks.senders.send_post_task',
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)
],
},
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_message(message: Message, 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:
await message.reply_text("Шаблонов пока нет. Создай через /tpl_new.")
return ENTER_TEXT
else:
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:
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_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({
"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 message.reply_text(
"Шаблон применён. Как публикуем?",
reply_markup=markup
)
return CONFIRM_SEND
except Exception as e:
logger.error(f"Error applying template via message: {e}")
await message.reply_text("Ошибка при применении шаблона")
return ConversationHandler.END
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]}"
await message.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)
await message.reply_text("Что дальше?", reply_markup=markup)
return PREVIEW_CONFIRM
except Exception as e:
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:
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:
"""Открытие списка шаблонов."""
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:
"""Навигация по списку шаблонов."""
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:
"""Применение выбранного шаблона."""
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:
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:
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(update, uid, name, {})
except Exception as e:
logger.error(f"Error applying template: {e}")
await update.callback_query.edit_message_text("Ошибка при применении шаблона")
return ConversationHandler.END
async def choose_template_preview(update: Update, context: CallbackContext) -> int:
"""Предпросмотр шаблона."""
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 = 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)
}
if required:
await update.callback_query.edit_message_text(
"Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) +
"\nПришли значения в формате key=value (по строкам или в одну строку)."
)
return PREVIEW_VARS
return await _render_preview_and_confirm(update, uid, name, {})
except Exception as e:
logger.error(f"Error previewing template: {e}")
await update.callback_query.edit_message_text("Ошибка при предпросмотре шаблона")
return ConversationHandler.END
async def choose_template_cancel(update: Update, context: CallbackContext) -> int:
"""Отмена выбора шаблона."""
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__":
main()