bot rafactor and bugfix

This commit is contained in:
2025-08-19 04:45:16 +09:00
parent 43dda889f8
commit a8d860ed87
31 changed files with 4396 additions and 613 deletions

1585
app/bots/editor/handlers.py Normal file

File diff suppressed because it is too large Load Diff

View File

View 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

View 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

View 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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))

View File

@@ -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()

View File

@@ -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}")

View 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
}

View 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()

View File

View 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

View 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))

View File

@@ -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: