Compare commits

14 Commits

Author SHA1 Message Date
2a8f9000c9 add endpoint get room/{room_id} 2026-01-02 18:27:57 +05:00
2f32c09b6b rework voice_rooms api 2026-01-02 18:17:36 +05:00
7f660ba7a2 add Voice tag 2026-01-02 17:16:10 +05:00
f189b5b71c add voice room router 2026-01-02 17:14:27 +05:00
ed54c3a741 add voice_rooms 2026-01-02 17:12:27 +05:00
540977dc62 fix voice rooms 2026-01-02 16:34:43 +05:00
baa4341129 test voice rooms 2026-01-02 15:32:59 +05:00
16b477045c fix sell item #2 2025-12-29 21:25:17 +05:00
19d77328d9 loss desctiption 2025-12-29 21:22:20 +05:00
2288e0b239 add description in sell 2025-12-29 21:12:31 +05:00
0507624394 add description and image to marketplace items 2025-12-29 17:25:57 +05:00
daa2561b89 delete websocket in coins(не работает нихуя) 2025-12-29 13:54:51 +05:00
9d007a45c8 fix material 2025-12-29 13:34:37 +05:00
3b4e3d85ed material in prank 2025-12-29 13:28:36 +05:00
16 changed files with 293 additions and 106 deletions

View File

@ -1,25 +0,0 @@
# app/api/coins_ws.py
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.realtime.coins_hub import coins_hub
router = APIRouter(tags=["Coins WS"])
@router.websocket("/ws/coins")
async def coins_ws(
websocket: WebSocket,
username: str = Query(...),
):
await coins_hub.connect(username, websocket)
try:
while True:
await websocket.receive_text() # ping / keep-alive
except WebSocketDisconnect:
pass
finally:
await coins_hub.disconnect(username, websocket)
@router.get("/ws/coins/ping")
async def ping():
return {"ok": True}

View File

