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:
@@ -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=
|
||||||
@@ -157,3 +157,175 @@ class DMConsumer(AsyncWebsocketConsumer):
|
|||||||
return av.url if av else None
|
return av.url if av else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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
@@ -33,7 +33,7 @@ class ProfileForm(forms.ModelForm):
|
|||||||
class ChannelForm(forms.ModelForm):
|
class ChannelForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Channel
|
model = Channel
|
||||||
fields = ['name', 'description']
|
fields = ['name', 'description', 'channel_type']
|
||||||
|
|
||||||
|
|
||||||
class MessageFileForm(forms.Form):
|
class MessageFileForm(forms.Form):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class UserProfile(models.Model):
|
|||||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user')
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user')
|
||||||
blocked_users = models.ManyToManyField(User, related_name='blocked_by', blank=True)
|
blocked_users = models.ManyToManyField(User, related_name='blocked_by', blank=True)
|
||||||
online = models.BooleanField(default=False)
|
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):
|
def __str__(self):
|
||||||
return f'{self.user.username} ({self.role})'
|
return f'{self.user.username} ({self.role})'
|
||||||
@@ -27,8 +28,13 @@ class UserProfile(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Channel(models.Model):
|
class Channel(models.Model):
|
||||||
|
CHANNEL_TYPES = [
|
||||||
|
('text', 'Text Channel'),
|
||||||
|
('voice', 'Voice Channel'),
|
||||||
|
]
|
||||||
name = models.CharField(max_length=100, unique=True)
|
name = models.CharField(max_length=100, unique=True)
|
||||||
description = models.TextField(blank=True, default='')
|
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')
|
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)
|
members = models.ManyToManyField(User, related_name='channels', blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ from chat import consumers
|
|||||||
websocket_urlpatterns = [
|
websocket_urlpatterns = [
|
||||||
re_path(r'ws/channel/(?P<channel_id>\d+)/$', consumers.ChannelConsumer.as_asgi()),
|
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/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()),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -237,6 +237,86 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-block;
|
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 ── */
|
/* ── Scrollbar ── */
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -245,7 +325,363 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block body %}{% endblock %}
|
{% 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 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 %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 %}
|
{% 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>
|
</div>
|
||||||
{% for ch in all_channels %}
|
{% 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 }}">
|
<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 }}
|
<i class="bi bi-hash"></i>{{ ch.name }}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-bar">
|
<div class="user-bar">
|
||||||
@@ -131,20 +152,20 @@ const sendBtn = document.getElementById('send-btn');
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
|
|
||||||
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
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');
|
window.channelWs.onopen = () => console.log('WS connected');
|
||||||
ws.onclose = () => console.log('WS closed');
|
window.channelWs.onclose = () => console.log('WS closed');
|
||||||
|
|
||||||
ws.onmessage = function(e) {
|
window.channelWs.onmessage = function(e) {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
appendMessage(data);
|
appendMessage(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const msg = input.value.trim();
|
const msg = input.value.trim();
|
||||||
if (!msg || ws.readyState !== WebSocket.OPEN) return;
|
if (!msg || window.channelWs.readyState !== WebSocket.OPEN) return;
|
||||||
ws.send(JSON.stringify({ message: msg }));
|
window.channelWs.send(JSON.stringify({ message: msg }));
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Channel Name</label>
|
<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">
|
<input type="text" name="name" class="form-control" id="id_channel_name" required placeholder="general">
|
||||||
</div>
|
</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">
|
<div class="mb-4">
|
||||||
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Description</label>
|
<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">
|
<input type="text" name="description" class="form-control" id="id_channel_desc" placeholder="Optional">
|
||||||
|
|||||||
@@ -95,17 +95,17 @@ const myAvatar = "{% if request.user.profile.avatar %}{{ request.user.profile.av
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
|
|
||||||
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
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);
|
const data = JSON.parse(e.data);
|
||||||
appendDM(data);
|
appendDM(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
function sendDM() {
|
function sendDM() {
|
||||||
const msg = input.value.trim();
|
const msg = input.value.trim();
|
||||||
if (!msg || ws.readyState !== WebSocket.OPEN) return;
|
if (!msg || window.channelWs.readyState !== WebSocket.OPEN) return;
|
||||||
ws.send(JSON.stringify({ message: msg }));
|
window.channelWs.send(JSON.stringify({ message: msg }));
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% for ch in channels %}
|
{% 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 }}">
|
<a href="{% url 'channel' ch.id %}" class="channel-item" id="ch_{{ ch.id }}">
|
||||||
<i class="bi bi-hash"></i>
|
<i class="bi bi-hash"></i>
|
||||||
<span>{{ ch.name }}</span>
|
<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>
|
<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 %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="px-3 py-2" style="color:#8a8c92;font-size:.8rem;">No channels yet.</div>
|
<div class="px-3 py-2" style="color:#8a8c92;font-size:.8rem;">No channels yet.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -14,4 +14,5 @@ urlpatterns = [
|
|||||||
path('admin-panel/', views.admin_panel_view, name='admin_panel'),
|
path('admin-panel/', views.admin_panel_view, name='admin_panel'),
|
||||||
path('message/<int:message_id>/delete/', views.delete_message_view, name='delete_message'),
|
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('user/<int:user_id>/block/', views.block_user_view, name='block_user'),
|
||||||
|
path('channel/voice/credentials/', views.voice_credentials_view, name='voice_credentials'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -221,3 +221,42 @@ def handler404(request, exception):
|
|||||||
|
|
||||||
def handler500(request):
|
def handler500(request):
|
||||||
return render(request, 'chat/500.html', status=500)
|
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"]}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|||||||
@@ -109,3 +109,8 @@ LOGOUT_REDIRECT_URL = '/login/'
|
|||||||
# PoC: allow large file uploads
|
# PoC: allow large file uploads
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 52428800 # 50MB
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 52428800 # 50MB
|
||||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 52428800
|
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='')
|
||||||
|
|||||||
Reference in New Issue
Block a user