import { connectedUsers } from './state'; import { ConnectedUser } from './types'; import { broadcastToServer, findUserByOderId, getServerIdsForOderId, getUniqueUsersInServer, isOderIdConnectedToServer } from './broadcast'; import { authorizeWebSocketJoin } from '../services/server-access.service'; import { getPluginRequirementsSnapshot, PluginSupportError, validatePluginEventEnvelope } from '../services/plugin-support.service'; interface WsMessage { [key: string]: unknown; type: string; } function normalizeDisplayName(value: unknown, fallback = 'User'): string { const normalized = typeof value === 'string' ? value.trim() : ''; return normalized || fallback; } function normalizeDescription(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const normalized = value.trim(); return normalized || undefined; } function normalizeProfileUpdatedAt(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; } function normalizeHomeSignalServerUrl(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const normalized = value.trim().replace(/\/+$/, ''); return normalized || undefined; } function buildPresenceUserPayload(user: ConnectedUser): { oderId: string; displayName: string; description?: string; profileUpdatedAt?: number; homeSignalServerUrl?: string; status: 'online' | 'away' | 'busy' | 'offline'; } { return { oderId: user.oderId, displayName: normalizeDisplayName(user.displayName), description: user.description, profileUpdatedAt: user.profileUpdatedAt, homeSignalServerUrl: user.homeSignalServerUrl, status: user.status ?? 'online' }; } function readMessageId(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const normalized = value.trim(); if (!normalized || normalized === 'undefined' || normalized === 'null') { return undefined; } return normalized; } function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void { if (error instanceof PluginSupportError) { user.ws.send( JSON.stringify({ type: 'plugin_error', serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined, pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined, eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined, eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, code: error.code, message: error.message }) ); return; } console.error('Unhandled plugin websocket error:', error); user.ws.send( JSON.stringify({ type: 'plugin_error', code: 'INTERNAL_ERROR', message: 'Internal server error' }) ); } /** Sends the current user list for a given server to a single connected user. */ function sendServerUsers(user: ConnectedUser, serverId: string): void { const users = getUniqueUsersInServer(serverId, user.oderId).map((cu) => buildPresenceUserPayload(cu)); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); } async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise { try { const snapshot = await getPluginRequirementsSnapshot(serverId); user.ws.send( JSON.stringify({ type: 'plugin_requirements', serverId, snapshot }) ); } catch (error) { sendPluginError(user, error, { type: 'plugin_requirements', serverId }); } } function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { const newOderId = readMessageId(message['oderId']) ?? connectionId; const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined; const previousDisplayName = normalizeDisplayName(user.displayName); const previousDescription = user.description; const previousProfileUpdatedAt = user.profileUpdatedAt; const previousHomeSignalServerUrl = user.homeSignalServerUrl; user.oderId = newOderId; user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); if (Object.prototype.hasOwnProperty.call(message, 'description')) { user.description = normalizeDescription(message['description']); } if (Object.prototype.hasOwnProperty.call(message, 'profileUpdatedAt')) { user.profileUpdatedAt = normalizeProfileUpdatedAt(message['profileUpdatedAt']); } if (Object.prototype.hasOwnProperty.call(message, 'homeSignalServerUrl')) { user.homeSignalServerUrl = normalizeHomeSignalServerUrl(message['homeSignalServerUrl']); } user.connectionScope = newScope; connectedUsers.set(connectionId, user); console.log(`User identified: ${user.displayName} (${user.oderId})`); if ( user.displayName === previousDisplayName && user.description === previousDescription && user.profileUpdatedAt === previousProfileUpdatedAt && user.homeSignalServerUrl === previousHomeSignalServerUrl ) { return; } for (const serverId of user.serverIds) { broadcastToServer( serverId, { type: 'user_joined', ...buildPresenceUserPayload(user), serverId }, user.oderId ); } } async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise { const sid = readMessageId(message['serverId']); if (!sid) return; const authorization = await authorizeWebSocketJoin(sid, user.oderId); if (!authorization.allowed) { user.ws.send( JSON.stringify({ type: 'access_denied', serverId: sid, reason: authorization.reason }) ); return; } const isNewConnectionMembership = !user.serverIds.has(sid); const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId); user.serverIds.add(sid); user.viewedServerId = sid; connectedUsers.set(connectionId, user); console.log( `User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} ` + `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})` ); sendServerUsers(user, sid); await sendPluginRequirements(user, sid); if (isNewIdentityMembership) { broadcastToServer( sid, { type: 'user_joined', ...buildPresenceUserPayload(user), serverId: sid }, user.oderId ); } } async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise { const viewSid = readMessageId(message['serverId']); if (!viewSid) return; if (!user.serverIds.has(viewSid)) { return; } user.viewedServerId = viewSid; connectedUsers.set(connectionId, user); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`); sendServerUsers(user, viewSid); await sendPluginRequirements(user, viewSid); } function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId; if (!leaveSid) return; user.serverIds.delete(leaveSid); if (user.viewedServerId === leaveSid) user.viewedServerId = undefined; connectedUsers.set(connectionId, user); const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId); if (remainingServerIds.includes(leaveSid)) { return; } broadcastToServer( leaveSid, { type: 'user_left', oderId: user.oderId, displayName: normalizeDisplayName(user.displayName), serverId: leaveSid, serverIds: remainingServerIds }, user.oderId ); } function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void { const targetUserId = readMessageId(message['targetUserId']) ?? ''; console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`); const targetUser = findUserByOderId(targetUserId); if (targetUser) { targetUser.ws.send(JSON.stringify({ ...message, fromUserId: user.oderId })); console.log(`Successfully forwarded ${message.type} to ${targetUserId}`); } else { console.log( `Target user ${targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map((cu) => ({ oderId: cu.oderId, displayName: cu.displayName })) ); } } function handleChatMessage(user: ConnectedUser, message: WsMessage): void { const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; if (chatSid && user.serverIds.has(chatSid)) { broadcastToServer(chatSid, { type: 'chat_message', serverId: chatSid, message: message['message'], senderId: user.oderId, senderName: user.displayName, timestamp: Date.now() }); } } function handleVoiceState(user: ConnectedUser, message: WsMessage): void { const serverId = readMessageId(message['serverId']) ?? user.viewedServerId; if (!serverId || !user.serverIds.has(serverId)) { return; } broadcastToServer( serverId, { ...message, type: 'voice_state', serverId, oderId: user.oderId, displayName: normalizeDisplayName(user.displayName) }, user.oderId ); } function handleTyping(user: ConnectedUser, message: WsMessage): void { const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general'; const isTyping = message['isTyping'] !== false; if (typingSid && user.serverIds.has(typingSid)) { broadcastToServer( typingSid, { type: 'user_typing', serverId: typingSid, channelId, isTyping, oderId: user.oderId, displayName: user.displayName }, user.oderId ); } } const VALID_STATUSES = new Set([ 'online', 'away', 'busy', 'offline' ]); function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void { const status = typeof message['status'] === 'string' ? message['status'] : undefined; if (!status || !VALID_STATUSES.has(status)) return; user.status = status as ConnectedUser['status']; connectedUsers.set(connectionId, user); console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`); for (const serverId of user.serverIds) { broadcastToServer( serverId, { type: 'status_update', oderId: user.oderId, status }, user.oderId ); } } function handleServerIconAvailable(user: ConnectedUser, message: WsMessage, connectionId: string): void { const serverId = readMessageId(message['serverId']); const iconUpdatedAt = typeof message['iconUpdatedAt'] === 'number' && Number.isFinite(message['iconUpdatedAt']) ? message['iconUpdatedAt'] : 0; if (!serverId || iconUpdatedAt <= 0 || !user.serverIds.has(serverId)) { return; } const availableIcons = user.serverIconUpdatedAtByServerId ?? new Map(); availableIcons.set(serverId, iconUpdatedAt); user.serverIconUpdatedAtByServerId = availableIcons; connectedUsers.set(connectionId, user); } function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): void { const serverId = readMessageId(message['serverId']); const localUpdatedAt = typeof message['iconUpdatedAt'] === 'number' && Number.isFinite(message['iconUpdatedAt']) ? message['iconUpdatedAt'] : 0; if (!serverId) { return; } const users = getUniqueUsersInServer(serverId, user.oderId) .filter((candidate) => (candidate.serverIconUpdatedAtByServerId?.get(serverId) ?? 0) > localUpdatedAt) .map((candidate) => ({ oderId: candidate.oderId, displayName: normalizeDisplayName(candidate.displayName), description: candidate.description, profileUpdatedAt: candidate.profileUpdatedAt, status: candidate.status ?? 'online' })); if (users.length === 0) { return; } user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users })); } async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise { const serverId = readMessageId(message['serverId']) ?? user.viewedServerId; const pluginId = readMessageId(message['pluginId']); const eventName = readMessageId(message['eventName']); if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) { user.ws.send( JSON.stringify({ type: 'plugin_error', serverId, pluginId, eventName, eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, code: 'INVALID_PLUGIN_EVENT', message: 'Plugin event is missing required fields or server membership' }) ); return; } try { await validatePluginEventEnvelope({ type: 'plugin_event', serverId, pluginId, eventName, eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, payload: message['payload'], sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined }); broadcastToServer( serverId, { type: 'plugin_event', serverId, pluginId, eventName, eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined, payload: message['payload'], sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined, sourceUserId: user.oderId, emittedAt: Date.now() }, user.oderId ); } catch (error) { sendPluginError(user, error, message); } } export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise { const user = connectedUsers.get(connectionId); if (!user) return; user.lastPong = Date.now(); connectedUsers.set(connectionId, user); switch (message.type) { case 'keepalive': user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() })); break; case 'identify': handleIdentify(user, message, connectionId); break; case 'join_server': await handleJoinServer(user, message, connectionId); break; case 'view_server': await handleViewServer(user, message, connectionId); break; case 'leave_server': handleLeaveServer(user, message, connectionId); break; case 'offer': case 'answer': case 'ice_candidate': case 'direct-message': case 'direct-message-status': case 'direct-message-mutation': case 'direct-message-typing': case 'direct-message-sync-request': case 'direct-message-sync': case 'direct-call': case 'server_icon_peer_request': case 'server_icon_peer_data': forwardRtcMessage(user, message); break; case 'chat_message': handleChatMessage(user, message); break; case 'voice_state': handleVoiceState(user, message); break; case 'typing': handleTyping(user, message); break; case 'status_update': handleStatusUpdate(user, message, connectionId); break; case 'server_icon_available': handleServerIconAvailable(user, message, connectionId); break; case 'server_icon_sync_request': handleServerIconSyncRequest(user, message); break; case 'plugin_event': await handlePluginEvent(user, message); break; default: console.log('Unknown message type:', message.type); } }