This commit is contained in:
@@ -4,6 +4,59 @@ import { join } from 'path';
|
||||
import axios from 'axios';
|
||||
|
||||
const TIMEOUT_SECONDS = 1800; // 30 Minuten
|
||||
const MAX_ACTIVE_VIDEO_CONNECTIONS = 3;
|
||||
const ACTIVE_VIDEO_SESSION_STATUSES = new Set(['ringing', 'connecting', 'active']);
|
||||
const TERMINAL_VIDEO_SESSION_STATUSES = new Set(['rejected', 'cancelled', 'ended', 'failed']);
|
||||
const VIDEO_CONNECTION_STATES = new Set(['new', 'connecting', 'connected', 'disconnected', 'failed', 'closed']);
|
||||
|
||||
function parseDelimitedEnvList(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.split(/[,\n]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildVideoIceServers() {
|
||||
const jsonConfig = String(process.env.VIDEO_ICE_SERVERS_JSON || '').trim();
|
||||
if (jsonConfig) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonConfig);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.error('Ungültiges VIDEO_ICE_SERVERS_JSON:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const iceServers = [];
|
||||
const stunUrls = parseDelimitedEnvList(process.env.VIDEO_STUN_URLS);
|
||||
if (stunUrls.length > 0) {
|
||||
iceServers.push({ urls: stunUrls });
|
||||
}
|
||||
|
||||
const turnUrls = parseDelimitedEnvList(process.env.VIDEO_TURN_URLS);
|
||||
const turnUsername = String(process.env.VIDEO_TURN_USERNAME || '').trim();
|
||||
const turnCredential = String(process.env.VIDEO_TURN_CREDENTIAL || '').trim();
|
||||
if (turnUrls.length > 0 && turnUsername && turnCredential) {
|
||||
iceServers.push({
|
||||
urls: turnUrls,
|
||||
username: turnUsername,
|
||||
credential: turnCredential
|
||||
});
|
||||
}
|
||||
|
||||
return iceServers;
|
||||
}
|
||||
|
||||
const VIDEO_ICE_SERVERS = buildVideoIceServers();
|
||||
const VIDEO_MEDIA_RELAY_CONFIGURED = VIDEO_ICE_SERVERS.some((server) => {
|
||||
const urls = Array.isArray(server?.urls) ? server.urls : [server?.urls];
|
||||
return urls.some((url) => String(url || '').startsWith('turn:') || String(url || '').startsWith('turns:'));
|
||||
});
|
||||
|
||||
if (!VIDEO_MEDIA_RELAY_CONFIGURED) {
|
||||
console.warn('Video-Medienpfad ist nicht vollständig konfiguriert. Setze VIDEO_TURN_URLS, VIDEO_TURN_USERNAME und VIDEO_TURN_CREDENTIAL.');
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(sessionId) {
|
||||
@@ -149,6 +202,8 @@ function parseLoginRecord(line) {
|
||||
|
||||
let clients = new Map();
|
||||
let conversations = new Map(); // Key: "user1:user2" (alphabetisch sortiert)
|
||||
let videoConversations = new Map(); // Key: "user1:user2"
|
||||
let videoSessions = new Map(); // Key: callId
|
||||
|
||||
// Map: Socket-ID -> Express-Session-ID (für Session-Wiederherstellung)
|
||||
let socketToSessionMap = new Map();
|
||||
@@ -222,6 +277,306 @@ function getConversationKey(user1, user2) {
|
||||
return [user1, user2].sort().join(':');
|
||||
}
|
||||
|
||||
function isSocketConnected(socket) {
|
||||
return !!(socket && socket.connected);
|
||||
}
|
||||
|
||||
function isClientOnline(client) {
|
||||
return !!(client && client.userName && isSocketConnected(client.socket));
|
||||
}
|
||||
|
||||
function getClientByUserName(userName) {
|
||||
for (const client of clients.values()) {
|
||||
if (client.userName === userName) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrCreateVideoConversation(user1, user2) {
|
||||
const users = [user1, user2].sort();
|
||||
const convKey = users.join(':');
|
||||
let state = videoConversations.get(convKey);
|
||||
if (!state) {
|
||||
state = {
|
||||
convKey,
|
||||
users,
|
||||
consents: {
|
||||
[users[0]]: false,
|
||||
[users[1]]: false
|
||||
},
|
||||
activeCallIds: new Set()
|
||||
};
|
||||
videoConversations.set(convKey, state);
|
||||
} else {
|
||||
for (const user of users) {
|
||||
if (typeof state.consents[user] !== 'boolean') {
|
||||
state.consents[user] = false;
|
||||
}
|
||||
}
|
||||
if (!(state.activeCallIds instanceof Set)) {
|
||||
state.activeCallIds = new Set(Array.isArray(state.activeCallIds) ? state.activeCallIds : []);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function getExistingVideoConversation(user1, user2) {
|
||||
return videoConversations.get(getConversationKey(user1, user2)) || null;
|
||||
}
|
||||
|
||||
function getVideoConversationForRead(user1, user2) {
|
||||
const existing = getExistingVideoConversation(user1, user2);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const users = [user1, user2].sort();
|
||||
return {
|
||||
convKey: users.join(':'),
|
||||
users,
|
||||
consents: {
|
||||
[users[0]]: false,
|
||||
[users[1]]: false
|
||||
},
|
||||
activeCallIds: new Set()
|
||||
};
|
||||
}
|
||||
|
||||
function removeVideoConversationIfEmpty(user1, user2) {
|
||||
const convKey = getConversationKey(user1, user2);
|
||||
const state = videoConversations.get(convKey);
|
||||
if (!state) return;
|
||||
|
||||
const hasConsent = Object.values(state.consents || {}).some(Boolean);
|
||||
const hasActiveCalls = Array.from(state.activeCallIds || []).some((callId) => {
|
||||
const session = videoSessions.get(callId);
|
||||
return session && ACTIVE_VIDEO_SESSION_STATUSES.has(session.status);
|
||||
});
|
||||
|
||||
if (!hasConsent && !hasActiveCalls) {
|
||||
videoConversations.delete(convKey);
|
||||
}
|
||||
}
|
||||
|
||||
function getOtherParticipant(session, userName) {
|
||||
return session.participants.find((participant) => participant !== userName) || null;
|
||||
}
|
||||
|
||||
function buildVideoConsentPayload(viewerName, state) {
|
||||
const otherUserName = state.users.find((user) => user !== viewerName) || null;
|
||||
return {
|
||||
withUserName: otherUserName,
|
||||
localConsent: !!state.consents[viewerName],
|
||||
remoteConsent: otherUserName ? !!state.consents[otherUserName] : false,
|
||||
videoVisible: !!(otherUserName && state.consents[viewerName] && state.consents[otherUserName])
|
||||
};
|
||||
}
|
||||
|
||||
function emitVideoConsentStateToUser(userName, otherUserName) {
|
||||
const client = getClientByUserName(userName);
|
||||
if (!isClientOnline(client)) return;
|
||||
|
||||
const state = getVideoConversationForRead(userName, otherUserName);
|
||||
client.socket.emit('videoConsent:update', buildVideoConsentPayload(userName, state));
|
||||
}
|
||||
|
||||
function emitVideoConsentStateToParticipants(user1, user2) {
|
||||
emitVideoConsentStateToUser(user1, user2);
|
||||
emitVideoConsentStateToUser(user2, user1);
|
||||
}
|
||||
|
||||
function buildVideoCallPayloadForUser(session, userName) {
|
||||
const otherUserName = getOtherParticipant(session, userName);
|
||||
const muteStates = session.muteStates || {};
|
||||
const connectionStates = session.connectionStates || {};
|
||||
const includeMedia = session.status === 'connecting' || session.status === 'active';
|
||||
return {
|
||||
callId: session.callId,
|
||||
roomId: session.roomId,
|
||||
withUserName: otherUserName,
|
||||
initiatedBy: session.initiatedBy,
|
||||
status: session.status,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
endedAt: session.endedAt || null,
|
||||
reason: session.reason || null,
|
||||
localMuted: !!muteStates[userName],
|
||||
remoteMuted: otherUserName ? !!muteStates[otherUserName] : false,
|
||||
connectionState: connectionStates[userName] || 'new',
|
||||
remoteConnectionState: otherUserName ? (connectionStates[otherUserName] || 'new') : 'new',
|
||||
media: includeMedia ? {
|
||||
mode: 'webrtc-relay',
|
||||
relayOnly: true,
|
||||
iceTransportPolicy: 'relay',
|
||||
iceServers: VIDEO_ICE_SERVERS,
|
||||
isCaller: session.initiatedBy === userName
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
function emitVideoCallEventToUser(userName, eventName, session) {
|
||||
const client = getClientByUserName(userName);
|
||||
if (!isClientOnline(client)) return;
|
||||
client.socket.emit(eventName, buildVideoCallPayloadForUser(session, userName));
|
||||
}
|
||||
|
||||
function emitVideoCapacityToUser(userName) {
|
||||
const client = getClientByUserName(userName);
|
||||
if (!isClientOnline(client)) return;
|
||||
const activeConnections = getActiveVideoSessionsForUser(userName).length;
|
||||
client.socket.emit('videoCall:capacity', {
|
||||
activeConnections,
|
||||
maxConnections: MAX_ACTIVE_VIDEO_CONNECTIONS,
|
||||
reachedMax: activeConnections >= MAX_ACTIVE_VIDEO_CONNECTIONS
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveVideoSessionsForUser(userName) {
|
||||
return Array.from(videoSessions.values()).filter((session) => (
|
||||
session.participants.includes(userName) &&
|
||||
ACTIVE_VIDEO_SESSION_STATUSES.has(session.status)
|
||||
));
|
||||
}
|
||||
|
||||
function hasVideoCapacity(userName) {
|
||||
return getActiveVideoSessionsForUser(userName).length < MAX_ACTIVE_VIDEO_CONNECTIONS;
|
||||
}
|
||||
|
||||
function findActiveVideoSessionBetween(user1, user2) {
|
||||
return Array.from(videoSessions.values()).find((session) => (
|
||||
ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) &&
|
||||
session.participants.includes(user1) &&
|
||||
session.participants.includes(user2)
|
||||
)) || null;
|
||||
}
|
||||
|
||||
function createVideoSession(callerUserName, calleeUserName) {
|
||||
const now = new Date().toISOString();
|
||||
const callId = crypto.randomUUID();
|
||||
return {
|
||||
callId,
|
||||
roomId: `video-${callId}`,
|
||||
participants: [callerUserName, calleeUserName],
|
||||
initiatedBy: callerUserName,
|
||||
status: 'ringing',
|
||||
muteStates: {
|
||||
[callerUserName]: false,
|
||||
[calleeUserName]: false
|
||||
},
|
||||
connectionStates: {
|
||||
[callerUserName]: 'new',
|
||||
[calleeUserName]: 'new'
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
endedAt: null,
|
||||
reason: null
|
||||
};
|
||||
}
|
||||
|
||||
function touchVideoSession(session, status, reason = null) {
|
||||
session.status = status;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
if (reason) {
|
||||
session.reason = reason;
|
||||
}
|
||||
if (TERMINAL_VIDEO_SESSION_STATUSES.has(status)) {
|
||||
session.endedAt = session.updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
function registerVideoSession(session) {
|
||||
videoSessions.set(session.callId, session);
|
||||
const state = getOrCreateVideoConversation(session.participants[0], session.participants[1]);
|
||||
state.activeCallIds.add(session.callId);
|
||||
return state;
|
||||
}
|
||||
|
||||
function finalizeVideoSession(session) {
|
||||
const state = getExistingVideoConversation(session.participants[0], session.participants[1]);
|
||||
if (state) {
|
||||
state.activeCallIds.delete(session.callId);
|
||||
}
|
||||
videoSessions.delete(session.callId);
|
||||
removeVideoConversationIfEmpty(session.participants[0], session.participants[1]);
|
||||
}
|
||||
|
||||
function sendVideoCallError(socket, code, message, details = {}) {
|
||||
socket.emit('videoCall:error', {
|
||||
code,
|
||||
message,
|
||||
...details
|
||||
});
|
||||
}
|
||||
|
||||
function emitVideoCallUpdateToParticipants(session) {
|
||||
emitVideoCallEventToUser(session.participants[0], 'videoCall:update', session);
|
||||
emitVideoCallEventToUser(session.participants[1], 'videoCall:update', session);
|
||||
}
|
||||
|
||||
function buildVideoSignalPayload(callId, fromUserName, signalType, details = {}) {
|
||||
return {
|
||||
callId,
|
||||
fromUserName,
|
||||
signalType,
|
||||
...details
|
||||
};
|
||||
}
|
||||
|
||||
function isRelayIceCandidate(candidate) {
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (candidate.type) {
|
||||
return String(candidate.type).toLowerCase() === 'relay';
|
||||
}
|
||||
const candidateLine = String(candidate.candidate || '');
|
||||
return /\btyp relay\b/i.test(candidateLine);
|
||||
}
|
||||
|
||||
function endVideoSession(session, reason = 'ended', initiatorUserName = null) {
|
||||
if (!session || TERMINAL_VIDEO_SESSION_STATUSES.has(session.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchVideoSession(session, 'ended', reason);
|
||||
for (const participant of session.participants) {
|
||||
const payload = buildVideoCallPayloadForUser(session, participant);
|
||||
if (initiatorUserName) {
|
||||
payload.endedBy = initiatorUserName;
|
||||
}
|
||||
const client = getClientByUserName(participant);
|
||||
if (isClientOnline(client)) {
|
||||
client.socket.emit('videoCall:end', payload);
|
||||
}
|
||||
}
|
||||
finalizeVideoSession(session);
|
||||
for (const participant of session.participants) {
|
||||
emitVideoCapacityToUser(participant);
|
||||
}
|
||||
}
|
||||
|
||||
function endVideoSessionsForPair(user1, user2, reason = 'ended', initiatorUserName = null) {
|
||||
for (const session of Array.from(videoSessions.values())) {
|
||||
if (
|
||||
ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) &&
|
||||
session.participants.includes(user1) &&
|
||||
session.participants.includes(user2)
|
||||
) {
|
||||
endVideoSession(session, reason, initiatorUserName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function endAllVideoSessionsForUser(userName, reason = 'ended', initiatorUserName = null) {
|
||||
for (const session of Array.from(videoSessions.values())) {
|
||||
if (ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) && session.participants.includes(userName)) {
|
||||
endVideoSession(session, reason, initiatorUserName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logClientLogin(client, __dirname) {
|
||||
try {
|
||||
const logsDir = join(__dirname, '../logs');
|
||||
@@ -808,6 +1163,7 @@ export function setupBroadcast(io, __dirname) {
|
||||
|
||||
// Speichere Socket-ID mit Session-ID
|
||||
socket.data.sessionId = sessionId;
|
||||
socketToSessionMap.set(socket.id, sessionId);
|
||||
|
||||
let client = clients.get(sessionId);
|
||||
if (!client) {
|
||||
@@ -836,10 +1192,15 @@ export function setupBroadcast(io, __dirname) {
|
||||
socket.emit('connected', connectedData);
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`[Disconnect] Socket getrennt für Session-ID: ${sessionId}, Grund: ${reason}`);
|
||||
const client = clients.get(sessionId);
|
||||
const currentSessionId = socket.data.sessionId || sessionId;
|
||||
socketToSessionMap.delete(socket.id);
|
||||
console.log(`[Disconnect] Socket getrennt für Session-ID: ${currentSessionId}, Grund: ${reason}`);
|
||||
const client = clients.get(currentSessionId);
|
||||
if (client) {
|
||||
console.log(`[Disconnect] Client gefunden: ${client.userName || 'unbekannt'}, Socket war verbunden: ${client.socket ? client.socket.connected : 'null'}`);
|
||||
if (client.userName) {
|
||||
endAllVideoSessionsForUser(client.userName, 'disconnect', client.userName);
|
||||
}
|
||||
|
||||
// Setze Socket auf null, damit keine Nachrichten mehr an diesen Client gesendet werden
|
||||
// ABER: Lösche den Client NICHT, damit die Session beim Reload wiederhergestellt werden kann
|
||||
@@ -866,6 +1227,7 @@ export function setupBroadcast(io, __dirname) {
|
||||
if (expressSessionId) {
|
||||
console.log('setSessionId - Express-Session-ID erhalten:', expressSessionId);
|
||||
const currentSessionId = socket.data.sessionId;
|
||||
socketToSessionMap.set(socket.id, expressSessionId);
|
||||
|
||||
if (currentSessionId !== expressSessionId) {
|
||||
console.log('setSessionId - Aktualisiere Session-ID von', currentSessionId, 'zu', expressSessionId);
|
||||
@@ -921,6 +1283,7 @@ export function setupBroadcast(io, __dirname) {
|
||||
loggedIn: true,
|
||||
user: existingClient.toJSON()
|
||||
});
|
||||
emitVideoCapacityToUser(existingClient.userName);
|
||||
|
||||
// Aktualisiere Userliste für alle Clients, damit der wiederhergestellte Client die Liste erhält
|
||||
broadcastUserList();
|
||||
@@ -1065,6 +1428,141 @@ export function setupBroadcast(io, __dirname) {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoConsent:set', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoConsentSet(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoConsent:set:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_CONSENT_SET_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:invite', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallInvite(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:invite:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_INVITE_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:accept', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallAccept(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:accept:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_ACCEPT_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:reject', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallReject(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:reject:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_REJECT_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:cancel', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallCancel(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:cancel:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_CANCEL_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:end', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallEnd(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:end:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_END_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:muteState', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallMuteState(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:muteState:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_MUTE_STATE_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:signal', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallSignal(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:signal:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_SIGNAL_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('videoCall:connectionState', async (data) => {
|
||||
try {
|
||||
const currentClient = clients.get(socket.data.sessionId);
|
||||
if (!currentClient) {
|
||||
socket.emit('error', { message: 'Client nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
currentClient.setActivity();
|
||||
handleVideoCallConnectionState(socket, currentClient, data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten von videoCall:connectionState:', error);
|
||||
sendVideoCallError(socket, 'VIDEO_CONNECTION_STATE_FAILED', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
async function handleLogin(socket, client, data) {
|
||||
@@ -1154,6 +1652,7 @@ export function setupBroadcast(io, __dirname) {
|
||||
sessionId: client.sessionId,
|
||||
user: client.toJSON()
|
||||
});
|
||||
emitVideoCapacityToUser(client.userName);
|
||||
}
|
||||
|
||||
function handleMessage(socket, client, data) {
|
||||
@@ -1305,6 +1804,322 @@ export function setupBroadcast(io, __dirname) {
|
||||
imageType: msg.imageType || null
|
||||
}))
|
||||
});
|
||||
emitVideoConsentStateToUser(client.userName, withUserName);
|
||||
|
||||
const session = findActiveVideoSessionBetween(client.userName, withUserName);
|
||||
if (session) {
|
||||
socket.emit('videoCall:update', buildVideoCallPayloadForUser(session, client.userName));
|
||||
}
|
||||
}
|
||||
|
||||
function handleVideoConsentSet(socket, client, data) {
|
||||
if (!client.userName) {
|
||||
socket.emit('error', { message: 'Nicht eingeloggt' });
|
||||
return;
|
||||
}
|
||||
|
||||
const withUserName = String(data?.withUserName || '').trim();
|
||||
const allowed = !!data?.allowed;
|
||||
|
||||
if (!withUserName || withUserName === client.userName) {
|
||||
sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.');
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrCreateVideoConversation(client.userName, withUserName);
|
||||
state.consents[client.userName] = allowed;
|
||||
|
||||
if (!allowed) {
|
||||
endVideoSessionsForPair(client.userName, withUserName, 'consent_revoked', client.userName);
|
||||
}
|
||||
|
||||
emitVideoConsentStateToParticipants(client.userName, withUserName);
|
||||
}
|
||||
|
||||
function assertCanUseVideoWith(socket, client, targetUserName) {
|
||||
if (!client.userName) {
|
||||
sendVideoCallError(socket, 'VIDEO_NOT_LOGGED_IN', 'Nicht eingeloggt.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetClient = getClientByUserName(targetUserName);
|
||||
if (!targetClient || !targetClient.userName) {
|
||||
sendVideoCallError(socket, 'VIDEO_PARTNER_NOT_FOUND', 'Gesprächspartner nicht gefunden.', { withUserName: targetUserName });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isClientOnline(targetClient)) {
|
||||
sendVideoCallError(socket, 'VIDEO_PARTNER_OFFLINE', 'Gesprächspartner ist nicht online.', { withUserName: targetUserName });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (targetClient.blockedUsers.has(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_BLOCKED_BY_PARTNER', 'Du wurdest von diesem Benutzer blockiert.', { withUserName: targetUserName });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client.blockedUsers.has(targetUserName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_PARTNER_BLOCKED', 'Du hast diesen Benutzer blockiert.', { withUserName: targetUserName });
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = getOrCreateVideoConversation(client.userName, targetUserName);
|
||||
if (!state.consents[client.userName] || !state.consents[targetUserName]) {
|
||||
sendVideoCallError(socket, 'VIDEO_CONSENT_REQUIRED', 'Videochat ist erst nach beidseitiger Freigabe sichtbar.', { withUserName: targetUserName });
|
||||
return null;
|
||||
}
|
||||
|
||||
return { targetClient, state };
|
||||
}
|
||||
|
||||
function handleVideoCallInvite(socket, client, data) {
|
||||
const withUserName = String(data?.withUserName || '').trim();
|
||||
if (!withUserName || withUserName === client.userName) {
|
||||
sendVideoCallError(socket, 'VIDEO_INVALID_PARTNER', 'Ungültiger Gesprächspartner.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VIDEO_MEDIA_RELAY_CONFIGURED) {
|
||||
sendVideoCallError(socket, 'VIDEO_MEDIA_NOT_CONFIGURED', 'Video-Medienserver ist derzeit nicht konfiguriert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const check = assertCanUseVideoWith(socket, client, withUserName);
|
||||
if (!check) return;
|
||||
|
||||
if (!hasVideoCapacity(client.userName)) {
|
||||
emitVideoCapacityToUser(client.userName);
|
||||
sendVideoCallError(socket, 'VIDEO_CAPACITY_REACHED', 'Maximal drei Videoverbindungen gleichzeitig erlaubt.', {
|
||||
withUserName,
|
||||
activeConnections: getActiveVideoSessionsForUser(client.userName).length,
|
||||
maxConnections: MAX_ACTIVE_VIDEO_CONNECTIONS
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasVideoCapacity(withUserName)) {
|
||||
emitVideoCapacityToUser(withUserName);
|
||||
sendVideoCallError(socket, 'VIDEO_PARTNER_CAPACITY_REACHED', 'Der Gesprächspartner hat bereits die maximale Anzahl an Videoverbindungen erreicht.', {
|
||||
withUserName
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSession = findActiveVideoSessionBetween(client.userName, withUserName);
|
||||
if (existingSession) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_ALREADY_EXISTS', 'Für diesen Gesprächspartner existiert bereits ein laufender Videochat.', {
|
||||
withUserName,
|
||||
callId: existingSession.callId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = createVideoSession(client.userName, withUserName);
|
||||
registerVideoSession(session);
|
||||
|
||||
emitVideoCallEventToUser(client.userName, 'videoCall:invite', session);
|
||||
emitVideoCallEventToUser(withUserName, 'videoCall:incoming', session);
|
||||
emitVideoCapacityToUser(client.userName);
|
||||
emitVideoCapacityToUser(withUserName);
|
||||
}
|
||||
|
||||
function handleVideoCallAccept(socket, client, data) {
|
||||
const callId = String(data?.callId || '').trim();
|
||||
const session = videoSessions.get(callId);
|
||||
|
||||
if (!session || session.status !== 'ringing' || !session.participants.includes(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.initiatedBy === client.userName) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_INVALID_ACCEPT', 'Der Anrufer kann den eigenen Anruf nicht annehmen.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VIDEO_MEDIA_RELAY_CONFIGURED) {
|
||||
sendVideoCallError(socket, 'VIDEO_MEDIA_NOT_CONFIGURED', 'Video-Medienserver ist derzeit nicht konfiguriert.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
const otherUserName = getOtherParticipant(session, client.userName);
|
||||
const check = assertCanUseVideoWith(socket, client, otherUserName);
|
||||
if (!check) {
|
||||
touchVideoSession(session, 'failed', 'preconditions_failed');
|
||||
emitVideoCallEventToUser(session.participants[0], 'videoCall:end', session);
|
||||
emitVideoCallEventToUser(session.participants[1], 'videoCall:end', session);
|
||||
finalizeVideoSession(session);
|
||||
emitVideoCapacityToUser(session.participants[0]);
|
||||
emitVideoCapacityToUser(session.participants[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
touchVideoSession(session, 'connecting');
|
||||
emitVideoCallEventToUser(session.participants[0], 'videoCall:start', session);
|
||||
emitVideoCallEventToUser(session.participants[1], 'videoCall:start', session);
|
||||
emitVideoCapacityToUser(session.participants[0]);
|
||||
emitVideoCapacityToUser(session.participants[1]);
|
||||
}
|
||||
|
||||
function handleVideoCallReject(socket, client, data) {
|
||||
const callId = String(data?.callId || '').trim();
|
||||
const session = videoSessions.get(callId);
|
||||
|
||||
if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status !== 'ringing' || session.initiatedBy === client.userName) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_INVALID_REJECT', 'Nur der Angerufene kann einen klingelnden Anruf ablehnen.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
touchVideoSession(session, 'rejected', 'rejected');
|
||||
emitVideoCallEventToUser(session.participants[0], 'videoCall:reject', session);
|
||||
emitVideoCallEventToUser(session.participants[1], 'videoCall:reject', session);
|
||||
finalizeVideoSession(session);
|
||||
emitVideoCapacityToUser(session.participants[0]);
|
||||
emitVideoCapacityToUser(session.participants[1]);
|
||||
}
|
||||
|
||||
function handleVideoCallCancel(socket, client, data) {
|
||||
const callId = String(data?.callId || '').trim();
|
||||
const session = videoSessions.get(callId);
|
||||
|
||||
if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status !== 'ringing') {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_INVALID_CANCEL', 'Nur klingelnde Anrufe können abgebrochen werden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.initiatedBy !== client.userName) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_INVALID_CANCEL', 'Nur der Anrufer kann einen klingelnden Anruf abbrechen.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
touchVideoSession(session, 'cancelled', 'cancelled');
|
||||
emitVideoCallEventToUser(session.participants[0], 'videoCall:cancel', session);
|
||||
emitVideoCallEventToUser(session.participants[1], 'videoCall:cancel', session);
|
||||
finalizeVideoSession(session);
|
||||
emitVideoCapacityToUser(session.participants[0]);
|
||||
emitVideoCapacityToUser(session.participants[1]);
|
||||
}
|
||||
|
||||
function handleVideoCallEnd(socket, client, data) {
|
||||
const callId = String(data?.callId || '').trim();
|
||||
const session = videoSessions.get(callId);
|
||||
|
||||
if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
endVideoSession(session, 'ended', client.userName);
|
||||
}
|
||||
|
||||
function handleVideoCallMuteState(socket, client, data) {
|
||||
const callId = String(data?.callId || '').trim();
|
||||
const muted = !!data?.muted;
|
||||
const session = videoSessions.get(callId);
|
||||
|
||||
if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
session.muteStates = session.muteStates || {};
|
||||
session.muteStates[client.userName] = muted;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
|
||||
emitVideoCallEventToUser(session.participants[0], 'videoCall:muteState', session);
|
||||
emitVideoCallEventToUser(session.participants[1], 'videoCall:muteState', session);
|
||||
}
|
||||
|
||||
function handleVideoCallSignal(socket, client, data) {
|
||||
const callId = String(data?.callId || '').trim();
|
||||
const signalType = String(data?.signalType || '').trim();
|
||||
const session = videoSessions.get(callId);
|
||||
|
||||
if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
const otherUserName = getOtherParticipant(session, client.userName);
|
||||
const targetClient = getClientByUserName(otherUserName);
|
||||
if (!isClientOnline(targetClient)) {
|
||||
endVideoSession(session, 'partner_disconnected', client.userName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signalType === 'description') {
|
||||
const description = data?.description;
|
||||
const descriptionType = String(description?.type || '').trim();
|
||||
const sdp = String(description?.sdp || '').trim();
|
||||
if (!descriptionType || !sdp || !['offer', 'answer'].includes(descriptionType)) {
|
||||
sendVideoCallError(socket, 'VIDEO_SIGNAL_INVALID_DESCRIPTION', 'Ungültige Video-Signalisierung.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
targetClient.socket.emit('videoCall:signal', buildVideoSignalPayload(callId, client.userName, signalType, {
|
||||
description: {
|
||||
type: descriptionType,
|
||||
sdp
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (signalType === 'candidate') {
|
||||
const candidate = data?.candidate;
|
||||
if (!isRelayIceCandidate(candidate)) {
|
||||
sendVideoCallError(socket, 'VIDEO_SIGNAL_NON_RELAY_CANDIDATE', 'Nur Relay-Kandidaten sind für Videochat erlaubt.', { callId });
|
||||
return;
|
||||
}
|
||||
targetClient.socket.emit('videoCall:signal', buildVideoSignalPayload(callId, client.userName, signalType, { candidate }));
|
||||
return;
|
||||
}
|
||||
|
||||
sendVideoCallError(socket, 'VIDEO_SIGNAL_UNSUPPORTED', 'Nicht unterstütztes Video-Signalisierungsformat.', { callId });
|
||||
}
|
||||
|
||||
function handleVideoCallConnectionState(socket, client, data) {
|
||||
const callId = String(data?.callId || '').trim();
|
||||
const nextState = String(data?.connectionState || '').trim().toLowerCase();
|
||||
const session = videoSessions.get(callId);
|
||||
|
||||
if (!session || !ACTIVE_VIDEO_SESSION_STATUSES.has(session.status) || !session.participants.includes(client.userName)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CALL_NOT_FOUND', 'Videoanruf nicht gefunden.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VIDEO_CONNECTION_STATES.has(nextState)) {
|
||||
sendVideoCallError(socket, 'VIDEO_CONNECTION_STATE_INVALID', 'Ungültiger Video-Verbindungsstatus.', { callId });
|
||||
return;
|
||||
}
|
||||
|
||||
session.connectionStates = session.connectionStates || {};
|
||||
session.connectionStates[client.userName] = nextState;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
|
||||
if (nextState === 'failed' || nextState === 'closed') {
|
||||
endVideoSession(session, 'media_connection_failed', client.userName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
session.status === 'connecting' &&
|
||||
session.participants.every((participant) => session.connectionStates[participant] === 'connected')
|
||||
) {
|
||||
touchVideoSession(session, 'active');
|
||||
}
|
||||
|
||||
emitVideoCallUpdateToParticipants(session);
|
||||
}
|
||||
|
||||
function handleUserSearch(socket, client, data) {
|
||||
@@ -1406,6 +2221,9 @@ export function setupBroadcast(io, __dirname) {
|
||||
|
||||
const { userName } = data;
|
||||
client.blockedUsers.add(userName);
|
||||
getOrCreateVideoConversation(client.userName, userName).consents[client.userName] = false;
|
||||
endVideoSessionsForPair(client.userName, userName, 'blocked', client.userName);
|
||||
emitVideoConsentStateToParticipants(client.userName, userName);
|
||||
|
||||
socket.emit('userBlocked', {
|
||||
userName
|
||||
@@ -1420,6 +2238,7 @@ export function setupBroadcast(io, __dirname) {
|
||||
|
||||
const { userName } = data;
|
||||
client.blockedUsers.delete(userName);
|
||||
emitVideoConsentStateToParticipants(client.userName, userName);
|
||||
|
||||
socket.emit('userUnblocked', {
|
||||
userName
|
||||
@@ -1488,6 +2307,12 @@ export function setupBroadcast(io, __dirname) {
|
||||
for (const [sid, client] of clients.entries()) {
|
||||
if (client.activitiesTimedOut()) {
|
||||
console.log(`Client ${client.userName} hat Timeout erreicht`);
|
||||
if (client.userName) {
|
||||
endAllVideoSessionsForUser(client.userName, 'timeout', client.userName);
|
||||
}
|
||||
if (client.socket?.id) {
|
||||
socketToSessionMap.delete(client.socket.id);
|
||||
}
|
||||
clients.delete(sid);
|
||||
broadcastUserList();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user