Some checks failed
continuous-integration/drone/pr Build is failing
- Messages now show just sender name (bold) followed by message text
- For admin senders: displays as 'АДМИН'
- For regular users to admins: shows 'Nickname (карта: XXXX)'
- Removed decorative emoji prefixes (💬) for cleaner messaging
- Applies consistent formatting across text, photo, video, and document messages
460 lines
18 KiB
Python
460 lines
18 KiB
Python
"""Обработчики P2P чата между пользователями"""
|
||
from aiogram import Router, F
|
||
from aiogram.filters import Command, StateFilter
|
||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||
|
||
from src.filters.case_insensitive import CaseInsensitiveCommand
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from typing import Optional
|
||
|
||
from src.core.p2p_services import P2PMessageService
|
||
from src.core.services import UserService
|
||
from src.core.models import User
|
||
from src.core.database import async_session_maker
|
||
from src.core.config import ADMIN_IDS
|
||
|
||
|
||
router = Router(name='p2p_chat_router')
|
||
|
||
|
||
class P2PChatStates(StatesGroup):
|
||
"""Состояния для P2P чата"""
|
||
waiting_for_recipient = State() # Ожидание выбора получателя
|
||
chatting = State() # В процессе переписки с пользователем
|
||
|
||
|
||
def is_admin(user_id: int) -> bool:
|
||
"""Проверка прав администратора"""
|
||
return user_id in ADMIN_IDS
|
||
|
||
|
||
def format_sender_name(user: User, is_current_user: bool = False, current_user_is_admin: bool = False) -> str:
|
||
"""
|
||
Форматирует имя отправителя для отображения в чате
|
||
|
||
Args:
|
||
user: Объект пользователя
|
||
is_current_user: Текущий ли это пользователь
|
||
current_user_is_admin: Админ ли текущий пользователь
|
||
|
||
Returns:
|
||
Отформатированное имя
|
||
"""
|
||
if is_current_user:
|
||
return "🔵 Вы"
|
||
|
||
# Если это администратор и текущий пользователь не админ - показываем "Админ"
|
||
if user.is_admin and not current_user_is_admin:
|
||
return "🔵 Админ"
|
||
|
||
# Формируем базовое имя (используем nickname из профиля)
|
||
name = user.nickname or user.first_name or f"@{user.username}" or "Unknown"
|
||
|
||
# Добавляем информацию о карте если пользователь админ и текущий юзер админ
|
||
if current_user_is_admin and user.club_card_number:
|
||
name += f" (карта: {user.club_card_number})"
|
||
|
||
return f"🔵 {name}"
|
||
|
||
|
||
@router.message(CaseInsensitiveCommand("chat"))
|
||
async def show_chat_menu(message: Message, state: FSMContext):
|
||
"""
|
||
Главное меню чата (регистронезависимо)
|
||
/chat - показать меню с опциями общения
|
||
"""
|
||
# Очищаем состояние при входе в меню (выход из диалога)
|
||
await state.clear()
|
||
|
||
async with async_session_maker() as session:
|
||
user = await UserService.get_or_create_user(
|
||
session,
|
||
message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name
|
||
)
|
||
|
||
if not user:
|
||
await message.answer("❌ Вы не зарегистрированы. Используйте /start")
|
||
return
|
||
|
||
# Получаем количество непрочитанных сообщений
|
||
unread_count = await P2PMessageService.get_unread_count(session, user.id)
|
||
|
||
# Получаем последние диалоги
|
||
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
|
||
|
||
text = "💬 <b>Чат</b>\n\n"
|
||
|
||
if unread_count > 0:
|
||
text += f"📨 У вас <b>{unread_count}</b> непрочитанных сообщений\n\n"
|
||
|
||
text += "Выберите действие:"
|
||
|
||
buttons = [
|
||
[InlineKeyboardButton(
|
||
text="✉️ Написать пользователю",
|
||
callback_data="p2p:select_user"
|
||
)],
|
||
[InlineKeyboardButton(
|
||
text="📋 Мои диалоги",
|
||
callback_data="p2p:my_conversations"
|
||
)]
|
||
]
|
||
|
||
if is_admin(message.from_user.id):
|
||
buttons.append([InlineKeyboardButton(
|
||
text="📢 Написать всем (broadcast)",
|
||
callback_data="p2p:broadcast"
|
||
)])
|
||
|
||
await message.answer(
|
||
text,
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data == "p2p:select_user")
|
||
async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||
"""Выбор получателя для P2P сообщения"""
|
||
await callback.answer()
|
||
|
||
async with async_session_maker() as session:
|
||
# Получаем всех зарегистрированных пользователей кроме себя
|
||
users = await UserService.get_all_users(session)
|
||
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
|
||
|
||
if not users:
|
||
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
||
return
|
||
|
||
# Создаём кнопки с пользователями (по 1 на строку)
|
||
buttons = []
|
||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||
display_name = user.nickname or f"@{user.username}" or user.first_name or "Unknown"
|
||
if user.club_card_number:
|
||
display_name += f" (карта: {user.club_card_number})"
|
||
|
||
buttons.append([InlineKeyboardButton(
|
||
text=display_name,
|
||
callback_data=f"p2p:user:{user.id}"
|
||
)])
|
||
|
||
buttons.append([InlineKeyboardButton(
|
||
text="« Назад",
|
||
callback_data="p2p:back_to_menu"
|
||
)])
|
||
|
||
await callback.message.edit_text(
|
||
"👥 <b>Выберите пользователя:</b>\n\n"
|
||
"Кликните на пользователя, чтобы начать диалог",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data.startswith("p2p:user:"))
|
||
async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
||
"""Начать диалог с выбранным пользователем"""
|
||
await callback.answer()
|
||
|
||
user_id = int(callback.data.split(":")[2])
|
||
|
||
async with async_session_maker() as session:
|
||
recipient = await session.get(User, user_id)
|
||
|
||
if not recipient:
|
||
await callback.message.edit_text("❌ Пользователь не найден")
|
||
return
|
||
|
||
sender = await UserService.get_or_create_user(
|
||
session,
|
||
callback.from_user.id,
|
||
username=callback.from_user.username,
|
||
first_name=callback.from_user.first_name,
|
||
last_name=callback.from_user.last_name
|
||
)
|
||
|
||
# Получаем последние 10 сообщений из диалога
|
||
messages = await P2PMessageService.get_conversation(
|
||
session,
|
||
sender.id,
|
||
recipient.id,
|
||
limit=10
|
||
)
|
||
|
||
# Сохраняем ID получателя в состоянии
|
||
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
|
||
await state.set_state(P2PChatStates.chatting)
|
||
|
||
recipient_name = recipient.nickname or f"@{recipient.username}" or recipient.first_name or "Unknown"
|
||
|
||
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
||
|
||
if messages:
|
||
text += "📝 <b>Последние сообщения:</b>\n\n"
|
||
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
||
# Определяем имя отправителя
|
||
is_current = msg.sender_id == sender.id
|
||
user_for_display = sender if is_current else recipient
|
||
sender_name = format_sender_name(user_for_display, is_current, is_admin(sender.telegram_id))
|
||
|
||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||
text += f"• {sender_name}: {msg_text}\n"
|
||
text += "\n"
|
||
|
||
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
||
text += "⚠️ <b>Важно:</b> В режиме диалога все сообщения отправляются только собеседнику.\n"
|
||
text += "Для выхода в общий чат используйте кнопку ниже или команду /chat"
|
||
|
||
buttons = [[InlineKeyboardButton(
|
||
text="« Завершить диалог",
|
||
callback_data="p2p:end_conversation"
|
||
)]]
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data == "p2p:my_conversations")
|
||
async def show_conversations(callback: CallbackQuery):
|
||
"""Показать список диалогов"""
|
||
await callback.answer()
|
||
|
||
async with async_session_maker() as session:
|
||
sender = await UserService.get_or_create_user(
|
||
session,
|
||
callback.from_user.id,
|
||
username=callback.from_user.username,
|
||
first_name=callback.from_user.first_name,
|
||
last_name=callback.from_user.last_name
|
||
)
|
||
|
||
conversations = await P2PMessageService.get_recent_conversations(session, sender.id, limit=10)
|
||
|
||
if not conversations:
|
||
await callback.message.edit_text(
|
||
"📭 У вас пока нет диалогов\n\n"
|
||
"Используйте /chat чтобы написать кому-нибудь"
|
||
)
|
||
return
|
||
|
||
text = "📋 <b>Ваши диалоги:</b>\n\n"
|
||
|
||
buttons = []
|
||
for peer, last_msg, unread in conversations:
|
||
peer_name = peer.nickname or f"@{peer.username}" or peer.first_name or "Unknown"
|
||
|
||
# Иконка в зависимости от непрочитанных
|
||
icon = "🔴" if unread > 0 else "💬"
|
||
|
||
# Превью последнего сообщения
|
||
preview = last_msg.text[:30] + "..." if last_msg.text and len(last_msg.text) > 30 else (last_msg.text or f"[{last_msg.message_type}]")
|
||
|
||
button_text = f"{icon} {peer_name}"
|
||
if unread > 0:
|
||
button_text += f" ({unread})"
|
||
|
||
buttons.append([InlineKeyboardButton(
|
||
text=button_text,
|
||
callback_data=f"p2p:user:{peer.id}"
|
||
)])
|
||
|
||
text += f"{icon} <b>{peer_name}</b>"
|
||
# Показываем номер карты если есть
|
||
if peer.club_card_number:
|
||
text += f" (карта: {peer.club_card_number})"
|
||
text += "\n"
|
||
text += f" {preview}\n"
|
||
if unread > 0:
|
||
text += f" 📨 Непрочитанных: {unread}\n"
|
||
text += "\n"
|
||
|
||
buttons.append([InlineKeyboardButton(
|
||
text="« Назад",
|
||
callback_data="p2p:back_to_menu"
|
||
)])
|
||
|
||
await callback.message.edit_text(
|
||
text,
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data == "p2p:end_conversation")
|
||
async def end_conversation(callback: CallbackQuery, state: FSMContext):
|
||
"""Завершить текущий диалог"""
|
||
await callback.answer("Диалог завершён")
|
||
await state.clear()
|
||
|
||
await callback.message.edit_text(
|
||
"✅ Диалог завершён\n\n"
|
||
"Используйте /chat чтобы открыть меню чата"
|
||
)
|
||
|
||
|
||
@router.callback_query(F.data == "p2p:back_to_menu")
|
||
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
||
"""Вернуться в главное меню"""
|
||
await callback.answer()
|
||
await state.clear()
|
||
|
||
async with async_session_maker() as session:
|
||
user = await UserService.get_or_create_user(
|
||
session,
|
||
callback.from_user.id,
|
||
username=callback.from_user.username,
|
||
first_name=callback.from_user.first_name,
|
||
last_name=callback.from_user.last_name
|
||
)
|
||
|
||
if not user:
|
||
await callback.message.edit_text("❌ Вы не зарегистрированы. Используйте /start")
|
||
return
|
||
|
||
# Получаем количество непрочитанных сообщений
|
||
unread_count = await P2PMessageService.get_unread_count(session, user.id)
|
||
|
||
# Получаем последние диалоги
|
||
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
|
||
|
||
text = "💬 <b>Чат</b>\n\n"
|
||
|
||
if unread_count > 0:
|
||
text += f"📨 У вас <b>{unread_count}</b> непрочитанных сообщений\n\n"
|
||
|
||
text += "Выберите действие:"
|
||
|
||
buttons = [
|
||
[InlineKeyboardButton(
|
||
text="✉️ Написать пользователю",
|
||
callback_data="p2p:select_user"
|
||
)],
|
||
[InlineKeyboardButton(
|
||
text="📋 Мои диалоги",
|
||
callback_data="p2p:my_conversations"
|
||
)]
|
||
]
|
||
|
||
if recent:
|
||
text += "\n\n<b>Последние диалоги:</b>\n"
|
||
for peer, last_msg, unread in recent:
|
||
unread_badge = f" ({unread})" if unread > 0 else ""
|
||
text += f" • @{peer.username or peer.first_name}{unread_badge}\n"
|
||
|
||
kb = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
await callback.message.edit_text(text, reply_markup=kb, parse_mode="HTML")
|
||
|
||
|
||
# Обработчик сообщений в состоянии chatting
|
||
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
|
||
async def handle_p2p_message(message: Message, state: FSMContext):
|
||
"""Обработка P2P сообщения от пользователя"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.info(f"[P2P] handle_p2p_message вызван: user={message.from_user.id}, в состоянии P2P chatting")
|
||
|
||
data = await state.get_data()
|
||
recipient_id = data.get("recipient_id")
|
||
recipient_telegram_id = data.get("recipient_telegram_id")
|
||
|
||
if not recipient_id or not recipient_telegram_id:
|
||
await message.answer("❌ Ошибка: получатель не найден. Начните диалог заново с /chat")
|
||
await state.clear()
|
||
return
|
||
|
||
async with async_session_maker() as session:
|
||
sender = await UserService.get_or_create_user(
|
||
session,
|
||
message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name
|
||
)
|
||
|
||
# Получаем информацию о получателе для определения как подписать сообщение
|
||
recipient = await UserService.get_by_telegram_id(session, recipient_telegram_id)
|
||
|
||
# Формируем подпись сообщения для получателя
|
||
if sender.is_admin:
|
||
sender_name = "АДМИН"
|
||
else:
|
||
sender_name = sender.nickname or f"@{sender.username}" or sender.first_name or "Unknown"
|
||
|
||
# Добавляем карту если получатель админ
|
||
if recipient and recipient.is_admin and sender.club_card_number:
|
||
sender_name += f" (карта: {sender.club_card_number})"
|
||
|
||
# Определяем тип сообщения
|
||
message_type = "text"
|
||
text = message.text
|
||
file_id = None
|
||
|
||
if message.photo:
|
||
message_type = "photo"
|
||
file_id = message.photo[-1].file_id
|
||
text = message.caption
|
||
elif message.video:
|
||
message_type = "video"
|
||
file_id = message.video.file_id
|
||
text = message.caption
|
||
elif message.document:
|
||
message_type = "document"
|
||
file_id = message.document.file_id
|
||
text = message.caption
|
||
|
||
# Отправляем сообщение получателю
|
||
try:
|
||
if message_type == "text":
|
||
sent = await message.bot.send_message(
|
||
recipient_telegram_id,
|
||
f"<b>{sender_name}</b>\n\n{text}",
|
||
parse_mode="HTML"
|
||
)
|
||
elif message_type == "photo":
|
||
sent = await message.bot.send_photo(
|
||
recipient_telegram_id,
|
||
photo=file_id,
|
||
caption=f"<b>{sender_name}</b>\n\n{text or ''}" ,
|
||
parse_mode="HTML"
|
||
)
|
||
elif message_type == "video":
|
||
sent = await message.bot.send_video(
|
||
recipient_telegram_id,
|
||
video=file_id,
|
||
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||
parse_mode="HTML"
|
||
)
|
||
elif message_type == "document":
|
||
sent = await message.bot.send_document(
|
||
recipient_telegram_id,
|
||
document=file_id,
|
||
caption=f"<b>{sender_name}</b>\n\n{text or ''}",
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
# Сохраняем в БД
|
||
await P2PMessageService.send_message(
|
||
session,
|
||
sender_id=sender.id,
|
||
recipient_id=recipient_id,
|
||
message_type=message_type,
|
||
text=text,
|
||
file_id=file_id,
|
||
sender_message_id=message.message_id,
|
||
recipient_message_id=sent.message_id
|
||
)
|
||
|
||
await message.answer("✅ Сообщение доставлено")
|
||
|
||
except Exception as e:
|
||
await message.answer(f"❌ Не удалось доставить сообщение: {e}")
|