bot rafactor and bugfix
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
# format, relative to the token %(here)s which refers to the location of this
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
# ini file
|
# ini file
|
||||||
script_location = %(here)s/migrations
|
script_location = migrations
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
|||||||
@@ -1,77 +1,3 @@
|
|||||||
# # app/api/routes/templates.py
|
|
||||||
# from __future__ import annotations
|
|
||||||
# from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
# from sqlalchemy import select, or_
|
|
||||||
# from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
# from app.db.session import get_async_session
|
|
||||||
# from app.models.templates import Template
|
|
||||||
# from app.api.schemas.template import TemplateIn, TemplateOut
|
|
||||||
# from app.services.templates import count_templates
|
|
||||||
|
|
||||||
# router = APIRouter(prefix="/templates", tags=["templates"])
|
|
||||||
|
|
||||||
# # Заглушка аутентификации
|
|
||||||
# async def get_owner_id(x_user_id: int | None = None):
|
|
||||||
# return x_user_id or 0
|
|
||||||
|
|
||||||
# @router.post("/", response_model=TemplateOut)
|
|
||||||
# async def create_tpl(
|
|
||||||
# data: TemplateIn,
|
|
||||||
# owner_id: int = Depends(get_owner_id),
|
|
||||||
# s: AsyncSession = Depends(get_async_session),
|
|
||||||
# ):
|
|
||||||
# tpl = Template(owner_id=owner_id, **data.model_dump())
|
|
||||||
# s.add(tpl)
|
|
||||||
# await s.commit()
|
|
||||||
# await s.refresh(tpl)
|
|
||||||
# return tpl
|
|
||||||
|
|
||||||
# @router.get("/", response_model=list[TemplateOut])
|
|
||||||
# async def list_tpls(
|
|
||||||
# owner_id: int = Depends(get_owner_id),
|
|
||||||
# limit: int = Query(20, ge=1, le=100),
|
|
||||||
# offset: int = Query(0, ge=0),
|
|
||||||
# q: str | None = Query(default=None),
|
|
||||||
# s: AsyncSession = Depends(get_async_session),
|
|
||||||
# ):
|
|
||||||
# stmt = select(Template).where(
|
|
||||||
# Template.owner_id == owner_id,
|
|
||||||
# Template.is_archived.is_(False),
|
|
||||||
# )
|
|
||||||
# if q:
|
|
||||||
# like = f"%{q}%"
|
|
||||||
# stmt = stmt.where(or_(Template.name.ilike(like), Template.title.ilike(like)))
|
|
||||||
# stmt = stmt.order_by(Template.updated_at.desc()).limit(limit).offset(offset)
|
|
||||||
# res = await s.execute(stmt)
|
|
||||||
# return list(res.scalars())
|
|
||||||
|
|
||||||
# @router.get("/count")
|
|
||||||
# async def count_tpls(
|
|
||||||
# owner_id: int = Depends(get_owner_id),
|
|
||||||
# q: str | None = None,
|
|
||||||
# ):
|
|
||||||
# total = await count_templates(owner_id, q)
|
|
||||||
# return {"total": total}
|
|
||||||
|
|
||||||
# @router.delete("/{tpl_id}")
|
|
||||||
# async def delete_tpl(
|
|
||||||
# tpl_id: int,
|
|
||||||
# owner_id: int = Depends(get_owner_id),
|
|
||||||
# s: AsyncSession = Depends(get_async_session),
|
|
||||||
# ):
|
|
||||||
# res = await s.execute(select(Template).where(
|
|
||||||
# Template.id == tpl_id,
|
|
||||||
# Template.owner_id == owner_id
|
|
||||||
# ))
|
|
||||||
# tpl = res.scalars().first()
|
|
||||||
# if not tpl:
|
|
||||||
# raise HTTPException(404, "not found")
|
|
||||||
# await s.delete(tpl)
|
|
||||||
# await s.commit()
|
|
||||||
# return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# app/api/routes/templates.py
|
# app/api/routes/templates.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|||||||
1585
app/bots/editor/handlers.py
Normal file
1585
app/bots/editor/handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
0
app/bots/editor/handlers/__init__.py
Normal file
0
app/bots/editor/handlers/__init__.py
Normal file
43
app/bots/editor/handlers/base.py
Normal file
43
app/bots/editor/handlers/base.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Базовые обработчики."""
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import ContextTypes, ConversationHandler
|
||||||
|
|
||||||
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработчик команды /start."""
|
||||||
|
if not update.message:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Привет! Я бот для управления постами.\n"
|
||||||
|
"Для создания шаблона используйте /newtemplate\n"
|
||||||
|
"Для создания поста используйте /newpost\n"
|
||||||
|
"Для просмотра шаблонов /templates\n"
|
||||||
|
"Для помощи /help"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработчик команды /help."""
|
||||||
|
if not update.message:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Доступные команды:\n"
|
||||||
|
"/start - начать работу с ботом\n"
|
||||||
|
"/newtemplate - создать новый шаблон\n"
|
||||||
|
"/templates - просмотреть существующие шаблоны\n"
|
||||||
|
"/newpost - создать новый пост\n"
|
||||||
|
"/cancel - отменить текущую операцию"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Отмена текущей операции."""
|
||||||
|
if not update.message:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Операция отменена.",
|
||||||
|
reply_markup=None
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
744
app/bots/editor/handlers/posts.py
Normal file
744
app/bots/editor/handlers/posts.py
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
"""Обработчики для работы с постами."""
|
||||||
|
from datetime import datetime
|
||||||
|
from logging import getLogger
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any, Optional, cast, Union
|
||||||
|
|
||||||
|
from telegram import (
|
||||||
|
Update,
|
||||||
|
Message,
|
||||||
|
CallbackQuery,
|
||||||
|
ReplyKeyboardMarkup,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
InlineKeyboardButton
|
||||||
|
)
|
||||||
|
from telegram.ext import (
|
||||||
|
ContextTypes,
|
||||||
|
ConversationHandler
|
||||||
|
)
|
||||||
|
from telegram.helpers import escape_markdown
|
||||||
|
from telegram.constants import ChatAction, ParseMode
|
||||||
|
from telegram.error import BadRequest, Forbidden, TelegramError
|
||||||
|
|
||||||
|
from ..session import UserSession, SessionStore
|
||||||
|
from ..states import BotStates
|
||||||
|
from ..keyboards import KbBuilder
|
||||||
|
from app.models.post import Post, PostType
|
||||||
|
from app.services.template import TemplateService
|
||||||
|
from app.services.channels import ChannelService
|
||||||
|
from app.services.telegram import PostService
|
||||||
|
from ..messages import MessageType
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
def parse_key_value_lines(text: str) -> Dict[str, str]:
|
||||||
|
"""Парсит строки в формате 'ключ = значение' в словарь."""
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for line in text.split('\n'):
|
||||||
|
if '=' not in line:
|
||||||
|
continue
|
||||||
|
key, value = map(str.strip, line.split('=', 1))
|
||||||
|
if key:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
async def newpost(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Начало создания нового поста."""
|
||||||
|
message = update.effective_message
|
||||||
|
user = update.effective_user
|
||||||
|
|
||||||
|
if not message or not user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем новую сессию
|
||||||
|
session = SessionStore.get_instance().get(user.id)
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
# Загружаем список каналов пользователя
|
||||||
|
channels = await ChannelService.get_user_channels(user.id)
|
||||||
|
if not channels:
|
||||||
|
await message.reply_text(
|
||||||
|
"У вас нет добавленных каналов. Используйте /add_channel чтобы добавить."
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
kb = KbBuilder.channels(channels)
|
||||||
|
await message.reply_text(
|
||||||
|
"Выберите канал для публикации:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.CHOOSE_CHANNEL
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in newpost: {e}")
|
||||||
|
await message.reply_text("Произошла ошибка при создании поста")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_channel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработка выбора канала."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
await message.edit_text("Неверный формат данных")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
channel_id = int(query.data.replace("channel:", ""))
|
||||||
|
|
||||||
|
# Проверяем существование канала
|
||||||
|
channel = await ChannelService.get_channel(channel_id)
|
||||||
|
if not channel:
|
||||||
|
await message.edit_text("Канал не найден")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
session = SessionStore.get_instance().get(query.from_user.id)
|
||||||
|
session.channel_id = channel_id
|
||||||
|
|
||||||
|
kb = KbBuilder.post_types()
|
||||||
|
await message.edit_text(
|
||||||
|
"Выберите тип поста:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.CHOOSE_TYPE
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_channel: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text("Произошла ошибка при выборе канала")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработка выбора типа поста."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
await message.edit_text("Неверный формат данных")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
post_type = PostType(query.data.replace("type:", ""))
|
||||||
|
|
||||||
|
session = SessionStore.get_instance().get(query.from_user.id)
|
||||||
|
session.type = post_type
|
||||||
|
|
||||||
|
kb = KbBuilder.parse_modes()
|
||||||
|
await message.edit_text(
|
||||||
|
"Выберите формат текста:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.CHOOSE_FORMAT
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_type: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text("Произошла ошибка при выборе типа поста")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_format(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработка выбора формата текста."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
await message.edit_text("Неверный формат данных")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
parse_mode = query.data.replace("fmt:", "")
|
||||||
|
if parse_mode not in [ParseMode.HTML, ParseMode.MARKDOWN_V2]:
|
||||||
|
await message.edit_text("Неизвестный формат текста")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
session = SessionStore.get_instance().get(query.from_user.id)
|
||||||
|
session.parse_mode = parse_mode
|
||||||
|
|
||||||
|
kb = KbBuilder.text_input_options()
|
||||||
|
await message.edit_text(
|
||||||
|
"Введите текст поста или выберите шаблон:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_format: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text("Произошла ошибка при выборе формата")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def enter_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработка ввода текста поста."""
|
||||||
|
message = update.effective_message
|
||||||
|
user = update.effective_user
|
||||||
|
|
||||||
|
if not message or not user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
text = message.text
|
||||||
|
if not text:
|
||||||
|
await message.reply_text("Пожалуйста, введите текст поста")
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = SessionStore.get_instance().get(user.id)
|
||||||
|
session.text = text
|
||||||
|
|
||||||
|
if session.type == MessageType.TEXT:
|
||||||
|
await message.reply_text(
|
||||||
|
"Введите клавиатуру в формате:\n"
|
||||||
|
"текст кнопки = ссылка\n\n"
|
||||||
|
"Или отправьте 'skip' чтобы пропустить"
|
||||||
|
)
|
||||||
|
return BotStates.EDIT_KEYBOARD
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Отправьте фото/видео/gif для поста"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_MEDIA
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in enter_text: {e}")
|
||||||
|
await message.reply_text("Произошла ошибка при сохранении текста")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_template_open(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Открытие выбора шаблона."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
user_id = query.from_user.id
|
||||||
|
|
||||||
|
templates = await TemplateService.list_user_templates(user_id)
|
||||||
|
if not templates:
|
||||||
|
await message.edit_text(
|
||||||
|
"У вас нет шаблонов. Создайте новый с помощью /newtemplate"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
total = len(templates)
|
||||||
|
if total == 0:
|
||||||
|
await message.edit_text("Список шаблонов пуст")
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
user_data = context.user_data
|
||||||
|
if not user_data:
|
||||||
|
user_data = {}
|
||||||
|
context.user_data = user_data
|
||||||
|
|
||||||
|
page = user_data.get("tpl_page", 0)
|
||||||
|
items = templates[page * KbBuilder.PAGE_SIZE:(page + 1) * KbBuilder.PAGE_SIZE]
|
||||||
|
|
||||||
|
kb = KbBuilder.templates_list(items, page, total)
|
||||||
|
await message.edit_text(
|
||||||
|
"Выберите шаблон:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_template_open: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text("Произошла ошибка при загрузке шаблонов")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_template_apply(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Применение выбранного шаблона."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
await message.edit_text("Неверный формат данных")
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
template_id = query.data.replace("tpluse:", "")
|
||||||
|
|
||||||
|
template = await TemplateService.get_template(template_id)
|
||||||
|
if not template:
|
||||||
|
await message.edit_text("Шаблон не найден")
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
session = SessionStore.get_instance().get(query.from_user.id)
|
||||||
|
session.template_id = template_id
|
||||||
|
session.text = template.content
|
||||||
|
|
||||||
|
if "{" in template.content and "}" in template.content:
|
||||||
|
# Шаблон содержит переменные
|
||||||
|
await message.edit_text(
|
||||||
|
"Введите значения для переменных в формате:\n"
|
||||||
|
"переменная = значение"
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_VARS
|
||||||
|
|
||||||
|
# Нет переменных, можно сразу показать предпросмотр
|
||||||
|
kb = KbBuilder.preview_confirm()
|
||||||
|
|
||||||
|
post_data = session.to_dict()
|
||||||
|
await PostService.preview_post(message, post_data)
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Предпросмотр поста. Выберите действие:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_template_apply: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text(
|
||||||
|
"Произошла ошибка при применении шаблона"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при применении шаблона: {e}")
|
||||||
|
await query.message.edit_text(
|
||||||
|
f"Ошибка при применении шаблона: {str(e)}"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
async def choose_template_preview(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Предпросмотр шаблона."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
await message.edit_text("Неверный формат данных")
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
template_id = query.data.replace("tplprev:", "")
|
||||||
|
template = await TemplateService.get_template(template_id)
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
await message.edit_text("Шаблон не найден")
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
await message.edit_text(
|
||||||
|
f"Предпросмотр шаблона:\n\n{template.content}",
|
||||||
|
parse_mode=template.parse_mode
|
||||||
|
)
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_template_preview: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text(
|
||||||
|
"Произошла ошибка при предпросмотре шаблона"
|
||||||
|
)
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
async def choose_template_navigate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Навигация по страницам шаблонов."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
# Получаем номер страницы
|
||||||
|
page = int(query.data.replace("tplpage:", ""))
|
||||||
|
|
||||||
|
user_data = context.user_data
|
||||||
|
if not user_data:
|
||||||
|
user_data = {}
|
||||||
|
context.user_data = user_data
|
||||||
|
user_data["tpl_page"] = page
|
||||||
|
|
||||||
|
# Перестраиваем список для новой страницы
|
||||||
|
templates = await TemplateService.list_user_templates(query.from_user.id)
|
||||||
|
total = len(templates)
|
||||||
|
items = templates[page * KbBuilder.PAGE_SIZE:(page + 1) * KbBuilder.PAGE_SIZE]
|
||||||
|
|
||||||
|
kb = KbBuilder.templates_list(items, page, total)
|
||||||
|
await message.edit_reply_markup(reply_markup=kb)
|
||||||
|
return BotStates.SELECT_TEMPLATE
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in choose_template_navigate: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text("Произошла ошибка при смене страницы")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def choose_template_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Отмена выбора шаблона."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
await query.message.edit_text(
|
||||||
|
"Введите текст поста:"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
async def preview_collect_vars(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Сбор значений переменных для шаблона."""
|
||||||
|
message = update.message
|
||||||
|
if not message or not message.from_user or not message.text:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
try:
|
||||||
|
variables = parse_key_value_lines(message.text)
|
||||||
|
session = SessionStore.get_instance().get(message.from_user.id)
|
||||||
|
if not session.template_id:
|
||||||
|
await message.reply_text("Шаблон не выбран")
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
template = await TemplateService.get_template(session.template_id)
|
||||||
|
if not template:
|
||||||
|
await message.reply_text("Шаблон не найден")
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
# Подставляем значения переменных
|
||||||
|
text = template.content
|
||||||
|
for var, value in variables.items():
|
||||||
|
text = text.replace(f"{{{var}}}", value)
|
||||||
|
|
||||||
|
session.text = text
|
||||||
|
post_data = session.to_dict()
|
||||||
|
|
||||||
|
kb = KbBuilder.preview_confirm()
|
||||||
|
await PostService.preview_post(message, post_data)
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Предпросмотр поста. Выберите действие:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
await message.reply_text(
|
||||||
|
f"Ошибка в формате переменных: {str(e)}\n"
|
||||||
|
"Используйте формат:\n"
|
||||||
|
"переменная = значение"
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_VARS
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in preview_collect_vars: {e}")
|
||||||
|
await message.reply_text(
|
||||||
|
"Произошла ошибка при обработке переменных"
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_VARS
|
||||||
|
|
||||||
|
async def preview_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Подтверждение предпросмотра поста."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
await message.edit_text("Неверный формат данных")
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
action = query.data.replace("pv:", "")
|
||||||
|
if action == "edit":
|
||||||
|
await message.edit_text(
|
||||||
|
"Введите текст поста:"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_TEXT
|
||||||
|
|
||||||
|
session = SessionStore.get_instance().get(query.from_user.id)
|
||||||
|
|
||||||
|
if not session.type:
|
||||||
|
await message.edit_text("Ошибка: не выбран тип поста")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
if session.type == MessageType.TEXT:
|
||||||
|
await message.edit_text(
|
||||||
|
"Введите клавиатуру в формате:\n"
|
||||||
|
"текст кнопки = ссылка\n\n"
|
||||||
|
"Или отправьте 'skip' чтобы пропустить"
|
||||||
|
)
|
||||||
|
return BotStates.EDIT_KEYBOARD
|
||||||
|
|
||||||
|
await message.edit_text(
|
||||||
|
"Отправьте фото/видео/gif для поста"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_MEDIA
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in preview_confirm: {e}")
|
||||||
|
if query.message:
|
||||||
|
await query.message.edit_text(
|
||||||
|
"Произошла ошибка при обработке предпросмотра"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def enter_media(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработка медиафайла."""
|
||||||
|
message = update.message
|
||||||
|
if not message or not message.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = SessionStore.get_instance().get(message.from_user.id)
|
||||||
|
|
||||||
|
if message.photo:
|
||||||
|
session.media_file_id = message.photo[-1].file_id
|
||||||
|
elif message.video:
|
||||||
|
session.media_file_id = message.video.file_id
|
||||||
|
elif message.animation:
|
||||||
|
session.media_file_id = message.animation.file_id
|
||||||
|
elif message.document:
|
||||||
|
session.media_file_id = message.document.file_id
|
||||||
|
else:
|
||||||
|
await message.reply_text(
|
||||||
|
"Пожалуйста, отправьте фото, видео или GIF"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_MEDIA
|
||||||
|
|
||||||
|
# Показываем предпросмотр
|
||||||
|
kb = KbBuilder.preview_confirm()
|
||||||
|
post_data = session.to_dict()
|
||||||
|
await PostService.preview_post(message, post_data)
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Предпросмотр поста. Выберите действие:",
|
||||||
|
reply_markup=kb
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in enter_media: {e}")
|
||||||
|
await message.reply_text(
|
||||||
|
"Произошла ошибка при обработке файла"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
session.media_id = message.animation.file_id
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Введите клавиатуру в формате:\n"
|
||||||
|
"текст кнопки = ссылка\n\n"
|
||||||
|
"Или отправьте 'skip' чтобы пропустить"
|
||||||
|
)
|
||||||
|
return BotStates.EDIT_KEYBOARD
|
||||||
|
|
||||||
|
async def edit_keyboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработка клавиатуры поста."""
|
||||||
|
message = update.message
|
||||||
|
if not message or not message.from_user or not message.text:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
try:
|
||||||
|
kb_text = message.text.strip()
|
||||||
|
session = SessionStore.get_instance().get(message.from_user.id)
|
||||||
|
|
||||||
|
if kb_text.lower() != "skip":
|
||||||
|
keyboard = parse_key_value_lines(kb_text)
|
||||||
|
session.keyboard = {"rows": []}
|
||||||
|
for text, url in keyboard.items():
|
||||||
|
session.keyboard["rows"].append([{"text": text, "url": url}])
|
||||||
|
|
||||||
|
# Показываем предпросмотр поста
|
||||||
|
post_data = session.to_dict()
|
||||||
|
await PostService.preview_post(message, post_data)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup([
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Отправить", callback_data="send:now"),
|
||||||
|
InlineKeyboardButton("Отложить", callback_data="send:schedule")
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Выберите действие:",
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
|
return BotStates.CONFIRM_SEND
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
await message.reply_text(
|
||||||
|
f"Ошибка в формате клавиатуры: {e}\n"
|
||||||
|
"Используйте формат:\n"
|
||||||
|
"текст кнопки = ссылка"
|
||||||
|
)
|
||||||
|
return BotStates.EDIT_KEYBOARD
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in edit_keyboard: {e}")
|
||||||
|
await message.reply_text(
|
||||||
|
"Произошла ошибка при обработке клавиатуры"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
async def confirm_send(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Подтверждение отправки поста."""
|
||||||
|
query = update.callback_query
|
||||||
|
if not query or not query.from_user:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = cast(Message, query.message)
|
||||||
|
if not query.data:
|
||||||
|
await message.edit_text("Неверный формат данных")
|
||||||
|
return BotStates.CONFIRM_SEND
|
||||||
|
|
||||||
|
action = query.data.replace("send:", "")
|
||||||
|
if action == "schedule":
|
||||||
|
await message.edit_text(
|
||||||
|
"Введите дату и время для отложенной публикации в формате:\n"
|
||||||
|
"ДД.ММ.ГГГГ ЧЧ:ММ"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_SCHEDULE
|
||||||
|
|
||||||
|
session = SessionStore.get_instance().get(query.from_user.id)
|
||||||
|
|
||||||
|
# Отправляем пост сейчас
|
||||||
|
post_data = session.to_dict()
|
||||||
|
|
||||||
|
message = query.message
|
||||||
|
if not message:
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
if not session.channel_id:
|
||||||
|
await context.bot.send_message(
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
text="Канал не выбран",
|
||||||
|
reply_markup=KbBuilder.go_back()
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
post = await PostService.create_post(context.bot, session.channel_id, post_data)
|
||||||
|
if post:
|
||||||
|
await context.bot.edit_message_text(
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
message_id=message.message_id,
|
||||||
|
text="Пост успешно отправлен!"
|
||||||
|
)
|
||||||
|
SessionStore.get_instance().drop(query.from_user.id)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
await context.bot.send_message(
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
text="Ошибка при отправке поста. Попробуйте позже.",
|
||||||
|
reply_markup=KbBuilder.go_back()
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in confirm_send: {e}")
|
||||||
|
message = query.message
|
||||||
|
if message:
|
||||||
|
await context.bot.edit_message_text(
|
||||||
|
chat_id=message.chat.id,
|
||||||
|
message_id=message.message_id,
|
||||||
|
text="Произошла ошибка при отправке поста"
|
||||||
|
)
|
||||||
|
return BotStates.PREVIEW_CONFIRM
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
session = get_session_store().get_or_create(query.from_user.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Отправляем пост
|
||||||
|
await schedule_post(session, schedule_time=None)
|
||||||
|
await query.message.edit_text("Пост успешно отправлен!")
|
||||||
|
|
||||||
|
# Очищаем сессию
|
||||||
|
get_session_store().drop(query.from_user.id)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке поста: {e}")
|
||||||
|
await query.message.edit_text(
|
||||||
|
f"Ошибка при отправке поста: {str(e)}"
|
||||||
|
)
|
||||||
|
return BotStates.CONFIRM_SEND
|
||||||
|
|
||||||
|
async def enter_schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
"""Обработка времени для отложенной публикации."""
|
||||||
|
message = update.message
|
||||||
|
if not message or not message.from_user or not message.text:
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule_text = message.text.strip()
|
||||||
|
if not schedule_text:
|
||||||
|
await message.reply_text(
|
||||||
|
"Некорректный формат даты.\n"
|
||||||
|
"Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_SCHEDULE
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule_time = datetime.strptime(schedule_text, "%d.%m.%Y %H:%M")
|
||||||
|
if schedule_time <= datetime.now():
|
||||||
|
await message.reply_text(
|
||||||
|
"Нельзя указать время в прошлом.\n"
|
||||||
|
"Введите время в будущем."
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_SCHEDULE
|
||||||
|
|
||||||
|
session = SessionStore.get_instance().get(message.from_user.id)
|
||||||
|
if not session.channel_id:
|
||||||
|
await message.reply_text("Не выбран канал для публикации")
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
# Отправляем отложенный пост
|
||||||
|
post_data = session.to_dict()
|
||||||
|
post_data["schedule_time"] = schedule_time
|
||||||
|
await PostService.create_post(context.bot, session.channel_id, post_data)
|
||||||
|
await message.reply_text("Пост запланирован!")
|
||||||
|
|
||||||
|
# Очищаем сессию
|
||||||
|
SessionStore.get_instance().drop(message.from_user.id)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await message.reply_text(
|
||||||
|
"Некорректный формат даты.\n"
|
||||||
|
"Используйте формат: ДД.ММ.ГГГГ ЧЧ:ММ"
|
||||||
|
)
|
||||||
|
return BotStates.ENTER_SCHEDULE
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in enter_schedule: {e}")
|
||||||
|
await message.reply_text(
|
||||||
|
"Произошла ошибка при обработке времени публикации"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
146
app/bots/editor/handlers/templates.py
Normal file
146
app/bots/editor/handlers/templates.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Обработчики для работы с шаблонами."""
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from telegram import Update, Message
|
||||||
|
from telegram.ext import ContextTypes, ConversationHandler
|
||||||
|
|
||||||
|
from app.bots.editor.states import BotStates
|
||||||
|
from app.bots.editor.session import get_session_store
|
||||||
|
from ..keyboards import template_type_keyboard, get_templates_keyboard
|
||||||
|
from ..utils.parsers import parse_key_value_lines
|
||||||
|
from ..utils.validation import validate_template_name
|
||||||
|
|
||||||
|
async def start_template_creation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||||
|
"""Начало создания шаблона."""
|
||||||
|
if not update.message:
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
message = update.message
|
||||||
|
await message.reply_text(
|
||||||
|
"Выберите тип шаблона:",
|
||||||
|
reply_markup=template_type_keyboard()
|
||||||
|
)
|
||||||
|
return BotStates.TPL_TYPE
|
||||||
|
|
||||||
|
async def handle_template_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||||
|
"""Обработка выбора типа шаблона."""
|
||||||
|
if not update.callback_query:
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
tpl_type = query.data
|
||||||
|
user_id = query.from_user.id
|
||||||
|
|
||||||
|
session_store = get_session_store()
|
||||||
|
session = session_store.get_or_create(user_id)
|
||||||
|
session.type = tpl_type
|
||||||
|
|
||||||
|
await query.message.edit_text("Введите название шаблона:")
|
||||||
|
return BotStates.TPL_NAME
|
||||||
|
|
||||||
|
async def handle_template_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||||
|
"""Обработка ввода имени шаблона."""
|
||||||
|
if not update.message:
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
message = update.message
|
||||||
|
user_id = message.from_user.id
|
||||||
|
name = message.text.strip()
|
||||||
|
|
||||||
|
if not validate_template_name(name):
|
||||||
|
await message.reply_text(
|
||||||
|
"Некорректное имя шаблона. Используйте только буквы, цифры и знаки - _"
|
||||||
|
)
|
||||||
|
return BotStates.TPL_NAME
|
||||||
|
|
||||||
|
session = get_session_store().get_or_create(user_id)
|
||||||
|
session.template_name = name
|
||||||
|
|
||||||
|
await message.reply_text("Введите текст шаблона:")
|
||||||
|
return BotStates.TPL_TEXT
|
||||||
|
|
||||||
|
async def handle_template_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||||
|
"""Обработка текста шаблона."""
|
||||||
|
if not update.message:
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
message = update.message
|
||||||
|
user_id = message.from_user.id
|
||||||
|
text = message.text.strip()
|
||||||
|
|
||||||
|
session = get_session_store().get_or_create(user_id)
|
||||||
|
session.text = text
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Введите клавиатуру в формате:\n"
|
||||||
|
"текст кнопки = ссылка\n\n"
|
||||||
|
"Или отправьте 'skip' чтобы пропустить"
|
||||||
|
)
|
||||||
|
return BotStates.TPL_NEW_KB
|
||||||
|
|
||||||
|
async def handle_template_keyboard(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||||
|
"""Обработка клавиатуры шаблона."""
|
||||||
|
if not update.message:
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
message = update.message
|
||||||
|
user_id = message.from_user.id
|
||||||
|
kb_text = message.text.strip()
|
||||||
|
|
||||||
|
session = get_session_store().get_or_create(user_id)
|
||||||
|
|
||||||
|
if kb_text != "skip":
|
||||||
|
try:
|
||||||
|
keyboard = parse_key_value_lines(kb_text)
|
||||||
|
session.keyboard = keyboard
|
||||||
|
except ValueError as e:
|
||||||
|
await message.reply_text(f"Ошибка разбора клавиатуры: {e}")
|
||||||
|
return BotStates.TPL_NEW_KB
|
||||||
|
|
||||||
|
try:
|
||||||
|
template_data = {
|
||||||
|
"owner_id": user_id,
|
||||||
|
"name": session.template_name,
|
||||||
|
"title": session.template_name,
|
||||||
|
"content": session.text,
|
||||||
|
"type": session.type,
|
||||||
|
"parse_mode": session.parse_mode or "HTML",
|
||||||
|
"keyboard_tpl": session.keyboard
|
||||||
|
}
|
||||||
|
await create_template(template_data)
|
||||||
|
await message.reply_text("Шаблон успешно создан")
|
||||||
|
|
||||||
|
# Очищаем сессию
|
||||||
|
get_session_store().drop(user_id)
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
await message.reply_text(f"Ошибка создания шаблона: {e}")
|
||||||
|
return BotStates.TPL_NEW_KB
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Неожиданная ошибка при создании шаблона: {e}")
|
||||||
|
await message.reply_text("Произошла непредвиденная ошибка при создании шаблона")
|
||||||
|
return BotStates.TPL_NEW_KB
|
||||||
|
|
||||||
|
async def list_templates(update: Update, context: ContextTypes.DEFAULT_TYPE) -> BotStates:
|
||||||
|
"""Список шаблонов."""
|
||||||
|
if not update.message:
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
message = update.message
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
templates = await get_user_templates(user_id)
|
||||||
|
if not templates:
|
||||||
|
await message.reply_text("У вас пока нет шаблонов")
|
||||||
|
return BotStates.CONVERSATION_END
|
||||||
|
|
||||||
|
page = context.user_data.get("tpl_page", 0)
|
||||||
|
keyboard = get_templates_keyboard(templates, page)
|
||||||
|
|
||||||
|
await message.reply_text(
|
||||||
|
"Выберите шаблон:",
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
|
return BotStates.TPL_SELECT
|
||||||
@@ -1,53 +1,207 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Iterable, List, Tuple, Optional
|
from typing import Iterable, List, Optional, Any
|
||||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
|
from .messages import MessageType
|
||||||
|
|
||||||
|
def template_type_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""Возвращает клавиатуру выбора типа шаблона."""
|
||||||
|
return KbBuilder.template_type_keyboard()
|
||||||
|
|
||||||
|
def get_templates_keyboard(templates: List[Any], page: int = 0) -> InlineKeyboardMarkup:
|
||||||
|
"""Возвращает клавиатуру со списком шаблонов."""
|
||||||
|
return KbBuilder.get_templates_keyboard(templates, page)
|
||||||
|
|
||||||
|
|
||||||
class KbBuilder:
|
class KbBuilder:
|
||||||
|
"""Строитель клавиатур для различных состояний бота."""
|
||||||
|
|
||||||
|
PAGE_SIZE = 8
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def channels(channels: Iterable) -> InlineKeyboardMarkup:
|
def channels(channels: Iterable) -> InlineKeyboardMarkup:
|
||||||
rows = [[InlineKeyboardButton(ch.title or str(ch.chat_id), callback_data=f"channel:{ch.id}")]
|
"""Клавиатура выбора канала."""
|
||||||
for ch in channels]
|
rows = [
|
||||||
|
[InlineKeyboardButton(
|
||||||
|
ch.title or str(ch.chat_id),
|
||||||
|
callback_data=f"channel:{ch.id}"
|
||||||
|
)]
|
||||||
|
for ch in channels
|
||||||
|
]
|
||||||
return InlineKeyboardMarkup(rows)
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def post_types() -> InlineKeyboardMarkup:
|
def post_types() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура выбора типа поста."""
|
||||||
rows = [
|
rows = [
|
||||||
[InlineKeyboardButton("Текст", callback_data="type:text"),
|
[
|
||||||
InlineKeyboardButton("Фото", callback_data="type:photo")],
|
InlineKeyboardButton("📝 Текст", callback_data=f"type:{MessageType.TEXT.value}"),
|
||||||
[InlineKeyboardButton("Видео", callback_data="type:video"),
|
InlineKeyboardButton("📷 Фото", callback_data=f"type:{MessageType.PHOTO.value}")
|
||||||
InlineKeyboardButton("GIF", callback_data="type:animation")],
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("🎥 Видео", callback_data=f"type:{MessageType.VIDEO.value}"),
|
||||||
|
InlineKeyboardButton("🎬 GIF", callback_data=f"type:{MessageType.ANIMATION.value}")
|
||||||
|
],
|
||||||
]
|
]
|
||||||
return InlineKeyboardMarkup(rows)
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_modes() -> InlineKeyboardMarkup:
|
def parse_modes() -> InlineKeyboardMarkup:
|
||||||
rows = [[InlineKeyboardButton("HTML", callback_data="fmt:HTML"),
|
"""Клавиатура выбора формата текста."""
|
||||||
InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2")]]
|
|
||||||
return InlineKeyboardMarkup(rows)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def send_confirm() -> InlineKeyboardMarkup:
|
|
||||||
rows = [
|
rows = [
|
||||||
[InlineKeyboardButton("Отправить сейчас", callback_data="send:now")],
|
[
|
||||||
[InlineKeyboardButton("Запланировать", callback_data="send:schedule")],
|
InlineKeyboardButton("HTML", callback_data="fmt:HTML"),
|
||||||
|
InlineKeyboardButton("MarkdownV2", callback_data="fmt:MarkdownV2")
|
||||||
|
]
|
||||||
]
|
]
|
||||||
return InlineKeyboardMarkup(rows)
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def templates_list(items: List, page: int, total: int, page_size: int) -> InlineKeyboardMarkup:
|
def template_type_keyboard() -> InlineKeyboardMarkup:
|
||||||
rows: List[List[InlineKeyboardButton]] = []
|
"""Клавиатура выбора типа шаблона."""
|
||||||
for t in items:
|
rows = [
|
||||||
rows.append([
|
[
|
||||||
InlineKeyboardButton((t.title or t.name), callback_data=f"tpluse:{t.name}"),
|
InlineKeyboardButton("📝 Текст", callback_data=f"tpl_type:{MessageType.TEXT.value}"),
|
||||||
InlineKeyboardButton("👁 Предпросмотр", callback_data=f"tplprev:{t.name}")
|
InlineKeyboardButton("📷 Фото", callback_data=f"tpl_type:{MessageType.PHOTO.value}")
|
||||||
])
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("🎥 Видео", callback_data=f"tpl_type:{MessageType.VIDEO.value}"),
|
||||||
|
InlineKeyboardButton("🎬 GIF", callback_data=f"tpl_type:{MessageType.ANIMATION.value}")
|
||||||
|
]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_templates_keyboard(templates: List[Any], page: int = 0) -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура списка шаблонов с пагинацией."""
|
||||||
|
start_idx = page * KbBuilder.PAGE_SIZE
|
||||||
|
end_idx = start_idx + KbBuilder.PAGE_SIZE
|
||||||
|
page_templates = templates[start_idx:end_idx]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for template in page_templates:
|
||||||
|
rows.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
f"{template.name} ({template.type})",
|
||||||
|
callback_data=f"template:{template.id}"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
nav_row = []
|
||||||
|
if page > 0:
|
||||||
|
nav_row.append(InlineKeyboardButton("◀️ Назад", callback_data=f"page:{page-1}"))
|
||||||
|
if end_idx < len(templates):
|
||||||
|
nav_row.append(InlineKeyboardButton("Вперед ▶️", callback_data=f"page:{page+1}"))
|
||||||
|
|
||||||
|
if nav_row:
|
||||||
|
rows.append(nav_row)
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
@staticmethod
|
||||||
|
def go_back() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура с кнопкой назад."""
|
||||||
|
return InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton("« Назад", callback_data="back")]
|
||||||
|
])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def text_input_options() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура при вводе текста."""
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("📋 Использовать шаблон", callback_data="tpl:choose")],
|
||||||
|
[InlineKeyboardButton("⌨️ Добавить клавиатуру", callback_data="kb:add")],
|
||||||
|
[InlineKeyboardButton("❌ Отмена", callback_data="cancel")]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_confirm() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура подтверждения отправки."""
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("📤 Отправить сейчас", callback_data="send:now")],
|
||||||
|
[InlineKeyboardButton("⏰ Запланировать", callback_data="send:schedule")],
|
||||||
|
[InlineKeyboardButton("✏️ Редактировать", callback_data="send:edit")],
|
||||||
|
[InlineKeyboardButton("❌ Отмена", callback_data="send:cancel")]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def preview_confirm() -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура после предпросмотра."""
|
||||||
|
rows = [
|
||||||
|
[InlineKeyboardButton("✅ Использовать", callback_data="pv:use")],
|
||||||
|
[InlineKeyboardButton("✏️ Изменить переменные", callback_data="pv:edit")],
|
||||||
|
[InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def templates_list(
|
||||||
|
items: List,
|
||||||
|
page: int,
|
||||||
|
total: int,
|
||||||
|
show_delete: bool = False
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура списка шаблонов с пагинацией.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Список шаблонов на текущей странице
|
||||||
|
page: Номер текущей страницы
|
||||||
|
total: Общее количество шаблонов
|
||||||
|
show_delete: Показывать ли кнопку удаления
|
||||||
|
"""
|
||||||
|
rows: List[List[InlineKeyboardButton]] = []
|
||||||
|
|
||||||
|
for t in items:
|
||||||
|
row = [
|
||||||
|
InlineKeyboardButton(
|
||||||
|
(t.title or t.name),
|
||||||
|
callback_data=f"tpluse:{t.name}"
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
"👁 Предпросмотр",
|
||||||
|
callback_data=f"tplprev:{t.name}"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if show_delete:
|
||||||
|
row.append(InlineKeyboardButton(
|
||||||
|
"🗑",
|
||||||
|
callback_data=f"tpldel:{t.name}"
|
||||||
|
))
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
# Навигация
|
||||||
nav: List[InlineKeyboardButton] = []
|
nav: List[InlineKeyboardButton] = []
|
||||||
if page > 0:
|
if page > 0:
|
||||||
nav.append(InlineKeyboardButton("◀️ Назад", callback_data=f"tplpage:{page-1}"))
|
nav.append(InlineKeyboardButton(
|
||||||
if (page + 1) * page_size < total:
|
"◀️ Назад",
|
||||||
nav.append(InlineKeyboardButton("Вперёд ▶️", callback_data=f"tplpage:{page+1}"))
|
callback_data=f"tplpage:{page-1}"
|
||||||
|
))
|
||||||
|
if (page + 1) * KbBuilder.PAGE_SIZE < total:
|
||||||
|
nav.append(InlineKeyboardButton(
|
||||||
|
"Вперёд ▶️",
|
||||||
|
callback_data=f"tplpage:{page+1}"
|
||||||
|
))
|
||||||
|
|
||||||
|
if nav:
|
||||||
|
rows.append(nav)
|
||||||
|
|
||||||
|
# Кнопка отмены
|
||||||
|
rows.append([InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def template_delete_confirm(name: str) -> InlineKeyboardMarkup:
|
||||||
|
"""Клавиатура подтверждения удаления шаблона."""
|
||||||
|
rows = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("✅ Удалить", callback_data=f"tpldelok:{name}"),
|
||||||
|
InlineKeyboardButton("❌ Отмена", callback_data="tpl:cancel")
|
||||||
|
]
|
||||||
|
]
|
||||||
|
return InlineKeyboardMarkup(rows)
|
||||||
if nav:
|
if nav:
|
||||||
rows.append(nav)
|
rows.append(nav)
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,111 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import shlex
|
import shlex
|
||||||
from typing import Dict
|
from enum import Enum
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(Enum):
|
||||||
|
TEXT = "text"
|
||||||
|
PHOTO = "photo"
|
||||||
|
VIDEO = "video"
|
||||||
|
ANIMATION = "animation"
|
||||||
|
|
||||||
|
|
||||||
|
class Messages:
|
||||||
|
# Команды
|
||||||
|
WELCOME_MESSAGE = (
|
||||||
|
"👋 Привет! Я редактор постов для каналов.\n\n"
|
||||||
|
"🤖 Сначала добавьте бота через /add_bot\n"
|
||||||
|
"📢 Затем добавьте каналы через /add_channel\n"
|
||||||
|
"📝 Потом можно:\n"
|
||||||
|
"- Создать пост: /newpost\n"
|
||||||
|
"- Управлять шаблонами: /tpl_new, /tpl_list\n"
|
||||||
|
"- Посмотреть список ботов: /bots\n"
|
||||||
|
"- Посмотреть список каналов: /channels\n\n"
|
||||||
|
"❓ Справка: /help"
|
||||||
|
)
|
||||||
|
|
||||||
|
HELP_MESSAGE = (
|
||||||
|
"📖 Справка по командам:\n\n"
|
||||||
|
"Управление ботами и каналами:\n"
|
||||||
|
"/add_bot - Добавить нового бота\n"
|
||||||
|
"/bots - Список ваших ботов\n"
|
||||||
|
"/add_channel - Добавить канал\n"
|
||||||
|
"/channels - Список ваших каналов\n\n"
|
||||||
|
"Управление постами:\n"
|
||||||
|
"/newpost - Создать новый пост\n\n"
|
||||||
|
"Управление шаблонами:\n"
|
||||||
|
"/tpl_new - Создать шаблон\n"
|
||||||
|
"/tpl_list - Список ваших шаблонов"
|
||||||
|
)
|
||||||
|
|
||||||
|
START = ("👋 Привет! Я редактор постов. Доступные команды:\n"
|
||||||
|
"/newpost — создать новый пост\n"
|
||||||
|
"/tpl_new — создать шаблон\n"
|
||||||
|
"/tpl_list — список шаблонов")
|
||||||
|
|
||||||
|
# Ошибки
|
||||||
|
ERROR_SESSION_EXPIRED = "❌ Сессия истекла. Начните заново с /newpost"
|
||||||
|
ERROR_INVALID_FORMAT = "❌ Неверный формат. Попробуйте еще раз"
|
||||||
|
ERROR_NO_CHANNELS = "❌ У вас нет каналов. Добавьте канал через админку"
|
||||||
|
ERROR_TEMPLATE_NOT_FOUND = "❌ Шаблон не найден"
|
||||||
|
ERROR_TEMPLATE_CREATE = "❌ Ошибка при создании шаблона: {error}"
|
||||||
|
ERROR_INVALID_KEYBOARD = "❌ Неверный формат клавиатуры"
|
||||||
|
|
||||||
|
# Создание поста
|
||||||
|
SELECT_CHANNEL = "📢 Выберите канал для публикации:"
|
||||||
|
SELECT_TYPE = "📝 Выберите тип поста:"
|
||||||
|
SELECT_FORMAT = "🔤 Выберите формат текста:"
|
||||||
|
ENTER_TEXT = "✏️ Введите текст сообщения\nИли используйте #имя_шаблона для применения шаблона"
|
||||||
|
ENTER_MEDIA = "📎 Отправьте {media_type}"
|
||||||
|
ENTER_KEYBOARD = "⌨️ Введите клавиатуру в формате:\nтекст|url\nтекст2|url2\n\nИли отправьте 'skip' для пропуска"
|
||||||
|
|
||||||
|
# Шаблоны
|
||||||
|
TEMPLATE_LIST = "📜 Список шаблонов (стр. {page}/{total_pages}):"
|
||||||
|
TEMPLATE_CREATE_NAME = "📝 Введите имя для нового шаблона:"
|
||||||
|
TEMPLATE_CREATE_TYPE = "📌 Выберите тип шаблона:"
|
||||||
|
TEMPLATE_CREATE_FORMAT = "🔤 Выберите формат текста:"
|
||||||
|
TEMPLATE_CREATE_CONTENT = "✏️ Введите содержимое шаблона:"
|
||||||
|
TEMPLATE_CREATE_KEYBOARD = "⌨️ Введите клавиатуру или отправьте 'skip':"
|
||||||
|
TEMPLATE_CREATED = "✅ Шаблон успешно создан"
|
||||||
|
TEMPLATE_DELETED = "🗑 Шаблон удален"
|
||||||
|
|
||||||
|
# Отправка
|
||||||
|
CONFIRM_SEND = "📤 Как отправить пост?"
|
||||||
|
ENTER_SCHEDULE = "📅 Введите дату и время для публикации в формате YYYY-MM-DD HH:MM"
|
||||||
|
POST_SCHEDULED = "✅ Пост запланирован на {datetime}"
|
||||||
|
POST_SENT = "✅ Пост отправлен"
|
||||||
|
|
||||||
|
|
||||||
class MessageParsers:
|
class MessageParsers:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_template_invocation(s: str) -> tuple[str, Dict[str, str]]:
|
def parse_template_invocation(s: str) -> tuple[str, Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Пример: "#promo title='Hi' url=https://x.y"
|
Парсит вызов шаблона вида: "#promo title='Hi' url=https://x.y"
|
||||||
-> ("promo", {"title":"Hi", "url":"https://x.y"})
|
Возвращает: ("promo", {"title":"Hi", "url":"https://x.y"})
|
||||||
"""
|
"""
|
||||||
s = (s or "").strip()
|
s = (s or "").strip()
|
||||||
if not s.startswith("#"):
|
if not s.startswith("#"):
|
||||||
raise ValueError("not a template invocation")
|
raise ValueError("not a template invocation")
|
||||||
parts = shlex.split(s)
|
|
||||||
name = parts[0][1:]
|
try:
|
||||||
args: Dict[str, str] = {}
|
parts = shlex.split(s)
|
||||||
for tok in parts[1:]:
|
name = parts[0][1:] # убираем #
|
||||||
if "=" in tok:
|
args: Dict[str, str] = {}
|
||||||
k, v = tok.split("=", 1)
|
|
||||||
args[k] = v
|
for tok in parts[1:]:
|
||||||
return name, args
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
args[k.strip()] = v.strip().strip('"\'')
|
||||||
|
return name, args
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Ошибка парсинга шаблона: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_key_value_lines(text: str) -> Dict[str, str]:
|
def parse_key_value_lines(text: str) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Поддерживает:
|
Парсит переменные в форматах:
|
||||||
- построчно:
|
- построчно:
|
||||||
key=value
|
key=value
|
||||||
key2="quoted value"
|
key2="quoted value"
|
||||||
@@ -35,17 +115,60 @@ class MessageParsers:
|
|||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return {}
|
return {}
|
||||||
if "\n" in text:
|
|
||||||
out: Dict[str, str] = {}
|
try:
|
||||||
|
if "\n" in text:
|
||||||
|
# Построчный формат
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if "=" in line:
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
out[k.strip()] = v.strip().strip('"\'')
|
||||||
|
return out
|
||||||
|
else:
|
||||||
|
# Однострочный формат
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for tok in shlex.split(text):
|
||||||
|
if "=" in tok:
|
||||||
|
k, v = tok.split("=", 1)
|
||||||
|
out[k.strip()] = v.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Ошибка парсинга переменных: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_keyboard(text: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Парсит клавиатуру в формате:
|
||||||
|
текст1|url1
|
||||||
|
текст2|url2
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
{
|
||||||
|
"rows": [
|
||||||
|
[{"text": "текст1", "url": "url1"}],
|
||||||
|
[{"text": "текст2", "url": "url2"}]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text or text.lower() == "skip":
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = []
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
if "=" in line:
|
line = line.strip()
|
||||||
k, v = line.split("=", 1)
|
if "|" in line:
|
||||||
out[k.strip()] = v.strip().strip('"')
|
text, url = line.split("|", 1)
|
||||||
return out
|
rows.append([{
|
||||||
|
"text": text.strip(),
|
||||||
out: Dict[str, str] = {}
|
"url": url.strip()
|
||||||
for tok in shlex.split(text):
|
}])
|
||||||
if "=" in tok:
|
return {"rows": rows} if rows else None
|
||||||
k, v = tok.split("=", 1)
|
|
||||||
out[k] = v
|
except Exception as e:
|
||||||
|
raise ValueError(f"Ошибка парсинга клавиатуры: {e}")
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from telegram.ext import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from .states import States
|
from .states import BotStates as States
|
||||||
from .session import SessionStore
|
from .session import SessionStore
|
||||||
from .wizard import EditorWizard
|
from .wizard import EditorWizard
|
||||||
|
|
||||||
|
|||||||
114
app/bots/editor/router.py
Normal file
114
app/bots/editor/router.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Маршрутизация команд бота."""
|
||||||
|
from telegram.ext import (
|
||||||
|
Application, CommandHandler, MessageHandler, CallbackQueryHandler,
|
||||||
|
ConversationHandler, filters
|
||||||
|
)
|
||||||
|
|
||||||
|
from .states import BotStates
|
||||||
|
from .handlers.base import start, help_command, cancel
|
||||||
|
from .handlers.templates import (
|
||||||
|
start_template_creation,
|
||||||
|
handle_template_type,
|
||||||
|
handle_template_name,
|
||||||
|
handle_template_text,
|
||||||
|
handle_template_keyboard,
|
||||||
|
list_templates
|
||||||
|
)
|
||||||
|
from .handlers.posts import (
|
||||||
|
newpost,
|
||||||
|
choose_channel,
|
||||||
|
choose_type,
|
||||||
|
choose_format,
|
||||||
|
enter_text,
|
||||||
|
choose_template_open,
|
||||||
|
choose_template_apply,
|
||||||
|
choose_template_preview,
|
||||||
|
choose_template_navigate,
|
||||||
|
choose_template_cancel,
|
||||||
|
preview_collect_vars,
|
||||||
|
preview_confirm,
|
||||||
|
enter_media,
|
||||||
|
edit_keyboard,
|
||||||
|
confirm_send,
|
||||||
|
enter_schedule
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_handlers(app: Application) -> None:
|
||||||
|
"""Регистрация обработчиков команд."""
|
||||||
|
# Базовые команды
|
||||||
|
app.add_handler(CommandHandler("start", start))
|
||||||
|
app.add_handler(CommandHandler("help", help_command))
|
||||||
|
|
||||||
|
# Шаблоны
|
||||||
|
template_handler = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("newtemplate", start_template_creation)],
|
||||||
|
states={
|
||||||
|
BotStates.TPL_TYPE: [
|
||||||
|
CallbackQueryHandler(handle_template_type)
|
||||||
|
],
|
||||||
|
BotStates.TPL_NAME: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_name)
|
||||||
|
],
|
||||||
|
BotStates.TPL_TEXT: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_text)
|
||||||
|
],
|
||||||
|
BotStates.TPL_NEW_KB: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_template_keyboard)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("cancel", cancel)]
|
||||||
|
)
|
||||||
|
app.add_handler(template_handler)
|
||||||
|
|
||||||
|
# Создание поста
|
||||||
|
post_handler = ConversationHandler(
|
||||||
|
entry_points=[CommandHandler("newpost", newpost)],
|
||||||
|
states={
|
||||||
|
BotStates.CHOOSE_CHANNEL: [
|
||||||
|
CallbackQueryHandler(choose_channel, pattern=r"^channel:")
|
||||||
|
],
|
||||||
|
BotStates.CHOOSE_TYPE: [
|
||||||
|
CallbackQueryHandler(choose_type, pattern=r"^type:")
|
||||||
|
],
|
||||||
|
BotStates.CHOOSE_FORMAT: [
|
||||||
|
CallbackQueryHandler(choose_format, pattern=r"^fmt:")
|
||||||
|
],
|
||||||
|
BotStates.ENTER_TEXT: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text),
|
||||||
|
CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$")
|
||||||
|
],
|
||||||
|
BotStates.SELECT_TEMPLATE: [
|
||||||
|
CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"),
|
||||||
|
CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"),
|
||||||
|
CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$")
|
||||||
|
],
|
||||||
|
BotStates.PREVIEW_VARS: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars)
|
||||||
|
],
|
||||||
|
BotStates.PREVIEW_CONFIRM: [
|
||||||
|
CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"),
|
||||||
|
CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$")
|
||||||
|
],
|
||||||
|
BotStates.ENTER_MEDIA: [
|
||||||
|
MessageHandler(
|
||||||
|
filters.PHOTO | filters.VIDEO | filters.ANIMATION & ~filters.COMMAND,
|
||||||
|
enter_media
|
||||||
|
)
|
||||||
|
],
|
||||||
|
BotStates.EDIT_KEYBOARD: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard)
|
||||||
|
],
|
||||||
|
BotStates.CONFIRM_SEND: [
|
||||||
|
CallbackQueryHandler(confirm_send, pattern=r"^send:")
|
||||||
|
],
|
||||||
|
BotStates.ENTER_SCHEDULE: [
|
||||||
|
MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fallbacks=[CommandHandler("cancel", cancel)]
|
||||||
|
)
|
||||||
|
app.add_handler(post_handler)
|
||||||
|
|
||||||
|
# Просмотр шаблонов
|
||||||
|
app.add_handler(CommandHandler("templates", list_templates))
|
||||||
@@ -1,47 +1,150 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, Optional
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional, List
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from app.bots.editor.messages import MessageType
|
||||||
|
from app.models.post import PostType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_TTL = 60 * 60 # 1 час
|
DEFAULT_TTL = 60 * 60 # 1 час
|
||||||
|
|
||||||
|
# Тип сообщения, используемый в сессии
|
||||||
|
SessionType = MessageType | PostType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class UserSession:
|
class UserSession:
|
||||||
|
"""Сессия пользователя при создании поста."""
|
||||||
|
|
||||||
|
# Основные данные поста
|
||||||
channel_id: Optional[int] = None
|
channel_id: Optional[int] = None
|
||||||
type: Optional[str] = None # text/photo/video/animation
|
type: Optional[SessionType] = None
|
||||||
parse_mode: Optional[str] = None # HTML/MarkdownV2
|
parse_mode: Optional[str] = None # HTML/MarkdownV2
|
||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
media_file_id: Optional[str] = None
|
media_file_id: Optional[str] = None
|
||||||
keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]}
|
keyboard: Optional[dict] = None # {"rows": [[{"text","url"}], ...]}
|
||||||
|
|
||||||
|
# Данные шаблона
|
||||||
|
template_name: Optional[str] = None
|
||||||
|
template_id: Optional[str] = None
|
||||||
|
template_vars: Dict[str, str] = field(default_factory=dict)
|
||||||
|
missing_vars: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Метаданные отправки
|
||||||
|
schedule_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
def update(self, data: Dict[str, Any]) -> None:
|
||||||
|
"""Обновляет поля сессии из словаря."""
|
||||||
|
for key, value in data.items():
|
||||||
|
if hasattr(self, key):
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
last_activity: float = field(default_factory=time.time)
|
last_activity: float = field(default_factory=time.time)
|
||||||
|
state: Optional[int] = None
|
||||||
|
|
||||||
def touch(self) -> None:
|
def touch(self) -> None:
|
||||||
|
"""Обновляет время последней активности."""
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Очищает все данные сессии."""
|
||||||
|
self.channel_id = None
|
||||||
|
self.type = None
|
||||||
|
self.parse_mode = None
|
||||||
|
self.text = None
|
||||||
|
self.media_file_id = None
|
||||||
|
self.keyboard = None
|
||||||
|
self.template_name = None
|
||||||
|
self.template_vars.clear()
|
||||||
|
self.missing_vars.clear()
|
||||||
|
self.state = None
|
||||||
|
self.touch()
|
||||||
|
|
||||||
|
def is_complete(self) -> bool:
|
||||||
|
"""Проверяет, заполнены ли все необходимые поля."""
|
||||||
|
if not self.channel_id or not self.type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.type == MessageType.TEXT:
|
||||||
|
return bool(self.text)
|
||||||
|
else:
|
||||||
|
return bool(self.text and self.media_file_id)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Конвертирует сессию в словарь для отправки."""
|
||||||
|
return {
|
||||||
|
"type": self.type.value if self.type else None,
|
||||||
|
"text": self.text,
|
||||||
|
"media_file_id": self.media_file_id,
|
||||||
|
"parse_mode": self.parse_mode or "HTML",
|
||||||
|
"keyboard": self.keyboard,
|
||||||
|
"template_id": self.template_id,
|
||||||
|
"template_name": self.template_name,
|
||||||
|
"template_vars": self.template_vars
|
||||||
|
}
|
||||||
|
|
||||||
|
as_dict = to_dict
|
||||||
|
|
||||||
|
|
||||||
class SessionStore:
|
class SessionStore:
|
||||||
"""Простое и быстрое in-memory хранилище с авто-очисткой."""
|
"""Thread-safe хранилище сессий с автоочисткой."""
|
||||||
|
|
||||||
|
_instance: Optional["SessionStore"] = None
|
||||||
|
|
||||||
def __init__(self, ttl: int = DEFAULT_TTL) -> None:
|
def __init__(self, ttl: int = DEFAULT_TTL) -> None:
|
||||||
self._data: Dict[int, UserSession] = {}
|
self._data: Dict[int, UserSession] = {}
|
||||||
self._ttl = ttl
|
self._ttl = ttl
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> "SessionStore":
|
||||||
|
"""Возвращает глобальный экземпляр."""
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
def get(self, uid: int) -> UserSession:
|
def get(self, uid: int) -> UserSession:
|
||||||
s = self._data.get(uid)
|
"""Получает или создает сессию пользователя."""
|
||||||
if not s:
|
with self._lock:
|
||||||
s = UserSession()
|
s = self._data.get(uid)
|
||||||
self._data[uid] = s
|
if not s:
|
||||||
s.touch()
|
s = UserSession()
|
||||||
self._cleanup()
|
self._data[uid] = s
|
||||||
return s
|
s.touch()
|
||||||
|
self._cleanup()
|
||||||
|
return s
|
||||||
|
|
||||||
def drop(self, uid: int) -> None:
|
def drop(self, uid: int) -> None:
|
||||||
self._data.pop(uid, None)
|
"""Удаляет сессию пользователя."""
|
||||||
|
with self._lock:
|
||||||
def _cleanup(self) -> None:
|
if uid in self._data:
|
||||||
now = time.time()
|
logger.info(f"Dropping session for user {uid}")
|
||||||
for uid in list(self._data.keys()):
|
|
||||||
if now - self._data[uid].last_activity > self._ttl:
|
|
||||||
del self._data[uid]
|
del self._data[uid]
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
"""Удаляет истекшие сессии."""
|
||||||
|
now = time.time()
|
||||||
|
expired = []
|
||||||
|
|
||||||
|
for uid, session in self._data.items():
|
||||||
|
if now - session.last_activity > self._ttl:
|
||||||
|
expired.append(uid)
|
||||||
|
|
||||||
|
for uid in expired:
|
||||||
|
logger.info(f"Session expired for user {uid}")
|
||||||
|
del self._data[uid]
|
||||||
|
|
||||||
|
def get_active_count(self) -> int:
|
||||||
|
"""Возвращает количество активных сессий."""
|
||||||
|
return len(self._data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_store() -> SessionStore:
|
||||||
|
"""Возвращает глобальный экземпляр хранилища сессий."""
|
||||||
|
return SessionStore.get_instance()
|
||||||
|
|||||||
@@ -1,23 +1,88 @@
|
|||||||
from __future__ import annotations
|
"""Состояния бота редактора."""
|
||||||
from enum import IntEnum
|
from enum import IntEnum, auto
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
class BotStates(IntEnum):
|
||||||
class States(IntEnum):
|
"""Состояния для ConversationHandler."""
|
||||||
CHOOSE_CHANNEL = 0
|
|
||||||
CHOOSE_TYPE = 1
|
# Общие состояния
|
||||||
CHOOSE_FORMAT = 2
|
CONVERSATION_END = -1
|
||||||
ENTER_TEXT = 3
|
START = 1
|
||||||
ENTER_MEDIA = 4
|
MAIN_MENU = 2
|
||||||
EDIT_KEYBOARD = 5
|
|
||||||
CONFIRM_SEND = 6
|
# Состояния создания шаблона
|
||||||
ENTER_SCHEDULE = 7
|
TPL_TYPE = 10
|
||||||
|
TPL_NAME = 11
|
||||||
SELECT_TEMPLATE = 8
|
TPL_TEXT = 12
|
||||||
PREVIEW_VARS = 9
|
TPL_NEW_KB = 13
|
||||||
PREVIEW_CONFIRM = 10
|
TPL_SELECT = 14
|
||||||
|
TPL_NEW_NAME = 15
|
||||||
TPL_NEW_NAME = 11
|
TPL_NEW_TYPE = 16
|
||||||
TPL_NEW_TYPE = 12
|
TPL_NEW_FORMAT = 17
|
||||||
TPL_NEW_FORMAT = 13
|
TPL_NEW_CONTENT = 18
|
||||||
TPL_NEW_CONTENT = 14
|
TEMPLATE_PREVIEW = 19
|
||||||
TPL_NEW_KB = 15
|
TEMPLATE_VARS = 20
|
||||||
|
|
||||||
|
# Состояния создания поста
|
||||||
|
CREATE_POST = 30
|
||||||
|
CHOOSE_CHANNEL = 31 # Выбор канала
|
||||||
|
CHOOSE_TYPE = 32 # Выбор типа поста (текст/фото/видео/gif)
|
||||||
|
CHOOSE_FORMAT = 33 # Выбор формата текста (HTML/Markdown)
|
||||||
|
ENTER_TEXT = 34 # Ввод текста поста
|
||||||
|
ENTER_MEDIA = 35 # Загрузка медиафайла
|
||||||
|
EDIT_KEYBOARD = 36 # Редактирование клавиатуры
|
||||||
|
CONFIRM_SEND = 37 # Подтверждение отправки
|
||||||
|
ENTER_SCHEDULE = 38 # Ввод времени для отложенной публикации
|
||||||
|
SELECT_TEMPLATE = 39 # Выбор шаблона
|
||||||
|
PREVIEW_VARS = 40 # Ввод значений для переменных
|
||||||
|
PREVIEW_CONFIRM = 41 # Подтверждение предпросмотра
|
||||||
|
|
||||||
|
# Состояния работы с каналами
|
||||||
|
CHANNEL_NAME = 50
|
||||||
|
CHANNEL_DESC = 51
|
||||||
|
CHANNEL_INVITE = 52
|
||||||
|
|
||||||
|
# Состояния управления ботами и каналами
|
||||||
|
BOT_TOKEN = 60 # Ввод токена бота
|
||||||
|
CHANNEL_ID = 61 # Ввод идентификатора канала
|
||||||
|
CHANNEL_TITLE = 62 # Ввод имени канала
|
||||||
|
CHANNEL_SELECT_BOT = 63 # Выбор бота для канала
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_description(cls, state: int) -> str:
|
||||||
|
"""Возвращает описание состояния."""
|
||||||
|
descriptions: Dict[int, str] = {
|
||||||
|
# Общие состояния
|
||||||
|
cls.CONVERSATION_END: "Завершение диалога",
|
||||||
|
|
||||||
|
# Шаблоны
|
||||||
|
cls.TPL_TYPE: "Выбор типа шаблона",
|
||||||
|
cls.TPL_NAME: "Ввод имени шаблона",
|
||||||
|
cls.TPL_TEXT: "Ввод текста шаблона",
|
||||||
|
cls.TPL_NEW_KB: "Ввод клавиатуры шаблона",
|
||||||
|
cls.TPL_SELECT: "Выбор шаблона",
|
||||||
|
cls.TPL_NEW_CONTENT: "Ввод содержимого шаблона",
|
||||||
|
|
||||||
|
# Посты
|
||||||
|
cls.CHOOSE_CHANNEL: "Выбор канала",
|
||||||
|
cls.CHOOSE_TYPE: "Выбор типа поста",
|
||||||
|
cls.CHOOSE_FORMAT: "Выбор формата",
|
||||||
|
cls.ENTER_TEXT: "Ввод текста",
|
||||||
|
cls.ENTER_MEDIA: "Загрузка медиа",
|
||||||
|
cls.EDIT_KEYBOARD: "Редактирование клавиатуры",
|
||||||
|
cls.CONFIRM_SEND: "Подтверждение отправки",
|
||||||
|
cls.ENTER_SCHEDULE: "Планирование публикации",
|
||||||
|
cls.SELECT_TEMPLATE: "Выбор шаблона",
|
||||||
|
cls.PREVIEW_VARS: "Ввод значений переменных",
|
||||||
|
cls.PREVIEW_CONFIRM: "Подтверждение предпросмотра",
|
||||||
|
|
||||||
|
# Каналы и боты
|
||||||
|
cls.CHANNEL_NAME: "Ввод имени канала",
|
||||||
|
cls.CHANNEL_DESC: "Ввод описания канала",
|
||||||
|
cls.CHANNEL_INVITE: "Ввод инвайт-ссылки",
|
||||||
|
cls.BOT_TOKEN: "Ввод токена бота",
|
||||||
|
cls.CHANNEL_ID: "Ввод ID канала",
|
||||||
|
cls.CHANNEL_TITLE: "Ввод названия канала",
|
||||||
|
cls.CHANNEL_SELECT_BOT: "Выбор бота для канала",
|
||||||
|
}
|
||||||
|
return descriptions.get(state, f"Неизвестное состояние {state}")
|
||||||
|
|||||||
49
app/bots/editor/template.py
Normal file
49
app/bots/editor/template.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Модуль для работы с шаблонами."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.templates import Template
|
||||||
|
|
||||||
|
|
||||||
|
async def render_template_by_name(
|
||||||
|
name: str,
|
||||||
|
template_vars: Dict[str, Any],
|
||||||
|
context: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Рендеринг шаблона по имени.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя шаблона
|
||||||
|
template_vars: Переменные для подстановки
|
||||||
|
context: Дополнительный контекст
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отрендеренные данные для поста
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Если шаблон не найден
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = Template.__table__.select().where(Template.__table__.c.name == name)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
raise ValueError(f"Шаблон {name} не найден")
|
||||||
|
|
||||||
|
text = template.content
|
||||||
|
keyboard = template.keyboard_tpl
|
||||||
|
|
||||||
|
# Подстановка переменных
|
||||||
|
for key, value in template_vars.items():
|
||||||
|
text = text.replace(f"{{${key}}}", str(value))
|
||||||
|
|
||||||
|
# Подготовка данных для отправки
|
||||||
|
return {
|
||||||
|
"type": template.type,
|
||||||
|
"text": text,
|
||||||
|
"keyboard": keyboard,
|
||||||
|
"parse_mode": template.parse_mode
|
||||||
|
}
|
||||||
24
app/bots/editor/templates.py
Normal file
24
app/bots/editor/templates.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Функции для работы с шаблонами."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.templates import Template
|
||||||
|
|
||||||
|
|
||||||
|
async def list_templates(owner_id: Optional[int] = None):
|
||||||
|
"""Получение списка шаблонов."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(Template)
|
||||||
|
if owner_id:
|
||||||
|
stmt = stmt.filter(Template.owner_id == owner_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def create_template(template_data: dict):
|
||||||
|
"""Создание нового шаблона."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
template = Template(**template_data)
|
||||||
|
session.add(template)
|
||||||
|
await session.commit()
|
||||||
0
app/bots/editor/utils/__init__.py
Normal file
0
app/bots/editor/utils/__init__.py
Normal file
56
app/bots/editor/utils/parsers.py
Normal file
56
app/bots/editor/utils/parsers.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Утилиты для парсинга данных."""
|
||||||
|
from typing import Dict, Any, List, Set
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
def extract_variables(text: str) -> Set[str]:
|
||||||
|
"""Извлекает переменные из шаблона."""
|
||||||
|
if not text:
|
||||||
|
return set()
|
||||||
|
return set(re.findall(r'\{([^}]+)\}', text))
|
||||||
|
|
||||||
|
def validate_url(url: str) -> bool:
|
||||||
|
"""Проверяет валидность URL."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return bool(parsed.scheme and parsed.netloc)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_key_value_lines(text: str) -> Dict[str, Any]:
|
||||||
|
"""Парсинг клавиатуры из текста формата 'текст = ссылка'."""
|
||||||
|
keyboard = {"rows": []}
|
||||||
|
current_row = []
|
||||||
|
|
||||||
|
lines = text.strip().split('\n')
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line == "---":
|
||||||
|
if current_row:
|
||||||
|
keyboard["rows"].append(current_row)
|
||||||
|
current_row = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split('=', 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"Неверный формат строки: {line}")
|
||||||
|
|
||||||
|
text, url = parts[0].strip(), parts[1].strip()
|
||||||
|
|
||||||
|
# Проверка URL
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
raise ValueError(f"Некорректный URL: {url}")
|
||||||
|
except Exception:
|
||||||
|
raise ValueError(f"Некорректный URL: {url}")
|
||||||
|
|
||||||
|
current_row.append({"text": text, "url": url})
|
||||||
|
|
||||||
|
if current_row:
|
||||||
|
keyboard["rows"].append(current_row)
|
||||||
|
|
||||||
|
return keyboard
|
||||||
9
app/bots/editor/utils/validation.py
Normal file
9
app/bots/editor/utils/validation.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Утилиты для валидации данных."""
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
def validate_template_name(name: str) -> bool:
|
||||||
|
"""Проверка корректности имени шаблона."""
|
||||||
|
if not name or len(name) > 50:
|
||||||
|
return False
|
||||||
|
return bool(re.match(r'^[\w\-]+$', name))
|
||||||
@@ -1,27 +1,42 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
from telegram import Update
|
from telegram import Update, Message, InlineKeyboardMarkup, CallbackQuery
|
||||||
from telegram.ext import CallbackContext
|
from telegram.ext import CallbackContext
|
||||||
|
from telegram.error import TelegramError
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.tasks.senders import send_post_task
|
from app.tasks.senders import send_post_task
|
||||||
from app.db.session import async_session_maker
|
from app.db.session import async_session_maker
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
|
from app.models.bot import Bot
|
||||||
|
from app.models.templates import Template
|
||||||
|
from app.models.user import User
|
||||||
|
from .states import BotStates as States # Алиас для совместимости
|
||||||
|
from app.services.template import list_templates, create_template
|
||||||
from app.services.templates import (
|
from app.services.templates import (
|
||||||
render_template_by_name, list_templates, count_templates,
|
render_template_by_name, count_templates,
|
||||||
create_template, delete_template, required_variables_of_template,
|
required_variables_of_template, delete_template
|
||||||
)
|
)
|
||||||
|
from app.services.telegram import validate_bot_token
|
||||||
from jinja2 import TemplateError
|
from jinja2 import TemplateError
|
||||||
|
from .session import SessionStore, UserSession
|
||||||
from .states import States
|
from .messages import Messages, MessageParsers, MessageType
|
||||||
from .session import SessionStore
|
|
||||||
from .messages import MessageParsers
|
|
||||||
from .keyboards import KbBuilder
|
from .keyboards import KbBuilder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MEDIA_TYPE_MAP = {
|
||||||
|
MessageType.PHOTO: "фото",
|
||||||
|
MessageType.VIDEO: "видео",
|
||||||
|
MessageType.ANIMATION: "GIF-анимацию"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Заглушка для build_payload, если сервиса нет
|
# Заглушка для build_payload, если сервиса нет
|
||||||
try:
|
try:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
0
app/bots/states/__init__.py
Normal file
0
app/bots/states/__init__.py
Normal file
@@ -1,18 +1,9 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from enum import IntEnum
|
|
||||||
from telegram import Update
|
from telegram import Update
|
||||||
from telegram.ext import CallbackContext
|
from telegram.ext import CallbackContext
|
||||||
|
from ..editor.states import BotStates
|
||||||
|
|
||||||
class State(ABC):
|
class State(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def handle(self, update: Update, context: CallbackContext) -> int:
|
async def handle(self, update: Update, context: CallbackContext) -> int:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class BotStates(IntEnum):
|
|
||||||
CHOOSE_CHANNEL = 0
|
|
||||||
CHOOSE_TYPE = 1
|
|
||||||
ENTER_TEXT = 2
|
|
||||||
ENTER_MEDIA = 3
|
|
||||||
EDIT_KEYBOARD = 4
|
|
||||||
CONFIRM_SEND = 5
|
|
||||||
ENTER_SCHEDULE = 6
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import ForeignKey, String, func, DateTime
|
from sqlalchemy import ForeignKey, String, func, DateTime, Column
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ class Bot(Base):
|
|||||||
__tablename__ = "bots"
|
__tablename__ = "bots"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
bot_id: Mapped[int] = mapped_column() # Telegram bot ID
|
||||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
name: Mapped[str] = mapped_column(String(64))
|
name: Mapped[str] = mapped_column(String(64))
|
||||||
username: Mapped[str | None] = mapped_column(String(64))
|
username: Mapped[str | None] = mapped_column(String(64))
|
||||||
@@ -15,3 +16,4 @@ class Bot(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
owner = relationship("User")
|
owner = relationship("User")
|
||||||
|
channels = relationship("Channel", back_populates="bot")
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from datetime import datetime
|
|||||||
from sqlalchemy import ForeignKey, String, BigInteger, Boolean, UniqueConstraint, func, DateTime
|
from sqlalchemy import ForeignKey, String, BigInteger, Boolean, UniqueConstraint, func, DateTime
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
from app.models.user import User # Добавляем импорт User
|
from app.models.user import User
|
||||||
|
from app.models.bot import Bot
|
||||||
|
|
||||||
class Channel(Base):
|
class Channel(Base):
|
||||||
__tablename__ = "channels"
|
__tablename__ = "channels"
|
||||||
@@ -11,12 +12,14 @@ class Channel(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
chat_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
bot_id: Mapped[int] = mapped_column(ForeignKey("bots.id", ondelete="CASCADE"))
|
||||||
|
chat_id: Mapped[int] = mapped_column(index=True)
|
||||||
title: Mapped[str | None] = mapped_column(String(128))
|
title: Mapped[str | None] = mapped_column(String(128))
|
||||||
username: Mapped[str | None] = mapped_column(String(64))
|
username: Mapped[str | None] = mapped_column(String(64))
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
owner = relationship("User")
|
owner = relationship("User")
|
||||||
|
bot = relationship("Bot", back_populates="channels")
|
||||||
|
|
||||||
class BotChannel(Base):
|
class BotChannel(Base):
|
||||||
__tablename__ = "bot_channels"
|
__tablename__ = "bot_channels"
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class Post(Base):
|
|||||||
bot_id: Mapped[Optional[int]] = mapped_column(ForeignKey("bots.id", ondelete="SET NULL"), nullable=True)
|
bot_id: Mapped[Optional[int]] = mapped_column(ForeignKey("bots.id", ondelete="SET NULL"), nullable=True)
|
||||||
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
channel_id: Mapped[int] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
|
||||||
|
|
||||||
type: Mapped[PostType] = mapped_column(Enum(PostType))
|
type: Mapped[PostType] = mapped_column()
|
||||||
text: Mapped[Optional[str]] = mapped_column(String(4096))
|
text: Mapped[str | None] = mapped_column(String(4096))
|
||||||
media_file_id: Mapped[Optional[str]] = mapped_column(String(512))
|
media_file_id: Mapped[Optional[str]] = mapped_column(String(512))
|
||||||
parse_mode: Mapped[Optional[str]] = mapped_column(String(16))
|
parse_mode: Mapped[Optional[str]] = mapped_column(String(16))
|
||||||
keyboard_id: Mapped[Optional[int]] = mapped_column(ForeignKey("keyboards.id", ondelete="SET NULL"))
|
keyboard_id: Mapped[Optional[int]] = mapped_column(ForeignKey("keyboards.id", ondelete="SET NULL"))
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ from app.db.session import Base
|
|||||||
from app.models.post import PostType
|
from app.models.post import PostType
|
||||||
from enum import Enum as PyEnum
|
from enum import Enum as PyEnum
|
||||||
|
|
||||||
|
class PostStatus(str, PyEnum):
|
||||||
|
draft = "draft"
|
||||||
|
scheduled = "scheduled"
|
||||||
|
sent = "sent"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
class TemplateVisibility(str, PyEnum):
|
class TemplateVisibility(str, PyEnum):
|
||||||
private = "private"
|
private = "private"
|
||||||
org = "org"
|
org = "org"
|
||||||
@@ -18,6 +24,58 @@ class Template(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("owner_id", "name", name="uq_template_owner_name"),
|
UniqueConstraint("owner_id", "name", name="uq_template_owner_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
title: Mapped[str] = mapped_column(String(200))
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
keyboard_tpl: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
parse_mode: Mapped[str] = mapped_column(String(20), default="HTML")
|
||||||
|
type: Mapped[PostType] = mapped_column(Enum(PostType))
|
||||||
|
visibility: Mapped[TemplateVisibility] = mapped_column(
|
||||||
|
EnumType(TemplateVisibility),
|
||||||
|
default=TemplateVisibility.private
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_name(cls, session, name: str) -> Optional[Template]:
|
||||||
|
"""Получение шаблона по имени."""
|
||||||
|
stmt = cls.__table__.select().where(cls.__table__.c.name == name)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def render(self, template_vars: dict, context: dict) -> dict:
|
||||||
|
"""Рендеринг шаблона.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_vars: Переменные для подстановки
|
||||||
|
context: Дополнительный контекст
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отрендеренные данные
|
||||||
|
"""
|
||||||
|
text = self.content
|
||||||
|
keyboard = self.keyboard_tpl
|
||||||
|
|
||||||
|
# Подстановка переменных
|
||||||
|
for key, value in template_vars.items():
|
||||||
|
text = text.replace(f"{{${key}}}", str(value))
|
||||||
|
|
||||||
|
# Подготовка данных для отправки
|
||||||
|
return {
|
||||||
|
"type": self.type,
|
||||||
|
"text": text,
|
||||||
|
"keyboard": keyboard,
|
||||||
|
"parse_mode": self.parse_mode
|
||||||
|
}
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class User(Base):
|
|||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
tg_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
tg_user_id: Mapped[int] = mapped_column(unique=True, index=True)
|
||||||
username: Mapped[str | None] = mapped_column(String(64))
|
username: Mapped[str | None] = mapped_column(String(64))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user")
|
role: Mapped[str] = mapped_column(String(16), default="user")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
93
app/services/channels.py
Normal file
93
app/services/channels.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Сервис для работы с каналами."""
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.channel import Channel, BotChannel
|
||||||
|
from app.models.bot import Bot
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
|
||||||
|
class ChannelService:
|
||||||
|
"""Сервис для работы с каналами."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_user_channels(user_id: int) -> List[Channel]:
|
||||||
|
"""Получает список каналов пользователя."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(Channel).where(Channel.owner_id == user_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_channel(channel_id: int) -> Optional[Channel]:
|
||||||
|
"""Получает канал по ID."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(Channel).where(Channel.id == channel_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_bot_channels(bot_id: int) -> List[Channel]:
|
||||||
|
"""Получает список каналов бота."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(Channel).where(Channel.bot_id == bot_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def add_channel(
|
||||||
|
owner_id: int,
|
||||||
|
bot_id: int,
|
||||||
|
chat_id: int,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
username: Optional[str] = None
|
||||||
|
) -> Channel:
|
||||||
|
"""Добавляет новый канал."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
channel = Channel(
|
||||||
|
owner_id=owner_id,
|
||||||
|
bot_id=bot_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
title=title,
|
||||||
|
username=username
|
||||||
|
)
|
||||||
|
session.add(channel)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(channel)
|
||||||
|
return channel
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_channel(
|
||||||
|
channel_id: int,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
username: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Обновляет данные канала."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(Channel).where(Channel.id == channel_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
channel = result.scalars().first()
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if title is not None:
|
||||||
|
channel.title = title
|
||||||
|
if username is not None:
|
||||||
|
channel.username = username
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_channel(channel_id: int) -> bool:
|
||||||
|
"""Удаляет канал."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = select(Channel).where(Channel.id == channel_id)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
channel = result.scalars().first()
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await session.delete(channel)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
@@ -1,19 +1,214 @@
|
|||||||
from typing import Iterable
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional, Iterable, Tuple
|
||||||
|
|
||||||
def make_keyboard_payload(buttons: Iterable[tuple[str, str]] | None):
|
from telegram import Bot, Message, InlineKeyboardMarkup
|
||||||
|
from telegram.error import InvalidToken, TelegramError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def make_keyboard_payload(buttons: Optional[Iterable[Tuple[str, str]]]) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Создает структуру inline-клавиатуры для API Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buttons: Список кнопок в формате [(text, url), ...]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict в формате {"rows": [[{"text": text, "url": url}], ...]}
|
||||||
|
"""
|
||||||
if not buttons:
|
if not buttons:
|
||||||
return None
|
return None
|
||||||
rows = [[{"text": t, "url": u}] for t, u in buttons]
|
rows = [[{"text": t, "url": u}] for t, u in buttons]
|
||||||
return {"rows": rows}
|
return {"rows": rows}
|
||||||
|
|
||||||
|
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 для отправки поста.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ptype: Тип поста (text/photo/video/animation)
|
||||||
|
text: Текст сообщения
|
||||||
|
media_file_id: ID медиафайла в Telegram
|
||||||
|
parse_mode: Формат разметки (HTML/MarkdownV2)
|
||||||
|
keyboard: Inline клавиатура
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict содержащий все необходимые поля для отправки
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
def build_payload(ptype: str, text: str | None, media_file_id: str | None,
|
async def validate_bot_token(token: str) -> Tuple[bool, Optional[str], Optional[int]]:
|
||||||
parse_mode: str | None, keyboard: dict | None) -> dict:
|
"""
|
||||||
# ptype: "text" | "photo" | "video" | "animation"
|
Проверяет валидность токена бота и возвращает его username и ID.
|
||||||
return {
|
|
||||||
"type": ptype,
|
Args:
|
||||||
"text": text,
|
token: Токен бота для проверки
|
||||||
"media_file_id": media_file_id,
|
|
||||||
"parse_mode": parse_mode,
|
Returns:
|
||||||
"keyboard": keyboard,
|
tuple[bool, Optional[str], Optional[int]]: (is_valid, username, bot_id)
|
||||||
}
|
"""
|
||||||
|
try:
|
||||||
|
bot = Bot(token)
|
||||||
|
me = await bot.get_me()
|
||||||
|
return True, me.username, me.id
|
||||||
|
except InvalidToken:
|
||||||
|
logger.warning(f"Invalid bot token provided: {token[:10]}...")
|
||||||
|
return False, None, None
|
||||||
|
except TelegramError as e:
|
||||||
|
logger.error(f"Telegram error while validating bot token: {e}")
|
||||||
|
return False, None, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Unexpected error while validating bot token: {e}")
|
||||||
|
return False, None, None
|
||||||
|
finally:
|
||||||
|
if 'bot' in locals():
|
||||||
|
await bot.close()
|
||||||
|
|
||||||
|
def validate_message_length(text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет длину сообщения на соответствие лимитам Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если длина в пределах лимита
|
||||||
|
"""
|
||||||
|
return len(text) <= 4096 # Максимальная длина текста в Telegram
|
||||||
|
|
||||||
|
def is_valid_webhook_url(url: str) -> bool:
|
||||||
|
"""Проверяет соответствие URL требованиям Telegram для вебхуков.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если URL валидный, иначе False
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True # TODO: implement proper validation
|
||||||
|
|
||||||
|
|
||||||
|
class PostService:
|
||||||
|
"""Сервис для работы с постами."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def preview_post(message: Message, post_data: Dict[str, Any]) -> None:
|
||||||
|
"""Показывает предпросмотр поста.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message (Message): Telegram сообщение
|
||||||
|
post_data (Dict[str, Any]): Данные поста из сессии
|
||||||
|
"""
|
||||||
|
text = post_data.get('text', '')
|
||||||
|
parse_mode = post_data.get('parse_mode', 'HTML')
|
||||||
|
keyboard = post_data.get('keyboard')
|
||||||
|
|
||||||
|
if keyboard:
|
||||||
|
# Создаем разметку клавиатуры
|
||||||
|
rows = keyboard.get('rows', [])
|
||||||
|
markup = InlineKeyboardMarkup(rows) if rows else None
|
||||||
|
else:
|
||||||
|
markup = None
|
||||||
|
|
||||||
|
media_file_id = post_data.get('media_file_id')
|
||||||
|
if media_file_id:
|
||||||
|
# Отправляем медиафайл с подписью
|
||||||
|
try:
|
||||||
|
await message.reply_photo(
|
||||||
|
photo=media_file_id,
|
||||||
|
caption=text,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
reply_markup=markup
|
||||||
|
)
|
||||||
|
except TelegramError as e:
|
||||||
|
# В случае ошибки отправляем только текст
|
||||||
|
logger.error(f"Error sending photo preview: {e}")
|
||||||
|
await message.reply_text(
|
||||||
|
text=text,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
reply_markup=markup
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Отправляем только текст
|
||||||
|
await message.reply_text(
|
||||||
|
text=text,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
reply_markup=markup
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_post(bot: Bot, chat_id: int, post_data: Dict[str, Any]) -> bool:
|
||||||
|
"""Создает новый пост в канале.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot (Bot): Экземпляр бота
|
||||||
|
chat_id (int): ID канала
|
||||||
|
post_data (Dict[str, Any]): Данные поста
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: Успешность создания
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = post_data.get('text', '')
|
||||||
|
parse_mode = post_data.get('parse_mode', 'HTML')
|
||||||
|
keyboard = post_data.get('keyboard')
|
||||||
|
|
||||||
|
if keyboard:
|
||||||
|
rows = keyboard.get('rows', [])
|
||||||
|
markup = InlineKeyboardMarkup(rows) if rows else None
|
||||||
|
else:
|
||||||
|
markup = None
|
||||||
|
|
||||||
|
media_file_id = post_data.get('media_file_id')
|
||||||
|
if media_file_id:
|
||||||
|
await bot.send_photo(
|
||||||
|
chat_id=chat_id,
|
||||||
|
photo=media_file_id,
|
||||||
|
caption=text,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
reply_markup=markup
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
reply_markup=markup
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except TelegramError as e:
|
||||||
|
logger.error(f"Error creating post: {e}")
|
||||||
|
return False
|
||||||
|
def validate_url(url: str) -> bool:
|
||||||
|
"""Проверяет соответствие URL требованиям.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): URL для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если URL соответствует требованиям
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
url.startswith("https://") and
|
||||||
|
not url.startswith("https://telegram.org") and
|
||||||
|
len(url) <= 512
|
||||||
|
)
|
||||||
121
app/services/template.py
Normal file
121
app/services/template.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Логика работы с шаблонами."""
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.db.session import async_session_maker
|
||||||
|
from app.models.templates import Template
|
||||||
|
from app.models.post import PostType
|
||||||
|
from app.bots.editor.messages import MessageType
|
||||||
|
|
||||||
|
class TemplateService:
|
||||||
|
@staticmethod
|
||||||
|
async def list_user_templates(owner_id: int) -> List[Template]:
|
||||||
|
"""Получить список шаблонов пользователя."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
query = select(Template).where(Template.owner_id == owner_id)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_template(template_id: str) -> Optional[Template]:
|
||||||
|
"""Получить шаблон по ID."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
query = select(Template).where(Template.id == template_id)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_templates(owner_id: Optional[int] = None, limit: Optional[int] = None, offset: Optional[int] = None) -> list[Template]:
|
||||||
|
"""Получить список всех шаблонов.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner_id: Опциональный ID владельца
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Template]: Список шаблонов
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
query = Template.__table__.select()
|
||||||
|
if owner_id is not None:
|
||||||
|
query = query.where(Template.__table__.c.owner_id == owner_id)
|
||||||
|
if offset is not None:
|
||||||
|
query = query.offset(offset)
|
||||||
|
if limit is not None:
|
||||||
|
query = query.limit(limit)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return list(result.scalars())
|
||||||
|
|
||||||
|
async def create_template(template_data: Dict[str, Any]) -> Template:
|
||||||
|
"""Создать новый шаблон.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_data: Данные шаблона
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Template: Созданный шаблон
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
template = Template(**template_data)
|
||||||
|
session.add(template)
|
||||||
|
await session.commit()
|
||||||
|
return template
|
||||||
|
|
||||||
|
async def render_template_by_name(
|
||||||
|
name: str,
|
||||||
|
template_vars: Dict[str, Any],
|
||||||
|
context: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Рендеринг шаблона по имени.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя шаблона
|
||||||
|
template_vars: Переменные для подстановки
|
||||||
|
context: Дополнительный контекст
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Отрендеренные данные для поста
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
stmt = Template.__table__.select().where(Template.__table__.c.name == name)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
template = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
raise ValueError(f"Шаблон {name} не найден")
|
||||||
|
|
||||||
|
text = template.content
|
||||||
|
keyboard = template.keyboard_tpl
|
||||||
|
|
||||||
|
# Подстановка переменных
|
||||||
|
for key, value in template_vars.items():
|
||||||
|
text = text.replace(f"{{${key}}}", str(value))
|
||||||
|
|
||||||
|
# Проверяем тип и конвертируем в MessageType
|
||||||
|
message_type = MessageType.TEXT
|
||||||
|
if template.type == PostType.photo:
|
||||||
|
message_type = MessageType.PHOTO
|
||||||
|
elif template.type == PostType.video:
|
||||||
|
message_type = MessageType.VIDEO
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": message_type,
|
||||||
|
"text": text,
|
||||||
|
"keyboard": keyboard,
|
||||||
|
"parse_mode": template.parse_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
async def count_templates(owner_id: Optional[int] = None) -> int:
|
||||||
|
"""Посчитать количество шаблонов.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner_id: Опциональный ID владельца
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Количество шаблонов
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
query = Template.__table__.select()
|
||||||
|
if owner_id is not None:
|
||||||
|
query = query.where(Template.__table__.c.owner_id == owner_id)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return len(list(result.scalars()))
|
||||||
8
bin/migrate.sh
Executable file
8
bin/migrate.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Creating migration..."
|
||||||
|
docker compose run --rm api bash -c "alembic revision --autogenerate -m 'Add description column to templates'"
|
||||||
|
|
||||||
|
echo "Applying migration..."
|
||||||
|
docker compose run --rm api bash -c "alembic upgrade head"
|
||||||
Reference in New Issue
Block a user