refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,36 @@
/*
* 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 {VoiceRegionRecord, VoiceRegionWithServers, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
export interface IVoiceRepository {
listRegions(): Promise<Array<VoiceRegionRecord>>;
listRegionsWithServers(): Promise<Array<VoiceRegionWithServers>>;
getRegion(id: string): Promise<VoiceRegionRecord | null>;
getRegionWithServers(id: string): Promise<VoiceRegionWithServers | null>;
upsertRegion(region: VoiceRegionRecord): Promise<void>;
deleteRegion(regionId: string): Promise<void>;
createRegion(region: Omit<VoiceRegionRecord, 'createdAt' | 'updatedAt'>): Promise<VoiceRegionRecord>;
listServersForRegion(regionId: string): Promise<Array<VoiceServerRecord>>;
listServers(regionId: string): Promise<Array<VoiceServerRecord>>;
getServer(regionId: string, serverId: string): Promise<VoiceServerRecord | null>;
createServer(server: Omit<VoiceServerRecord, 'createdAt' | 'updatedAt'>): Promise<VoiceServerRecord>;
upsertServer(server: VoiceServerRecord): Promise<void>;
deleteServer(regionId: string, serverId: string): Promise<void>;
}

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 '@fluxer/api/src/BrandedTypes';
import type {
VoiceRegionAvailability,
VoiceRegionMetadata,
VoiceRegionRecord,
VoiceServerRecord,
} from '@fluxer/api/src/voice/VoiceModel';
import type {VoiceTopology} from '@fluxer/api/src/voice/VoiceTopology';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
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,98 @@
/*
* 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 '@fluxer/api/src/Config';
import {Logger} from '@fluxer/api/src/Logger';
import {VoiceRepository} from '@fluxer/api/src/voice/VoiceRepository';
export class VoiceDataInitializer {
async initialize(): Promise<void> {
if (!Config.voice.enabled || !Config.voice.defaultRegion) {
return;
}
const defaultRegion = Config.voice.defaultRegion;
const livekitApiKey = Config.voice.apiKey;
const livekitApiSecret = Config.voice.apiSecret;
if (!livekitApiKey || !livekitApiSecret) {
Logger.warn('[VoiceDataInitializer] LiveKit API key/secret not configured, cannot create default region');
return;
}
try {
const repository = new VoiceRepository();
const existingRegions = await repository.listRegions();
if (existingRegions.length > 0) {
Logger.info(
`[VoiceDataInitializer] ${existingRegions.length} voice region(s) already exist, skipping default region creation`,
);
return;
}
Logger.info('[VoiceDataInitializer] Creating default voice region from config...');
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`;
})();
await repository.createRegion({
id: defaultRegion.id,
name: defaultRegion.name,
emoji: defaultRegion.emoji,
latitude: defaultRegion.latitude,
longitude: defaultRegion.longitude,
isDefault: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
Logger.info(`[VoiceDataInitializer] Created region: ${defaultRegion.name} (${defaultRegion.id})`);
const serverId = `${defaultRegion.id}-server-1`;
await repository.createServer({
regionId: defaultRegion.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}`);
Logger.info('[VoiceDataInitializer] Successfully created default voice region');
} catch (error) {
Logger.error({error}, '[VoiceDataInitializer] Failed to create default voice region');
}
}
}

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 '@fluxer/api/src/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,387 @@
/*
* 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 {createUserID} from '@fluxer/api/src/BrandedTypes';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {ILiveKitService} from '@fluxer/api/src/infrastructure/ILiveKitService';
import type {IMetricsService} from '@fluxer/api/src/infrastructure/IMetricsService';
import {parseParticipantIdentity, parseRoomName} from '@fluxer/api/src/infrastructure/VoiceRoomContext';
import type {VoiceRoomStore} from '@fluxer/api/src/infrastructure/VoiceRoomStore';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
interface GatewayVoiceStateEntry {
readonly connectionId: string;
readonly userId: string;
readonly channelId: string;
}
interface GatewayPendingJoinEntry {
readonly connectionId: string;
readonly userId: string;
readonly tokenNonce: string;
readonly expiresAt: number;
}
interface VoiceReconciliationWorkerOptions {
gatewayService: IGatewayService;
liveKitService: ILiveKitService;
voiceRoomStore: VoiceRoomStore;
kvClient: IKVProvider;
metricsService: IMetricsService;
logger: ILogger;
intervalMs?: number;
staggerDelayMs?: number;
}
interface RoomReconciliationResult {
readonly roomName: string;
readonly livekitOnlyConfirmed: number;
readonly livekitOnlyDisconnected: number;
readonly gatewayOnlyRemoved: number;
readonly consistent: number;
}
const DEFAULT_INTERVAL_MS = 60_000;
const DEFAULT_STAGGER_DELAY_MS = 100;
const ROOM_KEY_PREFIX = 'voice:room:server:';
export class VoiceReconciliationWorker {
private readonly gatewayService: IGatewayService;
private readonly liveKitService: ILiveKitService;
private readonly voiceRoomStore: VoiceRoomStore;
private readonly kvClient: IKVProvider;
private readonly metricsService: IMetricsService;
private readonly logger: ILogger;
private readonly intervalMs: number;
private readonly staggerDelayMs: number;
private intervalHandle: ReturnType<typeof setInterval> | null = null;
private reconciling = false;
constructor(options: VoiceReconciliationWorkerOptions) {
this.gatewayService = options.gatewayService;
this.liveKitService = options.liveKitService;
this.voiceRoomStore = options.voiceRoomStore;
this.kvClient = options.kvClient;
this.metricsService = options.metricsService;
this.logger = options.logger.child({worker: 'VoiceReconciliationWorker'});
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
this.staggerDelayMs = options.staggerDelayMs ?? DEFAULT_STAGGER_DELAY_MS;
}
start(): void {
if (this.intervalHandle) {
this.logger.warn('VoiceReconciliationWorker is already running');
return;
}
this.logger.info({intervalMs: this.intervalMs}, 'Starting VoiceReconciliationWorker');
this.runReconciliation();
this.intervalHandle = setInterval(() => {
this.runReconciliation();
}, this.intervalMs);
}
stop(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
this.logger.info('Stopped VoiceReconciliationWorker');
}
}
async reconcile(): Promise<void> {
const startTime = Date.now();
const roomNames = await this.discoverActiveRooms();
this.logger.info({roomCount: roomNames.length}, 'Starting reconciliation sweep');
let roomsChecked = 0;
let totalConfirmed = 0;
let totalDisconnected = 0;
let totalGatewayRemoved = 0;
let totalConsistent = 0;
let totalErrors = 0;
for (const roomName of roomNames) {
try {
const result = await this.reconcileRoom(roomName);
roomsChecked++;
totalConfirmed += result.livekitOnlyConfirmed;
totalDisconnected += result.livekitOnlyDisconnected;
totalGatewayRemoved += result.gatewayOnlyRemoved;
totalConsistent += result.consistent;
} catch (error) {
totalErrors++;
this.logger.error({error, roomName}, 'Failed to reconcile room');
}
if (this.staggerDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, this.staggerDelayMs));
}
}
const durationMs = Date.now() - startTime;
this.metricsService.gauge({name: 'fluxer.voice.reconcile.rooms_checked', value: roomsChecked});
this.metricsService.counter({name: 'fluxer.voice.reconcile.livekit_only_confirmed', value: totalConfirmed});
this.metricsService.counter({name: 'fluxer.voice.reconcile.livekit_only_disconnected', value: totalDisconnected});
this.metricsService.counter({name: 'fluxer.voice.reconcile.gateway_only_removed', value: totalGatewayRemoved});
this.metricsService.gauge({name: 'fluxer.voice.reconcile.consistent', value: totalConsistent});
this.metricsService.counter({name: 'fluxer.voice.reconcile.errors', value: totalErrors});
this.metricsService.histogram({name: 'fluxer.voice.reconcile.sweep_duration', valueMs: durationMs});
this.logger.info(
{
roomsChecked,
totalConfirmed,
totalDisconnected,
totalGatewayRemoved,
totalConsistent,
totalErrors,
durationMs,
},
'Reconciliation sweep complete',
);
}
private runReconciliation(): void {
if (this.reconciling) {
this.logger.warn('Skipping reconciliation sweep; previous sweep still in progress');
return;
}
this.reconciling = true;
this.reconcile()
.catch((error) => {
this.logger.error({error}, 'Reconciliation sweep failed unexpectedly');
})
.finally(() => {
this.reconciling = false;
});
}
private async discoverActiveRooms(): Promise<Array<string>> {
const keys = await this.kvClient.scan(`${ROOM_KEY_PREFIX}*`, 1000);
const roomNames: Array<string> = [];
for (const key of keys) {
const suffix = key.slice(ROOM_KEY_PREFIX.length);
if (suffix.startsWith('guild:')) {
const parts = suffix.split(':');
if (parts.length === 3) {
const guildId = parts[1];
const channelId = parts[2];
roomNames.push(`guild_${guildId}_channel_${channelId}`);
}
} else if (suffix.startsWith('dm:')) {
const channelId = suffix.slice(3);
roomNames.push(`dm_channel_${channelId}`);
}
}
return roomNames;
}
private async reconcileRoom(roomName: string): Promise<RoomReconciliationResult> {
const roomContext = parseRoomName(roomName);
if (!roomContext) {
this.logger.warn({roomName}, 'Could not parse room name; skipping');
return {roomName, livekitOnlyConfirmed: 0, livekitOnlyDisconnected: 0, gatewayOnlyRemoved: 0, consistent: 0};
}
const guildId = roomContext.type === 'guild' ? roomContext.guildId : undefined;
const channelId = roomContext.channelId;
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, channelId);
if (!pinnedServer) {
this.logger.debug({roomName}, 'No pinned server for room; skipping');
return {roomName, livekitOnlyConfirmed: 0, livekitOnlyDisconnected: 0, gatewayOnlyRemoved: 0, consistent: 0};
}
const {regionId, serverId} = pinnedServer;
const listResult = await this.liveKitService.listParticipants({
guildId,
channelId,
regionId,
serverId,
});
if (listResult.status === 'error') {
this.logger.warn({roomName, errorCode: listResult.errorCode}, 'Failed to list LiveKit participants');
throw new Error(`LiveKit listParticipants failed for ${roomName}: ${listResult.errorCode}`);
}
const livekitParticipants = listResult.participants;
const [{voiceStates}, {pendingJoins}] = await Promise.all([
this.gatewayService.getVoiceStatesForChannel({guildId, channelId}),
this.gatewayService.getPendingJoinsForChannel({guildId, channelId}),
]);
const gatewayConnectionIds = new Set<string>();
for (const vs of voiceStates) {
gatewayConnectionIds.add(vs.connectionId);
}
const pendingJoinByConnectionId = new Map<string, GatewayPendingJoinEntry>();
for (const pj of pendingJoins) {
pendingJoinByConnectionId.set(pj.connectionId, pj);
}
const livekitConnectionIds = new Set<string>();
let livekitOnlyConfirmed = 0;
let livekitOnlyDisconnected = 0;
let consistent = 0;
for (const participant of livekitParticipants) {
const parsed = parseParticipantIdentity(participant.identity);
if (!parsed) {
this.logger.warn({roomName, identity: participant.identity}, 'Could not parse participant identity; skipping');
continue;
}
const {userId, connectionId} = parsed;
livekitConnectionIds.add(connectionId);
if (gatewayConnectionIds.has(connectionId)) {
consistent++;
continue;
}
const pendingJoin = pendingJoinByConnectionId.get(connectionId);
if (pendingJoin && pendingJoin.expiresAt > Date.now()) {
await this.confirmPendingJoin(guildId, channelId, connectionId, pendingJoin, roomName);
livekitOnlyConfirmed++;
} else {
await this.evictFromLiveKit(userId, guildId, channelId, connectionId, regionId, serverId, roomName);
livekitOnlyDisconnected++;
}
}
let gatewayOnlyRemoved = 0;
for (const vs of voiceStates) {
if (!livekitConnectionIds.has(vs.connectionId)) {
await this.removeGhostState(guildId, vs, channelId, roomName);
gatewayOnlyRemoved++;
}
}
if (livekitOnlyConfirmed > 0 || livekitOnlyDisconnected > 0 || gatewayOnlyRemoved > 0) {
this.logger.info(
{roomName, livekitOnlyConfirmed, livekitOnlyDisconnected, gatewayOnlyRemoved, consistent},
'Room reconciliation found divergence',
);
}
return {roomName, livekitOnlyConfirmed, livekitOnlyDisconnected, gatewayOnlyRemoved, consistent};
}
private async confirmPendingJoin(
guildId: GuildID | undefined,
channelId: ChannelID,
connectionId: string,
pendingJoin: GatewayPendingJoinEntry,
roomName: string,
): Promise<void> {
try {
const result = await this.gatewayService.confirmVoiceConnection({
guildId,
channelId,
connectionId,
tokenNonce: pendingJoin.tokenNonce,
});
if (!result.success) {
this.logger.warn(
{roomName, connectionId, error: result.error},
'Gateway rejected voice connection confirmation',
);
}
} catch (error) {
this.logger.error({error, roomName, connectionId}, 'Failed to confirm pending voice connection');
}
}
private async evictFromLiveKit(
userId: UserID,
guildId: GuildID | undefined,
channelId: ChannelID,
connectionId: string,
regionId: string,
serverId: string,
roomName: string,
): Promise<void> {
try {
this.logger.info(
{roomName, userId: userId.toString(), connectionId},
'Evicting orphaned participant from LiveKit',
);
await this.liveKitService.disconnectParticipant({
userId,
guildId,
channelId,
connectionId,
regionId,
serverId,
});
} catch (error) {
this.logger.error({error, roomName, connectionId}, 'Failed to evict participant from LiveKit');
}
}
private async removeGhostState(
guildId: GuildID | undefined,
voiceState: GatewayVoiceStateEntry,
channelId: ChannelID,
roomName: string,
): Promise<void> {
try {
this.logger.info(
{roomName, userId: voiceState.userId, connectionId: voiceState.connectionId},
'Removing ghost voice state from gateway',
);
const result = await this.gatewayService.disconnectVoiceUserIfInChannel({
guildId,
channelId,
userId: createUserID(BigInt(voiceState.userId)),
connectionId: voiceState.connectionId,
});
if (result.ignored) {
this.logger.debug(
{roomName, userId: voiceState.userId, connectionId: voiceState.connectionId},
'Gateway ignored ghost state removal (user may have moved)',
);
}
} catch (error) {
this.logger.error(
{error, roomName, userId: voiceState.userId, connectionId: voiceState.connectionId},
'Failed to remove ghost voice state from gateway',
);
}
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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 {calculateDistance, parseCoordinate} from '@fluxer/api/src/utils/GeoUtils';
import type {VoiceRegionAvailability} from '@fluxer/api/src/voice/VoiceModel';
export interface VoiceRegionPreference {
regionId: string | null;
mode: 'explicit' | 'automatic';
}
export function resolveVoiceRegionPreference({
preferredRegionId,
accessibleRegions,
availableRegions,
defaultRegionId,
}: {
preferredRegionId: string | null;
accessibleRegions: Array<VoiceRegionAvailability>;
availableRegions: Array<VoiceRegionAvailability>;
defaultRegionId: string | null;
}): VoiceRegionPreference {
const accessibleRegionIds = new Set(accessibleRegions.map((region) => region.id));
if (preferredRegionId) {
if (accessibleRegionIds.has(preferredRegionId)) {
return {regionId: preferredRegionId, mode: 'explicit'};
}
return {regionId: null, mode: 'automatic'};
}
if (defaultRegionId && accessibleRegionIds.has(defaultRegionId)) {
return {regionId: defaultRegionId, mode: 'automatic'};
}
const defaultRegion =
accessibleRegions.find((region) => region.isDefault) ?? availableRegions.find((region) => region.isDefault) ?? null;
if (defaultRegion) {
return {regionId: defaultRegion.id, mode: 'automatic'};
}
const fallbackRegion = accessibleRegions[0] ?? availableRegions[0] ?? null;
return {regionId: fallbackRegion ? fallbackRegion.id : null, mode: 'automatic'};
}
export function selectVoiceRegionId({
preferredRegionId,
mode,
accessibleRegions,
availableRegions,
latitude,
longitude,
}: {
preferredRegionId: string | null;
mode: VoiceRegionPreference['mode'];
accessibleRegions: Array<VoiceRegionAvailability>;
availableRegions: Array<VoiceRegionAvailability>;
latitude?: string;
longitude?: string;
}): string | null {
if (mode === 'automatic' && accessibleRegions.length > 0) {
const closestRegionId = findClosestRegionId(latitude, longitude, accessibleRegions);
if (closestRegionId) {
return closestRegionId;
}
}
if (preferredRegionId) {
return preferredRegionId;
}
const accessibleFallback = accessibleRegions[0];
if (accessibleFallback) {
return accessibleFallback.id;
}
return availableRegions[0]?.id ?? null;
}
function findClosestRegionId(
latitude: string | undefined,
longitude: string | undefined,
accessibleRegions: Array<VoiceRegionAvailability>,
): string | null {
const userLat = parseCoordinate(latitude);
const userLon = parseCoordinate(longitude);
if (userLat === null || userLon === null) {
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;
}

View File

@@ -0,0 +1,233 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {
BatchBuilder,
defineTable,
deleteOneOrMany,
fetchMany,
fetchOne,
upsertOne,
} from '@fluxer/api/src/database/Cassandra';
import {
VOICE_REGION_COLUMNS,
VOICE_SERVER_COLUMNS,
type VoiceRegionRow,
type VoiceServerRow,
} from '@fluxer/api/src/database/types/VoiceTypes';
import type {IVoiceRepository} from '@fluxer/api/src/voice/IVoiceRepository';
import type {VoiceRegionRecord, VoiceRegionWithServers, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
function toIterable<T>(value: unknown): Array<T> {
if (value === null || value === undefined) return [];
if (value instanceof Set) return Array.from(value) as Array<T>;
if (Array.isArray(value)) return value as Array<T>;
return [value as T];
}
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 implements IVoiceRepository {
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(toIterable<string>(row.required_guild_features)),
allowedGuildIds: createGuildIDSet(new Set(toIterable<bigint>(row.allowed_guild_ids))),
allowedUserIds: createUserIDSet(new Set(toIterable<bigint>(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(toIterable<string>(row.required_guild_features)),
allowedGuildIds: createGuildIDSet(new Set(toIterable<bigint>(row.allowed_guild_ids))),
allowedUserIds: createUserIDSet(new Set(toIterable<bigint>(row.allowed_user_ids))),
},
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View File

@@ -0,0 +1,565 @@
/*
* 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',
};
}
}
}

View File

@@ -0,0 +1,247 @@
/*
* 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 {Logger} from '@fluxer/api/src/Logger';
import type {IVoiceRepository} from '@fluxer/api/src/voice/IVoiceRepository';
import {VOICE_CONFIGURATION_CHANNEL} from '@fluxer/api/src/voice/VoiceConstants';
import type {VoiceRegionMetadata, VoiceRegionRecord, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
import type {IKVProvider, IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
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();
private kvSubscription: IKVSubscription | null = null;
constructor(
private voiceRepository: IVoiceRepository,
private kvClient: IKVProvider | null,
) {}
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
await this.reload();
if (this.kvClient) {
try {
const subscription = this.kvClient.duplicate();
this.kvSubscription = subscription;
await subscription.connect();
await subscription.subscribe(VOICE_CONFIGURATION_CHANNEL);
subscription.on('message', (channel) => {
if (channel === VOICE_CONFIGURATION_CHANNEL) {
this.reload().catch((error) => {
Logger.error({error}, 'Failed to reload voice topology from KV 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');
}
}
}
shutdown(): void {
if (this.kvSubscription) {
this.kvSubscription.disconnect();
this.kvSubscription = null;
}
}
}

View File

@@ -0,0 +1,452 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {VoiceAccessContext} from '@fluxer/api/src/voice/VoiceAvailabilityService';
import {VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService';
import type {VoiceRegionRecord, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
import type {VoiceTopology} from '@fluxer/api/src/voice/VoiceTopology';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {describe, expect, it} from 'vitest';
function createMockRegion(overrides: Partial<VoiceRegionRecord> = {}): VoiceRegionRecord {
return {
id: 'us-default',
name: 'US Default',
emoji: 'flag',
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(),
...overrides,
};
}
function createMockServer(overrides: Partial<VoiceServerRecord> = {}): VoiceServerRecord {
return {
regionId: 'us-default',
serverId: 'server-1',
endpoint: 'wss://voice.example.com',
apiKey: 'test-key',
apiSecret: 'test-secret',
isActive: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockTopology(
regions: Array<VoiceRegionRecord>,
serversByRegion: Map<string, Array<VoiceServerRecord>>,
): VoiceTopology {
return {
getAllRegions: () => regions,
getServersForRegion: (regionId: string) => serversByRegion.get(regionId) ?? [],
getRegionMetadataList: () =>
regions.map((r) => ({
id: r.id,
name: r.name,
emoji: r.emoji,
latitude: r.latitude,
longitude: r.longitude,
isDefault: r.isDefault,
vipOnly: r.restrictions.vipOnly,
requiredGuildFeatures: Array.from(r.restrictions.requiredGuildFeatures),
})),
} as VoiceTopology;
}
describe('VoiceAvailabilityService', () => {
let service: VoiceAvailabilityService;
describe('isRegionAccessible', () => {
it('returns true for unrestricted region', () => {
const region = createMockRegion();
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.isRegionAccessible(region, context)).toBe(true);
});
it('returns false when user is not in allowedUserIds', () => {
const region = createMockRegion({
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set([456n as UserID]),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.isRegionAccessible(region, context)).toBe(false);
});
it('returns true when user is in allowedUserIds', () => {
const region = createMockRegion({
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set([123n as UserID]),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.isRegionAccessible(region, context)).toBe(true);
});
it('returns false for vipOnly region without VIP_VOICE feature', () => {
const region = createMockRegion({
restrictions: {
vipOnly: true,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
guildId: 456n as GuildID,
guildFeatures: new Set(),
};
expect(service.isRegionAccessible(region, context)).toBe(false);
});
it('returns true for vipOnly region with VIP_VOICE feature', () => {
const region = createMockRegion({
restrictions: {
vipOnly: true,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
guildId: 456n as GuildID,
guildFeatures: new Set([GuildFeatures.VIP_VOICE]),
};
expect(service.isRegionAccessible(region, context)).toBe(true);
});
it('returns false for vipOnly region without guildId', () => {
const region = createMockRegion({
restrictions: {
vipOnly: true,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.isRegionAccessible(region, context)).toBe(false);
});
it('returns true for guild in allowedGuildIds', () => {
const region = createMockRegion({
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set([456n as GuildID]),
allowedUserIds: new Set(),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
guildId: 456n as GuildID,
};
expect(service.isRegionAccessible(region, context)).toBe(true);
});
it('returns false for guild not in allowedGuildIds', () => {
const region = createMockRegion({
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set([789n as GuildID]),
allowedUserIds: new Set(),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
guildId: 456n as GuildID,
};
expect(service.isRegionAccessible(region, context)).toBe(false);
});
it('returns true for guild with required feature', () => {
const region = createMockRegion({
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(['PREMIUM']),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
guildId: 456n as GuildID,
guildFeatures: new Set(['PREMIUM']),
};
expect(service.isRegionAccessible(region, context)).toBe(true);
});
it('returns false for guild without required feature', () => {
const region = createMockRegion({
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(['PREMIUM']),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
const topology = createMockTopology([region], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
guildId: 456n as GuildID,
guildFeatures: new Set(),
};
expect(service.isRegionAccessible(region, context)).toBe(false);
});
});
describe('isServerAccessible', () => {
it('returns false for inactive server', () => {
const region = createMockRegion();
const server = createMockServer({isActive: false});
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.isServerAccessible(server, context)).toBe(false);
});
it('returns true for active unrestricted server', () => {
const region = createMockRegion();
const server = createMockServer();
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.isServerAccessible(server, context)).toBe(true);
});
it('returns false when user is not in server allowedUserIds', () => {
const region = createMockRegion();
const server = createMockServer({
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set([456n as UserID]),
},
});
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
expect(service.isServerAccessible(server, context)).toBe(false);
});
});
describe('getAvailableRegions', () => {
it('returns regions with accessibility status', () => {
const region1 = createMockRegion({id: 'us-default', isDefault: true});
const region2 = createMockRegion({
id: 'eu-vip',
isDefault: false,
restrictions: {
vipOnly: true,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
});
const server1 = createMockServer({regionId: 'us-default'});
const topology = createMockTopology(
[region1, region2],
new Map([
['us-default', [server1]],
['eu-vip', []],
]),
);
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
const regions = service.getAvailableRegions(context);
expect(regions).toHaveLength(2);
expect(regions[0].id).toBe('us-default');
expect(regions[0].isAccessible).toBe(true);
expect(regions[1].id).toBe('eu-vip');
expect(regions[1].isAccessible).toBe(false);
});
it('marks region as not accessible if no servers are available', () => {
const region = createMockRegion();
const topology = createMockTopology([region], new Map([['us-default', []]]));
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
const regions = service.getAvailableRegions(context);
expect(regions).toHaveLength(1);
expect(regions[0].isAccessible).toBe(false);
expect(regions[0].serverCount).toBe(0);
});
});
describe('selectServer', () => {
it('returns server from specified region', () => {
const region = createMockRegion();
const server = createMockServer();
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
const selected = service.selectServer('us-default', context);
expect(selected).not.toBeNull();
expect(selected!.serverId).toBe('server-1');
});
it('returns null for region with no servers', () => {
const region = createMockRegion();
const topology = createMockTopology([region], new Map([['us-default', []]]));
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
const selected = service.selectServer('us-default', context);
expect(selected).toBeNull();
});
it('returns null for non-existent region', () => {
const topology = createMockTopology([], new Map());
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
const selected = service.selectServer('non-existent', context);
expect(selected).toBeNull();
});
it('rotates between accessible servers', () => {
const region = createMockRegion();
const server1 = createMockServer({serverId: 'server-1'});
const server2 = createMockServer({serverId: 'server-2'});
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
service = new VoiceAvailabilityService(topology);
const context: VoiceAccessContext = {
requestingUserId: 123n as UserID,
};
const first = service.selectServer('us-default', context);
const second = service.selectServer('us-default', context);
const third = service.selectServer('us-default', context);
expect(first!.serverId).toBe('server-1');
expect(second!.serverId).toBe('server-2');
expect(third!.serverId).toBe('server-1');
});
});
});

View File

@@ -0,0 +1,139 @@
/*
* 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 {createTestAccount, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannelInvite,
createDmChannel,
createFriendship,
createGuild,
getChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {updateUserSettings} from '@fluxer/api/src/user/tests/UserTestUtils';
import {IncomingCallFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Voice Call Eligibility', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function setupUsersWithMutualGuild() {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
return {user1, user2, guild};
}
describe('DM call eligibility', () => {
it('returns ringable true for DM between friends', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await createFriendship(harness, user1, user2);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
const callData = await createBuilder<{ringable: boolean; silent?: boolean}>(harness, user1.token)
.get(`/channels/${dmChannel.id}/call`)
.execute();
expect(callData.ringable).toBe(true);
});
it('returns ringable true for DM with mutual guild membership', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
await updateUserSettings(harness, user2.token, {
incoming_call_flags: IncomingCallFlags.GUILD_MEMBERS,
});
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
.get(`/channels/${dmChannel.id}/call`)
.execute();
expect(callData.ringable).toBe(true);
});
it('returns ringable false for unclaimed account trying DM call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await unclaimAccount(harness, user1.userId);
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
.get(`/channels/${dmChannel.id}/call`)
.execute();
expect(callData.ringable).toBe(false);
});
});
describe('Channel type validation', () => {
it('returns 404 for non-existent channel', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token)
.get('/channels/999999999999999999/call')
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
.execute();
});
it('returns error for text channel call eligibility check', async () => {
const user = await createTestAccount(harness);
await ensureSessionStarted(harness, user.token);
const guild = await createGuild(harness, user.token, 'Test Guild');
const textChannel = await getChannel(harness, user.token, guild.system_channel_id!);
await createBuilder(harness, user.token)
.get(`/channels/${textChannel.id}/call`)
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_CHANNEL_TYPE_FOR_CALL')
.execute();
});
});
});

View File

@@ -0,0 +1,157 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannelInvite,
createDmChannel,
createFriendship,
createGuild,
getChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Voice Call Ringing', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function setupUsersWithMutualGuild() {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
return {user1, user2, guild};
}
describe('Ring call', () => {
it('rings call recipients in DM channel', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/ring`)
.body({recipients: [user2.userId]})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('rings call without specifying recipients', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/ring`)
.body({})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('rings call for friends DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await createFriendship(harness, user1, user2);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/ring`)
.body({recipients: [user2.userId]})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('returns 404 for non-existent channel', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token)
.post('/channels/999999999999999999/call/ring')
.body({recipients: []})
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
.execute();
});
it('returns error for text channel ring', async () => {
const user = await createTestAccount(harness);
await ensureSessionStarted(harness, user.token);
const guild = await createGuild(harness, user.token, 'Test Guild');
const textChannel = await getChannel(harness, user.token, guild.system_channel_id!);
await createBuilder(harness, user.token)
.post(`/channels/${textChannel.id}/call/ring`)
.body({})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_CHANNEL_TYPE_FOR_CALL')
.execute();
});
});
describe('Stop ringing', () => {
it('returns 404 when stopping ring for non-existent call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
.body({recipients: [user2.userId]})
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
.execute();
});
it('returns 404 when stopping ring without recipients for non-existent call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
.body({})
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
.execute();
});
});
});

View File

@@ -0,0 +1,138 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannelInvite,
createDmChannel,
createGuild,
getChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Voice Call Update', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function setupUsersWithMutualGuild() {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
return {user1, user2, guild};
}
describe('Update call region', () => {
it('returns 404 when updating region for non-existent call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.patch(`/channels/${dmChannel.id}/call`)
.body({region: 'us-west'})
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
.execute();
});
it('returns 404 when updating call without body', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.patch(`/channels/${dmChannel.id}/call`)
.body({})
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
.execute();
});
it('returns 404 for non-existent channel update', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token)
.patch('/channels/999999999999999999/call')
.body({region: 'us-west'})
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
.execute();
});
it('returns error for text channel update', async () => {
const user = await createTestAccount(harness);
await ensureSessionStarted(harness, user.token);
const guild = await createGuild(harness, user.token, 'Test Guild');
const textChannel = await getChannel(harness, user.token, guild.system_channel_id!);
await createBuilder(harness, user.token)
.patch(`/channels/${textChannel.id}/call`)
.body({region: 'us-west'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_CHANNEL_TYPE_FOR_CALL')
.execute();
});
});
describe('End call', () => {
it('ends call for DM channel', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/end`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('ends call for channel without active call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/end`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
});
});

View File

@@ -0,0 +1,264 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
addMemberRole,
createChannel,
createChannelInvite,
createGuild,
createPermissionOverwrite,
createRole,
getChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Voice Channel Permissions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('Voice channel creation', () => {
it('owner can create voice channel', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
expect(voiceChannel.type).toBe(ChannelTypes.GUILD_VOICE);
expect(voiceChannel.name).toBe('voice-test');
});
it('member without permission cannot create voice channel', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
await createBuilder(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'voice-test', type: ChannelTypes.GUILD_VOICE})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
it('member with MANAGE_CHANNELS permission can create voice channel', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
const role = await createRole(harness, owner.token, guild.id, {
name: 'Channel Manager',
permissions: Permissions.MANAGE_CHANNELS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
const voiceChannel = await createBuilder<ChannelResponse>(harness, member.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'voice-test', type: ChannelTypes.GUILD_VOICE})
.execute();
expect(voiceChannel.type).toBe(ChannelTypes.GUILD_VOICE);
});
});
describe('Voice channel access', () => {
it('member can view voice channel by default', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
const channel = await getChannel(harness, member.token, voiceChannel.id);
expect(channel.id).toBe(voiceChannel.id);
expect(channel.type).toBe(ChannelTypes.GUILD_VOICE);
});
it('member cannot view voice channel when VIEW_CHANNEL is denied', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
await createPermissionOverwrite(harness, owner.token, voiceChannel.id, member.userId, {
type: 1,
allow: '0',
deny: Permissions.VIEW_CHANNEL.toString(),
});
await createBuilder(harness, member.token)
.get(`/channels/${voiceChannel.id}`)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
});
describe('Voice channel modification', () => {
it('owner can update voice channel name', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${voiceChannel.id}`)
.body({name: 'updated-voice'})
.execute();
expect(updated.name).toBe('updated-voice');
});
it('owner can update voice channel bitrate', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${voiceChannel.id}`)
.body({bitrate: 64000})
.execute();
expect(updated.bitrate).toBe(64000);
});
it('owner can update voice channel user_limit', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${voiceChannel.id}`)
.body({user_limit: 10})
.execute();
expect(updated.user_limit).toBe(10);
});
it('member without permission cannot update voice channel', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
await createBuilder(harness, member.token)
.patch(`/channels/${voiceChannel.id}`)
.body({name: 'updated-voice'})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
});
describe('Voice channel deletion', () => {
it('owner can delete voice channel', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
await createBuilder(harness, owner.token)
.delete(`/channels/${voiceChannel.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, owner.token)
.get(`/channels/${voiceChannel.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('member without permission cannot delete voice channel', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
await createBuilder(harness, member.token)
.delete(`/channels/${voiceChannel.id}`)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
.execute();
});
});
});

View File

@@ -0,0 +1,126 @@
/*
* 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 {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createChannel, createGuild, getChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Voice Channel RTC Region', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('voice channel has null rtc_region by default', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
expect(voiceChannel.rtc_region).toBeNull();
});
it('owner can set rtc_region on voice channel', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${voiceChannel.id}`)
.body({rtc_region: 'us-west'})
.execute();
expect(updated.rtc_region).toBe('us-west');
});
it('owner can clear rtc_region to null', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${voiceChannel.id}`)
.body({rtc_region: 'us-west'})
.execute();
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${voiceChannel.id}`)
.body({rtc_region: null})
.execute();
expect(updated.rtc_region).toBeNull();
});
it('rtc_region persists after fetch', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${voiceChannel.id}`)
.body({rtc_region: 'eu-west'})
.execute();
const fetched = await getChannel(harness, owner.token, voiceChannel.id);
expect(fetched.rtc_region).toBe('eu-west');
});
it('voice channel bitrate is set during creation', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
expect(voiceChannel.bitrate).toBeDefined();
expect(typeof voiceChannel.bitrate).toBe('number');
});
it('voice channel user_limit defaults to 0', async () => {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
expect(voiceChannel.user_limit).toBe(0);
});
});

View File

@@ -0,0 +1,107 @@
/*
* 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 {VoiceRegionAvailability} from '@fluxer/api/src/voice/VoiceModel';
import {resolveVoiceRegionPreference, selectVoiceRegionId} from '@fluxer/api/src/voice/VoiceRegionSelection';
import {describe, expect, it} from 'vitest';
function createRegionAvailability({
id,
latitude,
longitude,
isDefault,
}: {
id: string;
latitude: number;
longitude: number;
isDefault: boolean;
}): VoiceRegionAvailability {
return {
id,
name: `Region ${id.toUpperCase()}`,
emoji: id.toUpperCase(),
latitude,
longitude,
isDefault,
vipOnly: false,
requiredGuildFeatures: [],
isAccessible: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
serverCount: 1,
activeServerCount: 1,
};
}
describe('VoiceRegionSelection', () => {
it('selects the closest region when coordinates are provided', () => {
const regions = [
createRegionAvailability({id: 'a', latitude: 0, longitude: 0, isDefault: true}),
createRegionAvailability({id: 'b', latitude: 50, longitude: 50, isDefault: false}),
];
const preference = resolveVoiceRegionPreference({
preferredRegionId: null,
accessibleRegions: regions,
availableRegions: regions,
defaultRegionId: null,
});
const selected = selectVoiceRegionId({
preferredRegionId: preference.regionId,
mode: preference.mode,
accessibleRegions: regions,
availableRegions: regions,
latitude: '49',
longitude: '49',
});
expect(selected).toBe('b');
});
it('keeps explicit regions even when coordinates would choose another', () => {
const regions = [
createRegionAvailability({id: 'a', latitude: 0, longitude: 0, isDefault: false}),
createRegionAvailability({id: 'b', latitude: 50, longitude: 50, isDefault: false}),
];
const preference = resolveVoiceRegionPreference({
preferredRegionId: 'a',
accessibleRegions: regions,
availableRegions: regions,
defaultRegionId: null,
});
const selected = selectVoiceRegionId({
preferredRegionId: preference.regionId,
mode: preference.mode,
accessibleRegions: regions,
availableRegions: regions,
latitude: '49',
longitude: '49',
});
expect(preference.mode).toBe('explicit');
expect(selected).toBe('a');
});
});

View File

@@ -0,0 +1,139 @@
/*
* 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 {createTestAccount as authCreateTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannel,
createChannelInvite,
createDmChannel,
createFriendship,
createGuild,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
export async function createTestAccount(harness: ApiTestHarness): Promise<TestAccount> {
return authCreateTestAccount(harness);
}
export async function createTestAccountUnclaimed(harness: ApiTestHarness): Promise<TestAccount> {
const account = await authCreateTestAccount(harness);
await createBuilder(harness, '').post(`/test/users/${account.userId}/unclaim`).body(null).execute();
return account;
}
export async function createGuildWithVoiceChannel(
harness: ApiTestHarness,
token: string,
guildName: string,
): Promise<{guild: GuildResponse; voiceChannel: ChannelResponse}> {
const guild = await createGuild(harness, token, guildName);
const voiceChannel = await createChannel(harness, token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
return {guild, voiceChannel};
}
export interface VoiceTestSetup {
owner: TestAccount;
member: TestAccount;
guild: GuildResponse;
voiceChannel: ChannelResponse;
textChannel: ChannelResponse;
}
export async function setupVoiceTestGuild(harness: ApiTestHarness): Promise<VoiceTestSetup> {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
await ensureSessionStarted(harness, member.token);
const guild = await createGuild(harness, owner.token, 'Voice Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
const textChannel = await createChannel(harness, owner.token, guild.id, 'text-test', ChannelTypes.GUILD_TEXT);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
return {owner, member, guild, voiceChannel, textChannel};
}
export interface DmCallTestSetup {
user1: TestAccount;
user2: TestAccount;
dmChannel: {id: string; type: number};
}
export async function setupDmCallTest(harness: ApiTestHarness): Promise<DmCallTestSetup> {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
return {user1, user2, dmChannel};
}
export async function setupDmCallTestWithFriendship(harness: ApiTestHarness): Promise<DmCallTestSetup> {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await createFriendship(harness, user1, user2);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
return {user1, user2, dmChannel};
}
export async function getCallEligibility(
harness: ApiTestHarness,
token: string,
channelId: string,
): Promise<{ringable: boolean; silent?: boolean}> {
return createBuilder<{ringable: boolean; silent?: boolean}>(harness, token)
.get(`/channels/${channelId}/call`)
.execute();
}
export async function ringCall(
harness: ApiTestHarness,
token: string,
channelId: string,
recipients?: Array<string>,
): Promise<void> {
const body = recipients ? {recipients} : {};
await createBuilder(harness, token).post(`/channels/${channelId}/call/ring`).body(body).expect(204).execute();
}
export async function endCall(harness: ApiTestHarness, token: string, channelId: string): Promise<void> {
await createBuilder(harness, token).post(`/channels/${channelId}/call/end`).body(null).expect(204).execute();
}

View File

@@ -0,0 +1,313 @@
/*
* 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 {IVoiceRepository} from '@fluxer/api/src/voice/IVoiceRepository';
import type {VoiceRegionWithServers} from '@fluxer/api/src/voice/VoiceModel';
import {VoiceTopology} from '@fluxer/api/src/voice/VoiceTopology';
import {beforeEach, describe, expect, it, vi} from 'vitest';
function createMockVoiceRepository(regions: Array<VoiceRegionWithServers>): IVoiceRepository {
return {
listRegionsWithServers: vi.fn().mockResolvedValue(regions),
listRegions: vi.fn().mockResolvedValue(regions),
getRegion: vi.fn().mockResolvedValue(null),
getRegionWithServers: vi.fn().mockResolvedValue(null),
upsertRegion: vi.fn().mockResolvedValue(undefined),
deleteRegion: vi.fn().mockResolvedValue(undefined),
createRegion: vi.fn().mockResolvedValue(null),
listServersForRegion: vi.fn().mockResolvedValue([]),
listServers: vi.fn().mockResolvedValue([]),
getServer: vi.fn().mockResolvedValue(null),
createServer: vi.fn().mockResolvedValue(null),
upsertServer: vi.fn().mockResolvedValue(undefined),
deleteServer: vi.fn().mockResolvedValue(undefined),
};
}
describe('VoiceTopology', () => {
let topology: VoiceTopology;
const mockRegions: Array<VoiceRegionWithServers> = [
{
id: 'us-default',
name: 'US Default',
emoji: 'flag-us',
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(),
servers: [
{
regionId: 'us-default',
serverId: 'us-server-1',
endpoint: 'wss://us1.voice.example.com',
apiKey: 'key1',
apiSecret: 'secret1',
isActive: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
createdAt: new Date(),
updatedAt: new Date(),
},
{
regionId: 'us-default',
serverId: 'us-server-2',
endpoint: 'wss://us2.voice.example.com',
apiKey: 'key2',
apiSecret: 'secret2',
isActive: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: 'eu-default',
name: 'EU Default',
emoji: 'flag-eu',
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(),
servers: [
{
regionId: 'eu-default',
serverId: 'eu-server-1',
endpoint: 'wss://eu1.voice.example.com',
apiKey: 'key3',
apiSecret: 'secret3',
isActive: true,
restrictions: {
vipOnly: false,
requiredGuildFeatures: new Set(),
allowedGuildIds: new Set(),
allowedUserIds: new Set(),
},
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
];
beforeEach(() => {
const repository = createMockVoiceRepository(mockRegions);
topology = new VoiceTopology(repository, null);
});
describe('before initialization', () => {
it('returns null for default region before initialization', () => {
expect(topology.getDefaultRegion()).toBeNull();
});
it('returns null for default region id before initialization', () => {
expect(topology.getDefaultRegionId()).toBeNull();
});
it('returns empty array for all regions before initialization', () => {
expect(topology.getAllRegions()).toHaveLength(0);
});
});
describe('after initialization', () => {
beforeEach(async () => {
await topology.initialize();
});
it('returns default region after initialization', () => {
const defaultRegion = topology.getDefaultRegion();
expect(defaultRegion).not.toBeNull();
expect(defaultRegion!.id).toBe('us-default');
expect(defaultRegion!.isDefault).toBe(true);
});
it('returns default region id after initialization', () => {
expect(topology.getDefaultRegionId()).toBe('us-default');
});
it('returns all regions after initialization', () => {
const regions = topology.getAllRegions();
expect(regions).toHaveLength(2);
expect(regions[0].id).toBe('us-default');
expect(regions[1].id).toBe('eu-default');
});
it('returns specific region by id', () => {
const region = topology.getRegion('eu-default');
expect(region).not.toBeNull();
expect(region!.id).toBe('eu-default');
expect(region!.name).toBe('EU Default');
});
it('returns null for non-existent region', () => {
const region = topology.getRegion('non-existent');
expect(region).toBeNull();
});
it('returns servers for a region', () => {
const servers = topology.getServersForRegion('us-default');
expect(servers).toHaveLength(2);
expect(servers[0].serverId).toBe('us-server-1');
expect(servers[1].serverId).toBe('us-server-2');
});
it('returns empty array for region with no servers', () => {
const servers = topology.getServersForRegion('non-existent');
expect(servers).toHaveLength(0);
});
it('returns specific server by region and server id', () => {
const server = topology.getServer('us-default', 'us-server-2');
expect(server).not.toBeNull();
expect(server!.serverId).toBe('us-server-2');
expect(server!.endpoint).toBe('wss://us2.voice.example.com');
});
it('returns null for non-existent server', () => {
const server = topology.getServer('us-default', 'non-existent');
expect(server).toBeNull();
});
it('returns null for server in non-existent region', () => {
const server = topology.getServer('non-existent', 'us-server-1');
expect(server).toBeNull();
});
it('returns region metadata list', () => {
const metadata = topology.getRegionMetadataList();
expect(metadata).toHaveLength(2);
expect(metadata[0]).toEqual({
id: 'us-default',
name: 'US Default',
emoji: 'flag-us',
latitude: 39.8283,
longitude: -98.5795,
isDefault: true,
vipOnly: false,
requiredGuildFeatures: [],
});
});
});
describe('getNextServer', () => {
beforeEach(async () => {
await topology.initialize();
});
it('rotates through servers in order', () => {
const first = topology.getNextServer('us-default');
const second = topology.getNextServer('us-default');
const third = topology.getNextServer('us-default');
expect(first!.serverId).toBe('us-server-1');
expect(second!.serverId).toBe('us-server-2');
expect(third!.serverId).toBe('us-server-1');
});
it('returns null for region with no servers', () => {
const server = topology.getNextServer('non-existent');
expect(server).toBeNull();
});
});
describe('subscriber management', () => {
it('registers and unregisters subscribers', () => {
const subscriber = vi.fn();
topology.registerSubscriber(subscriber);
topology.unregisterSubscriber(subscriber);
});
});
describe('shutdown', () => {
it('shuts down without error', () => {
expect(() => topology.shutdown()).not.toThrow();
});
});
describe('empty regions', () => {
it('handles empty region list', async () => {
const repository = createMockVoiceRepository([]);
topology = new VoiceTopology(repository, null);
await topology.initialize();
expect(topology.getAllRegions()).toHaveLength(0);
expect(topology.getDefaultRegion()).toBeNull();
expect(topology.getDefaultRegionId()).toBeNull();
});
});
describe('no default region', () => {
it('uses first region as fallback when no default is set', async () => {
const regionsWithoutDefault: Array<VoiceRegionWithServers> = [
{
...mockRegions[0],
isDefault: false,
},
mockRegions[1],
];
const repository = createMockVoiceRepository(regionsWithoutDefault);
topology = new VoiceTopology(repository, null);
await topology.initialize();
expect(topology.getDefaultRegionId()).toBe('us-default');
});
});
});

View File

@@ -0,0 +1,119 @@
/*
* 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 {createTestAccount, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
acceptInvite,
createChannelInvite,
createDmChannel,
createFriendship,
createGuild,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {updateUserSettings} from '@fluxer/api/src/user/tests/UserTestUtils';
import {IncomingCallFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Voice Unclaimed Account Restrictions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('DM call restrictions', () => {
it('unclaimed account cannot initiate DM call', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await unclaimAccount(harness, user1.userId);
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
.get(`/channels/${dmChannel.id}/call`)
.execute();
expect(callData.ringable).toBe(false);
});
it('claimed account can initiate DM call with unclaimed recipient', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
await acceptInvite(harness, user2.token, invite.code);
await updateUserSettings(harness, user2.token, {
incoming_call_flags: IncomingCallFlags.GUILD_MEMBERS,
});
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await unclaimAccount(harness, user2.userId);
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
.get(`/channels/${dmChannel.id}/call`)
.execute();
expect(callData.ringable).toBe(true);
});
it('unclaimed account cannot call friend', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await createFriendship(harness, user1, user2);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await unclaimAccount(harness, user1.userId);
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
.get(`/channels/${dmChannel.id}/call`)
.execute();
expect(callData.ringable).toBe(false);
});
});
});