Files
fluxer/packages/api/src/voice/VoiceService.tsx
2026-02-17 12:22:36 +00:00

566 lines
15 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<string> | 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<VoiceRegionAvailability>;
}): {
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<void> {
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<void> {
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<void> {
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<void> {
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',
};
}
}
}