mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(voice): add opt-in peer-to-peer calls and country rollouts (#3367)
This commit is contained in:
1 parent
e5a568acca
commit
bef5e33eec
215 files changed
+15840
-2693
No files matched your search
@@ -318,6 +318,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
url: master.integrations.voice.url,
|
||||
internalUrl: master.integrations.voice.internal_url,
|
||||
defaultRegion: master.integrations.voice.default_region,
|
||||
p2pStunUrls: master.integrations.voice.p2p_stun_urls,
|
||||
},
|
||||
stripe: {
|
||||
enabled: master.integrations.stripe.enabled,
|
||||
|
||||
@@ -338,6 +338,10 @@ import {
|
||||
type UserSettingsRow,
|
||||
type UsersPendingDeletionRow,
|
||||
} from '@app/api/database/types/UserTypes';
|
||||
import {
|
||||
VOICE_P2P_CONNECTION_REPORT_COLUMNS,
|
||||
type VoiceP2pConnectionReportRow,
|
||||
} from '@app/api/database/types/VoiceTypes';
|
||||
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
@@ -1474,3 +1478,9 @@ export const BillingActionIntents = defineTable<BillingActionIntentRow, 'intent_
|
||||
columns: BILLING_ACTION_INTENT_COLUMNS,
|
||||
primaryKey: ['intent_id'],
|
||||
});
|
||||
export const VoiceP2pConnectionReports = defineTable<VoiceP2pConnectionReportRow, 'user_id' | 'report_id'>({
|
||||
name: 'voice_p2p_connection_reports',
|
||||
columns: VOICE_P2P_CONNECTION_REPORT_COLUMNS,
|
||||
primaryKey: ['user_id', 'report_id'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {VoiceP2pConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
@@ -66,6 +67,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gatewayRollout,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
voiceP2p,
|
||||
captcha,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
@@ -76,6 +78,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getVoiceP2pConfig(),
|
||||
instanceConfigRepository.getCaptchaConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
@@ -113,6 +116,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gateway_rollout: gatewayRollout,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
voice_p2p: voiceP2p,
|
||||
captcha,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
@@ -396,6 +400,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.voice_p2p) {
|
||||
const patch = omitUndefinedFields(data.voice_p2p);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateVoiceP2pConfig((current) =>
|
||||
VoiceP2pConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.captcha) {
|
||||
const patch = omitUndefinedFields(data.captcha);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {ThemeController} from '@app/api/theme/ThemeController';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {UnfurlController} from '@app/api/unfurl/UnfurlController';
|
||||
import {UserController} from '@app/api/user/controllers/UserController';
|
||||
import {VoiceP2pConnectionReportController} from '@app/api/voice/VoiceP2pConnectionReportController';
|
||||
import {WebhookController} from '@app/api/webhook/WebhookController';
|
||||
|
||||
export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
@@ -67,6 +68,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
TestHarnessController(routes);
|
||||
}
|
||||
UserController(routes);
|
||||
VoiceP2pConnectionReportController(routes);
|
||||
StoreBillingController(routes);
|
||||
WebhookController(routes);
|
||||
OAuth2Controller(routes);
|
||||
|
||||
@@ -120,6 +120,7 @@ function serializeGuildVoiceChannel(channel: Channel, ctx: ContentWarningCtx): C
|
||||
user_limit: channel.userLimit,
|
||||
voice_connection_limit: channel.voiceConnectionLimit,
|
||||
rtc_region: channel.rtcRegion,
|
||||
rtc_p2p: channel.rtcP2p,
|
||||
...serializeContentWarningFields(channel, ctx),
|
||||
rate_limit_per_user: channel.rateLimitPerUser,
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ function gdmRow(overrides: Partial<ChannelRow> = {}): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: LAST_MESSAGE,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -100,6 +100,7 @@ export function channelRowFromPrivateChannelSnapshot(row: PrivateChannelRow): Ch
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: row.channel_last_message_id ?? null,
|
||||
last_pin_timestamp: row.channel_last_pin_timestamp ?? null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {TEXT_THREAD_PARENT_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
|
||||
@@ -192,6 +193,7 @@ export function ChannelController(app: HonoApp) {
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const channelRequestService = ctx.get('channelRequestService');
|
||||
const {countryCode} = await lookupGeoip(ctx.req.raw);
|
||||
return ctx.json(
|
||||
await channelRequestService.updateChannel({
|
||||
viewer: viewerFromCtx(ctx),
|
||||
@@ -202,6 +204,7 @@ export function ChannelController(app: HonoApp) {
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
countryCode,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ function makeChannelRow(channelId: ChannelID): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -128,6 +128,7 @@ function channelRow(channelId: ChannelID, type: number, overrides: Partial<Chann
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
@@ -860,6 +861,7 @@ describe('thread storage leaves control storage untouched', () => {
|
||||
'user_limit',
|
||||
'voice_connection_limit',
|
||||
'rtc_region',
|
||||
'rtc_p2p',
|
||||
'last_message_id',
|
||||
'last_pin_timestamp',
|
||||
'permission_overwrites',
|
||||
|
||||
@@ -149,6 +149,7 @@ export class ChannelDataService {
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
countryCode,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
@@ -158,6 +159,7 @@ export class ChannelDataService {
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<Channel> {
|
||||
const authChannel = await this.auth.getChannelAuthenticated({userId, channelId, viewer, skipNsfwValidation: true});
|
||||
const {channel} = authChannel;
|
||||
@@ -227,6 +229,9 @@ export class ChannelDataService {
|
||||
if (guildChannelData.rtc_region !== undefined) {
|
||||
channelUpdateData.rtc_region = guildChannelData.rtc_region ?? null;
|
||||
}
|
||||
if (guildChannelData.rtc_p2p !== undefined) {
|
||||
channelUpdateData.rtc_p2p = guildChannelData.rtc_p2p;
|
||||
}
|
||||
if (guildChannelData.icon !== undefined) {
|
||||
channelUpdateData.icon = guildChannelData.icon ?? null;
|
||||
}
|
||||
@@ -246,6 +251,7 @@ export class ChannelDataService {
|
||||
requestCache,
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
countryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ export class ChannelRequestService {
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<ChannelResponse> {
|
||||
if (!('available_tags' in params.data) || params.data.available_tags === undefined) {
|
||||
return this.applyChannelUpdate(params);
|
||||
@@ -138,6 +139,7 @@ export class ChannelRequestService {
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<ChannelResponse> {
|
||||
const channel = await this.channelService.channelData.editChannel({
|
||||
userId: params.userId,
|
||||
@@ -148,6 +150,7 @@ export class ChannelRequestService {
|
||||
requestCache: params.requestCache,
|
||||
auditLogReason: params.auditLogReason,
|
||||
typeConversion: params.typeConversion,
|
||||
countryCode: params.countryCode,
|
||||
});
|
||||
return this.maskedChannelResponse(channel, params);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ function buildPersonalNotesChannelRow(userId: UserID): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ThreadViewer,
|
||||
viewerActive,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
@@ -48,6 +49,7 @@ import {Logger} from '@app/api/Logger';
|
||||
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite';
|
||||
import type {ThreadState} from '@app/api/models/ThreadState';
|
||||
@@ -81,6 +83,7 @@ import {CannotExecuteOnDmError} from '@fluxer/errors/src/domains/core/CannotExec
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import {resolveVoiceP2pAssignment} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {ChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
@@ -105,6 +108,7 @@ export interface ChannelUpdateData {
|
||||
deny?: bigint;
|
||||
}> | null;
|
||||
rtc_region?: string | null;
|
||||
rtc_p2p?: boolean;
|
||||
icon?: string | null;
|
||||
owner_id?: bigint | null;
|
||||
nicks?: Record<string, string | null> | null;
|
||||
@@ -137,6 +141,21 @@ export class ChannelOperationsService {
|
||||
private snowflakeService: ISnowflakeService,
|
||||
) {}
|
||||
|
||||
private async assertVoiceP2pAssigned(userId: UserID, countryCode: string | null): Promise<void> {
|
||||
const config = await getInstanceConfigRepository().getVoiceP2pConfig();
|
||||
const user = config.enabled ? await this.userRepository.findUnique(userId) : null;
|
||||
if (
|
||||
user === null ||
|
||||
!resolveVoiceP2pAssignment(
|
||||
config,
|
||||
userId.toString(),
|
||||
await resolveExperimentTargeting(user, countryCode, [config]),
|
||||
).enabled
|
||||
) {
|
||||
throw InputValidationError.fromCode('rtc_p2p', ValidationErrorCodes.GUILD_FEATURE_NOT_TOGGLEABLE);
|
||||
}
|
||||
}
|
||||
|
||||
async getChannel({
|
||||
userId,
|
||||
viewer,
|
||||
@@ -183,6 +202,7 @@ export class ChannelOperationsService {
|
||||
auditLogReason,
|
||||
typeConversion,
|
||||
threadParent,
|
||||
countryCode,
|
||||
}: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
@@ -193,6 +213,7 @@ export class ChannelOperationsService {
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
threadParent?: ThreadParentSettingsInput | null;
|
||||
countryCode?: string | null;
|
||||
}): Promise<Channel> {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
@@ -267,6 +288,9 @@ export class ChannelOperationsService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.rtc_p2p === true && !channel.rtcP2p && channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
await this.assertVoiceP2pAssigned(userId, countryCode ?? null);
|
||||
}
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
let permissionOverwrites = channel.permissionOverwrites;
|
||||
if (data.permission_overwrites !== undefined) {
|
||||
@@ -378,6 +402,7 @@ export class ChannelOperationsService {
|
||||
data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.rtc_region
|
||||
: channel.rtcRegion,
|
||||
rtc_p2p: data.rtc_p2p !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.rtc_p2p : channel.rtcP2p,
|
||||
permission_overwrites: new Map(
|
||||
Array.from(permissionOverwrites.entries()).map(([targetId, overwrite]) => [
|
||||
targetId,
|
||||
|
||||
@@ -44,6 +44,7 @@ function dmChannelRow(lastMessageId: MessageID | null): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: lastMessageId,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -453,6 +453,7 @@ export class ThreadCreationService {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -38,6 +38,7 @@ function channel(id: bigint, type: number, overrides: Partial<ChannelRow> = {}):
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -192,6 +192,7 @@ export interface APIConfig {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
p2pStunUrls?: Array<string>;
|
||||
};
|
||||
stripe: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ChannelRow {
|
||||
user_limit: Nullish<number>;
|
||||
voice_connection_limit: Nullish<number>;
|
||||
rtc_region: Nullish<string>;
|
||||
rtc_p2p: Nullish<boolean>;
|
||||
last_message_id: Nullish<MessageID>;
|
||||
last_pin_timestamp: Nullish<Date>;
|
||||
permission_overwrites: Nullish<Map<RoleID | UserID, PermissionOverwrite>>;
|
||||
@@ -156,6 +157,7 @@ export const CHANNEL_COLUMNS = [
|
||||
'user_limit',
|
||||
'voice_connection_limit',
|
||||
'rtc_region',
|
||||
'rtc_p2p',
|
||||
'last_message_id',
|
||||
'last_pin_timestamp',
|
||||
'permission_overwrites',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
|
||||
export interface VoiceRegionRow {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -65,3 +67,43 @@ export const VOICE_SERVER_COLUMNS = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
] as const satisfies ReadonlyArray<keyof VoiceServerRow>;
|
||||
|
||||
export interface VoiceP2pConnectionReportRow {
|
||||
user_id: UserID;
|
||||
report_id: bigint;
|
||||
reported_at: Date;
|
||||
channel_id: ChannelID;
|
||||
guild_id: GuildID | null;
|
||||
participant_count: number;
|
||||
outcome: string;
|
||||
local_candidate_type: string | null;
|
||||
remote_candidate_type: string | null;
|
||||
ip_family: string | null;
|
||||
protocol: string | null;
|
||||
setup_ms: number | null;
|
||||
ice_restarted: boolean;
|
||||
country: string | null;
|
||||
ip: string | null;
|
||||
client_platform: string | null;
|
||||
client_os: string | null;
|
||||
}
|
||||
|
||||
export const VOICE_P2P_CONNECTION_REPORT_COLUMNS = [
|
||||
'user_id',
|
||||
'report_id',
|
||||
'reported_at',
|
||||
'channel_id',
|
||||
'guild_id',
|
||||
'participant_count',
|
||||
'outcome',
|
||||
'local_candidate_type',
|
||||
'remote_candidate_type',
|
||||
'ip_family',
|
||||
'protocol',
|
||||
'setup_ms',
|
||||
'ice_restarted',
|
||||
'country',
|
||||
'ip',
|
||||
'client_platform',
|
||||
'client_os',
|
||||
] as const satisfies ReadonlyArray<keyof VoiceP2pConnectionReportRow>;
|
||||
@@ -8,9 +8,11 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveChannelThreadsAssignment} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {resolveVoiceP2pAssignment} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
@@ -30,20 +32,26 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, domainMigrationConfig, channelThreadsConfig] = await Promise.all([
|
||||
const [delivery, domainMigrationConfig, channelThreadsConfig, voiceP2pConfig, geoip] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getCompiledChannelThreadsConfig(),
|
||||
instanceConfigRepository.getVoiceP2pConfig(),
|
||||
lookupGeoip(ctx.req.raw),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, domainMigrationConfig);
|
||||
const targeting = await resolveExperimentTargeting(user, geoip.countryCode, [
|
||||
domainMigrationConfig,
|
||||
voiceP2pConfig,
|
||||
]);
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
plutonium_page: {enabled: true},
|
||||
voice_p2p: resolveVoiceP2pAssignment(voiceP2pConfig, userId, targeting),
|
||||
},
|
||||
};
|
||||
const channelThreads = resolveChannelThreadsAssignment(channelThreadsConfig, userId);
|
||||
|
||||
@@ -13,11 +13,12 @@ const NO_GUILDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export async function resolveExperimentTargeting(
|
||||
user: User,
|
||||
config: TargetableExperimentConfig,
|
||||
countryCode: string | null,
|
||||
configs: ReadonlyArray<TargetableExperimentConfig>,
|
||||
): Promise<ExperimentTargeting> {
|
||||
const needsGuilds = config.enabled && config.included_guild_ids.length > 0;
|
||||
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
|
||||
const memberGuildIds = needsGuilds
|
||||
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
|
||||
: NO_GUILDS;
|
||||
return {memberGuildIds, premium: !user.isBot && user.isPremium()};
|
||||
return {memberGuildIds, premium: !user.isBot && user.isPremium(), countryCode};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {DEFAULT_VOICE_P2P_CONFIG, INERT_VOICE_P2P_ASSIGNMENT} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
@@ -22,8 +23,29 @@ import {
|
||||
readChannelThreadsAssignment,
|
||||
readDomainMigrationAssignment,
|
||||
readPlutoniumPageAssignment,
|
||||
readVoiceP2pAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {lookupGeoipMock} = vi.hoisted(() => ({
|
||||
lookupGeoipMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/api/utils/IpUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@app/api/utils/IpUtils')>()),
|
||||
lookupGeoip: lookupGeoipMock,
|
||||
}));
|
||||
|
||||
function geoipCountry(countryCode: string | null): GeoipResult {
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: '203.0.113.10',
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
const NOT_MODIFIED = 304;
|
||||
const ENDPOINT = '/experiments';
|
||||
@@ -37,6 +59,8 @@ describe('GET /experiments', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
lookupGeoipMock.mockReset();
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -59,6 +83,7 @@ describe('GET /experiments', () => {
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
channel_threads: {active: true, config_version: 0},
|
||||
plutonium_page: {enabled: true},
|
||||
voice_p2p: INERT_VOICE_P2P_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -72,6 +97,15 @@ describe('GET /experiments', () => {
|
||||
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the voice p2p assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'voice_p2p')).toBe(true);
|
||||
expect(readVoiceP2pAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('serves the plutonium page to everyone and ignores a leftover rollout row', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setConfig(
|
||||
@@ -121,7 +155,65 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in domain migration and leaves everyone else out', async () => {
|
||||
it('resolves the voice p2p caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceP2pConfig({
|
||||
...DEFAULT_VOICE_P2P_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId, excluded.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.voice_p2p).toEqual({enabled: true, max_participants: 2});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.voice_p2p).toEqual({enabled: false, max_participants: 2});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.voice_p2p).toEqual({enabled: false, max_participants: 2});
|
||||
});
|
||||
|
||||
it('limits the percentage rollout of every experiment to the listed request countries', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const included = await createTestAccount(harness);
|
||||
const repository = getInstanceConfigRepository();
|
||||
const rollout = {enabled: true, rollout_basis_points: 10000, rollout_country_codes: ['SE', 'NO']};
|
||||
await repository.setDomainMigrationConfig({...DEFAULT_DOMAIN_MIGRATION_CONFIG, ...rollout});
|
||||
await repository.setVoiceP2pConfig({
|
||||
...DEFAULT_VOICE_P2P_CONFIG,
|
||||
...rollout,
|
||||
included_user_ids: [included.userId],
|
||||
});
|
||||
|
||||
for (const [countryCode, expected] of [
|
||||
['SE', true],
|
||||
['BR', false],
|
||||
[null, false],
|
||||
] as const) {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(countryCode));
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: expected});
|
||||
expect(body.assignments.voice_p2p).toEqual({enabled: expected, max_participants: 2});
|
||||
}
|
||||
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const includedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, included.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(includedBody.assignments.voice_p2p).toEqual({enabled: true, max_participants: 2});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
@@ -129,21 +221,29 @@ describe('GET /experiments', () => {
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setVoiceP2pConfig({
|
||||
...DEFAULT_VOICE_P2P_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.voice_p2p).toEqual({enabled: true, max_participants: 2});
|
||||
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.voice_p2p).toEqual({enabled: false, max_participants: 2});
|
||||
});
|
||||
|
||||
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
|
||||
@@ -152,11 +252,17 @@ describe('GET /experiments', () => {
|
||||
const free = await createTestAccount(harness);
|
||||
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
await repository.setVoiceP2pConfig({
|
||||
...DEFAULT_VOICE_P2P_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
for (const [account, expected] of [
|
||||
[subscriber, true],
|
||||
[visionary, true],
|
||||
@@ -164,24 +270,63 @@ describe('GET /experiments', () => {
|
||||
] as const) {
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: expected});
|
||||
expect(body.assignments.voice_p2p).toEqual({enabled: expected, max_participants: 2});
|
||||
}
|
||||
});
|
||||
|
||||
it('stores the guild ids and premium switch an admin sets for domain migration', async () => {
|
||||
it('stores the guild ids, countries and premium switch an admin sets for each experiment', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<{
|
||||
domain_migration: {included_guild_ids: Array<string>; include_premium_users: boolean};
|
||||
}>(harness, admin.token)
|
||||
const countryCodes = ['SE', 'NO'];
|
||||
const section = {included_guild_ids: guildIds, include_premium_users: true, rollout_country_codes: countryCodes};
|
||||
const body = await createBuilder<
|
||||
Record<
|
||||
'domain_migration' | 'voice_p2p',
|
||||
{included_guild_ids: Array<string>; include_premium_users: boolean; rollout_country_codes: Array<string>}
|
||||
>
|
||||
>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({domain_migration: {included_guild_ids: guildIds, include_premium_users: true}})
|
||||
.body({domain_migration: section, voice_p2p: section})
|
||||
.execute();
|
||||
for (const stored of [body.domain_migration, body.voice_p2p]) {
|
||||
expect(stored.included_guild_ids).toEqual(guildIds);
|
||||
expect(stored.include_premium_users).toBe(true);
|
||||
expect(stored.rollout_country_codes).toEqual(countryCodes);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'lowercase', countryCodes: ['se']},
|
||||
{name: 'duplicated', countryCodes: ['SE', 'SE']},
|
||||
{name: 'malformed', countryCodes: ['SWE']},
|
||||
])('rejects $name rollout country codes from an admin', async ({countryCodes}) => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_p2p: {rollout_country_codes: countryCodes}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it.each([1, 5])('rejects a voice p2p max_participants of %i from an admin', async (maxParticipants) => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_p2p: {max_participants: maxParticipants}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.domain_migration.include_premium_users).toBe(true);
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
@@ -336,6 +481,40 @@ describe('GET /experiments', () => {
|
||||
expect(afterUndefined.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
});
|
||||
|
||||
it('bumps the voice p2p config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{voice_p2p: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_p2p: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_p2p).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
voice_p2p: {config_version: number; rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_p2p: {rollout_basis_points: 2500, max_participants: 4}})
|
||||
.execute();
|
||||
expect(afterSecond.voice_p2p).toMatchObject({config_version: 2, rollout_basis_points: 2500, max_participants: 4});
|
||||
|
||||
const afterEmpty = await createBuilder<{voice_p2p: {config_version: number}}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_p2p: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.voice_p2p).toMatchObject({config_version: 2});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.voice_p2p).toEqual({enabled: true, max_participants: 4});
|
||||
});
|
||||
|
||||
it('no longer exposes or accepts plutonium page and channel threads settings in the admin config', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
|
||||
@@ -238,6 +238,7 @@ export class ChannelOperationsService {
|
||||
? (params.data.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: permissionOverwrites,
|
||||
|
||||
@@ -881,6 +881,7 @@ export class GuildOperationsService {
|
||||
user_limit: bitrate !== null ? 0 : null,
|
||||
voice_connection_limit: bitrate !== null ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
@@ -1113,6 +1114,7 @@ export class GuildOperationsService {
|
||||
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: permissionOverwrites,
|
||||
@@ -1163,6 +1165,7 @@ export class GuildOperationsService {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannelID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
addMemberRole,
|
||||
createChannel,
|
||||
@@ -10,11 +11,14 @@ import {
|
||||
updateChannel,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {createGuild, getGuildChannels} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getChannelRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {DEFAULT_VOICE_P2P_CONFIG, type VoiceP2pConfig} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
@@ -26,6 +30,9 @@ describe('Guild Channel Management', () => {
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
async function setVoiceP2pConfig(config: Partial<VoiceP2pConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().setVoiceP2pConfig({...DEFAULT_VOICE_P2P_CONFIG, ...config});
|
||||
}
|
||||
async function addGuildFeaturesForTesting(guildId: string, features: Array<string>): Promise<void> {
|
||||
await createBuilder<{
|
||||
success: boolean;
|
||||
@@ -559,6 +566,98 @@ describe('Guild Channel Management', () => {
|
||||
.execute();
|
||||
expect(data.voice_connection_limit).toBe(10);
|
||||
});
|
||||
test('should default rtc_p2p to false and update it on a voice channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
expect(voiceChannel.rtc_p2p).toBe(false);
|
||||
await setVoiceP2pConfig({enabled: true, included_user_ids: [account.userId]});
|
||||
const enabled = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, rtc_p2p: true})
|
||||
.execute();
|
||||
expect(enabled.rtc_p2p).toBe(true);
|
||||
const untouched = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 4})
|
||||
.execute();
|
||||
expect(untouched.rtc_p2p).toBe(true);
|
||||
const disabled = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, rtc_p2p: false})
|
||||
.execute();
|
||||
expect(disabled.rtc_p2p).toBe(false);
|
||||
});
|
||||
test('should refuse enabling rtc_p2p outside the voice_p2p experiment', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
for (const config of [{included_user_ids: [account.userId]}, {enabled: true}]) {
|
||||
await setVoiceP2pConfig(config);
|
||||
const refusal = await createBuilder<{errors?: Array<{path: string; code: string}>}>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, rtc_p2p: true})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(refusal.errors).toContainEqual(
|
||||
expect.objectContaining({path: 'rtc_p2p', code: ValidationErrorCodes.GUILD_FEATURE_NOT_TOGGLEABLE}),
|
||||
);
|
||||
}
|
||||
const stored = await getChannelRepository().findUnique(createChannelID(BigInt(voiceChannel.id)));
|
||||
expect(stored?.rtcP2p).toBe(false);
|
||||
await setVoiceP2pConfig({enabled: true, included_user_ids: [account.userId]});
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, rtc_p2p: true})
|
||||
.execute();
|
||||
await setVoiceP2pConfig({});
|
||||
const disabled = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, rtc_p2p: false})
|
||||
.execute();
|
||||
expect(disabled.rtc_p2p).toBe(false);
|
||||
});
|
||||
test('should ignore rtc_p2p on a text channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${textChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_TEXT, rtc_p2p: true})
|
||||
.execute();
|
||||
expect(data.rtc_p2p).toBeUndefined();
|
||||
const stored = await getChannelRepository().findUnique(createChannelID(BigInt(textChannel.id)));
|
||||
expect(stored?.rtcP2p).toBe(false);
|
||||
});
|
||||
test('should require MANAGE_CHANNELS to update rtc_p2p', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
owner.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
await createBuilder(harness, members[0].token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, rtc_p2p: true})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
const stored = await getChannelRepository().findUnique(createChannelID(BigInt(voiceChannel.id)));
|
||||
expect(stored?.rtcP2p).toBe(false);
|
||||
});
|
||||
test('should set unlimited user capacity with zero', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
@@ -50,6 +50,7 @@ function threadRow(threadId: ChannelID): ChannelRow {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
PushRelayConfigSchema,
|
||||
toLegacyPushServiceDeliveryWire,
|
||||
} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {type VoiceP2pConfig, VoiceP2pConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {
|
||||
type ExperimentDeliveryConfig,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
@@ -109,6 +110,7 @@ import {z} from 'zod';
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const VOICE_P2P_CONFIG_KEY = 'voice_p2p_config';
|
||||
const CAPTCHA_CONFIG_KEY = 'captcha_config';
|
||||
const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
@@ -442,6 +444,7 @@ type StoredConfigSection =
|
||||
| 'gateway rollout'
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'voice p2p'
|
||||
| 'captcha'
|
||||
| 'channel threads'
|
||||
| 'experiment delivery'
|
||||
@@ -661,6 +664,10 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredVoiceP2pConfig(raw: string | null): VoiceP2pConfig {
|
||||
return parseStoredConfigOrDefault(VoiceP2pConfigSchema, raw, 'voice p2p');
|
||||
}
|
||||
|
||||
function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig {
|
||||
return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha');
|
||||
}
|
||||
@@ -1350,6 +1357,7 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredVoiceP2pConfig(snapshot.get(VOICE_P2P_CONFIG_KEY) ?? null);
|
||||
parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null);
|
||||
syncChannelThreadsConfig(snapshot.get(CHANNEL_THREADS_CONFIG_KEY) ?? null, parseStoredChannelThreadsConfig);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
@@ -1595,6 +1603,21 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getVoiceP2pConfig(): Promise<VoiceP2pConfig> {
|
||||
const raw = await this.getConfig(VOICE_P2P_CONFIG_KEY);
|
||||
return parseStoredVoiceP2pConfig(raw);
|
||||
}
|
||||
|
||||
async setVoiceP2pConfig(config: VoiceP2pConfig): Promise<void> {
|
||||
await this.updateVoiceP2pConfig(() => config);
|
||||
}
|
||||
|
||||
updateVoiceP2pConfig(update: (current: VoiceP2pConfig) => VoiceP2pConfig): Promise<VoiceP2pConfig> {
|
||||
return this.updateStoredConfig(VOICE_P2P_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(VoiceP2pConfigSchema, update(parseStoredVoiceP2pConfig(raw)), 'voice p2p'),
|
||||
);
|
||||
}
|
||||
|
||||
async getCaptchaConfig(): Promise<CaptchaConfig> {
|
||||
const raw = await this.getConfig(CAPTCHA_CONFIG_KEY);
|
||||
return parseStoredCaptchaConfig(raw);
|
||||
|
||||
@@ -298,6 +298,7 @@ class RequestServices implements RequestScopedServices {
|
||||
getChannelRepository(),
|
||||
voiceRoomStore,
|
||||
voiceAvailabilityService,
|
||||
getInstanceConfigRepository(),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ import {UserActivityBuffer} from '@app/api/user/services/UserActivityBuffer';
|
||||
import {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
|
||||
import {awaitAll} from '@app/api/utils/ConcurrencyUtils';
|
||||
import {UserPermissionUtils} from '@app/api/utils/UserPermissionUtils';
|
||||
import {VoiceP2pConnectionReportRepository} from '@app/api/voice/VoiceP2pConnectionReportRepository';
|
||||
import {VoiceRepository} from '@app/api/voice/VoiceRepository';
|
||||
import {SweegoWebhookService} from '@app/api/webhook/SweegoWebhookService';
|
||||
import {WebhookRepository} from '@app/api/webhook/WebhookRepository';
|
||||
@@ -114,6 +115,7 @@ export const getReadStateRepository = singleton(() => new ReadStateRepository())
|
||||
export const getFavoriteMemeRepository = singleton(() => new FavoriteMemeRepository());
|
||||
export const getConnectionRepository = singleton(() => new ConnectionRepository());
|
||||
export const getReportRepository = singleton(() => new ReportRepository());
|
||||
export const getVoiceP2pConnectionReportRepository = singleton(() => new VoiceP2pConnectionReportRepository());
|
||||
export const getAdminRepository = singleton(() => new AdminRepository());
|
||||
export const getAdminArchiveRepository = singleton(() => new AdminArchiveRepository());
|
||||
export const getVoiceRepository = singleton(() => new VoiceRepository());
|
||||
|
||||
@@ -32,6 +32,7 @@ export class Channel {
|
||||
readonly userLimit: number | null;
|
||||
readonly voiceConnectionLimit: number | null;
|
||||
readonly rtcRegion: string | null;
|
||||
readonly rtcP2p: boolean;
|
||||
readonly lastMessageId: MessageID | null;
|
||||
readonly lastPinTimestamp: Date | null;
|
||||
readonly permissionOverwrites: Map<RoleID | UserID, ChannelPermissionOverwrite>;
|
||||
@@ -63,6 +64,7 @@ export class Channel {
|
||||
row.voice_connection_limit ??
|
||||
(this.type === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null);
|
||||
this.rtcRegion = row.rtc_region ?? null;
|
||||
this.rtcP2p = row.rtc_p2p ?? false;
|
||||
this.lastMessageId = row.last_message_id ?? null;
|
||||
this.lastPinTimestamp = row.last_pin_timestamp ?? null;
|
||||
this.permissionOverwrites = new Map();
|
||||
@@ -123,6 +125,7 @@ export class Channel {
|
||||
user_limit: this.userLimit,
|
||||
voice_connection_limit: this.voiceConnectionLimit,
|
||||
rtc_region: this.rtcRegion,
|
||||
rtc_p2p: this.rtcP2p,
|
||||
last_message_id: this.lastMessageId,
|
||||
last_pin_timestamp: this.lastPinTimestamp,
|
||||
permission_overwrites: permOverwritesMap,
|
||||
|
||||
@@ -22247,6 +22247,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/voice/p2p/connection-reports": {
|
||||
"post": {
|
||||
"operationId": "create_voice_p2p_connection_reports",
|
||||
"summary": "Report peer-to-peer connection outcomes",
|
||||
"tags": ["Voice"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Records how peer connections of a peer-to-peer voice call settled, one report for each remote peer. Fluxer adds the request address, its country and the client platform of the session.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/VoiceP2pConnectionReportsRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/webhooks/{webhook_id}": {
|
||||
"get": {
|
||||
"operationId": "get_webhook",
|
||||
@@ -24442,6 +24496,123 @@
|
||||
"required": ["id", "guild_id", "channel_id", "name", "type", "user"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceP2pConnectionReportsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reports": {
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_id": {
|
||||
"description": "The voice channel or private channel of the call",
|
||||
"$ref": "#/components/schemas/SnowflakeType"
|
||||
},
|
||||
"guild_id": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}],
|
||||
"description": "The guild of the voice channel, null for a call"
|
||||
},
|
||||
"participant_count": {
|
||||
"type": "integer",
|
||||
"minimum": 2,
|
||||
"maximum": 4,
|
||||
"description": "Participants in the call when the peer connection settled"
|
||||
},
|
||||
"outcome": {
|
||||
"description": "How the peer connection settled",
|
||||
"x-enumNames": ["connected", "failed"],
|
||||
"x-enumDescriptions": ["The peer connection reached connected", "The peer connection failed"],
|
||||
"enum": ["connected", "failed"],
|
||||
"type": "string"
|
||||
},
|
||||
"local_candidate_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "ICE candidate type of one end of the selected candidate pair",
|
||||
"x-enumNames": ["host", "srflx", "prflx", "relay"],
|
||||
"x-enumDescriptions": [
|
||||
"A local interface address",
|
||||
"A public address a STUN server reflected",
|
||||
"A public address learned from a peer during checks",
|
||||
"A relayed address"
|
||||
],
|
||||
"enum": ["host", "srflx", "prflx", "relay"],
|
||||
"type": "string"
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Local candidate type, null without a selected pair"
|
||||
},
|
||||
"remote_candidate_type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "ICE candidate type of one end of the selected candidate pair",
|
||||
"x-enumNames": ["host", "srflx", "prflx", "relay"],
|
||||
"x-enumDescriptions": [
|
||||
"A local interface address",
|
||||
"A public address a STUN server reflected",
|
||||
"A public address learned from a peer during checks",
|
||||
"A relayed address"
|
||||
],
|
||||
"enum": ["host", "srflx", "prflx", "relay"],
|
||||
"type": "string"
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Remote candidate type, null without a selected pair"
|
||||
},
|
||||
"ip_family": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Address family of the selected pair",
|
||||
"x-enumNames": ["ipv4", "ipv6"],
|
||||
"x-enumDescriptions": ["IPv4", "IPv6"],
|
||||
"enum": ["ipv4", "ipv6"],
|
||||
"type": "string"
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Address family of the selected pair, null without one"
|
||||
},
|
||||
"protocol": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Transport protocol of the selected pair",
|
||||
"x-enumNames": ["udp", "tcp"],
|
||||
"x-enumDescriptions": ["UDP", "TCP"],
|
||||
"enum": ["udp", "tcp"],
|
||||
"type": "string"
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Transport protocol of the selected pair, null without one"
|
||||
},
|
||||
"setup_ms": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "Milliseconds from peer connection creation to first connected"
|
||||
},
|
||||
"ice_restarted": {"type": "boolean", "description": "Whether the peer connection ran an ICE restart"}
|
||||
},
|
||||
"required": [
|
||||
"channel_id",
|
||||
"guild_id",
|
||||
"participant_count",
|
||||
"outcome",
|
||||
"local_candidate_type",
|
||||
"remote_candidate_type",
|
||||
"ip_family",
|
||||
"protocol",
|
||||
"setup_ms",
|
||||
"ice_restarted"
|
||||
]
|
||||
},
|
||||
"description": "One report for each remote peer that settled"
|
||||
}
|
||||
},
|
||||
"required": ["reports"]
|
||||
},
|
||||
"EntranceSoundPlayRequest": {
|
||||
"type": "object",
|
||||
"properties": {"sound_id": {"$ref": "#/components/schemas/SnowflakeType"}},
|
||||
@@ -27199,6 +27370,10 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}]
|
||||
},
|
||||
"rtc_region": {"description": "The voice region ID for the voice channel", "type": ["string", "null"]},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"last_message_id": {
|
||||
"description": "The ID of the last message sent in this channel",
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}]
|
||||
@@ -30683,7 +30858,8 @@
|
||||
"properties": {
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
|
||||
"channel_threads": {"$ref": "#/components/schemas/ChannelThreadsAssignmentResponse"},
|
||||
"plutonium_page": {"$ref": "#/components/schemas/PlutoniumPageAssignmentResponse"}
|
||||
"plutonium_page": {"$ref": "#/components/schemas/PlutoniumPageAssignmentResponse"},
|
||||
"voice_p2p": {"$ref": "#/components/schemas/VoiceP2pAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -31005,6 +31181,10 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}]
|
||||
},
|
||||
"rtc_region": {"description": "The voice region ID for the voice channel", "type": ["string", "null"]},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"last_message_id": {
|
||||
"description": "The ID of the last message sent in this channel",
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}]
|
||||
@@ -31356,6 +31536,10 @@
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}]
|
||||
},
|
||||
"rtc_region": {"description": "The voice region ID for the voice channel", "type": ["string", "null"]},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"last_message_id": {
|
||||
"description": "The ID of the last message sent in this channel",
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeStringType"}, {"type": "null"}]
|
||||
@@ -31946,97 +32130,9 @@
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"type": {
|
||||
"description": "Convert between text (0) and announcement (5). Other conversions are rejected.",
|
||||
"anyOf": [{"type": "number", "const": 0}, {"type": "number", "const": 5}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {"description": "The channel topic (1-1024 characters)", "type": ["string", "null"]},
|
||||
"url": {"description": "External URL for link channels", "type": ["string", "null"]},
|
||||
"parent_id": {
|
||||
"description": "ID of the parent category for this channel",
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}]
|
||||
},
|
||||
"bitrate": {
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature",
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000}, {"type": "null"}]
|
||||
},
|
||||
"user_limit": {
|
||||
"description": "Maximum users allowed in voice channel (0-99, 0 means unlimited)",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99}, {"type": "null"}]
|
||||
},
|
||||
"voice_connection_limit": {
|
||||
"description": "Maximum active voice connections allowed per user in a voice channel (1-100)",
|
||||
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}]
|
||||
},
|
||||
"permission_overwrites": {
|
||||
"description": "Permission overwrites for roles and members",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "The ID of the role or user to overwrite permissions for",
|
||||
"$ref": "#/components/schemas/SnowflakeType"
|
||||
},
|
||||
"type": {
|
||||
"description": "The type of overwrite (0 = role, 1 = member)",
|
||||
"x-enumNames": ["ROLE", "MEMBER"],
|
||||
"enum": [0, 1],
|
||||
"type": "integer"
|
||||
},
|
||||
"allow": {
|
||||
"description": "Bitwise value of allowed permissions",
|
||||
"$ref": "#/components/schemas/UnsignedInt64Type"
|
||||
},
|
||||
"deny": {
|
||||
"description": "Bitwise value of denied permissions",
|
||||
"$ref": "#/components/schemas/UnsignedInt64Type"
|
||||
}
|
||||
},
|
||||
"required": ["id", "type"]
|
||||
}
|
||||
},
|
||||
"rate_limit_per_user": {
|
||||
"description": "Slowmode delay in seconds (0-21600)",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}]
|
||||
},
|
||||
"nsfw": {
|
||||
"description": "Legacy: setting true maps to nsfw_override=true; setting false maps to nsfw_override=null (inherit). Prefer nsfw_override.",
|
||||
"type": ["boolean", "null"]
|
||||
},
|
||||
"nsfw_override": {
|
||||
"description": "Per-channel adult-content override (true=on, false=off, null=inherit from category then guild). Takes precedence over the legacy `nsfw` field if both are present.",
|
||||
"type": ["boolean", "null"]
|
||||
},
|
||||
"content_warning_level": {
|
||||
"description": "Channel-level content warning override (0=inherit, 1=force-warn)",
|
||||
"$ref": "#/components/schemas/ContentWarningLevelInput"
|
||||
},
|
||||
"content_warning_text": {
|
||||
"description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild",
|
||||
"anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}]
|
||||
},
|
||||
"icon": {
|
||||
"description": "Base64-encoded icon image for group DM channels",
|
||||
"anyOf": [{"$ref": "#/components/schemas/Base64ImageType"}, {"type": "null"}]
|
||||
},
|
||||
"owner_id": {
|
||||
"description": "ID of the new owner for group DM channels",
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}]
|
||||
},
|
||||
"nicks": {
|
||||
"description": "Custom nicknames for users in this channel",
|
||||
"$ref": "#/components/schemas/ChannelNicknameOverrides"
|
||||
},
|
||||
"rtc_region": {
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"type": {
|
||||
@@ -32130,6 +32226,106 @@
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"type": {
|
||||
"description": "Convert between text (0) and announcement (5). Other conversions are rejected.",
|
||||
"anyOf": [{"type": "number", "const": 0}, {"type": "number", "const": 5}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {"description": "The channel topic (1-1024 characters)", "type": ["string", "null"]},
|
||||
"url": {"description": "External URL for link channels", "type": ["string", "null"]},
|
||||
"parent_id": {
|
||||
"description": "ID of the parent category for this channel",
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}]
|
||||
},
|
||||
"bitrate": {
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature",
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000}, {"type": "null"}]
|
||||
},
|
||||
"user_limit": {
|
||||
"description": "Maximum users allowed in voice channel (0-99, 0 means unlimited)",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99}, {"type": "null"}]
|
||||
},
|
||||
"voice_connection_limit": {
|
||||
"description": "Maximum active voice connections allowed per user in a voice channel (1-100)",
|
||||
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}]
|
||||
},
|
||||
"permission_overwrites": {
|
||||
"description": "Permission overwrites for roles and members",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "The ID of the role or user to overwrite permissions for",
|
||||
"$ref": "#/components/schemas/SnowflakeType"
|
||||
},
|
||||
"type": {
|
||||
"description": "The type of overwrite (0 = role, 1 = member)",
|
||||
"x-enumNames": ["ROLE", "MEMBER"],
|
||||
"enum": [0, 1],
|
||||
"type": "integer"
|
||||
},
|
||||
"allow": {
|
||||
"description": "Bitwise value of allowed permissions",
|
||||
"$ref": "#/components/schemas/UnsignedInt64Type"
|
||||
},
|
||||
"deny": {
|
||||
"description": "Bitwise value of denied permissions",
|
||||
"$ref": "#/components/schemas/UnsignedInt64Type"
|
||||
}
|
||||
},
|
||||
"required": ["id", "type"]
|
||||
}
|
||||
},
|
||||
"rate_limit_per_user": {
|
||||
"description": "Slowmode delay in seconds (0-21600)",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}]
|
||||
},
|
||||
"nsfw": {
|
||||
"description": "Legacy: setting true maps to nsfw_override=true; setting false maps to nsfw_override=null (inherit). Prefer nsfw_override.",
|
||||
"type": ["boolean", "null"]
|
||||
},
|
||||
"nsfw_override": {
|
||||
"description": "Per-channel adult-content override (true=on, false=off, null=inherit from category then guild). Takes precedence over the legacy `nsfw` field if both are present.",
|
||||
"type": ["boolean", "null"]
|
||||
},
|
||||
"content_warning_level": {
|
||||
"description": "Channel-level content warning override (0=inherit, 1=force-warn)",
|
||||
"$ref": "#/components/schemas/ContentWarningLevelInput"
|
||||
},
|
||||
"content_warning_text": {
|
||||
"description": "Custom channel content warning text (max 200 characters); null inherits from parent or guild",
|
||||
"anyOf": [{"type": "string", "maxLength": 200}, {"type": "null"}]
|
||||
},
|
||||
"icon": {
|
||||
"description": "Base64-encoded icon image for group DM channels",
|
||||
"anyOf": [{"$ref": "#/components/schemas/Base64ImageType"}, {"type": "null"}]
|
||||
},
|
||||
"owner_id": {
|
||||
"description": "ID of the new owner for group DM channels",
|
||||
"anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}]
|
||||
},
|
||||
"nicks": {
|
||||
"description": "Custom nicknames for users in this channel",
|
||||
"$ref": "#/components/schemas/ChannelNicknameOverrides"
|
||||
},
|
||||
"rtc_region": {
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]}
|
||||
}
|
||||
},
|
||||
@@ -32218,6 +32414,10 @@
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {"description": "The name of the category", "type": ["string", "null"]}
|
||||
}
|
||||
},
|
||||
@@ -32306,6 +32506,10 @@
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]}
|
||||
}
|
||||
},
|
||||
@@ -34009,6 +34213,10 @@
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"default_auto_archive_duration": {
|
||||
"description": "Default auto archive duration in minutes for new threads",
|
||||
@@ -34118,6 +34326,10 @@
|
||||
"description": "Voice region ID for the voice channel (1-64 characters)",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"rtc_p2p": {
|
||||
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"default_auto_archive_duration": {
|
||||
"description": "Default auto archive duration in minutes for new threads",
|
||||
@@ -36155,6 +36367,15 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"VoiceP2pAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"max_participants": {"type": "integer", "minimum": 2, "maximum": 4}
|
||||
},
|
||||
"required": ["enabled", "max_participants"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PlutoniumPageAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
|
||||
@@ -180,4 +180,8 @@ export const ChannelRateLimitConfigs = {
|
||||
bucket: 'voice:entrance_sound:play::user_id::channel_id',
|
||||
config: {limit: 3, windowMs: ms('30 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
VOICE_P2P_CONNECTION_REPORTS: {
|
||||
bucket: 'voice:p2p:connection_reports',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
} as const;
|
||||
@@ -501,6 +501,10 @@ export class RpcService {
|
||||
canStream: request.can_stream,
|
||||
canVideo: request.can_video,
|
||||
tokenNonce: request.token_nonce,
|
||||
p2p: request.p2p,
|
||||
p2pInitiator: request.p2p_initiator,
|
||||
p2pParticipantCount: request.p2p_participant_count,
|
||||
countryCode: request.country_code,
|
||||
});
|
||||
return {
|
||||
type: 'voice_get_token',
|
||||
@@ -1286,6 +1290,7 @@ export class RpcService {
|
||||
),
|
||||
pinned_dms: timeRpcStepSync(responseBuildSteps, 'map_pinned_dms', () => userData.pinnedDMs?.map(String) ?? []),
|
||||
country_code: countryCode,
|
||||
geoip_country_code: geoipCountryIso,
|
||||
latitude: geoipLatitude,
|
||||
longitude: geoipLongitude,
|
||||
rtc_regions: rtcRegions,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface RpcVoiceStatesResponse {
|
||||
type: 'guild_collection';
|
||||
data: {
|
||||
voice_states: Array<{connection_id: string; p2p?: boolean}>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('RpcService guild voice state p2p flag', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('keeps p2p on a persisted guild voice state', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'P2P Voice State Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'voice', ChannelTypes.GUILD_VOICE);
|
||||
await createBuilder(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({
|
||||
type: 'voice_state_upsert',
|
||||
guild_id: guild.id,
|
||||
voice_state: {
|
||||
guild_id: guild.id,
|
||||
channel_id: channel.id,
|
||||
user_id: account.userId,
|
||||
connection_id: 'brisk-forte',
|
||||
mute: false,
|
||||
deaf: false,
|
||||
self_mute: false,
|
||||
self_deaf: false,
|
||||
p2p: true,
|
||||
},
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const response = await createBuilder<RpcVoiceStatesResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'guild_collection', guild_id: guild.id, collection: 'voice_states'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.data.voice_states).toMatchObject([{connection_id: 'brisk-forte', p2p: true}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface RpcSessionResponse {
|
||||
type: 'session';
|
||||
data: {
|
||||
country_code: string;
|
||||
geoip_country_code: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
describe('RpcService session geoip country', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('reports an unknown country as null without the country_code default', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const response = await createBuilder<RpcSessionResponse>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.data.geoip_country_code).toBeNull();
|
||||
expect(response.data.country_code).toBe('US');
|
||||
});
|
||||
});
|
||||
@@ -989,6 +989,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: lastMessageId,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
@@ -1031,6 +1032,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: lastMessageId,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -156,6 +156,7 @@ function buildDmChannelRow({
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -522,6 +522,7 @@ export class UserChannelService {
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -86,6 +86,7 @@ function createPrivateChannel(channelId: ChannelID, type: number, recipientIds:
|
||||
user_limit: null,
|
||||
voice_connection_limit: null,
|
||||
rtc_region: null,
|
||||
rtc_p2p: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
|
||||
@@ -61,6 +61,7 @@ export function serializeChannelForAudit(channel: Channel): Record<string, unkno
|
||||
voice_connection_limit: channel.voiceConnectionLimit,
|
||||
bitrate: channel.bitrate,
|
||||
rtc_region: channel.rtcRegion ?? null,
|
||||
rtc_p2p: channel.rtcP2p,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
|
||||
import {getInstanceProductName} from '@app/api/instance/ProductName';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {getVoiceP2pConnectionReportRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '@app/api/utils/SessionClientIdentity';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {VoiceP2pConnectionReportsRequest} from '@fluxer/schema/src/domains/voice/VoiceP2pConnectionReportSchemas';
|
||||
|
||||
export function VoiceP2pConnectionReportController(app: HonoApp) {
|
||||
app.post(
|
||||
'/voice/p2p/connection-reports',
|
||||
RateLimitMiddleware(RateLimitConfigs.VOICE_P2P_CONNECTION_REPORTS),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', VoiceP2pConnectionReportsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_voice_p2p_connection_reports',
|
||||
summary: 'Report peer-to-peer connection outcomes',
|
||||
description:
|
||||
'Records how peer connections of a peer-to-peer voice call settled, one report for each remote peer. Fluxer adds the request address, its country and the client platform of the session.',
|
||||
requestSchema: VoiceP2pConnectionReportsRequest,
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['sessionToken'],
|
||||
tags: ['Voice'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {reports} = ctx.req.valid('json');
|
||||
if (!(await ctx.get('instanceConfigRepository').getVoiceP2pConfig()).enabled) {
|
||||
return ctx.body(null, 204);
|
||||
}
|
||||
const authSession = ctx.get('authSession');
|
||||
const {countryCode, normalizedIp} = await lookupGeoip(ctx.req.raw);
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: authSession?.clientUserAgent ?? null,
|
||||
reportedOs: authSession?.clientOs ?? null,
|
||||
productName: getInstanceProductName(),
|
||||
});
|
||||
const userId = ctx.get('user').id;
|
||||
const snowflakeService = ctx.get('snowflakeService');
|
||||
const reportedAt = new Date();
|
||||
const rows = await Promise.all(
|
||||
reports.map(async (report) => ({
|
||||
user_id: userId,
|
||||
report_id: await snowflakeService.generate(),
|
||||
reported_at: reportedAt,
|
||||
channel_id: createChannelID(report.channel_id),
|
||||
guild_id: report.guild_id === null ? null : createGuildID(report.guild_id),
|
||||
participant_count: report.participant_count,
|
||||
outcome: report.outcome,
|
||||
local_candidate_type: report.local_candidate_type,
|
||||
remote_candidate_type: report.remote_candidate_type,
|
||||
ip_family: report.ip_family,
|
||||
protocol: report.protocol,
|
||||
setup_ms: report.setup_ms,
|
||||
ice_restarted: report.ice_restarted,
|
||||
country: countryCode,
|
||||
ip: normalizedIp,
|
||||
client_platform: clientInfo.platform,
|
||||
client_os: clientInfo.os,
|
||||
})),
|
||||
);
|
||||
await getVoiceP2pConnectionReportRepository().insertReports(rows);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BatchBuilder} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {VoiceP2pConnectionReportRow} from '@app/api/database/types/VoiceTypes';
|
||||
import {VoiceP2pConnectionReports} from '@app/api/Tables';
|
||||
|
||||
export class VoiceP2pConnectionReportRepository {
|
||||
async insertReports(rows: ReadonlyArray<VoiceP2pConnectionReportRow>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
for (const row of rows) {
|
||||
batch.addPrepared(VoiceP2pConnectionReports.insert(row));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {ListParticipantsResult} from '@app/api/infrastructure/ILiveKitService';
|
||||
import type {LiveKitService} from '@app/api/infrastructure/LiveKitService';
|
||||
import type {PinnedRoomServer, VoiceRoomStore} from '@app/api/infrastructure/VoiceRoomStore';
|
||||
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {VoiceAccessContext, VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionAvailability, VoiceServerRecord} from '@app/api/voice/VoiceModel';
|
||||
@@ -23,6 +27,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
|
||||
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';
|
||||
import {resolveVoiceP2pAssignment} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
|
||||
interface GetVoiceTokenParams {
|
||||
guildId?: GuildID;
|
||||
@@ -36,8 +41,35 @@ interface GetVoiceTokenParams {
|
||||
canStream?: boolean;
|
||||
canVideo?: boolean;
|
||||
tokenNonce?: string;
|
||||
p2p?: boolean;
|
||||
p2pInitiator?: boolean;
|
||||
p2pParticipantCount?: number;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
interface VoiceTokenGrant {
|
||||
token: string;
|
||||
endpoint: string;
|
||||
connectionId: string;
|
||||
tokenNonce: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface VoiceP2pGrant {
|
||||
p2p: true;
|
||||
connectionId: string;
|
||||
iceServers: Array<{urls: Array<string>}>;
|
||||
maxParticipants: number;
|
||||
}
|
||||
|
||||
interface VoiceP2pDecline {
|
||||
p2p: false;
|
||||
declineReason: 'channel_full';
|
||||
}
|
||||
|
||||
type VoiceP2pDecision = {kind: 'granted'; maxParticipants: number} | {kind: 'declined'} | {kind: 'channel_full'};
|
||||
|
||||
interface VoicePermissions {
|
||||
canSpeak: boolean;
|
||||
canStream: boolean;
|
||||
@@ -64,16 +96,10 @@ export class VoiceService {
|
||||
private channelRepository: IChannelRepository,
|
||||
private voiceRoomStore: VoiceRoomStore,
|
||||
private voiceAvailabilityService: VoiceAvailabilityService,
|
||||
private instanceConfigRepository: InstanceConfigRepository,
|
||||
) {}
|
||||
|
||||
async getVoiceToken(params: GetVoiceTokenParams): Promise<{
|
||||
token: string;
|
||||
endpoint: string;
|
||||
connectionId: string;
|
||||
tokenNonce: string;
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
}> {
|
||||
async getVoiceToken(params: GetVoiceTokenParams): Promise<VoiceTokenGrant | VoiceP2pGrant | VoiceP2pDecline> {
|
||||
const {guildId, channelId, userId, connectionId: providedConnectionId} = params;
|
||||
const selectionKey = this.createVoiceRoutingSelectionKey(guildId, channelId);
|
||||
let connectionId = providedConnectionId;
|
||||
@@ -120,6 +146,19 @@ export class VoiceService {
|
||||
guildFeatures = guild.features;
|
||||
}
|
||||
}
|
||||
const p2pDecision: VoiceP2pDecision = params.p2p ? await this.decideVoiceP2p(user, params) : {kind: 'declined'};
|
||||
if (p2pDecision.kind === 'channel_full') {
|
||||
return {p2p: false, declineReason: 'channel_full'};
|
||||
}
|
||||
if (p2pDecision.kind === 'granted') {
|
||||
const stunUrls = Config.voice.p2pStunUrls;
|
||||
return {
|
||||
p2p: true,
|
||||
connectionId: providedConnectionId || generateConnectionId(),
|
||||
iceServers: stunUrls?.length ? [{urls: stunUrls}] : [],
|
||||
maxParticipants: p2pDecision.maxParticipants,
|
||||
};
|
||||
}
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: userId,
|
||||
guildId,
|
||||
@@ -257,6 +296,22 @@ export class VoiceService {
|
||||
return {token, endpoint, connectionId, tokenNonce, regionId, serverId};
|
||||
}
|
||||
|
||||
private async decideVoiceP2p(user: User, params: GetVoiceTokenParams): Promise<VoiceP2pDecision> {
|
||||
const config = await this.instanceConfigRepository.getVoiceP2pConfig();
|
||||
if (!config.enabled) {
|
||||
return {kind: 'declined'};
|
||||
}
|
||||
if (params.p2pParticipantCount !== undefined && params.p2pParticipantCount > config.max_participants) {
|
||||
return {kind: 'channel_full'};
|
||||
}
|
||||
const granted: VoiceP2pDecision = {kind: 'granted', maxParticipants: config.max_participants};
|
||||
if (!params.p2pInitiator) {
|
||||
return granted;
|
||||
}
|
||||
const targeting = await resolveExperimentTargeting(user, params.countryCode ?? null, [config]);
|
||||
return resolveVoiceP2pAssignment(config, user.id.toString(), targeting).enabled ? granted : {kind: 'declined'};
|
||||
}
|
||||
|
||||
private createVoiceRoutingSelectionKey(guildId: GuildID | undefined, channelId: ChannelID): string {
|
||||
const guildKey = guildId?.toString() ?? 'dm';
|
||||
return `${guildKey}:${channelId.toString()}`;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUniqueEmail, createUniqueUsername} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {fetchMany} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
VOICE_P2P_CONNECTION_REPORT_COLUMNS,
|
||||
type VoiceP2pConnectionReportRow,
|
||||
} from '@app/api/database/types/VoiceTypes';
|
||||
import {getInstanceProductName} from '@app/api/instance/ProductName';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {VoiceP2pConnectionReports} from '@app/api/Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_CREDENTIALS, TEST_USER_DATA} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {DEFAULT_VOICE_P2P_CONFIG} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {lookupGeoipMock} = vi.hoisted(() => ({
|
||||
lookupGeoipMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/api/utils/IpUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@app/api/utils/IpUtils')>()),
|
||||
lookupGeoip: lookupGeoipMock,
|
||||
}));
|
||||
|
||||
const ENDPOINT = '/voice/p2p/connection-reports';
|
||||
const CLIENT_IP = '203.0.113.10';
|
||||
const ANDROID_USER_AGENT = 'Fluxer Android/1.4.0';
|
||||
|
||||
const LIST_REPORTS = VoiceP2pConnectionReports.select({where: VoiceP2pConnectionReports.where.eq('user_id')});
|
||||
|
||||
function geoipCountry(countryCode: string | null): GeoipResult {
|
||||
return {countryCode, normalizedIp: CLIENT_IP, city: 'São Paulo', region: 'SP', countryName: 'Brazil'};
|
||||
}
|
||||
|
||||
function connectedReport(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
channel_id: '1189375284394692610',
|
||||
guild_id: null,
|
||||
participant_count: 2,
|
||||
outcome: 'connected',
|
||||
local_candidate_type: 'srflx',
|
||||
remote_candidate_type: 'host',
|
||||
ip_family: 'ipv6',
|
||||
protocol: 'udp',
|
||||
setup_ms: 840,
|
||||
ice_restarted: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function registerAndroidAccount(harness: ApiTestHarness): Promise<{userId: string; token: string}> {
|
||||
const registration = await createBuilder<{user_id: string; token: string}>(harness, '')
|
||||
.post('/auth/register')
|
||||
.header('user-agent', ANDROID_USER_AGENT)
|
||||
.header('x-forwarded-for', CLIENT_IP)
|
||||
.body({
|
||||
email: createUniqueEmail('p2p-report'),
|
||||
username: createUniqueUsername('p2preport'),
|
||||
global_name: TEST_USER_DATA.DEFAULT_GLOBAL_NAME,
|
||||
password: TEST_CREDENTIALS.STRONG_PASSWORD,
|
||||
date_of_birth: TEST_USER_DATA.DEFAULT_DATE_OF_BIRTH,
|
||||
consent: true,
|
||||
})
|
||||
.execute();
|
||||
return {userId: registration.user_id, token: registration.token};
|
||||
}
|
||||
|
||||
async function listReports(userId: string): Promise<Array<VoiceP2pConnectionReportRow>> {
|
||||
return fetchMany<VoiceP2pConnectionReportRow>(LIST_REPORTS.bind({user_id: createUserID(BigInt(userId))}));
|
||||
}
|
||||
|
||||
describe('POST /voice/p2p/connection-reports', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
lookupGeoipMock.mockReset();
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
await getInstanceConfigRepository().setVoiceP2pConfig({...DEFAULT_VOICE_P2P_CONFIG, enabled: true});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated caller', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(ENDPOINT)
|
||||
.body({reports: [connectedReport()]})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects a bot', async () => {
|
||||
const bot = await createTestBotAccount(harness);
|
||||
await createBuilder(harness, `Bot ${bot.botToken}`)
|
||||
.post(ENDPOINT)
|
||||
.body({reports: [connectedReport()]})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('stores each report with the server-derived fields and the request address', async () => {
|
||||
const account = await registerAndroidAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(ENDPOINT)
|
||||
.header('x-forwarded-for', CLIENT_IP)
|
||||
.body({
|
||||
reports: [
|
||||
connectedReport(),
|
||||
connectedReport({
|
||||
guild_id: '1189375284394692608',
|
||||
participant_count: 3,
|
||||
outcome: 'failed',
|
||||
local_candidate_type: null,
|
||||
remote_candidate_type: null,
|
||||
ip_family: null,
|
||||
protocol: null,
|
||||
setup_ms: null,
|
||||
ice_restarted: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const rows = await listReports(account.userId);
|
||||
expect(rows).toHaveLength(2);
|
||||
const shared = {
|
||||
user_id: BigInt(account.userId),
|
||||
channel_id: 1189375284394692610n,
|
||||
country: 'BR',
|
||||
client_platform: `${getInstanceProductName()} Android`,
|
||||
client_os: 'Android',
|
||||
};
|
||||
const connected = rows.find((row) => row.outcome === 'connected');
|
||||
const failed = rows.find((row) => row.outcome === 'failed');
|
||||
expect(connected).toMatchObject({
|
||||
...shared,
|
||||
guild_id: null,
|
||||
participant_count: 2,
|
||||
local_candidate_type: 'srflx',
|
||||
remote_candidate_type: 'host',
|
||||
ip_family: 'ipv6',
|
||||
protocol: 'udp',
|
||||
setup_ms: 840,
|
||||
ice_restarted: false,
|
||||
});
|
||||
expect(failed).toMatchObject({
|
||||
...shared,
|
||||
guild_id: 1189375284394692608n,
|
||||
participant_count: 3,
|
||||
local_candidate_type: null,
|
||||
remote_candidate_type: null,
|
||||
ip_family: null,
|
||||
protocol: null,
|
||||
setup_ms: null,
|
||||
ice_restarted: true,
|
||||
});
|
||||
for (const row of rows) {
|
||||
expect(Object.keys(row).sort()).toEqual([...VOICE_P2P_CONNECTION_REPORT_COLUMNS].sort());
|
||||
expect(row.reported_at).toBeInstanceOf(Date);
|
||||
expect(row.ip).toBe(CLIENT_IP);
|
||||
}
|
||||
expect(connected?.report_id).not.toEqual(failed?.report_id);
|
||||
});
|
||||
|
||||
it('accepts and drops reports while the experiment is disabled', async () => {
|
||||
await getInstanceConfigRepository().setVoiceP2pConfig(DEFAULT_VOICE_P2P_CONFIG);
|
||||
const account = await registerAndroidAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(ENDPOINT)
|
||||
.body({reports: [connectedReport()]})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
expect(await listReports(account.userId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stores a null country when the lookup finds none', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
|
||||
const account = await registerAndroidAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(ENDPOINT)
|
||||
.body({reports: [connectedReport()]})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const rows = await listReports(account.userId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.country).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'an empty batch', body: {reports: []}},
|
||||
{name: 'more than 8 reports', body: {reports: Array.from({length: 9}, () => connectedReport())}},
|
||||
{name: 'a single participant', body: {reports: [connectedReport({participant_count: 1})]}},
|
||||
{name: 'more than 4 participants', body: {reports: [connectedReport({participant_count: 5})]}},
|
||||
{name: 'an unknown outcome', body: {reports: [connectedReport({outcome: 'checking'})]}},
|
||||
{name: 'an unknown candidate type', body: {reports: [connectedReport({local_candidate_type: 'turn'})]}},
|
||||
{name: 'an unknown address family', body: {reports: [connectedReport({ip_family: 'ipx'})]}},
|
||||
{name: 'an unknown protocol', body: {reports: [connectedReport({protocol: 'sctp'})]}},
|
||||
{name: 'a negative setup time', body: {reports: [connectedReport({setup_ms: -1})]}},
|
||||
{name: 'a malformed channel id', body: {reports: [connectedReport({channel_id: 'abc'})]}},
|
||||
{name: 'a missing ice_restarted', body: {reports: [connectedReport({ice_restarted: undefined})]}},
|
||||
])('rejects $name and stores nothing', async ({body}) => {
|
||||
const account = await registerAndroidAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(ENDPOINT)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
|
||||
expect(await listReports(account.userId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createGuildID,
|
||||
createUserID,
|
||||
type GuildID,
|
||||
type UserID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createChannel, createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {
|
||||
getChannelRepository,
|
||||
getGuildRepository,
|
||||
getInstanceConfigRepository,
|
||||
getUserRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {VoiceService} from '@app/api/voice/VoiceService';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DEFAULT_VOICE_P2P_CONFIG, type VoiceP2pConfig} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const SFU_PATH_REACHED = 'sfu path reached';
|
||||
|
||||
function createVoiceService(): VoiceService {
|
||||
const sfuOnly = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error(SFU_PATH_REACHED);
|
||||
},
|
||||
},
|
||||
);
|
||||
return new VoiceService(
|
||||
sfuOnly as never,
|
||||
getGuildRepository(),
|
||||
getUserRepository(),
|
||||
getChannelRepository(),
|
||||
sfuOnly as never,
|
||||
sfuOnly as never,
|
||||
getInstanceConfigRepository(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('VoiceService peer-to-peer grant', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let voiceService: VoiceService;
|
||||
let params: {guildId: GuildID; channelId: ChannelID; userId: UserID};
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
voiceService = createVoiceService();
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'P2P Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'voice', ChannelTypes.GUILD_VOICE);
|
||||
params = {
|
||||
guildId: createGuildID(BigInt(guild.id)),
|
||||
channelId: createChannelID(BigInt(channel.id)),
|
||||
userId: createUserID(BigInt(account.userId)),
|
||||
};
|
||||
});
|
||||
afterEach(async () => {
|
||||
Config.voice.p2pStunUrls = undefined;
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setConfig(config: Partial<VoiceP2pConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().setVoiceP2pConfig({...DEFAULT_VOICE_P2P_CONFIG, ...config});
|
||||
}
|
||||
|
||||
test('grants an assigned initiator without selecting a voice server', async () => {
|
||||
await setConfig({enabled: true, included_user_ids: [params.userId.toString()]});
|
||||
const grant = await voiceService.getVoiceToken({...params, p2p: true, p2pInitiator: true});
|
||||
expect(grant).toEqual({
|
||||
p2p: true,
|
||||
connectionId: expect.stringMatching(/^[a-z]+-[a-z]+$/u),
|
||||
iceServers: [],
|
||||
maxParticipants: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('hands out the configured STUN urls as one entry without credentials', async () => {
|
||||
Config.voice.p2pStunUrls = ['stun:stun.example.com:3478', 'stun:stun2.example.com:3478'];
|
||||
await setConfig({enabled: true});
|
||||
const grant = await voiceService.getVoiceToken({...params, p2p: true, connectionId: 'brisk-forte'});
|
||||
expect(grant).toEqual({
|
||||
p2p: true,
|
||||
connectionId: 'brisk-forte',
|
||||
iceServers: [{urls: ['stun:stun.example.com:3478', 'stun:stun2.example.com:3478']}],
|
||||
maxParticipants: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('carries the configured cap so the call can enforce it on join', async () => {
|
||||
await setConfig({enabled: true, max_participants: 3});
|
||||
const grant = await voiceService.getVoiceToken({...params, p2p: true, p2pParticipantCount: 2});
|
||||
expect(grant).toMatchObject({p2p: true, maxParticipants: 3});
|
||||
});
|
||||
|
||||
test('declines a joiner past the configured cap as channel full', async () => {
|
||||
await setConfig({enabled: true, max_participants: 3});
|
||||
const grant = await voiceService.getVoiceToken({...params, p2p: true, p2pParticipantCount: 3});
|
||||
expect(grant).toMatchObject({p2p: true});
|
||||
await expect(voiceService.getVoiceToken({...params, p2p: true, p2pParticipantCount: 4})).resolves.toEqual({
|
||||
p2p: false,
|
||||
declineReason: 'channel_full',
|
||||
});
|
||||
});
|
||||
|
||||
test('never refuses an initiator for size at the lowest cap', async () => {
|
||||
await setConfig({enabled: true, max_participants: 2, included_user_ids: [params.userId.toString()]});
|
||||
const grant = await voiceService.getVoiceToken({
|
||||
...params,
|
||||
p2p: true,
|
||||
p2pInitiator: true,
|
||||
p2pParticipantCount: 1,
|
||||
});
|
||||
expect(grant).toMatchObject({p2p: true});
|
||||
});
|
||||
|
||||
test('declines every request while disabled whatever the count', async () => {
|
||||
await setConfig({enabled: false, max_participants: 2});
|
||||
await expect(voiceService.getVoiceToken({...params, p2p: true, p2pParticipantCount: 3})).rejects.toThrow(
|
||||
SFU_PATH_REACHED,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the connection id the gateway supplies', async () => {
|
||||
await setConfig({enabled: true});
|
||||
const grant = await voiceService.getVoiceToken({...params, p2p: true, connectionId: 'brisk-forte'});
|
||||
expect(grant).toEqual({p2p: true, connectionId: 'brisk-forte', iceServers: [], maxParticipants: 2});
|
||||
});
|
||||
|
||||
test('grants a joiner outside the rollout while the experiment is enabled', async () => {
|
||||
await setConfig({enabled: true, excluded_user_ids: [params.userId.toString()]});
|
||||
const grant = await voiceService.getVoiceToken({...params, p2p: true, p2pInitiator: false});
|
||||
expect(grant).toMatchObject({p2p: true});
|
||||
});
|
||||
|
||||
test('declines an initiator outside the rollout', async () => {
|
||||
await setConfig({enabled: true});
|
||||
await expect(voiceService.getVoiceToken({...params, p2p: true, p2pInitiator: true})).rejects.toThrow(
|
||||
SFU_PATH_REACHED,
|
||||
);
|
||||
});
|
||||
|
||||
test('applies the forwarded country to the initiator rollout', async () => {
|
||||
await setConfig({enabled: true, rollout_basis_points: 10_000, rollout_country_codes: ['SE']});
|
||||
const grant = await voiceService.getVoiceToken({...params, p2p: true, p2pInitiator: true, countryCode: 'SE'});
|
||||
expect(grant).toMatchObject({p2p: true});
|
||||
await expect(
|
||||
voiceService.getVoiceToken({...params, p2p: true, p2pInitiator: true, countryCode: 'US'}),
|
||||
).rejects.toThrow(SFU_PATH_REACHED);
|
||||
await expect(voiceService.getVoiceToken({...params, p2p: true, p2pInitiator: true})).rejects.toThrow(
|
||||
SFU_PATH_REACHED,
|
||||
);
|
||||
});
|
||||
|
||||
test('declines every request while the experiment is disabled', async () => {
|
||||
await setConfig({enabled: false, included_user_ids: [params.userId.toString()]});
|
||||
await expect(voiceService.getVoiceToken({...params, p2p: true, p2pInitiator: false})).rejects.toThrow(
|
||||
SFU_PATH_REACHED,
|
||||
);
|
||||
});
|
||||
|
||||
test('takes the ordinary path when the gateway does not ask for peer-to-peer', async () => {
|
||||
await setConfig({enabled: true, included_user_ids: [params.userId.toString()]});
|
||||
await expect(voiceService.getVoiceToken(params)).rejects.toThrow(SFU_PATH_REACHED);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user