initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import {GuildFeatures} from '~/Constants';
import type {
VoiceRegionAvailability,
VoiceRegionMetadata,
VoiceRegionRecord,
VoiceServerRecord,
} from '~/voice/VoiceModel';
import type {VoiceTopology} from '~/voice/VoiceTopology';
export interface VoiceAccessContext {
requestingUserId: UserID;
guildId?: GuildID;
guildFeatures?: Set<string>;
}
export class VoiceAvailabilityService {
private rotationIndex: Map<string, number> = new Map();
constructor(private topology: VoiceTopology) {}
getRegionMetadata(): Array<VoiceRegionMetadata> {
return this.topology.getRegionMetadataList();
}
isRegionAccessible(region: VoiceRegionRecord, context: VoiceAccessContext): boolean {
const {restrictions} = region;
if (restrictions.allowedUserIds.size > 0 && !restrictions.allowedUserIds.has(context.requestingUserId)) {
return false;
}
const hasAllowedGuildIds = restrictions.allowedGuildIds.size > 0;
const hasRequiredGuildFeatures = restrictions.requiredGuildFeatures.size > 0;
const hasVipOnly = restrictions.vipOnly;
if (!hasAllowedGuildIds && !hasRequiredGuildFeatures && !hasVipOnly) {
return true;
}
if (!context.guildId) {
return false;
}
const isGuildAllowed = hasAllowedGuildIds && restrictions.allowedGuildIds.has(context.guildId);
if (isGuildAllowed) {
return true;
}
if (!hasRequiredGuildFeatures && !hasVipOnly) {
return !hasAllowedGuildIds;
}
if (!context.guildFeatures) {
return false;
}
if (hasVipOnly && !context.guildFeatures.has(GuildFeatures.VIP_VOICE)) {
return false;
}
if (hasRequiredGuildFeatures) {
for (const feature of restrictions.requiredGuildFeatures) {
if (context.guildFeatures.has(feature)) {
return true;
}
}
return false;
}
return true;
}
isServerAccessible(server: VoiceServerRecord, context: VoiceAccessContext): boolean {
const {restrictions} = server;
if (!server.isActive) {
return false;
}
if (restrictions.allowedUserIds.size > 0 && !restrictions.allowedUserIds.has(context.requestingUserId)) {
return false;
}
const hasAllowedGuildIds = restrictions.allowedGuildIds.size > 0;
const hasRequiredGuildFeatures = restrictions.requiredGuildFeatures.size > 0;
const hasVipOnly = restrictions.vipOnly;
if (!hasAllowedGuildIds && !hasRequiredGuildFeatures && !hasVipOnly) {
return true;
}
if (!context.guildId) {
return false;
}
const isGuildAllowed = hasAllowedGuildIds && restrictions.allowedGuildIds.has(context.guildId);
if (isGuildAllowed) {
return true;
}
if (!hasRequiredGuildFeatures && !hasVipOnly) {
return !hasAllowedGuildIds;
}
if (!context.guildFeatures) {
return false;
}
if (hasVipOnly && !context.guildFeatures.has(GuildFeatures.VIP_VOICE)) {
return false;
}
if (hasRequiredGuildFeatures) {
for (const feature of restrictions.requiredGuildFeatures) {
if (context.guildFeatures.has(feature)) {
return true;
}
}
return false;
}
return true;
}
getAvailableRegions(context: VoiceAccessContext): Array<VoiceRegionAvailability> {
const regions = this.topology.getAllRegions();
return regions.map<VoiceRegionAvailability>((region) => {
const servers = this.topology.getServersForRegion(region.id);
const accessibleServers = servers.filter((server) => this.isServerAccessible(server, context));
const regionAccessible = this.isRegionAccessible(region, context);
return {
id: region.id,
name: region.name,
emoji: region.emoji,
latitude: region.latitude,
longitude: region.longitude,
isDefault: region.isDefault,
vipOnly: region.restrictions.vipOnly,
requiredGuildFeatures: Array.from(region.restrictions.requiredGuildFeatures),
serverCount: servers.length,
activeServerCount: accessibleServers.length,
isAccessible: regionAccessible && accessibleServers.length > 0,
restrictions: region.restrictions,
};
});
}
selectServer(regionId: string, context: VoiceAccessContext): VoiceServerRecord | null {
const servers = this.topology.getServersForRegion(regionId);
if (servers.length === 0) {
return null;
}
const accessibleServers = servers.filter((server) => this.isServerAccessible(server, context));
if (accessibleServers.length === 0) {
return null;
}
const index = this.rotationIndex.get(regionId) ?? 0;
const server = accessibleServers[index % accessibleServers.length];
this.rotationIndex.set(regionId, (index + 1) % accessibleServers.length);
return server;
}
resetRotation(regionId: string): void {
this.rotationIndex.delete(regionId);
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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/>.
*/
export const VOICE_CONFIGURATION_CHANNEL = 'voice:config:refresh';
export const VOICE_OCCUPANCY_REGION_KEY_PREFIX = 'voice:occupancy:region';
export const VOICE_OCCUPANCY_SERVER_KEY_PREFIX = 'voice:occupancy:server';

View File

@@ -0,0 +1,158 @@
/*
* 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 {Config} from '~/Config';
import {Logger} from '~/Logger';
import type {VoiceRegionRecord} from './VoiceModel';
import {VoiceRepository} from './VoiceRepository';
export class VoiceDataInitializer {
async initialize(): Promise<void> {
if (!Config.voice.enabled || !Config.voice.autoCreateDummyData) {
return;
}
try {
const repository = new VoiceRepository();
const existingRegions = await repository.listRegions();
if (existingRegions.length > 0) {
Logger.info(
`[VoiceDataInitializer] Deleting ${existingRegions.length} existing voice regions to recreate with fresh data...`,
);
for (const region of existingRegions) {
await repository.deleteRegion(region.id);
Logger.info(`[VoiceDataInitializer] Deleted region: ${region.name} (${region.id})`);
}
}
Logger.info('[VoiceDataInitializer] Creating dummy voice regions and servers...');
const livekitApiKey = Config.voice.apiKey;
const livekitApiSecret = Config.voice.apiSecret;
if (!livekitApiKey || !livekitApiSecret) {
Logger.warn('[VoiceDataInitializer] LiveKit API key/secret not configured, cannot create dummy servers');
return;
}
await this.createDefaultRegions(repository, livekitApiKey, livekitApiSecret);
Logger.info('[VoiceDataInitializer] Successfully created dummy voice regions and servers');
} catch (error) {
Logger.error({error}, '[VoiceDataInitializer] Failed to create dummy voice data');
}
}
private async createDefaultRegions(
repository: VoiceRepository,
livekitApiKey: string,
livekitApiSecret: string,
): Promise<void> {
const defaultRegions: Array<{
region: VoiceRegionRecord;
}> = [
{
region: {
id: 'us-default',
name: 'US Default',
emoji: '🇺🇸',
latitude: 39.8283,
longitude: -98.5795,
isDefault: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
createdAt: new Date(),
updatedAt: new Date(),
},
},
{
region: {
id: 'eu-default',
name: 'EU Default',
emoji: '🇪🇺',
latitude: 50.0755,
longitude: 14.4378,
isDefault: false,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
createdAt: new Date(),
updatedAt: new Date(),
},
},
{
region: {
id: 'asia-default',
name: 'Asia Default',
emoji: '🌏',
latitude: 35.6762,
longitude: 139.6503,
isDefault: false,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
createdAt: new Date(),
updatedAt: new Date(),
},
},
];
const livekitEndpoint =
Config.voice.url ||
(() => {
const protocol = new URL(Config.endpoints.apiPublic).protocol.slice(0, -1) === 'https' ? 'wss' : 'ws';
return `${protocol}://${new URL(Config.endpoints.apiPublic).hostname}/livekit`;
})();
for (const {region} of defaultRegions) {
await repository.createRegion(region);
Logger.info(`[VoiceDataInitializer] Created region: ${region.name} (${region.id})`);
const serverId = `${region.id}-server-1`;
await repository.createServer({
regionId: region.id,
serverId,
endpoint: livekitEndpoint,
apiKey: livekitApiKey,
apiSecret: livekitApiSecret,
isActive: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
Logger.info(`[VoiceDataInitializer] Created server: ${serverId} -> ${livekitEndpoint}`);
}
}
}

View File

@@ -0,0 +1,73 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
interface VoiceRestriction {
vipOnly: boolean;
requiredGuildFeatures: Set<string>;
allowedGuildIds: Set<GuildID>;
allowedUserIds: Set<UserID>;
}
export interface VoiceRegionRecord {
id: string;
name: string;
emoji: string;
latitude: number;
longitude: number;
isDefault: boolean;
restrictions: VoiceRestriction;
createdAt: Date | null;
updatedAt: Date | null;
}
export interface VoiceServerRecord {
regionId: string;
serverId: string;
endpoint: string;
apiKey: string;
apiSecret: string;
isActive: boolean;
restrictions: VoiceRestriction;
createdAt: Date | null;
updatedAt: Date | null;
}
export interface VoiceRegionWithServers extends VoiceRegionRecord {
servers: Array<VoiceServerRecord>;
}
export interface VoiceRegionMetadata {
id: string;
name: string;
emoji: string;
latitude: number;
longitude: number;
isDefault: boolean;
vipOnly: boolean;
requiredGuildFeatures: Array<string>;
}
export interface VoiceRegionAvailability extends VoiceRegionMetadata {
isAccessible: boolean;
restrictions: VoiceRestriction;
serverCount: number;
activeServerCount: number;
}

View File

@@ -0,0 +1,218 @@
/*
* 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 {createGuildIDSet, createUserIDSet} from '~/BrandedTypes';
import {BatchBuilder, defineTable, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import {
VOICE_REGION_COLUMNS,
VOICE_SERVER_COLUMNS,
type VoiceRegionRow,
type VoiceServerRow,
} from '~/database/CassandraTypes';
import type {VoiceRegionRecord, VoiceRegionWithServers, VoiceServerRecord} from './VoiceModel';
const VoiceRegions = defineTable<VoiceRegionRow, 'id'>({
name: 'voice_regions',
columns: VOICE_REGION_COLUMNS,
primaryKey: ['id'],
});
const VoiceServers = defineTable<VoiceServerRow, 'region_id' | 'server_id'>({
name: 'voice_servers',
columns: VOICE_SERVER_COLUMNS,
primaryKey: ['region_id', 'server_id'],
});
const LIST_REGIONS_CQL = VoiceRegions.selectCql();
const GET_REGION_CQL = VoiceRegions.selectCql({
where: VoiceRegions.where.eq('id'),
});
const LIST_SERVERS_FOR_REGION_CQL = VoiceServers.selectCql({
where: VoiceServers.where.eq('region_id'),
});
const GET_SERVER_CQL = VoiceServers.selectCql({
where: [VoiceServers.where.eq('region_id'), VoiceServers.where.eq('server_id')],
});
export class VoiceRepository {
async listRegions(): Promise<Array<VoiceRegionRecord>> {
const rows = await fetchMany<VoiceRegionRow>(LIST_REGIONS_CQL, {});
return rows.map((row) => this.mapRegionRow(row));
}
async listRegionsWithServers(): Promise<Array<VoiceRegionWithServers>> {
const regions = await this.listRegions();
const results: Array<VoiceRegionWithServers> = [];
for (const region of regions) {
const servers = await this.listServersForRegion(region.id);
results.push({
...region,
servers,
});
}
return results;
}
async getRegion(id: string): Promise<VoiceRegionRecord | null> {
const row = await fetchOne<VoiceRegionRow>(GET_REGION_CQL, {id});
return row ? this.mapRegionRow(row) : null;
}
async getRegionWithServers(id: string): Promise<VoiceRegionWithServers | null> {
const region = await this.getRegion(id);
if (!region) {
return null;
}
const servers = await this.listServersForRegion(id);
return {...region, servers};
}
async upsertRegion(region: VoiceRegionRecord): Promise<void> {
const row: VoiceRegionRow = {
id: region.id,
name: region.name,
emoji: region.emoji,
latitude: region.latitude,
longitude: region.longitude,
is_default: region.isDefault,
vip_only: region.restrictions.vipOnly,
required_guild_features: new Set(region.restrictions.requiredGuildFeatures),
allowed_guild_ids: new Set(Array.from(region.restrictions.allowedGuildIds).map((id) => BigInt(id))),
allowed_user_ids: new Set(Array.from(region.restrictions.allowedUserIds).map((id) => BigInt(id))),
created_at: region.createdAt ?? new Date(),
updated_at: region.updatedAt ?? new Date(),
};
await upsertOne(VoiceRegions.upsertAll(row));
}
async deleteRegion(regionId: string): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(VoiceRegions.deleteByPk({id: regionId}));
const servers = await this.listServersForRegion(regionId);
for (const server of servers) {
batch.addPrepared(VoiceServers.deleteByPk({region_id: regionId, server_id: server.serverId}));
}
await batch.execute();
}
async createRegion(region: Omit<VoiceRegionRecord, 'createdAt' | 'updatedAt'>): Promise<VoiceRegionRecord> {
const now = new Date();
const fullRegion: VoiceRegionRecord = {
...region,
createdAt: now,
updatedAt: now,
};
await this.upsertRegion(fullRegion);
return fullRegion;
}
async listServersForRegion(regionId: string): Promise<Array<VoiceServerRecord>> {
const rows = await fetchMany<VoiceServerRow>(LIST_SERVERS_FOR_REGION_CQL, {region_id: regionId});
return rows.map((row) => this.mapServerRow(row));
}
async listServers(regionId: string): Promise<Array<VoiceServerRecord>> {
return this.listServersForRegion(regionId);
}
async getServer(regionId: string, serverId: string): Promise<VoiceServerRecord | null> {
const row = await fetchOne<VoiceServerRow>(GET_SERVER_CQL, {region_id: regionId, server_id: serverId});
return row ? this.mapServerRow(row) : null;
}
async createServer(server: Omit<VoiceServerRecord, 'createdAt' | 'updatedAt'>): Promise<VoiceServerRecord> {
const now = new Date();
const fullServer: VoiceServerRecord = {
...server,
createdAt: now,
updatedAt: now,
};
await this.upsertServer(fullServer);
return fullServer;
}
async upsertServer(server: VoiceServerRecord): Promise<void> {
const row: VoiceServerRow = {
region_id: server.regionId,
server_id: server.serverId,
endpoint: server.endpoint,
api_key: server.apiKey,
api_secret: server.apiSecret,
is_active: server.isActive,
vip_only: server.restrictions.vipOnly,
required_guild_features: new Set(server.restrictions.requiredGuildFeatures),
allowed_guild_ids: new Set(Array.from(server.restrictions.allowedGuildIds).map((id) => BigInt(id))),
allowed_user_ids: new Set(Array.from(server.restrictions.allowedUserIds).map((id) => BigInt(id))),
created_at: server.createdAt ?? new Date(),
updated_at: server.updatedAt ?? new Date(),
};
await upsertOne(VoiceServers.upsertAll(row));
}
async deleteServer(regionId: string, serverId: string): Promise<void> {
await deleteOneOrMany(VoiceServers.deleteByPk({region_id: regionId, server_id: serverId}));
}
private mapRegionRow(row: VoiceRegionRow): VoiceRegionRecord {
return {
id: row.id,
name: row.name,
emoji: row.emoji,
latitude: row.latitude,
longitude: row.longitude,
isDefault: row.is_default ?? false,
restrictions: {
vipOnly: row.vip_only ?? false,
requiredGuildFeatures: new Set(row.required_guild_features ?? []),
allowedGuildIds: createGuildIDSet(new Set(row.allowed_guild_ids ?? [])),
allowedUserIds: createUserIDSet(new Set(row.allowed_user_ids ?? [])),
},
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private mapServerRow(row: VoiceServerRow): VoiceServerRecord {
return {
regionId: row.region_id,
serverId: row.server_id,
endpoint: row.endpoint,
apiKey: row.api_key,
apiSecret: row.api_secret,
isActive: row.is_active ?? true,
restrictions: {
vipOnly: row.vip_only ?? false,
requiredGuildFeatures: new Set(row.required_guild_features ?? []),
allowedGuildIds: createGuildIDSet(new Set(row.allowed_guild_ids ?? [])),
allowedUserIds: createUserIDSet(new Set(row.allowed_user_ids ?? [])),
},
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View File

@@ -0,0 +1,550 @@
/*
* 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 '~/BrandedTypes';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import {
FeatureTemporarilyDisabledError,
UnknownChannelError,
UnknownGuildMemberError,
UnknownUserError,
} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {LiveKitService} from '~/infrastructure/LiveKitService';
import type {VoiceRoomStore} from '~/infrastructure/VoiceRoomStore';
import type {IUserRepository} from '~/user/IUserRepository';
import {calculateDistance} from '~/utils/GeoUtils';
import type {VoiceAccessContext, VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
import type {VoiceRegionAvailability, VoiceServerRecord} from '~/voice/VoiceModel';
import {generateConnectionId} from '~/words/words';
interface GetVoiceTokenParams {
guildId?: GuildID;
channelId: ChannelID;
userId: UserID;
connectionId?: string;
latitude?: string;
longitude?: string;
canSpeak?: boolean;
canStream?: boolean;
canVideo?: boolean;
}
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: IGuildRepository,
private userRepository: IUserRepository,
private channelRepository: IChannelRepository,
private voiceRoomStore: VoiceRoomStore,
private voiceAvailabilityService: VoiceAvailabilityService,
) {}
private findClosestRegion(
latitude: string,
longitude: string,
accessibleRegions: Array<{id: string; latitude: number; longitude: number}>,
): string | null {
const userLat = parseFloat(latitude);
const userLon = parseFloat(longitude);
if (Number.isNaN(userLat) || Number.isNaN(userLon)) {
return null;
}
let closestRegion: string | null = null;
let minDistance = Number.POSITIVE_INFINITY;
for (const region of accessibleRegions) {
const distance = calculateDistance(userLat, userLon, region.latitude, region.longitude);
if (distance < minDistance) {
minDistance = distance;
closestRegion = region.id;
}
}
return closestRegion;
}
async getVoiceToken(params: GetVoiceTokenParams): Promise<{
token: string;
endpoint: string;
connectionId: 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();
}
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 regionPreference = this.determineRegionPreference({
channelRtcRegion: channel.rtcRegion,
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,
});
if (resolvedPinnedServer) {
regionId = resolvedPinnedServer.regionId;
serverId = resolvedPinnedServer.serverId;
serverEndpoint = resolvedPinnedServer.endpoint;
}
if (!serverId) {
const coordinates =
regionPreference.mode === 'automatic' && params.latitude && params.longitude
? {latitude: params.latitude, longitude: params.longitude}
: null;
regionId = this.chooseRegionId({
preferredRegionId: regionPreference.regionId,
accessibleRegions,
availableRegions,
coordinates,
});
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();
const {token, endpoint} = await this.liveKitService.createToken({
userId,
guildId,
channelId,
connectionId,
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) => {
console.error('Failed to update LiveKit participant after token creation:', error);
});
}
return {token, endpoint, connectionId};
}
private determineRegionPreference({
channelRtcRegion,
accessibleRegions,
availableRegions,
defaultRegionId,
}: {
channelRtcRegion: string | null;
accessibleRegions: Array<VoiceRegionAvailability>;
availableRegions: Array<VoiceRegionAvailability>;
defaultRegionId: string | null;
}): {regionId: string | null; mode: 'explicit' | 'automatic'} {
const accessibleRegionIds = new Set(accessibleRegions.map((region) => region.id));
if (channelRtcRegion) {
if (accessibleRegionIds.has(channelRtcRegion)) {
return {regionId: channelRtcRegion, mode: 'explicit'};
}
return {regionId: null, mode: 'automatic'};
}
if (defaultRegionId && accessibleRegionIds.has(defaultRegionId)) {
return {regionId: defaultRegionId, mode: 'automatic'};
}
const fallbackRegion = accessibleRegions[0] ?? availableRegions[0] ?? null;
return {regionId: fallbackRegion ? fallbackRegion.id : null, mode: 'automatic'};
}
private chooseRegionId({
preferredRegionId,
accessibleRegions,
availableRegions,
coordinates,
}: {
preferredRegionId: string | null;
accessibleRegions: Array<VoiceRegionAvailability>;
availableRegions: Array<VoiceRegionAvailability>;
coordinates: {latitude: string; longitude: string} | null;
}): string | null {
if (coordinates && accessibleRegions.length > 0) {
const closestRegionId = this.findClosestRegion(coordinates.latitude, coordinates.longitude, accessibleRegions);
if (closestRegionId) {
return closestRegionId;
}
}
if (preferredRegionId) {
return preferredRegionId;
}
const accessibleFallback = accessibleRegions[0];
if (accessibleFallback) {
return accessibleFallback.id;
}
return availableRegions[0]?.id ?? null;
}
private async resolvePinnedServer({
pinnedServer,
guildId,
channelId,
context,
}: {
pinnedServer: Awaited<ReturnType<VoiceRoomStore['getPinnedRoomServer']>>;
guildId?: GuildID;
channelId: ChannelID;
context: VoiceAccessContext;
}): Promise<{regionId: string; serverId: string; endpoint: string} | null> {
if (!pinnedServer) {
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 participants = await this.liveKitService.listParticipants({
guildId,
channelId,
regionId: pinnedServer.regionId,
serverId: pinnedServer.serverId,
});
for (const participant of 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) {
console.error(`Failed to update participant ${participant.identity}:`, error);
}
}
}
}
}
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 participants = await this.liveKitService.listParticipants({
guildId,
channelId,
regionId: pinnedServer.regionId,
serverId: pinnedServer.serverId,
});
let disconnectedCount = 0;
for (const participant of 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) {
console.error(`Failed to disconnect participant ${participant.identity}:`, error);
}
}
return {
success: true,
disconnectedCount,
message: `Successfully disconnected ${disconnectedCount} participant(s)`,
};
} catch (error) {
console.error('Error disconnecting channel participants:', error);
return {
success: false,
disconnectedCount: 0,
message: 'Failed to retrieve participants from voice room',
};
}
}
}

View File

@@ -0,0 +1,236 @@
/*
* 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 {Redis} from 'ioredis';
import {Logger} from '~/Logger';
import {VOICE_CONFIGURATION_CHANNEL} from '~/voice/VoiceConstants';
import type {VoiceRegionMetadata, VoiceRegionRecord, VoiceServerRecord} from '~/voice/VoiceModel';
import type {VoiceRepository} from '~/voice/VoiceRepository';
type Subscriber = () => void;
export class VoiceTopology {
private initialized = false;
private reloadPromise: Promise<void> | null = null;
private regions: Map<string, VoiceRegionRecord> = new Map();
private serversByRegion: Map<string, Array<VoiceServerRecord>> = new Map();
private defaultRegionId: string | null = null;
private subscribers: Set<Subscriber> = new Set();
private serverRotationIndex: Map<string, number> = new Map();
constructor(
private voiceRepository: VoiceRepository,
private redisSubscriber: Redis | null,
) {}
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
await this.reload();
if (this.redisSubscriber) {
try {
await this.redisSubscriber.subscribe(VOICE_CONFIGURATION_CHANNEL);
this.redisSubscriber.on('message', (channel, _message) => {
if (channel === VOICE_CONFIGURATION_CHANNEL) {
this.reload().catch((error) => {
Logger.error({error}, 'Failed to reload voice topology from Redis notification');
});
}
});
} catch (error) {
Logger.error({error}, 'Failed to subscribe to voice configuration channel');
}
}
this.initialized = true;
}
getDefaultRegion(): VoiceRegionRecord | null {
if (this.defaultRegionId === null) {
return null;
}
return this.regions.get(this.defaultRegionId) ?? null;
}
getDefaultRegionId(): string | null {
return this.defaultRegionId;
}
getRegion(regionId: string): VoiceRegionRecord | null {
return this.regions.get(regionId) ?? null;
}
getAllRegions(): Array<VoiceRegionRecord> {
return Array.from(this.regions.values());
}
getRegionMetadataList(): Array<VoiceRegionMetadata> {
return this.getAllRegions().map((region) => ({
id: region.id,
name: region.name,
emoji: region.emoji,
latitude: region.latitude,
longitude: region.longitude,
isDefault: region.isDefault,
vipOnly: region.restrictions.vipOnly,
requiredGuildFeatures: Array.from(region.restrictions.requiredGuildFeatures),
}));
}
getServersForRegion(regionId: string): Array<VoiceServerRecord> {
return (this.serversByRegion.get(regionId) ?? []).slice();
}
getServer(regionId: string, serverId: string): VoiceServerRecord | null {
const servers = this.serversByRegion.get(regionId);
if (!servers) {
return null;
}
return servers.find((server) => server.serverId === serverId) ?? null;
}
registerSubscriber(subscriber: Subscriber): void {
this.subscribers.add(subscriber);
}
unregisterSubscriber(subscriber: Subscriber): void {
this.subscribers.delete(subscriber);
}
getNextServer(regionId: string): VoiceServerRecord | null {
const servers = this.serversByRegion.get(regionId);
if (!servers || servers.length === 0) {
return null;
}
const currentIndex = this.serverRotationIndex.get(regionId) ?? 0;
const server = servers[currentIndex % servers.length];
this.serverRotationIndex.set(regionId, (currentIndex + 1) % servers.length);
return server;
}
private async reload(): Promise<void> {
if (this.reloadPromise) {
return this.reloadPromise;
}
this.reloadPromise = (async () => {
const regionsWithServers = await this.voiceRepository.listRegionsWithServers();
const newRegions: Map<string, VoiceRegionRecord> = new Map();
const newServers: Map<string, Array<VoiceServerRecord>> = new Map();
for (const region of regionsWithServers) {
const sortedServers = region.servers.slice().sort((a, b) => a.serverId.localeCompare(b.serverId));
const regionRecord: VoiceRegionRecord = {
id: region.id,
name: region.name,
emoji: region.emoji,
latitude: region.latitude,
longitude: region.longitude,
isDefault: region.isDefault,
restrictions: {
vipOnly: region.restrictions.vipOnly,
requiredGuildFeatures: new Set(region.restrictions.requiredGuildFeatures),
allowedGuildIds: new Set(region.restrictions.allowedGuildIds),
allowedUserIds: new Set(region.restrictions.allowedUserIds),
},
createdAt: region.createdAt,
updatedAt: region.updatedAt,
};
newRegions.set(region.id, regionRecord);
newServers.set(
region.id,
sortedServers.map((server) => ({
...server,
restrictions: {
vipOnly: server.restrictions.vipOnly,
requiredGuildFeatures: new Set(server.restrictions.requiredGuildFeatures),
allowedGuildIds: new Set(server.restrictions.allowedGuildIds),
allowedUserIds: new Set(server.restrictions.allowedUserIds),
},
})),
);
}
this.regions = newRegions;
this.serversByRegion = newServers;
this.recalculateServerRotation();
this.recalculateDefaultRegion();
this.notifySubscribers();
})()
.catch((error) => {
Logger.error({error}, 'Failed to reload voice topology');
throw error;
})
.finally(() => {
this.reloadPromise = null;
});
return this.reloadPromise;
}
private recalculateServerRotation(): void {
const newIndex = new Map<string, number>();
for (const [regionId, servers] of this.serversByRegion.entries()) {
if (servers.length === 0) {
continue;
}
const previousIndex = this.serverRotationIndex.get(regionId) ?? 0;
newIndex.set(regionId, previousIndex % servers.length);
}
this.serverRotationIndex = newIndex;
}
private recalculateDefaultRegion(): void {
const regions = Array.from(this.regions.values());
let defaultRegion: VoiceRegionRecord | null = null;
for (const region of regions) {
if (region.isDefault) {
defaultRegion = region;
break;
}
}
if (!defaultRegion && regions.length > 0) {
defaultRegion = regions[0];
}
this.defaultRegionId = defaultRegion ? defaultRegion.id : null;
}
private notifySubscribers(): void {
for (const subscriber of this.subscribers) {
try {
subscriber();
} catch (error) {
Logger.error({error}, 'VoiceTopology subscriber threw an error');
}
}
}
}