""" WebSocket server for moderation chat """ import socketio import logging from typing import Dict, Set, Optional from datetime import datetime from config import settings from database import moderation_admins_collection from utils.auth import normalize_username logger = logging.getLogger(__name__) # Create Socket.IO server sio = socketio.AsyncServer( async_mode='asgi', cors_allowed_origins='*', logger=True, # Включить логирование для отладки engineio_logger=True, # Включить логирование Engine.IO ping_timeout=60, ping_interval=25, always_connect=True # Разрешить подключение к корневому namespace ) # Track connected moderators connected_moderators: Dict[str, dict] = {} # {sid: {username, telegramId, isOwner}} def broadcast_online(): """Broadcast list of online moderators""" unique = {} for data in connected_moderators.values(): username = data.get('username') if username: unique[username] = data online_list = list(unique.values()) sio.emit('online', online_list, namespace='/mod-chat') # Обработчик для корневого namespace (требуется для Socket.IO handshake) # Socket.IO сначала подключается к корневому namespace для handshake, # затем клиент подключается к указанному namespace # В python-socketio для корневого namespace можно не указывать namespace явно @sio.on('connect') async def on_connect_root(sid, environ): """Handle client connection to root namespace (Socket.IO handshake)""" print(f"[WebSocket] 🔄 Handshake to ROOT namespace: {sid}") print(f"[WebSocket] Environ type: {type(environ)}") if environ: print(f"[WebSocket] Environ keys: {list(environ.keys()) if isinstance(environ, dict) else 'N/A'}") logger.info(f"[WebSocket] Handshake to ROOT namespace: {sid}") # Разрешаем подключение для handshake # Возвращаем True, чтобы разрешить подключение return True # Namespace handlers for /mod-chat @sio.on('connect', namespace='/mod-chat') async def on_connect(sid, environ): """Handle client connection to /mod-chat namespace""" print(f"[WebSocket] ✅ Client connected to /mod-chat: {sid}") logger.info(f"[WebSocket] Client connected to /mod-chat: {sid}") if environ: print(f"[WebSocket] Environ keys: {list(environ.keys()) if isinstance(environ, dict) else 'N/A'}") if isinstance(environ, dict): print(f"[WebSocket] Query string: {environ.get('QUERY_STRING', 'N/A')}") print(f"[WebSocket] Path: {environ.get('PATH_INFO', 'N/A')}") # Don't authorize immediately - wait for 'auth' event # Возвращаем True, чтобы разрешить подключение return True @sio.on('disconnect', namespace='/mod-chat') async def on_disconnect(sid): """Handle client disconnection""" logger.info(f"[WebSocket] Client disconnected from /mod-chat: {sid}") if sid in connected_moderators: del connected_moderators[sid] broadcast_online() @sio.on('auth', namespace='/mod-chat') async def on_auth(sid, data): """Handle authentication for moderation chat""" print(f"[WebSocket] 📥 Auth запрос от {sid}: {data}") try: username = normalize_username(data.get('username')) if data.get('username') else None telegram_id = data.get('telegramId') print(f"[WebSocket] Обработка auth: username={username}, telegramId={telegram_id}") if not username or not telegram_id: print(f"[WebSocket] ❌ Auth failed: missing username or telegramId") logger.warning(f"[WebSocket] Auth failed: missing username or telegramId") await sio.emit('unauthorized', namespace='/mod-chat', room=sid) await sio.disconnect(sid, namespace='/mod-chat') return # Check if user is owner owner_usernames = settings.OWNER_USERNAMES_LIST print(f"[WebSocket] Owner usernames: {owner_usernames}") is_owner = username.lower() in [u.lower() for u in owner_usernames] print(f"[WebSocket] Is owner: {is_owner}") # Check if user is moderation admin admin = await moderation_admins_collection().find_one({ 'telegramId': str(telegram_id) }) is_admin = admin is not None print(f"[WebSocket] Is admin: {is_admin}, admin data: {admin}") if not is_owner and not is_admin: print(f"[WebSocket] ❌ Access denied: {username} (telegramId: {telegram_id})") logger.warning(f"[WebSocket] Access denied: {username} (telegramId: {telegram_id})") await sio.emit('unauthorized', namespace='/mod-chat', room=sid) await sio.disconnect(sid, namespace='/mod-chat') return # Store connection data connected_moderators[sid] = { 'username': username, 'telegramId': telegram_id, 'isOwner': is_owner } print(f"[WebSocket] ✅ Auth success: {username} (owner: {is_owner}, admin: {is_admin})") logger.info(f"[WebSocket] Auth success: {username} (owner: {is_owner}, admin: {is_admin})") await sio.emit('ready', namespace='/mod-chat', room=sid) broadcast_online() except Exception as e: print(f"[WebSocket] ❌ Error in auth: {type(e).__name__}: {e}") logger.error(f"[WebSocket] Error in auth: {e}") import traceback traceback.print_exc() await sio.emit('unauthorized', namespace='/mod-chat', room=sid) await sio.disconnect(sid, namespace='/mod-chat') @sio.on('message', namespace='/mod-chat') async def on_message(sid, data): """Handle moderation chat message""" try: if sid not in connected_moderators: logger.warning(f"[WebSocket] Message from unauthorized client: {sid}") return user_data = connected_moderators[sid] text = (data.get('text') or '').strip() if not text: return message = { 'id': f"{int(datetime.utcnow().timestamp() * 1000)}-{sid[:8]}", 'username': user_data['username'], 'telegramId': user_data['telegramId'], 'text': text, 'createdAt': datetime.utcnow().isoformat() } # Broadcast to all in namespace await sio.emit('message', message, namespace='/mod-chat') logger.info(f"[WebSocket] Message from {user_data['username']}: {text[:50]}...") except Exception as e: logger.error(f"[WebSocket] Error handling message: {e}") import traceback traceback.print_exc() def get_socketio_app(): """Get Socket.IO ASGI app""" # Socket.IO ASGI app должен обернуть FastAPI app для правильной работы # Но мы делаем это в main.py через SocketIOWrapper # Здесь просто возвращаем ASGI app для Socket.IO # socketio_path указывает путь, по которому Socket.IO будет слушать return socketio.ASGIApp(sio, socketio_path='/socket.io')