JCEF migration: add jcefmaven dep, make JavaFX optional, create LogBridge, wire JCEFLauncher entry point
This commit is contained in:
+216
-7
@@ -43,6 +43,32 @@ class BanUserRequest(BaseModel):
|
||||
days: int = Field(..., ge=1, le=365)
|
||||
reason: str
|
||||
|
||||
class CreatePassCodeRequest(BaseModel):
|
||||
expires_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
max_uses: int = Field(1, ge=1, le=10)
|
||||
|
||||
class UpdatePassRequest(BaseModel):
|
||||
code: str
|
||||
expires_days: Optional[int] = None
|
||||
max_uses: int = Field(1, ge=1, le=10)
|
||||
is_active: bool = True
|
||||
|
||||
class DeletePassRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
class CreatePassRequest(BaseModel):
|
||||
expires_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
max_uses: int = Field(1, ge=1, le=10)
|
||||
|
||||
class UpdatePassRequest(BaseModel):
|
||||
code: str
|
||||
expires_days: Optional[int] = None
|
||||
max_uses: int = Field(1, ge=1, le=10)
|
||||
is_active: bool = True
|
||||
|
||||
class DeletePassRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ======================
|
||||
|
||||
@router.get("/users")
|
||||
@@ -532,11 +558,17 @@ async def get_admin_stats(
|
||||
recent_registrations = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM users WHERE created_at > ?
|
||||
""", (week_ago,)).fetchone()["count"]
|
||||
|
||||
|
||||
# Онлайн пользователи
|
||||
online_now = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM user_status WHERE is_online = 1
|
||||
""").fetchone()["count"]
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_passes": active_passes,
|
||||
"banned_users": banned_users,
|
||||
"online_now": online_now,
|
||||
"recent_registrations_7d": recent_registrations,
|
||||
"roles_distribution": [
|
||||
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
|
||||
@@ -550,6 +582,85 @@ async def get_admin_stats(
|
||||
}
|
||||
|
||||
|
||||
# ====================== PASS CRUD ======================
|
||||
|
||||
@router.post("/passes/create")
|
||||
async def admin_create_pass(
|
||||
body: CreatePassCodeRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Create a new pass code"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
pass_code = secrets.token_hex(12).upper()
|
||||
now = time.time()
|
||||
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_at)
|
||||
VALUES (?, ?, ?, ?, 1, 0, ?)
|
||||
""", (pass_code, 'admin', expires_at, body.max_uses, now))
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "pass_create",
|
||||
f"Created pass {pass_code[:8]}... (expires: {body.expires_days}d, max_uses: {body.max_uses})", ip)
|
||||
|
||||
return {"success": True, "code": pass_code, "expires_at": expires_at, "max_uses": body.max_uses}
|
||||
|
||||
|
||||
@router.put("/passes/update")
|
||||
async def admin_update_pass(
|
||||
body: UpdatePassRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Update an existing pass"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Pass not found")
|
||||
|
||||
expires_at = time.time() + (body.expires_days * 86400) if body.expires_days else None
|
||||
|
||||
conn.execute("""
|
||||
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
|
||||
WHERE code = ?
|
||||
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "pass_update",
|
||||
f"Updated pass {body.code[:8]}... (active: {body.is_active})", ip)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/passes/delete")
|
||||
async def admin_delete_pass(
|
||||
body: DeletePassRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Delete (deactivate) a pass"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Pass not found")
|
||||
|
||||
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
|
||||
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "pass_delete",
|
||||
f"Deleted pass {body.code[:8]}...", ip)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ====================== WHITELIST MODS ======================
|
||||
|
||||
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
@@ -656,15 +767,21 @@ async def remove_whitelist_mod(
|
||||
|
||||
@router.get("/clients")
|
||||
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
|
||||
"""List online clients"""
|
||||
"""List online clients with 5-minute stale timeout"""
|
||||
with get_db() as conn:
|
||||
# Auto-clean stale online status (no heartbeat > 5 min)
|
||||
stale_cutoff = time.time() - 300
|
||||
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
|
||||
conn.commit()
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address
|
||||
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
|
||||
(SELECT s.ip_address FROM user_sessions s
|
||||
WHERE s.user_id = u.id AND s.is_active = 1
|
||||
ORDER BY s.created_at DESC LIMIT 1) as ip_address
|
||||
FROM users u
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1
|
||||
JOIN user_status us ON u.id = us.user_id
|
||||
WHERE us.is_online = 1
|
||||
GROUP BY u.id
|
||||
ORDER BY us.last_seen DESC
|
||||
""").fetchall()
|
||||
clients = []
|
||||
@@ -672,7 +789,7 @@ async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODE
|
||||
clients.append({
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"online": bool(row["is_online"]),
|
||||
"online": True,
|
||||
"current_pack": row["current_pack"] or "",
|
||||
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
|
||||
"ip": row["ip_address"] or ""
|
||||
@@ -811,6 +928,98 @@ async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODER
|
||||
return {"success": True, "passes": passes}
|
||||
|
||||
|
||||
@router.post("/passes/create")
|
||||
async def create_pass(
|
||||
body: CreatePassRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
pass_code = secrets.token_hex(12).upper()
|
||||
now = time.time()
|
||||
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_by, activated_at)
|
||||
VALUES (?, 'admin', ?, ?, 1, 0, NULL, ?)
|
||||
""", (pass_code, expires_at, body.max_uses, now))
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"pass_create",
|
||||
f"Created pass code {pass_code} (expires_days: {body.expires_days}, max_uses: {body.max_uses})",
|
||||
ip
|
||||
)
|
||||
|
||||
return {"success": True, "code": pass_code}
|
||||
|
||||
|
||||
@router.put("/passes/update")
|
||||
async def update_pass(
|
||||
body: UpdatePassRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT code FROM passes WHERE code = ?", (body.code,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Pass not found")
|
||||
|
||||
expires_at = None
|
||||
if body.expires_days is not None:
|
||||
expires_at = time.time() + (body.expires_days * 86400)
|
||||
|
||||
conn.execute("""
|
||||
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
|
||||
WHERE code = ?
|
||||
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"pass_update",
|
||||
f"Updated pass {body.code} (expires_days: {body.expires_days}, max_uses: {body.max_uses}, is_active: {body.is_active})",
|
||||
ip
|
||||
)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/passes/delete")
|
||||
async def delete_pass(
|
||||
body: DeletePassRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT code FROM passes WHERE code = ?", (body.code,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Pass not found")
|
||||
|
||||
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
|
||||
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"pass_delete",
|
||||
f"Deactivated pass {body.code}",
|
||||
ip
|
||||
)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/whitelist/mods/{mod_hash}/delete")
|
||||
async def admin_delete_whitelist_mod_adapter(
|
||||
mod_hash: str,
|
||||
|
||||
@@ -542,6 +542,10 @@ async def logout(current_user: dict = Depends(get_current_user), request: Reques
|
||||
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
|
||||
(current_user["id"],)
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE user_status SET is_online = 0 WHERE user_id = ?",
|
||||
(current_user["id"],)
|
||||
)
|
||||
|
||||
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
|
||||
|
||||
|
||||
@@ -116,7 +116,12 @@ async def remove_friend(
|
||||
@router.get("/friends/list")
|
||||
async def list_friends(current_user: dict = Depends(get_current_user)):
|
||||
friends = []
|
||||
stale_cutoff = time.time() - 300
|
||||
with get_db() as conn:
|
||||
# Auto-clean stale online
|
||||
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
|
||||
conn.commit()
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role,
|
||||
COALESCE(us.is_online, 0) as online,
|
||||
@@ -159,6 +164,17 @@ async def list_friend_requests(current_user: dict = Depends(get_current_user)):
|
||||
})
|
||||
return {"requests": requests}
|
||||
|
||||
def get_friends_ids(user_id: int) -> list[int]:
|
||||
"""Get list of friend user_ids for a given user"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END as friend_id
|
||||
FROM friendships f
|
||||
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
|
||||
""", (user_id, user_id, user_id)).fetchall()
|
||||
return [r["friend_id"] for r in rows]
|
||||
|
||||
|
||||
@router.post("/friends/status")
|
||||
async def update_status(
|
||||
req: StatusUpdateRequest,
|
||||
@@ -173,4 +189,11 @@ async def update_status(
|
||||
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
""", (current_user["id"], int(req.online), req.current_pack or ""))
|
||||
# Broadcast status change to friends via WebSocket
|
||||
try:
|
||||
from ws_manager import manager
|
||||
friend_ids = get_friends_ids(current_user["id"])
|
||||
await manager.broadcast_status_change(current_user["id"], friend_ids)
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok"}
|
||||
|
||||
+154
-2
@@ -11,7 +11,7 @@ import httpx
|
||||
import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
@@ -28,8 +28,9 @@ from log_manager import init_logging
|
||||
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||
from roles import Permissions, has_permission
|
||||
from admin_router import router as admin_router
|
||||
from friends import router as friends_router, init_friends_db
|
||||
from friends import router as friends_router, init_friends_db, get_friends_ids
|
||||
from playtime import router as playtime_router, init_playtime_db
|
||||
from ws_manager import manager
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -222,8 +223,14 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
asyncio.create_task(periodic_sync())
|
||||
|
||||
# Start WebSocket admin clients broadcaster
|
||||
broadcaster = asyncio.create_task(admin_clients_broadcaster())
|
||||
|
||||
yield
|
||||
|
||||
# Cancel WebSocket broadcaster
|
||||
broadcaster.cancel()
|
||||
|
||||
# Cleanup proxy client
|
||||
if proxy_client:
|
||||
await proxy_client.aclose()
|
||||
@@ -925,6 +932,151 @@ app.include_router(friends_router)
|
||||
app.include_router(playtime_router)
|
||||
|
||||
|
||||
# ====================== WEBSOCKET ======================
|
||||
|
||||
async def admin_clients_broadcaster():
|
||||
"""Periodically push client list to connected admins via WebSocket"""
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
try:
|
||||
from auth import get_db
|
||||
from roles import ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
|
||||
admin_ids = [uid for uid in list(manager._connections.keys())]
|
||||
if not admin_ids:
|
||||
continue
|
||||
# Check which are actually admins
|
||||
with get_db() as conn:
|
||||
admin_rows = conn.execute(
|
||||
"SELECT id FROM users WHERE id IN ({}) AND role >= ?".format(
|
||||
",".join("?" for _ in admin_ids)),
|
||||
admin_ids + [ROLE_MODERATOR]
|
||||
).fetchall()
|
||||
real_admins = [r["id"] for r in admin_rows]
|
||||
if not real_admins:
|
||||
continue
|
||||
stale_cutoff = time.time() - 300
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
|
||||
conn.commit()
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
|
||||
(SELECT s.ip_address FROM user_sessions s
|
||||
WHERE s.user_id = u.id AND s.is_active = 1
|
||||
ORDER BY s.created_at DESC LIMIT 1) as ip_address
|
||||
FROM users u
|
||||
JOIN user_status us ON u.id = us.user_id
|
||||
WHERE us.is_online = 1
|
||||
ORDER BY us.last_seen DESC
|
||||
""").fetchall()
|
||||
clients = []
|
||||
for row in rows:
|
||||
clients.append({
|
||||
"id": row["id"], "username": row["username"],
|
||||
"online": True, "current_pack": row["current_pack"] or "",
|
||||
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
|
||||
"ip": row["ip_address"] or ""
|
||||
})
|
||||
await manager.broadcast_admin_clients(clients, real_admins)
|
||||
except Exception as exc:
|
||||
logger.debug("admin_clients_broadcaster error", error=str(exc))
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
ws: WebSocket,
|
||||
token: str = Query(...),
|
||||
):
|
||||
try:
|
||||
payload = verify_jwt(token)
|
||||
if payload is None:
|
||||
await ws.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
user_id = payload.get("user_id")
|
||||
if user_id is None:
|
||||
await ws.close(code=4001, reason="Invalid token payload")
|
||||
return
|
||||
except Exception:
|
||||
await ws.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
await manager.connect(user_id, ws)
|
||||
|
||||
# Send initial friends list
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cutoff = time.time() - 300
|
||||
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (cutoff,))
|
||||
conn.commit()
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role,
|
||||
COALESCE(us.is_online, 0) as online,
|
||||
COALESCE(us.current_pack, '') as current_pack,
|
||||
us.last_seen
|
||||
FROM friendships f
|
||||
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
|
||||
""", (user_id, user_id, user_id))
|
||||
friends = []
|
||||
for row in rows:
|
||||
friends.append({
|
||||
"id": row["id"], "username": row["username"],
|
||||
"role": row["role"], "online": bool(row["online"]),
|
||||
"current_pack": row["current_pack"],
|
||||
"last_seen": str(row["last_seen"]) if row["last_seen"] else None
|
||||
})
|
||||
await manager.send_json(user_id, {"type": "friends_update", "friends": friends})
|
||||
|
||||
# Send initial friend requests
|
||||
with get_db() as conn:
|
||||
req_rows = conn.execute("""
|
||||
SELECT u.id, u.username, u.role, f.created_at
|
||||
FROM friendships f
|
||||
JOIN users u ON f.requester_id = u.id
|
||||
WHERE f.target_id = ? AND f.status = 'pending'
|
||||
""", (user_id,))
|
||||
requests = []
|
||||
for row in req_rows:
|
||||
requests.append({
|
||||
"id": row["id"], "username": row["username"],
|
||||
"role": row["role"], "created_at": str(row["created_at"]) if row["created_at"] else None
|
||||
})
|
||||
await manager.send_json(user_id, {"type": "requests_update", "requests": requests})
|
||||
except Exception as exc:
|
||||
logger.warning("WS initial data error", error=str(exc))
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive_text()
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
msg_type = msg.get("type")
|
||||
if msg_type == "ping":
|
||||
await ws.send_text(json.dumps({"type": "pong"}))
|
||||
elif msg_type == "status":
|
||||
current_pack = msg.get("current_pack", "")
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
|
||||
VALUES (?, 1, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
is_online = 1,
|
||||
current_pack = COALESCE(?, user_status.current_pack),
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
""", (user_id, current_pack, current_pack))
|
||||
# Notify friends
|
||||
friend_ids = get_friends_ids(user_id)
|
||||
await manager.broadcast_status_change(user_id, friend_ids)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("WS error", user_id=user_id, error=str(exc))
|
||||
finally:
|
||||
manager.disconnect(user_id, ws)
|
||||
|
||||
|
||||
# Monkey patch to catch invalid HTTP requests
|
||||
original_data_received = HttpToolsProtocol.data_received
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import asyncio
|
||||
import json
|
||||
import structlog
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
# user_id -> list of WebSocket connections
|
||||
self._connections: dict[int, list[WebSocket]] = defaultdict(list)
|
||||
# user_id -> set of friend user_ids (cached for broadcast)
|
||||
self._friend_cache: dict[int, set[int]] = {}
|
||||
self._cleanup_lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, user_id: int, ws: WebSocket):
|
||||
await ws.accept()
|
||||
self._connections[user_id].append(ws)
|
||||
logger.info("WS connected", user_id=user_id, total=len(self._connections))
|
||||
|
||||
def disconnect(self, user_id: int, ws: WebSocket):
|
||||
conns = self._connections.get(user_id, [])
|
||||
if ws in conns:
|
||||
conns.remove(ws)
|
||||
if not self._connections.get(user_id):
|
||||
self._connections.pop(user_id, None)
|
||||
logger.info("WS disconnected", user_id=user_id, total=len(self._connections))
|
||||
|
||||
async def send_json(self, user_id: int, data: dict):
|
||||
conns = self._connections.get(user_id, [])
|
||||
if not conns:
|
||||
return
|
||||
msg = json.dumps(data)
|
||||
dead = []
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(user_id, ws)
|
||||
|
||||
async def broadcast_friends_update(self, user_id: int, friends_data: list[dict]):
|
||||
"""Broadcast friend list update to a specific user"""
|
||||
await self.send_json(user_id, {
|
||||
"type": "friends_update",
|
||||
"friends": friends_data
|
||||
})
|
||||
|
||||
async def broadcast_status_change(self, user_id: int, friends: list[int]):
|
||||
"""Notify all friends that this user's status changed"""
|
||||
data = {"type": "friend_status", "user_id": user_id}
|
||||
for fid in friends:
|
||||
await self.send_json(fid, data)
|
||||
|
||||
async def broadcast_admin_clients(self, clients_data: list[dict], admin_ids: list[int]):
|
||||
"""Broadcast client list to all connected admins"""
|
||||
data = {"type": "clients_update", "clients": clients_data}
|
||||
for aid in admin_ids:
|
||||
await self.send_json(aid, data)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
Reference in New Issue
Block a user