1791 lines
70 KiB
Python
1791 lines
70 KiB
Python
"""Главный модуль бота редактора."""
|
||
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 main():
|
||
"""Запуск бота."""
|
||
app = Application.builder().token(settings.editor_bot_token).build()
|
||
|
||
# Регистрация обработчиков
|
||
register_handlers(app)
|
||
|
||
# Запуск бота
|
||
await app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||
|
||
if __name__ == "__main__":
|
||
import asyncio
|
||
try:
|
||
asyncio.run(main())
|
||
except KeyboardInterrupt:
|
||
logger.info("Бот остановлен")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка: {e}", exc_info=True)
|
||
finally:
|
||
# Убедимся, что все циклы закрыты
|
||
loop = asyncio.get_event_loop()
|
||
if loop.is_running():
|
||
loop.stop()
|
||
if not loop.is_closed():
|
||
loop.close()
|
||
except Exception as e:
|
||
logger.error(f"Ошибка: {e}", exc_info=True)
|
||
|
||
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,
|
||
handle_post_template,
|
||
handle_post_channel,
|
||
handle_post_schedule
|
||
)
|
||
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,
|
||
"owner_id": user.id,
|
||
"parse_mode": session.parse_mode,
|
||
"keyboard_tpl": session.keyboard
|
||
}
|
||
|
||
try:
|
||
await create_template(template_data)
|
||
await message.reply_text("Шаблон успешно сохранен")
|
||
return ConversationHandler.END
|
||
except ValueError as e:
|
||
await message.reply_text(f"Ошибка создания шаблона: {e}")
|
||
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()
|