refactor progress
This commit is contained in:
36
packages/api/src/voice/IVoiceRepository.tsx
Normal file
36
packages/api/src/voice/IVoiceRepository.tsx
Normal 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>;
|
||||
}
|
||||
192
packages/api/src/voice/VoiceAvailabilityService.tsx
Normal file
192
packages/api/src/voice/VoiceAvailabilityService.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@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);
|
||||
}
|
||||
}
|
||||
22
packages/api/src/voice/VoiceConstants.tsx
Normal file
22
packages/api/src/voice/VoiceConstants.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const VOICE_CONFIGURATION_CHANNEL = 'voice:config:refresh';
|
||||
export const VOICE_OCCUPANCY_REGION_KEY_PREFIX = 'voice:occupancy:region';
|
||||
export const VOICE_OCCUPANCY_SERVER_KEY_PREFIX = 'voice:occupancy:server';
|
||||
98
packages/api/src/voice/VoiceDataInitializer.tsx
Normal file
98
packages/api/src/voice/VoiceDataInitializer.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/api/src/voice/VoiceModel.tsx
Normal file
73
packages/api/src/voice/VoiceModel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@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;
|
||||
}
|
||||
387
packages/api/src/voice/VoiceReconciliationWorker.tsx
Normal file
387
packages/api/src/voice/VoiceReconciliationWorker.tsx
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
120
packages/api/src/voice/VoiceRegionSelection.tsx
Normal file
120
packages/api/src/voice/VoiceRegionSelection.tsx
Normal 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;
|
||||
}
|
||||
233
packages/api/src/voice/VoiceRepository.tsx
Normal file
233
packages/api/src/voice/VoiceRepository.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
565
packages/api/src/voice/VoiceService.tsx
Normal file
565
packages/api/src/voice/VoiceService.tsx
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
247
packages/api/src/voice/VoiceTopology.tsx
Normal file
247
packages/api/src/voice/VoiceTopology.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
452
packages/api/src/voice/tests/VoiceAvailabilityService.test.tsx
Normal file
452
packages/api/src/voice/tests/VoiceAvailabilityService.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/api/src/voice/tests/VoiceCallEligibility.test.tsx
Normal file
139
packages/api/src/voice/tests/VoiceCallEligibility.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
157
packages/api/src/voice/tests/VoiceCallRinging.test.tsx
Normal file
157
packages/api/src/voice/tests/VoiceCallRinging.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
138
packages/api/src/voice/tests/VoiceCallUpdate.test.tsx
Normal file
138
packages/api/src/voice/tests/VoiceCallUpdate.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
264
packages/api/src/voice/tests/VoiceChannelPermissions.test.tsx
Normal file
264
packages/api/src/voice/tests/VoiceChannelPermissions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
126
packages/api/src/voice/tests/VoiceChannelRtcRegion.test.tsx
Normal file
126
packages/api/src/voice/tests/VoiceChannelRtcRegion.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
107
packages/api/src/voice/tests/VoiceRegionSelection.test.tsx
Normal file
107
packages/api/src/voice/tests/VoiceRegionSelection.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
139
packages/api/src/voice/tests/VoiceTestUtils.tsx
Normal file
139
packages/api/src/voice/tests/VoiceTestUtils.tsx
Normal 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();
|
||||
}
|
||||
313
packages/api/src/voice/tests/VoiceTopology.test.tsx
Normal file
313
packages/api/src/voice/tests/VoiceTopology.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user