bot rafactor and bugfix
This commit is contained in:
1585
app/bots/editor/handlers.py
Normal file
1585
app/bots/editor/handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
0
app/bots/editor/handlers/__init__.py
Normal file
0
app/bots/editor/handlers/__init__.py
Normal file
43
app/bots/editor/handlers/base.py
Normal file
43
app/bots/editor/handlers/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Базовые обработчики."""
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes, ConversationHandler
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработчик команды /start."""
|
||||
if not update.message:
|
||||
return ConversationHandler.END
|
||||
|
||||
await update.message.reply_text(
|
||||
"Привет! Я бот для управления постами.\n"
|
||||
"Для создания шаблона используйте /newtemplate\n"
|
||||
"Для создания поста используйте /newpost\n"
|
||||
"Для просмотра шаблонов /templates\n"
|
||||
"Для помощи /help"
|
||||
)
|
||||
return ConversationHandler.END
|
||||
|
||||
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработчик команды /help."""
|
||||
if not update.message:
|
||||
return ConversationHandler.END
|
||||
|
||||
await update.message.reply_text(
|
||||
"Доступные команды:\n"
|
||||
"/start - начать работу с ботом\n"
|
||||
"/newtemplate - создать новый шаблон\n"
|
||||
"/templates - просмотреть существующие шаблоны\n"
|
||||
"/newpost - создать новый пост\n"
|
||||
"/cancel - отменить текущую операцию"
|
||||
)
|
||||
return ConversationHandler.END
|
||||
|
||||
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Отмена текущей операции."""
|
||||
if not update.message:
|
||||
return ConversationHandler.END
|
||||
|
||||
await update.message.reply_text(
|
||||
"Операция отменена.",
|
||||
reply_markup=None
|
||||
)
|
||||
return ConversationHandler.END
|
||||
744
app/bots/editor/handlers/posts.py
Normal file
744
app/bots/editor/handlers/posts.py
Normal file
@@ -0,0 +1,744 @@
|
||||
"""Обработчики для работы с постами."""
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
import re
|
||||
from typing import Dict, Any, Optional, cast, Union
|
||||
|
||||
from telegram import (
|
||||
Update,
|
||||
Message,
|
||||
CallbackQuery,
|
||||
ReplyKeyboardMarkup,
|
||||
InlineKeyboardMarkup,
|
||||
InlineKeyboardButton
|
||||
)
|
||||
from telegram.ext import (
|
||||
ContextTypes,
|
||||
ConversationHandler
|
||||
)
|
||||
from telegram.helpers import escape_markdown
|
||||
from telegram.constants import ChatAction, ParseMode
|
||||
from telegram.error import BadRequest, Forbidden, TelegramError
|
||||
|
||||
from ..session import UserSession, SessionStore
|
||||
from ..states import BotStates
|
||||
from ..keyboards import KbBuilder
|
||||
from app.models.post import Post, PostType
|
||||
from app.services.template import TemplateService
|
||||
from app.services.channels import ChannelService
|
||||
from app.services.telegram import PostService
|
||||
from ..messages import MessageType
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
def parse_key_value_lines(text: str) -> Dict[str, str]:
|
||||
"""Парсит строки в формате 'ключ = значение' в словарь."""
|
||||
if not text:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
for line in text.split('\n'):
|
||||
if '=' not in line:
|
||||
continue
|
||||
key, value = map(str.strip, line.split('=', 1))
|
||||
if key:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
async def newpost(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Начало создания нового поста."""
|
||||
message = update.effective_message
|
||||
user = update.effective_user
|
||||
|
||||
if not message or not user:
|
||||
return ConversationHandler.END
|
||||
|
||||
try:
|
||||
# Создаем новую сессию
|
||||
session = SessionStore.get_instance().get(user.id)
|
||||
session.clear()
|
||||
|
||||
# Загружаем список каналов пользователя
|
||||
channels = await ChannelService.get_user_channels(user.id)
|
||||
if not channels:
|
||||
await message.reply_text(
|
||||
"У вас нет добавленных каналов. Используйте /add_channel чтобы добавить."
|
||||
)
|
||||
return ConversationHandler.END
|
||||
|
||||
kb = KbBuilder.channels(channels)
|
||||
await message.reply_text(
|
||||
"Выберите канал для публикации:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.CHOOSE_CHANNEL
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in newpost: {e}")
|
||||
await message.reply_text("Произошла ошибка при создании поста")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработка выбора канала."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
await message.edit_text("Неверный формат данных")
|
||||
return ConversationHandler.END
|
||||
|
||||
channel_id = int(query.data.replace("channel:", ""))
|
||||
|
||||
# Проверяем существование канала
|
||||
channel = await ChannelService.get_channel(channel_id)
|
||||
if not channel:
|
||||
await message.edit_text("Канал не найден")
|
||||
return ConversationHandler.END
|
||||
|
||||
session = SessionStore.get_instance().get(query.from_user.id)
|
||||
session.channel_id = channel_id
|
||||
|
||||
kb = KbBuilder.post_types()
|
||||
await message.edit_text(
|
||||
"Выберите тип поста:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.CHOOSE_TYPE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose_channel: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text("Произошла ошибка при выборе канала")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработка выбора типа поста."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
await message.edit_text("Неверный формат данных")
|
||||
return ConversationHandler.END
|
||||
|
||||
post_type = PostType(query.data.replace("type:", ""))
|
||||
|
||||
session = SessionStore.get_instance().get(query.from_user.id)
|
||||
session.type = post_type
|
||||
|
||||
kb = KbBuilder.parse_modes()
|
||||
await message.edit_text(
|
||||
"Выберите формат текста:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.CHOOSE_FORMAT
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose_type: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text("Произошла ошибка при выборе типа поста")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_format(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработка выбора формата текста."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
await message.edit_text("Неверный формат данных")
|
||||
return ConversationHandler.END
|
||||
|
||||
parse_mode = query.data.replace("fmt:", "")
|
||||
if parse_mode not in [ParseMode.HTML, ParseMode.MARKDOWN_V2]:
|
||||
await message.edit_text("Неизвестный формат текста")
|
||||
return ConversationHandler.END
|
||||
|
||||
session = SessionStore.get_instance().get(query.from_user.id)
|
||||
session.parse_mode = parse_mode
|
||||
|
||||
kb = KbBuilder.text_input_options()
|
||||
await message.edit_text(
|
||||
"Введите текст поста или выберите шаблон:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose_format: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text("Произошла ошибка при выборе формата")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def enter_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработка ввода текста поста."""
|
||||
message = update.effective_message
|
||||
user = update.effective_user
|
||||
|
||||
if not message or not user:
|
||||
return ConversationHandler.END
|
||||
|
||||
text = message.text
|
||||
if not text:
|
||||
await message.reply_text("Пожалуйста, введите текст поста")
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
try:
|
||||
session = SessionStore.get_instance().get(user.id)
|
||||
session.text = text
|
||||
|
||||
if session.type == MessageType.TEXT:
|
||||
await message.reply_text(
|
||||
"Введите клавиатуру в формате:\n"
|
||||
"текст кнопки = ссылка\n\n"
|
||||
"Или отправьте 'skip' чтобы пропустить"
|
||||
)
|
||||
return BotStates.EDIT_KEYBOARD
|
||||
|
||||
await message.reply_text(
|
||||
"Отправьте фото/видео/gif для поста"
|
||||
)
|
||||
return BotStates.ENTER_MEDIA
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in enter_text: {e}")
|
||||
await message.reply_text("Произошла ошибка при сохранении текста")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_template_open(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Открытие выбора шаблона."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
user_id = query.from_user.id
|
||||
|
||||
templates = await TemplateService.list_user_templates(user_id)
|
||||
if not templates:
|
||||
await message.edit_text(
|
||||
"У вас нет шаблонов. Создайте новый с помощью /newtemplate"
|
||||
)
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
total = len(templates)
|
||||
if total == 0:
|
||||
await message.edit_text("Список шаблонов пуст")
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
user_data = context.user_data
|
||||
if not user_data:
|
||||
user_data = {}
|
||||
context.user_data = user_data
|
||||
|
||||
page = user_data.get("tpl_page", 0)
|
||||
items = templates[page * KbBuilder.PAGE_SIZE:(page + 1) * KbBuilder.PAGE_SIZE]
|
||||
|
||||
kb = KbBuilder.templates_list(items, page, total)
|
||||
await message.edit_text(
|
||||
"Выберите шаблон:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose_template_open: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text("Произошла ошибка при загрузке шаблонов")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_template_apply(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Применение выбранного шаблона."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
await message.edit_text("Неверный формат данных")
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
template_id = query.data.replace("tpluse:", "")
|
||||
|
||||
template = await TemplateService.get_template(template_id)
|
||||
if not template:
|
||||
await message.edit_text("Шаблон не найден")
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
session = SessionStore.get_instance().get(query.from_user.id)
|
||||
session.template_id = template_id
|
||||
session.text = template.content
|
||||
|
||||
if "{" in template.content and "}" in template.content:
|
||||
# Шаблон содержит переменные
|
||||
await message.edit_text(
|
||||
"Введите значения для переменных в формате:\n"
|
||||
"переменная = значение"
|
||||
)
|
||||
return BotStates.PREVIEW_VARS
|
||||
|
||||
# Нет переменных, можно сразу показать предпросмотр
|
||||
kb = KbBuilder.preview_confirm()
|
||||
|
||||
post_data = session.to_dict()
|
||||
await PostService.preview_post(message, post_data)
|
||||
|
||||
await message.reply_text(
|
||||
"Предпросмотр поста. Выберите действие:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose_template_apply: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text(
|
||||
"Произошла ошибка при применении шаблона"
|
||||
)
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при применении шаблона: {e}")
|
||||
await query.message.edit_text(
|
||||
f"Ошибка при применении шаблона: {str(e)}"
|
||||
)
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
async def choose_template_preview(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Предпросмотр шаблона."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
await message.edit_text("Неверный формат данных")
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
template_id = query.data.replace("tplprev:", "")
|
||||
template = await TemplateService.get_template(template_id)
|
||||
|
||||
if not template:
|
||||
await message.edit_text("Шаблон не найден")
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
await message.edit_text(
|
||||
f"Предпросмотр шаблона:\n\n{template.content}",
|
||||
parse_mode=template.parse_mode
|
||||
)
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose_template_preview: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text(
|
||||
"Произошла ошибка при предпросмотре шаблона"
|
||||
)
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
async def choose_template_navigate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Навигация по страницам шаблонов."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
# Получаем номер страницы
|
||||
page = int(query.data.replace("tplpage:", ""))
|
||||
|
||||
user_data = context.user_data
|
||||
if not user_data:
|
||||
user_data = {}
|
||||
context.user_data = user_data
|
||||
user_data["tpl_page"] = page
|
||||
|
||||
# Перестраиваем список для новой страницы
|
||||
templates = await TemplateService.list_user_templates(query.from_user.id)
|
||||
total = len(templates)
|
||||
items = templates[page * KbBuilder.PAGE_SIZE:(page + 1) * KbBuilder.PAGE_SIZE]
|
||||
|
||||
kb = KbBuilder.templates_list(items, page, total)
|
||||
await message.edit_reply_markup(reply_markup=kb)
|
||||
return BotStates.SELECT_TEMPLATE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in choose_template_navigate: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text("Произошла ошибка при смене страницы")
|
||||
return ConversationHandler.END
|
||||
|
||||
async def choose_template_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Отмена выбора шаблона."""
|
||||
query = update.callback_query
|
||||
if not query:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
await query.message.edit_text(
|
||||
"Введите текст поста:"
|
||||
)
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
async def preview_collect_vars(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Сбор значений переменных для шаблона."""
|
||||
message = update.message
|
||||
if not message or not message.from_user or not message.text:
|
||||
return ConversationHandler.END
|
||||
|
||||
try:
|
||||
variables = parse_key_value_lines(message.text)
|
||||
session = SessionStore.get_instance().get(message.from_user.id)
|
||||
if not session.template_id:
|
||||
await message.reply_text("Шаблон не выбран")
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
template = await TemplateService.get_template(session.template_id)
|
||||
if not template:
|
||||
await message.reply_text("Шаблон не найден")
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
# Подставляем значения переменных
|
||||
text = template.content
|
||||
for var, value in variables.items():
|
||||
text = text.replace(f"{{{var}}}", value)
|
||||
|
||||
session.text = text
|
||||
post_data = session.to_dict()
|
||||
|
||||
kb = KbBuilder.preview_confirm()
|
||||
await PostService.preview_post(message, post_data)
|
||||
|
||||
await message.reply_text(
|
||||
"Предпросмотр поста. Выберите действие:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
|
||||
except ValueError as e:
|
||||
await message.reply_text(
|
||||
f"Ошибка в формате переменных: {str(e)}\n"
|
||||
"Используйте формат:\n"
|
||||
"переменная = значение"
|
||||
)
|
||||
return BotStates.PREVIEW_VARS
|
||||
except Exception as e:
|
||||
logger.error(f"Error in preview_collect_vars: {e}")
|
||||
await message.reply_text(
|
||||
"Произошла ошибка при обработке переменных"
|
||||
)
|
||||
return BotStates.PREVIEW_VARS
|
||||
|
||||
async def preview_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Подтверждение предпросмотра поста."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
await message.edit_text("Неверный формат данных")
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
|
||||
action = query.data.replace("pv:", "")
|
||||
if action == "edit":
|
||||
await message.edit_text(
|
||||
"Введите текст поста:"
|
||||
)
|
||||
return BotStates.ENTER_TEXT
|
||||
|
||||
session = SessionStore.get_instance().get(query.from_user.id)
|
||||
|
||||
if not session.type:
|
||||
await message.edit_text("Ошибка: не выбран тип поста")
|
||||
return ConversationHandler.END
|
||||
|
||||
if session.type == MessageType.TEXT:
|
||||
await message.edit_text(
|
||||
"Введите клавиатуру в формате:\n"
|
||||
"текст кнопки = ссылка\n\n"
|
||||
"Или отправьте 'skip' чтобы пропустить"
|
||||
)
|
||||
return BotStates.EDIT_KEYBOARD
|
||||
|
||||
await message.edit_text(
|
||||
"Отправьте фото/видео/gif для поста"
|
||||
)
|
||||
return BotStates.ENTER_MEDIA
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in preview_confirm: {e}")
|
||||
if query.message:
|
||||
await query.message.edit_text(
|
||||
"Произошла ошибка при обработке предпросмотра"
|
||||
)
|
||||
return ConversationHandler.END
|
||||
|
||||
async def enter_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработка медиафайла."""
|
||||
message = update.message
|
||||
if not message or not message.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
try:
|
||||
session = SessionStore.get_instance().get(message.from_user.id)
|
||||
|
||||
if message.photo:
|
||||
session.media_file_id = message.photo[-1].file_id
|
||||
elif message.video:
|
||||
session.media_file_id = message.video.file_id
|
||||
elif message.animation:
|
||||
session.media_file_id = message.animation.file_id
|
||||
elif message.document:
|
||||
session.media_file_id = message.document.file_id
|
||||
else:
|
||||
await message.reply_text(
|
||||
"Пожалуйста, отправьте фото, видео или GIF"
|
||||
)
|
||||
return BotStates.ENTER_MEDIA
|
||||
|
||||
# Показываем предпросмотр
|
||||
kb = KbBuilder.preview_confirm()
|
||||
post_data = session.to_dict()
|
||||
await PostService.preview_post(message, post_data)
|
||||
|
||||
await message.reply_text(
|
||||
"Предпросмотр поста. Выберите действие:",
|
||||
reply_markup=kb
|
||||
)
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in enter_media: {e}")
|
||||
await message.reply_text(
|
||||
"Произошла ошибка при обработке файла"
|
||||
)
|
||||
return ConversationHandler.END
|
||||
session.media_id = message.animation.file_id
|
||||
|
||||
await message.reply_text(
|
||||
"Введите клавиатуру в формате:\n"
|
||||
"текст кнопки = ссылка\n\n"
|
||||
"Или отправьте 'skip' чтобы пропустить"
|
||||
)
|
||||
return BotStates.EDIT_KEYBOARD
|
||||
|
||||
async def edit_keyboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработка клавиатуры поста."""
|
||||
message = update.message
|
||||
if not message or not message.from_user or not message.text:
|
||||
return ConversationHandler.END
|
||||
|
||||
try:
|
||||
kb_text = message.text.strip()
|
||||
session = SessionStore.get_instance().get(message.from_user.id)
|
||||
|
||||
if kb_text.lower() != "skip":
|
||||
keyboard = parse_key_value_lines(kb_text)
|
||||
session.keyboard = {"rows": []}
|
||||
for text, url in keyboard.items():
|
||||
session.keyboard["rows"].append([{"text": text, "url": url}])
|
||||
|
||||
# Показываем предпросмотр поста
|
||||
post_data = session.to_dict()
|
||||
await PostService.preview_post(message, post_data)
|
||||
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton("Отправить", callback_data="send:now"),
|
||||
InlineKeyboardButton("Отложить", callback_data="send:schedule")
|
||||
]
|
||||
])
|
||||
|
||||
await message.reply_text(
|
||||
"Выберите действие:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return BotStates.CONFIRM_SEND
|
||||
|
||||
except ValueError as e:
|
||||
await message.reply_text(
|
||||
f"Ошибка в формате клавиатуры: {e}\n"
|
||||
"Используйте формат:\n"
|
||||
"текст кнопки = ссылка"
|
||||
)
|
||||
return BotStates.EDIT_KEYBOARD
|
||||
except Exception as e:
|
||||
logger.error(f"Error in edit_keyboard: {e}")
|
||||
await message.reply_text(
|
||||
"Произошла ошибка при обработке клавиатуры"
|
||||
)
|
||||
return ConversationHandler.END
|
||||
|
||||
async def confirm_send(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Подтверждение отправки поста."""
|
||||
query = update.callback_query
|
||||
if not query or not query.from_user:
|
||||
return ConversationHandler.END
|
||||
|
||||
await query.answer()
|
||||
|
||||
try:
|
||||
message = cast(Message, query.message)
|
||||
if not query.data:
|
||||
await message.edit_text("Неверный формат данных")
|
||||
return BotStates.CONFIRM_SEND
|
||||
|
||||
action = query.data.replace("send:", "")
|
||||
if action == "schedule":
|
||||
await message.edit_text(
|
||||
"Введите дату и время для отложенной публикации в формате:\n"
|
||||
"ДД.ММ.ГГГГ ЧЧ:ММ"
|
||||
)
|
||||
return BotStates.ENTER_SCHEDULE
|
||||
|
||||
session = SessionStore.get_instance().get(query.from_user.id)
|
||||
|
||||
# Отправляем пост сейчас
|
||||
post_data = session.to_dict()
|
||||
|
||||
message = query.message
|
||||
if not message:
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
|
||||
if not session.channel_id:
|
||||
await context.bot.send_message(
|
||||
chat_id=message.chat.id,
|
||||
text="Канал не выбран",
|
||||
reply_markup=KbBuilder.go_back()
|
||||
)
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
|
||||
post = await PostService.create_post(context.bot, session.channel_id, post_data)
|
||||
if post:
|
||||
await context.bot.edit_message_text(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
text="Пост успешно отправлен!"
|
||||
)
|
||||
SessionStore.get_instance().drop(query.from_user.id)
|
||||
return ConversationHandler.END
|
||||
|
||||
await context.bot.send_message(
|
||||
chat_id=message.chat.id,
|
||||
text="Ошибка при отправке поста. Попробуйте позже.",
|
||||
reply_markup=KbBuilder.go_back()
|
||||
)
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in confirm_send: {e}")
|
||||
message = query.message
|
||||
if message:
|
||||
await context.bot.edit_message_text(
|
||||
chat_id=message.chat.id,
|
||||
message_id=message.message_id,
|
||||
text="Произошла ошибка при отправке поста"
|
||||
)
|
||||
return BotStates.PREVIEW_CONFIRM
|
||||
return ConversationHandler.END
|
||||
|
||||
session = get_session_store().get_or_create(query.from_user.id)
|
||||
|
||||
try:
|
||||
# Отправляем пост
|
||||
await schedule_post(session, schedule_time=None)
|
||||
await query.message.edit_text("Пост успешно отправлен!")
|
||||
|
||||
# Очищаем сессию
|
||||
get_session_store().drop(query.from_user.id)
|
||||
return ConversationHandler.END
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке поста: {e}")
|
||||
await query.message.edit_text(
|
||||
f"Ошибка при отправке поста: {str(e)}"
|
||||
)
|
||||
return BotStates.CONFIRM_SEND
|
||||
|
||||
async def enter_schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
"""Обработка времени для отложенной публикации."""
|
||||
message = update.message
|
||||
if not message or not message.from_user or not message.text:
|
||||
return ConversationHandler.END
|
||||
|
||||
try:
|
||||
schedule_text = message.text.strip()
|
||||
if not schedule_text:
|
||||
await message.reply_text(
|
||||
"Некорректный формат даты.\n"
|
||||
"Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ"
|
||||
)
|
||||
return BotStates.ENTER_SCHEDULE
|
||||
|
||||
try:
|
||||
schedule_time = datetime.strptime(schedule_text, "%d.%m.%Y %H:%M")
|
||||
if schedule_time <= datetime.now():
|
||||
await message.reply_text(
|
||||
"Нельзя указать время в прошлом.\n"
|
||||
"Введите время в будущем."
|
||||
)
|
||||
return BotStates.ENTER_SCHEDULE
|
||||
|
||||
session = SessionStore.get_instance().get(message.from_user.id)
|
||||
if not session.channel_id:
|
||||
await message.reply_text("Не выбран канал для публикации")
|
||||
return ConversationHandler.END
|
||||
|
||||
# Отправляем отложенный пост
|
||||
post_data = session.to_dict()
|
||||
post_data["schedule_time"] = schedule_time
|
||||
await PostService.create_post(context.bot, session.channel_id, post_data)
|
||||
await message.reply_text("Пост запланирован!")
|
||||
|
||||
# Очищаем сессию
|
||||
SessionStore.get_instance().drop(message.from_user.id)
|
||||
return ConversationHandler.END
|
||||
|
||||
except ValueError:
|
||||
await message.reply_text(
|
||||
"Некорректный формат даты.\n"
|
||||
"Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ"
|
||||
)
|
||||
return BotStates.ENTER_SCHEDULE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in enter_schedule: {e}")
|
||||
await message.reply_text(
|
||||
"Произошла ошибка при обработке времени публикации"
|
||||
)
|
||||
return ConversationHandler.END
|
||||
146
app/bots/editor/handlers/templates.py
Normal file
146
app/bots/editor/handlers/templates.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Обработчики для работы с шаблонами."""
|
||||
from typing import Optional, Dict, Any
|
||||
from telegram import Update, Message
|
||||
from telegram.ext import ContextTypes, ConversationHandler
|
||||
|
||||
from app.bots.editor.states import BotStates
|
||||
from app.bots.editor.session import get_session_store
|
||||
from ..keyboards import template_type_keyboard, get_templates_keyboard
|
||||
from ..utils.parsers import parse_key_value_lines
|
||||
from ..utils.validation import validate_template_name
|
||||
|
||||
async def start_template_creation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||
"""Начало создания шаблона."""
|
||||
if not update.message:
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
message = update.message
|
||||
await message.reply_text(
|
||||
"Выберите тип шаблона:",
|
||||
reply_markup=template_type_keyboard()
|
||||
)
|
||||
return BotStates.TPL_TYPE
|
||||
|
||||
async def handle_template_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||
"""Обработка выбора типа шаблона."""
|
||||
if not update.callback_query:
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
tpl_type = query.data
|
||||
user_id = query.from_user.id
|
||||
|
||||
session_store = get_session_store()
|
||||
session = session_store.get_or_create(user_id)
|
||||
session.type = tpl_type
|
||||
|
||||
await query.message.edit_text("Введите название шаблона:")
|
||||
return BotStates.TPL_NAME
|
||||
|
||||
async def handle_template_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||
"""Обработка ввода имени шаблона."""
|
||||
if not update.message:
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
message = update.message
|
||||
user_id = message.from_user.id
|
||||
name = message.text.strip()
|
||||
|
||||
if not validate_template_name(name):
|
||||
await message.reply_text(
|
||||
"Некорректное имя шаблона. Используйте только буквы, цифры и знаки - _"
|
||||
)
|
||||
return BotStates.TPL_NAME
|
||||
|
||||
session = get_session_store().get_or_create(user_id)
|
||||
session.template_name = name
|
||||
|
||||
await message.reply_text("Введите текст шаблона:")
|
||||
return BotStates.TPL_TEXT
|
||||
|
||||
async def handle_template_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||
"""Обработка текста шаблона."""
|
||||
if not update.message:
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
message = update.message
|
||||
user_id = message.from_user.id
|
||||
text = message.text.strip()
|
||||
|
||||
session = get_session_store().get_or_create(user_id)
|
||||
session.text = text
|
||||
|
||||
await message.reply_text(
|
||||
"Введите клавиатуру в формате:\n"
|
||||
"текст кнопки = ссылка\n\n"
|
||||
"Или отправьте 'skip' чтобы пропустить"
|
||||
)
|
||||
return BotStates.TPL_NEW_KB
|
||||
|
||||
async def handle_template_keyboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||
"""Обработка клавиатуры шаблона."""
|
||||
if not update.message:
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
message = update.message
|
||||
user_id = message.from_user.id
|
||||
kb_text = message.text.strip()
|
||||
|
||||
session = get_session_store().get_or_create(user_id)
|
||||
|
||||
if kb_text != "skip":
|
||||
try:
|
||||
keyboard = parse_key_value_lines(kb_text)
|
||||
session.keyboard = keyboard
|
||||
except ValueError as e:
|
||||
await message.reply_text(f"Ошибка разбора клавиатуры: {e}")
|
||||
return BotStates.TPL_NEW_KB
|
||||
|
||||
try:
|
||||
template_data = {
|
||||
"owner_id": user_id,
|
||||
"name": session.template_name,
|
||||
"title": session.template_name,
|
||||
"content": session.text,
|
||||
"type": session.type,
|
||||
"parse_mode": session.parse_mode or "HTML",
|
||||
"keyboard_tpl": session.keyboard
|
||||
}
|
||||
await create_template(template_data)
|
||||
await message.reply_text("Шаблон успешно создан")
|
||||
|
||||
# Очищаем сессию
|
||||
get_session_store().drop(user_id)
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
except ValueError as e:
|
||||
await message.reply_text(f"Ошибка создания шаблона: {e}")
|
||||
return BotStates.TPL_NEW_KB
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при создании шаблона: {e}")
|
||||
await message.reply_text("Произошла непредвиденная ошибка при создании шаблона")
|
||||
return BotStates.TPL_NEW_KB
|
||||
|
||||
async def list_templates(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||
"""Список шаблонов."""
|
||||
if not update.message:
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
message = update.message
|
||||
user_id = message.from_user.id
|
||||
|
||||
templates = await get_user_templates(user_id)
|
||||
if not templates:
|
||||
await message.reply_text("У вас пока нет шаблонов")
|
||||
return BotStates.CONVERSATION_END
|
||||
|
||||
page = context.user_data.get("tpl_page", 0)
|
||||
keyboard = get_templates_keyboard(templates, page)
|
||||
|
||||
await message.reply_text(
|
||||
"Выберите шаблон:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return BotStates.TPL_SELECT
|
||||
@@ -1,53 +1,207 @@
|
||||
from __future__ import annotations
|
||||
from typing import Iterable, List, Tuple, Optional
|
||||
from typing import Iterable, List, Optional, Any
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from .messages import MessageType
|
||||
|
||||
def template_type_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Возвращает клавиатуру выбора типа шаблона."""
|
||||
return KbBuilder.template_type_keyboard()
|
||||
|
||||
def get_templates_keyboard(templates: List[Any], page: int = 0) -> InlineKeyboardMarkup:
|
||||
"""Возвращает клавиатуру со списком шаблонов."""
|
||||
return KbBuilder.get_templates_keyboard(templates, page)
|
||||
|
||||
|
||||
class KbBuilder:
|
||||
"""Строитель клавиатур для различных состояний бота."""
|
||||
|
||||
PAGE_SIZE = 8
|
||||
|
||||
@staticmethod
|
||||
def channels(channels: Iterable) -> InlineKeyboardMarkup:
|
||||
rows = [[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
|
||||
for ch in channels]
|
||||
"""Клавиатура выбора канала."""
|
||||
rows = [
|
||||
[InlineKeyboardButton(
|
||||
ch.title or str(ch.chat_id),
|
||||
callback_data=f"channel:{ch.id}"
|
||||
)]
|
||||
for ch in channels
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def post_types() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура выбора типа поста."""
|
||||
rows = [
|
||||
[InlineKeyboardButton("Текст", callback_data="type:text"),
|
||||
InlineKeyboardButton("Фото", callback_data="type:photo")],
|
||||
[InlineKeyboardButton("Видео", callback_data="type:video"),
|
||||
InlineKeyboardButton("GIF", callback_data="type:animation")],
|
||||
[
|
||||
InlineKeyboardButton("📝 Текст", callback_data=f"type:{MessageType.TEXT.value}"),
|
||||
InlineKeyboardButton("📷 Фото", callback_data=f"type:{MessageType.PHOTO.value}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("🎥 Видео", callback_data=f"type:{MessageType.VIDEO.value}"),
|
||||
InlineKeyboardButton("🎬 GIF", callback_data=f"type:{MessageType.ANIMATION.value}")
|
||||
],
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def parse_modes() -> InlineKeyboardMarkup:
|
||||
rows = [[InlineKeyboardButton("HTML", callback_data="fmt:HTML"),
|
||||
InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2")]]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def send_confirm() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура выбора формата текста."""
|
||||
rows = [
|
||||
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
|
||||
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")],
|
||||
[
|
||||
InlineKeyboardButton("HTML", callback_data="fmt:HTML"),
|
||||
InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2")
|
||||
]
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def templates_list(items: List, page: int, total: int, page_size: int) -> InlineKeyboardMarkup:
|
||||
rows: List[List[InlineKeyboardButton]] = []
|
||||
for t in items:
|
||||
rows.append([
|
||||
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
|
||||
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
|
||||
])
|
||||
def template_type_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура выбора типа шаблона."""
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton("📝 Текст", callback_data=f"tpl_type:{MessageType.TEXT.value}"),
|
||||
InlineKeyboardButton("📷 Фото", callback_data=f"tpl_type:{MessageType.PHOTO.value}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("🎥 Видео", callback_data=f"tpl_type:{MessageType.VIDEO.value}"),
|
||||
InlineKeyboardButton("🎬 GIF", callback_data=f"tpl_type:{MessageType.ANIMATION.value}")
|
||||
]
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def get_templates_keyboard(templates: List[Any], page: int = 0) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура списка шаблонов с пагинацией."""
|
||||
start_idx = page * KbBuilder.PAGE_SIZE
|
||||
end_idx = start_idx + KbBuilder.PAGE_SIZE
|
||||
page_templates = templates[start_idx:end_idx]
|
||||
|
||||
rows = []
|
||||
for template in page_templates:
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
f"{template.name} ({template.type})",
|
||||
callback_data=f"template:{template.id}"
|
||||
)
|
||||
])
|
||||
|
||||
nav_row = []
|
||||
if page > 0:
|
||||
nav_row.append(InlineKeyboardButton("◀️ Назад", callback_data=f"page:{page-1}"))
|
||||
if end_idx < len(templates):
|
||||
nav_row.append(InlineKeyboardButton("Вперед ▶️", callback_data=f"page:{page+1}"))
|
||||
|
||||
if nav_row:
|
||||
rows.append(nav_row)
|
||||
|
||||
return InlineKeyboardMarkup(rows)
|
||||
@staticmethod
|
||||
def go_back() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура с кнопкой назад."""
|
||||
return InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton("« Назад", callback_data="back")]
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def text_input_options() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура при вводе текста."""
|
||||
rows = [
|
||||
[InlineKeyboardButton("📋 Использовать шаблон", callback_data="tpl:choose")],
|
||||
[InlineKeyboardButton("⌨️ Добавить клавиатуру", callback_data="kb:add")],
|
||||
[InlineKeyboardButton("❌ Отмена", callback_data="cancel")]
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def send_confirm() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура подтверждения отправки."""
|
||||
rows = [
|
||||
[InlineKeyboardButton("📤 Отправить сейчас", callback_data="send:now")],
|
||||
[InlineKeyboardButton("⏰ Запланировать", callback_data="send:schedule")],
|
||||
[InlineKeyboardButton("✏️ Редактировать", callback_data="send:edit")],
|
||||
[InlineKeyboardButton("❌ Отмена", callback_data="send:cancel")]
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def preview_confirm() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура после предпросмотра."""
|
||||
rows = [
|
||||
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
|
||||
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
|
||||
[InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")]
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def templates_list(
|
||||
items: List,
|
||||
page: int,
|
||||
total: int,
|
||||
show_delete: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Клавиатура списка шаблонов с пагинацией.
|
||||
|
||||
Args:
|
||||
items: Список шаблонов на текущей странице
|
||||
page: Номер текущей страницы
|
||||
total: Общее количество шаблонов
|
||||
show_delete: Показывать ли кнопку удаления
|
||||
"""
|
||||
rows: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
for t in items:
|
||||
row = [
|
||||
InlineKeyboardButton(
|
||||
(t.title or t.name),
|
||||
callback_data=f"tpluse:{t.name}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
"👁 Предпросмотр",
|
||||
callback_data=f"tplprev:{t.name}"
|
||||
)
|
||||
]
|
||||
if show_delete:
|
||||
row.append(InlineKeyboardButton(
|
||||
"🗑",
|
||||
callback_data=f"tpldel:{t.name}"
|
||||
))
|
||||
rows.append(row)
|
||||
|
||||
# Навигация
|
||||
nav: List[InlineKeyboardButton] = []
|
||||
if page > 0:
|
||||
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
|
||||
if (page + 1) * page_size < total:
|
||||
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
|
||||
nav.append(InlineKeyboardButton(
|
||||
"◀️ Назад",
|
||||
callback_data=f"tplpage:{page-1}"
|
||||
))
|
||||
if (page + 1) * KbBuilder.PAGE_SIZE < total:
|
||||
nav.append(InlineKeyboardButton(
|
||||
"Вперёд ▶️",
|
||||
callback_data=f"tplpage:{page+1}"
|
||||
))
|
||||
|
||||
if nav:
|
||||
rows.append(nav)
|
||||
|
||||
# Кнопка отмены
|
||||
rows.append([InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")])
|
||||
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
@staticmethod
|
||||
def template_delete_confirm(name: str) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура подтверждения удаления шаблона."""
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton("✅ Удалить", callback_data=f"tpldelok:{name}"),
|
||||
InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")
|
||||
]
|
||||
]
|
||||
return InlineKeyboardMarkup(rows)
|
||||
if nav:
|
||||
rows.append(nav)
|
||||
|
||||
|
||||
@@ -1,31 +1,111 @@
|
||||
from __future__ import annotations
|
||||
import shlex
|
||||
from typing import Dict
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
TEXT = "text"
|
||||
PHOTO = "photo"
|
||||
VIDEO = "video"
|
||||
ANIMATION = "animation"
|
||||
|
||||
|
||||
class Messages:
|
||||
# Команды
|
||||
WELCOME_MESSAGE = (
|
||||
"👋 Привет! Я редактор постов для каналов.\n\n"
|
||||
"🤖 Сначала добавьте бота через /add_bot\n"
|
||||
"📢 Затем добавьте каналы через /add_channel\n"
|
||||
"📝 Потом можно:\n"
|
||||
"- Создать пост: /newpost\n"
|
||||
"- Управлять шаблонами: /tpl_new, /tpl_list\n"
|
||||
"- Посмотреть список ботов: /bots\n"
|
||||
"- Посмотреть список каналов: /channels\n\n"
|
||||
"❓ Справка: /help"
|
||||
)
|
||||
|
||||
HELP_MESSAGE = (
|
||||
"📖 Справка по командам:\n\n"
|
||||
"Управление ботами и каналами:\n"
|
||||
"/add_bot - Добавить нового бота\n"
|
||||
"/bots - Список ваших ботов\n"
|
||||
"/add_channel - Добавить канал\n"
|
||||
"/channels - Список ваших каналов\n\n"
|
||||
"Управление постами:\n"
|
||||
"/newpost - Создать новый пост\n\n"
|
||||
"Управление шаблонами:\n"
|
||||
"/tpl_new - Создать шаблон\n"
|
||||
"/tpl_list - Список ваших шаблонов"
|
||||
)
|
||||
|
||||
START = ("👋 Привет! Я редактор постов. Доступные команды:\n"
|
||||
"/newpost — создать новый пост\n"
|
||||
"/tpl_new — создать шаблон\n"
|
||||
"/tpl_list — список шаблонов")
|
||||
|
||||
# Ошибки
|
||||
ERROR_SESSION_EXPIRED = "❌ Сессия истекла. Начните заново с /newpost"
|
||||
ERROR_INVALID_FORMAT = "❌ Неверный формат. Попробуйте еще раз"
|
||||
ERROR_NO_CHANNELS = "❌ У вас нет каналов. Добавьте канал через админку"
|
||||
ERROR_TEMPLATE_NOT_FOUND = "❌ Шаблон не найден"
|
||||
ERROR_TEMPLATE_CREATE = "❌ Ошибка при создании шаблона: {error}"
|
||||
ERROR_INVALID_KEYBOARD = "❌ Неверный формат клавиатуры"
|
||||
|
||||
# Создание поста
|
||||
SELECT_CHANNEL = "📢 Выберите канал для публикации:"
|
||||
SELECT_TYPE = "📝 Выберите тип поста:"
|
||||
SELECT_FORMAT = "🔤 Выберите формат текста:"
|
||||
ENTER_TEXT = "✏️ Введите текст сообщения\nИли используйте #имя_шаблона для применения шаблона"
|
||||
ENTER_MEDIA = "📎 Отправьте {media_type}"
|
||||
ENTER_KEYBOARD = "⌨️ Введите клавиатуру в формате:\nтекст|url\nтекст2|url2\n\nИли отправьте 'skip' для пропуска"
|
||||
|
||||
# Шаблоны
|
||||
TEMPLATE_LIST = "📜 Список шаблонов (стр. {page}/{total_pages}):"
|
||||
TEMPLATE_CREATE_NAME = "📝 Введите имя для нового шаблона:"
|
||||
TEMPLATE_CREATE_TYPE = "📌 Выберите тип шаблона:"
|
||||
TEMPLATE_CREATE_FORMAT = "🔤 Выберите формат текста:"
|
||||
TEMPLATE_CREATE_CONTENT = "✏️ Введите содержимое шаблона:"
|
||||
TEMPLATE_CREATE_KEYBOARD = "⌨️ Введите клавиатуру или отправьте 'skip':"
|
||||
TEMPLATE_CREATED = "✅ Шаблон успешно создан"
|
||||
TEMPLATE_DELETED = "🗑 Шаблон удален"
|
||||
|
||||
# Отправка
|
||||
CONFIRM_SEND = "📤 Как отправить пост?"
|
||||
ENTER_SCHEDULE = "📅 Введите дату и время для публикации в формате YYYY-MM-DD HH:MM"
|
||||
POST_SCHEDULED = "✅ Пост запланирован на {datetime}"
|
||||
POST_SENT = "✅ Пост отправлен"
|
||||
|
||||
|
||||
class MessageParsers:
|
||||
@staticmethod
|
||||
def parse_template_invocation(s: str) -> tuple[str, Dict[str, str]]:
|
||||
"""
|
||||
Пример: "#promo title='Hi' url=https://x.y"
|
||||
-> ("promo", {"title":"Hi", "url":"https://x.y"})
|
||||
Парсит вызов шаблона вида: "#promo title='Hi' url=https://x.y"
|
||||
Возвращает: ("promo", {"title":"Hi", "url":"https://x.y"})
|
||||
"""
|
||||
s = (s or "").strip()
|
||||
if not s.startswith("#"):
|
||||
raise ValueError("not a template invocation")
|
||||
parts = shlex.split(s)
|
||||
name = parts[0][1:]
|
||||
args: Dict[str, str] = {}
|
||||
for tok in parts[1:]:
|
||||
if "=" in tok:
|
||||
k, v = tok.split("=", 1)
|
||||
args[k] = v
|
||||
return name, args
|
||||
|
||||
try:
|
||||
parts = shlex.split(s)
|
||||
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().strip('"\'')
|
||||
return name, args
|
||||
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Ошибка парсинга шаблона: {e}")
|
||||
|
||||
@staticmethod
|
||||
def parse_key_value_lines(text: str) -> Dict[str, str]:
|
||||
"""
|
||||
Поддерживает:
|
||||
Парсит переменные в форматах:
|
||||
- построчно:
|
||||
key=value
|
||||
key2="quoted value"
|
||||
@@ -35,17 +115,60 @@ class MessageParsers:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
if "\n" in text:
|
||||
out: Dict[str, str] = {}
|
||||
|
||||
try:
|
||||
if "\n" in text:
|
||||
# Построчный формат
|
||||
out: Dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip().strip('"\'')
|
||||
return out
|
||||
else:
|
||||
# Однострочный формат
|
||||
out: Dict[str, str] = {}
|
||||
for tok in shlex.split(text):
|
||||
if "=" in tok:
|
||||
k, v = tok.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Ошибка парсинга переменных: {e}")
|
||||
|
||||
@staticmethod
|
||||
def parse_keyboard(text: str) -> Optional[Dict]:
|
||||
"""
|
||||
Парсит клавиатуру в формате:
|
||||
текст1|url1
|
||||
текст2|url2
|
||||
|
||||
Возвращает:
|
||||
{
|
||||
"rows": [
|
||||
[{"text": "текст1", "url": "url1"}],
|
||||
[{"text": "текст2", "url": "url2"}]
|
||||
]
|
||||
}
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text or text.lower() == "skip":
|
||||
return None
|
||||
|
||||
try:
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
out[k.strip()] = v.strip().strip('"')
|
||||
return out
|
||||
|
||||
out: Dict[str, str] = {}
|
||||
for tok in shlex.split(text):
|
||||
if "=" in tok:
|
||||
k, v = tok.split("=", 1)
|
||||
out[k] = v
|
||||
line = line.strip()
|
||||
if "|" in line:
|
||||
text, url = line.split("|", 1)
|
||||
rows.append([{
|
||||
"text": text.strip(),
|
||||
"url": url.strip()
|
||||
}])
|
||||
return {"rows": rows} if rows else None
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Ошибка парсинга клавиатуры: {e}")
|
||||
return out
|
||||
|
||||
@@ -6,7 +6,7 @@ from telegram.ext import (
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
from .states import States
|
||||
from .states import BotStates as States
|
||||
from .session import SessionStore
|
||||
from .wizard import EditorWizard
|
||||
|
||||
|
||||
114
app/bots/editor/router.py
Normal file
114
app/bots/editor/router.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Маршрутизация команд бота."""
|
||||
from telegram.ext import (
|
||||
Application, CommandHandler, MessageHandler, CallbackQueryHandler,
|
||||
ConversationHandler, filters
|
||||
)
|
||||
|
||||
from .states import BotStates
|
||||
from .handlers.base import start, help_command, cancel
|
||||
from .handlers.templates import (
|
||||
start_template_creation,
|
||||
handle_template_type,
|
||||
handle_template_name,
|
||||
handle_template_text,
|
||||
handle_template_keyboard,
|
||||
list_templates
|
||||
)
|
||||
from .handlers.posts import (
|
||||
newpost,
|
||||
choose_channel,
|
||||
choose_type,
|
||||
choose_format,
|
||||
enter_text,
|
||||
choose_template_open,
|
||||
choose_template_apply,
|
||||
choose_template_preview,
|
||||
choose_template_navigate,
|
||||
choose_template_cancel,
|
||||
preview_collect_vars,
|
||||
preview_confirm,
|
||||
enter_media,
|
||||
edit_keyboard,
|
||||
confirm_send,
|
||||
enter_schedule
|
||||
)
|
||||
|
||||
def register_handlers(app: Application) -> None:
|
||||
"""Регистрация обработчиков команд."""
|
||||
# Базовые команды
|
||||
app.add_handler(CommandHandler("start", start))
|
||||
app.add_handler(CommandHandler("help", help_command))
|
||||
|
||||
# Шаблоны
|
||||
template_handler = ConversationHandler(
|
||||
entry_points=[CommandHandler("newtemplate", start_template_creation)],
|
||||
states={
|
||||
BotStates.TPL_TYPE: [
|
||||
CallbackQueryHandler(handle_template_type)
|
||||
],
|
||||
BotStates.TPL_NAME: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_name)
|
||||
],
|
||||
BotStates.TPL_TEXT: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_text)
|
||||
],
|
||||
BotStates.TPL_NEW_KB: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_keyboard)
|
||||
]
|
||||
},
|
||||
fallbacks=[CommandHandler("cancel", cancel)]
|
||||
)
|
||||
app.add_handler(template_handler)
|
||||
|
||||
# Создание поста
|
||||
post_handler = ConversationHandler(
|
||||
entry_points=[CommandHandler("newpost", newpost)],
|
||||
states={
|
||||
BotStates.CHOOSE_CHANNEL: [
|
||||
CallbackQueryHandler(choose_channel, pattern=r"^channel:")
|
||||
],
|
||||
BotStates.CHOOSE_TYPE: [
|
||||
CallbackQueryHandler(choose_type, pattern=r"^type:")
|
||||
],
|
||||
BotStates.CHOOSE_FORMAT: [
|
||||
CallbackQueryHandler(choose_format, pattern=r"^fmt:")
|
||||
],
|
||||
BotStates.ENTER_TEXT: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text),
|
||||
CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$")
|
||||
],
|
||||
BotStates.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$")
|
||||
],
|
||||
BotStates.PREVIEW_VARS: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars)
|
||||
],
|
||||
BotStates.PREVIEW_CONFIRM: [
|
||||
CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"),
|
||||
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$")
|
||||
],
|
||||
BotStates.ENTER_MEDIA: [
|
||||
MessageHandler(
|
||||
filters.PHOTO | filters.VIDEO | filters.ANIMATION & ~filters.COMMAND,
|
||||
enter_media
|
||||
)
|
||||
],
|
||||
BotStates.EDIT_KEYBOARD: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard)
|
||||
],
|
||||
BotStates.CONFIRM_SEND: [
|
||||
CallbackQueryHandler(confirm_send, pattern=r"^send:")
|
||||
],
|
||||
BotStates.ENTER_SCHEDULE: [
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule)
|
||||
]
|
||||
},
|
||||
fallbacks=[CommandHandler("cancel", cancel)]
|
||||
)
|
||||
app.add_handler(post_handler)
|
||||
|
||||
# Просмотр шаблонов
|
||||
app.add_handler(CommandHandler("templates", list_templates))
|
||||
@@ -1,47 +1,150 @@
|
||||
from __future__ import annotations
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, List
|
||||
from threading import Lock
|
||||
|
||||
from app.bots.editor.messages import MessageType
|
||||
from app.models.post import PostType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TTL = 60 * 60 # 1 час
|
||||
|
||||
# Тип сообщения, используемый в сессии
|
||||
SessionType = MessageType | PostType
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserSession:
|
||||
"""Сессия пользователя при создании поста."""
|
||||
|
||||
# Основные данные поста
|
||||
channel_id: Optional[int] = None
|
||||
type: Optional[str] = None # text/photo/video/animation
|
||||
parse_mode: Optional[str] = None # HTML/MarkdownV2
|
||||
type: Optional[SessionType] = None
|
||||
parse_mode: Optional[str] = None # HTML/MarkdownV2
|
||||
text: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]}
|
||||
keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]}
|
||||
|
||||
# Данные шаблона
|
||||
template_name: Optional[str] = None
|
||||
template_id: Optional[str] = None
|
||||
template_vars: Dict[str, str] = field(default_factory=dict)
|
||||
missing_vars: List[str] = field(default_factory=list)
|
||||
|
||||
# Метаданные отправки
|
||||
schedule_time: Optional[datetime] = None
|
||||
|
||||
def update(self, data: Dict[str, Any]) -> None:
|
||||
"""Обновляет поля сессии из словаря."""
|
||||
for key, value in data.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
# Метаданные
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
|
||||
state: Optional[int] = None
|
||||
|
||||
def touch(self) -> None:
|
||||
"""Обновляет время последней активности."""
|
||||
self.last_activity = time.time()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Очищает все данные сессии."""
|
||||
self.channel_id = None
|
||||
self.type = None
|
||||
self.parse_mode = None
|
||||
self.text = None
|
||||
self.media_file_id = None
|
||||
self.keyboard = None
|
||||
self.template_name = None
|
||||
self.template_vars.clear()
|
||||
self.missing_vars.clear()
|
||||
self.state = None
|
||||
self.touch()
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
"""Проверяет, заполнены ли все необходимые поля."""
|
||||
if not self.channel_id or not self.type:
|
||||
return False
|
||||
|
||||
if self.type == MessageType.TEXT:
|
||||
return bool(self.text)
|
||||
else:
|
||||
return bool(self.text and self.media_file_id)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Конвертирует сессию в словарь для отправки."""
|
||||
return {
|
||||
"type": self.type.value if self.type else None,
|
||||
"text": self.text,
|
||||
"media_file_id": self.media_file_id,
|
||||
"parse_mode": self.parse_mode or "HTML",
|
||||
"keyboard": self.keyboard,
|
||||
"template_id": self.template_id,
|
||||
"template_name": self.template_name,
|
||||
"template_vars": self.template_vars
|
||||
}
|
||||
|
||||
as_dict = to_dict
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Простое и быстрое in-memory хранилище с авто-очисткой."""
|
||||
|
||||
"""Thread-safe хранилище сессий с автоочисткой."""
|
||||
|
||||
_instance: Optional["SessionStore"] = None
|
||||
|
||||
def __init__(self, ttl: int = DEFAULT_TTL) -> None:
|
||||
self._data: Dict[int, UserSession] = {}
|
||||
self._ttl = ttl
|
||||
|
||||
self._lock = Lock()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "SessionStore":
|
||||
"""Возвращает глобальный экземпляр."""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def get(self, uid: int) -> UserSession:
|
||||
s = self._data.get(uid)
|
||||
if not s:
|
||||
s = UserSession()
|
||||
self._data[uid] = s
|
||||
s.touch()
|
||||
self._cleanup()
|
||||
return s
|
||||
|
||||
"""Получает или создает сессию пользователя."""
|
||||
with self._lock:
|
||||
s = self._data.get(uid)
|
||||
if not s:
|
||||
s = UserSession()
|
||||
self._data[uid] = s
|
||||
s.touch()
|
||||
self._cleanup()
|
||||
return s
|
||||
|
||||
def drop(self, uid: int) -> None:
|
||||
self._data.pop(uid, None)
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
now = time.time()
|
||||
for uid in list(self._data.keys()):
|
||||
if now - self._data[uid].last_activity > self._ttl:
|
||||
"""Удаляет сессию пользователя."""
|
||||
with self._lock:
|
||||
if uid in self._data:
|
||||
logger.info(f"Dropping session for user {uid}")
|
||||
del self._data[uid]
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
"""Удаляет истекшие сессии."""
|
||||
now = time.time()
|
||||
expired = []
|
||||
|
||||
for uid, session in self._data.items():
|
||||
if now - session.last_activity > self._ttl:
|
||||
expired.append(uid)
|
||||
|
||||
for uid in expired:
|
||||
logger.info(f"Session expired for user {uid}")
|
||||
del self._data[uid]
|
||||
|
||||
def get_active_count(self) -> int:
|
||||
"""Возвращает количество активных сессий."""
|
||||
return len(self._data)
|
||||
|
||||
|
||||
def get_session_store() -> SessionStore:
|
||||
"""Возвращает глобальный экземпляр хранилища сессий."""
|
||||
return SessionStore.get_instance()
|
||||
|
||||
@@ -1,23 +1,88 @@
|
||||
from __future__ import annotations
|
||||
from enum import IntEnum
|
||||
"""Состояния бота редактора."""
|
||||
from enum import IntEnum, auto
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class States(IntEnum):
|
||||
CHOOSE_CHANNEL = 0
|
||||
CHOOSE_TYPE = 1
|
||||
CHOOSE_FORMAT = 2
|
||||
ENTER_TEXT = 3
|
||||
ENTER_MEDIA = 4
|
||||
EDIT_KEYBOARD = 5
|
||||
CONFIRM_SEND = 6
|
||||
ENTER_SCHEDULE = 7
|
||||
|
||||
SELECT_TEMPLATE = 8
|
||||
PREVIEW_VARS = 9
|
||||
PREVIEW_CONFIRM = 10
|
||||
|
||||
TPL_NEW_NAME = 11
|
||||
TPL_NEW_TYPE = 12
|
||||
TPL_NEW_FORMAT = 13
|
||||
TPL_NEW_CONTENT = 14
|
||||
TPL_NEW_KB = 15
|
||||
class BotStates(IntEnum):
|
||||
"""Состояния для ConversationHandler."""
|
||||
|
||||
# Общие состояния
|
||||
CONVERSATION_END = -1
|
||||
START = 1
|
||||
MAIN_MENU = 2
|
||||
|
||||
# Состояния создания шаблона
|
||||
TPL_TYPE = 10
|
||||
TPL_NAME = 11
|
||||
TPL_TEXT = 12
|
||||
TPL_NEW_KB = 13
|
||||
TPL_SELECT = 14
|
||||
TPL_NEW_NAME = 15
|
||||
TPL_NEW_TYPE = 16
|
||||
TPL_NEW_FORMAT = 17
|
||||
TPL_NEW_CONTENT = 18
|
||||
TEMPLATE_PREVIEW = 19
|
||||
TEMPLATE_VARS = 20
|
||||
|
||||
# Состояния создания поста
|
||||
CREATE_POST = 30
|
||||
CHOOSE_CHANNEL = 31 # Выбор канала
|
||||
CHOOSE_TYPE = 32 # Выбор типа поста (текст/фото/видео/gif)
|
||||
CHOOSE_FORMAT = 33 # Выбор формата текста (HTML/Markdown)
|
||||
ENTER_TEXT = 34 # Ввод текста поста
|
||||
ENTER_MEDIA = 35 # Загрузка медиафайла
|
||||
EDIT_KEYBOARD = 36 # Редактирование клавиатуры
|
||||
CONFIRM_SEND = 37 # Подтверждение отправки
|
||||
ENTER_SCHEDULE = 38 # Ввод времени для отложенной публикации
|
||||
SELECT_TEMPLATE = 39 # Выбор шаблона
|
||||
PREVIEW_VARS = 40 # Ввод значений для переменных
|
||||
PREVIEW_CONFIRM = 41 # Подтверждение предпросмотра
|
||||
|
||||
# Состояния работы с каналами
|
||||
CHANNEL_NAME = 50
|
||||
CHANNEL_DESC = 51
|
||||
CHANNEL_INVITE = 52
|
||||
|
||||
# Состояния управления ботами и каналами
|
||||
BOT_TOKEN = 60 # Ввод токена бота
|
||||
CHANNEL_ID = 61 # Ввод идентификатора канала
|
||||
CHANNEL_TITLE = 62 # Ввод имени канала
|
||||
CHANNEL_SELECT_BOT = 63 # Выбор бота для канала
|
||||
|
||||
@classmethod
|
||||
def get_description(cls, state: int) -> str:
|
||||
"""Возвращает описание состояния."""
|
||||
descriptions: Dict[int, str] = {
|
||||
# Общие состояния
|
||||
cls.CONVERSATION_END: "Завершение диалога",
|
||||
|
||||
# Шаблоны
|
||||
cls.TPL_TYPE: "Выбор типа шаблона",
|
||||
cls.TPL_NAME: "Ввод имени шаблона",
|
||||
cls.TPL_TEXT: "Ввод текста шаблона",
|
||||
cls.TPL_NEW_KB: "Ввод клавиатуры шаблона",
|
||||
cls.TPL_SELECT: "Выбор шаблона",
|
||||
cls.TPL_NEW_CONTENT: "Ввод содержимого шаблона",
|
||||
|
||||
# Посты
|
||||
cls.CHOOSE_CHANNEL: "Выбор канала",
|
||||
cls.CHOOSE_TYPE: "Выбор типа поста",
|
||||
cls.CHOOSE_FORMAT: "Выбор формата",
|
||||
cls.ENTER_TEXT: "Ввод текста",
|
||||
cls.ENTER_MEDIA: "Загрузка медиа",
|
||||
cls.EDIT_KEYBOARD: "Редактирование клавиатуры",
|
||||
cls.CONFIRM_SEND: "Подтверждение отправки",
|
||||
cls.ENTER_SCHEDULE: "Планирование публикации",
|
||||
cls.SELECT_TEMPLATE: "Выбор шаблона",
|
||||
cls.PREVIEW_VARS: "Ввод значений переменных",
|
||||
cls.PREVIEW_CONFIRM: "Подтверждение предпросмотра",
|
||||
|
||||
# Каналы и боты
|
||||
cls.CHANNEL_NAME: "Ввод имени канала",
|
||||
cls.CHANNEL_DESC: "Ввод описания канала",
|
||||
cls.CHANNEL_INVITE: "Ввод инвайт-ссылки",
|
||||
cls.BOT_TOKEN: "Ввод токена бота",
|
||||
cls.CHANNEL_ID: "Ввод ID канала",
|
||||
cls.CHANNEL_TITLE: "Ввод названия канала",
|
||||
cls.CHANNEL_SELECT_BOT: "Выбор бота для канала",
|
||||
}
|
||||
return descriptions.get(state, f"Неизвестное состояние {state}")
|
||||
|
||||
49
app/bots/editor/template.py
Normal file
49
app/bots/editor/template.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Модуль для работы с шаблонами."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.db.session import async_session_maker
|
||||
from app.models.templates import Template
|
||||
|
||||
|
||||
async def render_template_by_name(
|
||||
name: str,
|
||||
template_vars: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Рендеринг шаблона по имени.
|
||||
|
||||
Args:
|
||||
name: Имя шаблона
|
||||
template_vars: Переменные для подстановки
|
||||
context: Дополнительный контекст
|
||||
|
||||
Returns:
|
||||
Отрендеренные данные для поста
|
||||
|
||||
Raises:
|
||||
ValueError: Если шаблон не найден
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
stmt = Template.__table__.select().where(Template.__table__.c.name == name)
|
||||
result = await session.execute(stmt)
|
||||
template = result.scalar_one_or_none()
|
||||
|
||||
if not template:
|
||||
raise ValueError(f"Шаблон {name} не найден")
|
||||
|
||||
text = template.content
|
||||
keyboard = template.keyboard_tpl
|
||||
|
||||
# Подстановка переменных
|
||||
for key, value in template_vars.items():
|
||||
text = text.replace(f"{{${key}}}", str(value))
|
||||
|
||||
# Подготовка данных для отправки
|
||||
return {
|
||||
"type": template.type,
|
||||
"text": text,
|
||||
"keyboard": keyboard,
|
||||
"parse_mode": template.parse_mode
|
||||
}
|
||||
24
app/bots/editor/templates.py
Normal file
24
app/bots/editor/templates.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Функции для работы с шаблонами."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.db.session import async_session_maker
|
||||
from app.models.templates import Template
|
||||
|
||||
|
||||
async def list_templates(owner_id: Optional[int] = None):
|
||||
"""Получение списка шаблонов."""
|
||||
async with async_session_maker() as session:
|
||||
stmt = select(Template)
|
||||
if owner_id:
|
||||
stmt = stmt.filter(Template.owner_id == owner_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def create_template(template_data: dict):
|
||||
"""Создание нового шаблона."""
|
||||
async with async_session_maker() as session:
|
||||
template = Template(**template_data)
|
||||
session.add(template)
|
||||
await session.commit()
|
||||
0
app/bots/editor/utils/__init__.py
Normal file
0
app/bots/editor/utils/__init__.py
Normal file
56
app/bots/editor/utils/parsers.py
Normal file
56
app/bots/editor/utils/parsers.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Утилиты для парсинга данных."""
|
||||
from typing import Dict, Any, List, Set
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def extract_variables(text: str) -> Set[str]:
|
||||
"""Извлекает переменные из шаблона."""
|
||||
if not text:
|
||||
return set()
|
||||
return set(re.findall(r'\{([^}]+)\}', text))
|
||||
|
||||
def validate_url(url: str) -> bool:
|
||||
"""Проверяет валидность URL."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
return bool(parsed.scheme and parsed.netloc)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def parse_key_value_lines(text: str) -> Dict[str, Any]:
|
||||
"""Парсинг клавиатуры из текста формата 'текст = ссылка'."""
|
||||
keyboard = {"rows": []}
|
||||
current_row = []
|
||||
|
||||
lines = text.strip().split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line == "---":
|
||||
if current_row:
|
||||
keyboard["rows"].append(current_row)
|
||||
current_row = []
|
||||
continue
|
||||
|
||||
parts = line.split('=', 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Неверный формат строки: {line}")
|
||||
|
||||
text, url = parts[0].strip(), parts[1].strip()
|
||||
|
||||
# Проверка URL
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise ValueError(f"Некорректный URL: {url}")
|
||||
except Exception:
|
||||
raise ValueError(f"Некорректный URL: {url}")
|
||||
|
||||
current_row.append({"text": text, "url": url})
|
||||
|
||||
if current_row:
|
||||
keyboard["rows"].append(current_row)
|
||||
|
||||
return keyboard
|
||||
9
app/bots/editor/utils/validation.py
Normal file
9
app/bots/editor/utils/validation.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Утилиты для валидации данных."""
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
def validate_template_name(name: str) -> bool:
|
||||
"""Проверка корректности имени шаблона."""
|
||||
if not name or len(name) > 50:
|
||||
return False
|
||||
return bool(re.match(r'^[\w\-]+$', name))
|
||||
@@ -1,27 +1,42 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from telegram import Update
|
||||
from telegram import Update, Message, InlineKeyboardMarkup, CallbackQuery
|
||||
from telegram.ext import CallbackContext
|
||||
from telegram.error import TelegramError
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
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.bot import Bot
|
||||
from app.models.templates import Template
|
||||
from app.models.user import User
|
||||
from .states import BotStates as States # Алиас для совместимости
|
||||
from app.services.template import list_templates, create_template
|
||||
from app.services.templates import (
|
||||
render_template_by_name, list_templates, count_templates,
|
||||
create_template, delete_template, required_variables_of_template,
|
||||
render_template_by_name, count_templates,
|
||||
required_variables_of_template, delete_template
|
||||
)
|
||||
from app.services.telegram import validate_bot_token
|
||||
from jinja2 import TemplateError
|
||||
|
||||
from .states import States
|
||||
from .session import SessionStore
|
||||
from .messages import MessageParsers
|
||||
from .session import SessionStore, UserSession
|
||||
from .messages import Messages, MessageParsers, MessageType
|
||||
from .keyboards import KbBuilder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEDIA_TYPE_MAP = {
|
||||
MessageType.PHOTO: "фото",
|
||||
MessageType.VIDEO: "видео",
|
||||
MessageType.ANIMATION: "GIF-анимацию"
|
||||
}
|
||||
|
||||
|
||||
# Заглушка для build_payload, если сервиса нет
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user