"""Signaling-only relay for a self-hosted WebRTC mesh — Phase C of [[my-sea-invite-voice-blueprint]] (per disco-voice-mesh.pdf). Media (the actual audio) never touches the server — only SDP offers/answers and ICE candidates are relayed. The server is room-agnostic: `room_id` is a STRING url kwarg — `mysea-` for my-sea today, an epic room UUID later (the `_can_join` gate is the only room-type-aware piece, so epic rooms reuse this consumer unchanged). Mesh handshake (newcomer offers to each existing peer): client → hello (after `welcome`) server → peer.hello broadcast → each existing peer replies `present` newcomer ← present (per existing peer) → newcomer sends `offer` to each offer / answer / ice are relayed point-to-point by `target` peer id, tagged with the `source` peer id. On disconnect the server broadcasts `left` so peers tear down their RTCPeerConnection. """ import uuid as uuidlib from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer class RoomVoiceConsumer(AsyncJsonWebsocketConsumer): async def connect(self): self.room_id = self.scope["url_route"]["kwargs"]["room_id"] self.user = self.scope.get("user") self.room_group = None self.peer_group = None self.peer_id = None if not (self.user and getattr(self.user, "is_authenticated", False)): await self.close() return if not await self._can_join(self.user, self.room_id): await self.close() return self.peer_id = uuidlib.uuid4().hex self.room_group = f"voice.{self.room_id}" self.peer_group = f"peer.{self.peer_id}" await self.channel_layer.group_add(self.room_group, self.channel_name) await self.channel_layer.group_add(self.peer_group, self.channel_name) await self.accept() # Hand the client its own peer id; it sends `hello` next. await self.send_json({"type": "welcome", "peer_id": self.peer_id}) async def disconnect(self, code): if self.room_group: await self.channel_layer.group_send( self.room_group, {"type": "peer.left", "source": self.peer_id}, ) await self.channel_layer.group_discard(self.room_group, self.channel_name) if self.peer_group: await self.channel_layer.group_discard(self.peer_group, self.channel_name) async def receive_json(self, content): mtype = content.get("type") if mtype == "hello": await self.channel_layer.group_send( self.room_group, {"type": "peer.hello", "source": self.peer_id}, ) elif mtype in ("offer", "answer", "ice"): target = content.get("target") if target: await self.channel_layer.group_send( f"peer.{target}", { "type": f"relay.{mtype}", "kind": mtype, "source": self.peer_id, "payload": content.get("payload"), }, ) # ── room-group fan-out handlers ───────────────────────────────────── async def peer_hello(self, event): src = event.get("source") if src == self.peer_id: return # don't echo our own hello # Tell the newcomer we're already present (they'll offer to us)… await self.channel_layer.group_send( f"peer.{src}", {"type": "peer.present", "source": self.peer_id}, ) # …and note the newcomer locally so we're ready to answer their offer. await self.send_json({"type": "hello", "source": src}) async def peer_present(self, event): await self.send_json({"type": "present", "source": event["source"]}) async def peer_left(self, event): if event.get("source") == self.peer_id: return await self.send_json({"type": "left", "source": event["source"]}) # ── point-to-point signaling relay ────────────────────────────────── async def relay_offer(self, event): await self._relay(event) async def relay_answer(self, event): await self._relay(event) async def relay_ice(self, event): await self._relay(event) async def _relay(self, event): await self.send_json({ "type": event["kind"], "source": event["source"], "payload": event["payload"], }) # ── membership gate ───────────────────────────────────────────────── @database_sync_to_async def _can_join(self, user, room_id): """`mysea-` → the owner OR a present invitee (token deposited, not left). An epic room UUID → a seated gamer (wired in a later sprint; the consumer itself needs no change).""" if room_id.startswith("mysea-"): owner_id = room_id[len("mysea-"):] if str(user.id) == owner_id: return True from apps.gameboard.models import SeaInvite return SeaInvite.objects.filter( owner_id=owner_id, invitee=user, status=SeaInvite.ACCEPTED, token_deposited_at__isnull=False, left_at__isnull=True, ).exists() from apps.epic.models import TableSeat return TableSeat.objects.filter(room_id=room_id, gamer=user).exists()