videochat integriert
All checks were successful
Deploy SingleChat / deploy (push) Successful in 27s

This commit is contained in:
Torsten Schulz (local)
2026-06-17 12:53:03 +02:00
parent 8c9a600645
commit 10e6e7a80a
22 changed files with 4443 additions and 510 deletions

View File

@@ -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();
}