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
This commit is contained in:
2026-05-23 20:56:37 +02:00
parent 963441fe50
commit 88bdfc797b
14 changed files with 773 additions and 11 deletions
+12
View File
@@ -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=
+172
View File
@@ -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
+1 -1
View File
@@ -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):
+6
View File
@@ -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)
+2
View File
@@ -4,4 +4,6 @@ from chat import consumers
websocket_urlpatterns = [
re_path(r'ws/channel/(?P<channel_id>\d+)/$', consumers.ChannelConsumer.as_asgi()),
re_path(r'ws/dm/(?P<username>[\w.@+-]+)/$', consumers.DMConsumer.as_asgi()),
re_path(r'ws/voice/(?P<channel_id>\d+)/$', consumers.VoiceSignalingConsumer.as_asgi()),
re_path(r'ws/global/$', consumers.GlobalConsumer.as_asgi()),
]
+436
View File
@@ -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 @@
</head>
<body>
{% block body %}{% endblock %}
<!-- Voice connected status panel -->
<div class="voice-status-panel" id="voice-status-panel">
<div class="voice-status-info">
<div class="voice-status-title"><i class="bi bi-broadcast me-1"></i>Voice Connected</div>
<div class="voice-status-channel" id="voice-status-channel-name">Channel Name</div>
</div>
<div class="voice-status-actions">
<button id="voice-mute-btn" title="Mute Microphone"><i class="bi bi-mic-fill"></i></button>
<button id="voice-deafen-btn" title="Deafen Audio"><i class="bi bi-volume-mute-fill"></i></button>
<button id="voice-disconnect-btn" class="text-danger" title="Disconnect"><i class="bi bi-telephone-x-fill"></i></button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function() {
const currentUserId = {% if request.user.is_authenticated %}{{ request.user.id }}{% else %}null{% endif %};
const currentUsername = "{% if request.user.is_authenticated %}{{ request.user.username|escapejs }}{% endif %}";
if (!currentUserId) return; // Not logged in
// Voice state
let localStream = null;
let pcs = {};
let voiceWs = null;
let cloudflareIceServers = [];
let isMuted = false;
let isDeafened = false;
let currentVoiceChannelId = null;
// Connect global updates WebSocket
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const globalWs = new WebSocket(`${wsProto}//${location.host}/ws/global/`);
globalWs.onmessage = function(e) {
const event = JSON.parse(e.data);
if (event.type === 'voice_presence_change') {
const channelId = event.channel_id;
const userId = event.user_id;
const username = event.username;
const avatar = event.avatar;
// Clean up user from any other voice channel list in DOM first
document.querySelectorAll(`.voice-member-item[data-user-id="${userId}"]`).forEach(el => el.remove());
if (event.action === 'joined') {
const container = document.getElementById(`voice_members_${channelId}`);
if (container) {
const div = document.createElement('div');
div.className = 'voice-member-item';
div.dataset.userId = userId;
const avatarHtml = avatar
? `<img src="${avatar}" alt="av">`
: `<div class="voice-member-avatar-fallback">${username[0].toUpperCase()}</div>`;
div.innerHTML = `${avatarHtml}<span>${username}</span>`;
container.appendChild(div);
}
}
} else if (event.type === 'user_presence_change') {
const userId = event.user_id;
const online = event.online;
const dmLink = document.getElementById(`dm_link_${userId}`);
if (dmLink) {
const dot = dmLink.querySelector('.online-dot, .offline-dot');
if (dot) {
dot.className = online ? 'online-dot' : 'offline-dot';
}
}
document.querySelectorAll('.sidebar-users .channel-item').forEach(el => {
if (el.textContent.includes(event.username)) {
const dot = el.querySelector('.online-dot, .offline-dot');
if (dot) {
dot.className = online ? 'online-dot' : 'offline-dot';
}
}
});
}
};
// Voice Controls UI
const statusPanel = document.getElementById('voice-status-panel');
const channelNameLabel = document.getElementById('voice-status-channel-name');
const muteBtn = document.getElementById('voice-mute-btn');
const deafenBtn = document.getElementById('voice-deafen-btn');
const disconnectBtn = document.getElementById('voice-disconnect-btn');
muteBtn.onclick = function() {
isMuted = !isMuted;
if (localStream) {
localStream.getAudioTracks().forEach(track => track.enabled = !isMuted);
}
muteBtn.classList.toggle('active', isMuted);
muteBtn.querySelector('i').className = isMuted ? 'bi bi-mic-mute-fill' : 'bi bi-mic-fill';
};
deafenBtn.onclick = function() {
isDeafened = !isDeafened;
if (localStream) {
localStream.getAudioTracks().forEach(track => track.enabled = !isDeafened && !isMuted);
}
muteBtn.classList.toggle('active', isDeafened || isMuted);
muteBtn.querySelector('i').className = (isDeafened || isMuted) ? 'bi bi-mic-mute-fill' : 'bi bi-mic-fill';
document.querySelectorAll('audio[id^="audio_peer_"]').forEach(audio => {
audio.muted = isDeafened;
});
deafenBtn.classList.toggle('active', isDeafened);
deafenBtn.querySelector('i').className = isDeafened ? 'bi bi-volume-mute-fill' : 'bi bi-volume-up-fill';
};
disconnectBtn.onclick = function() {
disconnectVoice();
};
async function joinVoiceChannel(channelId, channelName) {
if (currentVoiceChannelId === channelId) return;
if (currentVoiceChannelId) disconnectVoice();
currentVoiceChannelId = channelId;
channelNameLabel.textContent = channelName;
statusPanel.style.display = 'flex';
try {
const credsRes = await fetch('/channel/voice/credentials/');
const credsData = await credsRes.json();
cloudflareIceServers = credsData.iceServers || [];
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (isMuted || isDeafened) {
localStream.getAudioTracks().forEach(track => track.enabled = false);
}
voiceWs = new WebSocket(`${wsProto}//${location.host}/ws/voice/${channelId}/`);
voiceWs.onmessage = function(e) {
const event = JSON.parse(e.data);
if (event.type === 'voice_user_joined') {
const pc = getOrCreatePC(event.user_id, event.username);
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
voiceWs.send(JSON.stringify({
action: 'signal',
target_id: event.user_id,
data: { sdp: pc.localDescription }
}));
});
} else if (event.type === 'voice_user_left') {
cleanupPeer(event.user_id);
} else if (event.type === 'voice_signal_relay') {
const senderId = event.sender_id;
const data = event.data;
if (data.sdp) {
const pc = getOrCreatePC(senderId, event.sender_username);
pc.setRemoteDescription(new RTCSessionDescription(data.sdp))
.then(() => {
if (data.sdp.type === 'offer') {
pc.createAnswer()
.then(answer => pc.setLocalDescription(answer))
.then(() => {
voiceWs.send(JSON.stringify({
action: 'signal',
target_id: senderId,
data: { sdp: pc.localDescription }
}));
});
}
});
} else if (data.candidate) {
const pc = getOrCreatePC(senderId, event.sender_username);
pc.addIceCandidate(new RTCIceCandidate(data.candidate))
.catch(err => console.error("Error adding ice candidate:", err));
}
}
};
} catch (err) {
console.error("Failed to join voice channel:", err);
disconnectVoice();
alert("Could not access microphone or connect to voice server.");
}
}
function getOrCreatePC(userId, username) {
if (pcs[userId]) return pcs[userId];
const pc = new RTCPeerConnection({ iceServers: cloudflareIceServers });
pcs[userId] = pc;
if (localStream) {
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
}
pc.onicecandidate = function(e) {
if (e.candidate) {
voiceWs.send(JSON.stringify({
action: 'signal',
target_id: userId,
data: { candidate: e.candidate }
}));
}
};
pc.ontrack = function(e) {
let audio = document.getElementById(`audio_peer_${userId}`);
if (!audio) {
audio = document.createElement('audio');
audio.id = `audio_peer_${userId}`;
audio.autoplay = true;
audio.style.display = 'none';
document.body.appendChild(audio);
}
audio.srcObject = e.streams[0];
audio.muted = isDeafened;
};
pc.onconnectionstatechange = function() {
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed' || pc.connectionState === 'closed') {
cleanupPeer(userId);
}
};
return pc;
}
function cleanupPeer(userId) {
if (pcs[userId]) {
pcs[userId].close();
delete pcs[userId];
}
const audio = document.getElementById(`audio_peer_${userId}`);
if (audio) audio.remove();
}
function disconnectVoice() {
if (voiceWs) {
voiceWs.close();
voiceWs = null;
}
for (let id in pcs) {
cleanupPeer(id);
}
pcs = {};
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
currentVoiceChannelId = null;
statusPanel.style.display = 'none';
}
document.body.addEventListener('click', function(e) {
const vc = e.target.closest('.voice-channel');
if (vc) {
const channelId = vc.dataset.channelId;
const channelName = vc.dataset.channelName;
joinVoiceChannel(channelId, channelName);
}
});
// AJAX Shell Navigation
document.body.addEventListener('click', function(e) {
const link = e.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('#') || href.includes('delete') || href.includes('logout') || href.includes('admin') || href.includes('profile')) {
return;
}
if (link.target === '_blank') return;
e.preventDefault();
loadPage(href);
});
function loadPage(url) {
fetch(url)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newMain = doc.querySelector('.main-content');
const currentMain = document.querySelector('.main-content');
if (newMain && currentMain) {
currentMain.replaceWith(newMain);
}
document.querySelectorAll('.channel-item.active').forEach(el => el.classList.remove('active'));
const activeLink = document.querySelector(`.channel-item[href="${url}"]`);
if (activeLink) activeLink.classList.add('active');
if (window.channelWs) {
window.channelWs.close();
}
history.pushState({ url }, '', url);
doc.querySelectorAll('script').forEach(script => {
if (!script.src && (script.textContent.includes('WebSocket') || script.textContent.includes('channelWs'))) {
const newScript = document.createElement('script');
newScript.textContent = script.textContent;
document.body.appendChild(newScript);
newScript.remove();
}
});
})
.catch(err => {
console.error("AJAX navigation failed, falling back to full redirect:", err);
window.location.href = url;
});
}
window.onpopstate = function(e) {
if (e.state && e.state.url) {
loadPage(e.state.url);
} else {
loadPage(location.pathname);
}
};
document.body.addEventListener('submit', function(e) {
const form = e.target.closest('form');
if (!form || form.getAttribute('action')?.includes('login') || form.getAttribute('action')?.includes('register') || form.getAttribute('action')?.includes('profile') || form.getAttribute('action')?.includes('create')) {
return;
}
e.preventDefault();
const formData = new FormData(form);
const action = form.getAttribute('action') || location.pathname;
fetch(action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(() => {
loadPage(location.pathname);
})
.catch(err => console.error("Upload form failed:", err));
});
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+27 -6
View File
@@ -11,9 +11,30 @@
{% if profile.is_moderator %}<a href="{% url 'create_channel' %}" class="text-white" id="btn_create_ch"><i class="bi bi-plus-lg"></i></a>{% endif %}
</div>
{% for ch in all_channels %}
{% if ch.channel_type == 'voice' %}
<div class="channel-wrapper" id="ch_wrapper_{{ ch.id }}">
<div class="channel-item voice-channel" id="ch_{{ ch.id }}" data-channel-id="{{ ch.id }}" data-channel-name="{{ ch.name }}" style="cursor:pointer; display:flex; align-items:center;">
<i class="bi bi-volume-up-fill me-1"></i>
<span>{{ ch.name }}</span>
</div>
<div class="voice-members-list" id="voice_members_{{ ch.id }}">
{% for mem in ch.voice_members.all %}
<div class="voice-member-item" data-user-id="{{ mem.user.id }}">
{% if mem.avatar %}
<img src="{{ mem.avatar.url }}" alt="av">
{% else %}
<div class="voice-member-avatar-fallback">{{ mem.user.username|first|upper }}</div>
{% endif %}
<span>{{ mem.user.username }}</span>
</div>
{% endfor %}
</div>
</div>
{% else %}
<a href="{% url 'channel' ch.id %}" class="channel-item {% if ch.id == channel.id %}active{% endif %}" id="ch_{{ ch.id }}">
<i class="bi bi-hash"></i>{{ ch.name }}
</a>
{% endif %}
{% endfor %}
</div>
<div class="user-bar">
@@ -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 = '';
}
+7
View File
@@ -11,6 +11,13 @@
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Channel Name</label>
<input type="text" name="name" class="form-control" id="id_channel_name" required placeholder="general">
</div>
<div class="mb-3">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Channel Type</label>
<select name="channel_type" class="form-select form-control" id="id_channel_type">
<option value="text">Text Channel</option>
<option value="voice">Voice Channel</option>
</select>
</div>
<div class="mb-4">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Description</label>
<input type="text" name="description" class="form-control" id="id_channel_desc" placeholder="Optional">
+4 -4
View File
@@ -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 = '';
}
+24
View File
@@ -16,6 +16,29 @@
{% endif %}
</div>
{% for ch in channels %}
{% if ch.channel_type == 'voice' %}
<div class="channel-wrapper" id="ch_wrapper_{{ ch.id }}">
<div class="channel-item voice-channel" id="ch_{{ ch.id }}" data-channel-id="{{ ch.id }}" data-channel-name="{{ ch.name }}" style="cursor:pointer; display:flex; align-items:center;">
<i class="bi bi-volume-up-fill me-1"></i>
<span>{{ ch.name }}</span>
{% if profile.is_admin %}
<a href="{% url 'delete_channel' ch.id %}" class="ms-auto text-danger" style="font-size:.75rem;" onclick="return confirm('Delete #{{ ch.name }}?')" id="del_ch_{{ ch.id }}"><i class="bi bi-trash"></i></a>
{% endif %}
</div>
<div class="voice-members-list" id="voice_members_{{ ch.id }}">
{% for mem in ch.voice_members.all %}
<div class="voice-member-item" data-user-id="{{ mem.user.id }}">
{% if mem.avatar %}
<img src="{{ mem.avatar.url }}" alt="av">
{% else %}
<div class="voice-member-avatar-fallback">{{ mem.user.username|first|upper }}</div>
{% endif %}
<span>{{ mem.user.username }}</span>
</div>
{% endfor %}
</div>
</div>
{% else %}
<a href="{% url 'channel' ch.id %}" class="channel-item" id="ch_{{ ch.id }}">
<i class="bi bi-hash"></i>
<span>{{ ch.name }}</span>
@@ -23,6 +46,7 @@
<a href="{% url 'delete_channel' ch.id %}" class="ms-auto text-danger" style="font-size:.75rem;" onclick="return confirm('Delete #{{ ch.name }}?')" id="del_ch_{{ ch.id }}"><i class="bi bi-trash"></i></a>
{% endif %}
</a>
{% endif %}
{% empty %}
<div class="px-3 py-2" style="color:#8a8c92;font-size:.8rem;">No channels yet.</div>
{% endfor %}
+37
View File
@@ -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')
+1
View File
@@ -14,4 +14,5 @@ urlpatterns = [
path('admin-panel/', views.admin_panel_view, name='admin_panel'),
path('message/<int:message_id>/delete/', views.delete_message_view, name='delete_message'),
path('user/<int:user_id>/block/', views.block_user_view, name='block_user'),
path('channel/voice/credentials/', views.voice_credentials_view, name='voice_credentials'),
]
+39
View File
@@ -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"]}
]
})
+5
View File
@@ -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='')