1f0fef114b
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding via structlog contextvars (aiogram) and http_method/http_path (FastAPI) - Use bound_contextvars() context manager instead of clear_contextvars() to safely restore previous state instead of wiping all context - Register ContextVarsMiddleware as outermost middleware (before GlobalError) so all error logs include user context - Replace structlog.get_logger() with structlog.get_logger(__name__) across 270 calls in 265 files for meaningful logger names - Switch wrapper_class from BoundLogger to make_filtering_bound_logger() for pre-processor level filtering (performance optimization) - Migrate 1411 %-style positional arg logger calls to structlog kwargs style across 161 files via AST script - Migrate log_rotation_service.py from stdlib logging to structlog - Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking to Telegram notifications and general log files - Fix LoggingMiddleware: add from_user null-safety for channel posts, switch time.time() to time.monotonic() for duration measurement - Remove duplicate logger assignments in purchase.py, config.py, inline.py, and admin/payments.py
434 lines
17 KiB
Python
434 lines
17 KiB
Python
import structlog
|
||
from aiogram import Dispatcher, F, types
|
||
from aiogram.filters import StateFilter
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database.crud.user_message import (
|
||
create_user_message,
|
||
delete_user_message,
|
||
get_all_user_messages,
|
||
get_user_message_by_id,
|
||
get_user_messages_stats,
|
||
toggle_user_message_status,
|
||
update_user_message,
|
||
)
|
||
from app.database.models import User
|
||
from app.localization.texts import get_texts
|
||
from app.utils.decorators import admin_required, error_handler
|
||
from app.utils.validators import (
|
||
get_html_help_text,
|
||
sanitize_html,
|
||
validate_html_tags,
|
||
)
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
class UserMessageStates(StatesGroup):
|
||
waiting_for_message_text = State()
|
||
waiting_for_edit_text = State()
|
||
|
||
|
||
def get_user_messages_keyboard(language: str = 'ru'):
|
||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||
|
||
return InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(text='📝 Добавить сообщение', callback_data='add_user_message')],
|
||
[InlineKeyboardButton(text='📋 Список сообщений', callback_data='list_user_messages:0')],
|
||
[InlineKeyboardButton(text='📊 Статистика', callback_data='user_messages_stats')],
|
||
[InlineKeyboardButton(text='🔙 Назад в админку', callback_data='admin_panel')],
|
||
]
|
||
)
|
||
|
||
|
||
def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = 'ru'):
|
||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||
|
||
status_text = '🔴 Деактивировать' if is_active else '🟢 Активировать'
|
||
|
||
return InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[InlineKeyboardButton(text='✏️ Редактировать', callback_data=f'edit_user_message:{message_id}')],
|
||
[InlineKeyboardButton(text=status_text, callback_data=f'toggle_user_message:{message_id}')],
|
||
[InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'delete_user_message:{message_id}')],
|
||
[InlineKeyboardButton(text='🔙 К списку', callback_data='list_user_messages:0')],
|
||
]
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_user_messages_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
get_texts(db_user.language)
|
||
|
||
text = (
|
||
'📢 <b>Управление сообщениями в главном меню</b>\n\n'
|
||
'Здесь вы можете добавлять сообщения, которые будут показываться пользователям '
|
||
'в главном меню между информацией о подписке и кнопками действий.\n\n'
|
||
'• Сообщения поддерживают HTML теги\n'
|
||
'• Можно создать несколько сообщений\n'
|
||
'• Активные сообщения показываются случайно\n'
|
||
'• Неактивные сообщения не показываются'
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=get_user_messages_keyboard(db_user.language), parse_mode='HTML')
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def add_user_message_start(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||
await callback.message.edit_text(
|
||
f'📝 <b>Добавление нового сообщения</b>\n\n'
|
||
f'Введите текст сообщения, которое будет показываться в главном меню.\n\n'
|
||
f'{get_html_help_text()}\n\n'
|
||
f'Отправьте /cancel для отмены.',
|
||
parse_mode='HTML',
|
||
)
|
||
|
||
await state.set_state(UserMessageStates.waiting_for_message_text)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def process_new_message_text(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession):
|
||
if message.text == '/cancel':
|
||
await state.clear()
|
||
await message.answer(
|
||
'❌ Добавление сообщения отменено.', reply_markup=get_user_messages_keyboard(db_user.language)
|
||
)
|
||
return
|
||
|
||
message_text = message.text.strip()
|
||
|
||
if len(message_text) > 4000:
|
||
await message.answer(
|
||
'❌ Сообщение слишком длинное. Максимум 4000 символов.\n'
|
||
'Попробуйте еще раз или отправьте /cancel для отмены.'
|
||
)
|
||
return
|
||
|
||
is_valid, error_msg = validate_html_tags(message_text)
|
||
if not is_valid:
|
||
await message.answer(
|
||
f'❌ Ошибка в HTML разметке: {error_msg}\n\n'
|
||
f'Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.',
|
||
parse_mode=None,
|
||
)
|
||
return
|
||
|
||
try:
|
||
new_message = await create_user_message(db=db, message_text=message_text, created_by=db_user.id, is_active=True)
|
||
|
||
await state.clear()
|
||
|
||
await message.answer(
|
||
f'✅ <b>Сообщение добавлено!</b>\n\n'
|
||
f'<b>ID:</b> {new_message.id}\n'
|
||
f'<b>Статус:</b> {"🟢 Активно" if new_message.is_active else "🔴 Неактивно"}\n'
|
||
f'<b>Создано:</b> {new_message.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
||
f'<b>Предварительный просмотр:</b>\n'
|
||
f'<blockquote>{message_text}</blockquote>',
|
||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||
parse_mode='HTML',
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error('Ошибка создания сообщения', error=e)
|
||
await state.clear()
|
||
await message.answer(
|
||
'❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.',
|
||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||
)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def list_user_messages(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
page = 0
|
||
if ':' in callback.data:
|
||
try:
|
||
page = int(callback.data.split(':')[1])
|
||
except (ValueError, IndexError):
|
||
page = 0
|
||
|
||
limit = 5
|
||
offset = page * limit
|
||
|
||
messages = await get_all_user_messages(db, offset=offset, limit=limit)
|
||
|
||
if not messages:
|
||
await callback.message.edit_text(
|
||
'📋 <b>Список сообщений</b>\n\nСообщений пока нет. Добавьте первое сообщение!',
|
||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
text = '📋 <b>Список сообщений</b>\n\n'
|
||
|
||
for msg in messages:
|
||
status_emoji = '🟢' if msg.is_active else '🔴'
|
||
preview = msg.message_text[:100] + '...' if len(msg.message_text) > 100 else msg.message_text
|
||
preview = preview.replace('<', '<').replace('>', '>')
|
||
|
||
text += f'{status_emoji} <b>ID {msg.id}</b>\n{preview}\n📅 {msg.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
||
|
||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||
|
||
keyboard = []
|
||
|
||
for msg in messages:
|
||
status_emoji = '🟢' if msg.is_active else '🔴'
|
||
keyboard.append(
|
||
[InlineKeyboardButton(text=f'{status_emoji} ID {msg.id}', callback_data=f'view_user_message:{msg.id}')]
|
||
)
|
||
|
||
nav_buttons = []
|
||
if page > 0:
|
||
nav_buttons.append(InlineKeyboardButton(text='⬅️ Назад', callback_data=f'list_user_messages:{page - 1}'))
|
||
|
||
nav_buttons.append(InlineKeyboardButton(text='➕ Добавить', callback_data='add_user_message'))
|
||
|
||
if len(messages) == limit:
|
||
nav_buttons.append(InlineKeyboardButton(text='Вперед ➡️', callback_data=f'list_user_messages:{page + 1}'))
|
||
|
||
if nav_buttons:
|
||
keyboard.append(nav_buttons)
|
||
|
||
keyboard.append([InlineKeyboardButton(text='🔙 Назад', callback_data='user_messages_panel')])
|
||
|
||
await callback.message.edit_text(
|
||
text, reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML'
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def view_user_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
try:
|
||
message_id = int(callback.data.split(':')[1])
|
||
except (ValueError, IndexError):
|
||
await callback.answer('❌ Неверный ID сообщения', show_alert=True)
|
||
return
|
||
|
||
message = await get_user_message_by_id(db, message_id)
|
||
|
||
if not message:
|
||
await callback.answer('❌ Сообщение не найдено', show_alert=True)
|
||
return
|
||
|
||
safe_content = sanitize_html(message.message_text)
|
||
|
||
status_text = '🟢 Активно' if message.is_active else '🔴 Неактивно'
|
||
|
||
text = (
|
||
f'📋 <b>Сообщение ID {message.id}</b>\n\n'
|
||
f'<b>Статус:</b> {status_text}\n'
|
||
f'<b>Создано:</b> {message.created_at.strftime("%d.%m.%Y %H:%M")}\n'
|
||
f'<b>Обновлено:</b> {message.updated_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
||
f'<b>Содержимое:</b>\n'
|
||
f'<blockquote>{safe_content}</blockquote>'
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=get_message_actions_keyboard(message_id, message.is_active, db_user.language),
|
||
parse_mode='HTML',
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def toggle_message_status(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
try:
|
||
message_id = int(callback.data.split(':')[1])
|
||
except (ValueError, IndexError):
|
||
await callback.answer('❌ Неверный ID сообщения', show_alert=True)
|
||
return
|
||
|
||
message = await toggle_user_message_status(db, message_id)
|
||
|
||
if not message:
|
||
await callback.answer('❌ Сообщение не найдено', show_alert=True)
|
||
return
|
||
|
||
status_text = 'активировано' if message.is_active else 'деактивировано'
|
||
await callback.answer(f'✅ Сообщение {status_text}')
|
||
|
||
await view_user_message(callback, db_user, db)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def delete_message_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
"""Подтвердить удаление сообщения"""
|
||
try:
|
||
message_id = int(callback.data.split(':')[1])
|
||
except (ValueError, IndexError):
|
||
await callback.answer('❌ Неверный ID сообщения', show_alert=True)
|
||
return
|
||
|
||
success = await delete_user_message(db, message_id)
|
||
|
||
if success:
|
||
await callback.answer('✅ Сообщение удалено')
|
||
await list_user_messages(
|
||
types.CallbackQuery(
|
||
id=callback.id,
|
||
from_user=callback.from_user,
|
||
chat_instance=callback.chat_instance,
|
||
data='list_user_messages:0',
|
||
message=callback.message,
|
||
),
|
||
db_user,
|
||
db,
|
||
)
|
||
else:
|
||
await callback.answer('❌ Ошибка удаления сообщения', show_alert=True)
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def show_messages_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||
stats = await get_user_messages_stats(db)
|
||
|
||
text = (
|
||
'📊 <b>Статистика сообщений</b>\n\n'
|
||
f'📝 Всего сообщений: <b>{stats["total_messages"]}</b>\n'
|
||
f'🟢 Активных: <b>{stats["active_messages"]}</b>\n'
|
||
f'🔴 Неактивных: <b>{stats["inactive_messages"]}</b>\n\n'
|
||
'Активные сообщения показываются пользователям случайным образом '
|
||
'в главном меню между информацией о подписке и кнопками действий.'
|
||
)
|
||
|
||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||
|
||
keyboard = InlineKeyboardMarkup(
|
||
inline_keyboard=[[InlineKeyboardButton(text='🔙 Назад', callback_data='user_messages_panel')]]
|
||
)
|
||
|
||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML')
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def edit_user_message_start(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||
try:
|
||
message_id = int(callback.data.split(':')[1])
|
||
except (ValueError, IndexError):
|
||
await callback.answer('❌ Неверный ID сообщения', show_alert=True)
|
||
return
|
||
|
||
message = await get_user_message_by_id(db, message_id)
|
||
|
||
if not message:
|
||
await callback.answer('❌ Сообщение не найдено', show_alert=True)
|
||
return
|
||
|
||
await callback.message.edit_text(
|
||
f'✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n'
|
||
f'<b>Текущий текст:</b>\n'
|
||
f'<blockquote>{sanitize_html(message.message_text)}</blockquote>\n\n'
|
||
f'Введите новый текст сообщения или отправьте /cancel для отмены:',
|
||
parse_mode='HTML',
|
||
)
|
||
|
||
await state.set_data({'editing_message_id': message_id})
|
||
await state.set_state(UserMessageStates.waiting_for_edit_text)
|
||
await callback.answer()
|
||
|
||
|
||
@admin_required
|
||
@error_handler
|
||
async def process_edit_message_text(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession):
|
||
if message.text == '/cancel':
|
||
await state.clear()
|
||
await message.answer('❌ Редактирование отменено.', reply_markup=get_user_messages_keyboard(db_user.language))
|
||
return
|
||
|
||
data = await state.get_data()
|
||
message_id = data.get('editing_message_id')
|
||
|
||
if not message_id:
|
||
await state.clear()
|
||
await message.answer('❌ Ошибка: ID сообщения не найден')
|
||
return
|
||
|
||
new_text = message.text.strip()
|
||
|
||
if len(new_text) > 4000:
|
||
await message.answer(
|
||
'❌ Сообщение слишком длинное. Максимум 4000 символов.\n'
|
||
'Попробуйте еще раз или отправьте /cancel для отмены.'
|
||
)
|
||
return
|
||
|
||
is_valid, error_msg = validate_html_tags(new_text)
|
||
if not is_valid:
|
||
await message.answer(
|
||
f'❌ Ошибка в HTML разметке: {error_msg}\n\n'
|
||
f'Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.',
|
||
parse_mode=None,
|
||
)
|
||
return
|
||
|
||
try:
|
||
updated_message = await update_user_message(db=db, message_id=message_id, message_text=new_text)
|
||
|
||
if updated_message:
|
||
await state.clear()
|
||
await message.answer(
|
||
f'✅ <b>Сообщение обновлено!</b>\n\n'
|
||
f'<b>ID:</b> {updated_message.id}\n'
|
||
f'<b>Обновлено:</b> {updated_message.updated_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
||
f'<b>Новый текст:</b>\n'
|
||
f'<blockquote>{sanitize_html(new_text)}</blockquote>',
|
||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||
parse_mode='HTML',
|
||
)
|
||
else:
|
||
await state.clear()
|
||
await message.answer(
|
||
'❌ Сообщение не найдено или ошибка обновления.',
|
||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error('Ошибка обновления сообщения', error=e)
|
||
await state.clear()
|
||
await message.answer(
|
||
'❌ Произошла ошибка при обновлении сообщения.', reply_markup=get_user_messages_keyboard(db_user.language)
|
||
)
|
||
|
||
|
||
def register_handlers(dp: Dispatcher):
|
||
dp.callback_query.register(show_user_messages_panel, F.data == 'user_messages_panel')
|
||
|
||
dp.callback_query.register(add_user_message_start, F.data == 'add_user_message')
|
||
|
||
dp.message.register(process_new_message_text, StateFilter(UserMessageStates.waiting_for_message_text))
|
||
|
||
dp.callback_query.register(edit_user_message_start, F.data.startswith('edit_user_message:'))
|
||
|
||
dp.message.register(process_edit_message_text, StateFilter(UserMessageStates.waiting_for_edit_text))
|
||
|
||
dp.callback_query.register(list_user_messages, F.data.startswith('list_user_messages'))
|
||
|
||
dp.callback_query.register(view_user_message, F.data.startswith('view_user_message:'))
|
||
|
||
dp.callback_query.register(toggle_message_status, F.data.startswith('toggle_user_message:'))
|
||
|
||
dp.callback_query.register(delete_message_confirm, F.data.startswith('delete_user_message:'))
|
||
|
||
dp.callback_query.register(show_messages_stats, F.data == 'user_messages_stats')
|