Files
postbot/app/bots/editor_bot.py
2025-08-19 05:13:16 +09:00

1781 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Главный модуль бота редактора."""
import time
import shlex
import logging
from typing import cast, Optional, Dict, Any
from urllib.parse import urlparse
from datetime import datetime
import asyncio
from typing import Optional, Dict, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from telegram import Update, Message, CallbackQuery, User, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.ext import (
Application, CommandHandler, MessageHandler, CallbackQueryHandler,
ContextTypes, ConversationHandler, filters
)
from app.core.config import settings
from .editor.states import BotStates
from .editor.session import get_session_store, SessionStore
from .editor.router import register_handlers
from app.services.template import count_templates
# Определение типа Context
Context = ContextTypes.DEFAULT_TYPE
# Константы
DEFAULT_TTL = 3600 # 1 час
# Настройка логирования
logger = logging.getLogger(__name__)
async def run_bot():
"""Запуск бота."""
app = Application.builder().token(settings.editor_bot_token).build()
# Регистрация обработчиков
register_handlers(app)
# Запуск бота
await app.run_polling(allowed_updates=Update.ALL_TYPES)
def main():
"""Основная функция запуска."""
try:
asyncio.run(run_bot())
except KeyboardInterrupt:
logger.info("Бот остановлен")
except Exception as e:
logger.error(f"Ошибка: {e}", exc_info=True)
if __name__ == "__main__":
main()
from app.core.config import settings
from .editor.session import SessionStore
from .editor.handlers.templates import (
start_template_creation,
handle_template_type,
handle_template_name,
handle_template_text,
handle_template_keyboard,
list_templates
)
from .editor.handlers.posts import newpost
from .editor.states import BotStates
# Настройка логирования
logger = logging.getLogger(__name__)
# Константы
SESSION_TIMEOUT = 3600 # 1 час
async def cancel_handler(update: Update, context: Context) -> int:
"""Отмена текущей операции."""
message = cast(Message, update.message)
if not message or not message.from_user:
return BotStates.CONVERSATION_END
user_id = message.from_user.id
session_store = get_session_store()
session_store.drop(user_id)
await message.reply_text("Операция отменена")
return BotStates.CONVERSATION_END
def cleanup_old_sessions():
"""Очистка старых сессий."""
try:
now = time.time()
session_store = get_session_store()
for uid, session in list(session_store._data.items()):
if now - session.last_activity > DEFAULT_TTL:
del session_store._data[uid]
except Exception as e:
logger.error(f"Error cleaning up sessions: {e}")
Context = ContextTypes.DEFAULT_TYPE
PREVIEW_VARS = BotStates.TEMPLATE_VARS
# Convert state constants from BotStates
CONVERSATION_END = BotStates.CONVERSATION_END
START = BotStates.START
MAIN_MENU = BotStates.MAIN_MENU
SELECT_TEMPLATE = BotStates.SELECT_TEMPLATE
TEMPLATE_PREVIEW = BotStates.TEMPLATE_PREVIEW
TEMPLATE_VARS = BotStates.TEMPLATE_VARS
PREVIEW_CONFIRM = BotStates.PREVIEW_CONFIRM
TPL_NEW_NAME = BotStates.TPL_NEW_NAME
TPL_NEW_TYPE = BotStates.TPL_NEW_TYPE
TPL_NEW_FORMAT = BotStates.TPL_NEW_FORMAT
CREATE_POST = BotStates.CREATE_POST
CHOOSE_CHANNEL = BotStates.CHOOSE_CHANNEL
CHOOSE_TYPE = BotStates.CHOOSE_TYPE
CHOOSE_FORMAT = BotStates.CHOOSE_FORMAT
ENTER_TEXT = BotStates.ENTER_TEXT
ENTER_MEDIA = BotStates.ENTER_MEDIA
EDIT_KEYBOARD = BotStates.EDIT_KEYBOARD
CONFIRM_SEND = BotStates.CONFIRM_SEND
ENTER_SCHEDULE = BotStates.ENTER_SCHEDULE
BOT_TOKEN = BotStates.BOT_TOKEN
CHANNEL_ID = BotStates.CHANNEL_ID
CHANNEL_TITLE = BotStates.CHANNEL_TITLE
CHANNEL_SELECT_BOT = BotStates.CHANNEL_SELECT_BOT
TPL_NEW_CONTENT = BotStates.TPL_NEW_CONTENT
TPL_NEW_KB = BotStates.TPL_NEW_KB
from telegram.ext import (
filters,
CallbackContext
)
from telegram.error import TelegramError
from typing import List, Optional
from celery import shared_task
from sqlalchemy import select
from app.models.templates import Template
from app.models.channel import Channel
from app.models.post import PostType
from app.db.session import async_session_maker
from .editor.session import SessionStore, UserSession
from .editor.messages import MessageType
from .editor.router import register_handlers
from .editor.states import BotStates
from app.services.template import render_template_by_name, list_templates, create_template
# Константы пагинации
PAGE_SIZE = 5 # Количество элементов на странице
from typing import Optional
from app.core.config import settings
from app.tasks.celery_app import celery_app
from .editor.session import SessionStore
from .editor.router import register_handlers
from .editor.template import render_template_by_name
from .editor.states import BotStates
# Константы
PAGE_SIZE = 5 # Количество элементов на странице
MAX_MESSAGE_LENGTH = 4096
# Глобальное хранилище сессий
_session_store: Optional[SessionStore] = None
def get_session_store() -> SessionStore:
"""Получить глобальное хранилище сессий"""
global _session_store
if _session_store is None:
_session_store = SessionStore()
return _session_store
SESSION_TIMEOUT = 3600 # 1 час
ALLOWED_URL_SCHEMES = {'http', 'https'}
# Настройка логирования
logger = logging.getLogger(__name__)
def validate_message_length(text: str) -> bool:
"""Проверка длины сообщения согласно лимитам Telegram.
Args:
text: Текст для проверки
Returns:
bool: True если длина в пределах лимита
"""
return len(text) <= MAX_MESSAGE_LENGTH
def validate_url(url: str) -> bool:
"""Проверяет URL на допустимость.
Args:
url: URL для проверки
Returns:
bool: True если URL допустимый
"""
try:
result = urlparse(url)
return bool(result.scheme and result.netloc and result.scheme in ALLOWED_URL_SCHEMES)
except Exception:
return False
def parse_template_invocation(s: str) -> tuple[str, dict]:
"""Разбор строки вызова шаблона.
Args:
s: Строка в формате #template_name key1=value1 key2=value2
Returns:
tuple: (имя_шаблона, словарь_параметров)
Raises:
ValueError: Если неверный формат строки
"""
s = s.strip()
if not s.startswith("#"):
raise ValueError("Имя шаблона должно начинаться с #")
parts = shlex.split(s)
if not parts:
raise ValueError("Пустой шаблон")
name = parts[0][1:]
args: dict[str, str] = {}
for tok in parts[1:]:
if "=" in tok:
k, v = tok.split("=", 1)
args[k.strip()] = v.strip()
return name, args
def parse_key_value_lines(text: str) -> dict:
"""Парсинг строк формата key=value.
Args:
text: Строки в формате key=value или key="quoted value"
Returns:
dict: Словарь параметров
"""
text = (text or "").strip()
if not text:
return {}
out = {}
if "\n" in text:
for line in text.splitlines():
if "=" in line:
k, v = line.split("=", 1)
v = v.strip().strip('"')
if k.strip(): # Проверка на пустой ключ
out[k.strip()] = v
else:
try:
for tok in shlex.split(text):
if "=" in tok:
k, v = tok.split("=", 1)
if k.strip(): # Проверка на пустой ключ
out[k.strip()] = v
except ValueError as e:
logger.warning(f"Error parsing key-value line: {e}")
return out
async def choose_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Обработчик выбора типа поста."""
query = cast(CallbackQuery, update.callback_query)
if not query or not query.data:
return ConversationHandler.END
user_id = query.from_user.id
type_choice = query.data.split(":")[1]
session = get_session_store().get(user_id)
session.clear()
session.type = MessageType(type_choice)
session.touch()
await query.edit_message_text("Выберите формат сообщения")
return BotStates.CHOOSE_FORMAT
async def choose_format(update: Update, context: Context) -> int:
"""Обработчик выбора формата сообщения."""
query = cast(CallbackQuery, update.callback_query)
if not query or not query.data:
return ConversationHandler.END
user_id = query.from_user.id
format_choice = query.data.split(":")[1]
session = get_session_store().get(user_id)
session.parse_mode = format_choice
session.touch()
await query.edit_message_text("Введите текст сообщения")
return BotStates.ENTER_TEXT
async def enter_text(update: Update, context: Context) -> int:
"""Обработчик ввода текста сообщения."""
message = cast(Message, update.message)
if not message or not message.text:
return ConversationHandler.END
if not message.from_user:
return ConversationHandler.END
user_id = message.from_user.id
text = message.text
if not validate_message_length(text):
await message.reply_text("Слишком длинное сообщение")
return BotStates.ENTER_TEXT
session = get_session_store().get(user_id)
session.text = text
session.touch()
await message.reply_text("Текст сохранен. Введите ID медиафайла или пропустите")
return BotStates.ENTER_MEDIA
async def enter_media(update: Update, context: Context) -> int:
"""Обработчик ввода медиафайла."""
message = cast(Message, update.message)
if not message or not message.text:
return ConversationHandler.END
user = cast(User, message.from_user)
user_id = user.id
media_id = message.text
session = get_session_store().get(user_id)
session.media_file_id = media_id if media_id != "skip" else None
session.touch()
await message.reply_text("Введите клавиатуру или пропустите")
return BotStates.EDIT_KEYBOARD
async def edit_keyboard(update: Update, context: Context) -> int:
"""Обработчик редактирования клавиатуры."""
message = cast(Message, update.message)
if not message or not message.text:
return ConversationHandler.END
user = cast(User, message.from_user)
user_id = user.id
keyboard_text = message.text
session = get_session_store().get(user_id)
if keyboard_text != "skip":
try:
keyboard_data = parse_key_value_lines(keyboard_text) if keyboard_text else {}
session.keyboard = keyboard_data
session.touch()
except ValueError as e:
await message.reply_text(f"Ошибка разбора клавиатуры: {e}")
return BotStates.EDIT_KEYBOARD
await message.reply_text("Подтвердите отправку")
return BotStates.CONFIRM_SEND
async def confirm_send(update: Update, context: Context) -> int:
"""Обработчик подтверждения отправки."""
query = cast(CallbackQuery, update.callback_query)
if not query or not query.data or not query.message:
return ConversationHandler.END
user = update.effective_user
if not user:
await query.answer("Ошибка: пользователь не определен")
return ConversationHandler.END
try:
choice = query.data.split(":", 1)[1]
if choice == "yes":
session = get_session_store().get(user.id)
if not session:
await query.edit_message_text("Ошибка: сессия потеряна")
return ConversationHandler.END
if not session.is_complete():
await query.edit_message_text("Ошибка: не все поля заполнены")
return ConversationHandler.END
try:
payload = session.to_dict()
# Отправляем задачу через Celery
task = celery_app.send_task(
'app.tasks.senders.send_post_task',
args=[payload]
)
await query.edit_message_text(
f"Пост поставлен в очередь\nID задачи: {task.id}"
)
except ValueError as e:
await query.edit_message_text(f"Ошибка валидации: {e}")
return ConversationHandler.END
except Exception as e:
logger.error(f"Ошибка при отправке поста: {e}")
await query.edit_message_text("Произошла ошибка при отправке поста")
return ConversationHandler.END
else:
await query.edit_message_text("Отправка отменена")
# Очищаем сессию пользователя
if user := update.effective_user:
session = get_session_store().get(user.id)
if session:
session.clear()
return ConversationHandler.END
except Exception as e:
logger.error(f"Ошибка при подтверждении отправки: {e}")
await query.answer("Произошла ошибка")
return ConversationHandler.END
async def preview_collect_vars(update: Update, context: Context) -> int:
"""Сбор переменных для предпросмотра шаблона."""
message = cast(Message, update.message)
if not message or not message.text:
return ConversationHandler.END
if not message.from_user:
return ConversationHandler.END
user_id = message.from_user.id
vars_text = message.text
try:
template_vars = parse_key_value_lines(vars_text)
session = get_session_store().get(user_id)
session.template_vars = template_vars
session.touch()
await message.reply_text("Переменные сохранены. Подтвердите предпросмотр")
return BotStates.PREVIEW_CONFIRM
except ValueError as e:
await message.reply_text(f"Ошибка разбора переменных: {e}")
return BotStates.TEMPLATE_VARS
async def preview_confirm(update: Update, context: Context) -> int:
"""Подтверждение предпросмотра шаблона."""
query = cast(CallbackQuery, update.callback_query)
if not query or not query.data or not query.message:
return ConversationHandler.END
user_id = query.from_user.id
choice = query.data.split(":")[1]
session = get_session_store().get(user_id)
if not session:
await query.edit_message_text("Ошибка: сессия потеряна")
return ConversationHandler.END
if choice == "use":
try:
template_name = session.template_name
template_vars = session.template_vars
if not template_name:
raise ValueError("Имя шаблона не задано")
# Создаем контекст для шаблонизатора
template_context = {
"user_id": user_id,
"vars": template_vars,
"context": dict(context.user_data) if context.user_data else {}
}
# Рендерим и обновляем сессию
rendered = await render_template_by_name(template_name, template_vars, template_context)
session.update(rendered)
await query.edit_message_text("Шаблон применен. Проверьте параметры отправки")
return BotStates.CONFIRM_SEND
except Exception as e:
logger.error(f"Ошибка при применении шаблона: {e}")
await query.edit_message_text(f"Ошибка применения шаблона: {e}")
return ConversationHandler.END
else:
await query.edit_message_text("Редактирование отменено")
return ConversationHandler.END
def create_template_dict(data: dict, user_id: int) -> dict:
"""Создание словаря параметров для создания шаблона.
Args:
data: Исходные данные
user_id: ID пользователя
Returns:
dict: Подготовленные данные для создания шаблона
"""
template_data = {
"owner_id": user_id,
"name": data.get("name"),
"title": data.get("name"),
"content": data.get("content"),
"keyboard_tpl": data.get("keyboard"),
"type_": data.get("type"),
"parse_mode": data.get("format")
}
# Проверяем обязательные поля
required_fields = ["name", "content", "type_"]
missing_fields = [f for f in required_fields if not template_data.get(f)]
if missing_fields:
raise ValueError(f"Не хватает обязательных полей: {', '.join(missing_fields)}")
return template_data
async def tpl_new_start(update: Update, context: Context) -> int:
"""Начало создания нового шаблона."""
message = cast(Message, update.message)
if not message or not message.from_user:
return ConversationHandler.END
user_id = message.from_user.id
session = get_session_store().get(user_id)
session.clear()
await message.reply_text("Введите имя нового шаблона")
return BotStates.TPL_NEW_NAME
async def tpl_new_name(update: Update, context: Context) -> int:
"""Ввод имени нового шаблона."""
message = cast(Message, update.message)
if not message or not message.text or not message.from_user:
return ConversationHandler.END
user_id = message.from_user.id
name = message.text.strip()
if not name or " " in name:
await message.reply_text("Недопустимое имя шаблона")
return BotStates.TPL_NEW_NAME
session = get_session_store().get(user_id)
session.template_name = name
keyboard = []
for post_type in PostType:
button = InlineKeyboardButton(
text=post_type.value.capitalize(),
callback_data=f"type:{post_type.value}"
)
keyboard.append([button])
markup = InlineKeyboardMarkup(keyboard)
await message.reply_text("Выберите тип шаблона", reply_markup=markup)
return BotStates.TPL_NEW_TYPE
async def tpl_new_type(update: Update, context: Context) -> int:
"""Выбор типа нового шаблона."""
query = cast(CallbackQuery, update.callback_query)
if not query or not query.data:
return ConversationHandler.END
user = cast(User, query.from_user)
type_choice = query.data.split(":")[1]
session = get_session_store().get(user.id)
session.type = PostType(type_choice)
session.touch()
keyboard = [
[InlineKeyboardButton("HTML", callback_data="format:HTML")],
[InlineKeyboardButton("Markdown", callback_data="format:MarkdownV2")]
]
markup = InlineKeyboardMarkup(keyboard)
await query.edit_message_text("Выберите формат сообщения", reply_markup=markup)
return BotStates.TPL_NEW_FORMAT
async def tpl_new_format(update: Update, context: Context) -> int:
"""Выбор формата нового шаблона."""
query = cast(CallbackQuery, update.callback_query)
if not query or not query.data:
return ConversationHandler.END
user = cast(User, query.from_user)
format_choice = query.data.split(":")[1]
session = get_session_store().get(user.id)
session.parse_mode = format_choice
session.touch()
await query.edit_message_text(
"Теперь введите содержимое шаблона.\n\n"
f"Формат: {format_choice}\n"
"Поддерживаются переменные в формате {variable_name}"
)
session.touch()
await query.edit_message_text("Введите содержимое шаблона")
return BotStates.TPL_NEW_CONTENT
async def tpl_new_content(update: Update, context: Context) -> int:
"""Ввод содержимого нового шаблона."""
message = cast(Message, update.message)
if not message or not message.text or not message.from_user:
return ConversationHandler.END
user = cast(User, message.from_user)
content = message.text
if len(content) > 4096: # Максимальная длина сообщения в Telegram
await message.reply_text("Слишком длинный шаблон. Максимальная длина: 4096 символов")
return BotStates.TPL_NEW_CONTENT
session = get_session_store().get(user.id)
template_data = {
"name": session.template_name,
"title": session.template_name,
"content": content,
"type": session.type,
"tg_user_id": user.id, # Передаем telegram user id вместо owner_id
"parse_mode": session.parse_mode,
"keyboard_tpl": session.keyboard
}
try:
await create_template(template_data)
await message.reply_text("Шаблон успешно сохранен")
return ConversationHandler.END
except Exception as e:
logger.error(f"Ошибка создания шаблона: {e}", exc_info=True)
await message.reply_text(f"Ошибка создания шаблона. Пожалуйста, попробуйте позже.")
return BotStates.TPL_NEW_CONTENT
async def list_user_templates(update: Update, context: Context) -> None:
"""Вывод списка доступных шаблонов."""
message = cast(Message, update.message)
if not message or not message.from_user:
return None
try:
user_id = message.from_user.id
if context and hasattr(context, 'user_data'):
if not isinstance(context.user_data, dict):
context.user_data = {}
if context.user_data is not None:
context.user_data["tpl_page"] = 0
templates = await list_templates(owner_id=user_id)
if not templates:
await message.reply_text("Нет доступных шаблонов")
return None
text = "Доступные шаблоны:\n\n"
for tpl in templates:
# Используем явное приведение типа для доступа к атрибутам
tpl_name = getattr(tpl, 'name', 'Без имени')
tpl_type = getattr(tpl, 'type', 'Неизвестный тип')
text += f"{tpl_name} ({tpl_type})\n"
await message.reply_text(text)
except Exception as e:
logger.error(f"Ошибка при загрузке шаблонов: {e}")
await message.reply_text("Ошибка при загрузке списка шаблонов")
@shared_task
def send_post_async(data: dict) -> None:
"""Отправка поста из данных сессии в фоновом режиме.
Args:
data: Данные поста
"""
post_type = data.get("type")
if not post_type or not isinstance(post_type, str):
raise ValueError("Неверный тип поста")
payload = build_payload(
ptype=post_type,
text=data.get("text"),
media_file_id=data.get("media_id"),
parse_mode=data.get("format"),
keyboard=data.get("keyboard"),
)
celery_app.send_task('app.tasks.senders.send_post_task', args=[payload])
@shared_task(bind=True)
def send_post(self, data: dict) -> None:
"""Отправка поста из данных сессии.
Args:
data: Данные сессии
"""
try:
post_type = data.get("type")
if not post_type or not isinstance(post_type, str):
raise ValueError("Неверный тип поста")
payload = build_payload(
ptype=post_type,
text=data.get("text"),
media_file_id=data.get("media_id"),
parse_mode=data.get("format"),
keyboard=data.get("keyboard"),
)
celery_app.send_task('app.tasks.senders.send_post_task', args=[payload])
except Exception as e:
logger.error(f"Ошибка при отправке поста: {e}")
raise
async def create_template_with_data(data: dict, user_id: int) -> None:
"""Создание нового шаблона из данных сессии.
Args:
data: Данные сессии
user_id: ID пользователя
"""
name = data.get("name")
content = data.get("content")
keyboard = data.get("keyboard")
tpl_type = data.get("type")
parse_mode = data.get("format")
if not name or not content or not tpl_type:
raise ValueError("Не хватает обязательных данных для шаблона")
template_data = {
"owner_id": user_id,
"name": name,
"title": name,
"content": content,
"type": PostType(tpl_type),
"parse_mode": parse_mode or "HTML",
"keyboard_tpl": keyboard
}
await create_template(template_data)
async def handle_template_kb(update: Update, context: Context) -> int:
"""Обработка клавиатуры шаблона."""
message = cast(Message, update.message)
if not message or not message.text:
return ConversationHandler.END
user = cast(User, message.from_user)
user_id = user.id
kb_text = message.text
session_store = get_session_store()
data = cast(UserSession, session_store.get(user_id))
if not data:
await message.reply_text("Ошибка: сессия потеряна")
return ConversationHandler.END
if kb_text != "skip":
try:
keyboard = parse_key_value_lines(kb_text) if kb_text else {}
data.keyboard = keyboard
except ValueError as e:
await message.reply_text(f"Ошибка разбора клавиатуры: {e}")
return BotStates.TPL_NEW_KB
try:
if not data.template_name or not data.text:
await message.reply_text("Отсутствуют обязательные данные шаблона")
return BotStates.TPL_NEW_KB
template_data = {
"owner_id": user_id,
"name": data.template_name,
"title": data.template_name,
"content": data.text,
"keyboard_tpl": data.keyboard,
"type_": str(data.type) if data.type else "text",
"parse_mode": data.parse_mode if data.parse_mode else "html" # html по умолчанию
}
await create_template(template_data)
await message.reply_text("Шаблон создан успешно")
except ValueError as e:
await message.reply_text(f"Ошибка создания шаблона: {e}")
return TPL_NEW_KB
except Exception as e:
logger.error(f"Неожиданная ошибка при создании шаблона: {e}")
await message.reply_text("Произошла ошибка при создании шаблона")
return BotStates.TPL_NEW_KB
session_store = get_session_store()
session_store.drop(user_id)
return ConversationHandler.END
async def handle_template_pagination(update: Update, context: Context) -> int:
"""Обработка пагинации в списке шаблонов."""
if not update.callback_query or not update.effective_user:
return BotStates.ENTER_TEXT
query = cast(CallbackQuery, update.callback_query)
if not query or not isinstance(query.data, str):
return ConversationHandler.END
try:
parts = query.data.split(":", 1)
if len(parts) != 2:
await query.answer("Некорректный формат страницы")
return ConversationHandler.END
try:
page = max(0, int(parts[1]))
except ValueError:
await query.answer("Некорректный номер страницы")
return BotStates.CONVERSATION_END
except Exception as e:
logger.error(f"Error in handle_template_pagination: {e}")
return BotStates.CONVERSATION_END
return BotStates.CONVERSATION_END
user = update.effective_user
if not user or not user.id:
await query.answer("Ошибка: пользователь не определен")
return None
# Получаем шаблоны пользователя
templates = await list_templates(owner_id=user.id)
if not templates:
msg = cast(Optional[Message], query.message)
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("У вас нет шаблонов")
else:
await query.answer("У вас нет шаблонов")
return None
# Вычисляем диапазон для текущей страницы
start = page * PAGE_SIZE
end = start + PAGE_SIZE
page_templates = templates[start:end]
if not page_templates:
text = "Нет шаблонов на этой странице"
else:
text = "Доступные шаблоны:\n\n"
for tpl in page_templates:
tpl_name = getattr(tpl, 'name', 'Без имени')
tpl_type = getattr(tpl, 'type', 'Неизвестный тип')
text += f"{tpl_name} ({tpl_type})\n"
# Создаем кнопки навигации
keyboard: List[List[InlineKeyboardButton]] = [[]]
if page > 0:
keyboard[0].append(InlineKeyboardButton("⬅️", callback_data=f"page:{page-1}"))
if len(templates) > end:
keyboard[0].append(InlineKeyboardButton("➡️", callback_data=f"page:{page+1}"))
markup = InlineKeyboardMarkup(keyboard)
msg = cast(Optional[Message], query.message)
if not msg or not hasattr(msg, 'edit_text'):
await query.answer("Не удалось обновить сообщение")
return None
try:
await msg.edit_text(text=text, reply_markup=markup)
await query.answer()
except TelegramError as e:
logger.error(f"Ошибка при обновлении сообщения: {e}", exc_info=True)
await query.answer("Не удалось обновить список")
except Exception as e:
logger.error(f"Ошибка при обработке пагинации: {e}")
await query.answer("Произошла ошибка")
async def tpl_new_kb(update: Update, context: Context) -> int:
"""Ввод клавиатуры для нового шаблона."""
message = cast(Message, update.message)
if not message or not message.text:
return ConversationHandler.END
user = cast(User, message.from_user)
kb_text = message.text
session = get_session_store().get(user.id)
if not session.type or not session.template_name:
await message.reply_text("Ошибка: сессия потеряна")
return ConversationHandler.END
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("Шаблон успешно создан")
# Очищаем сессию после успешного создания
session_store = get_session_store()
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 tpl_list(update: Update, context: Context) -> BotStates:
"""Вывод списка доступных шаблонов."""
if not update.message:
return BotStates.CONVERSATION_END
try:
templates = await list_templates()
if not templates:
await update.message.reply_text("Нет доступных шаблонов")
return BotStates.CONVERSATION_END
text = "Доступные шаблоны:\n\n"
for tpl in templates:
text += f"{tpl.name} ({tpl.type})\n"
await update.message.reply_text(text)
return BotStates.MAIN_MENU
except Exception as e:
await update.message.reply_text(f"Ошибка загрузки шаблонов: {e}")
return BotStates.CONVERSATION_END
# -------- Команды верхнего уровня ---------
async def start(update: Update, context: Context) -> None:
"""Обработчик команды /start."""
if not update.effective_user or not update.message:
return
session = get_session_store().get(update.effective_user.id)
session.clear()
await update.message.reply_text(
"Привет! Я редактор. Команды:\n"
"/newpost — мастер поста\n"
"/tpl_new — создать шаблон\n"
"/tpl_list — список шаблонов"
)
async def newpost(update: Update, context: Context) -> int:
"""Начало создания нового поста."""
if not update.effective_user or not update.message:
return ConversationHandler.END
uid = update.effective_user.id
session = get_session_store().get(uid)
session.touch()
try:
async with async_session_maker() as s:
res = await s.execute(select(Channel).where(Channel.owner_id == uid).limit(50))
channels = list(res.scalars())
if not channels:
await update.message.reply_text(
"Пока нет каналов. Добавь через админку или команду /add_channel (в разработке)."
)
return ConversationHandler.END
kb = [
[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
for ch in channels
]
await update.message.reply_text(
"Выбери канал для публикации:",
reply_markup=InlineKeyboardMarkup(kb)
)
return BotStates.CHOOSE_CHANNEL
except Exception as e:
logger.error(f"Error in newpost: {e}")
await update.message.reply_text("Произошла ошибка. Попробуйте позже.")
return ConversationHandler.END
async def choose_channel(update: Update, context: Context) -> int:
"""Обработка выбора канала."""
if not update.callback_query or not update.effective_user:
return ConversationHandler.END
await update.callback_query.answer()
uid = update.effective_user.id
session = get_session_store().get(uid)
if session:
session.touch()
# Получаем необходимые объекты
query = cast(Optional[CallbackQuery], update.callback_query)
user = update.effective_user
if not query or not isinstance(query.data, str) or not user or not user.id:
return ConversationHandler.END
msg = cast(Optional[Message], query.message)
try:
# Парсим данные из callback
parts = query.data.split(":", 1)
if len(parts) != 2:
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка: неверный формат данных")
return ConversationHandler.END
# Проверяем корректность ID канала
try:
ch_id = int(parts[1])
except ValueError:
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка: некорректный ID канала")
return ConversationHandler.END
# Сохраняем в сессию
session = get_session_store().get(user.id)
session.channel_id = ch_id
# Создаем клавиатуру выбора типа поста
kb = [
[
InlineKeyboardButton("Текст", callback_data="type:text"),
InlineKeyboardButton("Фото", callback_data="type:photo")
],
[
InlineKeyboardButton("Видео", callback_data="type:video"),
InlineKeyboardButton("GIF", callback_data="type:animation")
],
]
# Отображаем сообщение с выбором типа
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text(
"Выберите тип поста:",
reply_markup=InlineKeyboardMarkup(kb)
)
return BotStates.CHOOSE_TYPE
except Exception as e:
logger.error(f"Ошибка при выборе канала: {e}", exc_info=True)
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Произошла ошибка. Попробуйте заново.")
return ConversationHandler.END
# ... [Остальные функции обновляются аналогично] ...
async def enter_schedule(update: Update, context: Context) -> int:
"""Обработка ввода времени для отложенной публикации."""
if not update.effective_user or not update.message or not update.message.text:
return ConversationHandler.END
uid = update.effective_user.id
session = get_session_store().get(uid)
if session:
session.touch()
try:
when = datetime.strptime(update.message.text.strip(), "%Y-%m-%d %H:%M")
if when < datetime.now():
await update.message.reply_text(
"Дата должна быть в будущем. Укажите корректное время в формате YYYY-MM-DD HH:MM"
)
return BotStates.ENTER_SCHEDULE
await _dispatch_with_eta(uid, when)
await update.message.reply_text("Задача запланирована.")
return ConversationHandler.END
except ValueError:
await update.message.reply_text(
"Неверный формат даты. Используйте формат YYYY-MM-DD HH:MM"
)
return BotStates.ENTER_SCHEDULE
except Exception as e:
logger.error(f"Error scheduling post: {e}")
await update.message.reply_text("Ошибка планирования. Попробуйте позже.")
return ConversationHandler.END
def build_payload(
ptype: str,
text: Optional[str] = None,
media_file_id: Optional[str] = None,
parse_mode: Optional[str] = None,
keyboard: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Построение данных для отправки поста."""
payload: Dict[str, Any] = {
"type": str(ptype),
"text": text if text is not None else "",
"parse_mode": str(parse_mode) if parse_mode is not None else "html",
"keyboard": keyboard if keyboard is not None else {},
}
if media_file_id:
payload["media_file_id"] = media_file_id
return payload
async def _dispatch_with_eta(uid: int, when: datetime) -> None:
"""Отправка отложенного поста."""
session_store = get_session_store()
data = session_store.get(uid)
if not data:
raise ValueError("Сессия потеряна")
token = settings.editor_bot_token
try:
payload = build_payload(
ptype=str(data.type) if data.type else "text",
text=data.text,
media_file_id=data.media_file_id,
parse_mode=data.parse_mode or "HTML",
keyboard=data.keyboard,
)
# Проверка длины сообщения
if not validate_message_length(payload.get("text", "")):
raise ValueError("Превышен максимальный размер сообщения")
# Проверка URL в клавиатуре
if keyboard := payload.get("keyboard"):
for row in keyboard.get("rows", []):
for btn in row:
if "url" in btn and not validate_url(btn["url"]):
raise ValueError(f"Небезопасный URL: {btn['url']}")
celery_app.send_task(
'app.tasks.senders.send_post_task',
args=[token, data.channel_id, payload],
eta=when
)
except Exception as e:
logger.error(f"Error in _dispatch_with_eta: {e}")
raise
def init_application():
"""Инициализация приложения и регистрация обработчиков."""
# Настройка логирования
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO
)
# Инициализация бота
app = Application.builder().token(settings.editor_bot_token).build()
# Создание обработчика постов
post_conv = 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.TEMPLATE_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.TEXT & ~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("start", start)],
)
# Создание обработчика шаблонов
tpl_conv = ConversationHandler(
entry_points=[CommandHandler("tpl_new", tpl_new_start)],
states={
BotStates.TPL_NEW_NAME: [
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name)
],
BotStates.TPL_NEW_TYPE: [
CallbackQueryHandler(tpl_new_type, pattern=r"^type:")
],
BotStates.TPL_NEW_FORMAT: [
CallbackQueryHandler(tpl_new_format, pattern=r"^format:")
],
BotStates.TPL_NEW_CONTENT: [
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content)
],
BotStates.TPL_NEW_KB: [
MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb)
],
},
fallbacks=[CommandHandler("cancel", cancel_handler)],
)
# Регистрация всех обработчиков
app.add_handler(CommandHandler("start", start))
app.add_handler(post_conv)
app.add_handler(tpl_conv)
app.add_handler(CommandHandler("tpl_list", tpl_list))
return app
# -------- Вспомогательные функции для шаблонов ---------
async def _render_tpl_list_message(message: Message, uid: int, page: int) -> int:
"""Отображение списка шаблонов с пагинацией через сообщение."""
try:
total = await count_templates(uid)
offset = page * PAGE_SIZE
tpls = await list_templates(uid, limit=PAGE_SIZE, offset=offset)
if not tpls:
if page == 0:
await message.reply_text("Шаблонов пока нет. Создай через /tpl_new.")
return BotStates.ENTER_TEXT
else:
return await _render_tpl_list_message(message, uid, 0)
kb = []
for t in tpls:
kb.append([
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
])
nav = []
if page > 0:
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
if offset + PAGE_SIZE < total:
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
if nav:
kb.append(nav)
text = "📜 Список шаблонов:\n\n"
if total > PAGE_SIZE:
text += f"(Страница {page + 1})"
await message.reply_text(text, reply_markup=InlineKeyboardMarkup(kb))
return ENTER_TEXT
except Exception as e:
logger.error(f"Error rendering template list for message: {e}")
await message.reply_text("Ошибка при загрузке списка шаблонов")
return BotStates.ENTER_TEXT
async def _render_tpl_list_query(query: CallbackQuery, uid: int, page: int) -> int:
"""Отображение списка шаблонов с пагинацией через callback query."""
try:
total = await count_templates(uid)
offset = page * PAGE_SIZE
tpls = await list_templates(uid, limit=PAGE_SIZE, offset=offset)
if not tpls:
if page == 0:
await query.edit_message_text("Шаблонов пока нет. Создай через /tpl_new.")
return BotStates.ENTER_TEXT
else:
return await _render_tpl_list_query(query, uid, 0)
kb = []
for t in tpls:
kb.append([
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
])
nav = []
if page > 0:
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
if offset + PAGE_SIZE < total:
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
if nav:
kb.append(nav)
text = "📜 Список шаблонов:\n\n"
if total > PAGE_SIZE:
text += f"(Страница {page + 1})"
await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(kb))
return ENTER_TEXT
except Exception as e:
logger.error(f"Error rendering template list for query: {e}")
await query.edit_message_text("Ошибка при загрузке списка шаблонов")
return BotStates.ENTER_TEXT
async def _render_tpl_list(update: Update, uid: int, page: int) -> int:
"""Отображение списка шаблонов с пагинацией."""
try:
offset = page * PAGE_SIZE
total = await count_templates(uid)
tpls = await list_templates(uid, offset=offset, limit=PAGE_SIZE)
kb = []
for t in tpls:
kb.append([
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
])
nav = []
if page > 0:
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
if offset + PAGE_SIZE < total:
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
if nav:
kb.append(nav)
kb.append([InlineKeyboardButton("Отмена", callback_data="tpl:cancel")])
text = f"Шаблоны (стр. {page+1}/{(total-1)//PAGE_SIZE + 1}):"
msg = update.effective_message
if isinstance(msg, Message):
if isinstance(update.callback_query, CallbackQuery):
await msg.edit_text(text, reply_markup=InlineKeyboardMarkup(kb))
else:
await msg.reply_text(text, reply_markup=InlineKeyboardMarkup(kb))
return BotStates.SELECT_TEMPLATE
except Exception as e:
logger.error(f"Error rendering template list: {e}")
if update.effective_message:
await update.effective_message.reply_text("Ошибка при загрузке списка шаблонов")
return BotStates.ENTER_TEXT
async def _apply_template_and_confirm_message(message: Message, uid: int, name: str, ctx_vars: dict) -> int:
"""Применение шаблона к текущему посту через сообщение."""
try:
rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid})
session = get_session_store().get(uid)
session.type = rendered["type"]
session.text = rendered["text"]
session.keyboard = rendered.get("keyboard")
session.parse_mode = rendered.get("parse_mode", "HTML")
session.touch()
kb = [
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")]
]
markup = InlineKeyboardMarkup(kb)
await message.reply_text(
"Шаблон применён. Как публикуем?",
reply_markup=markup
)
return BotStates.CONFIRM_SEND
except Exception as e:
logger.error(f"Error applying template via message: {e}")
await message.reply_text("Ошибка при применении шаблона")
return ConversationHandler.END
async def _apply_template_and_confirm_query(query: CallbackQuery, uid: int, name: str, ctx_vars: dict) -> int:
"""Применение шаблона к текущему посту через callback query."""
try:
rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid})
session = get_session_store().get(uid)
session.type = rendered["type"]
session.text = rendered["text"]
session.keyboard = rendered.get("keyboard")
session.parse_mode = rendered.get("parse_mode", "HTML")
session.touch()
kb = [
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")]
]
markup = InlineKeyboardMarkup(kb)
await query.edit_message_text(
"Шаблон применён. Как публикуем?",
reply_markup=markup
)
return BotStates.CONFIRM_SEND
except Exception as e:
logger.error(f"Error applying template via query: {e}")
await query.edit_message_text("Ошибка при применении шаблона")
return ConversationHandler.END
async def _apply_template_and_confirm(update: Update, uid: int, name: str, ctx_vars: dict) -> int:
"""Применение шаблона к текущему посту."""
try:
if update.callback_query:
return await _apply_template_and_confirm_query(update.callback_query, uid, name, ctx_vars)
elif update.message:
return await _apply_template_and_confirm_message(update.message, uid, name, ctx_vars)
else:
logger.error("Neither callback_query nor message found in update")
return ConversationHandler.END
except Exception as e:
logger.error(f"Error in _apply_template_and_confirm: {e}")
return ConversationHandler.END
async def _render_preview_and_confirm_message(message: Message, uid: int, name: str, ctx_vars: dict) -> int:
"""Рендеринг предпросмотра шаблона через сообщение."""
try:
rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid})
text = rendered["text"]
user_session = get_session_store().get(uid)
parse_mode = rendered.get("parse_mode") or "HTML"
preview_text = f"Предпросмотр:\n\n{text[:3500]}"
await message.reply_text(preview_text, parse_mode=parse_mode)
kb = [
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")]
]
markup = InlineKeyboardMarkup(kb)
await message.reply_text("Что дальше?", reply_markup=markup)
return BotStates.PREVIEW_CONFIRM
except Exception as e:
logger.error(f"Error in preview render via message: {e}")
await message.reply_text("Ошибка при рендеринге шаблона")
return ConversationHandler.END
async def _render_preview_and_confirm_query(query: CallbackQuery, uid: int, name: str, ctx_vars: dict) -> int:
"""Рендеринг предпросмотра шаблона через callback query."""
try:
rendered = await render_template_by_name(name=name, template_vars=ctx_vars, context={"user_id": uid})
text = rendered["text"]
session_store = get_session_store()
user_session = session_store.get(uid)
parse_mode = rendered.get("parse_mode") or (user_session.parse_mode if user_session else None) or "HTML"
preview_text = f"Предпросмотр:\n\n{text[:3500]}"
await query.edit_message_text(preview_text, parse_mode=parse_mode)
kb = [
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
[InlineKeyboardButton("Отмена", callback_data="tpl:cancel")]
]
markup = InlineKeyboardMarkup(kb)
if query.message:
message = query.message
if isinstance(message, Message):
await message.edit_text("Что дальше?", reply_markup=markup)
return BotStates.PREVIEW_CONFIRM
except Exception as e:
logger.error(f"Error in preview render via query: {e}")
await query.edit_message_text("Ошибка при рендеринге шаблона")
return ConversationHandler.END
async def _render_preview_and_confirm(update: Update, uid: int, name: str, ctx_vars: dict) -> int:
"""Рендеринг предпросмотра шаблона."""
try:
if update.callback_query:
return await _render_preview_and_confirm_query(update.callback_query, uid, name, ctx_vars)
elif update.message:
return await _render_preview_and_confirm_message(update.message, uid, name, ctx_vars)
else:
logger.error("Neither callback_query nor message found in update")
return ConversationHandler.END
except Exception as e:
logger.error(f"Error in _render_preview_and_confirm: {e}")
return ConversationHandler.END
# -------- Обработчики шаблонов ---------
async def choose_template_open(update: Update, context: Context) -> int:
"""Открытие списка шаблонов."""
query = cast(Optional[CallbackQuery], update.callback_query)
if not query:
return ConversationHandler.END
await query.answer()
user = update.effective_user
if not user or not user.id:
msg = cast(Optional[Message], query.message)
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка: пользователь не определен")
return ConversationHandler.END
if hasattr(context, 'user_data') and context.user_data is not None:
context.user_data["tpl_page"] = 0
return await _render_tpl_list(update, user.id, page=0)
async def choose_template_navigate(update: Update, context: Context) -> int:
"""Навигация по списку шаблонов."""
query = cast(Optional[CallbackQuery], update.callback_query)
user = update.effective_user
if not query or not query.data or not user or not user.id:
return ConversationHandler.END
try:
await query.answer()
msg = cast(Optional[Message], query.message)
# Получаем номер страницы из callback data
parts = query.data.split(":", 1)
if len(parts) != 2:
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка: неверный формат данных")
return ConversationHandler.END
try:
page = max(0, int(parts[1]))
except ValueError:
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка: некорректный номер страницы")
return ConversationHandler.END
if context and context.user_data is not None:
context.user_data["tpl_page"] = page
return await _render_tpl_list(update, user.id, page)
except Exception as e:
logger.error(f"Ошибка при навигации по шаблонам: {e}", exc_info=True)
msg = cast(Optional[Message], query.message)
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Произошла ошибка при навигации")
return ConversationHandler.END
page = int(page_s)
context.user_data["tpl_page"] = page
return await _render_tpl_list(q, uid, page)
async def choose_template_apply(update: Update, context: Context) -> int:
"""Применение выбранного шаблона."""
query = cast(Optional[CallbackQuery], update.callback_query)
user = update.effective_user
if not query or not isinstance(query.data, str) or not user or not user.id:
return ConversationHandler.END
try:
await query.answer()
msg = cast(Optional[Message], query.message)
parts = query.data.split(":", 1)
if len(parts) != 2:
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка: неверный формат данных")
return ConversationHandler.END
name = parts[1]
# Получаем шаблон и проверяем переменные
tpl = await render_template_by_name(name=name, template_vars={}, context={"user_id": user.id})
if not tpl:
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка: шаблон не найден")
return ConversationHandler.END
required = set(tpl.get("_required", []))
# Сохраняем данные в контекст
if context and context.user_data is not None:
context.user_data["preview"] = {
"name": name,
"provided": {},
"missing": list(required)
}
# Если есть обязательные переменные, запрашиваем их заполнение
if required:
if msg and hasattr(msg, 'edit_text'):
next_var = list(required)[0]
await msg.edit_text(
f"Для этого шаблона требуются параметры:\n"
f"{', '.join(sorted(required))}\n\n"
f"Пожалуйста, введите значение для параметра {next_var}:"
)
return BotStates.TEMPLATE_VARS
# Если переменных нет, применяем шаблон сразу
return await _apply_template_and_confirm(update, user.id, name, {})
except Exception as e:
logger.error(f"Ошибка при применении шаблона: {e}", exc_info=True)
msg = cast(Optional[Message], query.message if query else None)
if msg and hasattr(msg, 'edit_text'):
await msg.edit_text("Ошибка при применении шаблона")
return ConversationHandler.END
async def choose_template_preview(update: Update, context: Context) -> int:
"""Предпросмотр шаблона."""
if (not update.callback_query or not update.effective_user or not context.user_data
or not update.callback_query.data):
return ConversationHandler.END
await update.callback_query.answer()
uid = update.effective_user.id
try:
name = update.callback_query.data.split(":")[1]
tpl = await render_template_by_name(name=name, template_vars={}, context={"user_id": uid})
required = set(tpl.get("_required", []))
context.user_data["preview"] = {
"name": name,
"provided": {},
"missing": list(required)
}
if required:
await update.callback_query.edit_message_text(
"Для предпросмотра нужны переменные: " + ", ".join(sorted(required)) +
"\nПришли значения в формате key=value (по строкам или в одну строку)."
)
return PREVIEW_VARS
return await _render_preview_and_confirm(update, uid, name, {})
except Exception as e:
logger.error(f"Error previewing template: {e}")
await update.callback_query.edit_message_text("Ошибка при предпросмотре шаблона")
return ConversationHandler.END
async def choose_template_cancel(update: Update, context: Context) -> int:
"""Отмена выбора шаблона."""
if not update.callback_query:
return ConversationHandler.END
await update.callback_query.answer()
await update.callback_query.edit_message_text(
"Отправь текст сообщения или введи #имя для шаблона."
)
return ENTER_TEXT
import asyncio
import signal
from contextlib import suppress
class BotApplication:
"""Класс для управления жизненным циклом бота и планировщика."""
def __init__(self):
self.app = None
self.scheduler = None
self.loop = None
self._shutdown = False
def signal_handler(self, signum, frame):
"""Обработчик сигналов для корректного завершения."""
self._shutdown = True
if self.loop:
self.loop.stop()
async def shutdown(self):
"""Корректное завершение работы всех компонентов."""
if self.scheduler:
with suppress(Exception):
self.scheduler.shutdown()
if self.app:
with suppress(Exception):
await self.app.stop()
with suppress(Exception):
await self.app.shutdown()
async def run_bot(self):
"""Запуск бота и планировщика."""
try:
# Инициализация приложения
self.app = init_application()
if not self.app:
logger.critical("Failed to initialize application")
return
# Настройка планировщика
self.scheduler = AsyncIOScheduler()
self.scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30)
# Инициализация приложения
await self.app.initialize()
await self.app.start()
# Запуск планировщика
self.scheduler.start()
# Запуск бота
while not self._shutdown:
try:
if self.app and self.app.updater:
await self.app.updater.start_polling()
else:
logger.error("Application or updater is not initialized")
await asyncio.sleep(1)
except Exception as e:
if not self._shutdown:
logger.error(f"Polling error: {e}")
await asyncio.sleep(1)
else:
break
except Exception as e:
logger.critical(f"Critical error: {e}")
finally:
await self.shutdown()
async def init_bot():
"""Инициализация и настройка бота."""
try:
application = init_application()
if not application:
logger.critical("Не удалось инициализировать приложение")
return None
return application
except Exception as e:
logger.critical(f"Ошибка при инициализации бота: {e}")
return None
def start_bot():
"""Запуск бота с правильным управлением циклом событий."""
loop = None
try:
# Создаем и настраиваем цикл событий
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Инициализируем бота
application = loop.run_until_complete(init_bot())
if not application:
return
# Запускаем бота с правильным управлением циклом событий
from app.bots.bot_runner import run_bot
run_bot(application, cleanup_old_sessions)
except Exception as e:
logger.critical(f"Критическая ошибка при запуске бота: {e}")
raise
finally:
if loop:
try:
loop.close()
except Exception as e:
logger.error(f"Ошибка при закрытии цикла событий: {e}")
try:
# Создаем и настраиваем цикл событий
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Инициализируем бота
application = loop.run_until_complete(init_bot())
if not application:
return
# Запускаем бота с правильным управлением циклом событий
from app.bots.bot_runner import run_bot
run_bot(application, cleanup_old_sessions)
except Exception as e:
logger.critical(f"Критическая ошибка при запуске бота: {e}")
raise
finally:
if loop:
try:
loop.close()
except Exception as e:
logger.error(f"Ошибка при закрытии цикла событий: {e}")
if __name__ == "__main__":
start_bot()