feat: добавлен P2P чат между пользователями
- Новая модель P2PMessage для хранения личных сообщений - Миграция 008_add_p2p_messages.py - Сервис P2PMessageService для работы с P2P сообщениями - Команда /chat с меню чата - Выбор пользователя из списка - Отправка текста, фото, видео, документов - История последних диалогов - Счетчик непрочитанных сообщений - FSM состояния для управления диалогами
This commit is contained in:
335
src/handlers/p2p_chat.py
Normal file
335
src/handlers/p2p_chat.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Обработчики P2P чата между пользователями"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
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
|
||||
|
||||
|
||||
@router.message(Command("chat"))
|
||||
async def show_chat_menu(message: Message, state: FSMContext):
|
||||
"""
|
||||
Главное меню чата
|
||||
/chat - показать меню с опциями общения
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
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 = f"@{user.username}" if user.username else user.first_name
|
||||
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_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
# Получаем последние 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 = f"@{recipient.username}" if recipient.username else recipient.first_name
|
||||
|
||||
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
||||
|
||||
if messages:
|
||||
text += "📝 <b>Последние сообщения:</b>\n\n"
|
||||
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
||||
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name
|
||||
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"
|
||||
text += "Для выхода нажмите кнопку ниже"
|
||||
|
||||
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:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
conversations = await P2PMessageService.get_recent_conversations(session, user.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 = f"@{peer.username}" if peer.username else peer.first_name
|
||||
|
||||
# Иконка в зависимости от непрочитанных
|
||||
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>\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):
|
||||
"""Вернуться в главное меню"""
|
||||
await callback.answer()
|
||||
|
||||
# Имитируем команду /chat
|
||||
fake_message = callback.message
|
||||
fake_message.from_user = callback.from_user
|
||||
|
||||
await show_chat_menu(fake_message, None)
|
||||
|
||||
|
||||
# Обработчик сообщений в состоянии chatting
|
||||
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
|
||||
async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
"""Обработка P2P сообщения от пользователя"""
|
||||
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_user_by_telegram_id(session, message.from_user.id)
|
||||
sender_name = f"@{sender.username}" if sender.username else sender.first_name
|
||||
|
||||
# Определяем тип сообщения
|
||||
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}")
|
||||
Reference in New Issue
Block a user