bot rafactor and bugfix

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

93
app/services/channels.py Normal file
View 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

View File

@@ -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:
return None
rows = [[{"text": t, "url": u}] for t, u in buttons]
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,
parse_mode: str | None, keyboard: dict | None) -> dict:
# ptype: "text" | "photo" | "video" | "animation"
return {
"type": ptype,
"text": text,
"media_file_id": media_file_id,
"parse_mode": parse_mode,
"keyboard": keyboard,
}
async def validate_bot_token(token: str) -> Tuple[bool, Optional[str], Optional[int]]:
"""
Проверяет валидность токена бота и возвращает его username и ID.
Args:
token: Токен бота для проверки
Returns:
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
View 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()))