initial commit
This commit is contained in:
192
fluxer_api/src/voice/VoiceAvailabilityService.ts
Normal file
192
fluxer_api/src/voice/VoiceAvailabilityService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
fluxer_api/src/voice/VoiceConstants.ts
Normal file
22
fluxer_api/src/voice/VoiceConstants.ts
Normal 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';
|
||||
158
fluxer_api/src/voice/VoiceDataInitializer.ts
Normal file
158
fluxer_api/src/voice/VoiceDataInitializer.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
fluxer_api/src/voice/VoiceModel.ts
Normal file
73
fluxer_api/src/voice/VoiceModel.ts
Normal 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;
|
||||
}
|
||||
218
fluxer_api/src/voice/VoiceRepository.ts
Normal file
218
fluxer_api/src/voice/VoiceRepository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
550
fluxer_api/src/voice/VoiceService.ts
Normal file
550
fluxer_api/src/voice/VoiceService.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
236
fluxer_api/src/voice/VoiceTopology.ts
Normal file
236
fluxer_api/src/voice/VoiceTopology.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user