"""Управление комнатой 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()