@ -29,11 +29,12 @@ async def sell_item(
slot_index: int = Body(...),
amount: int = Body(...),
price: int = Body(...),
server_ip: str = Body(...)
server_ip: str = Body(...),
description: Optional[str] = Body(None),
):
"""Выставить предмет на продажу"""
from app.services.marketplace import MarketplaceService
return await MarketplaceService().add_item(username, slot_index, amount, price, server_ip)
return await MarketplaceService().add_item(username, slot_index, amount, price, server_ip, description)
@router.post("/items/buy/{item_id}")
async def buy_item(

87
app/api/voice_rooms.py Normal file
View File

@ -0,0 +1,87 @@
from fastapi import APIRouter, Body, HTTPException
from app.models.voice_rooms import JoinRoomRequest
from app.services.voice_rooms import VoiceRoomService
from app.realtime.voice_hub import voice_hub
router = APIRouter(prefix="/api/voice", tags=["Voice"])
service = VoiceRoomService()
@router.get("/rooms")
async def list_rooms():
rooms = await service.list_rooms()
result = []
for room in rooms:
room_id = room["id"]
users_map = voice_hub.rooms.get(room_id, {})
usernames = list(users_map.keys())
users_count = len(usernames)
if room["public"]:
# 🟢 публичная — отдаём всё безопасное
result.append({
"id": room["id"],
"name": room["name"],
"public": True,
"owner": room.get("owner"),
"max_users": room.get("max_users"),
"users": users_count,
"usernames": usernames,
"created_at": room["created_at"].isoformat()
if room.get("created_at") else None,
})
else:
# 🔒 приватная — ТОЛЬКО метаданные
result.append({
"name": room["name"],
"public": False,
"users": users_count,
"usernames": usernames,
"max_users": room.get("max_users"),
})
return result
@router.get("/rooms/{room_id}")
async def get_room(room_id: str):
room = await service.get_room(room_id)
users_map = voice_hub.rooms.get(room_id, {})
usernames = list(users_map.keys())
users_count = len(usernames)
if room["public"]:
# 🟢 публичная — полная информация
return {
"id": room["id"],
"name": room["name"],
"public": True,
"owner": room.get("owner"),
"max_users": room.get("max_users"),
"users": users_count,
"usernames": usernames,
"created_at": room["created_at"].isoformat()
if room.get("created_at") else None,
}
# 🔒 приватная — НЕЛЬЗЯ получать по room_id напрямую
# иначе это обход invite-кода
raise HTTPException(
status_code=403,
detail="Access to private room is forbidden",
)
@router.post("/rooms")
async def create_room(
name: str = Body(...),
public: bool = Body(True),
owner: str = Body(...),
):
return await service.create_room(name, owner, public)
@router.post("/rooms/join")
async def join_private(payload: JoinRoomRequest):
return await service.join_by_code(payload.code)

57
app/api/voice_ws.py Normal file
View File

@ -0,0 +1,57 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.realtime.voice_hub import voice_hub
from app.services.voice_rooms import voice_rooms_collection
router = APIRouter()
@router.websocket("/ws/voice")
async def voice_ws(
ws: WebSocket,
room_id: str = Query(...),
username: str = Query(...)
):
room = await voice_rooms_collection.find_one({"id": room_id})
if not room:
await ws.close(code=4004)
return
if username in voice_hub.rooms.get(room_id, {}):
await ws.close(code=4001)
return
if len(voice_hub.rooms.get(room_id, {})) >= room.get("max_users", 5):
await ws.close(code=4003)
return
await voice_hub.connect(room_id, username, ws)
users = list(voice_hub.rooms.get(room_id, {}).keys())
await ws.send_json({
"type": "users",
"users": users
})
await voice_hub.broadcast_except(
room_id,
username,
{"type": "join", "user": username}
)
try:
while True:
msg = await ws.receive_json()
if msg["type"] == "signal":
await voice_hub.send_to(
room_id,
msg["to"],
{
"type": "signal",
"from": username,
"data": msg["data"]
}
)
except WebSocketDisconnect:
voice_hub.disconnect(room_id, username)
await voice_hub.broadcast(
room_id,
{"type": "leave", "user": username}
)

View File

@ -5,6 +5,8 @@ class MarketplaceItemBase(BaseModel):
material: str
amount: int
price: int
description: Optional[str] = None
image_url: Optional[str] = None
seller_name: str
server_ip: str
display_name: Optional[str] = None

View File

@ -5,12 +5,12 @@ class PrankCommandCreate(BaseModel):
name: str
description: str
price: int
material: str
command_template: str
server_ids: List[str] = Field(
default=[],
description='Список серверов, где доступна команда. Использование ["*"] означает доступность на всех серверах'
)
material: str
targetDescription: Optional[str] = None # Сообщение для целевого игрока
globalDescription: Optional[str] = None # Сообщение для всех остальных
@ -18,6 +18,7 @@ class PrankCommandUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[int] = None
material: Optional[str] = None
command_template: Optional[str] = None
server_ids: Optional[List[str]] = None
targetDescription: Optional[str] = None
@ -29,6 +30,7 @@ class PrankCommand(BaseModel):
description: str
price: int
command_template: str
material: str
server_ids: List[str] = []
class PrankExecute(BaseModel):

View File

@ -0,0 +1,5 @@
from pydantic import BaseModel
class JoinRoomRequest(BaseModel):
code: str

View File

@ -1,53 +0,0 @@
# app/realtime/coins_hub.py
from typing import Dict, Set
from fastapi import WebSocket
import asyncio
class CoinsHub:
def __init__(self):
# username -> set of websockets
self._connections: Dict[str, Set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, username: str, ws: WebSocket):
await ws.accept()
async with self._lock:
self._connections.setdefault(username, set()).add(ws)
async def disconnect(self, username: str, ws: WebSocket):
async with self._lock:
conns = self._connections.get(username)
if not conns:
return
conns.discard(ws)
if not conns:
self._connections.pop(username, None)
async def send_update(self, username: str, coins: int):
async with self._lock:
conns = list(self._connections.get(username, []))
if not conns:
return
payload = {
"event": "coins:update",
"coins": coins,
}
dead: list[WebSocket] = []
for ws in conns:
try:
await ws.send_json(payload)
except Exception:
dead.append(ws)
if dead:
async with self._lock:
for ws in dead:
self._connections.get(username, set()).discard(ws)
coins_hub = CoinsHub()

41
app/realtime/voice_hub.py Normal file
View File

@ -0,0 +1,41 @@
from typing import Dict
from fastapi import WebSocket
class VoiceHub:
def __init__(self):
# room_id -> username -> websocket
self.rooms: Dict[str, Dict[str, WebSocket]] = {}
async def connect(self, room_id: str, username: str, ws: WebSocket):
await ws.accept()
self.rooms.setdefault(room_id, {})[username] = ws
def disconnect(self, room_id: str, username: str):
room = self.rooms.get(room_id)
if not room:
return
room.pop(username, None)
if not room:
self.rooms.pop(room_id, None)
async def send_to(self, room_id: str, to_user: str, payload: dict):
ws = self.rooms.get(room_id, {}).get(to_user)
if ws:
await ws.send_json(payload)
async def broadcast(self, room_id: str, payload: dict):
for ws in self.rooms.get(room_id, {}).values():
try:
await ws.send_json(payload)
except:
pass
async def broadcast_except(self, room_id: str, except_user: str, payload: dict):
for user, ws in self.rooms.get(room_id, {}).items():
if user != except_user:
try:
await ws.send_json(payload)
except:
pass
voice_hub = VoiceHub()

View File

@ -3,7 +3,6 @@ import re
from app.db.database import users_collection
from fastapi import HTTPException
from app.db.database import db
from app.realtime.coins_hub import coins_hub
MAX_MINUTES_PER_UPDATE = 120
@ -71,8 +70,6 @@ class CoinsService:
{"$set": {"coins": new_coins, "total_time_played": new_total_time}}
)
await coins_hub.send_update(user["username"], new_coins)
await coins_sessions_collection.insert_one({
"player_id": player_id,
"player_name": player_name,
@ -141,9 +138,7 @@ class CoinsService:
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
user = await users_collection.find_one({"username": username})
new_balance = user.get("coins", 0)
await coins_hub.send_update(username, new_balance)
return new_balance
return user.get("coins", 0)
async def decrease_balance(self, username: str, amount: int) -> int:
"""Уменьшить баланс пользователя"""
@ -159,6 +154,4 @@ class CoinsService:
raise HTTPException(status_code=404, detail=f"Пользователь {username} не найден")
user = await users_collection.find_one({"username": username})
new_balance = user.get("coins", 0)
await coins_hub.send_update(username, new_balance)
return new_balance
return user.get("coins", 0)

View File

@ -8,8 +8,6 @@ import random
from fastapi import HTTPException
from app.db.database import db, users_collection
from app.services.coins import CoinsService
TZ = ZoneInfo("Asia/Yekaterinburg") # как в dailyreward :contentReference[oaicite:1]{index=1}
coins_sessions_collection = db.coins_sessions
@ -170,8 +168,7 @@ class DailyQuestsService:
return {"claimed": False, "reason": "not_completed"}
# 2) начисляем coins
coins_service = CoinsService()
await coins_service.increase_balance(username, reward)
await users_collection.update_one({"username": username}, {"$inc": {"coins": reward}})
# 3) лог в coins_sessions (как daily_login) :contentReference[oaicite:4]{index=4}
await coins_sessions_collection.insert_one({
@ -222,8 +219,7 @@ class DailyQuestsService:
continue # уже claimed/не completed
# начисляем coins
coins_service = CoinsService()
await coins_service.increase_balance(username, reward)
await users_collection.update_one({"username": username}, {"$inc": {"coins": reward}})
total_added += reward
# лог (как в claim) :contentReference[oaicite:2]{index=2}

View File

@ -1,7 +1,6 @@
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from app.db.database import users_collection, db
from app.realtime.coins_hub import coins_hub
coins_sessions_collection = db.coins_sessions
@ -71,9 +70,6 @@ class DailyRewardService:
if result.modified_count == 0:
user2 = await users_collection.find_one({"username": username})
return {"claimed": False, "reason": "already_claimed_today", "streak": user2.get("daily_streak", 0)}
new_balance = (await users_collection.find_one({"username": username})).get("coins", 0)
await coins_hub.send_update(username, new_balance)
await coins_sessions_collection.insert_one({
"player_name": username,

View File

@ -80,7 +80,7 @@ class MarketplaceService:
raise HTTPException(status_code=404, detail="Предмет не найден")
return _serialize_mongodb_doc(item)
async def add_item(self, username: str, slot_index: int, amount: int, price: int, server_ip: str):
async def add_item(self, username: str, slot_index: int, amount: int, price: int, server_ip: str, description: str):
"""Выставить предмет на продажу"""
# Создаем операцию продажи
operation_id = str(uuid.uuid4())
@ -93,6 +93,7 @@ class MarketplaceService:
"amount": amount,
"price": price,
"server_ip": server_ip,
"description": description,
"status": "pending",
"created_at": datetime.utcnow()
}
@ -151,6 +152,8 @@ class MarketplaceService:
"material": item_data.get("material"),
"amount": item_data.get("amount"),
"price": operation.get("price"),
"description": operation.get("description"),
"image_url": item_data.get("image_url"),
"seller_name": operation.get("player_name"),
"server_ip": operation.get("server_ip"),
"display_name": item_data.get("meta", {}).get("display_name"),

View File

@ -27,6 +27,7 @@ class PrankService:
"name": command_data.name,
"description": command_data.description,
"price": command_data.price,
"material": command_data.material,
"command_template": command_data.command_template,
"server_ids": command_data.server_ids,
"targetDescription": command_data.targetDescription,
@ -49,6 +50,7 @@ class PrankService:
"name": cmd["name"],
"description": cmd["description"],
"price": cmd["price"],
"material": cmd.get("material"),
"command_template": cmd["command_template"],
"server_ids": cmd.get("server_ids", []),
"targetDescription": cmd.get("targetDescription"),
@ -68,6 +70,7 @@ class PrankService:
"name": command["name"],
"description": command["description"],
"price": command["price"],
"material": command.get("material"),
"command_template": command["command_template"],
"server_ids": command.get("server_ids", []),
"targetDescription": command.get("targetDescription"),
@ -88,6 +91,8 @@ class PrankService:
update["description"] = update_data.description
if update_data.price is not None:
update["price"] = update_data.price
if update_data.material is not None:
update["material"] = update_data.material
if update_data.command_template is not None:
if "{targetPlayer}" not in update_data.command_template:
raise HTTPException(status_code=400,

View File

@ -0,0 +1,73 @@
from datetime import datetime, timedelta
from uuid import uuid4
from fastapi import HTTPException
from app.db.database import db
voice_rooms_collection = db.voice_rooms
def _serialize(doc):
if not doc:
return None
doc["_id"] = str(doc["_id"])
if "created_at" in doc:
doc["created_at"] = doc["created_at"].isoformat()
if "expires_at" in doc and doc["expires_at"]:
doc["expires_at"] = doc["expires_at"].isoformat()
return doc
class VoiceRoomService:
async def list_rooms(self):
rooms = await voice_rooms_collection.find({}) \
.sort("created_at", -1) \
.to_list(100)
return rooms
async def create_room(
self,
name: str,
owner: str,
public: bool,
max_users: int = 5,
ttl_minutes: int | None = None,
):
room_id = str(uuid4())
invite_code = None if public else uuid4().hex[:6]
room = {
"id": room_id,
"name": name,
"public": public,
"invite_code": invite_code,
"owner": owner,
"max_users": max_users,
"created_at": datetime.utcnow(),
"expires_at": (
datetime.utcnow() + timedelta(minutes=ttl_minutes)
if ttl_minutes else None
),
}
await voice_rooms_collection.insert_one(room)
return _serialize(room)
async def get_room(self, room_id: str):
room = await voice_rooms_collection.find_one({"id": room_id})
if not room:
raise HTTPException(404, "Room not found")
return room
async def join_by_code(self, code: str):
room = await voice_rooms_collection.find_one({"invite_code": code})
if not room:
raise HTTPException(404, "Invalid invite code")
return {
"id": room["id"],
"name": room["name"],
"public": False,
"max_users": room.get("max_users"),
}

14
main.py
View File

@ -3,14 +3,12 @@ import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
import httpx
from app.api import admin_daily_quests, inventory, news, users, skins, capes, meta, server, store, pranks, marketplace, bonuses, case, promo
from app.api import admin_daily_quests, inventory, news, users, skins, capes, meta, server, store, pranks, marketplace, marketplace_ws, bonuses, case, promo, voice_rooms, voice_ws
from fastapi.middleware.cors import CORSMiddleware
from app.services.voice_rooms import voice_rooms_collection
from app.core.config import CAPES_DIR, CAPES_STORE_DIR, SKINS_DIR
from app.services.promo import PromoService
from app.webhooks import telegram
from app.db.database import users_collection
from app.api import marketplace_ws, coins_ws
from app.db.database import users_collection, sessions_collection
@ -30,6 +28,11 @@ async def lifespan(app: FastAPI):
expireAfterSeconds=0
)
await voice_rooms_collection.create_index(
"expires_at",
expireAfterSeconds=0
)
await users_collection.create_index("expires_at", expireAfterSeconds=0)
await users_collection.create_index("telegram_user_id", unique=True, sparse=True)
@ -68,7 +71,6 @@ app.include_router(store.router)
app.include_router(pranks.router)
app.include_router(marketplace.router)
app.include_router(marketplace_ws.router)
app.include_router(coins_ws.router)
app.include_router(case.router)
app.include_router(inventory.router)
app.include_router(bonuses.router)
@ -76,6 +78,8 @@ app.include_router(news.router)
app.include_router(telegram.router)
app.include_router(admin_daily_quests.router)
app.include_router(promo.router)
app.include_router(voice_ws.router)
app.include_router(voice_rooms.router)
# Монтируем статику
app.mount("/skins", StaticFiles(directory=str(SKINS_DIR)), name="skins")