feat(voice): add opt-in peer-to-peer calls and country rollouts (#3367)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 15:45:17 +02:00
1 parent e5a568acca
commit bef5e33eec
215 files changed
+15840 -2693

No files matched your search

+1
View File
@@ -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,
+10
View File
@@ -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,
+1
View File
@@ -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());
+3
View File
@@ -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,
+313 -92
View File
@@ -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;
+5
View File
@@ -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();
}
}
+63 -8
View File
@@ -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);
});
});