From 88bdfc797bf63315dbaa8557fd86f37b81a61b55 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Sat, 23 May 2026 20:56:37 +0200 Subject: [PATCH] feat(voice): add WebRTC voice channels with Cloudflare TURN support Add voice channel functionality with WebRTC peer-to-peer audio: - Add VoiceSignalingConsumer for WebRTC signaling and peer coordination - Add GlobalConsumer for real-time presence and voice status updates - Add voice channel type to Channel model with current_voice_channel tracking on UserProfile - Add voice controls UI (mute, deafen, disconnect) in status panel - Add voice member list display in sidebar with avatar support - Add --- .env | 12 + chat/consumers.py | 172 ++++++++++ chat/forms.py | 2 +- chat/models.py | 6 + chat/routing.py | 2 + chat/templates/chat/base.html | 436 ++++++++++++++++++++++++ chat/templates/chat/channel.html | 33 +- chat/templates/chat/create_channel.html | 7 + chat/templates/chat/dm.html | 8 +- chat/templates/chat/home.html | 24 ++ chat/tests.py | 37 ++ chat/urls.py | 1 + chat/views.py | 39 +++ core/settings.py | 5 + 14 files changed, 773 insertions(+), 11 deletions(-) create mode 100644 .env create mode 100644 chat/tests.py diff --git a/.env b/.env new file mode 100644 index 0000000..08eb348 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +SECRET_KEY=poc-secret-key-not-for-production +DEBUG=True +DB_NAME=discord_db +DB_USER=discord_user +DB_PASSWORD=discord_pass +DB_HOST=db +DB_PORT=5432 + +# Cloudflare Realtime TURN Configuration +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_TURN_KEY_ID= diff --git a/chat/consumers.py b/chat/consumers.py index c51ceb4..b8d69c8 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -157,3 +157,175 @@ class DMConsumer(AsyncWebsocketConsumer): return av.url if av else None except Exception: return None + + +class VoiceSignalingConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.channel_id = self.scope['url_route']['kwargs']['channel_id'] + self.group_name = f'voice_{self.channel_id}' + self.user = self.scope['user'] + + if not self.user.is_authenticated: + await self.close() + return + + # Add to voice channel group + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + # Update voice channel in DB + await self.set_voice_channel(self.channel_id) + + # Broadcast to voice channel group for WebRTC signaling + await self.channel_layer.group_send( + self.group_name, + { + 'type': 'voice_user_joined', + 'user_id': self.user.id, + 'username': self.user.username, + 'avatar': await self.get_avatar(), + } + ) + + # Broadcast to global updates group to update sidebars for everyone + await self.channel_layer.group_send( + 'global_updates', + { + 'type': 'voice_presence_change', + 'user_id': self.user.id, + 'username': self.user.username, + 'channel_id': int(self.channel_id), + 'action': 'joined', + 'avatar': await self.get_avatar(), + } + ) + + async def disconnect(self, close_code): + # Update voice channel in DB + await self.set_voice_channel(None) + + # Broadcast user left to voice group + await self.channel_layer.group_send( + self.group_name, + { + 'type': 'voice_user_left', + 'user_id': self.user.id, + 'username': self.user.username, + } + ) + + # Broadcast user left to global updates group + await self.channel_layer.group_send( + 'global_updates', + { + 'type': 'voice_presence_change', + 'user_id': self.user.id, + 'username': self.user.username, + 'channel_id': int(self.channel_id), + 'action': 'left', + } + ) + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data): + data = json.loads(text_data) + action = data.get('action') + + if action == 'signal': + target_id = data.get('target_id') + signal_data = data.get('data') + + # Relay signal to the target user + await self.channel_layer.group_send( + self.group_name, + { + 'type': 'voice_signal_relay', + 'sender_id': self.user.id, + 'sender_username': self.user.username, + 'target_id': target_id, + 'data': signal_data, + } + ) + + async def voice_user_joined(self, event): + if event['user_id'] != self.user.id: + await self.send(text_data=json.dumps(event)) + + async def voice_user_left(self, event): + if event['user_id'] != self.user.id: + await self.send(text_data=json.dumps(event)) + + async def voice_signal_relay(self, event): + if event['target_id'] == self.user.id: + await self.send(text_data=json.dumps(event)) + + @database_sync_to_async + def get_avatar(self): + try: + av = self.user.profile.avatar + return av.url if av else None + except Exception: + return None + + @database_sync_to_async + def set_voice_channel(self, channel_id): + try: + if channel_id: + from chat.models import Channel + channel = Channel.objects.get(id=channel_id) + self.user.profile.current_voice_channel = channel + else: + self.user.profile.current_voice_channel = None + self.user.profile.save(update_fields=['current_voice_channel']) + except Exception as e: + print("Error saving voice channel:", e) + + +class GlobalConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.user = self.scope['user'] + if not self.user.is_authenticated: + await self.close() + return + + self.group_name = 'global_updates' + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + await self.set_online(True) + await self.channel_layer.group_send( + self.group_name, + { + 'type': 'user_presence_change', + 'user_id': self.user.id, + 'username': self.user.username, + 'online': True + } + ) + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + await self.set_online(False) + await self.channel_layer.group_send( + self.group_name, + { + 'type': 'user_presence_change', + 'user_id': self.user.id, + 'username': self.user.username, + 'online': False + } + ) + + async def user_presence_change(self, event): + await self.send(text_data=json.dumps(event)) + + async def voice_presence_change(self, event): + await self.send(text_data=json.dumps(event)) + + @database_sync_to_async + def set_online(self, status): + try: + self.user.profile.online = status + self.user.profile.save(update_fields=['online']) + except Exception: + pass diff --git a/chat/forms.py b/chat/forms.py index 0fa73af..526c5c4 100644 --- a/chat/forms.py +++ b/chat/forms.py @@ -33,7 +33,7 @@ class ProfileForm(forms.ModelForm): class ChannelForm(forms.ModelForm): class Meta: model = Channel - fields = ['name', 'description'] + fields = ['name', 'description', 'channel_type'] class MessageFileForm(forms.Form): diff --git a/chat/models.py b/chat/models.py index 23050e2..1b8b10b 100644 --- a/chat/models.py +++ b/chat/models.py @@ -15,6 +15,7 @@ class UserProfile(models.Model): role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user') blocked_users = models.ManyToManyField(User, related_name='blocked_by', blank=True) online = models.BooleanField(default=False) + current_voice_channel = models.ForeignKey('Channel', on_delete=models.SET_NULL, null=True, blank=True, related_name='voice_members') def __str__(self): return f'{self.user.username} ({self.role})' @@ -27,8 +28,13 @@ class UserProfile(models.Model): class Channel(models.Model): + CHANNEL_TYPES = [ + ('text', 'Text Channel'), + ('voice', 'Voice Channel'), + ] name = models.CharField(max_length=100, unique=True) description = models.TextField(blank=True, default='') + channel_type = models.CharField(max_length=10, choices=CHANNEL_TYPES, default='text') created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_channels') members = models.ManyToManyField(User, related_name='channels', blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/chat/routing.py b/chat/routing.py index 559f2d8..ba85c94 100644 --- a/chat/routing.py +++ b/chat/routing.py @@ -4,4 +4,6 @@ from chat import consumers websocket_urlpatterns = [ re_path(r'ws/channel/(?P\d+)/$', consumers.ChannelConsumer.as_asgi()), re_path(r'ws/dm/(?P[\w.@+-]+)/$', consumers.DMConsumer.as_asgi()), + re_path(r'ws/voice/(?P\d+)/$', consumers.VoiceSignalingConsumer.as_asgi()), + re_path(r'ws/global/$', consumers.GlobalConsumer.as_asgi()), ] diff --git a/chat/templates/chat/base.html b/chat/templates/chat/base.html index f58ac0c..5650fff 100644 --- a/chat/templates/chat/base.html +++ b/chat/templates/chat/base.html @@ -237,6 +237,86 @@ border-radius: 50%; display: inline-block; } + .voice-members-list { + margin-left: 24px; + padding-bottom: 6px; + display: flex; + flex-direction: column; + gap: 4px; + } + .voice-member-item { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 6px; + font-size: 0.8rem; + color: var(--dc-muted); + } + .voice-member-item img { + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + } + .voice-member-avatar-fallback { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--dc-accent); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + font-weight: bold; + } + /* ── Voice status panel ── */ + .voice-status-panel { + padding: 10px 12px; + background: #111214; + border-top: 1px solid var(--dc-border); + display: none; + align-items: center; + gap: 10px; + font-size: 0.8rem; + } + .voice-status-info { + flex: 1; + min-width: 0; + } + .voice-status-title { + font-weight: 600; + color: var(--dc-online); + } + .voice-status-channel { + font-size: 0.72rem; + color: var(--dc-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .voice-status-actions { + display: flex; + gap: 4px; + } + .voice-status-actions button { + background: none; + border: none; + color: var(--dc-text); + cursor: pointer; + font-size: 1.1rem; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + } + .voice-status-actions button:hover { + background: rgba(255,255,255,0.08); + } + .voice-status-actions button.active { + color: var(--dc-admin); + } /* ── Scrollbar ── */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } @@ -245,7 +325,363 @@ {% block body %}{% endblock %} + + +
+
+
Voice Connected
+
Channel Name
+
+
+ + + +
+
+ + + + {% block scripts %}{% endblock %} diff --git a/chat/templates/chat/channel.html b/chat/templates/chat/channel.html index badf544..6f9e087 100644 --- a/chat/templates/chat/channel.html +++ b/chat/templates/chat/channel.html @@ -11,9 +11,30 @@ {% if profile.is_moderator %}{% endif %} {% for ch in all_channels %} + {% if ch.channel_type == 'voice' %} +
+
+ + {{ ch.name }} +
+
+ {% for mem in ch.voice_members.all %} +
+ {% if mem.avatar %} + av + {% else %} +
{{ mem.user.username|first|upper }}
+ {% endif %} + {{ mem.user.username }} +
+ {% endfor %} +
+
+ {% else %} {{ ch.name }} + {% endif %} {% endfor %}
@@ -131,20 +152,20 @@ const sendBtn = document.getElementById('send-btn'); container.scrollTop = container.scrollHeight; const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; -const ws = new WebSocket(`${wsProto}//${location.host}/ws/channel/${channelId}/`); +window.channelWs = new WebSocket(`${wsProto}//${location.host}/ws/channel/${channelId}/`); -ws.onopen = () => console.log('WS connected'); -ws.onclose = () => console.log('WS closed'); +window.channelWs.onopen = () => console.log('WS connected'); +window.channelWs.onclose = () => console.log('WS closed'); -ws.onmessage = function(e) { +window.channelWs.onmessage = function(e) { const data = JSON.parse(e.data); appendMessage(data); }; function sendMessage() { const msg = input.value.trim(); - if (!msg || ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify({ message: msg })); + if (!msg || window.channelWs.readyState !== WebSocket.OPEN) return; + window.channelWs.send(JSON.stringify({ message: msg })); input.value = ''; } diff --git a/chat/templates/chat/create_channel.html b/chat/templates/chat/create_channel.html index 5602bab..ad768a4 100644 --- a/chat/templates/chat/create_channel.html +++ b/chat/templates/chat/create_channel.html @@ -11,6 +11,13 @@
+
+ + +
diff --git a/chat/templates/chat/dm.html b/chat/templates/chat/dm.html index 406241a..6c66c5b 100644 --- a/chat/templates/chat/dm.html +++ b/chat/templates/chat/dm.html @@ -95,17 +95,17 @@ const myAvatar = "{% if request.user.profile.avatar %}{{ request.user.profile.av container.scrollTop = container.scrollHeight; const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; -const ws = new WebSocket(`${wsProto}//${location.host}/ws/dm/${otherUsername}/`); +window.channelWs = new WebSocket(`${wsProto}//${location.host}/ws/dm/${otherUsername}/`); -ws.onmessage = function(e) { +window.channelWs.onmessage = function(e) { const data = JSON.parse(e.data); appendDM(data); }; function sendDM() { const msg = input.value.trim(); - if (!msg || ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify({ message: msg })); + if (!msg || window.channelWs.readyState !== WebSocket.OPEN) return; + window.channelWs.send(JSON.stringify({ message: msg })); input.value = ''; } diff --git a/chat/templates/chat/home.html b/chat/templates/chat/home.html index 11ffd91..1ff2e7c 100644 --- a/chat/templates/chat/home.html +++ b/chat/templates/chat/home.html @@ -16,6 +16,29 @@ {% endif %}
{% for ch in channels %} + {% if ch.channel_type == 'voice' %} +
+
+ + {{ ch.name }} + {% if profile.is_admin %} + + {% endif %} +
+
+ {% for mem in ch.voice_members.all %} +
+ {% if mem.avatar %} + av + {% else %} +
{{ mem.user.username|first|upper }}
+ {% endif %} + {{ mem.user.username }} +
+ {% endfor %} +
+
+ {% else %} {{ ch.name }} @@ -23,6 +46,7 @@ {% endif %} + {% endif %} {% empty %}
No channels yet.
{% endfor %} diff --git a/chat/tests.py b/chat/tests.py new file mode 100644 index 0000000..589598b --- /dev/null +++ b/chat/tests.py @@ -0,0 +1,37 @@ +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth.models import User +from chat.models import Channel + + +class VoiceChannelsTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='testuser', password='password') + self.client = Client() + + def test_voice_credentials_anonymous(self): + # Anonymous user should be redirected to login + url = reverse('voice_credentials') + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_voice_credentials_authenticated(self): + # Authenticated user should get ICE servers JSON + self.client.login(username='testuser', password='password') + url = reverse('voice_credentials') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn('iceServers', data) + self.assertTrue(len(data['iceServers']) > 0) + self.assertIn('urls', data['iceServers'][0]) + + def test_channel_model_default_type(self): + # Default channel type should be 'text' + channel = Channel.objects.create(name='general', created_by=self.user) + self.assertEqual(channel.channel_type, 'text') + + def test_voice_channel_type(self): + # Can create channel of type 'voice' + channel = Channel.objects.create(name='Gaming', channel_type='voice', created_by=self.user) + self.assertEqual(channel.channel_type, 'voice') diff --git a/chat/urls.py b/chat/urls.py index d34de64..8d83869 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -14,4 +14,5 @@ urlpatterns = [ path('admin-panel/', views.admin_panel_view, name='admin_panel'), path('message//delete/', views.delete_message_view, name='delete_message'), path('user//block/', views.block_user_view, name='block_user'), + path('channel/voice/credentials/', views.voice_credentials_view, name='voice_credentials'), ] diff --git a/chat/views.py b/chat/views.py index 5e78100..04b434d 100644 --- a/chat/views.py +++ b/chat/views.py @@ -221,3 +221,42 @@ def handler404(request, exception): def handler500(request): return render(request, 'chat/500.html', status=500) + + +import urllib.request +import urllib.error +import json +from django.conf import settings + +@login_required +def voice_credentials_view(request): + account_id = getattr(settings, 'CLOUDFLARE_ACCOUNT_ID', '') + api_token = getattr(settings, 'CLOUDFLARE_API_TOKEN', '') + turn_key_id = getattr(settings, 'CLOUDFLARE_TURN_KEY_ID', '') + + if not all([account_id, api_token, turn_key_id]): + return JsonResponse({ + "iceServers": [ + {"urls": ["stun:stun.cloudflare.com:3478", "stun:stun.cloudflare.com:53"]} + ] + }) + + url = f"https://rtc.live.cloudflare.com/v1/turn/keys/{turn_key_id}/credentials/generate-ice-servers" + headers = { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json" + } + body = json.dumps({"ttl": 86400}).encode("utf-8") + + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=5) as response: + res_data = json.loads(response.read().decode()) + return JsonResponse(res_data) + except urllib.error.URLError as e: + return JsonResponse({ + "error": str(e), + "iceServers": [ + {"urls": ["stun:stun.cloudflare.com:3478", "stun:stun.cloudflare.com:53"]} + ] + }) diff --git a/core/settings.py b/core/settings.py index d2a5b16..bed893e 100644 --- a/core/settings.py +++ b/core/settings.py @@ -109,3 +109,8 @@ LOGOUT_REDIRECT_URL = '/login/' # PoC: allow large file uploads DATA_UPLOAD_MAX_MEMORY_SIZE = 52428800 # 50MB FILE_UPLOAD_MAX_MEMORY_SIZE = 52428800 + +# Cloudflare Realtime TURN Configuration +CLOUDFLARE_ACCOUNT_ID = config('CLOUDFLARE_ACCOUNT_ID', default='') +CLOUDFLARE_API_TOKEN = config('CLOUDFLARE_API_TOKEN', default='') +CLOUDFLARE_TURN_KEY_ID = config('CLOUDFLARE_TURN_KEY_ID', default='')