Подготовка к релизу

This commit is contained in:
2025-10-30 18:28:26 +03:00
parent 69c0162c6b
commit 4981db90a0
191 changed files with 45557 additions and 3651 deletions
+14
View File
@@ -0,0 +1,14 @@
"""
Вспомогательные утилиты для телеграм-бота.
Содержит функции валидации, форматирования и общие helpers.
"""
from .validators import Validator, InputValidator
from .formatters import MessageFormatter, KeyboardFormatter
from .helpers import safe_execute, chunk_text, escape_markdown
__all__ = [
'Validator', 'InputValidator',
'MessageFormatter', 'KeyboardFormatter',
'safe_execute', 'chunk_text', 'escape_markdown'
]
+326
View File
@@ -0,0 +1,326 @@
"""
Форматтеры для сообщений и клавиатур телеграм-бота.
Обеспечивают единообразное форматирование вывода.
"""
from typing import Dict, List, Any, Optional
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
class MessageFormatter:
"""Форматтер текстовых сообщений"""
@staticmethod
def format_user_info(user_data: Dict[str, Any]) -> str:
"""Форматирование информации о пользователе"""
if not user_data:
return "👤 <b>Информация о пользователе:</b>\n❌ Данные не найдены"
name = MessageFormatter.escape_html(str(user_data.get('name', 'Неизвестно')))
username = MessageFormatter.escape_html(str(user_data.get('username', 'Не указан')))
return (
"👤 <b>Информация о пользователе:</b>\n"
f"🆔 ID: {user_data.get('id', 'Неизвестен')}\n"
f"👤 Имя: {name}\n"
f"📱 Username: @{username}\n"
f"🏆 Репутация: {user_data.get('reputation', 0)}\n"
f"⭐ Ранг: {MessageFormatter.escape_html(str(user_data.get('rank', 'Новичок')))}\n"
f"💬 Сообщений: {user_data.get('message_count', 0)}\n"
f"📅 Дата регистрации: {user_data.get('joined_date', 'Неизвестна')}"
)
@staticmethod
def format_rank_info(score: int, warnings: int, role: str) -> str:
"""Форматирование информации о ранге"""
return (
"🏆 <b>Ваш ранг:</b>\n"
f"⭐ Очки: {score}\n"
f"⚠️ Предупреждений: {warnings}\n"
f"👑 Роль: {MessageFormatter.escape_html(role)}"
)
@staticmethod
def format_leaderboard(users: List[tuple]) -> str:
"""Форматирование таблицы лидеров"""
if not users:
return "📭 Таблица лидеров пуста"
result = ["🏆 <b>Таблица лидеров:</b>\n"]
for i, (user_id, username, display_name, points) in enumerate(users[:10], 1):
medal = MessageFormatter._get_medal(i)
name = MessageFormatter.escape_html(display_name or username or f"User{user_id}")
result.append(f"{medal} <b>{name}</b> - {points} очков")
return "\n".join(result)
@staticmethod
def _get_medal(position: int) -> str:
"""Получение медали для позиции в таблице лидеров"""
if position == 1:
return "🥇"
elif position == 2:
return "🥈"
elif position == 3:
return "🥉"
elif position == 10:
return "🔟"
else:
return f"{position}."
@staticmethod
def format_weather_info(weather_data: Dict[str, Any]) -> str:
"""Форматирование информации о погоде"""
if not weather_data:
return "🌤️ <b>Погода</b>\n❌ Данные не найдены"
city = MessageFormatter.escape_html(str(weather_data.get('city', 'Неизвестном городе')))
return (
f"🌤️ <b>Погода в {city}</b>\n"
f"🌡️ Температура: {weather_data.get('temp', 0)}°C\n"
f"🌡️ Ощущается как: {weather_data.get('feels_like', 0)}°C\n"
f"💧 Влажность: {weather_data.get('humidity', 0)}%\n"
f"💬 Описание: {MessageFormatter.escape_html(str(weather_data.get('description', 'Нет данных')))}"
)
@staticmethod
def format_news(articles: List[Dict[str, Any]]) -> str:
"""Форматирование списка новостей"""
if not articles:
return "📰 Новости не найдены"
result = ["📰 <b>Последние новости:</b>\n"]
for i, article in enumerate(articles[:10], 1):
title = MessageFormatter.escape_html(article.get('title', 'Без заголовка'))
url = article.get('url', '')
result.append(f"{i}. <b>{title}</b>")
if url:
result.append(f"🔗 {url}")
return "\n".join(result)
@staticmethod
def format_error_report(error_data: Dict[str, Any]) -> str:
"""Форматирование отчета об ошибке"""
if not error_data:
return "🐛 <b>Отчет об ошибке</b>\n❌ Данные не найдены"
error_id = error_data.get('id', 'Неизвестен')
return (
f"🐛 <b>Отчет об ошибке #{error_id}</b>\n"
f"📋 Тип: {MessageFormatter.escape_html(str(error_data.get('type', 'Неизвестен')))}\n"
f"📝 Заголовок: {MessageFormatter.escape_html(str(error_data.get('title', 'Без заголовка')))}\n"
f"⭐ Приоритет: {MessageFormatter.escape_html(str(error_data.get('priority', 'Не указан')))}\n"
f"👤 Создатель: {MessageFormatter.escape_html(str(error_data.get('admin_name', 'Неизвестен')))}\n"
f"📅 Дата создания: {error_data.get('created_at', 'Неизвестна')}\n"
f"📄 Описание:\n{MessageFormatter.escape_html(str(error_data.get('description', 'Без описания')))}"
)
@staticmethod
def format_donation_info(amount: float, points: int) -> str:
"""Форматирование информации о донате"""
return (
"💰 <b>Спасибо за поддержку!</b>\n"
f"💵 Сумма: {amount} RUB\n"
f"⭐ Получено очков: {points}\n"
"🎉 Ваша поддержка помогает развивать бота!"
)
@staticmethod
def format_achievement(achievement_name: str, description: str) -> str:
"""Форматирование достижения"""
return (
"🏆 <b>Новое достижение!</b>\n"
f"🎖 {MessageFormatter.escape_html(achievement_name)}\n"
f"📝 {MessageFormatter.escape_html(description)}"
)
@staticmethod
def format_moderation_info(media_type: str, user_name: str, transcription: str = None) -> str:
"""Форматирование информации для модерации"""
media_types = {
'audio': 'АУДИО',
'video': 'ВИДЕО',
'photo': 'ИЗОБРАЖЕНИЕ',
'document': 'ДОКУМЕНТ'
}
result = (
"🔍 <b>Медиафайл на модерации</b>\n"
f"👤 Пользователь: {MessageFormatter.escape_html(user_name)}\n"
f"📁 Тип файла: {media_types.get(media_type.lower(), media_type.upper())}"
)
if transcription:
result += f"\n🎵 Транскрипция: {MessageFormatter.escape_html(transcription)}"
return result
@staticmethod
def escape_html(text: str) -> str:
"""Экранирование HTML символов"""
if not text:
return ""
return (text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', "&quot;")
.replace("'", "&#x27;"))
@staticmethod
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
"""Усечение текста до указанной длины"""
if not text or len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
class KeyboardFormatter:
"""Форматтер клавиатур"""
@staticmethod
def create_main_menu(user_role: str = "user", chat_type: str = "private") -> InlineKeyboardMarkup:
"""Создание главного меню с учетом роли пользователя и типа чата"""
# Базовые кнопки для всех пользователей
keyboard = [
[InlineKeyboardButton("📋 Помощь", callback_data='menu_help')],
[InlineKeyboardButton("🎮 Мини игры", callback_data='menu_games')],
[InlineKeyboardButton("💰 Донат", callback_data='menu_donate')],
[InlineKeyboardButton("🏆 Таблица лидеров", callback_data='menu_leaderboard')]
]
# Дополнительные кнопки для модераторов и администраторов
if user_role in ["moderator", "admin", "super_admin"]:
keyboard.insert(0, [InlineKeyboardButton("🛡️ Модерация", callback_data='menu_moderation')])
# Дополнительные кнопки для администраторов только в личном чате
if user_role in ["admin", "super_admin"] and chat_type == "private":
keyboard.insert(1, [InlineKeyboardButton("👑 Администрирование", callback_data='menu_admin')])
keyboard.insert(2, [InlineKeyboardButton("⚙️ Триггеры", callback_data='menu_triggers')])
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_games_menu(chat_type: str = "private") -> InlineKeyboardMarkup:
"""Создание меню игр"""
if chat_type != "private":
# В групповых чатах показываем сообщение о том, что игры доступны только в личном чате
keyboard = [
[InlineKeyboardButton("💬 Играть в личном чате с ботом", url='https://t.me/bot_username')],
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')]
]
else:
# В личном чате показываем полное меню игр
keyboard = [
[InlineKeyboardButton("🎯 Игра 1", callback_data='game_1')],
[InlineKeyboardButton("🎲 Игра 2", callback_data='game_2')],
[InlineKeyboardButton("🎰 Игра 3", callback_data='game_3')],
[InlineKeyboardButton("🎪 Игра 4", callback_data='game_4')],
[InlineKeyboardButton("🎨 Игра 5", callback_data='game_5')],
[InlineKeyboardButton("🎭 Игра 6", callback_data='game_6')],
[InlineKeyboardButton("🎪 Игра 7", callback_data='game_7')],
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_donation_menu() -> InlineKeyboardMarkup:
"""Создание меню донатов"""
keyboard = [
[InlineKeyboardButton("💰 100 ₽", callback_data='donate_100')],
[InlineKeyboardButton("💰 250 ₽", callback_data='donate_250')],
[InlineKeyboardButton("💰 500 ₽", callback_data='donate_500')],
[InlineKeyboardButton("💰 1000 ₽", callback_data='donate_1000')],
[InlineKeyboardButton("💰 2500 ₽", callback_data='donate_2500')],
[InlineKeyboardButton("💰 Другая сумма", callback_data='donate_custom')],
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_admin_menu(user_role: str = "admin") -> InlineKeyboardMarkup:
"""Создание меню администратора с учетом роли"""
keyboard = []
# Базовые кнопки для администраторов
if user_role in ["admin", "super_admin"]:
keyboard.extend([
[InlineKeyboardButton("👥 Управление пользователями", callback_data='admin_users')],
[InlineKeyboardButton("📊 Статистика", callback_data='admin_stats')],
[InlineKeyboardButton("🔧 Настройки", callback_data='admin_settings')],
[InlineKeyboardButton("🐛 Отчеты об ошибках", callback_data='admin_errors')]
])
# Дополнительные кнопки только для супер-администраторов
if user_role == "super_admin":
keyboard.extend([
[InlineKeyboardButton("⚙️ Системные настройки", callback_data='admin_system')],
[InlineKeyboardButton("📋 Логи системы", callback_data='admin_logs')],
[InlineKeyboardButton("🔄 Перезапуск бота", callback_data='admin_restart')]
])
# Кнопка "Назад" для всех
keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')])
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_moderation_menu(user_role: str = "moderator") -> InlineKeyboardMarkup:
"""Создание меню модерации с учетом роли"""
keyboard = [
[InlineKeyboardButton("⚠️ Предупреждения", callback_data='mod_warnings')],
[InlineKeyboardButton("🔇 Заглушки", callback_data='mod_mutes')],
[InlineKeyboardButton("📊 Статистика модерации", callback_data='mod_stats')]
]
# Дополнительные кнопки для администраторов (могут банить)
if user_role in ["admin", "super_admin"]:
keyboard.insert(0, [InlineKeyboardButton("🚫 Баны пользователей", callback_data='mod_bans')])
keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')])
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_moderation_menu(media_type: str, user_id: int) -> InlineKeyboardMarkup:
"""Создание меню модерации"""
keyboard = [
[InlineKeyboardButton("✅ Одобрить", callback_data=f'moderate_approve_{user_id}')],
[InlineKeyboardButton("⏰ Одобрить с задержкой", callback_data=f'moderate_delay_{user_id}')],
[InlineKeyboardButton("❌ Отклонить", callback_data=f'moderate_reject_{user_id}')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_confirmation_menu(yes_callback: str, no_callback: str, yes_text: str = "Да", no_text: str = "Нет") -> InlineKeyboardMarkup:
"""Создание меню подтверждения"""
keyboard = [
[InlineKeyboardButton(yes_text, callback_data=yes_callback)],
[InlineKeyboardButton(no_text, callback_data=no_callback)]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_pagination_menu(current_page: int, total_pages: int, prefix: str) -> InlineKeyboardMarkup:
"""Создание меню пагинации"""
if total_pages == 1:
# Для одной страницы показываем только информационную кнопку
keyboard = [[InlineKeyboardButton(f"📄 {current_page}/{total_pages}", callback_data=f'{prefix}_info')]]
else:
# Для нескольких страниц показываем навигацию
navigation_row = []
if current_page > 1:
navigation_row.append(InlineKeyboardButton("⬅️", callback_data=f'{prefix}_prev'))
if current_page < total_pages:
navigation_row.append(InlineKeyboardButton("➡️", callback_data=f'{prefix}_next'))
info_row = [[InlineKeyboardButton(f"📄 {current_page}/{total_pages}", callback_data=f'{prefix}_info')]]
if navigation_row:
keyboard = [navigation_row] + info_row
else:
keyboard = info_row
return InlineKeyboardMarkup(keyboard)
Binary file not shown.
+273
View File
@@ -0,0 +1,273 @@
"""
Форматтеры для сообщений и клавиатур телеграм-бота.
Обеспечивают единообразное форматирование вывода.
"""
from typing import Dict, List, Any, Optional
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
class MessageFormatter:
"""Форматтер текстовых сообщений"""
@staticmethod
def format_user_info(user_data: Dict[str, Any]) -> str:
"""Форматирование информации о пользователе"""
if not user_data:
return "👤 <b>Информация о пользователе:</b>\n❌ Данные не найдены"
name = MessageFormatter.escape_html(str(user_data.get('name', 'Неизвестно')))
username = MessageFormatter.escape_html(str(user_data.get('username', 'Не указан')))
return (
"👤 <b>Информация о пользователе:</b>\n"
f"🆔 ID: {user_data.get('id', 'Неизвестен')}\n"
f"👤 Имя: {name}\n"
f"📱 Username: @{username}\n"
f"🏆 Репутация: {user_data.get('reputation', 0)}\n"
f"⭐ Ранг: {MessageFormatter.escape_html(str(user_data.get('rank', 'Новичок')))}\n"
f"💬 Сообщений: {user_data.get('message_count', 0)}\n"
f"📅 Дата регистрации: {user_data.get('joined_date', 'Неизвестна')}"
)
@staticmethod
def format_rank_info(score: int, warnings: int, role: str) -> str:
"""Форматирование информации о ранге"""
return (
"🏆 <b>Ваш ранг:</b>\n"
f"⭐ Очки: {score}\n"
f"⚠️ Предупреждений: {warnings}\n"
f"👑 Роль: {MessageFormatter.escape_html(role)}"
)
@staticmethod
def format_leaderboard(users: List[tuple]) -> str:
"""Форматирование таблицы лидеров"""
if not users:
return "📭 Таблица лидеров пуста"
result = ["🏆 <b>Таблица лидеров:</b>\n"]
for i, (user_id, username, display_name, points) in enumerate(users[:10], 1):
medal = MessageFormatter._get_medal(i)
name = MessageFormatter.escape_html(display_name or username or f"User{user_id}")
result.append(f"{medal} <b>{name}</b> - {points} очков")
return "\n".join(result)
@staticmethod
def _get_medal(position: int) -> str:
"""Получение медали для позиции в таблице лидеров"""
if position == 1:
return "🥇"
elif position == 2:
return "🥈"
elif position == 3:
return "🥉"
elif position == 10:
return "🔟"
else:
return f"{position}."
@staticmethod
def format_weather_info(weather_data: Dict[str, Any]) -> str:
"""Форматирование информации о погоде"""
if not weather_data:
return "🌤️ <b>Погода</b>\n❌ Данные не найдены"
return (
"🌤️ <b>Погода в {weather_data.get('city', 'Неизвестном городе')}</b>\n"
f"🌡️ Температура: {weather_data.get('temp', 0)}°C\n"
f"🌡️ Ощущается как: {weather_data.get('feels_like', 0)}°C\n"
f"💧 Влажность: {weather_data.get('humidity', 0)}%\n"
f"💬 Описание: {MessageFormatter.escape_html(str(weather_data.get('description', 'Нет данных')))}"
)
@staticmethod
def format_news(articles: List[Dict[str, Any]]) -> str:
"""Форматирование списка новостей"""
if not articles:
return "📰 Новости не найдены"
result = ["📰 <b>Последние новости:</b>\n"]
for i, article in enumerate(articles[:10], 1):
title = MessageFormatter.escape_html(article.get('title', 'Без заголовка'))
url = article.get('url', '')
result.append(f"{i}. <b>{title}</b>")
if url:
result.append(f"🔗 {url}")
return "\n".join(result)
@staticmethod
def format_error_report(error_data: Dict[str, Any]) -> str:
"""Форматирование отчета об ошибке"""
if not error_data:
return "🐛 <b>Отчет об ошибке</b>\n❌ Данные не найдены"
return (
"🐛 <b>Отчет об ошибке #{error_data.get('id', 'Неизвестен')}</b>\n"
f"📋 Тип: {MessageFormatter.escape_html(str(error_data.get('type', 'Неизвестен')))}\n"
f"📝 Заголовок: {MessageFormatter.escape_html(str(error_data.get('title', 'Без заголовка')))}\n"
f"⭐ Приоритет: {MessageFormatter.escape_html(str(error_data.get('priority', 'Не указан')))}\n"
f"👤 Создатель: {MessageFormatter.escape_html(str(error_data.get('admin_name', 'Неизвестен')))}\n"
f"📅 Дата создания: {error_data.get('created_at', 'Неизвестна')}\n"
f"📄 Описание:\n{MessageFormatter.escape_html(str(error_data.get('description', 'Без описания')))}"
)
@staticmethod
def format_donation_info(amount: float, points: int) -> str:
"""Форматирование информации о донате"""
return (
"💰 <b>Спасибо за поддержку!</b>\n"
f"💵 Сумма: {amount} RUB\n"
f"⭐ Получено очков: {points}\n"
"🎉 Ваша поддержка помогает развивать бота!"
)
@staticmethod
def format_achievement(achievement_name: str, description: str) -> str:
"""Форматирование достижения"""
return (
"🏆 <b>Новое достижение!</b>\n"
f"🎖 {MessageFormatter.escape_html(achievement_name)}\n"
f"📝 {MessageFormatter.escape_html(description)}"
)
@staticmethod
def format_moderation_info(media_type: str, user_name: str, transcription: str = None) -> str:
"""Форматирование информации для модерации"""
media_types = {
'audio': 'АУДИО',
'video': 'ВИДЕО',
'photo': 'ИЗОБРАЖЕНИЕ',
'document': 'ДОКУМЕНТ'
}
result = (
"🔍 <b>Медиафайл на модерации</b>\n"
f"👤 Пользователь: {MessageFormatter.escape_html(user_name)}\n"
f"📁 Тип файла: {media_types.get(media_type.lower(), media_type.upper())}"
)
if transcription:
result += f"\n🎵 Транскрипция: {MessageFormatter.escape_html(transcription)}"
return result
@staticmethod
def escape_html(text: str) -> str:
"""Экранирование HTML символов"""
if not text:
return ""
return (text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "&#x27;"))
@staticmethod
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
"""Усечение текста до указанной длины"""
if not text or len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
class KeyboardFormatter:
"""Форматтер клавиатур"""
@staticmethod
def create_main_menu() -> InlineKeyboardMarkup:
"""Создание главного меню"""
keyboard = [
[InlineKeyboardButton("📋 Помощь", callback_data='menu_help')],
[InlineKeyboardButton("🎮 Мини игры", callback_data='menu_games')],
[InlineKeyboardButton("💰 Донат", callback_data='menu_donate')],
[InlineKeyboardButton("🏆 Таблица лидеров", callback_data='menu_leaderboard')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_games_menu() -> InlineKeyboardMarkup:
"""Создание меню игр"""
keyboard = [
[InlineKeyboardButton("🎯 Игра 1", callback_data='game_1')],
[InlineKeyboardButton("🎲 Игра 2", callback_data='game_2')],
[InlineKeyboardButton("🎰 Игра 3", callback_data='game_3')],
[InlineKeyboardButton("🎪 Игра 4", callback_data='game_4')],
[InlineKeyboardButton("🎨 Игра 5", callback_data='game_5')],
[InlineKeyboardButton("🎭 Игра 6", callback_data='game_6')],
[InlineKeyboardButton("🎪 Игра 7", callback_data='game_7')],
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_donation_menu() -> InlineKeyboardMarkup:
"""Создание меню донатов"""
keyboard = [
[InlineKeyboardButton("💰 100 ₽", callback_data='donate_100')],
[InlineKeyboardButton("💰 250 ₽", callback_data='donate_250')],
[InlineKeyboardButton("💰 500 ₽", callback_data='donate_500')],
[InlineKeyboardButton("💰 1000 ₽", callback_data='donate_1000')],
[InlineKeyboardButton("💰 2500 ₽", callback_data='donate_2500')],
[InlineKeyboardButton("💰 Другая сумма", callback_data='donate_custom')],
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_admin_menu() -> InlineKeyboardMarkup:
"""Создание меню администратора"""
keyboard = [
[InlineKeyboardButton("👥 Управление пользователями", callback_data='admin_users')],
[InlineKeyboardButton("📊 Статистика", callback_data='admin_stats')],
[InlineKeyboardButton("🔧 Настройки", callback_data='admin_settings')],
[InlineKeyboardButton("🐛 Отчеты об ошибках", callback_data='admin_errors')],
[InlineKeyboardButton("⬅️ Назад", callback_data='menu_main')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_moderation_menu(media_type: str, user_id: int) -> InlineKeyboardMarkup:
"""Создание меню модерации"""
keyboard = [
[InlineKeyboardButton("✅ Одобрить", callback_data=f'moderate_approve_{user_id}')],
[InlineKeyboardButton("⏰ Одобрить с задержкой", callback_data=f'moderate_delay_{user_id}')],
[InlineKeyboardButton("❌ Отклонить", callback_data=f'moderate_reject_{user_id}')]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_confirmation_menu(yes_callback: str, no_callback: str, yes_text: str = "Да", no_text: str = "Нет") -> InlineKeyboardMarkup:
"""Создание меню подтверждения"""
keyboard = [
[InlineKeyboardButton(yes_text, callback_data=yes_callback)],
[InlineKeyboardButton(no_text, callback_data=no_callback)]
]
return InlineKeyboardMarkup(keyboard)
@staticmethod
def create_pagination_menu(current_page: int, total_pages: int, prefix: str) -> InlineKeyboardMarkup:
"""Создание меню пагинации"""
if total_pages == 1:
# Для одной страницы показываем только информационную кнопку
keyboard = [[InlineKeyboardButton(f"📄 {current_page}/{total_pages}", callback_data=f'{prefix}_info')]]
else:
# Для нескольких страниц показываем навигацию
navigation_row = []
if current_page > 1:
navigation_row.append(InlineKeyboardButton("⬅️", callback_data=f'{prefix}_prev'))
if current_page < total_pages:
navigation_row.append(InlineKeyboardButton("➡️", callback_data=f'{prefix}_next'))
info_row = [[InlineKeyboardButton(f"📄 {current_page}/{total_pages}", callback_data=f'{prefix}_info')]]
if navigation_row:
keyboard = [navigation_row] + info_row
else:
keyboard = info_row
return InlineKeyboardMarkup(keyboard)
+366
View File
@@ -0,0 +1,366 @@
"""
Вспомогательные функции для телеграм-бота.
Содержит общие утилиты и хелперы для повседневных задач.
"""
import asyncio
import functools
import logging
from typing import Any, Callable, Dict, List, Optional, Union
from core.exceptions import BotException
def safe_execute(func: Callable, *args, **kwargs) -> Any:
"""
Безопасное выполнение функции с обработкой исключений.
Args:
func: Функция для выполнения
*args: Позиционные аргументы
**kwargs: Именованные аргументы
Returns:
Результат выполнения функции или None при ошибке
"""
try:
return func(*args, **kwargs)
except Exception as e:
logging.error(f"Ошибка при выполнении функции {func.__name__}: {e}")
return None
def chunk_text(text: str, chunk_size: int = 4000) -> List[str]:
"""
Разбиение текста на части указанного размера.
Args:
text: Исходный текст
chunk_size: Максимальный размер части
Returns:
Список частей текста
"""
if not text:
return []
chunks = []
for i in range(0, len(text), chunk_size):
chunks.append(text[i:i + chunk_size])
return chunks
def escape_markdown(text: str) -> str:
"""
Экранирование специальных символов Markdown.
Args:
text: Текст для экранирования
Returns:
Экранированный текст
"""
if not text:
return ""
# Символы, которые нужно экранировать в Markdown
escape_chars = r'_*`['
result = text
for char in escape_chars:
result = result.replace(char, f'\\{char}')
return result
def create_chunks(items: List[Any], chunk_size: int) -> List[List[Any]]:
"""
Разбиение списка на части указанного размера.
Args:
items: Исходный список
chunk_size: Размер части
Returns:
Список частей
"""
return [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]
def format_number(num: Union[int, float]) -> str:
"""
Форматирование числа с разделителями тысяч.
Args:
num: Число для форматирования
Returns:
Отформатированная строка
"""
try:
if isinstance(num, float):
# Для float возвращаем как есть
return str(num)
else:
# Ручное форматирование для кроссплатформенности
s = str(int(num))
if len(s) <= 3:
return s
groups = []
while s:
groups.append(s[-3:])
s = s[:-3]
return ','.join(reversed(groups))
except (ValueError, TypeError):
return str(num)
def get_nested_value(data: Dict, path: str, default: Any = None) -> Any:
"""
Получение значения из вложенного словаря по пути.
Args:
data: Исходный словарь
path: Путь к значению (через точку)
default: Значение по умолчанию
Returns:
Значение или default
Example:
data = {'user': {'profile': {'name': 'John'}}}
get_nested_value(data, 'user.profile.name') # 'John'
"""
keys = path.split('.')
current = data
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
return current
def set_nested_value(data: Dict, path: str, value: Any) -> Dict:
"""
Установка значения во вложенный словарь по пути.
Args:
data: Исходный словарь
path: Путь к значению (через точку)
value: Значение для установки
Returns:
Обновленный словарь
Example:
data = {}
set_nested_value(data, 'user.profile.name', 'John')
# data = {'user': {'profile': {'name': 'John'}}}
"""
keys = path.split('.')
current = data
for key in keys[:-1]:
if key not in current:
current[key] = {}
current = current[key]
current[keys[-1]] = value
return data
def retry_async(max_attempts: int = 3, delay: float = 1.0):
"""
Декоратор для повторных попыток асинхронных функций.
Args:
max_attempts: Максимальное количество попыток
delay: Задержка между попытками в секундах
"""
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
await asyncio.sleep(delay)
raise last_exception
return wrapper
return decorator
def validate_required_fields(data: Dict, required_fields: List[str]) -> bool:
"""
Проверка наличия обязательных полей в данных.
Args:
data: Словарь с данными
required_fields: Список обязательных полей
Returns:
True если все поля присутствуют
Raises:
ValidationError: Если отсутствуют обязательные поля
"""
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
raise ValidationError(f"Отсутствуют обязательные поля: {', '.join(missing_fields)}")
return True
def calculate_percentage(part: Union[int, float], total: Union[int, float]) -> float:
"""
Вычисление процента.
Args:
part: Часть от целого
total: Целое
Returns:
Процент (0-100)
"""
try:
if total == 0:
return 0.0
return (float(part) / float(total)) * 100
except (ValueError, TypeError):
return 0.0
def format_duration(seconds: int) -> str:
"""
Форматирование длительности в читаемый вид.
Args:
seconds: Длительность в секундах
Returns:
Отформатированная строка
"""
if seconds < 60:
return f"{seconds} сек"
elif seconds < 3600:
minutes = seconds // 60
remaining_seconds = seconds % 60
return f"{minutes} мин {remaining_seconds} сек"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
return f"{hours} ч {minutes} мин"
def is_empty(value: Any) -> bool:
"""
Проверка значения на пустоту.
Args:
value: Значение для проверки
Returns:
True если значение пустое
"""
if value is None:
return True
if isinstance(value, (str, list, dict, tuple)):
return len(value) == 0
return False
def generate_user_mention(user_id: int, name: str) -> str:
"""
Генерация упоминания пользователя.
Args:
user_id: ID пользователя
name: Имя пользователя
Returns:
Строка упоминания в формате Markdown
"""
return f"[{name}](tg://user?id={user_id})"
def clean_string(text: str) -> str:
"""
Очистка строки от лишних пробелов и символов.
Args:
text: Исходная строка
Returns:
Очищенная строка
"""
if not text:
return ""
# Удаляем лишние пробелы и переносы строк
cleaned = ' '.join(text.split())
return cleaned.strip()
def merge_dicts(*dicts: Dict) -> Dict:
"""
Объединение словарей с приоритетом последних.
Args:
*dicts: Словари для объединения
Returns:
Объединенный словарь
"""
result = {}
for d in dicts:
if d:
result.update(d)
return result
def filter_dict(data: Dict, keys: List[str]) -> Dict:
"""
Фильтрация словаря по ключам.
Args:
data: Исходный словарь
keys: Ключи для сохранения
Returns:
Отфильтрованный словарь
"""
return {k: v for k, v in data.items() if k in keys}
def async_context_manager(func: Callable) -> Callable:
"""
Декоратор для создания асинхронного контекстного менеджера.
Args:
func: Функция для декорирования
Returns:
Декорированная функция
"""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
return wrapper
+177
View File
@@ -0,0 +1,177 @@
"""
Система валидации данных для телеграм-бота.
Обеспечивает безопасность и корректность входящих данных.
"""
import re
from typing import Optional, Union, List
class ValidationError(Exception):
"""Исключение валидации данных"""
def __init__(self, message: str, field: str = None):
self.message = message
self.field = field
super().__init__(message)
class Validator:
"""Базовый класс для валидаторов"""
@staticmethod
def validate_string_length(text: str, min_length: int = 0, max_length: int = 1000) -> bool:
"""Валидация длины строки"""
if not isinstance(text, str):
return False
return min_length <= len(text) <= max_length
@staticmethod
def validate_numeric_range(value: Union[int, float], min_val: Union[int, float], max_val: Union[int, float]) -> bool:
"""Валидация числового диапазона"""
try:
numeric_value = float(value)
return min_val <= numeric_value <= max_val
except (ValueError, TypeError):
return False
@staticmethod
def validate_user_id(user_id: Union[int, str]) -> bool:
"""Валидация ID пользователя Telegram"""
try:
uid = int(user_id)
return 1 <= uid <= 2147483647 # Диапазон Telegram user ID
except (ValueError, TypeError):
return False
@staticmethod
def validate_chat_id(chat_id: Union[int, str]) -> bool:
"""Валидация ID чата Telegram"""
try:
cid = int(chat_id)
return -2147483648 <= cid <= 2147483647 # Диапазон Telegram chat ID
except (ValueError, TypeError):
return False
class InputValidator:
"""Валидатор пользовательского ввода"""
# Регулярные выражения для валидации
EMAIL_PATTERN = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$|^[a-zA-Z0-9._%+-]+@localhost$')
PHONE_PATTERN = re.compile(r'^\+?[\d\s\-\(\)]{10,}$')
URL_PATTERN = re.compile(r'^https?://[^\s/$.?#].[^\s]*$')
@classmethod
def validate_email(cls, email: str) -> bool:
"""Валидация email адреса"""
return bool(cls.EMAIL_PATTERN.match(email)) if email else False
@classmethod
def validate_phone(cls, phone: str) -> bool:
"""Валидация номера телефона"""
return bool(cls.PHONE_PATTERN.match(phone)) if phone else False
@classmethod
def validate_url(cls, url: str) -> bool:
"""Валидация URL"""
return bool(cls.URL_PATTERN.match(url)) if url else False
@classmethod
def validate_city_name(cls, city: str, max_length: int = 50) -> bool:
"""Валидация названия города"""
if not city or not isinstance(city, str):
return False
# Проверка длины
if len(city) < 2 or len(city) > max_length:
return False
# Проверка на наличие только допустимых символов (буквы, пробелы, дефисы)
return bool(re.match(r'^[a-zA-Zа-яА-Я\s\-]+$', city))
@classmethod
def validate_username(cls, username: str) -> bool:
"""Валидация username Telegram"""
if not username or not isinstance(username, str):
return False
# Username должен начинаться с буквы, содержать только буквы, цифры и подчеркивания
# Длина от 5 до 32 символов
if not (5 <= len(username) <= 32):
return False
return bool(re.match(r'^[a-zA-Z][a-zA-Z0-9_]*$', username))
@classmethod
def validate_text_content(cls, text: str, max_length: int = 4000) -> bool:
"""Валидация текстового контента"""
if not text or not isinstance(text, str):
return False
# Проверка длины
if len(text) > max_length:
return False
# Проверка на наличие потенциально опасных символов
dangerous_chars = ['<', '>', '"', "'", '&']
return not any(char in text for char in dangerous_chars)
@classmethod
def validate_donation_amount(cls, amount: Union[str, float]) -> bool:
"""Валидация суммы доната"""
try:
amount_float = float(amount)
return 1.0 <= amount_float <= 100000.0 # От 1 рубля до 100k рублей
except (ValueError, TypeError):
return False
@classmethod
def validate_time_format(cls, time_str: str) -> bool:
"""Валидация формата времени"""
if not time_str or not isinstance(time_str, str):
return False
# Проверяем форматы: YYYY-MM-DD HH:MM:SS, YYYY-MM-DD HH:MM, +30m, +2h, +1d
patterns = [
r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?$', # Абсолютное время
r'^\+(\d+)[mhd]$' # Относительное время
]
return any(re.match(pattern, time_str) for pattern in patterns)
@classmethod
def sanitize_filename(cls, filename: str) -> str:
"""Очистка имени файла от опасных символов"""
if not filename:
return "unnamed_file"
# Оставляем только безопасные символы
safe_chars = re.sub(r'[^\w\.\-\s]', '', filename)
return safe_chars.strip() or "unnamed_file"
@classmethod
def validate_csv_filename(cls, filename: str) -> bool:
"""Валидация имени CSV файла"""
if not filename or not isinstance(filename, str):
return False
# Проверка длины
if len(filename) > 255:
return False
# Проверка на наличие только безопасных символов
return bool(re.match(r'^[a-zA-Z0-9._\-/\s]+$', filename))
@classmethod
def validate_error_type(cls, error_type: str) -> bool:
"""Валидация типа ошибки"""
if not error_type or not isinstance(error_type, str):
return False
valid_types = ['bug', 'feature', 'crash', 'ui', 'security', 'improvement', 'other']
return error_type.lower() in valid_types
@classmethod
def validate_priority(cls, priority: str) -> bool:
"""Валидация приоритета"""
valid_priorities = ['low', 'medium', 'high', 'critical']
return priority.lower() in valid_priorities