130 lines
5.6 KiB
Python
130 lines
5.6 KiB
Python
|
|
"""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-<owner_id>` 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-<owner_id>` → 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()
|