Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).
Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.
Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.
Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).
Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.
82 lines
3.8 KiB
Python
82 lines
3.8 KiB
Python
"""Управление комнатой LiveKit от имени организатора (задача B2): принудительный мьют.
|
||
|
||
Тонкая обёртка над `RoomServiceClient` — тот же паттерн, что и
|
||
`services/egress.py` (единственная точка мокирования в тестах, свой
|
||
`api.LiveKitAPI` на вызов, аутентификация СЕРВЕРНЫМИ `api_key`/`api_secret`,
|
||
а не токеном организатора). Именно поэтому организатору не нужен отдельный
|
||
LiveKit-грант в собственном access-токене под это действие — мьютит backend
|
||
от своего имени, клиент лишь инициирует вызов, а право на это проверяется
|
||
по владельцу конференции в БД (`services/conferences.py::mute_participant`),
|
||
ДО обращения сюда.
|
||
"""
|
||
|
||
import logging
|
||
|
||
from livekit import api
|
||
from livekit.protocol.models import TrackSource
|
||
|
||
from core.config import get_settings
|
||
from schemas.room_events import ForcedMuteSource
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Переэкспорт под более общим именем — этот модуль не завязан на протокол WS
|
||
# (`schemas/room_events.py`), которому концептуально принадлежит `ForcedMuteSource`.
|
||
MuteSource = ForcedMuteSource
|
||
|
||
_TRACK_SOURCE_BY_NAME: dict[MuteSource, int] = {
|
||
"microphone": TrackSource.MICROPHONE,
|
||
"camera": TrackSource.CAMERA,
|
||
}
|
||
|
||
|
||
class ParticipantNotInRoomError(Exception):
|
||
"""Участника с таким identity сейчас нет в комнате LiveKit (уже вышел/не заходил)."""
|
||
|
||
|
||
async def mute_participant_track(room_name: str, *, identity: str, source: MuteSource) -> bool:
|
||
"""Принудительно замьютить опубликованный трек участника; `True` — трек реально замьючен.
|
||
|
||
Если трек данного `source` сейчас не опубликован — не ошибка, а no-op:
|
||
искомое состояние («трек не идёт») уже достигнуто. Обычный случай с
|
||
0.0.15 — участники заходят с выключенными микрофоном/камерой (задача
|
||
A1), трек попросту не существует, пока человек не включит его сам;
|
||
мьютить в этот момент нечего, и это НЕ повод отвечать клиенту ошибкой.
|
||
"""
|
||
settings = get_settings()
|
||
lkapi = api.LiveKitAPI(
|
||
settings.livekit_url,
|
||
api_key=settings.livekit_api_key,
|
||
api_secret=settings.livekit_api_secret,
|
||
)
|
||
try:
|
||
try:
|
||
participant = await lkapi.room.get_participant(
|
||
api.RoomParticipantIdentity(room=room_name, identity=identity)
|
||
)
|
||
except api.TwirpError as exc:
|
||
if exc.status == 404:
|
||
raise ParticipantNotInRoomError from exc
|
||
raise
|
||
|
||
target_source = _TRACK_SOURCE_BY_NAME[source]
|
||
track = next((t for t in participant.tracks if t.source == target_source), None)
|
||
if track is None or track.muted:
|
||
return False
|
||
|
||
await lkapi.room.mute_published_track(
|
||
api.MuteRoomTrackRequest(
|
||
room=room_name, identity=identity, track_sid=track.sid, muted=True
|
||
)
|
||
)
|
||
logger.info(
|
||
"room_control: принудительный мьют — комната=%s identity=%s source=%s трек=%s",
|
||
room_name,
|
||
identity,
|
||
source,
|
||
track.sid,
|
||
)
|
||
return True
|
||
finally:
|
||
await lkapi.aclose()
|