fix quality code

This commit is contained in:
2026-02-01 23:03:23 +03:00
parent 731e68a597
commit f8962225ee
106 changed files with 8456 additions and 5810 deletions

View File

@@ -1,6 +1,7 @@
"""
Конфигурация для rate limiting
"""
from dataclasses import dataclass
from typing import Optional
@@ -8,26 +9,28 @@ from typing import Optional
@dataclass
class RateLimitSettings:
"""Настройки rate limiting для разных типов сообщений"""
# Основные настройки
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
burst_limit: int = 2 # Максимум 2 сообщения подряд
retry_after_multiplier: float = 1.5 # Множитель для увеличения задержки при retry
max_retry_delay: float = 30.0 # Максимальная задержка между попытками
max_retries: int = 3 # Максимальное количество повторных попыток
# Специальные настройки для разных типов сообщений
voice_message_delay: float = 2.0 # Дополнительная задержка для голосовых сообщений
media_message_delay: float = 1.5 # Дополнительная задержка для медиа сообщений
text_message_delay: float = 1.0 # Дополнительная задержка для текстовых сообщений
text_message_delay: float = 1.0 # Дополнительная задержка для текстовых сообщений
# Настройки для разных типов чатов
private_chat_multiplier: float = 1.0 # Множитель для приватных чатов
group_chat_multiplier: float = 0.8 # Множитель для групповых чатов
channel_multiplier: float = 0.6 # Множитель для каналов
group_chat_multiplier: float = 0.8 # Множитель для групповых чатов
channel_multiplier: float = 0.6 # Множитель для каналов
# Глобальные ограничения
global_messages_per_second: float = 10.0 # Максимум 10 сообщений в секунду глобально
global_messages_per_second: float = (
10.0 # Максимум 10 сообщений в секунду глобально
)
global_burst_limit: int = 20 # Максимум 20 сообщений подряд глобально
@@ -37,7 +40,7 @@ DEVELOPMENT_CONFIG = RateLimitSettings(
burst_limit=3,
retry_after_multiplier=1.2,
max_retry_delay=15.0,
max_retries=2
max_retries=2,
)
PRODUCTION_CONFIG = RateLimitSettings(
@@ -48,7 +51,7 @@ PRODUCTION_CONFIG = RateLimitSettings(
max_retries=3,
voice_message_delay=2.5,
media_message_delay=2.0,
text_message_delay=1.5
text_message_delay=1.5,
)
STRICT_CONFIG = RateLimitSettings(
@@ -59,46 +62,45 @@ STRICT_CONFIG = RateLimitSettings(
max_retries=5,
voice_message_delay=3.0,
media_message_delay=2.5,
text_message_delay=2.0
text_message_delay=2.0,
)
def get_rate_limit_config(environment: str = "production") -> RateLimitSettings:
"""
Получает конфигурацию rate limiting в зависимости от окружения
Args:
environment: Окружение ('development', 'production', 'strict')
Returns:
RateLimitSettings: Конфигурация для указанного окружения
"""
configs = {
"development": DEVELOPMENT_CONFIG,
"production": PRODUCTION_CONFIG,
"strict": STRICT_CONFIG
"strict": STRICT_CONFIG,
}
return configs.get(environment, PRODUCTION_CONFIG)
def get_adaptive_config(
current_error_rate: float,
base_config: Optional[RateLimitSettings] = None
current_error_rate: float, base_config: Optional[RateLimitSettings] = None
) -> RateLimitSettings:
"""
Получает адаптивную конфигурацию на основе текущего уровня ошибок
Args:
current_error_rate: Текущий уровень ошибок (0.0 - 1.0)
base_config: Базовая конфигурация
Returns:
RateLimitSettings: Адаптированная конфигурация
"""
if base_config is None:
base_config = PRODUCTION_CONFIG
# Если уровень ошибок высокий, ужесточаем ограничения
if current_error_rate > 0.1: # Более 10% ошибок
return RateLimitSettings(
@@ -109,9 +111,9 @@ def get_adaptive_config(
max_retries=base_config.max_retries + 1,
voice_message_delay=base_config.voice_message_delay * 1.5,
media_message_delay=base_config.media_message_delay * 1.3,
text_message_delay=base_config.text_message_delay * 1.2
text_message_delay=base_config.text_message_delay * 1.2,
)
# Если уровень ошибок низкий, можно немного ослабить ограничения
elif current_error_rate < 0.01: # Менее 1% ошибок
return RateLimitSettings(
@@ -122,8 +124,8 @@ def get_adaptive_config(
max_retries=max(1, base_config.max_retries - 1),
voice_message_delay=base_config.voice_message_delay * 0.8,
media_message_delay=base_config.media_message_delay * 0.9,
text_message_delay=base_config.text_message_delay * 0.9
text_message_delay=base_config.text_message_delay * 0.9,
)
# Возвращаем базовую конфигурацию
return base_config

View File

@@ -5,7 +5,7 @@ from aiogram.types import Message
class ChatTypeFilter(BaseFilter): # [1]
def __init__(self, chat_type: Union[str, list]): # [2]
def __init__(self, chat_type: Union[str, list]): # [2]
self.chat_type = chat_type
async def __call__(self, message: Message) -> bool: # [3]

View File

@@ -1,27 +1,37 @@
from .admin_handlers import admin_router
from .dependencies import AdminAccessMiddleware, BotDB, Settings
from .exceptions import (AdminAccessDeniedError, AdminError, InvalidInputError,
UserAlreadyBannedError, UserNotFoundError)
from .exceptions import (
AdminAccessDeniedError,
AdminError,
InvalidInputError,
UserAlreadyBannedError,
UserNotFoundError,
)
from .services import AdminService, BannedUser, User
from .utils import (escape_html, format_ban_confirmation, format_user_info,
handle_admin_error, return_to_admin_menu)
from .utils import (
escape_html,
format_ban_confirmation,
format_user_info,
handle_admin_error,
return_to_admin_menu,
)
__all__ = [
'admin_router',
'AdminAccessMiddleware',
'BotDB',
'Settings',
'AdminService',
'User',
'BannedUser',
'AdminError',
'AdminAccessDeniedError',
'UserNotFoundError',
'InvalidInputError',
'UserAlreadyBannedError',
'return_to_admin_menu',
'handle_admin_error',
'format_user_info',
'format_ban_confirmation',
'escape_html'
]
"admin_router",
"AdminAccessMiddleware",
"BotDB",
"Settings",
"AdminService",
"User",
"BannedUser",
"AdminError",
"AdminAccessDeniedError",
"UserNotFoundError",
"InvalidInputError",
"UserAlreadyBannedError",
"return_to_admin_menu",
"handle_admin_error",
"format_user_info",
"format_ban_confirmation",
"escape_html",
]

View File

@@ -1,21 +1,29 @@
from aiogram import F, Router, types
from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware
from helper_bot.handlers.admin.exceptions import (InvalidInputError,
UserAlreadyBannedError)
from helper_bot.handlers.admin.exceptions import (
InvalidInputError,
UserAlreadyBannedError,
)
from helper_bot.handlers.admin.services import AdminService
from helper_bot.handlers.admin.utils import (escape_html,
format_ban_confirmation,
format_user_info,
handle_admin_error,
return_to_admin_menu)
from helper_bot.keyboards.keyboards import (create_keyboard_for_approve_ban,
create_keyboard_for_ban_days,
create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin)
from helper_bot.handlers.admin.utils import (
escape_html,
format_ban_confirmation,
format_user_info,
handle_admin_error,
return_to_admin_menu,
)
from helper_bot.keyboards.keyboards import (
create_keyboard_for_approve_ban,
create_keyboard_for_ban_days,
create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin,
)
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
@@ -29,23 +37,19 @@ admin_router.message.middleware(AdminAccessMiddleware())
# ХЕНДЛЕРЫ МЕНЮ
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
Command('admin')
)
@admin_router.message(ChatTypeFilter(chat_type=["private"]), Command("admin"))
@track_time("admin_panel", "admin_handlers")
@track_errors("admin_handlers", "admin_panel")
async def admin_panel(
message: types.Message,
state: FSMContext,
**kwargs
):
async def admin_panel(message: types.Message, state: FSMContext, **kwargs):
"""Главное меню администратора"""
try:
await state.set_state("ADMIN")
logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}")
markup = get_reply_keyboard_admin()
await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
await message.answer(
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
)
except Exception as e:
await handle_admin_error(message, e, state, "admin_panel")
@@ -54,18 +58,20 @@ async def admin_panel(
# ХЕНДЛЕР ОТМЕНЫ
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_TARGET", "AWAIT_BAN_DETAILS", "AWAIT_BAN_DURATION", "BAN_CONFIRMATION"),
F.text == 'Отменить'
StateFilter(
"AWAIT_BAN_TARGET",
"AWAIT_BAN_DETAILS",
"AWAIT_BAN_DURATION",
"BAN_CONFIRMATION",
),
F.text == "Отменить",
)
@track_time("cancel_ban_process", "admin_handlers")
@track_errors("admin_handlers", "cancel_ban_process")
async def cancel_ban_process(
message: types.Message,
state: FSMContext,
**kwargs
):
async def cancel_ban_process(message: types.Message, state: FSMContext, **kwargs):
"""Отмена процесса блокировки"""
try:
current_state = await state.get_state()
@@ -78,32 +84,31 @@ async def cancel_ban_process(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text == 'Бан (Список)'
F.text == "Бан (Список)",
)
@track_time("get_last_users", "admin_handlers")
@track_errors("admin_handlers", "get_last_users")
@db_query_time("get_last_users", "users", "select")
async def get_last_users(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db")
):
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Получение списка последних пользователей"""
try:
logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}")
logger.info(
f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}"
)
admin_service = AdminService(bot_db)
users = await admin_service.get_last_users()
# Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination)
users_data = [
(user.full_name, user.user_id)
for user in users
]
keyboard = create_keyboard_with_pagination(1, len(users_data), users_data, 'ban')
users_data = [(user.full_name, user.user_id) for user in users]
keyboard = create_keyboard_with_pagination(
1, len(users_data), users_data, "ban"
)
await message.answer(
text="Список пользователей которые последними обращались к боту",
reply_markup=keyboard
reply_markup=keyboard,
)
except Exception as e:
await handle_admin_error(message, e, state, "get_last_users")
@@ -112,27 +117,31 @@ async def get_last_users(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text == 'Разбан (список)'
F.text == "Разбан (список)",
)
@track_time("get_banned_users", "admin_handlers")
@track_errors("admin_handlers", "get_banned_users")
@db_query_time("get_banned_users", "users", "select")
async def get_banned_users(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db")
):
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Получение списка заблокированных пользователей"""
try:
logger.info(f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}")
logger.info(
f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}"
)
admin_service = AdminService(bot_db)
message_text, buttons_list = await admin_service.get_banned_users_for_display(0)
if buttons_list:
keyboard = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock')
keyboard = create_keyboard_with_pagination(
1, len(buttons_list), buttons_list, "unlock"
)
await message.answer(text=message_text, reply_markup=keyboard)
else:
await message.answer(text="В списке заблокированных пользователей никого нет")
await message.answer(
text="В списке заблокированных пользователей никого нет"
)
except Exception as e:
await handle_admin_error(message, e, state, "get_banned_users")
@@ -141,68 +150,80 @@ async def get_banned_users(
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text.in_(['Бан по нику', 'Бан по ID'])
F.text.in_(["Бан по нику", "Бан по ID"]),
)
@track_time("start_ban_process", "admin_handlers")
@track_errors("admin_handlers", "start_ban_process")
async def start_ban_process(
message: types.Message,
state: FSMContext,
**kwargs
):
async def start_ban_process(message: types.Message, state: FSMContext, **kwargs):
"""Начало процесса блокировки пользователя"""
try:
ban_type = "username" if message.text == 'Бан по нику' else "id"
ban_type = "username" if message.text == "Бан по нику" else "id"
await state.update_data(ban_type=ban_type)
prompt_text = "Пришли мне username блокируемого пользователя" if ban_type == "username" else "Пришли мне ID блокируемого пользователя"
prompt_text = (
"Пришли мне username блокируемого пользователя"
if ban_type == "username"
else "Пришли мне ID блокируемого пользователя"
)
await message.answer(prompt_text)
await state.set_state('AWAIT_BAN_TARGET')
await state.set_state("AWAIT_BAN_TARGET")
except Exception as e:
await handle_admin_error(message, e, state, "start_ban_process")
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_TARGET")
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_TARGET")
)
@track_time("process_ban_target", "admin_handlers")
@track_errors("admin_handlers", "process_ban_target")
async def process_ban_target(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db")
):
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Обработка введенного username/ID для блокировки"""
logger.info(f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
logger.info(
f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}"
)
try:
user_data = await state.get_data()
ban_type = user_data.get('ban_type')
ban_type = user_data.get("ban_type")
admin_service = AdminService(bot_db)
logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}")
# Определяем пользователя
if ban_type == "username":
logger.info(f"process_ban_target: Поиск пользователя по username: {message.text}")
logger.info(
f"process_ban_target: Поиск пользователя по username: {message.text}"
)
user = await admin_service.get_user_by_username(message.text)
if not user:
logger.warning(f"process_ban_target: Пользователь с username '{message.text}' не найден")
await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.")
logger.warning(
f"process_ban_target: Пользователь с username '{message.text}' не найден"
)
await message.answer(
f"Пользователь с username '{escape_html(message.text)}' не найден."
)
await return_to_admin_menu(message, state)
return
else: # ban_type == "id"
try:
logger.info(f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}")
logger.info(
f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}"
)
user_id = await admin_service.validate_user_input(message.text)
user = await admin_service.get_user_by_id(user_id)
if not user:
logger.warning(f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных")
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.")
logger.warning(
f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных"
)
await message.answer(
f"Пользователь с ID {user_id} не найден в базе данных."
)
await return_to_admin_menu(message, state)
return
except InvalidInputError as e:
@@ -210,115 +231,117 @@ async def process_ban_target(
await message.answer(str(e))
await return_to_admin_menu(message, state)
return
logger.info(f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}")
logger.info(
f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}"
)
# Сохраняем данные пользователя
await state.update_data(
target_user_id=user.user_id,
target_username=user.username,
target_full_name=user.full_name
target_full_name=user.full_name,
)
# Показываем информацию о пользователе и запрашиваем причину
user_info = format_user_info(user.user_id, user.username, user.full_name)
markup = create_keyboard_for_ban_reason()
logger.info(f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}")
logger.info(
f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}"
)
await message.answer(
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup
reply_markup=markup,
)
await state.set_state('AWAIT_BAN_DETAILS')
await state.set_state("AWAIT_BAN_DETAILS")
logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS")
except Exception as e:
logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_target")
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_DETAILS")
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DETAILS")
)
@track_time("process_ban_reason", "admin_handlers")
@track_errors("admin_handlers", "process_ban_reason")
async def process_ban_reason(
message: types.Message,
state: FSMContext,
**kwargs
):
async def process_ban_reason(message: types.Message, state: FSMContext, **kwargs):
"""Обработка причины блокировки"""
logger.info(f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}")
logger.info(
f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}"
)
try:
# Проверяем текущее состояние
current_state = await state.get_state()
logger.info(f"process_ban_reason: Текущее состояние: {current_state}")
# Проверяем данные состояния
state_data = await state.get_data()
logger.info(f"process_ban_reason: Данные состояния: {state_data}")
logger.info(f"process_ban_reason: Обновление данных состояния с причиной: {message.text}")
logger.info(
f"process_ban_reason: Обновление данных состояния с причиной: {message.text}"
)
await state.update_data(ban_reason=message.text)
markup = create_keyboard_for_ban_days()
safe_reason = escape_html(message.text)
logger.info(f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}")
logger.info(
f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}"
)
await message.answer(
f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат",
reply_markup=markup
reply_markup=markup,
)
await state.set_state('AWAIT_BAN_DURATION')
await state.set_state("AWAIT_BAN_DURATION")
logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION")
except Exception as e:
logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True)
await handle_admin_error(message, e, state, "process_ban_reason")
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_BAN_DURATION")
ChatTypeFilter(chat_type=["private"]), StateFilter("AWAIT_BAN_DURATION")
)
@track_time("process_ban_duration", "admin_handlers")
@track_errors("admin_handlers", "process_ban_duration")
async def process_ban_duration(
message: types.Message,
state: FSMContext,
**kwargs
):
async def process_ban_duration(message: types.Message, state: FSMContext, **kwargs):
"""Обработка срока блокировки"""
try:
user_data = await state.get_data()
# Определяем срок блокировки
if message.text == 'Навсегда':
if message.text == "Навсегда":
ban_days = None
else:
try:
ban_days = int(message.text)
if ban_days <= 0:
await message.answer("Срок блокировки должен быть положительным числом.")
await message.answer(
"Срок блокировки должен быть положительным числом."
)
return
except ValueError:
await message.answer("Пожалуйста, введите корректное число дней или выберите 'Навсегда'.")
await message.answer(
"Пожалуйста, введите корректное число дней или выберите 'Навсегда'."
)
return
await state.update_data(ban_days=ban_days)
# Показываем подтверждение
confirmation_text = format_ban_confirmation(
user_data['target_user_id'],
user_data['ban_reason'],
ban_days
user_data["target_user_id"], user_data["ban_reason"], ban_days
)
markup = create_keyboard_for_approve_ban()
await message.answer(confirmation_text, reply_markup=markup)
await state.set_state('BAN_CONFIRMATION')
await state.set_state("BAN_CONFIRMATION")
except Exception as e:
await handle_admin_error(message, e, state, "process_ban_duration")
@@ -326,35 +349,31 @@ async def process_ban_duration(
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("BAN_CONFIRMATION"),
F.text == 'Подтвердить'
F.text == "Подтвердить",
)
@track_time("confirm_ban", "admin_handlers")
@track_errors("admin_handlers", "confirm_ban")
async def confirm_ban(
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
**kwargs
):
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
):
"""Подтверждение блокировки пользователя"""
try:
user_data = await state.get_data()
admin_service = AdminService(bot_db)
# Выполняем блокировку
await admin_service.ban_user(
user_id=user_data['target_user_id'],
username=user_data['target_username'],
reason=user_data['ban_reason'],
ban_days=user_data['ban_days'],
user_id=user_data["target_user_id"],
username=user_data["target_username"],
reason=user_data["ban_reason"],
ban_days=user_data["ban_days"],
ban_author_id=message.from_user.id,
)
safe_username = escape_html(user_data['target_username'])
safe_username = escape_html(user_data["target_username"])
await message.reply(f"Пользователь {safe_username} успешно заблокирован.")
await return_to_admin_menu(message, state)
except UserAlreadyBannedError as e:
await message.reply(str(e))
await return_to_admin_menu(message, state)

View File

@@ -9,7 +9,7 @@ ADMIN_BUTTON_TEXTS: Final[Dict[str, str]] = {
"BAN_BY_ID": "Бан по ID",
"UNBAN_LIST": "Разбан (список)",
"RETURN_TO_BOT": "Вернуться в бота",
"CANCEL": "Отменить"
"CANCEL": "Отменить",
}
# Admin button to command mapping for metrics
@@ -19,11 +19,11 @@ ADMIN_BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"Бан по ID": "admin_ban_by_id",
"Разбан (список)": "admin_unban_list",
"Вернуться в бота": "admin_return_to_bot",
"Отменить": "admin_cancel"
"Отменить": "admin_cancel",
}
# Admin commands
ADMIN_COMMANDS: Final[Dict[str, str]] = {
"ADMIN": "admin",
"TEST_METRICS": "test_metrics"
"TEST_METRICS": "test_metrics",
}

View File

@@ -7,6 +7,7 @@ except ImportError:
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import check_access
from logs.custom_logger import logger
@@ -14,36 +15,46 @@ from logs.custom_logger import logger
class AdminAccessMiddleware(BaseMiddleware):
"""Middleware для проверки административного доступа"""
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
if hasattr(event, 'from_user'):
async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
if hasattr(event, "from_user"):
user_id = event.from_user.id
username = getattr(event.from_user, 'username', 'Unknown')
logger.info(f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {user_id})")
username = getattr(event.from_user, "username", "Unknown")
logger.info(
f"AdminAccessMiddleware: проверка доступа для пользователя {username} (ID: {user_id})"
)
# Получаем bot_db из data (внедренного DependenciesMiddleware)
bot_db = data.get('bot_db')
bot_db = data.get("bot_db")
if not bot_db:
# Fallback: получаем напрямую если middleware не сработала
bdf = get_global_instance()
bot_db = bdf.get_db()
is_admin_result = await check_access(user_id, bot_db)
logger.info(f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}")
logger.info(
f"AdminAccessMiddleware: результат проверки для {username}: {is_admin_result}"
)
if not is_admin_result:
logger.warning(f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})")
if hasattr(event, 'answer'):
await event.answer('Доступ запрещен!')
logger.warning(
f"AdminAccessMiddleware: доступ запрещен для пользователя {username} (ID: {user_id})"
)
if hasattr(event, "answer"):
await event.answer("Доступ запрещен!")
return
try:
# Вызываем хендлер с data
return await handler(event, data)
except TypeError as e:
if "missing 1 required positional argument: 'data'" in str(e):
logger.error(f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'")
logger.error(
f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'"
)
# Пытаемся вызвать хендлер без data (для совместимости с MagicData)
return await handler(event)
else:

View File

@@ -1,23 +1,28 @@
class AdminError(Exception):
"""Базовое исключение для административных операций"""
pass
class AdminAccessDeniedError(AdminError):
"""Исключение при отказе в административном доступе"""
pass
class UserNotFoundError(AdminError):
"""Исключение при отсутствии пользователя"""
pass
class InvalidInputError(AdminError):
"""Исключение при некорректном вводе данных"""
pass
class UserAlreadyBannedError(AdminError):
"""Исключение при попытке забанить уже заблокированного пользователя"""
pass

View File

@@ -1,25 +1,31 @@
"""
Обработчики команд для мониторинга rate limiting
"""
from aiogram import F, Router, types
from aiogram.filters import Command, MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.middlewares.dependencies_middleware import \
DependenciesMiddleware
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
# Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time
from helper_bot.utils.rate_limit_metrics import (
get_rate_limit_metrics_summary, update_rate_limit_gauges)
from helper_bot.utils.rate_limit_monitor import (get_rate_limit_summary,
rate_limit_monitor)
get_rate_limit_metrics_summary,
update_rate_limit_gauges,
)
from helper_bot.utils.rate_limit_monitor import (
get_rate_limit_summary,
rate_limit_monitor,
)
from logs.custom_logger import logger
class RateLimitHandlers:
def __init__(self, db, settings):
self.db = db.get_db() if hasattr(db, 'get_db') else db
self.db = db.get_db() if hasattr(db, "get_db") else db
self.settings = settings
self.router = Router()
self._setup_handlers()
@@ -33,38 +39,38 @@ class RateLimitHandlers:
self.router.message.register(
self.rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_stats")
Command("ratelimit_stats"),
)
# Команда для сброса статистики rate limiting
self.router.message.register(
self.reset_rate_limit_stats_handler,
ChatTypeFilter(chat_type=["private"]),
Command("reset_ratelimit_stats")
Command("reset_ratelimit_stats"),
)
# Команда для просмотра ошибок rate limiting
self.router.message.register(
self.rate_limit_errors_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_errors")
Command("ratelimit_errors"),
)
# Команда для просмотра Prometheus метрик
self.router.message.register(
self.rate_limit_prometheus_handler,
ChatTypeFilter(chat_type=["private"]),
Command("ratelimit_prometheus")
Command("ratelimit_prometheus"),
)
@track_time("rate_limit_stats_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_stats_handler")
async def rate_limit_stats_handler(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Показывает статистику rate limiting"""
try:
@@ -72,11 +78,11 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Получаем сводку
summary = get_rate_limit_summary()
global_stats = rate_limit_monitor.get_global_stats()
# Формируем сообщение со статистикой
stats_text = (
f"📊 <b>Статистика Rate Limiting</b>\n\n"
@@ -89,15 +95,17 @@ class RateLimitHandlers:
f"• Активных чатов: {summary['active_chats']}\n"
f"• Ошибок за час: {summary['recent_errors_count']}\n\n"
)
# Добавляем детальную статистику
stats_text += f"🔍 <b>Детальная статистика:</b>\n"
stats_text += f"• Успешных запросов: {global_stats.successful_requests}\n"
stats_text += f"• Неудачных запросов: {global_stats.failed_requests}\n"
stats_text += f"• RetryAfter ошибок: {global_stats.retry_after_errors}\n"
stats_text += f"• Других ошибок: {global_stats.other_errors}\n"
stats_text += f"• Общее время ожидания: {global_stats.total_wait_time:.2f}с\n\n"
stats_text += (
f"• Общее время ожидания: {global_stats.total_wait_time:.2f}с\n\n"
)
# Добавляем топ чатов по запросам
top_chats = rate_limit_monitor.get_top_chats_by_requests(5)
if top_chats:
@@ -105,16 +113,16 @@ class RateLimitHandlers:
for i, (chat_id, chat_stats) in enumerate(top_chats, 1):
stats_text += f"{i}. Chat {chat_id}: {chat_stats.total_requests} запросов ({chat_stats.success_rate:.1%} успех)\n"
stats_text += "\n"
# Добавляем чаты с высоким процентом ошибок
high_error_chats = rate_limit_monitor.get_chats_with_high_error_rate(0.1)
if high_error_chats:
stats_text += f"⚠️ <b>Чаты с высоким процентом ошибок (>10%):</b>\n"
for chat_id, chat_stats in high_error_chats[:3]:
stats_text += f"• Chat {chat_id}: {chat_stats.error_rate:.1%} ошибок ({chat_stats.failed_requests}/{chat_stats.total_requests})\n"
await message.answer(stats_text, parse_mode='HTML')
await message.answer(stats_text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка при получении статистики rate limiting: {e}")
await message.answer("Произошла ошибка при получении статистики.")
@@ -123,10 +131,10 @@ class RateLimitHandlers:
@track_errors("rate_limit_handlers", "reset_rate_limit_stats_handler")
async def reset_rate_limit_stats_handler(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Сбрасывает статистику rate limiting"""
try:
@@ -134,12 +142,12 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Сбрасываем статистику
rate_limit_monitor.reset_stats()
await message.answer("✅ Статистика rate limiting сброшена.")
except Exception as e:
logger.error(f"Ошибка при сбросе статистики rate limiting: {e}")
await message.answer("Произошла ошибка при сбросе статистики.")
@@ -148,10 +156,10 @@ class RateLimitHandlers:
@track_errors("rate_limit_handlers", "rate_limit_errors_handler")
async def rate_limit_errors_handler(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Показывает недавние ошибки rate limiting"""
try:
@@ -159,29 +167,34 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Получаем ошибки за последний час
recent_errors = rate_limit_monitor.get_recent_errors(60)
error_summary = rate_limit_monitor.get_error_summary(60)
if not recent_errors:
await message.answer("✅ Ошибок rate limiting за последний час не было.")
await message.answer(
"✅ Ошибок rate limiting за последний час не было."
)
return
# Формируем сообщение с ошибками
errors_text = f"🚨 <b>Ошибки Rate Limiting (последний час)</b>\n\n"
errors_text += f"📊 <b>Сводка ошибок:</b>\n"
for error_type, count in error_summary.items():
errors_text += f"{error_type}: {count}\n"
errors_text += f"\nВсего ошибок: {len(recent_errors)}\n\n"
# Показываем последние 10 ошибок
errors_text += f"🔍 <b>Последние ошибки:</b>\n"
for i, error in enumerate(recent_errors[-10:], 1):
from datetime import datetime
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
timestamp = datetime.fromtimestamp(error["timestamp"]).strftime(
"%H:%M:%S"
)
errors_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
# Если сообщение слишком длинное, разбиваем на части
if len(errors_text) > 4000:
# Отправляем сводку
@@ -190,32 +203,37 @@ class RateLimitHandlers:
for error_type, count in error_summary.items():
summary_text += f"{error_type}: {count}\n"
summary_text += f"\nВсего ошибок: {len(recent_errors)}"
await message.answer(summary_text, parse_mode='HTML')
await message.answer(summary_text, parse_mode="HTML")
# Отправляем детали отдельным сообщением
details_text = f"🔍 <b>Последние ошибки:</b>\n"
for i, error in enumerate(recent_errors[-10:], 1):
from datetime import datetime
timestamp = datetime.fromtimestamp(error['timestamp']).strftime("%H:%M:%S")
timestamp = datetime.fromtimestamp(error["timestamp"]).strftime(
"%H:%M:%S"
)
details_text += f"{i}. {timestamp} - Chat {error['chat_id']} - {error['error_type']}\n"
await message.answer(details_text, parse_mode='HTML')
await message.answer(details_text, parse_mode="HTML")
else:
await message.answer(errors_text, parse_mode='HTML')
await message.answer(errors_text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка при получении ошибок rate limiting: {e}")
await message.answer("Произошла ошибка при получении информации об ошибках.")
await message.answer(
"Произошла ошибка при получении информации об ошибках."
)
@track_time("rate_limit_prometheus_handler", "rate_limit_handlers")
@track_errors("rate_limit_handlers", "rate_limit_prometheus_handler")
async def rate_limit_prometheus_handler(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Показывает Prometheus метрики rate limiting"""
try:
@@ -223,13 +241,13 @@ class RateLimitHandlers:
if not await bot_db.is_admin(message.from_user.id):
await message.answer("У вас нет прав для выполнения этой команды.")
return
# Обновляем gauge метрики
update_rate_limit_gauges()
# Получаем сводку метрик
metrics_summary = get_rate_limit_metrics_summary()
# Формируем сообщение с метриками
metrics_text = (
f"📊 <b>Prometheus метрики Rate Limiting</b>\n\n"
@@ -241,30 +259,40 @@ class RateLimitHandlers:
f"• rate_limit_avg_wait_time: {metrics_summary['average_wait_time']:.3f}s\n"
f"• rate_limit_active_chats: {metrics_summary['active_chats']}\n\n"
)
# Добавляем детальные метрики
metrics_text += f"🔍 <b>Детальные метрики:</b>\n"
metrics_text += f"• Успешных запросов: {metrics_summary['successful_requests']}\n"
metrics_text += f"Неудачных запросов: {metrics_summary['failed_requests']}\n"
metrics_text += f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n"
metrics_text += (
f"Успешных запросов: {metrics_summary['successful_requests']}\n"
)
metrics_text += (
f"• Неудачных запросов: {metrics_summary['failed_requests']}\n"
)
metrics_text += (
f"• RetryAfter ошибок: {metrics_summary['retry_after_errors']}\n"
)
metrics_text += f"• Других ошибок: {metrics_summary['other_errors']}\n"
metrics_text += f"• Общее время ожидания: {metrics_summary['total_wait_time']:.2f}s\n\n"
metrics_text += (
f"• Общее время ожидания: {metrics_summary['total_wait_time']:.2f}s\n\n"
)
# Добавляем информацию о доступных метриках
metrics_text += f"📈 <b>Доступные Prometheus метрики:</b>\n"
metrics_text += f"• rate_limit_requests_total - общее количество запросов\n"
metrics_text += f"• rate_limit_errors_total - количество ошибок по типам\n"
metrics_text += f"• rate_limit_wait_duration_seconds - время ожидания\n"
metrics_text += f"• rate_limit_request_interval_seconds - интервалы между запросами\n"
metrics_text += (
f"• rate_limit_request_interval_seconds - интервалы между запросами\n"
)
metrics_text += f"• rate_limit_active_chats - количество активных чатов\n"
metrics_text += f"• rate_limit_success_rate - процент успеха по чатам\n"
metrics_text += f"• rate_limit_requests_per_minute - запросов в минуту\n"
metrics_text += f"• rate_limit_total_requests - общее количество запросов\n"
metrics_text += f"• rate_limit_total_errors - количество ошибок\n"
metrics_text += f"• rate_limit_avg_wait_time - среднее время ожидания\n"
await message.answer(metrics_text, parse_mode='HTML')
await message.answer(metrics_text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка при получении Prometheus метрик: {e}")
await message.answer("Произошла ошибка при получении метрик.")

View File

@@ -1,11 +1,16 @@
from datetime import datetime
from typing import List, Optional
from helper_bot.handlers.admin.exceptions import (InvalidInputError,
UserAlreadyBannedError)
from helper_bot.utils.helper_func import (add_days_to_date,
get_banned_users_buttons,
get_banned_users_list)
from helper_bot.handlers.admin.exceptions import (
InvalidInputError,
UserAlreadyBannedError,
)
from helper_bot.utils.helper_func import (
add_days_to_date,
get_banned_users_buttons,
get_banned_users_list,
)
# Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time
from logs.custom_logger import logger
@@ -13,6 +18,7 @@ from logs.custom_logger import logger
class User:
"""Модель пользователя"""
def __init__(self, user_id: int, username: str, full_name: str):
self.user_id = user_id
self.username = username
@@ -21,7 +27,10 @@ class User:
class BannedUser:
"""Модель заблокированного пользователя"""
def __init__(self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]):
def __init__(
self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]
):
self.user_id = user_id
self.username = username
self.reason = reason
@@ -30,10 +39,10 @@ class BannedUser:
class AdminService:
"""Сервис для административных операций"""
def __init__(self, bot_db):
self.bot_db = bot_db
@track_time("get_last_users", "admin_service")
@track_errors("admin_service", "get_last_users")
async def get_last_users(self) -> List[User]:
@@ -41,17 +50,13 @@ class AdminService:
try:
users_data = await self.bot_db.get_last_users(30)
return [
User(
user_id=user[1],
username='Неизвестно',
full_name=user[0]
)
User(user_id=user[1], username="Неизвестно", full_name=user[0])
for user in users_data
]
except Exception as e:
logger.error(f"Ошибка при получении списка последних пользователей: {e}")
raise
@track_time("get_banned_users", "admin_service")
@track_errors("admin_service", "get_banned_users")
async def get_banned_users(self) -> List[BannedUser]:
@@ -65,18 +70,22 @@ class AdminService:
username = await self.bot_db.get_username(user_id)
full_name = await self.bot_db.get_full_name_by_id(user_id)
user_name = username or full_name or f"User_{user_id}"
banned_users.append(BannedUser(
user_id=user_id,
username=user_name,
reason=reason,
unban_date=unban_date
))
banned_users.append(
BannedUser(
user_id=user_id,
username=user_name,
reason=reason,
unban_date=unban_date,
)
)
return banned_users
except Exception as e:
logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}")
logger.error(
f"Ошибка при получении списка заблокированных пользователей: {e}"
)
raise
@track_time("get_user_by_username", "admin_service")
@track_errors("admin_service", "get_user_by_username")
async def get_user_by_username(self, username: str) -> Optional[User]:
@@ -85,17 +94,15 @@ class AdminService:
user_id = await self.bot_db.get_user_id_by_username(username)
if not user_id:
return None
full_name = await self.bot_db.get_full_name_by_id(user_id)
return User(
user_id=user_id,
username=username,
full_name=full_name or 'Неизвестно'
user_id=user_id, username=username, full_name=full_name or "Неизвестно"
)
except Exception as e:
logger.error(f"Ошибка при поиске пользователя по username {username}: {e}")
raise
@track_time("get_user_by_id", "admin_service")
@track_errors("admin_service", "get_user_by_id")
async def get_user_by_id(self, user_id: int) -> Optional[User]:
@@ -104,39 +111,50 @@ class AdminService:
user_info = await self.bot_db.get_user_by_id(user_id)
if not user_info:
return None
return User(
user_id=user_id,
username=user_info.username or 'Неизвестно',
full_name=user_info.full_name or 'Неизвестно'
username=user_info.username or "Неизвестно",
full_name=user_info.full_name or "Неизвестно",
)
except Exception as e:
logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}")
raise
@track_time("ban_user", "admin_service")
@track_errors("admin_service", "ban_user")
async def ban_user(self, user_id: int, username: str, reason: str, ban_days: Optional[int], ban_author_id: int) -> None:
async def ban_user(
self,
user_id: int,
username: str,
reason: str,
ban_days: Optional[int],
ban_author_id: int,
) -> None:
"""Заблокировать пользователя"""
try:
# Проверяем, не заблокирован ли уже пользователь
if await self.bot_db.check_user_in_blacklist(user_id):
raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован")
# Рассчитываем дату разблокировки
date_to_unban = None
if ban_days is not None:
date_to_unban = add_days_to_date(ban_days)
# Сохраняем в БД (username больше не передается, так как не используется в новой схеме)
await self.bot_db.set_user_blacklist(user_id, None, reason, date_to_unban, ban_author=ban_author_id)
logger.info(f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней")
await self.bot_db.set_user_blacklist(
user_id, None, reason, date_to_unban, ban_author=ban_author_id
)
logger.info(
f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней"
)
except Exception as e:
logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}")
raise
@track_time("unban_user", "admin_service")
@track_errors("admin_service", "unban_user")
async def unban_user(self, user_id: int) -> None:
@@ -147,7 +165,7 @@ class AdminService:
except Exception as e:
logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}")
raise
@track_time("validate_user_input", "admin_service")
@track_errors("admin_service", "validate_user_input")
async def validate_user_input(self, input_text: str) -> int:
@@ -155,11 +173,13 @@ class AdminService:
try:
user_id = int(input_text.strip())
if user_id <= 0:
raise InvalidInputError("ID пользователя должен быть положительным числом")
raise InvalidInputError(
"ID пользователя должен быть положительным числом"
)
return user_id
except ValueError:
raise InvalidInputError("ID пользователя должен быть числом")
@track_time("get_banned_users_for_display", "admin_service")
@track_errors("admin_service", "get_banned_users_for_display")
async def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]:
@@ -170,5 +190,7 @@ class AdminService:
buttons_list = await get_banned_users_buttons(self.bot_db)
return message_text, buttons_list
except Exception as e:
logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}")
logger.error(
f"Ошибка при получении данных заблокированных пользователей: {e}"
)
raise

View File

@@ -3,6 +3,7 @@ from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from helper_bot.handlers.admin.exceptions import AdminError
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin
from logs.custom_logger import logger
@@ -13,33 +14,41 @@ def escape_html(text: str) -> str:
return html.escape(str(text)) if text else ""
async def return_to_admin_menu(message: types.Message, state: FSMContext,
additional_message: Optional[str] = None) -> None:
async def return_to_admin_menu(
message: types.Message, state: FSMContext, additional_message: Optional[str] = None
) -> None:
"""Универсальная функция для возврата в админ-меню"""
logger.info(f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}")
logger.info(
f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}"
)
await state.set_data({})
await state.set_state("ADMIN")
markup = get_reply_keyboard_admin()
if additional_message:
logger.info(f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}")
logger.info(
f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}"
)
await message.answer(additional_message)
await message.answer('Вернулись в меню', reply_markup=markup)
logger.info(f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню")
await message.answer("Вернулись в меню", reply_markup=markup)
logger.info(
f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню"
)
async def handle_admin_error(message: types.Message, error: Exception,
state: FSMContext, error_context: str = "") -> None:
async def handle_admin_error(
message: types.Message, error: Exception, state: FSMContext, error_context: str = ""
) -> None:
"""Централизованная обработка ошибок административных операций"""
logger.error(f"Ошибка в {error_context}: {error}")
if isinstance(error, AdminError):
await message.answer(f"Ошибка: {str(error)}")
else:
await message.answer("Произошла внутренняя ошибка. Попробуйте позже.")
await return_to_admin_menu(message, state)
@@ -47,19 +56,23 @@ def format_user_info(user_id: int, username: str, full_name: str) -> str:
"""Форматирование информации о пользователе для отображения"""
safe_username = escape_html(username)
safe_full_name = escape_html(full_name)
return (f"<b>Выбран пользователь:</b>\n"
f"<b>ID:</b> {user_id}\n"
f"<b>Username:</b> {safe_username}\n"
f"<b>Имя:</b> {safe_full_name}")
return (
f"<b>Выбран пользователь:</b>\n"
f"<b>ID:</b> {user_id}\n"
f"<b>Username:</b> {safe_username}\n"
f"<b>Имя:</b> {safe_full_name}"
)
def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str:
"""Форматирование подтверждения бана"""
safe_reason = escape_html(reason)
ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней"
return (f"<b>Необходимо подтверждение:</b>\n"
f"<b>Пользователь:</b> {user_id}\n"
f"<b>Причина бана:</b> {safe_reason}\n"
f"<b>Срок бана:</b> {ban_text}")
return (
f"<b>Необходимо подтверждение:</b>\n"
f"<b>Пользователь:</b> {user_id}\n"
f"<b>Причина бана:</b> {safe_reason}\n"
f"<b>Срок бана:</b> {ban_text}"
)

View File

@@ -1,23 +1,34 @@
from .callback_handlers import callback_router
from .constants import (CALLBACK_BAN, CALLBACK_DECLINE, CALLBACK_PAGE,
CALLBACK_PUBLISH, CALLBACK_RETURN, CALLBACK_UNLOCK)
from .exceptions import (BanError, PostNotFoundError, PublishError,
UserBlockedBotError, UserNotFoundError)
from .constants import (
CALLBACK_BAN,
CALLBACK_DECLINE,
CALLBACK_PAGE,
CALLBACK_PUBLISH,
CALLBACK_RETURN,
CALLBACK_UNLOCK,
)
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
from .services import BanService, PostPublishService
__all__ = [
'callback_router',
'PostPublishService',
'BanService',
'UserBlockedBotError',
'PostNotFoundError',
'UserNotFoundError',
'PublishError',
'BanError',
'CALLBACK_PUBLISH',
'CALLBACK_DECLINE',
'CALLBACK_BAN',
'CALLBACK_UNLOCK',
'CALLBACK_RETURN',
'CALLBACK_PAGE'
"callback_router",
"PostPublishService",
"BanService",
"UserBlockedBotError",
"PostNotFoundError",
"UserNotFoundError",
"PublishError",
"BanError",
"CALLBACK_PUBLISH",
"CALLBACK_DECLINE",
"CALLBACK_BAN",
"CALLBACK_UNLOCK",
"CALLBACK_RETURN",
"CALLBACK_PAGE",
]

View File

@@ -7,28 +7,49 @@ from aiogram import F, Router
from aiogram.filters import MagicData
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from helper_bot.handlers.admin.utils import format_user_info
from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
from helper_bot.handlers.voice.services import AudioFileService
from helper_bot.keyboards.keyboards import (create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin)
from helper_bot.keyboards.keyboards import (
create_keyboard_for_ban_reason,
create_keyboard_with_pagination,
get_reply_keyboard_admin,
)
from helper_bot.utils.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import (get_banned_users_buttons,
get_banned_users_list)
from helper_bot.utils.helper_func import get_banned_users_buttons, get_banned_users_list
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
track_file_operations, track_time)
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_file_operations,
track_time,
)
from logs.custom_logger import logger
from .constants import (CALLBACK_BAN, CALLBACK_DECLINE, CALLBACK_PAGE,
CALLBACK_PUBLISH, CALLBACK_RETURN, CALLBACK_UNLOCK,
ERROR_BOT_BLOCKED, MESSAGE_DECLINED, MESSAGE_ERROR,
MESSAGE_PUBLISHED, MESSAGE_USER_BANNED,
MESSAGE_USER_UNLOCKED)
from .constants import (
CALLBACK_BAN,
CALLBACK_DECLINE,
CALLBACK_PAGE,
CALLBACK_PUBLISH,
CALLBACK_RETURN,
CALLBACK_UNLOCK,
ERROR_BOT_BLOCKED,
MESSAGE_DECLINED,
MESSAGE_ERROR,
MESSAGE_PUBLISHED,
MESSAGE_USER_BANNED,
MESSAGE_USER_UNLOCKED,
)
from .dependency_factory import get_ban_service, get_post_publish_service
from .exceptions import (BanError, PostNotFoundError, PublishError,
UserBlockedBotError, UserNotFoundError)
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
callback_router = Router()
@@ -36,65 +57,61 @@ callback_router = Router()
@callback_router.callback_query(F.data == CALLBACK_PUBLISH)
@track_time("post_for_group", "callback_handlers")
@track_errors("callback_handlers", "post_for_group")
async def post_for_group(
call: CallbackQuery,
settings: MagicData("settings")
):
async def post_for_group(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
logger.info(
f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})')
f"Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})"
)
try:
await publish_service.publish_post(call)
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка при публикации поста: {str(e)}')
logger.error(f"Ошибка при публикации поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
logger.error(f'Неожиданная ошибка при публикации поста: {str(e)}')
logger.error(f"Неожиданная ошибка при публикации поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DECLINE)
@track_time("decline_post_for_group", "callback_handlers")
@track_errors("callback_handlers", "decline_post_for_group")
async def decline_post_for_group(
call: CallbackQuery,
settings: MagicData("settings")
):
async def decline_post_for_group(call: CallbackQuery, settings: MagicData("settings")):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
logger.info(
f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})')
f"Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})"
)
try:
await publish_service.decline_post(call)
await call.answer(text=MESSAGE_DECLINED, cache_time=3)
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
logger.error(f'Ошибка при отклонении поста: {str(e)}')
logger.error(f"Ошибка при отклонении поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
important_logs = settings['Telegram']['important_logs']
important_logs = settings["Telegram"]["important_logs"]
await call.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
logger.error(f'Неожиданная ошибка при отклонении поста: {str(e)}')
logger.error(f"Неожиданная ошибка при отклонении поста: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@@ -110,65 +127,75 @@ async def ban_user_from_post(call: CallbackQuery, **kwargs):
except UserBlockedBotError:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (UserNotFoundError, BanError) as e:
logger.error(f'Ошибка при блокировке пользователя: {str(e)}')
logger.error(f"Ошибка при блокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
else:
logger.error(f'Неожиданная ошибка при блокировке пользователя: {str(e)}')
logger.error(f"Неожиданная ошибка при блокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@callback_router.callback_query(F.data.contains(CALLBACK_BAN))
@track_time("process_ban_user", "callback_handlers")
@track_errors("callback_handlers", "process_ban_user")
async def process_ban_user(call: CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs):
async def process_ban_user(
call: CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db"), **kwargs
):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[4:]
logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}")
logger.info(
f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}"
)
# Проверяем, что user_id является валидным числом
try:
user_id_int = int(user_id)
except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3)
await call.answer(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
return
try:
# Получаем username пользователя
username = await ban_service.ban_user(str(user_id_int), "")
if not username:
raise UserNotFoundError(f"Пользователь с ID {user_id_int} не найден в базе")
# Получаем full_name пользователя из базы данных
full_name = await bot_db.get_full_name_by_id(user_id_int)
if not full_name:
full_name = 'Неизвестно'
full_name = "Неизвестно"
# Сохраняем данные в формате, совместимом с admin_handlers
await state.update_data(
target_user_id=user_id_int,
target_username=username,
target_full_name=full_name
target_full_name=full_name,
)
# Используем единый формат отображения информации о пользователе
user_info = format_user_info(user_id_int, username, full_name)
markup = create_keyboard_for_ban_reason()
await call.message.answer(
text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup
reply_markup=markup,
)
await state.set_state("AWAIT_BAN_DETAILS")
logger.info(
f"process_ban_user: Состояние изменено на AWAIT_BAN_DETAILS для пользователя {user_id_int}"
)
await state.set_state('AWAIT_BAN_DETAILS')
logger.info(f"process_ban_user: Состояние изменено на AWAIT_BAN_DETAILS для пользователя {user_id_int}")
except UserNotFoundError:
markup = get_reply_keyboard_admin()
await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup)
await state.set_state('ADMIN')
await call.message.answer(
text="Пользователь с таким ID не найден в базе", reply_markup=markup
)
await state.set_state("ADMIN")
@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
@@ -178,22 +205,26 @@ async def process_unlock_user(call: CallbackQuery, **kwargs):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[7:]
# Проверяем, что user_id является валидным числом
try:
user_id_int = int(user_id)
except ValueError:
logger.error(f"Некорректный user_id в callback: {user_id}")
await call.answer(text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3)
await call.answer(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
return
try:
username = await ban_service.unlock_user(str(user_id_int))
await call.answer(f'{MESSAGE_USER_UNLOCKED} {username}', show_alert=True)
await call.answer(f"{MESSAGE_USER_UNLOCKED} {username}", show_alert=True)
except UserNotFoundError:
await call.answer(text='Пользователь не найден в базе', show_alert=True, cache_time=3)
await call.answer(
text="Пользователь не найден в базе", show_alert=True, cache_time=3
)
except Exception as e:
logger.error(f'Ошибка при разблокировке пользователя: {str(e)}')
logger.error(f"Ошибка при разблокировке пользователя: {str(e)}")
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
@@ -204,48 +235,52 @@ async def return_to_main_menu(call: CallbackQuery, **kwargs):
await call.message.delete()
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
markup = get_reply_keyboard_admin()
await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
await call.message.answer(
"Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup
)
@callback_router.callback_query(F.data.contains(CALLBACK_PAGE))
@track_time("change_page", "callback_handlers")
@track_errors("callback_handlers", "change_page")
async def change_page(
call: CallbackQuery,
bot_db: MagicData("bot_db"),
**kwargs
):
async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs):
try:
page_number = int(call.data[5:])
except ValueError:
logger.error(f"Некорректный номер страницы в callback: {call.data}")
await call.answer(text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3)
await call.answer(
text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3
)
return
logger.info(f"Переход на страницу {page_number}")
if call.message.text == 'Список пользователей которые последними обращались к боту':
if call.message.text == "Список пользователей которые последними обращались к боту":
list_users = await bot_db.get_last_users(30)
keyboard = create_keyboard_with_pagination(page_number, len(list_users), list_users, 'ban')
keyboard = create_keyboard_with_pagination(
page_number, len(list_users), list_users, "ban"
)
await call.bot.edit_message_reply_markup(
chat_id=call.message.chat.id,
chat_id=call.message.chat.id,
message_id=call.message.message_id,
reply_markup=keyboard
reply_markup=keyboard,
)
else:
message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db)
await call.bot.edit_message_text(
chat_id=call.message.chat.id,
chat_id=call.message.chat.id,
message_id=call.message.message_id,
text=message_user
text=message_user,
)
buttons = await get_banned_users_buttons(bot_db)
keyboard = create_keyboard_with_pagination(page_number, len(buttons), buttons, 'unlock')
keyboard = create_keyboard_with_pagination(
page_number, len(buttons), buttons, "unlock"
)
await call.bot.edit_message_reply_markup(
chat_id=call.message.chat.id,
chat_id=call.message.chat.id,
message_id=call.message.message_id,
reply_markup=keyboard
reply_markup=keyboard,
)
@@ -255,73 +290,81 @@ async def change_page(
@track_file_operations("voice")
@db_query_time("save_voice_message", "audio_moderate", "mixed")
async def save_voice_message(
call: CallbackQuery,
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs
):
**kwargs,
):
try:
logger.info(f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}")
logger.info(
f"Начинаем сохранение голосового сообщения. Message ID: {call.message.message_id}"
)
# Создаем сервис для работы с аудио файлами
audio_service = AudioFileService(bot_db)
# Получаем ID пользователя из базы
user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(call.message.message_id)
user_id = await bot_db.get_user_id_by_message_id_for_voice_bot(
call.message.message_id
)
logger.info(f"Получен user_id: {user_id}")
# Генерируем имя файла
file_name = await audio_service.generate_file_name(user_id)
logger.info(f"Сгенерировано имя файла: {file_name}")
# Собираем инфо о сообщении
time_UTC = int(time.time())
date_added = datetime.fromtimestamp(time_UTC)
# Получаем file_id из voice сообщения
file_id = call.message.voice.file_id if call.message.voice else ""
logger.info(f"Получен file_id: {file_id}")
# ВАЖНО: Сначала скачиваем и сохраняем файл на диск
logger.info("Начинаем скачивание и сохранение файла на диск...")
await audio_service.download_and_save_audio(call.bot, call.message, file_name)
logger.info("Файл успешно скачан и сохранен на диск")
# Только после успешного сохранения файла - сохраняем в базу данных
logger.info("Начинаем сохранение информации в базу данных...")
await audio_service.save_audio_file(file_name, user_id, date_added, file_id)
logger.info("Информация успешно сохранена в базу данных")
# Удаляем сообщение из предложки
logger.info("Удаляем сообщение из предложки...")
await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'],
message_id=call.message.message_id
chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id,
)
logger.info("Сообщение удалено из предложки")
# Удаляем запись из таблицы audio_moderate
logger.info("Удаляем запись из таблицы audio_moderate...")
await bot_db.delete_audio_moderate_record(call.message.message_id)
logger.info("Запись удалена из таблицы audio_moderate")
await call.answer(text='Сохранено!', cache_time=3)
await call.answer(text="Сохранено!", cache_time=3)
logger.info(f"Голосовое сообщение успешно сохранено: {file_name}")
except Exception as e:
logger.error(f"Ошибка при сохранении голосового сообщения: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
# Дополнительная информация для диагностики
try:
if 'call' in locals() and call.message:
if "call" in locals() and call.message:
logger.error(f"Message ID: {call.message.message_id}")
logger.error(f"User ID: {user_id if 'user_id' in locals() else 'не определен'}")
logger.error(f"File name: {file_name if 'file_name' in locals() else 'не определен'}")
logger.error(
f"User ID: {user_id if 'user_id' in locals() else 'не определен'}"
)
logger.error(
f"File name: {file_name if 'file_name' in locals() else 'не определен'}"
)
except:
pass
await call.answer(text='Ошибка при сохранении!', cache_time=3)
await call.answer(text="Ошибка при сохранении!", cache_time=3)
@callback_router.callback_query(F.data == CALLBACK_DELETE)
@@ -329,23 +372,23 @@ async def save_voice_message(
@track_errors("callback_handlers", "delete_voice_message")
@db_query_time("delete_voice_message", "audio_moderate", "delete")
async def delete_voice_message(
call: CallbackQuery,
call: CallbackQuery,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
**kwargs
):
**kwargs,
):
try:
# Удаляем сообщение из предложки
await call.bot.delete_message(
chat_id=settings['Telegram']['group_for_posts'],
message_id=call.message.message_id
chat_id=settings["Telegram"]["group_for_posts"],
message_id=call.message.message_id,
)
# Удаляем запись из таблицы audio_moderate
await bot_db.delete_audio_moderate_record(call.message.message_id)
await call.answer(text='Удалено!', cache_time=3)
await call.answer(text="Удалено!", cache_time=3)
except Exception as e:
logger.error(f"Ошибка при удалении голосового сообщения: {e}")
await call.answer(text='Ошибка при удалении!', cache_time=3)
await call.answer(text="Ошибка при удалении!", cache_time=3)

View File

@@ -33,9 +33,9 @@ ERROR_BOT_BLOCKED = "Forbidden: bot was blocked by the user"
# Callback to command mapping for metrics
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"publish": "publish",
"decline": "decline",
"decline": "decline",
"ban": "ban",
"unlock": "unlock",
"return": "return",
"page": "page"
"page": "page",
}

View File

@@ -3,6 +3,7 @@ from typing import Callable
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.context import FSMContext
from helper_bot.utils.base_dependency_factory import get_global_instance
from .services import BanService, PostPublishService
@@ -21,7 +22,7 @@ def get_post_publish_service() -> PostPublishService:
def get_ban_service() -> BanService:
"""Фабрика для BanService"""
bdf = get_global_instance()
db = bdf.get_db()
settings = bdf.settings
return BanService(None, db, settings)

View File

@@ -1,23 +1,28 @@
class UserBlockedBotError(Exception):
"""Исключение, возникающее когда пользователь заблокировал бота"""
pass
class PostNotFoundError(Exception):
"""Исключение, возникающее когда пост не найден в базе данных"""
pass
class UserNotFoundError(Exception):
"""Исключение, возникающее когда пользователь не найден в базе данных"""
pass
class PublishError(Exception):
"""Общее исключение для ошибок публикации"""
pass
class BanError(Exception):
"""Исключение для ошибок бана/разбана пользователей"""
pass

View File

@@ -4,28 +4,49 @@ from typing import Any, Dict
from aiogram import Bot, types
from aiogram.types import CallbackQuery
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
from helper_bot.utils.helper_func import (delete_user_blacklist,
get_text_message, send_audio_message,
send_media_group_to_channel,
send_photo_message,
send_text_message,
send_video_message,
send_video_note_message,
send_voice_message)
from helper_bot.utils.helper_func import (
delete_user_blacklist,
get_text_message,
send_audio_message,
send_media_group_to_channel,
send_photo_message,
send_text_message,
send_video_message,
send_video_note_message,
send_voice_message,
)
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
track_media_processing, track_time)
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_media_processing,
track_time,
)
from logs.custom_logger import logger
from .constants import (CONTENT_TYPE_AUDIO, CONTENT_TYPE_MEDIA_GROUP,
CONTENT_TYPE_PHOTO, CONTENT_TYPE_TEXT,
CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE,
CONTENT_TYPE_VOICE, ERROR_BOT_BLOCKED,
MESSAGE_POST_DECLINED, MESSAGE_POST_PUBLISHED,
MESSAGE_USER_BANNED_SPAM)
from .exceptions import (BanError, PostNotFoundError, PublishError,
UserBlockedBotError, UserNotFoundError)
from .constants import (
CONTENT_TYPE_AUDIO,
CONTENT_TYPE_MEDIA_GROUP,
CONTENT_TYPE_PHOTO,
CONTENT_TYPE_TEXT,
CONTENT_TYPE_VIDEO,
CONTENT_TYPE_VIDEO_NOTE,
CONTENT_TYPE_VOICE,
ERROR_BOT_BLOCKED,
MESSAGE_POST_DECLINED,
MESSAGE_POST_PUBLISHED,
MESSAGE_USER_BANNED_SPAM,
)
from .exceptions import (
BanError,
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
class PostPublishService:
@@ -35,10 +56,10 @@ class PostPublishService:
self.db = db
self.settings = settings
self.s3_storage = s3_storage
self.group_for_posts = settings['Telegram']['group_for_posts']
self.main_public = settings['Telegram']['main_public']
self.important_logs = settings['Telegram']['important_logs']
self.group_for_posts = settings["Telegram"]["group_for_posts"]
self.main_public = settings["Telegram"]["main_public"]
self.important_logs = settings["Telegram"]["important_logs"]
def _get_bot(self, message) -> Bot:
"""Получает бота из контекста сообщения или использует переданного"""
if self.bot:
@@ -53,14 +74,14 @@ class PostPublishService:
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
await self._publish_media_group(call)
return
# Проверяем, является ли сообщение частью медиагруппы (для обратной совместимости)
if call.message.media_group_id:
await self._publish_media_group(call)
return
content_type = call.message.content_type
if content_type == CONTENT_TYPE_TEXT:
await self._publish_text_post(call)
elif content_type == CONTENT_TYPE_PHOTO:
@@ -82,34 +103,50 @@ class PostPublishService:
"""Публикация текстового поста"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
# Получаем данные автора
user = await self.db.get_user_by_id(author_id)
if not user:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
sent_message = await send_text_message(self.main_public, call.message, formatted_text)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_text_message(
self.main_public, call.message, formatted_text
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Текст сообщение опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Текст сообщение опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_photo_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_photo_post")
@@ -117,37 +154,58 @@ class PostPublishService:
"""Публикация поста с фото"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
# Получаем данные автора
user = await self.db.get_user_by_id(author_id)
if not user:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
sent_message = await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, formatted_text)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_photo_message(
self.main_public,
call.message,
call.message.photo[-1].file_id,
formatted_text,
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с фото опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с фото опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_video_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_post")
@@ -155,37 +213,55 @@ class PostPublishService:
"""Публикация поста с видео"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
# Получаем данные автора
user = await self.db.get_user_by_id(author_id)
if not user:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
sent_message = await send_video_message(self.main_public, call.message, call.message.video.file_id, formatted_text)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_video_message(
self.main_public, call.message, call.message.video.file_id, formatted_text
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с видео опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с видео опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_video_note_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_video_note_post")
@@ -193,24 +269,36 @@ class PostPublishService:
"""Публикация поста с кружком"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
sent_message = await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id)
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
sent_message = await send_video_note_message(
self.main_public, call.message, call.message.video_note.file_id
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с кружком опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с кружком опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_audio_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_audio_post")
@@ -218,37 +306,55 @@ class PostPublishService:
"""Публикация поста с аудио"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
# Получаем сырой текст и is_anonymous из базы
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_message_id(call.message.message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_message_id(
call.message.message_id
)
)
if raw_text is None:
raw_text = ""
# Получаем данные автора
user = await self.db.get_user_by_id(author_id)
if not user:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
sent_message = await send_audio_message(self.main_public, call.message, call.message.audio.file_id, formatted_text)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
sent_message = await send_audio_message(
self.main_public, call.message, call.message.audio.file_id, formatted_text
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с аудио опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с аудио опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_voice_post", "post_publish_service")
@track_errors("post_publish_service", "_publish_voice_post")
@@ -256,24 +362,36 @@ class PostPublishService:
"""Публикация поста с войсом"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "approved")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "approved"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
sent_message = await send_voice_message(self.main_public, call.message, call.message.voice.file_id)
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'approved'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
sent_message = await send_voice_message(
self.main_public, call.message, call.message.voice.file_id
)
# Сохраняем published_message_id
await self.db.update_published_message_id(
original_message_id=call.message.message_id,
published_message_id=sent_message.message_id
published_message_id=sent_message.message_id,
)
# Сохраняем медиафайл из опубликованного поста (используем уже сохраненный файл)
await self._save_published_post_content(sent_message, sent_message.message_id, call.message.message_id)
await self._save_published_post_content(
sent_message, sent_message.message_id, call.message.message_id
)
await self._delete_post_and_notify_author(call, author_id)
logger.info(f'Пост с войсом опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}.')
logger.info(
f"Пост с войсом опубликован в канале {self.main_public}, published_message_id={sent_message.message_id}."
)
@track_time("_publish_media_group", "post_publish_service")
@track_errors("post_publish_service", "_publish_media_group")
@@ -282,91 +400,130 @@ class PostPublishService:
"""Публикация медиагруппы"""
try:
helper_message_id = call.message.message_id
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
helper_message_id
)
if not media_group_message_ids:
logger.error(f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}")
logger.error(
f"_publish_media_group: Не найдены message_id медиагруппы для helper_message_id={helper_message_id}"
)
raise PublishError("Не найдены message_id медиагруппы в базе данных")
post_content = await self.db.get_post_content_by_helper_id(helper_message_id)
post_content = await self.db.get_post_content_by_helper_id(
helper_message_id
)
if not post_content:
logger.error(f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}")
logger.error(
f"_publish_media_group: Контент медиагруппы не найден в базе данных для helper_message_id={helper_message_id}"
)
raise PublishError("Контент медиагруппы не найден в базе данных")
raw_text, is_anonymous = await self.db.get_post_text_and_anonymity_by_helper_id(helper_message_id)
raw_text, is_anonymous = (
await self.db.get_post_text_and_anonymity_by_helper_id(
helper_message_id
)
)
if raw_text is None:
raw_text = ""
author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id)
author_id = await self.db.get_author_id_by_helper_message_id(
helper_message_id
)
if not author_id:
logger.error(f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}")
raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}")
logger.error(
f"_publish_media_group: Автор не найден для медиагруппы helper_message_id={helper_message_id}"
)
raise PostNotFoundError(
f"Автор не найден для медиагруппы {helper_message_id}"
)
user = await self.db.get_user_by_id(author_id)
if not user:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
formatted_text = get_text_message(raw_text, user.first_name, user.username, is_anonymous)
raise PostNotFoundError(
f"Пользователь {author_id} не найден в базе данных"
)
formatted_text = get_text_message(
raw_text, user.first_name, user.username, is_anonymous
)
try:
await self._get_bot(call.message).delete_messages(
chat_id=self.group_for_posts,
message_ids=media_group_message_ids
chat_id=self.group_for_posts, message_ids=media_group_message_ids
)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при удалении медиагруппы из чата модерации: {e}")
logger.warning(
f"_publish_media_group: Ошибка при удалении медиагруппы из чата модерации: {e}"
)
sent_messages = await send_media_group_to_channel(
bot=self._get_bot(call.message),
chat_id=self.main_public,
post_content=post_content,
bot=self._get_bot(call.message),
chat_id=self.main_public,
post_content=post_content,
post_text=formatted_text,
s3_storage=self.s3_storage
s3_storage=self.s3_storage,
)
if len(sent_messages) == len(media_group_message_ids):
for i, original_message_id in enumerate(media_group_message_ids):
published_message_id = sent_messages[i].message_id
try:
await self.db.update_published_message_id(
original_message_id=original_message_id,
published_message_id=published_message_id
published_message_id=published_message_id,
)
await self._save_published_post_content(
sent_messages[i], published_message_id, original_message_id
)
await self._save_published_post_content(sent_messages[i], published_message_id, original_message_id)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при сохранении published_message_id для {original_message_id}: {e}")
logger.warning(
f"_publish_media_group: Ошибка при сохранении published_message_id для {original_message_id}: {e}"
)
else:
logger.warning(f"_publish_media_group: Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(media_group_message_ids)})")
logger.warning(
f"_publish_media_group: Количество опубликованных сообщений ({len(sent_messages)}) не совпадает с количеством оригинальных ({len(media_group_message_ids)})"
)
await self.db.update_status_for_media_group_by_helper_id(
helper_message_id, "approved"
)
await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "approved")
# Удаляем helper сообщение - это критично, делаем это всегда
try:
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts,
message_id=helper_message_id
chat_id=self.group_for_posts, message_id=helper_message_id
)
except Exception as e:
logger.warning(f"_publish_media_group: Ошибка при удалении helper сообщения: {e}")
logger.warning(
f"_publish_media_group: Ошибка при удалении helper сообщения: {e}"
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"_publish_media_group: Пользователь {author_id} заблокировал бота")
logger.warning(
f"_publish_media_group: Пользователь {author_id} заблокировал бота"
)
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"_publish_media_group: Ошибка при отправке уведомления автору: {e}")
logger.error(
f"_publish_media_group: Ошибка при отправке уведомления автору: {e}"
)
except Exception as e:
logger.error(f"_publish_media_group: Ошибка при публикации медиагруппы: {e}")
logger.error(
f"_publish_media_group: Ошибка при публикации медиагруппы: {e}"
)
# Пытаемся удалить helper сообщение даже при ошибке
try:
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts,
message_id=call.message.message_id
chat_id=self.group_for_posts, message_id=call.message.message_id
)
except Exception as delete_error:
logger.warning(f"_publish_media_group: Не удалось удалить helper сообщение при ошибке: {delete_error}")
logger.warning(
f"_publish_media_group: Не удалось удалить helper сообщение при ошибке: {delete_error}"
)
raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}")
@track_time("decline_post", "post_publish_service")
@@ -377,15 +534,25 @@ class PostPublishService:
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
await self._decline_media_group(call)
return
content_type = call.message.content_type
if content_type in [CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO,
CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]:
if content_type in [
CONTENT_TYPE_TEXT,
CONTENT_TYPE_PHOTO,
CONTENT_TYPE_AUDIO,
CONTENT_TYPE_VOICE,
CONTENT_TYPE_VIDEO,
CONTENT_TYPE_VIDEO_NOTE,
]:
await self._decline_single_post(call)
else:
logger.error(f"Неподдерживаемый тип контента для отклонения: {content_type}")
raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}")
logger.error(
f"Неподдерживаемый тип контента для отклонения: {content_type}"
)
raise PublishError(
f"Неподдерживаемый тип контента для отклонения: {content_type}"
)
@track_time("_decline_single_post", "post_publish_service")
@track_errors("post_publish_service", "_decline_single_post")
@@ -393,13 +560,21 @@ class PostPublishService:
"""Отклонение одиночного поста"""
author_id = await self._get_author_id(call.message.message_id)
updated_rows = await self.db.update_status_by_message_id(call.message.message_id, "declined")
updated_rows = await self.db.update_status_by_message_id(
call.message.message_id, "declined"
)
if updated_rows == 0:
logger.error(f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'")
raise PostNotFoundError(f"Пост с message_id={call.message.message_id} не найден в базе данных")
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
logger.error(
f"Не удалось обновить статус поста message_id={call.message.message_id} на 'declined'"
)
raise PostNotFoundError(
f"Пост с message_id={call.message.message_id} не найден в базе данных"
)
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts, message_id=call.message.message_id
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e:
@@ -408,7 +583,9 @@ class PostPublishService:
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}")
raise
logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).')
logger.info(
f"Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id})."
)
@track_time("_decline_media_group", "post_publish_service")
@track_errors("post_publish_service", "_decline_media_group")
@@ -416,10 +593,14 @@ class PostPublishService:
async def _decline_media_group(self, call: CallbackQuery) -> None:
"""Отклонение медиагруппы"""
helper_message_id = call.message.message_id
await self.db.update_status_for_media_group_by_helper_id(helper_message_id, "declined")
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
await self.db.update_status_for_media_group_by_helper_id(
helper_message_id, "declined"
)
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
helper_message_id
)
message_ids_to_delete = media_group_message_ids.copy()
message_ids_to_delete.append(helper_message_id)
@@ -428,19 +609,22 @@ class PostPublishService:
try:
await self._get_bot(call.message).delete_messages(
chat_id=self.group_for_posts,
message_ids=message_ids_to_delete
chat_id=self.group_for_posts, message_ids=message_ids_to_delete
)
except Exception as e:
logger.warning(f"_decline_media_group: Ошибка при удалении сообщений: {e}")
try:
await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
logger.warning(f"_decline_media_group: Пользователь {author_id} заблокировал бота")
logger.warning(
f"_decline_media_group: Пользователь {author_id} заблокировал бота"
)
raise UserBlockedBotError("Пользователь заблокировал бота")
logger.error(f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}")
logger.error(
f"_decline_media_group: Ошибка при отправке уведомления автору {author_id}: {e}"
)
raise
@track_time("_get_author_id", "post_publish_service")
@@ -460,7 +644,7 @@ class PostPublishService:
author_id = await self.db.get_author_id_by_helper_message_id(message_id)
if author_id:
return author_id
# Если не найден, ищем по основному message_id медиагруппы
# Для этого нужно найти связанные сообщения медиагруппы
try:
@@ -474,7 +658,7 @@ class PostPublishService:
return author_id
except Exception as e:
logger.warning(f"Не удалось найти автора через связанные сообщения: {e}")
# Если все способы не сработали, ищем напрямую
author_id = await self.db.get_author_id_by_message_id(message_id)
if not author_id:
@@ -483,10 +667,14 @@ class PostPublishService:
@track_time("_delete_post_and_notify_author", "post_publish_service")
@track_errors("post_publish_service", "_delete_post_and_notify_author")
async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
async def _delete_post_and_notify_author(
self, call: CallbackQuery, author_id: int
) -> None:
"""Удаление поста и уведомление автора"""
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts, message_id=call.message.message_id
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e:
@@ -497,16 +685,22 @@ class PostPublishService:
@track_time("_delete_media_group_and_notify_author", "post_publish_service")
@track_errors("post_publish_service", "_delete_media_group_and_notify_author")
@track_media_processing("media_group")
async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None:
async def _delete_media_group_and_notify_author(
self, call: CallbackQuery, author_id: int
) -> None:
"""Удаление медиагруппы и уведомление автора (legacy метод, используется для обратной совместимости)"""
helper_message_id = call.message.message_id
media_group_message_ids = await self.db.get_post_ids_by_helper_id(helper_message_id)
media_group_message_ids = await self.db.get_post_ids_by_helper_id(
helper_message_id
)
message_ids_to_delete = media_group_message_ids.copy()
message_ids_to_delete.append(helper_message_id)
await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids_to_delete)
await self._get_bot(call.message).delete_messages(
chat_id=self.group_for_posts, message_ids=message_ids_to_delete
)
try:
await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED)
except Exception as e:
@@ -516,30 +710,47 @@ class PostPublishService:
@track_time("_save_published_post_content", "post_publish_service")
@track_errors("post_publish_service", "_save_published_post_content")
async def _save_published_post_content(self, published_message: types.Message, published_message_id: int, original_message_id: int) -> None:
async def _save_published_post_content(
self,
published_message: types.Message,
published_message_id: int,
original_message_id: int,
) -> None:
"""Сохраняет ссылку на медиафайл из опубликованного поста (файл уже в S3 или на диске)."""
try:
# Получаем уже сохраненный путь/S3 ключ из оригинального поста
saved_content = await self.db.get_post_content_by_message_id(original_message_id)
saved_content = await self.db.get_post_content_by_message_id(
original_message_id
)
if saved_content and len(saved_content) > 0:
# Копируем тот же путь/S3 ключ
file_path, content_type = saved_content[0]
logger.debug(f"Копируем путь/S3 ключ для опубликованного поста: {file_path}")
logger.debug(
f"Копируем путь/S3 ключ для опубликованного поста: {file_path}"
)
success = await self.db.add_published_post_content(
published_message_id=published_message_id,
content_path=file_path, # Тот же путь/S3 ключ
content_type=content_type
content_type=content_type,
)
if success:
logger.info(f"Ссылка на файл сохранена для опубликованного поста: published_message_id={published_message_id}, path={file_path}")
logger.info(
f"Ссылка на файл сохранена для опубликованного поста: published_message_id={published_message_id}, path={file_path}"
)
else:
logger.warning(f"Не удалось сохранить ссылку на файл: published_message_id={published_message_id}")
logger.warning(
f"Не удалось сохранить ссылку на файл: published_message_id={published_message_id}"
)
else:
logger.warning(f"Контент не найден для оригинального поста message_id={original_message_id}")
logger.warning(
f"Контент не найден для оригинального поста message_id={original_message_id}"
)
except Exception as e:
logger.error(f"Ошибка при сохранении ссылки на контент опубликованного поста {published_message_id}: {e}")
logger.error(
f"Ошибка при сохранении ссылки на контент опубликованного поста {published_message_id}: {e}"
)
# Не прерываем публикацию, если сохранение контента не удалось
@@ -548,8 +759,8 @@ class BanService:
self.bot = bot
self.db = db
self.settings = settings
self.group_for_posts = settings['Telegram']['group_for_posts']
self.important_logs = settings['Telegram']['important_logs']
self.group_for_posts = settings["Telegram"]["group_for_posts"]
self.important_logs = settings["Telegram"]["important_logs"]
def _get_bot(self, message) -> Bot:
"""Получает бота из контекста сообщения или использует переданного"""
@@ -564,16 +775,22 @@ class BanService:
"""Бан пользователя за спам"""
# Если это helper-сообщение медиагруппы, используем специальный метод
if call.message.text == CONTENT_TYPE_MEDIA_GROUP:
author_id = await self.db.get_author_id_by_helper_message_id(call.message.message_id)
author_id = await self.db.get_author_id_by_helper_message_id(
call.message.message_id
)
else:
author_id = await self.db.get_author_id_by_message_id(call.message.message_id)
author_id = await self.db.get_author_id_by_message_id(
call.message.message_id
)
if not author_id:
raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}")
raise UserNotFoundError(
f"Автор не найден для сообщения {call.message.message_id}"
)
current_date = datetime.now()
date_to_unban = int((current_date + timedelta(days=7)).timestamp())
ban_author_id = call.from_user.id
await self.db.set_user_blacklist(
@@ -583,17 +800,21 @@ class BanService:
date_to_unban=date_to_unban,
ban_author=ban_author_id,
)
await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id)
await self._get_bot(call.message).delete_message(
chat_id=self.group_for_posts, message_id=call.message.message_id
)
date_str = (current_date + timedelta(days=7)).strftime("%d.%m.%Y %H:%M")
try:
await send_text_message(author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str))
await send_text_message(
author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str)
)
except Exception as e:
if str(e) == ERROR_BOT_BLOCKED:
raise UserBlockedBotError("Пользователь заблокировал бота")
raise
logger.info(f"Пользователь {author_id} заблокирован за спам до {date_str}")
@track_time("ban_user", "ban_service")
@@ -603,7 +824,7 @@ class BanService:
user_name = await self.db.get_username(int(user_id))
if not user_name:
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
return user_name
@track_time("unlock_user", "ban_service")
@@ -614,7 +835,7 @@ class BanService:
user_name = await self.db.get_username(int(user_id))
if not user_name:
raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе")
await delete_user_blacklist(int(user_id), self.db)
logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}")
return user_name

View File

@@ -6,27 +6,24 @@ from .constants import ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
from .exceptions import NoReplyToMessageError, UserNotFoundError
from .group_handlers import GroupHandlers, create_group_handlers, group_router
# Local imports - services
from .services import AdminReplyService, DatabaseProtocol
__all__ = [
# Main components
'group_router',
'create_group_handlers',
'GroupHandlers',
"group_router",
"create_group_handlers",
"GroupHandlers",
# Services
'AdminReplyService',
'DatabaseProtocol',
"AdminReplyService",
"DatabaseProtocol",
# Constants
'FSM_STATES',
'ERROR_MESSAGES',
"FSM_STATES",
"ERROR_MESSAGES",
# Exceptions
'NoReplyToMessageError',
'UserNotFoundError',
"NoReplyToMessageError",
"UserNotFoundError",
# Utilities
'error_handler'
"error_handler",
]

View File

@@ -3,12 +3,10 @@
from typing import Dict, Final
# FSM States
FSM_STATES: Final[Dict[str, str]] = {
"CHAT": "CHAT"
}
FSM_STATES: Final[Dict[str, str]] = {"CHAT": "CHAT"}
# Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = {
"NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!",
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение."
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение.",
}

View File

@@ -6,12 +6,14 @@ from typing import Any, Callable
# Third-party imports
from aiogram import types
# Local imports
from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
@@ -19,18 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
logger.error(f"Error in {func.__name__}: {str(e)}")
# Try to send error to logs if possible
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
from helper_bot.utils.base_dependency_factory import \
get_global_instance
message = next(
(arg for arg in args if isinstance(arg, types.Message)), None
)
if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
get_global_instance,
)
bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs']
important_logs = bdf.settings["Telegram"]["important_logs"]
await message.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
except Exception:
# If we can't log the error, at least it was logged to logger
pass
raise
return wrapper

View File

@@ -3,9 +3,11 @@
class NoReplyToMessageError(Exception):
"""Raised when admin tries to reply without selecting a message"""
pass
class UserNotFoundError(Exception):
"""Raised when user is not found in database for the given message_id"""
pass

View File

@@ -3,11 +3,14 @@
# Third-party imports
from aiogram import Router, types
from aiogram.fsm.context import FSMContext
# Local imports - filters
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
# Local imports - metrics
from helper_bot.utils.metrics import metrics, track_errors, track_time
# Local imports - utilities
from logs.custom_logger import logger
@@ -20,25 +23,24 @@ from .services import AdminReplyService
class GroupHandlers:
"""Main handler class for group messages"""
def __init__(self, db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup):
self.db = db
self.keyboard_markup = keyboard_markup
self.admin_reply_service = AdminReplyService(db)
# Create router
self.router = Router()
# Register handlers
self._register_handlers()
def _register_handlers(self):
"""Register all message handlers"""
self.router.message.register(
self.handle_message,
ChatTypeFilter(chat_type=["group", "supergroup"])
self.handle_message, ChatTypeFilter(chat_type=["group", "supergroup"])
)
@error_handler
@track_errors("group_handlers", "handle_message")
@track_time("handle_message", "group_handlers")
@@ -46,44 +48,46 @@ class GroupHandlers:
"""Handle admin reply to user through group chat"""
logger.info(
f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) '
f"Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) "
f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"'
)
# Check if message is a reply
if not message.reply_to_message:
await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"])
logger.warning(
f'В группе {message.chat.title} (ID: {message.chat.id}) '
f'админ не выделил сообщение для ответа.'
f"В группе {message.chat.title} (ID: {message.chat.id}) "
f"админ не выделил сообщение для ответа."
)
return
message_id = message.reply_to_message.message_id
reply_text = message.text
try:
# Get user ID for reply
chat_id = await self.admin_reply_service.get_user_id_for_reply(message_id)
# Send reply to user
await self.admin_reply_service.send_reply_to_user(
chat_id, message, reply_text, self.keyboard_markup
)
# Set state
await state.set_state(FSM_STATES["CHAT"])
except UserNotFoundError:
await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"])
logger.error(
f'Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} '
f'в группе {message.chat.title} (ID сообщения: {message.message_id})'
f"Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} "
f"в группе {message.chat.title} (ID сообщения: {message.message_id})"
)
# Factory function to create handlers with dependencies
def create_group_handlers(db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup) -> GroupHandlers:
def create_group_handlers(
db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMarkup
) -> GroupHandlers:
"""Create group handlers instance with dependencies"""
return GroupHandlers(db, keyboard_markup)
@@ -91,21 +95,23 @@ def create_group_handlers(db: AsyncBotDB, keyboard_markup: types.ReplyKeyboardMa
# Legacy router for backward compatibility
group_router = Router()
# Initialize with global dependencies (for backward compatibility)
def init_legacy_router():
"""Initialize legacy router with global dependencies"""
global group_router
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
#TODO: поменять архитектуру и подключить правильный BotDB
# TODO: поменять архитектуру и подключить правильный BotDB
db = bdf.get_db()
keyboard_markup = get_reply_keyboard_leave_chat()
handlers = create_group_handlers(db, keyboard_markup)
group_router = handlers.router
# Initialize legacy router
init_legacy_router()

View File

@@ -5,8 +5,10 @@ from typing import Optional, Protocol
# Third-party imports
from aiogram import types
# Local imports
from helper_bot.utils.helper_func import send_text_message
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
@@ -16,29 +18,32 @@ from .exceptions import NoReplyToMessageError, UserNotFoundError
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
async def get_user_by_message_id(self, message_id: int) -> Optional[int]: ...
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None): ...
async def add_message(
self, message_text: str, user_id: int, message_id: int, date: int = None
): ...
class AdminReplyService:
"""Service for admin reply operations"""
def __init__(self, db: DatabaseProtocol) -> None:
self.db = db
@track_time("get_user_id_for_reply", "admin_reply_service")
@track_errors("admin_reply_service", "get_user_id_for_reply")
@db_query_time("get_user_id_for_reply", "users", "select")
async def get_user_id_for_reply(self, message_id: int) -> int:
"""
Get user ID for reply by message ID.
Args:
message_id: ID of the message to reply to
Returns:
User ID for the reply
Raises:
UserNotFoundError: If user is not found in database
"""
@@ -46,19 +51,19 @@ class AdminReplyService:
if user_id is None:
raise UserNotFoundError(f"User not found for message_id: {message_id}")
return user_id
@track_time("send_reply_to_user", "admin_reply_service")
@track_errors("admin_reply_service", "send_reply_to_user")
async def send_reply_to_user(
self,
chat_id: int,
message: types.Message,
reply_text: str,
markup: types.ReplyKeyboardMarkup
self,
chat_id: int,
message: types.Message,
reply_text: str,
markup: types.ReplyKeyboardMarkup,
) -> None:
"""
Send reply to user.
Args:
chat_id: User's chat ID
message: Original message from admin

View File

@@ -4,28 +4,25 @@
# Local imports - constants and utilities
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler
from .private_handlers import (PrivateHandlers, create_private_handlers,
private_router)
from .private_handlers import PrivateHandlers, create_private_handlers, private_router
# Local imports - services
from .services import BotSettings, PostService, StickerService, UserService
__all__ = [
# Main components
'private_router',
'create_private_handlers',
'PrivateHandlers',
"private_router",
"create_private_handlers",
"PrivateHandlers",
# Services
'BotSettings',
'UserService',
'PostService',
'StickerService',
"BotSettings",
"UserService",
"PostService",
"StickerService",
# Constants
'FSM_STATES',
'BUTTON_TEXTS',
'ERROR_MESSAGES',
"FSM_STATES",
"BUTTON_TEXTS",
"ERROR_MESSAGES",
# Utilities
'error_handler'
"error_handler",
]

View File

@@ -7,7 +7,7 @@ FSM_STATES: Final[Dict[str, str]] = {
"START": "START",
"SUGGEST": "SUGGEST",
"PRE_CHAT": "PRE_CHAT",
"CHAT": "CHAT"
"CHAT": "CHAT",
}
# Button texts
@@ -18,7 +18,7 @@ BUTTON_TEXTS: Final[Dict[str, str]] = {
"RETURN_TO_BOT": "Вернуться в бота",
"WANT_STICKERS": "🤪Хочу стикеры",
"CONNECT_ADMIN": "📩Связаться с админами",
"VOICE_BOT": "🎤Голосовой бот"
"VOICE_BOT": "🎤Голосовой бот",
}
# Button to command mapping for metrics
@@ -29,15 +29,15 @@ BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"Вернуться в бота": "return_to_bot",
"🤪Хочу стикеры": "want_stickers",
"📩Связаться с админами": "connect_admin",
"🎤Голосовой бот": "voice_bot"
"🎤Голосовой бот": "voice_bot",
}
# Error messages
ERROR_MESSAGES: Final[Dict[str, str]] = {
"UNSUPPORTED_CONTENT": (
'Я пока не умею работать с таким сообщением. '
'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n'
'Мы добавим его к обработке если необходимо'
"Я пока не умею работать с таким сообщением. "
"Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n"
"Мы добавим его к обработке если необходимо"
),
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk"
"STICKERS_LINK": "Хорошо, лови, добавить можно отсюда: https://t.me/addstickers/love_biysk",
}

View File

@@ -6,12 +6,14 @@ from typing import Any, Callable
# Third-party imports
from aiogram import types
# Local imports
from logs.custom_logger import logger
def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling"""
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
@@ -19,18 +21,23 @@ def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
logger.error(f"Error in {func.__name__}: {str(e)}")
# Try to send error to logs if possible
try:
message = next((arg for arg in args if isinstance(arg, types.Message)), None)
if message and hasattr(message, 'bot'):
from helper_bot.utils.base_dependency_factory import \
get_global_instance
message = next(
(arg for arg in args if isinstance(arg, types.Message)), None
)
if message and hasattr(message, "bot"):
from helper_bot.utils.base_dependency_factory import (
get_global_instance,
)
bdf = get_global_instance()
important_logs = bdf.settings['Telegram']['important_logs']
important_logs = bdf.settings["Telegram"]["important_logs"]
await message.bot.send_message(
chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}",
)
except Exception:
# If we can't log the error, at least it was logged to logger
pass
raise
return wrapper

View File

@@ -8,18 +8,23 @@ from datetime import datetime
from aiogram import F, Router, types
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
# Local imports - filters and middlewares
from database.async_db import AsyncBotDB
from helper_bot.filters.main import ChatTypeFilter
# Local imports - utilities
from helper_bot.keyboards import (get_reply_keyboard,
get_reply_keyboard_for_post)
from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post
from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat
from helper_bot.middlewares.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.utils import messages
from helper_bot.utils.helper_func import (check_user_emoji, get_first_name,
update_user_info)
from helper_bot.utils.helper_func import (
check_user_emoji,
get_first_name,
update_user_info,
)
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
@@ -34,83 +39,138 @@ sleep = asyncio.sleep
class PrivateHandlers:
"""Main handler class for private messages"""
def __init__(self, db: AsyncBotDB, settings: BotSettings, s3_storage=None):
self.db = db
self.settings = settings
self.user_service = UserService(db, settings)
self.post_service = PostService(db, settings, s3_storage)
self.sticker_service = StickerService(settings)
self.router = Router()
self.router.message.middleware(AlbumMiddleware(latency=5.0))
self.router.message.middleware(BlacklistMiddleware())
# Register handlers
self._register_handlers()
def _register_handlers(self):
"""Register all message handlers"""
# Command handlers
self.router.message.register(self.handle_emoji_message, ChatTypeFilter(chat_type=["private"]), Command("emoji"))
self.router.message.register(self.handle_restart_message, ChatTypeFilter(chat_type=["private"]), Command("restart"))
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), Command("start"))
self.router.message.register(self.handle_start_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["RETURN_TO_BOT"])
# Button handlers
self.router.message.register(self.suggest_post, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SUGGEST_POST"])
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["SAY_GOODBYE"])
self.router.message.register(self.end_message, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["LEAVE_CHAT"])
self.router.message.register(self.stickers, ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["WANT_STICKERS"])
self.router.message.register(self.connect_with_admin, StateFilter(FSM_STATES["START"]), ChatTypeFilter(chat_type=["private"]), F.text == BUTTON_TEXTS["CONNECT_ADMIN"])
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
Command("emoji"),
)
self.router.message.register(
self.handle_restart_message,
ChatTypeFilter(chat_type=["private"]),
Command("restart"),
)
self.router.message.register(
self.handle_start_message,
ChatTypeFilter(chat_type=["private"]),
Command("start"),
)
self.router.message.register(
self.handle_start_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["RETURN_TO_BOT"],
)
# Button handlers
self.router.message.register(
self.suggest_post,
StateFilter(FSM_STATES["START"]),
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["SUGGEST_POST"],
)
self.router.message.register(
self.end_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["SAY_GOODBYE"],
)
self.router.message.register(
self.end_message,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["LEAVE_CHAT"],
)
self.router.message.register(
self.stickers,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["WANT_STICKERS"],
)
self.router.message.register(
self.connect_with_admin,
StateFilter(FSM_STATES["START"]),
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["CONNECT_ADMIN"],
)
# State handlers
self.router.message.register(self.suggest_router, StateFilter(FSM_STATES["SUGGEST"]), ChatTypeFilter(chat_type=["private"]))
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["PRE_CHAT"]), ChatTypeFilter(chat_type=["private"]))
self.router.message.register(self.resend_message_in_group_for_message, StateFilter(FSM_STATES["CHAT"]), ChatTypeFilter(chat_type=["private"]))
self.router.message.register(
self.suggest_router,
StateFilter(FSM_STATES["SUGGEST"]),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.resend_message_in_group_for_message,
StateFilter(FSM_STATES["PRE_CHAT"]),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.resend_message_in_group_for_message,
StateFilter(FSM_STATES["CHAT"]),
ChatTypeFilter(chat_type=["private"]),
)
@error_handler
@track_errors("private_handlers", "handle_emoji_message")
@track_time("handle_emoji_message", "private_handlers")
async def handle_emoji_message(self, message: types.Message, state: FSMContext, **kwargs):
async def handle_emoji_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle emoji command"""
await self.user_service.log_user_message(message)
user_emoji = await check_user_emoji(message)
await state.set_state(FSM_STATES["START"])
if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
await message.answer(f"Твоя эмодзя - {user_emoji}", parse_mode="HTML")
@error_handler
@track_errors("private_handlers", "handle_restart_message")
@track_time("handle_restart_message", "private_handlers")
async def handle_restart_message(self, message: types.Message, state: FSMContext, **kwargs):
async def handle_restart_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle restart command"""
markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["START"])
await update_user_info('love', message)
await update_user_info("love", message)
await check_user_emoji(message)
await message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML')
await message.answer("Я перезапущен!", reply_markup=markup, parse_mode="HTML")
@error_handler
@track_errors("private_handlers", "handle_start_message")
@track_time("handle_start_message", "private_handlers")
async def handle_start_message(self, message: types.Message, state: FSMContext, **kwargs):
async def handle_start_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle start command and return to bot button with metrics tracking"""
# User service operations with metrics
await self.user_service.log_user_message(message)
await self.user_service.ensure_user_exists(message)
await state.set_state(FSM_STATES["START"])
# Send sticker with metrics
await self.sticker_service.send_random_hello_sticker(message)
# Send welcome message with metrics
markup = await get_reply_keyboard(self.db, message.from_user.id)
hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE')
await message.answer(hello_message, reply_markup=markup, parse_mode='HTML')
hello_message = messages.get_message(get_first_name(message), "HELLO_MESSAGE")
await message.answer(hello_message, reply_markup=markup, parse_mode="HTML")
@error_handler
@track_errors("private_handlers", "suggest_post")
@track_time("suggest_post", "private_handlers")
@@ -120,11 +180,11 @@ class PrivateHandlers:
await self.user_service.update_user_activity(message.from_user.id)
await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["SUGGEST"])
markup = types.ReplyKeyboardRemove()
suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS')
suggest_news = messages.get_message(get_first_name(message), "SUGGEST_NEWS")
await message.answer(suggest_news, reply_markup=markup)
@error_handler
@track_errors("private_handlers", "end_message")
@track_time("end_message", "private_handlers")
@@ -133,31 +193,35 @@ class PrivateHandlers:
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
await self.user_service.log_user_message(message)
# Send sticker
await self.sticker_service.send_random_goodbye_sticker(message)
# Send goodbye message
markup = types.ReplyKeyboardRemove()
bye_message = messages.get_message(get_first_name(message), 'BYE_MESSAGE')
bye_message = messages.get_message(get_first_name(message), "BYE_MESSAGE")
await message.answer(bye_message, reply_markup=markup)
await state.set_state(FSM_STATES["START"])
@error_handler
@track_errors("private_handlers", "suggest_router")
@track_time("suggest_router", "private_handlers")
async def suggest_router(self, message: types.Message, state: FSMContext, album: list = None, **kwargs):
async def suggest_router(
self, message: types.Message, state: FSMContext, album: list = None, **kwargs
):
"""Handle post submission in suggest state"""
# Проверяем, есть ли механизм для получения полной медиагруппы (для медиагрупп)
album_getter = kwargs.get("album_getter")
if album_getter and message.media_group_id:
# Это медиагруппа - сразу отвечаем пользователю, обработку делаем в фоне
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
success_send_message = messages.get_message(
get_first_name(message), "SUCCESS_SEND_MESSAGE"
)
await message.answer(success_send_message, reply_markup=markup_for_user)
await state.set_state(FSM_STATES["START"])
# В фоне ждем полную медиагруппу и обрабатываем пост
async def process_media_group_background():
try:
@@ -165,14 +229,15 @@ class PrivateHandlers:
full_album = await album_getter.get_album(timeout=10.0)
if not full_album:
return
# Обрабатываем пост с полной медиагруппой
await self.user_service.update_user_activity(message.from_user.id)
await self.post_service.process_post(message, full_album)
except Exception as e:
from logs.custom_logger import logger
logger.error(f"Ошибка при фоновой обработке медиагруппы: {e}")
asyncio.create_task(process_media_group_background())
else:
# Обычное сообщение или медиагруппа уже собрана - обрабатываем синхронно
@@ -181,10 +246,12 @@ class PrivateHandlers:
await self.user_service.log_user_message(message)
await self.post_service.process_post(message, album)
markup_for_user = await get_reply_keyboard(self.db, message.from_user.id)
success_send_message = messages.get_message(get_first_name(message), 'SUCCESS_SEND_MESSAGE')
success_send_message = messages.get_message(
get_first_name(message), "SUCCESS_SEND_MESSAGE"
)
await message.answer(success_send_message, reply_markup=markup_for_user)
await state.set_state(FSM_STATES["START"])
@error_handler
@track_errors("private_handlers", "stickers")
@track_time("stickers", "private_handlers")
@@ -195,41 +262,46 @@ class PrivateHandlers:
markup = await get_reply_keyboard(self.db, message.from_user.id)
await self.db.update_stickers_info(message.from_user.id)
await self.user_service.log_user_message(message)
await message.answer(
text=ERROR_MESSAGES["STICKERS_LINK"],
reply_markup=markup
)
await message.answer(text=ERROR_MESSAGES["STICKERS_LINK"], reply_markup=markup)
await state.set_state(FSM_STATES["START"])
@error_handler
@track_errors("private_handlers", "connect_with_admin")
@track_time("connect_with_admin", "private_handlers")
async def connect_with_admin(self, message: types.Message, state: FSMContext, **kwargs):
async def connect_with_admin(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle connect with admin button"""
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
admin_message = messages.get_message(get_first_name(message), 'CONNECT_WITH_ADMIN')
admin_message = messages.get_message(
get_first_name(message), "CONNECT_WITH_ADMIN"
)
await message.answer(admin_message, parse_mode="html")
await self.user_service.log_user_message(message)
await state.set_state(FSM_STATES["PRE_CHAT"])
@error_handler
@track_errors("private_handlers", "resend_message_in_group_for_message")
@track_time("resend_message_in_group_for_message", "private_handlers")
@db_query_time("resend_message_in_group_for_message", "messages", "insert")
async def resend_message_in_group_for_message(self, message: types.Message, state: FSMContext, **kwargs):
async def resend_message_in_group_for_message(
self, message: types.Message, state: FSMContext, **kwargs
):
"""Handle messages in admin chat states"""
# User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id)
await message.forward(chat_id=self.settings.group_for_message)
current_date = datetime.now()
date = int(current_date.timestamp())
await self.db.add_message(message.text, message.from_user.id, message.message_id + 1, date)
question = messages.get_message(get_first_name(message), 'QUESTION')
await self.db.add_message(
message.text, message.from_user.id, message.message_id + 1, date
)
question = messages.get_message(get_first_name(message), "QUESTION")
user_state = await state.get_state()
if user_state == FSM_STATES["PRE_CHAT"]:
markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(question, reply_markup=markup)
@@ -240,7 +312,9 @@ class PrivateHandlers:
# Factory function to create handlers with dependencies
def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=None) -> PrivateHandlers:
def create_private_handlers(
db: AsyncBotDB, settings: BotSettings, s3_storage=None
) -> PrivateHandlers:
"""Create private handlers instance with dependencies"""
return PrivateHandlers(db, settings, s3_storage)
@@ -248,32 +322,34 @@ def create_private_handlers(db: AsyncBotDB, settings: BotSettings, s3_storage=No
# Legacy router for backward compatibility
private_router = Router()
# Initialize with global dependencies (for backward compatibility)
def init_legacy_router():
"""Initialize legacy router with global dependencies"""
global private_router
from helper_bot.utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
settings = BotSettings(
group_for_posts=bdf.settings['Telegram']['group_for_posts'],
group_for_message=bdf.settings['Telegram']['group_for_message'],
main_public=bdf.settings['Telegram']['main_public'],
group_for_logs=bdf.settings['Telegram']['group_for_logs'],
important_logs=bdf.settings['Telegram']['important_logs'],
preview_link=bdf.settings['Telegram']['preview_link'],
logs=bdf.settings['Settings']['logs'],
test=bdf.settings['Settings']['test']
group_for_posts=bdf.settings["Telegram"]["group_for_posts"],
group_for_message=bdf.settings["Telegram"]["group_for_message"],
main_public=bdf.settings["Telegram"]["main_public"],
group_for_logs=bdf.settings["Telegram"]["group_for_logs"],
important_logs=bdf.settings["Telegram"]["important_logs"],
preview_link=bdf.settings["Telegram"]["preview_link"],
logs=bdf.settings["Settings"]["logs"],
test=bdf.settings["Settings"]["test"],
)
db = bdf.get_db()
s3_storage = bdf.get_s3_storage()
handlers = create_private_handlers(db, settings, s3_storage)
# Instead of trying to copy handlers, we'll use the new router directly
# This maintains backward compatibility while using the new architecture
private_router = handlers.router
# Initialize legacy router
init_legacy_router()

View File

@@ -12,37 +12,61 @@ from typing import Any, Callable, Dict, Protocol, Union
# Third-party imports
from aiogram import types
from aiogram.types import FSInputFile
from database.models import TelegramPost, User
from helper_bot.keyboards import get_reply_keyboard_for_post
# Local imports - utilities
from helper_bot.utils.helper_func import (
add_in_db_media, check_username_and_full_name, determine_anonymity,
get_first_name, get_text_message, prepare_media_group_from_middlewares,
send_audio_message, send_media_group_message_to_private_chat,
send_photo_message, send_text_message, send_video_message,
send_video_note_message, send_voice_message)
add_in_db_media,
check_username_and_full_name,
determine_anonymity,
get_first_name,
get_text_message,
prepare_media_group_from_middlewares,
send_audio_message,
send_media_group_message_to_private_chat,
send_photo_message,
send_text_message,
send_video_message,
send_video_note_message,
send_voice_message,
)
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
track_file_operations,
track_media_processing, track_time)
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_file_operations,
track_media_processing,
track_time,
)
from logs.custom_logger import logger
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
async def user_exists(self, user_id: int) -> bool: ...
async def add_user(self, user: User) -> None: ...
async def update_user_info(self, user_id: int, username: str = None, full_name: str = None) -> None: ...
async def update_user_info(
self, user_id: int, username: str = None, full_name: str = None
) -> None: ...
async def update_user_date(self, user_id: int) -> None: ...
async def add_post(self, post: TelegramPost) -> None: ...
async def update_stickers_info(self, user_id: int) -> None: ...
async def add_message(self, message_text: str, user_id: int, message_id: int, date: int = None) -> None: ...
async def update_helper_message(self, message_id: int, helper_message_id: int) -> None: ...
async def add_message(
self, message_text: str, user_id: int, message_id: int, date: int = None
) -> None: ...
async def update_helper_message(
self, message_id: int, helper_message_id: int
) -> None: ...
@dataclass
class BotSettings:
"""Bot configuration settings"""
group_for_posts: str
group_for_message: str
main_public: str
@@ -55,18 +79,18 @@ class BotSettings:
class UserService:
"""Service for user-related operations"""
def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None:
self.db = db
self.settings = settings
@track_time("update_user_activity", "user_service")
@track_errors("user_service", "update_user_activity")
@db_query_time("update_user_activity", "users", "update")
async def update_user_activity(self, user_id: int) -> None:
"""Update user's last activity timestamp with metrics tracking"""
await self.db.update_user_date(user_id)
@track_time("ensure_user_exists", "user_service")
@track_errors("user_service", "ensure_user_exists")
@db_query_time("ensure_user_exists", "users", "insert")
@@ -78,7 +102,7 @@ class UserService:
first_name = get_first_name(message)
is_bot = message.from_user.is_bot
language_code = message.from_user.language_code
# Create User object with current timestamp
current_timestamp = int(datetime.now().timestamp())
user = User(
@@ -92,32 +116,38 @@ class UserService:
has_stickers=False,
date_added=current_timestamp,
date_changed=current_timestamp,
voice_bot_welcome_received=False
voice_bot_welcome_received=False,
)
# Пытаемся создать пользователя (если уже существует - игнорируем)
# Это устраняет race condition и упрощает логику
await self.db.add_user(user)
# Проверяем, нужно ли обновить информацию о существующем пользователе
is_need_update = await check_username_and_full_name(user_id, username, full_name, self.db)
is_need_update = await check_username_and_full_name(
user_id, username, full_name, self.db
)
if is_need_update:
await self.db.update_user_info(user_id, username, full_name)
safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь"
safe_full_name = (
html.escape(full_name) if full_name else "Неизвестный пользователь"
)
safe_username = html.escape(username) if username else "Без никнейма"
await message.answer(
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {safe_full_name} и ник @{safe_username}")
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {safe_full_name} и ник @{safe_username}"
)
await message.bot.send_message(
chat_id=self.settings.group_for_logs,
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}')
text=f"Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}",
)
await self.db.update_user_date(user_id)
async def log_user_message(self, message: types.Message) -> None:
"""Forward user message to logs group with metrics tracking"""
await message.forward(chat_id=self.settings.group_for_logs)
def get_safe_user_info(self, message: types.Message) -> tuple[str, str]:
"""Get safely escaped user information for logging"""
full_name = message.from_user.full_name or "Неизвестный пользователь"
@@ -127,44 +157,56 @@ class UserService:
class PostService:
"""Service for post-related operations"""
def __init__(self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None) -> None:
def __init__(
self, db: DatabaseProtocol, settings: BotSettings, s3_storage=None
) -> None:
self.db = db
self.settings = settings
self.s3_storage = s3_storage
async def _save_media_background(self, sent_message: types.Message, bot_db: Any, s3_storage) -> None:
async def _save_media_background(
self, sent_message: types.Message, bot_db: Any, s3_storage
) -> None:
"""Сохраняет медиа в фоне, чтобы не блокировать ответ пользователю"""
try:
success = await add_in_db_media(sent_message, bot_db, s3_storage)
if not success:
logger.warning(f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}")
logger.warning(
f"_save_media_background: Не удалось сохранить медиа для поста {sent_message.message_id}"
)
except Exception as e:
logger.error(f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}")
logger.error(
f"_save_media_background: Ошибка при сохранении медиа для поста {sent_message.message_id}: {e}"
)
@track_time("handle_text_post", "post_service")
@track_errors("post_service", "handle_text_post")
@db_query_time("handle_text_post", "posts", "insert")
async def handle_text_post(self, message: types.Message, first_name: str) -> None:
"""Handle text post submission"""
post_text = get_text_message(message.text.lower(), first_name, message.from_user.username)
post_text = get_text_message(
message.text.lower(), first_name, message.from_user.username
)
markup = get_reply_keyboard_for_post()
sent_message = await send_text_message(self.settings.group_for_posts, message, post_text, markup)
sent_message = await send_text_message(
self.settings.group_for_posts, message, post_text, markup
)
# Сохраняем сырой текст и определяем анонимность
raw_text = message.text or ""
is_anonymous = determine_anonymity(raw_text)
post = TelegramPost(
message_id=sent_message.message_id,
text=raw_text,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
@track_time("handle_photo_post", "post_service")
@track_errors("post_service", "handle_photo_post")
@db_query_time("handle_photo_post", "posts", "insert")
@@ -172,28 +214,36 @@ class PostService:
"""Handle photo post submission"""
post_caption = ""
if message.caption:
post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
post_caption = get_text_message(
message.caption.lower(), first_name, message.from_user.username
)
markup = get_reply_keyboard_for_post()
sent_message = await send_photo_message(
self.settings.group_for_posts, message, message.photo[-1].file_id, post_caption, markup
self.settings.group_for_posts,
message,
message.photo[-1].file_id,
post_caption,
markup,
)
# Сохраняем сырой caption и определяем анонимность
raw_caption = message.caption or ""
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
message_id=sent_message.message_id,
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
@track_time("handle_video_post", "post_service")
@track_errors("post_service", "handle_video_post")
@db_query_time("handle_video_post", "posts", "insert")
@@ -201,28 +251,36 @@ class PostService:
"""Handle video post submission"""
post_caption = ""
if message.caption:
post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
post_caption = get_text_message(
message.caption.lower(), first_name, message.from_user.username
)
markup = get_reply_keyboard_for_post()
sent_message = await send_video_message(
self.settings.group_for_posts, message, message.video.file_id, post_caption, markup
self.settings.group_for_posts,
message,
message.video.file_id,
post_caption,
markup,
)
# Сохраняем сырой caption и определяем анонимность
raw_caption = message.caption or ""
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
message_id=sent_message.message_id,
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
@track_time("handle_video_note_post", "post_service")
@track_errors("post_service", "handle_video_note_post")
@db_query_time("handle_video_note_post", "posts", "insert")
@@ -232,22 +290,24 @@ class PostService:
sent_message = await send_video_note_message(
self.settings.group_for_posts, message, message.video_note.file_id, markup
)
# Сохраняем пустую строку, так как video_note не имеет caption
raw_caption = ""
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
message_id=sent_message.message_id,
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
@track_time("handle_audio_post", "post_service")
@track_errors("post_service", "handle_audio_post")
@db_query_time("handle_audio_post", "posts", "insert")
@@ -255,28 +315,36 @@ class PostService:
"""Handle audio post submission"""
post_caption = ""
if message.caption:
post_caption = get_text_message(message.caption.lower(), first_name, message.from_user.username)
post_caption = get_text_message(
message.caption.lower(), first_name, message.from_user.username
)
markup = get_reply_keyboard_for_post()
sent_message = await send_audio_message(
self.settings.group_for_posts, message, message.audio.file_id, post_caption, markup
self.settings.group_for_posts,
message,
message.audio.file_id,
post_caption,
markup,
)
# Сохраняем сырой caption и определяем анонимность
raw_caption = message.caption or ""
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
message_id=sent_message.message_id,
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
@track_time("handle_voice_post", "post_service")
@track_errors("post_service", "handle_voice_post")
@db_query_time("handle_voice_post", "posts", "insert")
@@ -286,106 +354,116 @@ class PostService:
sent_message = await send_voice_message(
self.settings.group_for_posts, message, message.voice.file_id, markup
)
# Сохраняем пустую строку, так как voice не имеет caption
raw_caption = ""
is_anonymous = determine_anonymity(raw_caption)
post = TelegramPost(
message_id=sent_message.message_id,
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(post)
# Сохраняем медиа в фоне, чтобы не блокировать ответ пользователю
asyncio.create_task(self._save_media_background(sent_message, self.db, self.s3_storage))
asyncio.create_task(
self._save_media_background(sent_message, self.db, self.s3_storage)
)
@track_time("handle_media_group_post", "post_service")
@track_errors("post_service", "handle_media_group_post")
@track_errors("post_service", "handle_media_group_post")
@db_query_time("handle_media_group_post", "posts", "insert")
@track_media_processing("media_group")
async def handle_media_group_post(self, message: types.Message, album: list, first_name: str) -> None:
async def handle_media_group_post(
self, message: types.Message, album: list, first_name: str
) -> None:
"""Handle media group post submission"""
post_caption = " "
raw_caption = ""
if album and album[0].caption:
raw_caption = album[0].caption or ""
post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username)
post_caption = get_text_message(
album[0].caption.lower(), first_name, message.from_user.username
)
is_anonymous = determine_anonymity(raw_caption)
media_group = await prepare_media_group_from_middlewares(album, post_caption)
media_group_message_ids = await send_media_group_message_to_private_chat(
self.settings.group_for_posts, message, media_group, self.db, None, self.s3_storage
self.settings.group_for_posts,
message,
media_group,
self.db,
None,
self.s3_storage,
)
main_post_id = media_group_message_ids[-1]
main_post = TelegramPost(
message_id=main_post_id,
text=raw_caption,
author_id=message.from_user.id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous
is_anonymous=is_anonymous,
)
await self.db.add_post(main_post)
for msg_id in media_group_message_ids:
await self.db.add_message_link(main_post_id, msg_id)
await asyncio.sleep(0.2)
markup = get_reply_keyboard_for_post()
helper_message = await send_text_message(
self.settings.group_for_posts,
message,
"^",
markup
self.settings.group_for_posts, message, "^", markup
)
helper_message_id = helper_message.message_id
helper_post = TelegramPost(
message_id=helper_message_id,
text="^",
author_id=message.from_user.id,
helper_text_message_id=main_post_id,
created_at=int(datetime.now().timestamp())
created_at=int(datetime.now().timestamp()),
)
await self.db.add_post(helper_post)
await self.db.update_helper_message(
message_id=main_post_id,
helper_message_id=helper_message_id
message_id=main_post_id, helper_message_id=helper_message_id
)
@track_time("process_post", "post_service")
@track_errors("post_service", "process_post")
@track_media_processing("media_group")
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
async def process_post(
self, message: types.Message, album: Union[list, None] = None
) -> None:
"""Process post based on content type"""
first_name = get_first_name(message)
if message.media_group_id is not None:
await self.handle_media_group_post(message, album, first_name)
return
content_handlers: Dict[str, Callable] = {
'text': lambda: self.handle_text_post(message, first_name),
'photo': lambda: self.handle_photo_post(message, first_name),
'video': lambda: self.handle_video_post(message, first_name),
'video_note': lambda: self.handle_video_note_post(message),
'audio': lambda: self.handle_audio_post(message, first_name),
'voice': lambda: self.handle_voice_post(message)
"text": lambda: self.handle_text_post(message, first_name),
"photo": lambda: self.handle_photo_post(message, first_name),
"video": lambda: self.handle_video_post(message, first_name),
"video_note": lambda: self.handle_video_note_post(message),
"audio": lambda: self.handle_audio_post(message, first_name),
"voice": lambda: self.handle_voice_post(message),
}
handler = content_handlers.get(message.content_type)
if handler:
await handler()
else:
from .constants import ERROR_MESSAGES
await message.bot.send_message(
message.chat.id, ERROR_MESSAGES["UNSUPPORTED_CONTENT"]
)
@@ -393,29 +471,29 @@ class PostService:
class StickerService:
"""Service for sticker-related operations"""
def __init__(self, settings: BotSettings) -> None:
self.settings = settings
@track_time("send_random_hello_sticker", "sticker_service")
@track_errors("sticker_service", "send_random_hello_sticker")
@track_file_operations("sticker")
async def send_random_hello_sticker(self, message: types.Message) -> None:
"""Send random hello sticker with metrics tracking"""
name_stick_hello = list(Path('Stick').rglob('Hello_*'))
name_stick_hello = list(Path("Stick").rglob("Hello_*"))
if not name_stick_hello:
return
random_stick_hello = random.choice(name_stick_hello)
random_stick_hello = FSInputFile(path=random_stick_hello)
await message.answer_sticker(random_stick_hello)
await asyncio.sleep(0.3)
@track_time("send_random_goodbye_sticker", "sticker_service")
@track_errors("sticker_service", "send_random_goodbye_sticker")
@track_file_operations("sticker")
async def send_random_goodbye_sticker(self, message: types.Message) -> None:
"""Send random goodbye sticker with metrics tracking"""
name_stick_bye = list(Path('Stick').rglob('Universal_*'))
name_stick_bye = list(Path("Stick").rglob("Universal_*"))
if not name_stick_bye:
return
random_stick_bye = random.choice(name_stick_bye)

View File

@@ -1,6 +1,7 @@
"""
Утилиты для очистки и диагностики проблем с голосовыми файлами
"""
import asyncio
import os
from pathlib import Path
@@ -12,108 +13,122 @@ from logs.custom_logger import logger
class VoiceFileCleanupUtils:
"""Утилиты для очистки и диагностики голосовых файлов"""
def __init__(self, bot_db):
self.bot_db = bot_db
async def find_orphaned_db_records(self) -> List[Tuple[str, int]]:
"""Найти записи в БД, для которых нет соответствующих файлов"""
try:
# Получаем все записи из БД
all_audio_records = await self.bot_db.get_all_audio_records()
orphaned_records = []
for record in all_audio_records:
file_name = record.get('file_name', '')
user_id = record.get('author_id', 0)
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
file_name = record.get("file_name", "")
user_id = record.get("author_id", 0)
file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
if not os.path.exists(file_path):
orphaned_records.append((file_name, user_id))
logger.warning(f"Найдена запись в БД без файла: {file_name} (user_id: {user_id})")
logger.info(f"Найдено {len(orphaned_records)} записей в БД без соответствующих файлов")
logger.warning(
f"Найдена запись в БД без файла: {file_name} (user_id: {user_id})"
)
logger.info(
f"Найдено {len(orphaned_records)} записей в БД без соответствующих файлов"
)
return orphaned_records
except Exception as e:
logger.error(f"Ошибка при поиске orphaned записей: {e}")
return []
async def find_orphaned_files(self) -> List[str]:
"""Найти файлы на диске, для которых нет записей в БД"""
try:
if not os.path.exists(VOICE_USERS_DIR):
logger.warning(f"Директория {VOICE_USERS_DIR} не существует")
return []
# Получаем все файлы .ogg в директории
ogg_files = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
orphaned_files = []
# Получаем все записи из БД
all_audio_records = await self.bot_db.get_all_audio_records()
db_file_names = {record.get('file_name', '') for record in all_audio_records}
db_file_names = {
record.get("file_name", "") for record in all_audio_records
}
for file_path in ogg_files:
file_name = file_path.stem # Имя файла без расширения
if file_name not in db_file_names:
orphaned_files.append(str(file_path))
logger.warning(f"Найден файл без записи в БД: {file_path}")
logger.info(f"Найдено {len(orphaned_files)} файлов без записей в БД")
return orphaned_files
except Exception as e:
logger.error(f"Ошибка при поиске orphaned файлов: {e}")
return []
async def cleanup_orphaned_db_records(self, dry_run: bool = True) -> int:
"""Удалить записи в БД, для которых нет файлов"""
try:
orphaned_records = await self.find_orphaned_db_records()
if not orphaned_records:
logger.info("Нет orphaned записей для удаления")
return 0
if dry_run:
logger.info(f"DRY RUN: Найдено {len(orphaned_records)} записей для удаления")
logger.info(
f"DRY RUN: Найдено {len(orphaned_records)} записей для удаления"
)
for file_name, user_id in orphaned_records:
logger.info(f"DRY RUN: Будет удалена запись: {file_name} (user_id: {user_id})")
logger.info(
f"DRY RUN: Будет удалена запись: {file_name} (user_id: {user_id})"
)
return len(orphaned_records)
# Удаляем записи
deleted_count = 0
for file_name, user_id in orphaned_records:
try:
await self.bot_db.delete_audio_record_by_file_name(file_name)
deleted_count += 1
logger.info(f"Удалена запись в БД: {file_name} (user_id: {user_id})")
logger.info(
f"Удалена запись в БД: {file_name} (user_id: {user_id})"
)
except Exception as e:
logger.error(f"Ошибка при удалении записи {file_name}: {e}")
logger.info(f"Удалено {deleted_count} orphaned записей из БД")
return deleted_count
except Exception as e:
logger.error(f"Ошибка при очистке orphaned записей: {e}")
return 0
async def cleanup_orphaned_files(self, dry_run: bool = True) -> int:
"""Удалить файлы на диске, для которых нет записей в БД"""
try:
orphaned_files = await self.find_orphaned_files()
if not orphaned_files:
logger.info("Нет orphaned файлов для удаления")
return 0
if dry_run:
logger.info(f"DRY RUN: Найдено {len(orphaned_files)} файлов для удаления")
logger.info(
f"DRY RUN: Найдено {len(orphaned_files)} файлов для удаления"
)
for file_path in orphaned_files:
logger.info(f"DRY RUN: Будет удален файл: {file_path}")
return len(orphaned_files)
# Удаляем файлы
deleted_count = 0
for file_path in orphaned_files:
@@ -123,70 +138,76 @@ class VoiceFileCleanupUtils:
logger.info(f"Удален файл: {file_path}")
except Exception as e:
logger.error(f"Ошибка при удалении файла {file_path}: {e}")
logger.info(f"Удалено {deleted_count} orphaned файлов")
return deleted_count
except Exception as e:
logger.error(f"Ошибка при очистке orphaned файлов: {e}")
return 0
async def get_disk_usage_stats(self) -> dict:
"""Получить статистику использования диска"""
try:
if not os.path.exists(VOICE_USERS_DIR):
return {"error": f"Директория {VOICE_USERS_DIR} не существует"}
total_size = 0
file_count = 0
for file_path in Path(VOICE_USERS_DIR).glob("*.ogg"):
if file_path.is_file():
total_size += file_path.stat().st_size
file_count += 1
return {
"total_files": file_count,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"directory": VOICE_USERS_DIR
"directory": VOICE_USERS_DIR,
}
except Exception as e:
logger.error(f"Ошибка при получении статистики диска: {e}")
return {"error": str(e)}
async def run_full_diagnostic(self) -> dict:
"""Запустить полную диагностику"""
try:
logger.info("Запуск полной диагностики голосовых файлов...")
# Статистика диска
disk_stats = await self.get_disk_usage_stats()
# Orphaned записи в БД
orphaned_db_records = await self.find_orphaned_db_records()
# Orphaned файлы
orphaned_files = await self.find_orphaned_files()
# Количество записей в БД
all_audio_records = await self.bot_db.get_all_audio_records()
db_records_count = len(all_audio_records)
diagnostic_result = {
"disk_stats": disk_stats,
"db_records_count": db_records_count,
"orphaned_db_records_count": len(orphaned_db_records),
"orphaned_files_count": len(orphaned_files),
"orphaned_db_records": orphaned_db_records[:10], # Первые 10 для примера
"orphaned_db_records": orphaned_db_records[
:10
], # Первые 10 для примера
"orphaned_files": orphaned_files[:10], # Первые 10 для примера
"status": "healthy" if len(orphaned_db_records) == 0 and len(orphaned_files) == 0 else "issues_found"
"status": (
"healthy"
if len(orphaned_db_records) == 0 and len(orphaned_files) == 0
else "issues_found"
),
}
logger.info(f"Диагностика завершена. Статус: {diagnostic_result['status']}")
return diagnostic_result
except Exception as e:
logger.error(f"Ошибка при диагностике: {e}")
return {"error": str(e)}

View File

@@ -17,10 +17,10 @@ CMD_REFRESH = "refresh"
# Command to command mapping for metrics
COMMAND_MAPPING: Final[Dict[str, str]] = {
"start": "voice_start",
"help": "voice_help",
"help": "voice_help",
"restart": "voice_restart",
"emoji": "voice_emoji",
"refresh": "voice_refresh"
"refresh": "voice_refresh",
}
# Button texts
@@ -33,7 +33,7 @@ BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = {
"🎧Послушать": "voice_listen",
"Отменить": "voice_cancel",
"🔄Сбросить прослушивания": "voice_refresh_listen",
"😊Узнать эмодзи": "voice_emoji"
"😊Узнать эмодзи": "voice_emoji",
}
# Callback data
@@ -43,7 +43,7 @@ CALLBACK_DELETE = "delete"
# Callback to command mapping for metrics
CALLBACK_COMMAND_MAPPING: Final[Dict[str, str]] = {
"save": "voice_save",
"delete": "voice_delete"
"delete": "voice_delete",
}
# File paths

View File

@@ -1,23 +1,28 @@
class VoiceBotError(Exception):
"""Базовое исключение для voice_bot"""
pass
class VoiceMessageError(VoiceBotError):
"""Ошибка при работе с голосовыми сообщениями"""
pass
class AudioProcessingError(VoiceBotError):
"""Ошибка при обработке аудио"""
pass
class DatabaseError(VoiceBotError):
"""Ошибка базы данных"""
pass
class FileOperationError(VoiceBotError):
"""Ошибка при работе с файлами"""
pass

View File

@@ -7,16 +7,24 @@ from pathlib import Path
from typing import List, Optional, Tuple
from aiogram.types import FSInputFile
from helper_bot.handlers.voice.constants import (MESSAGE_DELAY_1,
MESSAGE_DELAY_2,
MESSAGE_DELAY_3,
MESSAGE_DELAY_4, STICK_DIR,
STICK_PATTERN, STICKER_DELAY,
VOICE_USERS_DIR)
from helper_bot.handlers.voice.exceptions import (AudioProcessingError,
DatabaseError,
FileOperationError,
VoiceMessageError)
from helper_bot.handlers.voice.constants import (
MESSAGE_DELAY_1,
MESSAGE_DELAY_2,
MESSAGE_DELAY_3,
MESSAGE_DELAY_4,
STICK_DIR,
STICK_PATTERN,
STICKER_DELAY,
VOICE_USERS_DIR,
)
from helper_bot.handlers.voice.exceptions import (
AudioProcessingError,
DatabaseError,
FileOperationError,
VoiceMessageError,
)
# Local imports - metrics
from helper_bot.utils.metrics import db_query_time, track_errors, track_time
from logs.custom_logger import logger
@@ -24,19 +32,23 @@ from logs.custom_logger import logger
class VoiceMessage:
"""Модель голосового сообщения"""
def __init__(self, file_name: str, user_id: int, date_added: datetime, file_id: int):
def __init__(
self, file_name: str, user_id: int, date_added: datetime, file_id: int
):
self.file_name = file_name
self.user_id = user_id
self.date_added = date_added
self.file_id = file_id
class VoiceBotService:
"""Сервис для работы с голосовыми сообщениями"""
def __init__(self, bot_db, settings):
self.bot_db = bot_db
self.settings = settings
@track_time("get_welcome_sticker", "voice_bot_service")
@track_errors("voice_bot_service", "get_welcome_sticker")
async def get_welcome_sticker(self) -> Optional[FSInputFile]:
@@ -45,17 +57,21 @@ class VoiceBotService:
name_stick_hello = list(Path(STICK_DIR).rglob(STICK_PATTERN))
if not name_stick_hello:
return None
random_stick_hello = random.choice(name_stick_hello)
random_stick_hello = FSInputFile(path=random_stick_hello)
logger.info(f"Стикер успешно получен. Наименование стикера: {random_stick_hello}")
logger.info(
f"Стикер успешно получен. Наименование стикера: {random_stick_hello}"
)
return random_stick_hello
except Exception as e:
logger.error(f"Ошибка при получении стикера: {e}")
if self.settings['Settings']['logs']:
await self._send_error_to_logs(f'Отправка приветственных стикеров лажает. Ошибка: {e}')
if self.settings["Settings"]["logs"]:
await self._send_error_to_logs(
f"Отправка приветственных стикеров лажает. Ошибка: {e}"
)
return None
@track_time("send_welcome_messages", "voice_bot_service")
@track_errors("voice_bot_service", "send_welcome_messages")
async def send_welcome_messages(self, message, user_emoji: str):
@@ -66,92 +82,94 @@ class VoiceBotService:
if sticker:
await message.answer_sticker(sticker)
await asyncio.sleep(STICKER_DELAY)
# Отправляем приветственное сообщение
markup = self._get_main_keyboard()
await message.answer(
text="<b>Привет.</b>",
parse_mode='html',
text="<b>Привет.</b>",
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(STICKER_DELAY)
# Отправляем описание
await message.answer(
text="<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(MESSAGE_DELAY_1)
# Отправляем аналогию
await message.answer(
text="Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(MESSAGE_DELAY_2)
# Отправляем правила
await message.answer(
text="Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(MESSAGE_DELAY_3)
# Отправляем информацию об анонимности
await message.answer(
text="Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем предложения
await message.answer(
text="Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию об эмодзи
await message.answer(
text=f"Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{user_emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем информацию о помощи
await message.answer(
text="Так же можешь ознакомиться с инструкцией к боту по команде /help",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
await asyncio.sleep(MESSAGE_DELAY_4)
# Отправляем финальное сообщение
await message.answer(
text="<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
parse_mode='html',
parse_mode="html",
reply_markup=markup,
disable_web_page_preview=not self.settings['Telegram']['preview_link']
disable_web_page_preview=not self.settings["Telegram"]["preview_link"],
)
except Exception as e:
logger.error(f"Ошибка при отправке приветственных сообщений: {e}")
raise VoiceMessageError(f"Не удалось отправить приветственные сообщения: {e}")
raise VoiceMessageError(
f"Не удалось отправить приветственные сообщения: {e}"
)
@track_time("get_random_audio", "voice_bot_service")
@track_errors("voice_bot_service", "get_random_audio")
async def get_random_audio(self, user_id: int) -> Optional[Tuple[str, str, str]]:
@@ -159,25 +177,25 @@ class VoiceBotService:
try:
check_audio = await self.bot_db.check_listen_audio(user_id=user_id)
list_audio = list(check_audio)
if not list_audio:
return None
# Получаем случайное аудио
number_element = random.randint(0, len(list_audio) - 1)
audio_for_user = check_audio[number_element]
# Получаем информацию об авторе
user_id_author = await self.bot_db.get_user_id_by_file_name(audio_for_user)
date_added = await self.bot_db.get_date_by_file_name(audio_for_user)
user_emoji = await self.bot_db.get_user_emoji(user_id_author)
return audio_for_user, date_added, user_emoji
except Exception as e:
logger.error(f"Ошибка при получении случайного аудио: {e}")
raise AudioProcessingError(f"Не удалось получить случайное аудио: {e}")
@track_time("mark_audio_as_listened", "voice_bot_service")
@track_errors("voice_bot_service", "mark_audio_as_listened")
async def mark_audio_as_listened(self, file_name: str, user_id: int) -> None:
@@ -187,7 +205,7 @@ class VoiceBotService:
except Exception as e:
logger.error(f"Ошибка при пометке аудио как прослушанного: {e}")
raise DatabaseError(f"Не удалось пометить аудио как прослушанное: {e}")
@track_time("clear_user_listenings", "voice_bot_service")
@track_errors("voice_bot_service", "clear_user_listenings")
@db_query_time("clear_user_listenings", "audio_moderate", "delete")
@@ -198,7 +216,7 @@ class VoiceBotService:
except Exception as e:
logger.error(f"Ошибка при очистке прослушиваний: {e}")
raise DatabaseError(f"Не удалось очистить прослушивания: {e}")
@track_time("get_remaining_audio_count", "voice_bot_service")
@track_errors("voice_bot_service", "get_remaining_audio_count")
async def get_remaining_audio_count(self, user_id: int) -> int:
@@ -209,25 +227,24 @@ class VoiceBotService:
except Exception as e:
logger.error(f"Ошибка при получении количества аудио: {e}")
raise DatabaseError(f"Не удалось получить количество аудио: {e}")
@track_time("get_main_keyboard", "voice_bot_service")
@track_errors("voice_bot_service", "get_main_keyboard")
def _get_main_keyboard(self):
"""Получить основную клавиатуру"""
from helper_bot.keyboards.keyboards import get_main_keyboard
return get_main_keyboard()
@track_time("send_error_to_logs", "voice_bot_service")
@track_errors("voice_bot_service", "send_error_to_logs")
async def _send_error_to_logs(self, message: str) -> None:
"""Отправить ошибку в логи"""
try:
from helper_bot.utils.helper_func import send_voice_message
await send_voice_message(
self.settings['Telegram']['important_logs'],
None,
None,
None
self.settings["Telegram"]["important_logs"], None, None, None
)
except Exception as e:
logger.error(f"Не удалось отправить ошибку в логи: {e}")
@@ -235,45 +252,49 @@ class VoiceBotService:
class AudioFileService:
"""Сервис для работы с аудио файлами"""
def __init__(self, bot_db):
self.bot_db = bot_db
@track_time("generate_file_name", "audio_file_service")
@track_errors("audio_file_service", "generate_file_name")
async def generate_file_name(self, user_id: int) -> str:
"""Сгенерировать имя файла для аудио"""
try:
# Проверяем есть ли запись о файле в базе данных
user_audio_count = await self.bot_db.get_user_audio_records_count(user_id=user_id)
user_audio_count = await self.bot_db.get_user_audio_records_count(
user_id=user_id
)
if user_audio_count == 0:
# Если нет, то генерируем имя файла
file_name = f'message_from_{user_id}_number_1'
file_name = f"message_from_{user_id}_number_1"
else:
# Иначе берем последнюю запись из БД, добавляем к ней 1
file_name = await self.bot_db.get_path_for_audio_record(user_id=user_id)
if file_name:
# Извлекаем номер из имени файла и увеличиваем на 1
try:
current_number = int(file_name.split('_')[-1])
current_number = int(file_name.split("_")[-1])
new_number = current_number + 1
except (ValueError, IndexError):
new_number = user_audio_count + 1
else:
new_number = user_audio_count + 1
file_name = f'message_from_{user_id}_number_{new_number}'
file_name = f"message_from_{user_id}_number_{new_number}"
return file_name
except Exception as e:
logger.error(f"Ошибка при генерации имени файла: {e}")
raise FileOperationError(f"Не удалось сгенерировать имя файла: {e}")
@track_time("save_audio_file", "audio_file_service")
@track_errors("audio_file_service", "save_audio_file")
async def save_audio_file(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None:
async def save_audio_file(
self, file_name: str, user_id: int, date_added: datetime, file_id: str
) -> None:
"""Сохранить информацию об аудио файле в базу данных"""
try:
# Проверяем существование файла перед сохранением в БД
@@ -281,16 +302,20 @@ class AudioFileService:
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
logger.error(error_msg)
raise FileOperationError(error_msg)
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
logger.info(f"Информация об аудио файле успешно сохранена в БД: {file_name}")
logger.info(
f"Информация об аудио файле успешно сохранена в БД: {file_name}"
)
except Exception as e:
logger.error(f"Ошибка при сохранении аудио файла в БД: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД: {e}")
@track_time("save_audio_file_with_transaction", "audio_file_service")
@track_errors("audio_file_service", "save_audio_file_with_transaction")
async def save_audio_file_with_transaction(self, file_name: str, user_id: int, date_added: datetime, file_id: str) -> None:
async def save_audio_file_with_transaction(
self, file_name: str, user_id: int, date_added: datetime, file_id: str
) -> None:
"""Сохранить информацию об аудио файле в базу данных с транзакцией"""
try:
# Проверяем существование файла перед сохранением в БД
@@ -298,68 +323,80 @@ class AudioFileService:
error_msg = f"Файл {file_name} не существует или поврежден, отменяем сохранение в БД"
logger.error(error_msg)
raise FileOperationError(error_msg)
# Используем транзакцию для атомарности операции
await self.bot_db.add_audio_record_simple(file_name, user_id, date_added)
logger.info(f"Информация об аудио файле успешно сохранена в БД с транзакцией: {file_name}")
logger.info(
f"Информация об аудио файле успешно сохранена в БД с транзакцией: {file_name}"
)
except Exception as e:
logger.error(f"Ошибка при сохранении аудио файла в БД с транзакцией: {e}")
raise DatabaseError(f"Не удалось сохранить аудио файл в БД с транзакцией: {e}")
raise DatabaseError(
f"Не удалось сохранить аудио файл в БД с транзакцией: {e}"
)
@track_time("download_and_save_audio", "audio_file_service")
@track_errors("audio_file_service", "download_and_save_audio")
async def download_and_save_audio(self, bot, message, file_name: str, max_retries: int = 3) -> None:
async def download_and_save_audio(
self, bot, message, file_name: str, max_retries: int = 3
) -> None:
"""Скачать и сохранить аудио файл с retry механизмом"""
last_exception = None
for attempt in range(max_retries):
try:
logger.info(f"Попытка {attempt + 1}/{max_retries} скачивания и сохранения аудио: {file_name}")
logger.info(
f"Попытка {attempt + 1}/{max_retries} скачивания и сохранения аудио: {file_name}"
)
# Проверяем наличие голосового сообщения
if not message or not message.voice:
error_msg = "Сообщение или голосовое сообщение не найдено"
logger.error(error_msg)
raise FileOperationError(error_msg)
file_id = message.voice.file_id
logger.info(f"Получен file_id: {file_id}")
# Получаем информацию о файле
try:
file_info = await bot.get_file(file_id=file_id)
logger.info(f"Получена информация о файле: {file_info.file_path}")
except Exception as e:
logger.error(f"Ошибка при получении информации о файле: {e}")
raise FileOperationError(f"Не удалось получить информацию о файле: {e}")
raise FileOperationError(
f"Не удалось получить информацию о файле: {e}"
)
# Скачиваем файл
try:
downloaded_file = await bot.download_file(file_path=file_info.file_path)
downloaded_file = await bot.download_file(
file_path=file_info.file_path
)
except Exception as e:
logger.error(f"Ошибка при скачивании файла: {e}")
raise FileOperationError(f"Не удалось скачать файл: {e}")
# Проверяем что файл успешно скачан
if not downloaded_file:
error_msg = "Не удалось скачать файл - получен пустой объект"
logger.error(error_msg)
raise FileOperationError(error_msg)
# Получаем размер файла без изменения позиции
current_pos = downloaded_file.tell()
downloaded_file.seek(0, 2) # Переходим в конец файла
file_size = downloaded_file.tell()
downloaded_file.seek(current_pos) # Возвращаемся в исходную позицию
logger.info(f"Файл скачан, размер: {file_size} bytes")
# Проверяем минимальный размер файла
if file_size < 100: # Минимальный размер для аудио файла
error_msg = f"Файл слишком маленький: {file_size} bytes"
logger.error(error_msg)
raise FileOperationError(error_msg)
# Создаем директорию если она не существует
try:
os.makedirs(VOICE_USERS_DIR, exist_ok=True)
@@ -367,27 +404,27 @@ class AudioFileService:
except Exception as e:
logger.error(f"Ошибка при создании директории: {e}")
raise FileOperationError(f"Не удалось создать директорию: {e}")
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
logger.info(f"Сохраняем файл по пути: {file_path}")
# Сбрасываем позицию в файле перед сохранением
downloaded_file.seek(0)
# Сохраняем файл
try:
with open(file_path, 'wb') as new_file:
with open(file_path, "wb") as new_file:
new_file.write(downloaded_file.read())
except Exception as e:
logger.error(f"Ошибка при записи файла на диск: {e}")
raise FileOperationError(f"Не удалось записать файл на диск: {e}")
# Проверяем что файл действительно создался и имеет правильный размер
if not os.path.exists(file_path):
error_msg = f"Файл не был создан: {file_path}"
logger.error(error_msg)
raise FileOperationError(error_msg)
saved_file_size = os.path.getsize(file_path)
if saved_file_size != file_size:
error_msg = f"Размер сохраненного файла не совпадает: ожидалось {file_size}, получено {saved_file_size}"
@@ -398,48 +435,62 @@ class AudioFileService:
except:
pass
raise FileOperationError(error_msg)
logger.info(f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes")
logger.info(
f"Файл успешно сохранен: {file_path}, размер: {saved_file_size} bytes"
)
return # Успешное завершение
except Exception as e:
last_exception = e
logger.error(f"Попытка {attempt + 1}/{max_retries} неудачна: {e}")
if attempt < max_retries - 1:
wait_time = (attempt + 1) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
logger.info(f"Ожидание {wait_time} секунд перед следующей попыткой...")
wait_time = (
attempt + 1
) * 2 # Экспоненциальная задержка: 2, 4, 6 секунд
logger.info(
f"Ожидание {wait_time} секунд перед следующей попыткой..."
)
await asyncio.sleep(wait_time)
else:
logger.error(f"Все {max_retries} попыток скачивания неудачны")
logger.error(f"Traceback последней ошибки: {traceback.format_exc()}")
logger.error(
f"Traceback последней ошибки: {traceback.format_exc()}"
)
# Если все попытки неудачны
raise FileOperationError(f"Не удалось скачать и сохранить аудио после {max_retries} попыток. Последняя ошибка: {last_exception}")
raise FileOperationError(
f"Не удалось скачать и сохранить аудио после {max_retries} попыток. Последняя ошибка: {last_exception}"
)
@track_time("verify_file_exists", "audio_file_service")
@track_errors("audio_file_service", "verify_file_exists")
async def verify_file_exists(self, file_name: str) -> bool:
"""Проверить существование и валидность файла"""
try:
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
file_path = f"{VOICE_USERS_DIR}/{file_name}.ogg"
if not os.path.exists(file_path):
logger.warning(f"Файл не существует: {file_path}")
return False
file_size = os.path.getsize(file_path)
if file_size == 0:
logger.warning(f"Файл пустой: {file_path}")
return False
if file_size < 100: # Минимальный размер для аудио файла
logger.warning(f"Файл слишком маленький: {file_path}, размер: {file_size} bytes")
logger.warning(
f"Файл слишком маленький: {file_path}, размер: {file_size} bytes"
)
return False
logger.info(f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes")
logger.info(
f"Файл проверен и валиден: {file_path}, размер: {file_size} bytes"
)
return True
except Exception as e:
logger.error(f"Ошибка при проверке файла {file_name}: {e}")
return False

View File

@@ -18,31 +18,37 @@ def format_time_ago(date_from_db: str) -> Optional[str]:
last_voice_time_timestamp = time.mktime(parse_date.timetuple())
time_now_timestamp = time.time()
date_difference = time_now_timestamp - last_voice_time_timestamp
# Считаем минуты, часы, дни
much_minutes_ago = round(date_difference / 60, 0)
much_hour_ago = round(date_difference / 3600, 0)
much_days_ago = int(round(much_hour_ago / 24, 0))
message_with_date = ''
message_with_date = ""
if much_minutes_ago <= 60:
word_minute = plural_time(1, much_minutes_ago)
# Экранируем потенциально проблемные символы
word_minute_escaped = html.escape(word_minute)
message_with_date = f'<b>Последнее сообщение было записано {word_minute_escaped} назад</b>'
message_with_date = (
f"<b>Последнее сообщение было записано {word_minute_escaped} назад</b>"
)
elif much_minutes_ago > 60 and much_hour_ago <= 24:
word_hour = plural_time(2, much_hour_ago)
# Экранируем потенциально проблемные символы
word_hour_escaped = html.escape(word_hour)
message_with_date = f'<b>Последнее сообщение было записано {word_hour_escaped} назад</b>'
message_with_date = (
f"<b>Последнее сообщение было записано {word_hour_escaped} назад</b>"
)
elif much_hour_ago > 24:
word_day = plural_time(3, much_days_ago)
# Экранируем потенциально проблемные символы
word_day_escaped = html.escape(word_day)
message_with_date = f'<b>Последнее сообщение было записано {word_day_escaped} назад</b>'
message_with_date = (
f"<b>Последнее сообщение было записано {word_day_escaped} назад</b>"
)
return message_with_date
except Exception as e:
logger.error(f"Ошибка при форматировании времени: {e}")
return None
@@ -52,11 +58,11 @@ def plural_time(type: int, n: float) -> str:
"""Форматировать множественное число для времени"""
word = []
if type == 1:
word = ['минуту', 'минуты', 'минут']
word = ["минуту", "минуты", "минут"]
elif type == 2:
word = ['час', 'часа', 'часов']
word = ["час", "часа", "часов"]
elif type == 3:
word = ['день', 'дня', 'дней']
word = ["день", "дня", "дней"]
else:
return str(int(n))
@@ -66,9 +72,10 @@ def plural_time(type: int, n: float) -> str:
p = 1
else:
p = 2
new_number = int(n)
return str(new_number) + ' ' + word[p]
return str(new_number) + " " + word[p]
@track_time("get_last_message_text", "voice_utils")
@track_errors("voice_utils", "get_last_message_text")
@@ -89,7 +96,8 @@ async def get_last_message_text(bot_db) -> Optional[str]:
async def validate_voice_message(message) -> bool:
"""Проверить валидность голосового сообщения"""
return message.content_type == 'voice'
return message.content_type == "voice"
@track_time("get_user_emoji_safe", "voice_utils")
@track_errors("voice_utils", "get_user_emoji_safe")
@@ -98,7 +106,11 @@ async def get_user_emoji_safe(bot_db, user_id: int) -> str:
"""Безопасно получить эмодзи пользователя"""
try:
user_emoji = await bot_db.get_user_emoji(user_id)
return user_emoji if user_emoji and user_emoji != "Смайл еще не определен" else "😊"
return (
user_emoji
if user_emoji and user_emoji != "Смайл еще не определен"
else "😊"
)
except Exception as e:
logger.error(f"Ошибка при получении эмодзи пользователя {user_id}: {e}")
return "😊"

View File

@@ -6,31 +6,44 @@ from aiogram import F, Router, types
from aiogram.filters import Command, MagicData, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.types import FSInputFile
from helper_bot.filters.main import ChatTypeFilter
from helper_bot.handlers.private.constants import BUTTON_TEXTS, FSM_STATES
from helper_bot.handlers.voice.constants import *
from helper_bot.handlers.voice.services import VoiceBotService
from helper_bot.handlers.voice.utils import (get_last_message_text,
get_user_emoji_safe,
validate_voice_message)
from helper_bot.handlers.voice.utils import (
get_last_message_text,
get_user_emoji_safe,
validate_voice_message,
)
from helper_bot.keyboards import get_reply_keyboard
from helper_bot.keyboards.keyboards import (get_main_keyboard,
get_reply_keyboard_for_voice)
from helper_bot.keyboards.keyboards import (
get_main_keyboard,
get_reply_keyboard_for_voice,
)
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.dependencies_middleware import \
DependenciesMiddleware
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.utils import messages
from helper_bot.utils.helper_func import (check_user_emoji, get_first_name,
send_voice_message, update_user_info)
from helper_bot.utils.helper_func import (
check_user_emoji,
get_first_name,
send_voice_message,
update_user_info,
)
# Local imports - metrics
from helper_bot.utils.metrics import (db_query_time, track_errors,
track_file_operations, track_time)
from helper_bot.utils.metrics import (
db_query_time,
track_errors,
track_file_operations,
track_time,
)
from logs.custom_logger import logger
class VoiceHandlers:
def __init__(self, db, settings):
self.db = db.get_db() if hasattr(db, 'get_db') else db
self.db = db.get_db() if hasattr(db, "get_db") else db
self.settings = settings
self.router = Router()
self._setup_handlers()
@@ -44,102 +57,114 @@ class VoiceHandlers:
self.router.message.register(
self.cancel_handler,
ChatTypeFilter(chat_type=["private"]),
F.text == "Отменить"
F.text == "Отменить",
)
# Обработчик кнопки "Голосовой бот"
self.router.message.register(
self.voice_bot_button_handler,
ChatTypeFilter(chat_type=["private"]),
F.text == BUTTON_TEXTS["VOICE_BOT"]
F.text == BUTTON_TEXTS["VOICE_BOT"],
)
# Команды
self.router.message.register(
self.restart_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_RESTART)
Command(CMD_RESTART),
)
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_EMOJI)
Command(CMD_EMOJI),
)
self.router.message.register(
self.help_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_HELP)
self.help_function, ChatTypeFilter(chat_type=["private"]), Command(CMD_HELP)
)
self.router.message.register(
self.start,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_START)
self.start, ChatTypeFilter(chat_type=["private"]), Command(CMD_START)
)
# Дополнительные команды
self.router.message.register(
self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]),
Command(CMD_REFRESH)
Command(CMD_REFRESH),
)
# Обработчики состояний и кнопок
self.router.message.register(
self.standup_write,
StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]),
F.text == BTN_SPEAK
F.text == BTN_SPEAK,
)
self.router.message.register(
self.suggest_voice,
StateFilter(STATE_STANDUP_WRITE),
ChatTypeFilter(chat_type=["private"]),
)
self.router.message.register(
self.standup_listen_audio,
StateFilter(STATE_START),
ChatTypeFilter(chat_type=["private"]),
F.text == BTN_LISTEN
F.text == BTN_LISTEN,
)
# Новые обработчики кнопок
self.router.message.register(
self.refresh_listen_function,
ChatTypeFilter(chat_type=["private"]),
F.text == "🔄Сбросить прослушивания"
F.text == "🔄Сбросить прослушивания",
)
self.router.message.register(
self.handle_emoji_message,
ChatTypeFilter(chat_type=["private"]),
F.text == "😊Узнать эмодзи"
F.text == "😊Узнать эмодзи",
)
@track_time("voice_bot_button_handler", "voice_handlers")
@track_errors("voice_handlers", "voice_bot_button_handler")
async def voice_bot_button_handler(self, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), settings: MagicData("settings")):
async def voice_bot_button_handler(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
):
"""Обработчик кнопки 'Голосовой бот' из основной клавиатуры"""
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) нажал кнопку 'Голосовой бот'")
logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) нажал кнопку 'Голосовой бот'"
)
try:
# Проверяем, получал ли пользователь приветственное сообщение
welcome_received = await bot_db.check_voice_bot_welcome_received(message.from_user.id)
logger.info(f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}")
welcome_received = await bot_db.check_voice_bot_welcome_received(
message.from_user.id
)
logger.info(
f"Пользователь {message.from_user.id}: welcome_received = {welcome_received}"
)
if welcome_received:
# Если уже получал приветствие, вызываем restart_function
logger.info(f"Пользователь {message.from_user.id}: вызываем restart_function")
logger.info(
f"Пользователь {message.from_user.id}: вызываем restart_function"
)
await self.restart_function(message, state, bot_db, settings)
else:
# Если не получал, вызываем start
logger.info(f"Пользователь {message.from_user.id}: вызываем start")
await self.start(message, state, bot_db, settings)
except Exception as e:
logger.error(f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}")
logger.error(
f"Ошибка при проверке приветственного сообщения для пользователя {message.from_user.id}: {e}"
)
# В случае ошибки вызываем start
await self.start(message, state, bot_db, settings)
@@ -147,49 +172,49 @@ class VoiceHandlers:
@track_errors("voice_handlers", "restart_function")
async def restart_function(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
logger.info(f"Пользователь {message.from_user.id}: вызывается функция restart_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
f"Пользователь {message.from_user.id}: вызывается функция restart_function"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message)
await check_user_emoji(message)
markup = get_main_keyboard()
await message.answer(text='🎤 Записывайся или слушай!', reply_markup=markup)
await message.answer(text="🎤 Записывайся или слушай!", reply_markup=markup)
await state.set_state(STATE_START)
@track_time("handle_emoji_message", "voice_handlers")
@track_errors("voice_handlers", "handle_emoji_message")
async def handle_emoji_message(
self,
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
self, message: types.Message, state: FSMContext, settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил информацию об эмодзи"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
user_emoji = await check_user_emoji(message)
await state.set_state(STATE_START)
if user_emoji is not None:
await message.answer(f'Твоя эмодзя - {user_emoji}', parse_mode='HTML')
await message.answer(f"Твоя эмодзя - {user_emoji}", parse_mode="HTML")
@track_time("help_function", "voice_handlers")
@track_errors("voice_handlers", "help_function")
async def help_function(
self,
message: types.Message,
state: FSMContext,
settings: MagicData("settings")
self, message: types.Message, state: FSMContext, settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию help_function"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message)
help_message = messages.get_message(get_first_name(message), 'HELP_MESSAGE')
help_message = messages.get_message(get_first_name(message), "HELP_MESSAGE")
await message.answer(
text=help_message,
disable_web_page_preview=not settings['Telegram']['preview_link']
disable_web_page_preview=not settings["Telegram"]["preview_link"],
)
await state.set_state(STATE_START)
@@ -198,43 +223,53 @@ class VoiceHandlers:
@db_query_time("mark_voice_bot_welcome_received", "audio_moderate", "update")
async def start(
self,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings"),
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}): вызывается функция start")
logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}): вызывается функция start"
)
await state.set_state(STATE_START)
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message)
user_emoji = await get_user_emoji_safe(bot_db, message.from_user.id)
# Создаем сервис и отправляем приветственные сообщения
voice_service = VoiceBotService(bot_db, settings)
await voice_service.send_welcome_messages(message, user_emoji)
logger.info(f"Приветственные сообщения отправлены пользователю {message.from_user.id}")
logger.info(
f"Приветственные сообщения отправлены пользователю {message.from_user.id}"
)
# Отмечаем, что пользователь получил приветственное сообщение
try:
await bot_db.mark_voice_bot_welcome_received(message.from_user.id)
logger.info(f"Пользователь {message.from_user.id}: отмечен как получивший приветствие")
logger.info(
f"Пользователь {message.from_user.id}: отмечен как получивший приветствие"
)
except Exception as e:
logger.error(f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}")
logger.error(
f"Ошибка при отметке получения приветствия для пользователя {message.from_user.id}: {e}"
)
@track_time("cancel_handler", "voice_handlers")
@track_errors("voice_handlers", "cancel_handler")
async def cancel_handler(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
settings: MagicData("settings"),
):
"""Обработчик кнопки 'Отменить' - возвращает в начальное состояние"""
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message)
markup = await get_reply_keyboard(self.db, message.from_user.id)
await message.answer(text='Добро пожаловать в меню!', reply_markup=markup, parse_mode='HTML')
await message.answer(
text="Добро пожаловать в меню!", reply_markup=markup, parse_mode="HTML"
)
await state.set_state(FSM_STATES["START"])
logger.info(f"Пользователь {message.from_user.id} возвращен в главное меню")
@@ -242,208 +277,253 @@ class VoiceHandlers:
@track_errors("voice_handlers", "refresh_listen_function")
async def refresh_listen_function(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию refresh_listen_function")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
settings: MagicData("settings"),
):
logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию refresh_listen_function"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await update_user_info(VOICE_BOT_NAME, message)
markup = get_main_keyboard()
# Очищаем прослушивания через сервис
voice_service = VoiceBotService(bot_db, settings)
await voice_service.clear_user_listenings(message.from_user.id)
listenings_cleared_message = messages.get_message(get_first_name(message), 'LISTENINGS_CLEARED_MESSAGE')
listenings_cleared_message = messages.get_message(
get_first_name(message), "LISTENINGS_CLEARED_MESSAGE"
)
await message.answer(
text=listenings_cleared_message,
disable_web_page_preview=not settings['Telegram']['preview_link'],
reply_markup=markup
disable_web_page_preview=not settings["Telegram"]["preview_link"],
reply_markup=markup,
)
await state.set_state(STATE_START)
@track_time("standup_write", "voice_handlers")
@track_errors("voice_handlers", "standup_write")
async def standup_write(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию standup_write")
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
settings: MagicData("settings"),
):
logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) вызвал функцию standup_write"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
markup = types.ReplyKeyboardRemove()
record_voice_message = messages.get_message(get_first_name(message), 'RECORD_VOICE_MESSAGE')
record_voice_message = messages.get_message(
get_first_name(message), "RECORD_VOICE_MESSAGE"
)
await message.answer(text=record_voice_message, reply_markup=markup)
try:
message_with_date = await get_last_message_text(bot_db)
if message_with_date:
await message.answer(text=message_with_date, parse_mode="html")
except Exception as e:
logger.error(f'Не удалось получить дату последнего сообщения для пользователя {message.from_user.id}: {e}')
await state.set_state(STATE_STANDUP_WRITE)
logger.error(
f"Не удалось получить дату последнего сообщения для пользователя {message.from_user.id}: {e}"
)
await state.set_state(STATE_STANDUP_WRITE)
@track_time("suggest_voice", "voice_handlers")
@track_errors("voice_handlers", "suggest_voice")
async def suggest_voice(
self,
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
settings: MagicData("settings"),
):
logger.info(
f"Вызов функции suggest_voice. Пользователь: {message.from_user.id} Имя автора сообщения: {message.from_user.full_name}"
)
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
markup = get_main_keyboard()
if await validate_voice_message(message):
markup_for_voice = get_reply_keyboard_for_voice()
# Отправляем аудио в приватный канал
sent_message = await send_voice_message(
settings['Telegram']['group_for_posts'],
settings["Telegram"]["group_for_posts"],
message,
message.voice.file_id,
markup_for_voice
message.voice.file_id,
markup_for_voice,
)
logger.info(
f"Голосовое сообщение пользователя {message.from_user.id} отправлено в группу постов (message_id: {sent_message.message_id})"
)
logger.info(f"Голосовое сообщение пользователя {message.from_user.id} отправлено в группу постов (message_id: {sent_message.message_id})")
# Сохраняем в базу инфо о посте
await bot_db.set_user_id_and_message_id_for_voice_bot(sent_message.message_id, message.from_user.id)
await bot_db.set_user_id_and_message_id_for_voice_bot(
sent_message.message_id, message.from_user.id
)
# Отправляем юзеру ответ и возвращаем его в меню
voice_saved_message = messages.get_message(get_first_name(message), 'VOICE_SAVED_MESSAGE')
voice_saved_message = messages.get_message(
get_first_name(message), "VOICE_SAVED_MESSAGE"
)
await message.answer(text=voice_saved_message, reply_markup=markup)
await state.set_state(STATE_START)
else:
logger.warning(f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию")
unknown_content_message = messages.get_message(get_first_name(message), 'UNKNOWN_CONTENT_MESSAGE')
await message.forward(chat_id=settings['Telegram']['group_for_logs'])
logger.warning(
f"Голосовое сообщение пользователя {message.from_user.id} не прошло валидацию"
)
unknown_content_message = messages.get_message(
get_first_name(message), "UNKNOWN_CONTENT_MESSAGE"
)
await message.forward(chat_id=settings["Telegram"]["group_for_logs"])
await message.answer(text=unknown_content_message, reply_markup=markup)
await state.set_state(STATE_STANDUP_WRITE)
@track_time("standup_listen_audio", "voice_handlers")
@track_errors("voice_handlers", "standup_listen_audio")
@track_file_operations("voice")
@db_query_time("standup_listen_audio", "audio_moderate", "mixed")
async def standup_listen_audio(
self,
message: types.Message,
message: types.Message,
bot_db: MagicData("bot_db"),
settings: MagicData("settings")
):
logger.info(f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил прослушивание аудио")
settings: MagicData("settings"),
):
logger.info(
f"Пользователь {message.from_user.id} ({message.from_user.full_name}) запросил прослушивание аудио"
)
markup = get_main_keyboard()
# Создаем сервис для работы с аудио
voice_service = VoiceBotService(bot_db, settings)
try:
#TODO: удалить логику из хендлера
# TODO: удалить логику из хендлера
# Получаем случайное аудио
audio_data = await voice_service.get_random_audio(message.from_user.id)
if not audio_data:
logger.warning(f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания")
no_audio_message = messages.get_message(get_first_name(message), 'NO_AUDIO_MESSAGE')
logger.warning(
f"Для пользователя {message.from_user.id} не найдено доступных аудио для прослушивания"
)
no_audio_message = messages.get_message(
get_first_name(message), "NO_AUDIO_MESSAGE"
)
await message.answer(text=no_audio_message, reply_markup=markup)
try:
message_with_date = await get_last_message_text(bot_db)
if message_with_date:
await message.answer(text=message_with_date, parse_mode="html")
except Exception as e:
logger.error(f'Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}')
logger.error(
f"Не удалось получить последнюю дату для пользователя {message.from_user.id}: {e}"
)
return
audio_for_user, date_added, user_emoji = audio_data
# Получаем путь к файлу
path = Path(f'{VOICE_USERS_DIR}/{audio_for_user}.ogg')
path = Path(f"{VOICE_USERS_DIR}/{audio_for_user}.ogg")
# Проверяем существование файла
if not path.exists():
logger.error(f"Файл не найден: {path} для пользователя {message.from_user.id}")
logger.error(
f"Файл не найден: {path} для пользователя {message.from_user.id}"
)
# Дополнительная диагностика
logger.error(f"Директория {VOICE_USERS_DIR} существует: {Path(VOICE_USERS_DIR).exists()}")
logger.error(
f"Директория {VOICE_USERS_DIR} существует: {Path(VOICE_USERS_DIR).exists()}"
)
if Path(VOICE_USERS_DIR).exists():
files_in_dir = list(Path(VOICE_USERS_DIR).glob("*.ogg"))
logger.error(f"Файлы в директории: {[f.name for f in files_in_dir]}")
logger.error(
f"Файлы в директории: {[f.name for f in files_in_dir]}"
)
await message.answer(
text="Файл аудио не найден. Обратитесь к администратору.",
reply_markup=markup
reply_markup=markup,
)
return
# Проверяем размер файла
if path.stat().st_size == 0:
logger.error(f"Файл пустой: {path} для пользователя {message.from_user.id}")
logger.error(
f"Файл пустой: {path} для пользователя {message.from_user.id}"
)
await message.answer(
text="Файл аудио поврежден. Обратитесь к администратору.",
reply_markup=markup
reply_markup=markup,
)
return
voice = FSInputFile(path)
# Формируем подпись
if user_emoji:
caption = f'{user_emoji}\nДата записи: {date_added}'
caption = f"{user_emoji}\nДата записи: {date_added}"
else:
caption = f'Дата записи: {date_added}'
logger.info(f"Подготовлено голосовое сообщение для пользователя {message.from_user.id}: {caption}")
caption = f"Дата записи: {date_added}"
logger.info(
f"Подготовлено голосовое сообщение для пользователя {message.from_user.id}: {caption}"
)
try:
from helper_bot.utils.rate_limiter import send_with_rate_limit
async def _send_voice():
return await message.bot.send_voice(
chat_id=message.chat.id,
voice=voice,
caption=caption,
reply_markup=markup
chat_id=message.chat.id,
voice=voice,
caption=caption,
reply_markup=markup,
)
await send_with_rate_limit(_send_voice, message.chat.id)
# Маркируем сообщение как прослушанное только после успешной отправки
await voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id)
# Получаем количество оставшихся аудио только после успешной отправки
remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id)
await message.answer(
text=f'Осталось непрослушанных: <b>{remaining_count}</b>',
reply_markup=markup
await voice_service.mark_audio_as_listened(
audio_for_user, message.from_user.id
)
# Получаем количество оставшихся аудио только после успешной отправки
remaining_count = await voice_service.get_remaining_audio_count(
message.from_user.id
)
await message.answer(
text=f"Осталось непрослушанных: <b>{remaining_count}</b>",
reply_markup=markup,
)
except Exception as voice_error:
if "VOICE_MESSAGES_FORBIDDEN" in str(voice_error):
# Если голосовые сообщения запрещены, отправляем информативное сообщение
logger.warning(f"Пользователь {message.from_user.id} запретил получение голосовых сообщений")
logger.warning(
f"Пользователь {message.from_user.id} запретил получение голосовых сообщений"
)
privacy_message = "🔇 К сожалению, у тебя закрыт доступ к получению голосовых сообщений.\n\nДля продолжения взаимодействия с ботом необходимо дать возможность мне присылать войсы в настройках приватности Telegram.\n\n💡 Как это сделать:\n1. Открой настройки Telegram\n2. Перейди в 'Конфиденциальность и безопасность'\n3. Выбери 'Голосовые сообщения'\n4. Разреши получение от 'Всех' или добавь меня в исключения"
await message.answer(text=privacy_message, reply_markup=markup)
return # Выходим без записи о прослушивании
else:
logger.error(f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}")
logger.error(
f"Ошибка при отправке голосового сообщения пользователю {message.from_user.id}: {voice_error}"
)
raise voice_error
except Exception as e:
logger.error(f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}")
logger.error(
f"Ошибка при прослушивании аудио для пользователя {message.from_user.id}: {e}"
)
await message.answer(
text="Произошла ошибка при получении аудио. Попробуйте позже.",
reply_markup=markup
reply_markup=markup,
)

View File

@@ -1,24 +1,21 @@
from aiogram import types
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
# Local imports - metrics
from helper_bot.utils.metrics import track_errors, track_time
def get_reply_keyboard_for_post():
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(
text="Опубликовать", callback_data="publish"),
types.InlineKeyboardButton(
text="Отклонить", callback_data="decline")
)
builder.row(types.InlineKeyboardButton(
text="👮‍♂️ Забанить", callback_data="ban")
builder.row(
types.InlineKeyboardButton(text="Опубликовать", callback_data="publish"),
types.InlineKeyboardButton(text="Отклонить", callback_data="decline"),
)
builder.row(types.InlineKeyboardButton(text="👮‍♂️ Забанить", callback_data="ban"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
async def get_reply_keyboard(db, user_id):
builder = ReplyKeyboardBuilder()
builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
@@ -43,18 +40,21 @@ def get_reply_keyboard_admin():
builder.row(
types.KeyboardButton(text="Бан (Список)"),
types.KeyboardButton(text="Бан по нику"),
types.KeyboardButton(text="Бан по ID")
types.KeyboardButton(text="Бан по ID"),
)
builder.row(
types.KeyboardButton(text="Разбан (список)"),
types.KeyboardButton(text="Вернуться в бота")
types.KeyboardButton(text="Вернуться в бота"),
)
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
@track_time("create_keyboard_with_pagination", "keyboard_service")
@track_errors("keyboard_service", "create_keyboard_with_pagination")
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list, callback: str):
def create_keyboard_with_pagination(
page: int, total_items: int, array_items: list, callback: str
):
"""
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback
@@ -67,74 +67,79 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li
Returns:
InlineKeyboardMarkup: Клавиатура с кнопками пагинации.
"""
# Проверяем валидность входных данных
if page < 1:
page = 1
if not array_items:
# Если нет элементов, возвращаем только кнопку "Назад"
keyboard = InlineKeyboardBuilder()
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
home_button = types.InlineKeyboardButton(
text="🏠 Назад", callback_data="return"
)
keyboard.row(home_button)
return keyboard.as_markup()
# Определяем общее количество страниц
items_per_page = 9
total_pages = (total_items + items_per_page - 1) // items_per_page
# Ограничиваем страницу максимальным значением
if page > total_pages:
page = total_pages
# Создаем билдер для клавиатуры
keyboard = InlineKeyboardBuilder()
# Вычисляем стартовый номер для текущей страницы
start_index = (page - 1) * items_per_page
# Кнопки с элементами текущей страницы
end_index = min(start_index + items_per_page, len(array_items))
current_row = []
for i in range(start_index, end_index):
current_row.append(types.InlineKeyboardButton(
text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}"
))
current_row.append(
types.InlineKeyboardButton(
text=f"{array_items[i][0]}",
callback_data=f"{callback}_{array_items[i][1]}",
)
)
# Когда набирается 3 кнопки, добавляем ряд
if len(current_row) == 3:
keyboard.row(*current_row)
current_row = []
# Добавляем оставшиеся кнопки, если они есть
if current_row:
keyboard.row(*current_row)
# Создаем кнопки навигации только если нужно
navigation_buttons = []
# Кнопка "Предыдущая" - показываем только если не первая страница
if page > 1:
prev_button = types.InlineKeyboardButton(
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
)
navigation_buttons.append(prev_button)
# Кнопка "Следующая" - показываем только если не последняя страница
if page < total_pages:
next_button = types.InlineKeyboardButton(
text="➡️ Следующая", callback_data=f"page_{page + 1}"
)
navigation_buttons.append(next_button)
# Добавляем кнопки навигации, если они есть
if navigation_buttons:
keyboard.row(*navigation_buttons)
# Кнопка "Назад"
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
keyboard.row(home_button)
return keyboard.as_markup()
@@ -143,7 +148,11 @@ def create_keyboard_for_ban_reason():
builder.add(types.KeyboardButton(text="Спам"))
builder.add(types.KeyboardButton(text="Заебал стикерами"))
builder.row(types.KeyboardButton(text="Реклама здесь: @kerrad1 "))
builder.row(types.KeyboardButton(text="Тема с лагерями: https://vk.com/topic-75343895_50049913"))
builder.row(
types.KeyboardButton(
text="Тема с лагерями: https://vk.com/topic-75343895_50049913"
)
)
builder.row(types.KeyboardButton(text="Отменить"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup
@@ -173,12 +182,12 @@ def get_main_keyboard():
# Первая строка: Высказаться и послушать
builder.row(
types.KeyboardButton(text="🎤Высказаться"),
types.KeyboardButton(text="🎧Послушать")
types.KeyboardButton(text="🎧Послушать"),
)
# Вторая строка: сбросить прослушивания и узнать эмодзи
builder.row(
types.KeyboardButton(text="🔄Сбросить прослушивания"),
types.KeyboardButton(text="😊Узнать эмодзи")
types.KeyboardButton(text="😊Узнать эмодзи"),
)
# Третья строка: Вернуться в меню
builder.row(types.KeyboardButton(text="Отменить"))
@@ -188,11 +197,7 @@ def get_main_keyboard():
def get_reply_keyboard_for_voice():
builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(
text="Сохранить", callback_data="save")
)
builder.row(types.InlineKeyboardButton(
text="Удалить", callback_data="delete")
)
builder.row(types.InlineKeyboardButton(text="Сохранить", callback_data="save"))
builder.row(types.InlineKeyboardButton(text="Удалить", callback_data="delete"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup

View File

@@ -6,22 +6,25 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.strategy import FSMStrategy
from helper_bot.handlers.admin import admin_router
from helper_bot.handlers.callback import callback_router
from helper_bot.handlers.group import group_router
from helper_bot.handlers.private import private_router
from helper_bot.handlers.voice import VoiceHandlers
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.dependencies_middleware import \
DependenciesMiddleware
from helper_bot.middlewares.metrics_middleware import (ErrorMetricsMiddleware,
MetricsMiddleware)
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.middlewares.metrics_middleware import (
ErrorMetricsMiddleware,
MetricsMiddleware,
)
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
from helper_bot.server_prometheus import (start_metrics_server,
stop_metrics_server)
from helper_bot.server_prometheus import start_metrics_server, stop_metrics_server
async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0):
async def start_bot_with_retry(
bot: Bot, dp: Dispatcher, max_retries: int = 5, base_delay: float = 1.0
):
"""Запуск бота с автоматическим перезапуском при сетевых ошибках"""
for attempt in range(max_retries):
try:
@@ -30,14 +33,21 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
break
except Exception as e:
error_msg = str(e).lower()
if any(keyword in error_msg for keyword in ['network', 'disconnected', 'timeout', 'connection']):
if any(
keyword in error_msg
for keyword in ["network", "disconnected", "timeout", "connection"]
):
if attempt < max_retries - 1:
delay = base_delay * (2 ** attempt) # Exponential backoff
logging.warning(f"Сетевая ошибка при запуске бота: {e}. Повтор через {delay:.1f}с (попытка {attempt + 1}/{max_retries})")
delay = base_delay * (2**attempt) # Exponential backoff
logging.warning(
f"Сетевая ошибка при запуске бота: {e}. Повтор через {delay:.1f}с (попытка {attempt + 1}/{max_retries})"
)
await asyncio.sleep(delay)
continue
else:
logging.error(f"Превышено максимальное количество попыток запуска бота: {e}")
logging.error(
f"Превышено максимальное количество попыток запуска бота: {e}"
)
raise
else:
logging.error(f"Критическая ошибка при запуске бота: {e}")
@@ -45,27 +55,33 @@ async def start_bot_with_retry(bot: Bot, dp: Dispatcher, max_retries: int = 5, b
async def start_bot(bdf):
token = bdf.settings['Telegram']['bot_token']
bot = Bot(token=token, default=DefaultBotProperties(
parse_mode='HTML',
link_preview_is_disabled=bdf.settings['Telegram']['preview_link']
), timeout=60.0) # Увеличиваем timeout для стабильности
token = bdf.settings["Telegram"]["bot_token"]
bot = Bot(
token=token,
default=DefaultBotProperties(
parse_mode="HTML",
link_preview_is_disabled=bdf.settings["Telegram"]["preview_link"],
),
timeout=60.0,
) # Увеличиваем timeout для стабильности
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER)
# ✅ Оптимизированная регистрация middleware
dp.update.outer_middleware(DependenciesMiddleware())
dp.update.outer_middleware(MetricsMiddleware())
dp.update.outer_middleware(BlacklistMiddleware())
dp.update.outer_middleware(RateLimitMiddleware())
# Создаем экземпляр VoiceHandlers
voice_handlers = VoiceHandlers(bdf, bdf.settings)
voice_router = voice_handlers.router
# Middleware уже добавлены на уровне dispatcher
dp.include_routers(admin_router, private_router, callback_router, group_router, voice_router)
dp.include_routers(
admin_router, private_router, callback_router, group_router, voice_router
)
# Добавляем обработчик завершения для корректного закрытия
@dp.shutdown()
async def on_shutdown():
@@ -75,20 +91,20 @@ async def start_bot(bdf):
logging.info("Bot session closed successfully")
except Exception as e:
logging.error(f"Error closing bot session during shutdown: {e}")
await bot.delete_webhook(drop_pending_updates=True)
# Запускаем HTTP сервер для метрик параллельно с ботом
metrics_host = bdf.settings.get('Metrics', {}).get('host', '0.0.0.0')
metrics_port = bdf.settings.get('Metrics', {}).get('port', 8080)
metrics_host = bdf.settings.get("Metrics", {}).get("host", "0.0.0.0")
metrics_port = bdf.settings.get("Metrics", {}).get("port", 8080)
try:
# Запускаем метрики сервер
await start_metrics_server(metrics_host, metrics_port)
logging.info(f"✅ Метрики сервер запущен на {metrics_host}:{metrics_port}")
logging.info("✅ Метрики будут обновляться в реальном времени через middleware")
# Запускаем бота с retry логикой
await start_bot_with_retry(bot, dp)
@@ -102,7 +118,7 @@ async def start_bot(bdf):
await stop_metrics_server()
except Exception as e:
logging.error(f"Error stopping metrics server: {e}")
# Закрываем сессию бота
try:
await bot.session.close()

View File

@@ -7,19 +7,21 @@ from aiogram.types import Message
class AlbumGetter:
"""Вспомогательный класс для получения полной медиагруппы из middleware"""
def __init__(self, album_data: Dict[str, Any], media_group_id: str, event: asyncio.Event):
def __init__(
self, album_data: Dict[str, Any], media_group_id: str, event: asyncio.Event
):
self.album_data = album_data
self.media_group_id = media_group_id
self.event = event
async def get_album(self, timeout: float = 10.0) -> Optional[List[Message]]:
"""
Ждет полную медиагруппу и возвращает ее.
Args:
timeout: Максимальное время ожидания в секундах
Returns:
Список сообщений медиагруппы или None при таймауте
"""
@@ -38,11 +40,11 @@ class AlbumMiddleware(BaseMiddleware):
Собирает все сообщения одной медиа группы и передает их как album в data.
Не блокирует handler - сразу вызывает его, а полную медиагруппу передает через Event.
"""
def __init__(self, latency: Union[int, float] = 5.0):
"""
Инициализация middleware.
Args:
latency: Задержка в секундах для сбора всех сообщений медиа группы
"""
@@ -54,43 +56,43 @@ class AlbumMiddleware(BaseMiddleware):
def collect_album_messages(self, event: Message) -> int:
"""
Собирает сообщения одной медиа группы.
Args:
event: Сообщение для обработки
Returns:
Количество сообщений в текущей медиа группе
"""
if not event.media_group_id:
return 0
if event.media_group_id not in self.album_data:
self.album_data[event.media_group_id] = {"messages": []}
self.album_data[event.media_group_id]["messages"].append(event)
return len(self.album_data[event.media_group_id]["messages"])
async def _collect_album_background(self, media_group_id: str) -> None:
"""
Фоновая задача для сбора всех сообщений медиагруппы.
Args:
media_group_id: ID медиагруппы для сбора
"""
try:
await asyncio.sleep(self.latency)
if media_group_id not in self.album_data:
return
# Получаем текущий список сообщений
album_messages = self.album_data[media_group_id]["messages"].copy()
album_messages.sort(key=lambda x: x.message_id)
# Сохраняем собранную медиагруппу и уведомляем через Event
self.album_data[media_group_id]["collected_album"] = album_messages
self.album_data[media_group_id]["event"].set()
# Очищаем данные после небольшой задержки (чтобы handler успел получить album)
await asyncio.sleep(1.0)
if media_group_id in self.album_data:
@@ -114,24 +116,24 @@ class AlbumMiddleware(BaseMiddleware):
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
"""
Основная логика middleware.
Для медиагрупп: сразу вызывает handler, передавая Event для получения полной медиагруппы.
Для обычных сообщений: сразу вызывает handler.
Args:
handler: Обработчик события
event: Событие (сообщение)
data: Данные для передачи в обработчик
Returns:
Результат выполнения обработчика
"""
if not event.media_group_id:
return await handler(event, data)
media_group_id = event.media_group_id
message_id = event.message_id
# Если это первое сообщение медиагруппы - создаем структуру данных
is_first_message = False
if media_group_id not in self.album_data:
@@ -141,27 +143,25 @@ class AlbumMiddleware(BaseMiddleware):
"messages": [],
"event": album_event,
"task": None,
"first_message_id": message_id
"first_message_id": message_id,
}
# Запускаем фоновую задачу для сбора медиагруппы
task = asyncio.create_task(self._collect_album_background(media_group_id))
self.album_data[media_group_id]["task"] = task
# Добавляем сообщение в медиагруппу
self.album_data[media_group_id]["messages"].append(event)
# Обрабатываем только первое сообщение медиагруппы
if not is_first_message:
# Для остальных сообщений просто возвращаемся, не вызывая handler
return
# Передаем объект-геттер в data, чтобы handler мог получить полную медиагруппу
album_getter = AlbumGetter(
self.album_data,
media_group_id,
self.album_data[media_group_id]["event"]
self.album_data, media_group_id, self.album_data[media_group_id]["event"]
)
data["album_getter"] = album_getter
# Сразу вызываем handler только для первого сообщения (не блокируем)
return await handler(event, data)

View File

@@ -4,6 +4,7 @@ from typing import Any, Dict
from aiogram import BaseMiddleware, types
from aiogram.types import CallbackQuery, Message, TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
@@ -12,47 +13,61 @@ BotDB = bdf.get_db()
class BlacklistMiddleware(BaseMiddleware):
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
# Проверяем тип события и получаем пользователя
user = None
if isinstance(event, Message):
user = event.from_user
elif isinstance(event, CallbackQuery):
user = event.from_user
# Если это не сообщение или callback, пропускаем проверку
if not user:
return await handler(event, data)
logger.info(f'Вызов BlacklistMiddleware для пользователя {user.username}')
logger.info(f"Вызов BlacklistMiddleware для пользователя {user.username}")
# Используем асинхронную версию для предотвращения блокировки
if await BotDB.check_user_in_blacklist(user.id):
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} заблокирован!')
logger.info(
f"BlacklistMiddleware результат для пользователя: {user.username} заблокирован!"
)
user_info = await BotDB.get_blacklist_users_by_id(user.id)
# Экранируем потенциально проблемные символы
reason = html.escape(str(user_info[1])) if user_info and user_info[1] else "Не указана"
reason = (
html.escape(str(user_info[1]))
if user_info and user_info[1]
else "Не указана"
)
# Преобразуем timestamp в человекочитаемый формат
if user_info and user_info[2]:
try:
timestamp = int(user_info[2])
date_unban = datetime.fromtimestamp(timestamp).strftime("%d-%m-%Y %H:%M")
date_unban = datetime.fromtimestamp(timestamp).strftime(
"%d-%m-%Y %H:%M"
)
except (ValueError, TypeError):
date_unban = "Не указана"
else:
date_unban = "Не указана"
# Отправляем сообщение в зависимости от типа события
if isinstance(event, Message):
await event.answer(
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}")
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}"
)
elif isinstance(event, CallbackQuery):
await event.answer(
f"<b>Ты заблокирован.</b>\n<b>Причина блокировки:</b> {reason}\n<b>Дата разбана:</b> {date_unban}",
show_alert=True)
show_alert=True,
)
return False
logger.info(f'BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен')
logger.info(
f"BlacklistMiddleware результат для пользователя: {user.username} доступ разрешен"
)
return await handler(event, data)

View File

@@ -2,30 +2,35 @@ from typing import Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
class DependenciesMiddleware(BaseMiddleware):
"""Универсальная middleware для внедрения зависимостей во все хендлеры"""
async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any:
async def __call__(
self, handler, event: TelegramObject, data: Dict[str, Any]
) -> Any:
try:
# Получаем глобальные зависимости
bdf = get_global_instance()
# Внедряем зависимости в data для MagicData
if 'bot_db' not in data:
data['bot_db'] = bdf.get_db()
if 'settings' not in data:
data['settings'] = bdf.settings
data['bot'] = data.get('bot')
data['dp'] = data.get('dp')
logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}")
if "bot_db" not in data:
data["bot_db"] = bdf.get_db()
if "settings" not in data:
data["settings"] = bdf.settings
data["bot"] = data.get("bot")
data["dp"] = data.get("dp")
logger.debug(
f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}"
)
except Exception as e:
logger.error(f"Ошибка в DependenciesMiddleware: {e}")
# Не прерываем выполнение, продолжаем без зависимостей
return await handler(event, data)

View File

@@ -16,16 +16,16 @@ from ..utils.metrics import metrics
# Import button command mapping
try:
from ..handlers.admin.constants import (ADMIN_BUTTON_COMMAND_MAPPING,
ADMIN_COMMANDS)
from ..handlers.admin.constants import ADMIN_BUTTON_COMMAND_MAPPING, ADMIN_COMMANDS
from ..handlers.callback.constants import CALLBACK_COMMAND_MAPPING
from ..handlers.private.constants import BUTTON_COMMAND_MAPPING
from ..handlers.voice.constants import \
BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING
from ..handlers.voice.constants import \
CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING
from ..handlers.voice.constants import \
COMMAND_MAPPING as VOICE_COMMAND_MAPPING
from ..handlers.voice.constants import (
BUTTON_COMMAND_MAPPING as VOICE_BUTTON_COMMAND_MAPPING,
)
from ..handlers.voice.constants import (
CALLBACK_COMMAND_MAPPING as VOICE_CALLBACK_COMMAND_MAPPING,
)
from ..handlers.voice.constants import COMMAND_MAPPING as VOICE_COMMAND_MAPPING
except ImportError:
# Fallback if constants not available
BUTTON_COMMAND_MAPPING = {}
@@ -39,40 +39,49 @@ except ImportError:
class MetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for automatic collection of ALL available metrics."""
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
# Metrics update intervals
self.last_active_users_update = 0
self.active_users_update_interval = 300 # 5 minutes
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Process event and collect comprehensive metrics."""
# Update active users periodically
current_time = time.time()
if current_time - self.last_active_users_update > self.active_users_update_interval:
if (
current_time - self.last_active_users_update
> self.active_users_update_interval
):
await self._update_active_users_metric()
self.last_active_users_update = current_time
# Extract command and event info
command_info = None
event_metrics = {}
# Process event based on type
if hasattr(event, 'message') and event.message:
event_metrics = await self._record_comprehensive_message_metrics(event.message)
if hasattr(event, "message") and event.message:
event_metrics = await self._record_comprehensive_message_metrics(
event.message
)
command_info = self._extract_command_info_with_fallback(event.message)
elif hasattr(event, 'callback_query') and event.callback_query:
event_metrics = await self._record_comprehensive_callback_metrics(event.callback_query)
command_info = self._extract_callback_command_info_with_fallback(event.callback_query)
elif hasattr(event, "callback_query") and event.callback_query:
event_metrics = await self._record_comprehensive_callback_metrics(
event.callback_query
)
command_info = self._extract_callback_command_info_with_fallback(
event.callback_query
)
elif isinstance(event, Message):
event_metrics = await self._record_comprehensive_message_metrics(event)
command_info = self._extract_command_info_with_fallback(event)
@@ -81,107 +90,106 @@ class MetricsMiddleware(BaseMiddleware):
command_info = self._extract_callback_command_info_with_fallback(event)
else:
event_metrics = await self._record_unknown_event_metrics(event)
if command_info:
self.logger.info(f"📊 Command info extracted: {command_info}")
else:
self.logger.warning(f"📊 No command info extracted for event type: {type(event).__name__}")
self.logger.warning(
f"📊 No command info extracted for event type: {type(event).__name__}"
)
# Execute handler with comprehensive timing and metrics
start_time = time.time()
try:
result = await handler(event, data)
duration = time.time() - start_time
# Record successful execution metrics
handler_name = self._get_handler_name(handler)
metrics.record_method_duration(
handler_name,
duration,
"handler",
"success"
)
metrics.record_method_duration(handler_name, duration, "handler", "success")
if command_info:
metrics.record_command(
command_info['command'],
command_info['handler_type'],
command_info['user_type'],
"success"
command_info["command"],
command_info["handler_type"],
command_info["user_type"],
"success",
)
await self._record_additional_success_metrics(event, event_metrics, handler_name)
await self._record_additional_success_metrics(
event, event_metrics, handler_name
)
return result
except Exception as e:
duration = time.time() - start_time
# Record error metrics
handler_name = self._get_handler_name(handler)
error_type = type(e).__name__
metrics.record_method_duration(
handler_name,
duration,
"handler",
"error"
)
metrics.record_error(
error_type,
"handler",
handler_name
)
metrics.record_method_duration(handler_name, duration, "handler", "error")
metrics.record_error(error_type, "handler", handler_name)
if command_info:
metrics.record_command(
command_info['command'],
command_info['handler_type'],
command_info['user_type'],
"error"
command_info["command"],
command_info["handler_type"],
command_info["user_type"],
"error",
)
await self._record_additional_error_metrics(event, event_metrics, handler_name, error_type)
await self._record_additional_error_metrics(
event, event_metrics, handler_name, error_type
)
raise
finally:
# Record middleware execution time
middleware_duration = time.time() - start_time
metrics.record_middleware("MetricsMiddleware", middleware_duration, "success")
metrics.record_middleware(
"MetricsMiddleware", middleware_duration, "success"
)
async def _update_active_users_metric(self):
"""Periodically update active users metric from database."""
try:
#TODO: Должна подключаться к базе данных, а не к глобальному экземпляру
# TODO: Должна подключаться к базе данных, а не к глобальному экземпляру
from ..utils.base_dependency_factory import get_global_instance
bdf = get_global_instance()
bot_db = bdf.get_db()
# Используем правильные методы AsyncBotDB для выполнения запросов
# Простой подсчет всех пользователей в базе
total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users"
total_users_result = await bot_db.fetch_one(total_users_query)
total_users = total_users_result['total'] if total_users_result else 1
total_users = total_users_result["total"] if total_users_result else 1
# Подсчет активных за день пользователей (date_changed - это Unix timestamp)
daily_users_query = "SELECT COUNT(DISTINCT user_id) as daily FROM our_users WHERE date_changed > (strftime('%s', 'now', '-1 day'))"
daily_users_result = await bot_db.fetch_one(daily_users_query)
daily_users = daily_users_result['daily'] if daily_users_result else 1
daily_users = daily_users_result["daily"] if daily_users_result else 1
# Устанавливаем метрики с правильными лейблами
metrics.set_active_users(daily_users, "daily")
metrics.set_total_users(total_users)
self.logger.info(f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)")
self.logger.info(
f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)"
)
except Exception as e:
self.logger.error(f"❌ Failed to update users metric: {e}")
# Устанавливаем 1 как fallback
metrics.set_active_users(1, "daily")
metrics.set_total_users(1)
async def _record_comprehensive_message_metrics(self, message: Message) -> Dict[str, Any]:
async def _record_comprehensive_message_metrics(
self, message: Message
) -> Dict[str, Any]:
"""Record comprehensive message metrics."""
# Determine message type
message_type = "text"
@@ -199,7 +207,7 @@ class MetricsMiddleware(BaseMiddleware):
message_type = "sticker"
elif message.animation:
message_type = "animation"
# Determine chat type
chat_type = "private"
if message.chat.type == ChatType.GROUP:
@@ -208,129 +216,139 @@ class MetricsMiddleware(BaseMiddleware):
chat_type = "supergroup"
elif message.chat.type == ChatType.CHANNEL:
chat_type = "channel"
# Record message processing
metrics.record_message(message_type, chat_type, "message_handler")
return {
'message_type': message_type,
'chat_type': chat_type,
'user_id': message.from_user.id if message.from_user else None,
'is_bot': message.from_user.is_bot if message.from_user else False
"message_type": message_type,
"chat_type": chat_type,
"user_id": message.from_user.id if message.from_user else None,
"is_bot": message.from_user.is_bot if message.from_user else False,
}
async def _record_comprehensive_callback_metrics(self, callback: CallbackQuery) -> Dict[str, Any]:
async def _record_comprehensive_callback_metrics(
self, callback: CallbackQuery
) -> Dict[str, Any]:
"""Record comprehensive callback metrics."""
# Record callback message
metrics.record_message("callback_query", "callback", "callback_handler")
return {
'callback_data': callback.data,
'user_id': callback.from_user.id if callback.from_user else None,
'is_bot': callback.from_user.is_bot if callback.from_user else False
"callback_data": callback.data,
"user_id": callback.from_user.id if callback.from_user else None,
"is_bot": callback.from_user.is_bot if callback.from_user else False,
}
async def _record_unknown_event_metrics(self, event: TelegramObject) -> Dict[str, Any]:
async def _record_unknown_event_metrics(
self, event: TelegramObject
) -> Dict[str, Any]:
"""Record metrics for unknown event types."""
# Record unknown event
metrics.record_message("unknown", "unknown", "unknown_handler")
return {
'event_type': type(event).__name__,
'event_data': str(event)[:100] if hasattr(event, '__str__') else "unknown"
"event_type": type(event).__name__,
"event_data": str(event)[:100] if hasattr(event, "__str__") else "unknown",
}
def _extract_command_info_with_fallback(self, message: Message) -> Optional[Dict[str, str]]:
def _extract_command_info_with_fallback(
self, message: Message
) -> Optional[Dict[str, str]]:
"""Extract command information with fallback for unknown commands."""
if not message.text:
return None
# Check if it's a slash command
if message.text.startswith('/'):
command_name = message.text.split()[0][1:] # Remove '/' and get command name
if message.text.startswith("/"):
command_name = message.text.split()[0][
1:
] # Remove '/' and get command name
# Check if it's an admin command
if command_name in ADMIN_COMMANDS:
return {
'command': ADMIN_COMMANDS[command_name],
'user_type': "admin" if message.from_user else "unknown",
'handler_type': "admin_handler"
"command": ADMIN_COMMANDS[command_name],
"user_type": "admin" if message.from_user else "unknown",
"handler_type": "admin_handler",
}
# Check if it's a voice bot command
elif command_name in VOICE_COMMAND_MAPPING:
return {
'command': VOICE_COMMAND_MAPPING[command_name],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "voice_command_handler"
"command": VOICE_COMMAND_MAPPING[command_name],
"user_type": "user" if message.from_user else "unknown",
"handler_type": "voice_command_handler",
}
else:
# FALLBACK: Record unknown command
return {
'command': command_name,
'user_type': "user" if message.from_user else "unknown",
'handler_type': "unknown_command_handler"
"command": command_name,
"user_type": "user" if message.from_user else "unknown",
"handler_type": "unknown_command_handler",
}
# Check if it's an admin button click
if message.text in ADMIN_BUTTON_COMMAND_MAPPING:
return {
'command': ADMIN_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "admin" if message.from_user else "unknown",
'handler_type': "admin_button_handler"
"command": ADMIN_BUTTON_COMMAND_MAPPING[message.text],
"user_type": "admin" if message.from_user else "unknown",
"handler_type": "admin_button_handler",
}
# Check if it's a regular button click (text button)
if message.text in BUTTON_COMMAND_MAPPING:
return {
'command': BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "button_handler"
"command": BUTTON_COMMAND_MAPPING[message.text],
"user_type": "user" if message.from_user else "unknown",
"handler_type": "button_handler",
}
# Check if it's a voice bot button click
if message.text in VOICE_BUTTON_COMMAND_MAPPING:
return {
'command': VOICE_BUTTON_COMMAND_MAPPING[message.text],
'user_type': "user" if message.from_user else "unknown",
'handler_type': "voice_button_handler"
"command": VOICE_BUTTON_COMMAND_MAPPING[message.text],
"user_type": "user" if message.from_user else "unknown",
"handler_type": "voice_button_handler",
}
# FALLBACK: Record ANY text message as a command for metrics
if message.text and len(message.text.strip()) > 0:
return {
'command': f"text",
'user_type': "user" if message.from_user else "unknown",
'handler_type': "text_message_handler"
"command": f"text",
"user_type": "user" if message.from_user else "unknown",
"handler_type": "text_message_handler",
}
return None
def _extract_callback_command_info_with_fallback(self, callback: CallbackQuery) -> Optional[Dict[str, str]]:
def _extract_callback_command_info_with_fallback(
self, callback: CallbackQuery
) -> Optional[Dict[str, str]]:
"""Extract callback command information with fallback."""
if not callback.data:
return None
# Extract command from callback data
parts = callback.data.split(':', 1)
parts = callback.data.split(":", 1)
if parts and parts[0] in CALLBACK_COMMAND_MAPPING:
return {
'command': CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "callback_handler"
"command": CALLBACK_COMMAND_MAPPING[parts[0]],
"user_type": "user" if callback.from_user else "unknown",
"handler_type": "callback_handler",
}
# Check if it's a voice bot callback
if parts and parts[0] in VOICE_CALLBACK_COMMAND_MAPPING:
return {
'command': VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "voice_callback_handler"
"command": VOICE_CALLBACK_COMMAND_MAPPING[parts[0]],
"user_type": "user" if callback.from_user else "unknown",
"handler_type": "voice_callback_handler",
}
# FALLBACK: Record unknown callback
if parts:
callback_data = parts[0]
# Группируем похожие callback'и по паттернам
if callback_data.startswith("ban_") and callback_data[4:].isdigit():
# callback_ban_123456 -> callback_ban
@@ -341,60 +359,69 @@ class MetricsMiddleware(BaseMiddleware):
else:
# Для остальных неизвестных callback'ов оставляем как есть
command = f"callback_{callback_data[:20]}"
return {
'command': command,
'user_type': "user" if callback.from_user else "unknown",
'handler_type': "unknown_callback_handler"
"command": command,
"user_type": "user" if callback.from_user else "unknown",
"handler_type": "unknown_callback_handler",
}
return None
async def _record_additional_success_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str):
async def _record_additional_success_metrics(
self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str
):
"""Record additional success metrics."""
try:
# Record rate limiting metrics (if applicable)
if hasattr(event, 'from_user') and event.from_user:
if hasattr(event, "from_user") and event.from_user:
# You can add rate limiting logic here
pass
# Record user activity metrics
if event_metrics.get('user_id'):
if event_metrics.get("user_id"):
# This could trigger additional user activity tracking
pass
except Exception as e:
self.logger.error(f"❌ Error recording additional success metrics: {e}")
async def _record_additional_error_metrics(self, event: TelegramObject, event_metrics: Dict[str, Any], handler_name: str, error_type: str):
async def _record_additional_error_metrics(
self,
event: TelegramObject,
event_metrics: Dict[str, Any],
handler_name: str,
error_type: str,
):
"""Record additional error metrics."""
try:
# Record specific error context
if event_metrics.get('user_id'):
if event_metrics.get("user_id"):
# You can add user-specific error tracking here
pass
except Exception as e:
self.logger.error(f"❌ Error recording additional error metrics: {e}")
def _get_handler_name(self, handler: Callable) -> str:
"""Extract handler name efficiently."""
# Check various ways to get handler name
if hasattr(handler, '__name__') and handler.__name__ != '<lambda>':
if hasattr(handler, "__name__") and handler.__name__ != "<lambda>":
return handler.__name__
elif hasattr(handler, '__qualname__') and handler.__qualname__ != '<lambda>':
elif hasattr(handler, "__qualname__") and handler.__qualname__ != "<lambda>":
return handler.__qualname__
elif hasattr(handler, 'callback') and hasattr(handler.callback, '__name__'):
elif hasattr(handler, "callback") and hasattr(handler.callback, "__name__"):
return handler.callback.__name__
elif hasattr(handler, 'view') and hasattr(handler.view, '__name__'):
elif hasattr(handler, "view") and hasattr(handler.view, "__name__"):
return handler.view.__name__
else:
# Пытаемся получить имя из строкового представления
handler_str = str(handler)
if 'function' in handler_str:
if "function" in handler_str:
# Извлекаем имя функции из строки
import re
match = re.search(r'function\s+(\w+)', handler_str)
match = re.search(r"function\s+(\w+)", handler_str)
if match:
return match.group(1)
return "unknown"
@@ -402,83 +429,77 @@ class MetricsMiddleware(BaseMiddleware):
class DatabaseMetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for database operation metrics."""
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Process event and collect database metrics."""
# Check if this handler involves database operations
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
handler_name = handler.__name__ if hasattr(handler, "__name__") else "unknown"
# Record middleware start
start_time = time.time()
try:
result = await handler(event, data)
# Record successful database operation
duration = time.time() - start_time
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "success")
return result
except Exception as e:
# Record failed database operation
duration = time.time() - start_time
metrics.record_middleware("DatabaseMetricsMiddleware", duration, "error")
metrics.record_error(
type(e).__name__,
"database_middleware",
handler_name
)
metrics.record_error(type(e).__name__, "database_middleware", handler_name)
raise
class ErrorMetricsMiddleware(BaseMiddleware):
"""Enhanced middleware for error tracking and metrics."""
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Process event and collect error metrics."""
# Record middleware start
start_time = time.time()
try:
result = await handler(event, data)
# Record successful error handling
duration = time.time() - start_time
metrics.record_middleware("ErrorMetricsMiddleware", duration, "success")
return result
except Exception as e:
# Record error metrics
duration = time.time() - start_time
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
metrics.record_middleware("ErrorMetricsMiddleware", duration, "error")
metrics.record_error(
type(e).__name__,
"error_middleware",
handler_name
handler_name = (
handler.__name__ if hasattr(handler, "__name__") else "unknown"
)
metrics.record_middleware("ErrorMetricsMiddleware", duration, "error")
metrics.record_error(type(e).__name__, "error_middleware", handler_name)
raise

View File

@@ -1,42 +1,43 @@
"""
Middleware для автоматического применения rate limiting ко всем входящим сообщениям
"""
from typing import Any, Awaitable, Callable, Dict, Union
from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from aiogram.types import (CallbackQuery, ChatMemberUpdated, InlineQuery,
Message, Update)
from aiogram.types import CallbackQuery, ChatMemberUpdated, InlineQuery, Message, Update
from helper_bot.utils.rate_limiter import telegram_rate_limiter
from logs.custom_logger import logger
class RateLimitMiddleware(BaseMiddleware):
"""Middleware для автоматического rate limiting входящих сообщений"""
def __init__(self):
super().__init__()
self.rate_limiter = telegram_rate_limiter
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated],
data: Dict[str, Any]
data: Dict[str, Any],
) -> Any:
"""Обрабатывает событие с rate limiting"""
# Извлекаем сообщение из Update
message = None
if isinstance(event, Update):
message = event.message
elif isinstance(event, Message):
message = event
# Применяем rate limiting только к сообщениям
if message is not None:
chat_id = message.chat.id
# Обертываем handler в rate limiting
async def rate_limited_handler():
try:
@@ -46,13 +47,11 @@ class RateLimitMiddleware(BaseMiddleware):
# Middleware не должен перехватывать эти ошибки,
# пусть их обрабатывает rate_limiter в функциях отправки
raise
# Применяем rate limiting к handler
return await self.rate_limiter.send_with_rate_limit(
rate_limited_handler,
chat_id
rate_limited_handler, chat_id
)
else:
# Для других типов событий просто вызываем handler
return await handler(event, data)

View File

@@ -12,7 +12,6 @@ class BulkTextMiddleware(BaseMiddleware):
self.latency = latency
self.texts = defaultdict(list)
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
"""
Main middleware logic.
@@ -37,10 +36,9 @@ class BulkTextMiddleware(BaseMiddleware):
# # Sort the album messages by message_id and add to data
msg_texts = self.texts[key]
msg_texts.sort(key=lambda x: x.message_id)
data["texts"] = ''.join([msg.text for msg in msg_texts])
data["texts"] = "".join([msg.text for msg in msg_texts])
#
# Remove the media group from tracking to free up memory
del self.texts[key]
# # Call the original event handler
return await handler(event, data)

View File

@@ -1,4 +1,3 @@
"""
HTTP server for metrics endpoint integration with centralized Prometheus monitoring.
Provides /metrics endpoint and health check for the bot.
@@ -17,53 +16,48 @@ try:
except ImportError:
# Fallback для случаев, когда custom_logger недоступен
import logging
logger = logging.getLogger(__name__)
class MetricsServer:
"""HTTP server for Prometheus metrics and health checks."""
def __init__(self, host: str = '0.0.0.0', port: int = 8080):
def __init__(self, host: str = "0.0.0.0", port: int = 8080):
self.host = host
self.port = port
self.app = web.Application()
self.runner: Optional[web.AppRunner] = None
self.site: Optional[web.TCPSite] = None
# Настраиваем роуты
self.app.router.add_get('/metrics', self.metrics_handler)
self.app.router.add_get('/health', self.health_handler)
self.app.router.add_get("/metrics", self.metrics_handler)
self.app.router.add_get("/health", self.health_handler)
async def metrics_handler(self, request: web.Request) -> web.Response:
"""Handle /metrics endpoint for Prometheus scraping."""
try:
logger.debug("Generating metrics...")
# Проверяем, что metrics доступен
if not metrics:
logger.error("Metrics object is not available")
return web.Response(
text="Metrics not available",
status=500
)
return web.Response(text="Metrics not available", status=500)
# Генерируем метрики в формате Prometheus
metrics_data = metrics.get_metrics()
logger.debug(f"Generated metrics: {len(metrics_data)} bytes")
return web.Response(
body=metrics_data,
content_type='text/plain; version=0.0.4'
body=metrics_data, content_type="text/plain; version=0.0.4"
)
except Exception as e:
logger.error(f"Error generating metrics: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return web.Response(
text=f"Error generating metrics: {e}",
status=500
)
return web.Response(text=f"Error generating metrics: {e}", status=500)
async def health_handler(self, request: web.Request) -> web.Response:
"""Handle /health endpoint for health checks."""
try:
@@ -71,77 +65,72 @@ class MetricsServer:
if not metrics:
return web.Response(
text="ERROR: Metrics not available",
content_type='text/plain',
status=503
content_type="text/plain",
status=503,
)
# Проверяем, что можем получить метрики
try:
metrics_data = metrics.get_metrics()
if not metrics_data:
return web.Response(
text="ERROR: Empty metrics",
content_type='text/plain',
status=503
content_type="text/plain",
status=503,
)
except Exception as e:
return web.Response(
text=f"ERROR: Metrics generation failed: {e}",
content_type='text/plain',
status=503
content_type="text/plain",
status=503,
)
return web.Response(
text="OK",
content_type='text/plain',
status=200
)
return web.Response(text="OK", content_type="text/plain", status=200)
except Exception as e:
logger.error(f"Health check failed: {e}")
return web.Response(
text=f"ERROR: Health check failed: {e}",
content_type='text/plain',
status=500
content_type="text/plain",
status=500,
)
async def start(self) -> None:
"""Start the HTTP server."""
try:
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.host, self.port)
await self.site.start()
logger.info(f"Metrics server started on {self.host}:{self.port}")
logger.info("Available endpoints:")
logger.info(f" - /metrics - Prometheus metrics")
logger.info(f" - /health - Health check")
except Exception as e:
logger.error(f"Failed to start metrics server: {e}")
raise
async def stop(self) -> None:
"""Stop the HTTP server."""
try:
if self.site:
await self.site.stop()
logger.info("Metrics server site stopped")
if self.runner:
await self.runner.cleanup()
logger.info("Metrics server runner cleaned up")
except Exception as e:
logger.error(f"Error stopping metrics server: {e}")
async def __aenter__(self):
"""Async context manager entry."""
await self.start()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.stop()
@@ -151,7 +140,9 @@ class MetricsServer:
metrics_server: Optional[MetricsServer] = None
async def start_metrics_server(host: str = '0.0.0.0', port: int = 8080) -> MetricsServer:
async def start_metrics_server(
host: str = "0.0.0.0", port: int = 8080
) -> MetricsServer:
"""Start metrics server and return instance."""
global metrics_server
metrics_server = MetricsServer(host, port)

View File

@@ -4,6 +4,7 @@ from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from helper_bot.utils.base_dependency_factory import get_global_instance
from logs.custom_logger import logger
@@ -15,17 +16,17 @@ class AutoUnbanScheduler:
Класс для автоматического разбана пользователей по истечении срока блокировки.
Запускается ежедневно в 5:00 по московскому времени.
"""
def __init__(self):
self.bdf = get_global_instance()
self.bot_db = self.bdf.get_db()
self.scheduler = AsyncIOScheduler()
self.bot = None # Будет установлен позже
def set_bot(self, bot):
"""Устанавливает экземпляр бота для отправки уведомлений"""
self.bot = bot
@track_time("auto_unban_users", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "auto_unban_users")
@db_query_time("auto_unban_users", "users", "mixed")
@@ -37,26 +38,32 @@ class AutoUnbanScheduler:
"""
try:
logger.info("Запуск автоматического разбана пользователей")
# Получаем текущий UNIX timestamp
current_timestamp = int(datetime.now().timestamp())
logger.info(f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}")
logger.info(
f"Поиск пользователей для разблокировки на timestamp: {current_timestamp}"
)
# Получаем список пользователей для разблокировки
users_to_unban = await self.bot_db.get_users_for_unblock_today(current_timestamp)
users_to_unban = await self.bot_db.get_users_for_unblock_today(
current_timestamp
)
if not users_to_unban:
logger.info("Нет пользователей для разблокировки сегодня")
return
logger.info(f"Найдено {len(users_to_unban)} пользователей для разблокировки")
logger.info(
f"Найдено {len(users_to_unban)} пользователей для разблокировки"
)
# Список для отслеживания результатов
success_count = 0
failed_count = 0
failed_users = []
# Разблокируем каждого пользователя
for user_id in users_to_unban:
try:
@@ -71,92 +78,99 @@ class AutoUnbanScheduler:
except Exception as e:
failed_count += 1
failed_users.append(f"{user_id}")
logger.error(f"Исключение при разблокировке пользователя {user_id}: {e}")
logger.error(
f"Исключение при разблокировке пользователя {user_id}: {e}"
)
# Формируем отчет
report = self._generate_report(success_count, failed_count, failed_users, users_to_unban)
report = self._generate_report(
success_count, failed_count, failed_users, users_to_unban
)
# Отправляем отчет в лог-канал
await self._send_report(report)
logger.info(f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}")
logger.info(
f"Автоматический разбан завершен. Успешно: {success_count}, Ошибок: {failed_count}"
)
except Exception as e:
error_msg = f"Критическая ошибка в автоматическом разбане: {e}"
logger.error(error_msg)
await self._send_error_report(error_msg)
def _generate_report(self, success_count: int, failed_count: int,
failed_users: list, all_users: dict) -> str:
def _generate_report(
self, success_count: int, failed_count: int, failed_users: list, all_users: dict
) -> str:
"""Генерирует отчет о результатах автоматического разбана"""
report = f"🤖 <b>Отчет об автоматическом разбане</b>\n\n"
report += f"📅 Дата: {datetime.now().strftime('%d.%m.%Y %H:%M')}\n"
report += f"✅ Успешно разблокировано: {success_count}\n"
report += f"❌ Ошибок: {failed_count}\n\n"
if success_count > 0:
report += "✅ <b>Разблокированные пользователи:</b>\n"
for user_id in all_users:
if str(user_id) not in failed_users:
report += f"• ID: {user_id}\n"
report += "\n"
if failed_users:
report += "❌ <b>Ошибки при разблокировке:</b>\n"
for user in failed_users:
report += f"{user}\n"
return report
@track_time("send_report", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "send_report")
async def _send_report(self, report: str):
"""Отправляет отчет в лог-канал"""
try:
if self.bot:
group_for_logs = self.bdf.settings['Telegram']['group_for_logs']
group_for_logs = self.bdf.settings["Telegram"]["group_for_logs"]
await self.bot.send_message(
chat_id=group_for_logs,
text=report,
parse_mode='HTML'
chat_id=group_for_logs, text=report, parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка при отправке отчета: {e}")
@track_time("send_error_report", "auto_unban_scheduler")
@track_errors("auto_unban_scheduler", "send_error_report")
async def _send_error_report(self, error_msg: str):
"""Отправляет отчет об ошибке в важный лог-канал"""
try:
if self.bot:
important_logs = self.bdf.settings['Telegram']['important_logs']
important_logs = self.bdf.settings["Telegram"]["important_logs"]
await self.bot.send_message(
chat_id=important_logs,
text=f"🚨 <b>Ошибка автоматического разбана</b>\n\n{error_msg}",
parse_mode='HTML'
parse_mode="HTML",
)
except Exception as e:
logger.error(f"Ошибка при отправке отчета об ошибке: {e}")
def start_scheduler(self):
"""Запускает планировщик задач"""
try:
# Добавляем задачу на ежедневное выполнение в 5:00 по Москве
self.scheduler.add_job(
self.auto_unban_users,
CronTrigger(hour=5, minute=0, timezone='Europe/Moscow'),
id='auto_unban_users',
name='Автоматический разбан пользователей',
replace_existing=True
CronTrigger(hour=5, minute=0, timezone="Europe/Moscow"),
id="auto_unban_users",
name="Автоматический разбан пользователей",
replace_existing=True,
)
# Запускаем планировщик
self.scheduler.start()
logger.info("Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве")
logger.info(
"Планировщик автоматического разбана запущен. Задача запланирована на 5:00 по Москве"
)
except Exception as e:
logger.error(f"Ошибка при запуске планировщика: {e}")
def stop_scheduler(self):
"""Останавливает планировщик задач"""
try:
@@ -165,7 +179,7 @@ class AutoUnbanScheduler:
logger.info("Планировщик автоматического разбана остановлен")
except Exception as e:
logger.error(f"Ошибка при остановке планировщика: {e}")
async def run_manual_unban(self):
"""Запускает разбан вручную (для тестирования)"""
logger.info("Запуск ручного разбана пользователей")

View File

@@ -2,21 +2,24 @@ import os
import sys
from typing import Optional
from database.async_db import AsyncBotDB
from dotenv import load_dotenv
from database.async_db import AsyncBotDB
from helper_bot.utils.s3_storage import S3StorageService
class BaseDependencyFactory:
def __init__(self):
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
env_path = os.path.join(project_dir, '.env')
project_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
env_path = os.path.join(project_dir, ".env")
if os.path.exists(env_path):
load_dotenv(env_path)
self.settings = {}
database_path = os.getenv('DATABASE_PATH', 'database/tg-bot-database.db')
database_path = os.getenv("DATABASE_PATH", "database/tg-bot-database.db")
if not os.path.isabs(database_path):
database_path = os.path.join(project_dir, database_path)
@@ -27,56 +30,61 @@ class BaseDependencyFactory:
def _load_settings_from_env(self):
"""Загружает настройки из переменных окружения."""
self.settings['Telegram'] = {
'bot_token': os.getenv('BOT_TOKEN', ''),
'listen_bot_token': os.getenv('LISTEN_BOT_TOKEN', ''),
'test_bot_token': os.getenv('TEST_BOT_TOKEN', ''),
'preview_link': self._parse_bool(os.getenv('PREVIEW_LINK', 'false')),
'main_public': os.getenv('MAIN_PUBLIC', ''),
'group_for_posts': self._parse_int(os.getenv('GROUP_FOR_POSTS', '0')),
'group_for_message': self._parse_int(os.getenv('GROUP_FOR_MESSAGE', '0')),
'group_for_logs': self._parse_int(os.getenv('GROUP_FOR_LOGS', '0')),
'important_logs': self._parse_int(os.getenv('IMPORTANT_LOGS', '0')),
'archive': self._parse_int(os.getenv('ARCHIVE', '0')),
'test_group': self._parse_int(os.getenv('TEST_GROUP', '0'))
self.settings["Telegram"] = {
"bot_token": os.getenv("BOT_TOKEN", ""),
"listen_bot_token": os.getenv("LISTEN_BOT_TOKEN", ""),
"test_bot_token": os.getenv("TEST_BOT_TOKEN", ""),
"preview_link": self._parse_bool(os.getenv("PREVIEW_LINK", "false")),
"main_public": os.getenv("MAIN_PUBLIC", ""),
"group_for_posts": self._parse_int(os.getenv("GROUP_FOR_POSTS", "0")),
"group_for_message": self._parse_int(os.getenv("GROUP_FOR_MESSAGE", "0")),
"group_for_logs": self._parse_int(os.getenv("GROUP_FOR_LOGS", "0")),
"important_logs": self._parse_int(os.getenv("IMPORTANT_LOGS", "0")),
"archive": self._parse_int(os.getenv("ARCHIVE", "0")),
"test_group": self._parse_int(os.getenv("TEST_GROUP", "0")),
}
self.settings['Settings'] = {
'logs': self._parse_bool(os.getenv('LOGS', 'false')),
'test': self._parse_bool(os.getenv('TEST', 'false'))
self.settings["Settings"] = {
"logs": self._parse_bool(os.getenv("LOGS", "false")),
"test": self._parse_bool(os.getenv("TEST", "false")),
}
self.settings['Metrics'] = {
'host': os.getenv('METRICS_HOST', '0.0.0.0'),
'port': self._parse_int(os.getenv('METRICS_PORT', '8080'))
self.settings["Metrics"] = {
"host": os.getenv("METRICS_HOST", "0.0.0.0"),
"port": self._parse_int(os.getenv("METRICS_PORT", "8080")),
}
self.settings['S3'] = {
'enabled': self._parse_bool(os.getenv('S3_ENABLED', 'false')),
'endpoint_url': os.getenv('S3_ENDPOINT_URL', ''),
'access_key': os.getenv('S3_ACCESS_KEY', ''),
'secret_key': os.getenv('S3_SECRET_KEY', ''),
'bucket_name': os.getenv('S3_BUCKET_NAME', ''),
'region': os.getenv('S3_REGION', 'us-east-1')
self.settings["S3"] = {
"enabled": self._parse_bool(os.getenv("S3_ENABLED", "false")),
"endpoint_url": os.getenv("S3_ENDPOINT_URL", ""),
"access_key": os.getenv("S3_ACCESS_KEY", ""),
"secret_key": os.getenv("S3_SECRET_KEY", ""),
"bucket_name": os.getenv("S3_BUCKET_NAME", ""),
"region": os.getenv("S3_REGION", "us-east-1"),
}
def _init_s3_storage(self):
"""Инициализирует S3StorageService если S3 включен."""
self.s3_storage = None
if self.settings['S3']['enabled']:
s3_config = self.settings['S3']
if s3_config['endpoint_url'] and s3_config['access_key'] and s3_config['secret_key'] and s3_config['bucket_name']:
if self.settings["S3"]["enabled"]:
s3_config = self.settings["S3"]
if (
s3_config["endpoint_url"]
and s3_config["access_key"]
and s3_config["secret_key"]
and s3_config["bucket_name"]
):
self.s3_storage = S3StorageService(
endpoint_url=s3_config['endpoint_url'],
access_key=s3_config['access_key'],
secret_key=s3_config['secret_key'],
bucket_name=s3_config['bucket_name'],
region=s3_config['region']
endpoint_url=s3_config["endpoint_url"],
access_key=s3_config["access_key"],
secret_key=s3_config["secret_key"],
bucket_name=s3_config["bucket_name"],
region=s3_config["region"],
)
def _parse_bool(self, value: str) -> bool:
"""Парсит строковое значение в boolean."""
return value.lower() in ('true', '1', 'yes', 'on')
return value.lower() in ("true", "1", "yes", "on")
def _parse_int(self, value: str) -> int:
"""Парсит строковое значение в integer."""
@@ -91,7 +99,7 @@ class BaseDependencyFactory:
def get_db(self) -> AsyncBotDB:
"""Возвращает подключение к базе данных."""
return self.database
def get_s3_storage(self) -> Optional[S3StorageService]:
"""Возвращает S3StorageService если S3 включен, иначе None."""
return self.s3_storage
@@ -99,6 +107,7 @@ class BaseDependencyFactory:
_global_instance = None
def get_global_instance():
"""Возвращает глобальный экземпляр BaseDependencyFactory."""
global _global_instance

File diff suppressed because it is too large Load Diff

View File

@@ -4,55 +4,55 @@ import html
from .metrics import metrics, track_errors, track_time
constants = {
'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
"&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉"
"&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂"
"&Наш бот голосового общения переехал ко мне! Доступен по кнопке 🎤Голосовой бот &Там можно послушать о чем говорит наш город🎧"
"&Предлагай свой пост мне и я обязательно его опубликую😉"
"&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇"
"&&Если что-то пошло не так: введи в чат команду /start или /restart, это перезапустит сценарий сначала."
"Почитать инструкцию к боту можно по команде /help. Если есть вопросы, то пиши в личку: @Kerrad1"
"&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже"
"&&Группа в ВК: https://vk.com/love_bsk"
"&Канал в ТГ: https://t.me/love_bsk",
'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼"
"&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
"&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'."
"&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего."
"&&❗️❗️Я обучен только на команды, указанные мной выше👆"
"&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
"&Пост будет опубликован только в группе ТГ📩",
"HELLO_MESSAGE": "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
"&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉"
"&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂"
"&Наш бот голосового общения переехал ко мне! Доступен по кнопке 🎤Голосовой бот &Там можно послушать о чем говорит наш город🎧"
"&Предлагай свой пост мне и я обязательно его опубликую😉"
"&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇"
"&&Если что-то пошло не так: введи в чат команду /start или /restart, это перезапустит сценарий сначала."
"Почитать инструкцию к боту можно по команде /help. Если есть вопросы, то пиши в личку: @Kerrad1"
"&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже"
"&&Группа в ВК: https://vk.com/love_bsk"
"&Канал в ТГ: https://t.me/love_bsk",
"SUGGEST_NEWS": "username, окей, жду от тебя текст поста🙌🏼"
"&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
"&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'."
"&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего."
"&&❗️❗️Я обучен только на команды, указанные мной выше👆"
"&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
"&Пост будет опубликован только в группе ТГ📩",
"CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️"
"&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️",
"&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️",
"DEL_MESSAGE": "username, напиши свое обращение или предложение✍"
"&Мы рассмотрим и ответим тебе в ближайшее время☺❤",
"&Мы рассмотрим и ответим тебе в ближайшее время☺❤",
"BYE_MESSAGE": "Если позднее захочешь предложить еще один пост или обратиться к админам с вопросом, то просто пришли в чат команду 👉 /restart"
"&&И тебе пока!👋🏼❤️",
"&&И тебе пока!👋🏼❤️",
"USER_ERROR": "Увы, я не понимаю тебя😐💔 Выбери один из пунктов в нижнем меню, а затем пиши.",
"QUESTION": "Сообщение успешно отправлено❤️ Ответим, как только сможем😉",
"SUCCESS_SEND_MESSAGE": "Пост успешно отправлен❤️ Ожидай одобрения😊",
# Voice handler messages
"MESSAGE_FOR_STANDUP": "Отлично, ты вошел в режим стендапа 📣"
"&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼"
"&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣"
"&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂"
"&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤"
"&❗️Но пожалуйста не оскорбляй никого, и будь вежлив.",
'WELCOME_MESSAGE': "<b>Привет.</b>",
'DESCRIPTION_MESSAGE': "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
'ANALOGY_MESSAGE': "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
'RULES_MESSAGE': "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
'ANONYMITY_MESSAGE': "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
'SUGGESTION_MESSAGE': "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
'EMOJI_INFO_MESSAGE': "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
'HELP_INFO_MESSAGE': "Так же можешь ознакомиться с инструкцией к боту по команде /help",
'FINAL_MESSAGE': "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
'HELP_MESSAGE': "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами",
'VOICE_SAVED_MESSAGE': "Окей, сохранил!👌",
'LISTENINGS_CLEARED_MESSAGE': "Прослушивания очищены. Можешь начать слушать заново🤗",
'NO_AUDIO_MESSAGE': "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится",
'UNKNOWN_CONTENT_MESSAGE': "Я тебя не понимаю🤷‍♀️ запиши голосовое",
'RECORD_VOICE_MESSAGE': "Хорошо, теперь пришли мне свое голосовое сообщение"
"&Это свободное пространство, в котором может высказаться каждый житель нашего города, и он будет услышан🙌🏼"
"&Для того чтобы высказаться, нажми кнопку: 'Высказаться' и запиши голосовое сообщение, оно выпадет анонимно кому-то другому🗣"
"&Для того чтобы послушать о чем говорит наш город, нажми кнопку: 'Послушать'👂"
"&Ты можешь анонимно пообщаться, поделиться чем-то важным, обратиться напрямую к жителям🤝 Также можешь выступить перед аудиторией (спеть песню, рассказать стихотворение, шутку)🎤"
"&❗️Но пожалуйста не оскорбляй никого, и будь вежлив.",
"WELCOME_MESSAGE": "<b>Привет.</b>",
"DESCRIPTION_MESSAGE": "<i>Здесь можно послушать голосовые сообщения от совершенно незнакомых людей из Бийска</i>",
"ANALOGY_MESSAGE": "Это почти как написать письмо, положить его в бутылку и швырнуть в океан. Никогда не узнаешь, послушал его кто-то или нет и ответить тоже не получится..",
"RULES_MESSAGE": "Записывать можно всё что угодно — никаких правил нет. Главное — твой голос, <i>хотя бы на 5-10 секунд</i>",
"ANONYMITY_MESSAGE": "Здесь всё анонимно: тот, кому я отправлю твое сообщение, не узнает ни твое имя, ни твой аккаунт (так что можно не стесняться говорить то, что не стал(а) бы выкладывать в собственные соцсети)",
"SUGGESTION_MESSAGE": "Если не знаешь, что сказать, можешь просто прочитать любое текстовое сообщение из недавно полученных или отправленных (или спеть, рассказать стихотворенье)",
"EMOJI_INFO_MESSAGE": "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
"HELP_INFO_MESSAGE": "Так же можешь ознакомиться с инструкцией к боту по команде /help",
"FINAL_MESSAGE": "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
"HELP_MESSAGE": "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами",
"VOICE_SAVED_MESSAGE": "Окей, сохранил!👌",
"LISTENINGS_CLEARED_MESSAGE": "Прослушивания очищены. Можешь начать слушать заново🤗",
"NO_AUDIO_MESSAGE": "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится",
"UNKNOWN_CONTENT_MESSAGE": "Я тебя не понимаю🤷‍♀️ запиши голосовое",
"RECORD_VOICE_MESSAGE": "Хорошо, теперь пришли мне свое голосовое сообщение",
}
@@ -64,5 +64,5 @@ def get_message(username: str, type_message: str):
raise TypeError("username is None")
message = constants[type_message]
# Экранируем потенциально проблемные символы для HTML
message = message.replace('username', html.escape(username)).replace('&', '\n')
message = message.replace("username", html.escape(username)).replace("&", "\n")
return message

View File

@@ -10,8 +10,13 @@ from contextlib import asynccontextmanager
from functools import wraps
from typing import Any, Dict, Optional
from prometheus_client import (CONTENT_TYPE_LATEST, Counter, Gauge, Histogram,
generate_latest)
from prometheus_client import (
CONTENT_TYPE_LATEST,
Counter,
Gauge,
Histogram,
generate_latest,
)
from prometheus_client.core import CollectorRegistry
# Метрики rate limiter теперь создаются в основном классе
@@ -19,372 +24,399 @@ from prometheus_client.core import CollectorRegistry
class BotMetrics:
"""Central class for managing all bot metrics."""
def __init__(self):
self.registry = CollectorRegistry()
# Создаем метрики rate limiter в том же registry
self._create_rate_limit_metrics()
# Bot commands counter
self.bot_commands_total = Counter(
'bot_commands_total',
'Total number of bot commands processed',
['command', 'status', 'handler_type', 'user_type'],
registry=self.registry
"bot_commands_total",
"Total number of bot commands processed",
["command", "status", "handler_type", "user_type"],
registry=self.registry,
)
# Method execution time histogram
self.method_duration_seconds = Histogram(
'method_duration_seconds',
'Time spent executing methods',
['method_name', 'handler_type', 'status'],
"method_duration_seconds",
"Time spent executing methods",
["method_name", "handler_type", "status"],
# Оптимизированные buckets для Telegram API (обычно < 1 сек)
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
registry=self.registry
registry=self.registry,
)
# Errors counter
self.errors_total = Counter(
'errors_total',
'Total number of errors',
['error_type', 'handler_type', 'method_name'],
registry=self.registry
"errors_total",
"Total number of errors",
["error_type", "handler_type", "method_name"],
registry=self.registry,
)
# Active users gauge
self.active_users = Gauge(
'active_users',
'Number of currently active users',
['user_type'],
registry=self.registry
"active_users",
"Number of currently active users",
["user_type"],
registry=self.registry,
)
# Total users gauge (отдельная метрика)
self.total_users = Gauge(
'total_users',
'Total number of users in database',
registry=self.registry
"total_users", "Total number of users in database", registry=self.registry
)
# Database query metrics
self.db_query_duration_seconds = Histogram(
'db_query_duration_seconds',
'Time spent executing database queries',
['query_type', 'table_name', 'operation'],
"db_query_duration_seconds",
"Time spent executing database queries",
["query_type", "table_name", "operation"],
# Оптимизированные buckets для SQLite/PostgreSQL
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
registry=self.registry
registry=self.registry,
)
# Database queries counter
self.db_queries_total = Counter(
'db_queries_total',
'Total number of database queries executed',
['query_type', 'table_name', 'operation'],
registry=self.registry
"db_queries_total",
"Total number of database queries executed",
["query_type", "table_name", "operation"],
registry=self.registry,
)
# Database errors counter
self.db_errors_total = Counter(
'db_errors_total',
'Total number of database errors',
['error_type', 'query_type', 'table_name', 'operation'],
registry=self.registry
"db_errors_total",
"Total number of database errors",
["error_type", "query_type", "table_name", "operation"],
registry=self.registry,
)
# Message processing metrics
self.messages_processed_total = Counter(
'messages_processed_total',
'Total number of messages processed',
['message_type', 'chat_type', 'handler_type'],
registry=self.registry
"messages_processed_total",
"Total number of messages processed",
["message_type", "chat_type", "handler_type"],
registry=self.registry,
)
# Middleware execution metrics
self.middleware_duration_seconds = Histogram(
'middleware_duration_seconds',
'Time spent in middleware execution',
['middleware_name', 'status'],
"middleware_duration_seconds",
"Time spent in middleware execution",
["middleware_name", "status"],
# Middleware должен быть быстрым
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.25],
registry=self.registry
registry=self.registry,
)
# Rate limiting metrics
self.rate_limit_hits_total = Counter(
'rate_limit_hits_total',
'Total number of rate limit hits',
['limit_type', 'user_id', 'action'],
registry=self.registry
"rate_limit_hits_total",
"Total number of rate limit hits",
["limit_type", "user_id", "action"],
registry=self.registry,
)
# User activity metrics
self.user_activity_total = Counter(
'user_activity_total',
'Total user activity events',
['activity_type', 'user_type', 'chat_type'],
registry=self.registry
"user_activity_total",
"Total user activity events",
["activity_type", "user_type", "chat_type"],
registry=self.registry,
)
# File download metrics
self.file_downloads_total = Counter(
'file_downloads_total',
'Total number of file downloads',
['content_type', 'status'],
registry=self.registry
"file_downloads_total",
"Total number of file downloads",
["content_type", "status"],
registry=self.registry,
)
self.file_download_duration_seconds = Histogram(
'file_download_duration_seconds',
'Time spent downloading files',
['content_type'],
"file_download_duration_seconds",
"Time spent downloading files",
["content_type"],
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0],
registry=self.registry
registry=self.registry,
)
self.file_download_size_bytes = Histogram(
'file_download_size_bytes',
'Size of downloaded files in bytes',
['content_type'],
"file_download_size_bytes",
"Size of downloaded files in bytes",
["content_type"],
buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824],
registry=self.registry
registry=self.registry,
)
# Media processing metrics
self.media_processing_total = Counter(
'media_processing_total',
'Total number of media processing operations',
['content_type', 'status'],
registry=self.registry
"media_processing_total",
"Total number of media processing operations",
["content_type", "status"],
registry=self.registry,
)
self.media_processing_duration_seconds = Histogram(
'media_processing_duration_seconds',
'Time spent processing media',
['content_type'],
"media_processing_duration_seconds",
"Time spent processing media",
["content_type"],
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0],
registry=self.registry
registry=self.registry,
)
def _create_rate_limit_metrics(self):
"""Создает метрики rate limiter в основном registry"""
try:
# Создаем метрики rate limiter в том же registry
self.rate_limit_requests_total = Counter(
'rate_limit_requests_total',
'Total number of rate limited requests',
['chat_id', 'status', 'error_type'],
registry=self.registry
"rate_limit_requests_total",
"Total number of rate limited requests",
["chat_id", "status", "error_type"],
registry=self.registry,
)
self.rate_limit_errors_total = Counter(
'rate_limit_errors_total',
'Total number of rate limit errors',
['error_type', 'chat_id'],
registry=self.registry
"rate_limit_errors_total",
"Total number of rate limit errors",
["error_type", "chat_id"],
registry=self.registry,
)
self.rate_limit_wait_duration_seconds = Histogram(
'rate_limit_wait_duration_seconds',
'Time spent waiting due to rate limiting',
['chat_id'],
"rate_limit_wait_duration_seconds",
"Time spent waiting due to rate limiting",
["chat_id"],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
registry=self.registry
registry=self.registry,
)
self.rate_limit_active_chats = Gauge(
'rate_limit_active_chats',
'Number of active chats with rate limiting',
registry=self.registry
"rate_limit_active_chats",
"Number of active chats with rate limiting",
registry=self.registry,
)
self.rate_limit_success_rate = Gauge(
'rate_limit_success_rate',
'Success rate of rate limited requests',
['chat_id'],
registry=self.registry
"rate_limit_success_rate",
"Success rate of rate limited requests",
["chat_id"],
registry=self.registry,
)
self.rate_limit_requests_per_minute = Gauge(
'rate_limit_requests_per_minute',
'Requests per minute',
['chat_id'],
registry=self.registry
"rate_limit_requests_per_minute",
"Requests per minute",
["chat_id"],
registry=self.registry,
)
self.rate_limit_total_requests = Gauge(
'rate_limit_total_requests',
'Total number of requests',
['chat_id'],
registry=self.registry
"rate_limit_total_requests",
"Total number of requests",
["chat_id"],
registry=self.registry,
)
self.rate_limit_total_errors = Gauge(
'rate_limit_total_errors',
'Total number of errors',
['chat_id', 'error_type'],
registry=self.registry
"rate_limit_total_errors",
"Total number of errors",
["chat_id", "error_type"],
registry=self.registry,
)
self.rate_limit_avg_wait_time_seconds = Gauge(
'rate_limit_avg_wait_time_seconds',
'Average wait time in seconds',
['chat_id'],
registry=self.registry
"rate_limit_avg_wait_time_seconds",
"Average wait time in seconds",
["chat_id"],
registry=self.registry,
)
except Exception as e:
# Логируем ошибку, но не прерываем инициализацию
import logging
logging.warning(f"Failed to create rate limit metrics: {e}")
def record_command(self, command_type: str, handler_type: str = "unknown", user_type: str = "unknown", status: str = "success"):
def record_command(
self,
command_type: str,
handler_type: str = "unknown",
user_type: str = "unknown",
status: str = "success",
):
"""Record a bot command execution."""
self.bot_commands_total.labels(
command=command_type,
status=status,
handler_type=handler_type,
user_type=user_type
user_type=user_type,
).inc()
def record_error(self, error_type: str, handler_type: str = "unknown", method_name: str = "unknown"):
def record_error(
self,
error_type: str,
handler_type: str = "unknown",
method_name: str = "unknown",
):
"""Record an error occurrence."""
self.errors_total.labels(
error_type=error_type,
handler_type=handler_type,
method_name=method_name
error_type=error_type, handler_type=handler_type, method_name=method_name
).inc()
def record_method_duration(self, method_name: str, duration: float, handler_type: str = "unknown", status: str = "success"):
def record_method_duration(
self,
method_name: str,
duration: float,
handler_type: str = "unknown",
status: str = "success",
):
"""Record method execution duration."""
self.method_duration_seconds.labels(
method_name=method_name,
handler_type=handler_type,
status=status
method_name=method_name, handler_type=handler_type, status=status
).observe(duration)
def set_active_users(self, count: int, user_type: str = "daily"):
"""Set the number of active users for a specific type."""
self.active_users.labels(user_type=user_type).set(count)
def set_total_users(self, count: int):
"""Set the total number of users in database."""
self.total_users.set(count)
def record_db_query(self, query_type: str, duration: float, table_name: str = "unknown", operation: str = "unknown"):
def record_db_query(
self,
query_type: str,
duration: float,
table_name: str = "unknown",
operation: str = "unknown",
):
"""Record database query duration."""
self.db_query_duration_seconds.labels(
query_type=query_type,
table_name=table_name,
operation=operation
query_type=query_type, table_name=table_name, operation=operation
).observe(duration)
self.db_queries_total.labels(
query_type=query_type,
table_name=table_name,
operation=operation
query_type=query_type, table_name=table_name, operation=operation
).inc()
def record_message(self, message_type: str, chat_type: str = "unknown", handler_type: str = "unknown"):
def record_message(
self,
message_type: str,
chat_type: str = "unknown",
handler_type: str = "unknown",
):
"""Record a processed message."""
self.messages_processed_total.labels(
message_type=message_type,
chat_type=chat_type,
handler_type=handler_type
message_type=message_type, chat_type=chat_type, handler_type=handler_type
).inc()
def record_middleware(self, middleware_name: str, duration: float, status: str = "success"):
def record_middleware(
self, middleware_name: str, duration: float, status: str = "success"
):
"""Record middleware execution duration."""
self.middleware_duration_seconds.labels(
middleware_name=middleware_name,
status=status
middleware_name=middleware_name, status=status
).observe(duration)
def record_file_download(self, content_type: str, file_size: int, duration: float):
"""Record file download metrics."""
self.file_downloads_total.labels(
content_type=content_type,
status="success"
content_type=content_type, status="success"
).inc()
self.file_download_duration_seconds.labels(
content_type=content_type
).observe(duration)
self.file_download_size_bytes.labels(
content_type=content_type
).observe(file_size)
self.file_download_duration_seconds.labels(content_type=content_type).observe(
duration
)
self.file_download_size_bytes.labels(content_type=content_type).observe(
file_size
)
def record_file_download_error(self, content_type: str, error_message: str):
"""Record file download error metrics."""
self.file_downloads_total.labels(
content_type=content_type,
status="error"
content_type=content_type, status="error"
).inc()
self.errors_total.labels(
error_type="file_download_error",
handler_type="media_processing",
method_name="download_file"
method_name="download_file",
).inc()
def record_media_processing(self, content_type: str, duration: float, success: bool):
def record_media_processing(
self, content_type: str, duration: float, success: bool
):
"""Record media processing metrics."""
status = "success" if success else "error"
self.media_processing_total.labels(
content_type=content_type,
status=status
content_type=content_type, status=status
).inc()
self.media_processing_duration_seconds.labels(
content_type=content_type
).observe(duration)
if not success:
self.errors_total.labels(
error_type="media_processing_error",
handler_type="media_processing",
method_name="add_in_db_media"
method_name="add_in_db_media",
).inc()
def record_db_error(self, error_type: str, query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"):
def record_db_error(
self,
error_type: str,
query_type: str = "unknown",
table_name: str = "unknown",
operation: str = "unknown",
):
"""Record database error occurrence."""
self.db_errors_total.labels(
error_type=error_type,
query_type=query_type,
table_name=table_name,
operation=operation
operation=operation,
).inc()
def record_rate_limit_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: str = None):
def record_rate_limit_request(
self,
chat_id: int,
success: bool,
wait_time: float = 0.0,
error_type: str = None,
):
"""Record rate limit request metrics."""
try:
# Определяем статус
status = "success" if success else "error"
# Записываем счетчик запросов
self.rate_limit_requests_total.labels(
chat_id=str(chat_id),
status=status,
error_type=error_type or "none"
chat_id=str(chat_id), status=status, error_type=error_type or "none"
).inc()
# Записываем время ожидания
if wait_time > 0:
self.rate_limit_wait_duration_seconds.labels(
chat_id=str(chat_id)
).observe(wait_time)
# Записываем ошибки
if not success and error_type:
self.rate_limit_errors_total.labels(
error_type=error_type,
chat_id=str(chat_id)
error_type=error_type, chat_id=str(chat_id)
).inc()
except Exception as e:
import logging
logging.warning(f"Failed to record rate limit request: {e}")
def update_rate_limit_gauges(self):
"""Update rate limit gauge metrics."""
try:
@@ -392,52 +424,51 @@ class BotMetrics:
# Обновляем количество активных чатов
self.rate_limit_active_chats.set(len(rate_limit_monitor.stats))
# Обновляем метрики для каждого чата
for chat_id, chat_stats in rate_limit_monitor.stats.items():
chat_id_str = str(chat_id)
# Процент успеха
self.rate_limit_success_rate.labels(
chat_id=chat_id_str
).set(chat_stats.success_rate)
self.rate_limit_success_rate.labels(chat_id=chat_id_str).set(
chat_stats.success_rate
)
# Запросов в минуту
self.rate_limit_requests_per_minute.labels(
chat_id=chat_id_str
).set(chat_stats.requests_per_minute)
self.rate_limit_requests_per_minute.labels(chat_id=chat_id_str).set(
chat_stats.requests_per_minute
)
# Общее количество запросов
self.rate_limit_total_requests.labels(
chat_id=chat_id_str
).set(chat_stats.total_requests)
self.rate_limit_total_requests.labels(chat_id=chat_id_str).set(
chat_stats.total_requests
)
# Среднее время ожидания
self.rate_limit_avg_wait_time_seconds.labels(
chat_id=chat_id_str
).set(chat_stats.average_wait_time)
self.rate_limit_avg_wait_time_seconds.labels(chat_id=chat_id_str).set(
chat_stats.average_wait_time
)
# Количество ошибок по типам
if chat_stats.retry_after_errors > 0:
self.rate_limit_total_errors.labels(
chat_id=chat_id_str,
error_type="RetryAfter"
chat_id=chat_id_str, error_type="RetryAfter"
).set(chat_stats.retry_after_errors)
if chat_stats.other_errors > 0:
self.rate_limit_total_errors.labels(
chat_id=chat_id_str,
error_type="Other"
chat_id=chat_id_str, error_type="Other"
).set(chat_stats.other_errors)
except Exception as e:
import logging
logging.warning(f"Failed to update rate limit gauges: {e}")
def get_metrics(self) -> bytes:
"""Generate metrics in Prometheus format."""
# Обновляем gauge метрики rate limiter перед генерацией
self.update_rate_limit_gauges()
return generate_latest(self.registry)
@@ -448,6 +479,7 @@ metrics = BotMetrics()
# Decorators for easy metric collection
def track_time(method_name: str = None, handler_type: str = "unknown"):
"""Decorator to track execution time of functions."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -456,27 +488,19 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
result = await func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"success"
method_name or func.__name__, duration, handler_type, "success"
)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"error"
method_name or func.__name__, duration, handler_type, "error"
)
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
@@ -484,35 +508,29 @@ def track_time(method_name: str = None, handler_type: str = "unknown"):
result = func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"success"
method_name or func.__name__, duration, handler_type, "success"
)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"error"
method_name or func.__name__, duration, handler_type, "error"
)
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def track_errors(handler_type: str = "unknown", method_name: str = None):
"""Decorator to track errors in functions."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -520,32 +538,32 @@ def track_errors(handler_type: str = "unknown", method_name: str = None):
return await func(*args, **kwargs)
except Exception as e:
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
type(e).__name__, handler_type, method_name or func.__name__
)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def db_query_time(query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"):
def db_query_time(
query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"
):
"""Decorator to track database query execution time."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -559,18 +577,11 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_db_error(
type(e).__name__,
query_type,
table_name,
operation
)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
type(e).__name__, query_type, table_name, operation
)
metrics.record_error(type(e).__name__, "database", func.__name__)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
@@ -583,21 +594,15 @@ def db_query_time(query_type: str = "unknown", table_name: str = "unknown", oper
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_db_error(
type(e).__name__,
query_type,
table_name,
operation
)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
type(e).__name__, query_type, table_name, operation
)
metrics.record_error(type(e).__name__, "database", func.__name__)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
@@ -612,16 +617,13 @@ async def track_middleware(middleware_name: str):
except Exception as e:
duration = time.time() - start_time
metrics.record_middleware(middleware_name, duration, "error")
metrics.record_error(
type(e).__name__,
"middleware",
middleware_name
)
metrics.record_error(type(e).__name__, "middleware", middleware_name)
raise
def track_media_processing(content_type: str = "unknown"):
"""Decorator to track media processing operations."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -635,7 +637,7 @@ def track_media_processing(content_type: str = "unknown"):
duration = time.time() - start_time
metrics.record_media_processing(content_type, duration, False)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
@@ -648,15 +650,17 @@ def track_media_processing(content_type: str = "unknown"):
duration = time.time() - start_time
metrics.record_media_processing(content_type, duration, False)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def track_file_operations(content_type: str = "unknown"):
"""Decorator to track file download/upload operations."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -664,43 +668,44 @@ def track_file_operations(content_type: str = "unknown"):
try:
result = await func(*args, **kwargs)
duration = time.time() - start_time
# Получаем размер файла из результата
file_size = 0
if result and isinstance(result, str) and os.path.exists(result):
file_size = os.path.getsize(result)
# Записываем метрики
metrics.record_file_download(content_type, file_size, duration)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_file_download_error(content_type, str(e))
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start_time
# Получаем размер файла из результата
file_size = 0
if result and isinstance(result, str) and os.path.exists(result):
file_size = os.path.getsize(result)
# Записываем метрики
metrics.record_file_download(content_type, file_size, duration)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_file_download_error(content_type, str(e))
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator

View File

@@ -1,6 +1,7 @@
"""
Мониторинг и статистика rate limiting
"""
import time
from collections import defaultdict, deque
from dataclasses import dataclass, field
@@ -12,6 +13,7 @@ from logs.custom_logger import logger
@dataclass
class RateLimitStats:
"""Статистика rate limiting для чата"""
chat_id: int
total_requests: int = 0
successful_requests: int = 0
@@ -21,53 +23,61 @@ class RateLimitStats:
total_wait_time: float = 0.0
last_request_time: float = 0.0
request_times: deque = field(default_factory=lambda: deque(maxlen=100))
@property
def success_rate(self) -> float:
"""Процент успешных запросов"""
if self.total_requests == 0:
return 1.0
return self.successful_requests / self.total_requests
@property
def error_rate(self) -> float:
"""Процент ошибок"""
return 1.0 - self.success_rate
@property
def average_wait_time(self) -> float:
"""Среднее время ожидания"""
if self.total_requests == 0:
return 0.0
return self.total_wait_time / self.total_requests
@property
def requests_per_minute(self) -> float:
"""Запросов в минуту"""
if not self.request_times:
return 0.0
current_time = time.time()
minute_ago = current_time - 60
# Подсчитываем запросы за последнюю минуту
recent_requests = sum(1 for req_time in self.request_times if req_time > minute_ago)
recent_requests = sum(
1 for req_time in self.request_times if req_time > minute_ago
)
return recent_requests
class RateLimitMonitor:
"""Монитор для отслеживания статистики rate limiting"""
def __init__(self, max_history_size: int = 1000):
self.stats: Dict[int, RateLimitStats] = defaultdict(lambda: RateLimitStats(0))
self.global_stats = RateLimitStats(0)
self.max_history_size = max_history_size
self.error_history: deque = deque(maxlen=max_history_size)
def record_request(self, chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None):
def record_request(
self,
chat_id: int,
success: bool,
wait_time: float = 0.0,
error_type: Optional[str] = None,
):
"""Записывает информацию о запросе"""
current_time = time.time()
# Обновляем статистику для чата
chat_stats = self.stats[chat_id]
chat_stats.chat_id = chat_id
@@ -75,7 +85,7 @@ class RateLimitMonitor:
chat_stats.total_wait_time += wait_time
chat_stats.last_request_time = current_time
chat_stats.request_times.append(current_time)
if success:
chat_stats.successful_requests += 1
else:
@@ -84,21 +94,23 @@ class RateLimitMonitor:
chat_stats.retry_after_errors += 1
else:
chat_stats.other_errors += 1
# Записываем ошибку в историю
self.error_history.append({
'chat_id': chat_id,
'error_type': error_type,
'timestamp': current_time,
'wait_time': wait_time
})
self.error_history.append(
{
"chat_id": chat_id,
"error_type": error_type,
"timestamp": current_time,
"wait_time": wait_time,
}
)
# Обновляем глобальную статистику
self.global_stats.total_requests += 1
self.global_stats.total_wait_time += wait_time
self.global_stats.last_request_time = current_time
self.global_stats.request_times.append(current_time)
if success:
self.global_stats.successful_requests += 1
else:
@@ -107,56 +119,54 @@ class RateLimitMonitor:
self.global_stats.retry_after_errors += 1
else:
self.global_stats.other_errors += 1
def get_chat_stats(self, chat_id: int) -> Optional[RateLimitStats]:
"""Получает статистику для конкретного чата"""
return self.stats.get(chat_id)
def get_global_stats(self) -> RateLimitStats:
"""Получает глобальную статистику"""
return self.global_stats
def get_top_chats_by_requests(self, limit: int = 10) -> List[tuple]:
"""Получает топ чатов по количеству запросов"""
sorted_chats = sorted(
self.stats.items(),
key=lambda x: x[1].total_requests,
reverse=True
self.stats.items(), key=lambda x: x[1].total_requests, reverse=True
)
return sorted_chats[:limit]
def get_chats_with_high_error_rate(self, threshold: float = 0.1) -> List[tuple]:
"""Получает чаты с высоким процентом ошибок"""
high_error_chats = [
(chat_id, stats) for chat_id, stats in self.stats.items()
(chat_id, stats)
for chat_id, stats in self.stats.items()
if stats.error_rate > threshold and stats.total_requests > 5
]
return sorted(high_error_chats, key=lambda x: x[1].error_rate, reverse=True)
def get_recent_errors(self, minutes: int = 60) -> List[dict]:
"""Получает недавние ошибки"""
current_time = time.time()
cutoff_time = current_time - (minutes * 60)
return [
error for error in self.error_history
if error['timestamp'] > cutoff_time
error for error in self.error_history if error["timestamp"] > cutoff_time
]
def get_error_summary(self, minutes: int = 60) -> Dict[str, int]:
"""Получает сводку ошибок за указанный период"""
recent_errors = self.get_recent_errors(minutes)
error_summary = defaultdict(int)
for error in recent_errors:
error_summary[error['error_type']] += 1
error_summary[error["error_type"]] += 1
return dict(error_summary)
def log_statistics(self, log_level: str = "info"):
"""Логирует текущую статистику"""
global_stats = self.get_global_stats()
log_message = (
f"Rate Limit Statistics:\n"
f" Total requests: {global_stats.total_requests}\n"
@@ -168,21 +178,25 @@ class RateLimitMonitor:
f" Requests per minute: {global_stats.requests_per_minute:.1f}\n"
f" Active chats: {len(self.stats)}"
)
if log_level == "error":
logger.error(log_message)
elif log_level == "warning":
logger.warning(log_message)
else:
logger.info(log_message)
# Логируем чаты с высоким процентом ошибок
high_error_chats = self.get_chats_with_high_error_rate(0.2)
if high_error_chats:
logger.warning(f"Chats with high error rate (>20%): {len(high_error_chats)}")
logger.warning(
f"Chats with high error rate (>20%): {len(high_error_chats)}"
)
for chat_id, stats in high_error_chats[:5]: # Показываем только первые 5
logger.warning(f" Chat {chat_id}: {stats.error_rate:.2%} error rate ({stats.failed_requests}/{stats.total_requests})")
logger.warning(
f" Chat {chat_id}: {stats.error_rate:.2%} error rate ({stats.failed_requests}/{stats.total_requests})"
)
def reset_stats(self, chat_id: Optional[int] = None):
"""Сбрасывает статистику"""
if chat_id is None:
@@ -200,7 +214,12 @@ class RateLimitMonitor:
rate_limit_monitor = RateLimitMonitor()
def record_rate_limit_request(chat_id: int, success: bool, wait_time: float = 0.0, error_type: Optional[str] = None):
def record_rate_limit_request(
chat_id: int,
success: bool,
wait_time: float = 0.0,
error_type: Optional[str] = None,
):
"""Удобная функция для записи информации о запросе"""
rate_limit_monitor.record_request(chat_id, success, wait_time, error_type)
@@ -209,13 +228,13 @@ def get_rate_limit_summary() -> Dict:
"""Получает краткую сводку по rate limiting"""
global_stats = rate_limit_monitor.get_global_stats()
recent_errors = rate_limit_monitor.get_recent_errors(60) # За последний час
return {
'total_requests': global_stats.total_requests,
'success_rate': global_stats.success_rate,
'error_rate': global_stats.error_rate,
'recent_errors_count': len(recent_errors),
'active_chats': len(rate_limit_monitor.stats),
'requests_per_minute': global_stats.requests_per_minute,
'average_wait_time': global_stats.average_wait_time
"total_requests": global_stats.total_requests,
"success_rate": global_stats.success_rate,
"error_rate": global_stats.error_rate,
"recent_errors_count": len(recent_errors),
"active_chats": len(rate_limit_monitor.stats),
"requests_per_minute": global_stats.requests_per_minute,
"average_wait_time": global_stats.average_wait_time,
}

View File

@@ -1,20 +1,23 @@
"""
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
"""
import asyncio
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
from logs.custom_logger import logger
from .metrics import metrics
@dataclass
@dataclass
class RateLimitConfig:
"""Конфигурация для rate limiting"""
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
burst_limit: int = 3 # Максимум 3 сообщения подряд
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
@@ -23,23 +26,23 @@ class RateLimitConfig:
class ChatRateLimiter:
"""Rate limiter для конкретного чата"""
def __init__(self, config: RateLimitConfig):
self.config = config
self.last_send_time = 0.0
self.burst_count = 0
self.burst_reset_time = 0.0
self.retry_delay = 1.0
async def wait_if_needed(self) -> None:
"""Ждет если необходимо для соблюдения rate limit"""
current_time = time.time()
# Сбрасываем счетчик burst если прошло достаточно времени
if current_time >= self.burst_reset_time:
self.burst_count = 0
self.burst_reset_time = current_time + 1.0
# Проверяем burst limit
if self.burst_count >= self.config.burst_limit:
wait_time = self.burst_reset_time - current_time
@@ -49,16 +52,16 @@ class ChatRateLimiter:
current_time = time.time()
self.burst_count = 0
self.burst_reset_time = current_time + 1.0
# Проверяем минимальный интервал между сообщениями
time_since_last = current_time - self.last_send_time
min_interval = 1.0 / self.config.messages_per_second
if time_since_last < min_interval:
wait_time = min_interval - time_since_last
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s")
await asyncio.sleep(wait_time)
# Обновляем время последней отправки
self.last_send_time = time.time()
self.burst_count += 1
@@ -66,126 +69,126 @@ class ChatRateLimiter:
class GlobalRateLimiter:
"""Глобальный rate limiter для всех чатов"""
def __init__(self, config: RateLimitConfig):
self.config = config
self.chat_limiters: Dict[int, ChatRateLimiter] = {}
self.global_last_send = 0.0
self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями
def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter:
"""Получает rate limiter для конкретного чата"""
if chat_id not in self.chat_limiters:
self.chat_limiters[chat_id] = ChatRateLimiter(self.config)
return self.chat_limiters[chat_id]
async def wait_if_needed(self, chat_id: int) -> None:
"""Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit"""
current_time = time.time()
# Глобальный rate limit
time_since_global = current_time - self.global_last_send
if time_since_global < self.global_min_interval:
wait_time = self.global_min_interval - time_since_global
await asyncio.sleep(wait_time)
current_time = time.time()
# Чат-специфичный rate limit
chat_limiter = self.get_chat_limiter(chat_id)
await chat_limiter.wait_if_needed()
self.global_last_send = time.time()
class RetryHandler:
"""Обработчик повторных попыток с экспоненциальной задержкой"""
def __init__(self, config: RateLimitConfig):
self.config = config
async def execute_with_retry(
self,
func: Callable,
chat_id: int,
*args,
max_retries: int = 3,
**kwargs
self, func: Callable, chat_id: int, *args, max_retries: int = 3, **kwargs
) -> Any:
"""Выполняет функцию с повторными попытками при ошибках"""
retry_count = 0
current_delay = self.config.retry_after_multiplier
total_wait_time = 0.0
while retry_count <= max_retries:
try:
result = await func(*args, **kwargs)
# Записываем успешный запрос
metrics.record_rate_limit_request(chat_id, True, total_wait_time)
return result
except TelegramRetryAfter as e:
retry_count += 1
if retry_count > max_retries:
logger.error(f"Max retries exceeded for RetryAfter: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "RetryAfter")
metrics.record_rate_limit_request(
chat_id, False, total_wait_time, "RetryAfter"
)
raise
# Используем время ожидания от Telegram или наше увеличенное
wait_time = max(e.retry_after, current_delay)
wait_time = min(wait_time, self.config.max_retry_delay)
total_wait_time += wait_time
logger.warning(f"RetryAfter error, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries})")
logger.warning(
f"RetryAfter error, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries})"
)
await asyncio.sleep(wait_time)
current_delay *= self.config.retry_after_multiplier
except TelegramAPIError as e:
retry_count += 1
if retry_count > max_retries:
logger.error(f"Max retries exceeded for TelegramAPIError: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "TelegramAPIError")
metrics.record_rate_limit_request(
chat_id, False, total_wait_time, "TelegramAPIError"
)
raise
wait_time = min(current_delay, self.config.max_retry_delay)
total_wait_time += wait_time
logger.warning(f"TelegramAPIError, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries}): {e}")
logger.warning(
f"TelegramAPIError, waiting {wait_time:.2f}s (attempt {retry_count}/{max_retries}): {e}"
)
await asyncio.sleep(wait_time)
current_delay *= self.config.retry_after_multiplier
except Exception as e:
# Для других ошибок не делаем retry
logger.error(f"Non-retryable error: {e}")
metrics.record_rate_limit_request(chat_id, False, total_wait_time, "Other")
metrics.record_rate_limit_request(
chat_id, False, total_wait_time, "Other"
)
raise
class TelegramRateLimiter:
"""Основной класс для rate limiting в Telegram боте"""
def __init__(self, config: Optional[RateLimitConfig] = None):
self.config = config or RateLimitConfig()
self.global_limiter = GlobalRateLimiter(self.config)
self.retry_handler = RetryHandler(self.config)
async def send_with_rate_limit(
self,
send_func: Callable,
chat_id: int,
*args,
**kwargs
self, send_func: Callable, chat_id: int, *args, **kwargs
) -> Any:
"""Отправляет сообщение с соблюдением rate limit и retry логики"""
async def _send():
await self.global_limiter.wait_if_needed(chat_id)
return await send_func(*args, **kwargs)
return await self.retry_handler.execute_with_retry(_send, chat_id)
# Глобальный экземпляр rate limiter
from helper_bot.config.rate_limit_config import (RateLimitSettings,
get_rate_limit_config)
from helper_bot.config.rate_limit_config import RateLimitSettings, get_rate_limit_config
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
@@ -194,9 +197,10 @@ def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
messages_per_second=settings.messages_per_second,
burst_limit=settings.burst_limit,
retry_after_multiplier=settings.retry_after_multiplier,
max_retry_delay=settings.max_retry_delay
max_retry_delay=settings.max_retry_delay,
)
# Получаем конфигурацию из настроек
_rate_limit_settings = get_rate_limit_config("production")
_default_config = _create_rate_limit_config(_rate_limit_settings)
@@ -204,16 +208,20 @@ _default_config = _create_rate_limit_config(_rate_limit_settings)
telegram_rate_limiter = TelegramRateLimiter(_default_config)
async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwargs) -> Any:
async def send_with_rate_limit(
send_func: Callable, chat_id: int, *args, **kwargs
) -> Any:
"""
Удобная функция для отправки сообщений с rate limiting
Args:
send_func: Функция отправки (например, bot.send_message)
chat_id: ID чата
*args, **kwargs: Аргументы для функции отправки
Returns:
Результат выполнения функции отправки
"""
return await telegram_rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs)
return await telegram_rate_limiter.send_with_rate_limit(
send_func, chat_id, *args, **kwargs
)

View File

@@ -1,114 +1,114 @@
"""
Сервис для работы с S3 хранилищем.
"""
import os
import tempfile
from pathlib import Path
from typing import Optional
import aioboto3
from logs.custom_logger import logger
class S3StorageService:
"""Сервис для работы с S3 хранилищем."""
def __init__(self, endpoint_url: str, access_key: str, secret_key: str,
bucket_name: str, region: str = "us-east-1"):
def __init__(
self,
endpoint_url: str,
access_key: str,
secret_key: str,
bucket_name: str,
region: str = "us-east-1",
):
self.endpoint_url = endpoint_url
self.access_key = access_key
self.secret_key = secret_key
self.bucket_name = bucket_name
self.region = region
self.session = aioboto3.Session()
async def upload_file(self, file_path: str, s3_key: str,
content_type: Optional[str] = None) -> bool:
async def upload_file(
self, file_path: str, s3_key: str, content_type: Optional[str] = None
) -> bool:
"""Загружает файл в S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
extra_args = {}
if content_type:
extra_args['ContentType'] = content_type
extra_args["ContentType"] = content_type
await s3.upload_file(
file_path,
self.bucket_name,
s3_key,
ExtraArgs=extra_args
file_path, self.bucket_name, s3_key, ExtraArgs=extra_args
)
logger.info(f"Файл загружен в S3: {s3_key}")
return True
except Exception as e:
logger.error(f"Ошибка загрузки файла в S3 {s3_key}: {e}")
return False
async def upload_fileobj(self, file_obj, s3_key: str,
content_type: Optional[str] = None) -> bool:
async def upload_fileobj(
self, file_obj, s3_key: str, content_type: Optional[str] = None
) -> bool:
"""Загружает файл из объекта в S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
extra_args = {}
if content_type:
extra_args['ContentType'] = content_type
extra_args["ContentType"] = content_type
await s3.upload_fileobj(
file_obj,
self.bucket_name,
s3_key,
ExtraArgs=extra_args
file_obj, self.bucket_name, s3_key, ExtraArgs=extra_args
)
logger.info(f"Файл загружен в S3 из объекта: {s3_key}")
return True
except Exception as e:
logger.error(f"Ошибка загрузки файла в S3 из объекта {s3_key}: {e}")
return False
async def download_file(self, s3_key: str, local_path: str) -> bool:
"""Скачивает файл из S3 на локальный диск."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
# Создаем директорию если её нет
os.makedirs(os.path.dirname(local_path), exist_ok=True)
await s3.download_file(
self.bucket_name,
s3_key,
local_path
)
await s3.download_file(self.bucket_name, s3_key, local_path)
logger.info(f"Файл скачан из S3: {s3_key} -> {local_path}")
return True
except Exception as e:
logger.error(f"Ошибка скачивания файла из S3 {s3_key}: {e}")
return False
async def download_to_temp(self, s3_key: str) -> Optional[str]:
"""Скачивает файл из S3 во временный файл. Возвращает путь к временному файлу."""
try:
# Определяем расширение из ключа
ext = Path(s3_key).suffix or '.bin'
ext = Path(s3_key).suffix or ".bin"
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
temp_path = temp_file.name
temp_file.close()
success = await self.download_file(s3_key, temp_path)
if success:
return temp_path
@@ -120,33 +120,35 @@ class S3StorageService:
pass
return None
except Exception as e:
logger.error(f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}")
logger.error(
f"Ошибка скачивания файла из S3 во временный файл {s3_key}: {e}"
)
return None
async def file_exists(self, s3_key: str) -> bool:
"""Проверяет существование файла в S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
await s3.head_object(Bucket=self.bucket_name, Key=s3_key)
return True
except:
return False
async def delete_file(self, s3_key: str) -> bool:
"""Удаляет файл из S3."""
try:
async with self.session.client(
's3',
"s3",
endpoint_url=self.endpoint_url,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
region_name=self.region
region_name=self.region,
) as s3:
await s3.delete_object(Bucket=self.bucket_name, Key=s3_key)
logger.info(f"Файл удален из S3: {s3_key}")
@@ -154,23 +156,35 @@ class S3StorageService:
except Exception as e:
logger.error(f"Ошибка удаления файла из S3 {s3_key}: {e}")
return False
def generate_s3_key(self, content_type: str, file_id: str) -> str:
"""Генерирует S3 ключ для файла. Один и тот же для всех постов с этим file_id."""
type_folders = {
'photo': 'photos',
'video': 'videos',
'audio': 'music',
'voice': 'voice',
'video_note': 'video_notes'
"photo": "photos",
"video": "videos",
"audio": "music",
"voice": "voice",
"video_note": "video_notes",
}
folder = type_folders.get(content_type, 'other')
folder = type_folders.get(content_type, "other")
# Определяем расширение из file_id или используем дефолтное
ext = '.jpg' if content_type == 'photo' else \
'.mp4' if content_type == 'video' else \
'.mp3' if content_type == 'audio' else \
'.ogg' if content_type == 'voice' else \
'.mp4' if content_type == 'video_note' else '.bin'
ext = (
".jpg"
if content_type == "photo"
else (
".mp4"
if content_type == "video"
else (
".mp3"
if content_type == "audio"
else (
".ogg"
if content_type == "voice"
else ".mp4" if content_type == "video_note" else ".bin"
)
)
)
)
return f"{folder}/{file_id}{ext}"