nakama/moderation/backend-py/websocket_server.py

261 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,
allow_upgrades=True, # Разрешить upgrade с polling на websocket
transports=['polling', 'websocket'] # Поддерживаемые транспорты
)
# 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)
# В python-socketio для ASGI режима нужно использовать декоратор БЕЗ явного namespace='/'
# или использовать namespace=None для корневого 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
return True
@sio.on('disconnect')
async def on_disconnect_root(sid):
"""Handle disconnection from root namespace"""
print(f"[WebSocket] Client disconnected from ROOT namespace: {sid}")
logger.info(f"[WebSocket] Client disconnected from ROOT namespace: {sid}")
# Обработчик auth в корневом namespace - обрабатываем так же, как в /mod-chat
@sio.on('auth')
async def on_auth_root(sid, data):
"""Handle auth in root namespace - process same as /mod-chat"""
print(f"[WebSocket] 📥 Auth запрос в ROOT namespace от {sid}: {data}")
print(f"[WebSocket] ⚠️ Клиент авторизуется в корневом namespace, обрабатываем здесь")
try:
username = normalize_username(data.get('username')) if data.get('username') else None
telegram_id = data.get('telegramId')
print(f"[WebSocket] Обработка auth в ROOT: username={username}, telegramId={telegram_id}")
if not username or not telegram_id:
print(f"[WebSocket] ❌ Auth failed: missing username or telegramId")
await sio.emit('unauthorized', room=sid)
await sio.disconnect(sid)
return
# Check if user is owner
owner_usernames = settings.OWNER_USERNAMES_LIST
is_owner = username.lower() in [u.lower() for u in owner_usernames]
# Check if user is moderation admin
admin = await moderation_admins_collection().find_one({
'telegramId': str(telegram_id)
})
is_admin = admin is not None
if not is_owner and not is_admin:
print(f"[WebSocket] ❌ Access denied: {username} (telegramId: {telegram_id})")
await sio.emit('unauthorized', room=sid)
await sio.disconnect(sid)
return
# Store connection data (используем тот же словарь, что и для /mod-chat)
connected_moderators[sid] = {
'username': username,
'telegramId': telegram_id,
'isOwner': is_owner
}
print(f"[WebSocket] ✅ Auth success в ROOT namespace: {username} (owner: {is_owner}, admin: {is_admin})")
await sio.emit('ready', room=sid)
broadcast_online()
except Exception as e:
print(f"[WebSocket] ❌ Error in auth (ROOT): {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
await sio.emit('unauthorized', room=sid)
await sio.disconnect(sid)
# 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"""
print("[WebSocket] Инициализация Socket.IO ASGI app...")
try:
# Проверяем зарегистрированные namespaces
if hasattr(sio, 'namespace_handlers'):
namespaces = list(sio.namespace_handlers.keys())
print(f"[WebSocket] Зарегистрированные namespaces: {namespaces}")
if not namespaces:
print("[WebSocket] ⚠️ ВНИМАНИЕ: Нет зарегистрированных namespaces!")
elif hasattr(sio, 'handlers'):
print(f"[WebSocket] Зарегистрированные handlers: {list(sio.handlers.keys())}")
else:
print("[WebSocket] Не удалось определить зарегистрированные namespaces")
# Попробуем проверить через другие атрибуты
print(f"[WebSocket] Доступные атрибуты sio: {[attr for attr in dir(sio) if not attr.startswith('_')]}")
except Exception as e:
print(f"[WebSocket] Ошибка при проверке namespaces: {e}")
import traceback
traceback.print_exc()
# Socket.IO ASGI app должен обернуть FastAPI app для правильной работы
# Но мы делаем это в main.py через SocketIOWrapper
# Здесь просто возвращаем ASGI app для Socket.IO
# socketio_path указывает путь, по которому Socket.IO будет слушать
app = socketio.ASGIApp(sio, socketio_path='/socket.io')
print("[WebSocket] ✅ Socket.IO ASGI app создан")
return app