/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import type {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes'; import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository'; import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate'; import type {LiveKitService} from '@fluxer/api/src/infrastructure/LiveKitService'; import type {PinnedRoomServer, VoiceRoomStore} from '@fluxer/api/src/infrastructure/VoiceRoomStore'; import {Logger} from '@fluxer/api/src/Logger'; import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository'; import type {VoiceAccessContext, VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService'; import type {VoiceRegionAvailability, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel'; import {resolveVoiceRegionPreference, selectVoiceRegionId} from '@fluxer/api/src/voice/VoiceRegionSelection'; import {generateConnectionId} from '@fluxer/api/src/words/Words'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {UnclaimedAccountCannotJoinOneOnOneVoiceCallsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotJoinOneOnOneVoiceCallsError'; import {UnclaimedAccountCannotJoinVoiceChannelsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotJoinVoiceChannelsError'; import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; interface GetVoiceTokenParams { guildId?: GuildID; channelId: ChannelID; userId: UserID; connectionId?: string; region?: string; latitude?: string; longitude?: string; canSpeak?: boolean; canStream?: boolean; canVideo?: boolean; tokenNonce?: string; } interface VoicePermissions { canSpeak: boolean; canStream: boolean; canVideo: boolean; } interface UpdateVoiceStateParams { guildId?: GuildID; channelId: ChannelID; userId: UserID; connectionId: string; mute?: boolean; deaf?: boolean; } export class VoiceService { constructor( private liveKitService: LiveKitService, private guildRepository: IGuildRepositoryAggregate, private userRepository: IUserRepository, private channelRepository: IChannelRepository, private voiceRoomStore: VoiceRoomStore, private voiceAvailabilityService: VoiceAvailabilityService, ) {} async getVoiceToken(params: GetVoiceTokenParams): Promise<{ token: string; endpoint: string; connectionId: string; tokenNonce: string; }> { const {guildId, channelId, userId, connectionId: providedConnectionId} = params; const user = await this.userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const channel = await this.channelRepository.findUnique(channelId); if (!channel) { throw new UnknownChannelError(); } const isUnclaimed = user.isUnclaimedAccount(); if (isUnclaimed) { if (channel.type === ChannelTypes.DM) { throw new UnclaimedAccountCannotJoinOneOnOneVoiceCallsError(); } if (channel.type === ChannelTypes.GUILD_VOICE) { const guild = guildId ? await this.guildRepository.findUnique(guildId) : null; const isOwner = guild?.ownerId === userId; if (!isOwner) { throw new UnclaimedAccountCannotJoinVoiceChannelsError(); } } } let mute = false; let deaf = false; let guildFeatures: Set | undefined; const voicePermissions: VoicePermissions = { canSpeak: params.canSpeak ?? true, canStream: params.canStream ?? true, canVideo: params.canVideo ?? true, }; if (guildId !== undefined) { const member = await this.guildRepository.getMember(guildId, userId); if (!member) { throw new UnknownGuildMemberError(); } mute = member.isMute; deaf = member.isDeaf; const guild = await this.guildRepository.findUnique(guildId); if (guild) { guildFeatures = guild.features; } } const context: VoiceAccessContext = { requestingUserId: userId, guildId, guildFeatures, }; const availableRegions = this.voiceAvailabilityService.getAvailableRegions(context); const accessibleRegions = availableRegions.filter((region) => region.isAccessible); const defaultRegionId = this.liveKitService.getDefaultRegionId(); const preferredRegionId = params.region ?? channel.rtcRegion ?? null; const regionPreference = resolveVoiceRegionPreference({ preferredRegionId, accessibleRegions, availableRegions, defaultRegionId, }); let regionId: string | null = null; let serverId: string | null = null; let serverEndpoint: string | null = null; const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, channelId); const resolvedPinnedServer = await this.resolvePinnedServer({ pinnedServer, guildId, channelId, context, preferredRegionId: regionPreference.regionId, mode: regionPreference.mode, }); if (resolvedPinnedServer) { regionId = resolvedPinnedServer.regionId; serverId = resolvedPinnedServer.serverId; serverEndpoint = resolvedPinnedServer.endpoint; } if (!serverId) { regionId = selectVoiceRegionId({ preferredRegionId: regionPreference.regionId, mode: regionPreference.mode, accessibleRegions, availableRegions, latitude: params.latitude, longitude: params.longitude, }); if (!regionId) { throw new FeatureTemporarilyDisabledError(); } const serverSelection = this.selectServerForRegion({ regionId, context, accessibleRegions, }); if (!serverSelection) { throw new FeatureTemporarilyDisabledError(); } regionId = serverSelection.regionId; serverId = serverSelection.server.serverId; serverEndpoint = serverSelection.server.endpoint; await this.voiceRoomStore.pinRoomServer(guildId, channelId, regionId, serverId, serverEndpoint); } if (!serverId || !regionId || !serverEndpoint) { throw new FeatureTemporarilyDisabledError(); } const serverRecord = this.liveKitService.getServer(regionId, serverId); if (!serverRecord) { throw new FeatureTemporarilyDisabledError(); } const connectionId = providedConnectionId || generateConnectionId(); Logger.debug( { guildId: guildId?.toString(), channelId: channelId.toString(), userId: userId.toString(), providedConnectionId, generatedConnectionId: connectionId, wasGenerated: !providedConnectionId, }, 'Voice token connection ID selection', ); const tokenNonce = params.tokenNonce ?? crypto.randomUUID(); const {token, endpoint} = await this.liveKitService.createToken({ userId, guildId, channelId, connectionId, tokenNonce, regionId, serverId, mute, deaf, canSpeak: voicePermissions.canSpeak, canStream: voicePermissions.canStream, canVideo: voicePermissions.canVideo, }); if (mute || deaf) { this.liveKitService .updateParticipant({ userId, guildId, channelId, connectionId, regionId, serverId, mute, deaf, }) .catch((error) => { Logger.error( { userId, guildId, channelId, connectionId, regionId, serverId, mute, deaf, error, }, 'Failed to update LiveKit participant after token creation', ); }); } return {token, endpoint, connectionId, tokenNonce}; } private async resolvePinnedServer({ pinnedServer, guildId, channelId, context, preferredRegionId, mode, }: { pinnedServer: PinnedRoomServer | null; guildId?: GuildID; channelId: ChannelID; context: VoiceAccessContext; preferredRegionId: string | null; mode: 'explicit' | 'automatic'; }): Promise<{regionId: string; serverId: string; endpoint: string} | null> { if (!pinnedServer) { return null; } if (mode === 'explicit' && preferredRegionId && pinnedServer.regionId !== preferredRegionId) { await this.voiceRoomStore.deleteRoomServer(guildId, channelId); return null; } const serverRecord = this.liveKitService.getServer(pinnedServer.regionId, pinnedServer.serverId); if (serverRecord && this.voiceAvailabilityService.isServerAccessible(serverRecord, context)) { return { regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, endpoint: serverRecord.endpoint, }; } await this.voiceRoomStore.deleteRoomServer(guildId, channelId); return null; } private selectServerForRegion({ regionId, context, accessibleRegions, }: { regionId: string; context: VoiceAccessContext; accessibleRegions: Array; }): { regionId: string; server: VoiceServerRecord; } | null { const initialServer = this.voiceAvailabilityService.selectServer(regionId, context); if (initialServer) { return {regionId, server: initialServer}; } const fallbackRegion = accessibleRegions.find((region) => region.id !== regionId); if (fallbackRegion) { const fallbackServer = this.voiceAvailabilityService.selectServer(fallbackRegion.id, context); if (fallbackServer) { return { regionId: fallbackRegion.id, server: fallbackServer, }; } } return null; } async updateVoiceState(params: UpdateVoiceStateParams): Promise { const {guildId, channelId, userId, connectionId, mute, deaf} = params; const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, channelId); if (!pinnedServer) { return; } await this.liveKitService.updateParticipant({ userId, guildId, channelId, connectionId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, mute, deaf, }); } async updateParticipant(params: { guildId?: GuildID; channelId: ChannelID; userId: UserID; mute: boolean; deaf: boolean; }): Promise { const {guildId, channelId, userId, mute, deaf} = params; const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, channelId); if (!pinnedServer) { return; } const result = await this.liveKitService.listParticipants({ guildId, channelId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, }); if (result.status === 'error') { Logger.error( {errorCode: result.errorCode, guildId, channelId}, 'Failed to list participants for self mute/deaf update', ); return; } for (const participant of result.participants) { const parts = participant.identity.split('_'); if (parts.length >= 2 && parts[0] === 'user') { const participantUserIdStr = parts[1]; if (participantUserIdStr === userId.toString()) { const connectionId = parts.slice(2).join('_'); try { await this.liveKitService.updateParticipant({ userId, guildId, channelId, connectionId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, mute, deaf, }); } catch (error) { Logger.error( { identity: participant.identity, userId, guildId, channelId, connectionId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, mute, deaf, error, }, 'Failed to update participant', ); } } } } } async disconnectParticipant(params: { guildId?: GuildID; channelId: ChannelID; userId: UserID; connectionId: string; }): Promise { const {guildId, channelId, userId, connectionId} = params; const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, channelId); if (!pinnedServer) { return; } await this.liveKitService.disconnectParticipant({ userId, guildId, channelId, connectionId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, }); } async updateParticipantPermissions(params: { guildId?: GuildID; channelId: ChannelID; userId: UserID; connectionId: string; canSpeak: boolean; canStream: boolean; canVideo: boolean; }): Promise { const {guildId, channelId, userId, connectionId, canSpeak, canStream, canVideo} = params; const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, channelId); if (!pinnedServer) { return; } await this.liveKitService.updateParticipantPermissions({ userId, guildId, channelId, connectionId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, canSpeak, canStream, canVideo, }); } async disconnectChannel(params: { guildId?: GuildID; channelId: ChannelID; }): Promise<{success: boolean; disconnectedCount: number; message?: string}> { const {guildId, channelId} = params; const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, channelId); if (!pinnedServer) { return { success: false, disconnectedCount: 0, message: 'No active voice session found for this channel', }; } try { const result = await this.liveKitService.listParticipants({ guildId, channelId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, }); if (result.status === 'error') { return { success: false, disconnectedCount: 0, message: 'Failed to retrieve participants from voice room', }; } let disconnectedCount = 0; for (const participant of result.participants) { try { const identityMatch = participant.identity.match(/^user_(\d+)_(.+)$/); if (identityMatch) { const [, userIdStr, connectionId] = identityMatch; const userId = BigInt(userIdStr) as UserID; await this.liveKitService.disconnectParticipant({ userId, guildId, channelId, connectionId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, }); disconnectedCount++; } } catch (error) { Logger.error( { identity: participant.identity, guildId, channelId, regionId: pinnedServer.regionId, serverId: pinnedServer.serverId, error, }, 'Failed to disconnect participant', ); } } return { success: true, disconnectedCount, message: `Successfully disconnected ${disconnectedCount} participant(s)`, }; } catch (error) { Logger.error({guildId, channelId, error}, 'Error disconnecting channel participants'); return { success: false, disconnectedCount: 0, message: 'Failed to retrieve participants from voice room', }; } } }