mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +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
@@ -25,6 +25,7 @@ import {
|
||||
InstanceBillingUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {PushRelayConfigResponse, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {VoiceP2pConfigResponse, VoiceP2pConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {
|
||||
ExperimentDeliveryConfigResponse,
|
||||
ExperimentDeliveryConfigUpdateRequest,
|
||||
@@ -701,6 +702,7 @@ export const InstanceConfigResponse = z.object({
|
||||
gateway_rollout: GatewayRolloutConfigResponse,
|
||||
push_relay: PushRelayConfigResponse,
|
||||
domain_migration: DomainMigrationConfigResponse,
|
||||
voice_p2p: VoiceP2pConfigResponse,
|
||||
captcha: CaptchaConfigResponse,
|
||||
experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
registration: InstanceRegistrationResponse,
|
||||
@@ -735,6 +737,7 @@ export const InstanceConfigUpdateRequest = z.object({
|
||||
gateway_rollout: GatewayRolloutConfigUpdateRequest.nullish(),
|
||||
push_relay: PushRelayConfigUpdateRequest.nullish(),
|
||||
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
|
||||
voice_p2p: VoiceP2pConfigUpdateRequest.nullish(),
|
||||
captcha: CaptchaConfigUpdateRequest.nullish(),
|
||||
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
|
||||
registration: z
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false};
|
||||
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false, countryCode: null};
|
||||
|
||||
const TARGETED_USER_ID = '1000000000000000001';
|
||||
const OTHER_USER_ID = '1000000000000000002';
|
||||
@@ -246,6 +246,7 @@ describe('resolveDomainMigrationAssignment guild targeting', () => {
|
||||
const MEMBER_GUILDS: ExperimentTargeting = {
|
||||
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
|
||||
premium: false,
|
||||
countryCode: null,
|
||||
};
|
||||
|
||||
test('serves members of an included guild at zero rollout', () => {
|
||||
@@ -270,7 +271,7 @@ describe('resolveDomainMigrationAssignment guild targeting', () => {
|
||||
});
|
||||
|
||||
describe('resolveDomainMigrationAssignment premium targeting', () => {
|
||||
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
|
||||
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true, countryCode: null};
|
||||
|
||||
test('serves premium users only when the switch is on', () => {
|
||||
const on = createConfig({enabled: true, include_premium_users: true});
|
||||
@@ -290,3 +291,60 @@ describe('resolveDomainMigrationAssignment premium targeting', () => {
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDomainMigrationAssignment country targeting', () => {
|
||||
const IN_SWEDEN: ExperimentTargeting = {...NO_TARGETING, countryCode: 'SE'};
|
||||
const IN_BRAZIL: ExperimentTargeting = {...NO_TARGETING, countryCode: 'BR'};
|
||||
|
||||
test('limits the percentage rollout to the listed countries', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 10000, rollout_country_codes: ['SE', 'NO']});
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, IN_SWEDEN)).toEqual({enabled: true});
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, IN_BRAZIL)).toEqual({enabled: false});
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('ignores the country while the list is empty', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 10000});
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, IN_BRAZIL)).toEqual({enabled: true});
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
test('still buckets users inside a listed country', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 2500, rollout_country_codes: ['SE']});
|
||||
for (const userId of syntheticUserIds(200)) {
|
||||
expect(resolveDomainMigrationAssignment(config, userId, IN_SWEDEN).enabled).toBe(
|
||||
experimentBucket(userId, config.rollout_salt) < 2500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps included users, included guilds and premium users outside the listed countries', () => {
|
||||
const guildId = '3000000000000000001';
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
rollout_country_codes: ['SE'],
|
||||
included_user_ids: [TARGETED_USER_ID],
|
||||
included_guild_ids: [guildId],
|
||||
include_premium_users: true,
|
||||
});
|
||||
const otherUserId = '1000000000000000007';
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, IN_BRAZIL)).toEqual({enabled: true});
|
||||
expect(
|
||||
resolveDomainMigrationAssignment(config, otherUserId, {...IN_BRAZIL, memberGuildIds: new Set([guildId])}),
|
||||
).toEqual({enabled: true});
|
||||
expect(resolveDomainMigrationAssignment(config, otherUserId, {...IN_BRAZIL, premium: true})).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(resolveDomainMigrationAssignment(config, otherUserId, IN_BRAZIL)).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
test('keeps exclusions ahead of a listed country', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_country_codes: ['SE'],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, IN_SWEDEN)).toEqual({enabled: false});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import {
|
||||
EXPERIMENT_BUCKET_RESOLUTION,
|
||||
ExperimentRolloutCountryCodesSchema,
|
||||
type ExperimentTargeting,
|
||||
experimentAudienceIncludes,
|
||||
experimentBucket,
|
||||
experimentRolloutCountryIncludes,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {z} from 'zod';
|
||||
|
||||
@@ -23,6 +25,7 @@ const domainMigrationConfigFields = {
|
||||
enabled: z.boolean(),
|
||||
config_version: z.number().int().min(0),
|
||||
rollout_basis_points: z.number().int().min(0).max(DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX),
|
||||
rollout_country_codes: ExperimentRolloutCountryCodesSchema,
|
||||
rollout_salt: z.string().trim().min(1).max(64).regex(DOMAIN_MIGRATION_SALT_PATTERN),
|
||||
included_user_ids: DomainMigrationTargetedUserIdsSchema,
|
||||
included_guild_ids: DomainMigrationTargetedUserIdsSchema,
|
||||
@@ -36,6 +39,7 @@ export const DomainMigrationConfigSchema = z.object({
|
||||
enabled: domainMigrationConfigFields.enabled.default(false),
|
||||
config_version: domainMigrationConfigFields.config_version.default(0),
|
||||
rollout_basis_points: domainMigrationConfigFields.rollout_basis_points.default(0),
|
||||
rollout_country_codes: domainMigrationConfigFields.rollout_country_codes.default([]),
|
||||
rollout_salt: domainMigrationConfigFields.rollout_salt.default(DEFAULT_DOMAIN_MIGRATION_SALT),
|
||||
included_user_ids: domainMigrationConfigFields.included_user_ids.default([]),
|
||||
included_guild_ids: domainMigrationConfigFields.included_guild_ids.default([]),
|
||||
@@ -79,6 +83,7 @@ export function resolveDomainMigrationAssignment(
|
||||
if (config.excluded_user_ids.includes(userId)) return {...INERT_DOMAIN_MIGRATION_ASSIGNMENT};
|
||||
if (config.included_user_ids.includes(userId)) return {enabled: true};
|
||||
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
|
||||
if (!experimentRolloutCountryIncludes(config, targeting)) return {...INERT_DOMAIN_MIGRATION_ASSIGNMENT};
|
||||
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DEFAULT_VOICE_P2P_CONFIG,
|
||||
resolveVoiceP2pAssignment,
|
||||
type VoiceP2pConfig,
|
||||
VoiceP2pConfigSchema,
|
||||
VoiceP2pConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false, countryCode: null};
|
||||
|
||||
const TARGETED_USER_ID = '1000000000000000001';
|
||||
|
||||
const SELECTED = {enabled: true, max_participants: 2};
|
||||
const UNSELECTED = {enabled: false, max_participants: 2};
|
||||
|
||||
function createConfig(overrides: Partial<VoiceP2pConfig> = {}): VoiceP2pConfig {
|
||||
return {...DEFAULT_VOICE_P2P_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides};
|
||||
}
|
||||
|
||||
function syntheticUserIds(count: number): Array<string> {
|
||||
return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString());
|
||||
}
|
||||
|
||||
describe('voice p2p configuration', () => {
|
||||
test('defaults to disabled', () => {
|
||||
expect(VoiceP2pConfigSchema.parse({})).toEqual({
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_country_codes: [],
|
||||
rollout_salt: 'voice-p2p-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
included_guild_ids: [],
|
||||
include_premium_users: false,
|
||||
max_participants: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('limits max_participants to 2 through 4', () => {
|
||||
expect(VoiceP2pConfigUpdateRequest.safeParse({max_participants: 4}).data).toEqual({max_participants: 4});
|
||||
expect(VoiceP2pConfigUpdateRequest.safeParse({max_participants: 1}).success).toBe(false);
|
||||
expect(VoiceP2pConfigUpdateRequest.safeParse({max_participants: 5}).success).toBe(false);
|
||||
expect(VoiceP2pConfigUpdateRequest.safeParse({max_participants: 2.5}).success).toBe(false);
|
||||
});
|
||||
|
||||
test('strips config_version from admin updates', () => {
|
||||
expect(VoiceP2pConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({});
|
||||
expect(VoiceP2pConfigUpdateRequest.safeParse({rollout_basis_points: 10001}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVoiceP2pAssignment', () => {
|
||||
test('reports the configured cap whether or not the caller is selected', () => {
|
||||
const config = createConfig({enabled: true, max_participants: 4, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({
|
||||
enabled: true,
|
||||
max_participants: 4,
|
||||
});
|
||||
expect(resolveVoiceP2pAssignment(config, '1000000000000000007', NO_TARGETING)).toEqual({
|
||||
enabled: false,
|
||||
max_participants: 4,
|
||||
});
|
||||
expect(resolveVoiceP2pAssignment({...config, enabled: false}, TARGETED_USER_ID, NO_TARGETING)).toEqual({
|
||||
enabled: false,
|
||||
max_participants: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test('serves nobody while disabled, even included users', () => {
|
||||
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(UNSELECTED);
|
||||
});
|
||||
|
||||
test('applies exclusions before inclusions', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
included_user_ids: [TARGETED_USER_ID],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(UNSELECTED);
|
||||
});
|
||||
|
||||
test('serves included users at zero rollout', () => {
|
||||
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(SELECTED);
|
||||
});
|
||||
|
||||
test('buckets the rollout by salt and user id', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 2500});
|
||||
for (const userId of syntheticUserIds(200)) {
|
||||
expect(resolveVoiceP2pAssignment(config, userId, NO_TARGETING).enabled).toBe(
|
||||
experimentBucket(userId, config.rollout_salt) < 2500,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVoiceP2pAssignment guild targeting', () => {
|
||||
const INCLUDED_GUILD_ID = '3000000000000000001';
|
||||
const MEMBER_GUILDS: ExperimentTargeting = {
|
||||
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
|
||||
premium: false,
|
||||
countryCode: null,
|
||||
};
|
||||
|
||||
test('serves members of an included guild at zero rollout', () => {
|
||||
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual(SELECTED);
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(UNSELECTED);
|
||||
});
|
||||
|
||||
test('keeps user exclusions ahead of guild membership', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
included_guild_ids: [INCLUDED_GUILD_ID],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual(UNSELECTED);
|
||||
});
|
||||
|
||||
test('serves no guild members while disabled', () => {
|
||||
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual(UNSELECTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVoiceP2pAssignment premium targeting', () => {
|
||||
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true, countryCode: null};
|
||||
|
||||
test('serves premium users only when the switch is on', () => {
|
||||
const on = createConfig({enabled: true, include_premium_users: true});
|
||||
expect(resolveVoiceP2pAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual(SELECTED);
|
||||
expect(resolveVoiceP2pAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual(UNSELECTED);
|
||||
expect(resolveVoiceP2pAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual(UNSELECTED);
|
||||
});
|
||||
|
||||
test('keeps user exclusions ahead of the premium switch', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual(UNSELECTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVoiceP2pAssignment country targeting', () => {
|
||||
const IN_SWEDEN: ExperimentTargeting = {...NO_TARGETING, countryCode: 'SE'};
|
||||
const IN_BRAZIL: ExperimentTargeting = {...NO_TARGETING, countryCode: 'BR'};
|
||||
|
||||
test('limits the percentage rollout to the listed countries', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 10000, rollout_country_codes: ['SE', 'NO']});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, IN_SWEDEN)).toEqual(SELECTED);
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, IN_BRAZIL)).toEqual(UNSELECTED);
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(UNSELECTED);
|
||||
});
|
||||
|
||||
test('ignores the country while the list is empty', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 10000});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, IN_BRAZIL)).toEqual(SELECTED);
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(SELECTED);
|
||||
});
|
||||
|
||||
test('still buckets users inside a listed country', () => {
|
||||
const config = createConfig({enabled: true, rollout_basis_points: 2500, rollout_country_codes: ['SE']});
|
||||
for (const userId of syntheticUserIds(200)) {
|
||||
expect(resolveVoiceP2pAssignment(config, userId, IN_SWEDEN).enabled).toBe(
|
||||
experimentBucket(userId, config.rollout_salt) < 2500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps included users, included guilds and premium users outside the listed countries', () => {
|
||||
const guildId = '3000000000000000001';
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
rollout_country_codes: ['SE'],
|
||||
included_user_ids: [TARGETED_USER_ID],
|
||||
included_guild_ids: [guildId],
|
||||
include_premium_users: true,
|
||||
});
|
||||
const otherUserId = '1000000000000000007';
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, IN_BRAZIL)).toEqual(SELECTED);
|
||||
expect(resolveVoiceP2pAssignment(config, otherUserId, {...IN_BRAZIL, memberGuildIds: new Set([guildId])})).toEqual(
|
||||
SELECTED,
|
||||
);
|
||||
expect(resolveVoiceP2pAssignment(config, otherUserId, {...IN_BRAZIL, premium: true})).toEqual(SELECTED);
|
||||
expect(resolveVoiceP2pAssignment(config, otherUserId, IN_BRAZIL)).toEqual(UNSELECTED);
|
||||
});
|
||||
|
||||
test('keeps exclusions ahead of a listed country', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
rollout_country_codes: ['SE'],
|
||||
excluded_user_ids: [TARGETED_USER_ID],
|
||||
});
|
||||
expect(resolveVoiceP2pAssignment(config, TARGETED_USER_ID, IN_SWEDEN)).toEqual(UNSELECTED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {VOICE_P2P_MAX_PARTICIPANTS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
EXPERIMENT_BUCKET_RESOLUTION,
|
||||
ExperimentRolloutCountryCodesSchema,
|
||||
type ExperimentTargeting,
|
||||
experimentAudienceIncludes,
|
||||
experimentBucket,
|
||||
experimentRolloutCountryIncludes,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {z} from 'zod';
|
||||
|
||||
const VOICE_P2P_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
|
||||
const VOICE_P2P_MAX_TARGETED_USERS = 1000;
|
||||
const DEFAULT_VOICE_P2P_SALT = 'voice-p2p-v1';
|
||||
const VOICE_P2P_MIN_PARTICIPANTS = 2;
|
||||
|
||||
const VOICE_P2P_SALT_PATTERN = /^[\x20-\x7e]+$/u;
|
||||
|
||||
const VoiceP2pTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
|
||||
const VoiceP2pTargetedUserIdsSchema = z.array(VoiceP2pTargetIdSchema).max(VOICE_P2P_MAX_TARGETED_USERS);
|
||||
|
||||
const voiceP2pConfigFields = {
|
||||
enabled: z.boolean(),
|
||||
config_version: z.number().int().min(0),
|
||||
rollout_basis_points: z.number().int().min(0).max(VOICE_P2P_ROLLOUT_BASIS_POINTS_MAX),
|
||||
rollout_country_codes: ExperimentRolloutCountryCodesSchema,
|
||||
rollout_salt: z.string().trim().min(1).max(64).regex(VOICE_P2P_SALT_PATTERN),
|
||||
included_user_ids: VoiceP2pTargetedUserIdsSchema,
|
||||
included_guild_ids: VoiceP2pTargetedUserIdsSchema,
|
||||
include_premium_users: z.boolean(),
|
||||
excluded_user_ids: VoiceP2pTargetedUserIdsSchema,
|
||||
max_participants: z.number().int().min(VOICE_P2P_MIN_PARTICIPANTS).max(VOICE_P2P_MAX_PARTICIPANTS),
|
||||
};
|
||||
|
||||
export const VoiceP2pConfigSchema = z.object({
|
||||
enabled: voiceP2pConfigFields.enabled.default(false),
|
||||
config_version: voiceP2pConfigFields.config_version.default(0),
|
||||
rollout_basis_points: voiceP2pConfigFields.rollout_basis_points.default(0),
|
||||
rollout_country_codes: voiceP2pConfigFields.rollout_country_codes.default([]),
|
||||
rollout_salt: voiceP2pConfigFields.rollout_salt.default(DEFAULT_VOICE_P2P_SALT),
|
||||
included_user_ids: voiceP2pConfigFields.included_user_ids.default([]),
|
||||
included_guild_ids: voiceP2pConfigFields.included_guild_ids.default([]),
|
||||
include_premium_users: voiceP2pConfigFields.include_premium_users.default(false),
|
||||
excluded_user_ids: voiceP2pConfigFields.excluded_user_ids.default([]),
|
||||
max_participants: voiceP2pConfigFields.max_participants.default(VOICE_P2P_MIN_PARTICIPANTS),
|
||||
});
|
||||
|
||||
export type VoiceP2pConfig = z.infer<typeof VoiceP2pConfigSchema>;
|
||||
|
||||
export const DEFAULT_VOICE_P2P_CONFIG: VoiceP2pConfig = VoiceP2pConfigSchema.parse({});
|
||||
|
||||
export const VoiceP2pConfigUpdateRequest = z.object(voiceP2pConfigFields).omit({config_version: true}).partial();
|
||||
|
||||
export type VoiceP2pConfigUpdateRequest = z.infer<typeof VoiceP2pConfigUpdateRequest>;
|
||||
|
||||
export const VoiceP2pConfigResponse = VoiceP2pConfigSchema;
|
||||
|
||||
export type VoiceP2pConfigResponse = z.infer<typeof VoiceP2pConfigResponse>;
|
||||
|
||||
export const VoiceP2pAssignmentResponse = z.object({
|
||||
enabled: voiceP2pConfigFields.enabled,
|
||||
max_participants: voiceP2pConfigFields.max_participants,
|
||||
});
|
||||
|
||||
export type VoiceP2pAssignmentResponse = z.infer<typeof VoiceP2pAssignmentResponse>;
|
||||
|
||||
export const INERT_VOICE_P2P_ASSIGNMENT: VoiceP2pAssignmentResponse = {
|
||||
enabled: false,
|
||||
max_participants: DEFAULT_VOICE_P2P_CONFIG.max_participants,
|
||||
};
|
||||
|
||||
export function resolveVoiceP2pAssignment(
|
||||
config: VoiceP2pConfig,
|
||||
userId: string,
|
||||
targeting: ExperimentTargeting,
|
||||
): VoiceP2pAssignmentResponse {
|
||||
return {enabled: voiceP2pSelects(config, userId, targeting), max_participants: config.max_participants};
|
||||
}
|
||||
|
||||
function voiceP2pSelects(config: VoiceP2pConfig, userId: string, targeting: ExperimentTargeting): boolean {
|
||||
if (!config.enabled) return false;
|
||||
if (config.excluded_user_ids.includes(userId)) return false;
|
||||
if (config.included_user_ids.includes(userId)) return true;
|
||||
if (experimentAudienceIncludes(config, targeting)) return true;
|
||||
if (!experimentRolloutCountryIncludes(config, targeting)) return false;
|
||||
return experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points;
|
||||
}
|
||||
@@ -144,6 +144,10 @@ const ChannelUpdateCommon = ChannelCommonBase.extend({
|
||||
.describe(
|
||||
`Voice region ID for the voice channel (${RTC_REGION_ID_MIN_LENGTH}-${RTC_REGION_ID_MAX_LENGTH} characters)`,
|
||||
),
|
||||
rtc_p2p: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether calls in the voice channel connect participants directly instead of through a voice server'),
|
||||
});
|
||||
const ChannelCreateTextRequest = ChannelCreateCommon.extend({
|
||||
type: createNamedLiteral(ChannelTypes.GUILD_TEXT, 'GUILD_TEXT', 'Channel type (text channel)'),
|
||||
|
||||
@@ -65,6 +65,10 @@ export const ChannelResponse = z.object({
|
||||
'The maximum active voice connections allowed per user in the voice channel',
|
||||
),
|
||||
rtc_region: z.string().nullish().describe('The voice region ID for the voice channel'),
|
||||
rtc_p2p: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether calls in the voice channel connect participants directly instead of through a voice server'),
|
||||
last_message_id: SnowflakeStringType.nullish().describe('The ID of the last message sent in this channel'),
|
||||
last_pin_timestamp: z.iso
|
||||
.datetime()
|
||||
@@ -160,6 +164,7 @@ export interface Channel {
|
||||
readonly user_limit?: number | null;
|
||||
readonly voice_connection_limit?: number | null;
|
||||
readonly rtc_region?: string | null;
|
||||
readonly rtc_p2p?: boolean;
|
||||
readonly last_message_id?: string | null;
|
||||
readonly last_pin_timestamp?: string | null;
|
||||
readonly permission_overwrites?: ReadonlyArray<ChannelOverwrite>;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {
|
||||
ExperimentRolloutCountryCodesSchema,
|
||||
type ExperimentTargeting,
|
||||
experimentBucket,
|
||||
experimentRolloutCountryIncludes,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const TARGETED_USER_ID = '1000000000000000001';
|
||||
@@ -41,3 +46,45 @@ describe('experimentBucket', () => {
|
||||
expect(changed.length).toBeGreaterThan(userIds.length - 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExperimentRolloutCountryCodesSchema', () => {
|
||||
test('accepts unique uppercase alpha-2 codes', () => {
|
||||
expect(ExperimentRolloutCountryCodesSchema.parse(['SE', 'BR'])).toEqual(['SE', 'BR']);
|
||||
expect(ExperimentRolloutCountryCodesSchema.parse([])).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{name: 'lowercase codes', value: ['se']},
|
||||
{name: 'alpha-3 codes', value: ['SWE']},
|
||||
{name: 'duplicates', value: ['SE', 'SE']},
|
||||
{
|
||||
name: 'more than 250 codes',
|
||||
value: Array.from(
|
||||
{length: 251},
|
||||
(_, index) => `${String.fromCharCode(65 + Math.floor(index / 26))}${String.fromCharCode(65 + (index % 26))}`,
|
||||
),
|
||||
},
|
||||
])('rejects $name', ({value}) => {
|
||||
expect(ExperimentRolloutCountryCodesSchema.safeParse(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('experimentRolloutCountryIncludes', () => {
|
||||
const targeting = (countryCode: string | null): ExperimentTargeting => ({
|
||||
memberGuildIds: new Set(),
|
||||
premium: false,
|
||||
countryCode,
|
||||
});
|
||||
|
||||
test('matches every country while the list is empty', () => {
|
||||
expect(experimentRolloutCountryIncludes({rollout_country_codes: []}, targeting('SE'))).toBe(true);
|
||||
expect(experimentRolloutCountryIncludes({rollout_country_codes: []}, targeting(null))).toBe(true);
|
||||
});
|
||||
|
||||
test('matches only listed countries and never an unknown country', () => {
|
||||
const rules = {rollout_country_codes: ['SE', 'NO']};
|
||||
expect(experimentRolloutCountryIncludes(rules, targeting('NO'))).toBe(true);
|
||||
expect(experimentRolloutCountryIncludes(rules, targeting('BR'))).toBe(false);
|
||||
expect(experimentRolloutCountryIncludes(rules, targeting(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {z} from 'zod';
|
||||
|
||||
const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
|
||||
const FNV_PRIME_32 = 0x01000193;
|
||||
|
||||
export const EXPERIMENT_BUCKET_RESOLUTION = 10000;
|
||||
const EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES = 250;
|
||||
|
||||
export const ExperimentRolloutCountryCodesSchema = z
|
||||
.array(z.string().regex(/^[A-Z]{2}$/u))
|
||||
.max(EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES)
|
||||
.refine((countryCodes) => new Set(countryCodes).size === countryCodes.length, {
|
||||
message: 'Country codes must be unique',
|
||||
});
|
||||
|
||||
export function experimentBucket(userId: string, salt: string): number {
|
||||
let hash = FNV_OFFSET_BASIS_32;
|
||||
@@ -18,6 +28,7 @@ export function experimentBucket(userId: string, salt: string): number {
|
||||
export interface ExperimentTargeting {
|
||||
readonly memberGuildIds: ReadonlySet<string>;
|
||||
readonly premium: boolean;
|
||||
readonly countryCode: string | null;
|
||||
}
|
||||
|
||||
interface ExperimentAudienceRules {
|
||||
@@ -29,3 +40,15 @@ export function experimentAudienceIncludes(rules: ExperimentAudienceRules, targe
|
||||
if (rules.include_premium_users && targeting.premium) return true;
|
||||
return rules.included_guild_ids.some((guildId) => targeting.memberGuildIds.has(guildId));
|
||||
}
|
||||
|
||||
interface ExperimentRolloutCountryRules {
|
||||
readonly rollout_country_codes: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export function experimentRolloutCountryIncludes(
|
||||
rules: ExperimentRolloutCountryRules,
|
||||
targeting: ExperimentTargeting,
|
||||
): boolean {
|
||||
if (rules.rollout_country_codes.length === 0) return true;
|
||||
return targeting.countryCode !== null && rules.rollout_country_codes.includes(targeting.countryCode);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
INERT_PLUTONIUM_PAGE_ASSIGNMENT,
|
||||
PlutoniumPageAssignmentResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
|
||||
import {INERT_VOICE_P2P_ASSIGNMENT, VoiceP2pAssignmentResponse} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS = 60;
|
||||
@@ -49,6 +50,7 @@ const ExperimentAssignmentsSchema = z.object({
|
||||
domain_migration: DomainMigrationAssignmentResponse.optional(),
|
||||
channel_threads: ChannelThreadsAssignmentResponse.optional(),
|
||||
plutonium_page: PlutoniumPageAssignmentResponse.optional(),
|
||||
voice_p2p: VoiceP2pAssignmentResponse.optional(),
|
||||
});
|
||||
|
||||
export const ExperimentAssignmentsResponse = z.object({
|
||||
@@ -80,3 +82,7 @@ export function readChannelThreadsAssignment(
|
||||
export function readPlutoniumPageAssignment(response: ExperimentAssignmentsResponse): PlutoniumPageAssignmentResponse {
|
||||
return response.assignments.plutonium_page ?? INERT_PLUTONIUM_PAGE_ASSIGNMENT;
|
||||
}
|
||||
|
||||
export function readVoiceP2pAssignment(response: ExperimentAssignmentsResponse): VoiceP2pAssignmentResponse {
|
||||
return response.assignments.voice_p2p ?? INERT_VOICE_P2P_ASSIGNMENT;
|
||||
}
|
||||
@@ -25,6 +25,12 @@ export const VoiceStateResponse = z.object({
|
||||
.optional()
|
||||
.describe('The stream keys the user is currently viewing'),
|
||||
version: Int32Type.optional().describe('The voice state version for ordering updates'),
|
||||
p2p: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether this connection exchanges media peer-to-peer. A channel or call is peer-to-peer iff it has at least one voice state and every voice state in it has p2p=true.',
|
||||
),
|
||||
e2ee_capable: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {RTC_REGION_ID_MAX_LENGTH, RTC_REGION_ID_MIN_LENGTH} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
RTC_REGION_ID_MAX_LENGTH,
|
||||
RTC_REGION_ID_MIN_LENGTH,
|
||||
VOICE_P2P_MAX_PARTICIPANTS,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {FORUM_UNREAD_COUNT_CAP, FORUM_UNREADS_MAX_THREADS} from '@fluxer/constants/src/ThreadConstants';
|
||||
import {ChannelThreadsConfigResponse} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {GatewayRolloutConfigResponse} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
@@ -156,6 +160,20 @@ export const RpcRequest = z.discriminatedUnion('type', [
|
||||
can_stream: z.boolean().optional().describe('Whether the user can stream in the channel'),
|
||||
can_video: z.boolean().optional().describe('Whether the user can use video in the channel'),
|
||||
token_nonce: createStringType(1, 64).optional().describe('Token nonce for replay prevention'),
|
||||
p2p: z.boolean().optional().describe('Whether the gateway asks for a peer-to-peer grant for this connection'),
|
||||
p2p_initiator: z.boolean().optional().describe('Whether this connection would start the peer-to-peer call'),
|
||||
p2p_participant_count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(VOICE_P2P_MAX_PARTICIPANTS)
|
||||
.optional()
|
||||
.describe('Peer-to-peer participants in the channel or call once this connection joins'),
|
||||
country_code: z
|
||||
.string()
|
||||
.regex(/^[A-Z]{2}$/u)
|
||||
.optional()
|
||||
.describe('ISO 3166-1 alpha-2 country code of the session, omitted when unknown'),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('kick_temporary_member').describe('Request type for kicking temporary guild members'),
|
||||
@@ -365,6 +383,10 @@ export const RpcResponseSessionData = z.object({
|
||||
guilds: z.array(GuildReadyResponse).describe('Guilds the user is a member of'),
|
||||
pinned_dms: z.array(SnowflakeStringType).describe('IDs of pinned DM channels'),
|
||||
country_code: z.string().describe('Two-letter country code from IP geolocation'),
|
||||
geoip_country_code: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Two-letter country code from IP geolocation, null when the lookup found none'),
|
||||
latitude: createStringType(1, 32).optional().describe('Latitude from IP geolocation'),
|
||||
longitude: createStringType(1, 32).optional().describe('Longitude from IP geolocation'),
|
||||
rtc_regions: z.array(RtcRegionResponse).describe('Available voice server regions'),
|
||||
@@ -481,14 +503,35 @@ export const RpcResponse = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('voice_get_token').describe('Response type for voice connection token'),
|
||||
data: z
|
||||
.object({
|
||||
token: z.string().describe('Voice server authentication token'),
|
||||
endpoint: z.string().describe('Voice server endpoint URL'),
|
||||
connectionId: z.string().describe('Unique connection identifier'),
|
||||
tokenNonce: z.string().describe('Token nonce for webhook confirmation'),
|
||||
regionId: createStringType(1, 64).optional().describe('Voice region selected for the connection'),
|
||||
serverId: createStringType(1, 128).optional().describe('Voice server selected for the connection'),
|
||||
})
|
||||
.union([
|
||||
z.object({
|
||||
token: z.string().describe('Voice server authentication token'),
|
||||
endpoint: z.string().describe('Voice server endpoint URL'),
|
||||
connectionId: z.string().describe('Unique connection identifier'),
|
||||
tokenNonce: z.string().describe('Token nonce for webhook confirmation'),
|
||||
regionId: createStringType(1, 64).optional().describe('Voice region selected for the connection'),
|
||||
serverId: createStringType(1, 128).optional().describe('Voice server selected for the connection'),
|
||||
}),
|
||||
z.object({
|
||||
p2p: z.literal(true).describe('Marks a peer-to-peer grant'),
|
||||
connectionId: z.string().describe('Unique connection identifier'),
|
||||
iceServers: z
|
||||
.array(z.object({urls: z.array(z.string()).describe('STUN URLs of the server')}))
|
||||
.describe('ICE servers the connection uses for its peer connections'),
|
||||
maxParticipants: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(VOICE_P2P_MAX_PARTICIPANTS)
|
||||
.describe('Configured peer-to-peer participant cap the call enforces on this join'),
|
||||
}),
|
||||
z.object({
|
||||
p2p: z.literal(false).describe('Marks a declined peer-to-peer request'),
|
||||
declineReason: z
|
||||
.literal('channel_full')
|
||||
.describe('The join would take the call past the configured peer-to-peer participant cap'),
|
||||
}),
|
||||
])
|
||||
.describe('Voice connection credentials'),
|
||||
}),
|
||||
z.object({
|
||||
|
||||
@@ -116,7 +116,14 @@ describe('channel update request body', () => {
|
||||
});
|
||||
|
||||
it('preserves guild-only fields in the documented body', () => {
|
||||
const input = {topic: ' Topic ', parent_id: '123', nsfw_override: false, bitrate: 64000, rtc_region: null};
|
||||
const input = {
|
||||
topic: ' Topic ',
|
||||
parent_id: '123',
|
||||
nsfw_override: false,
|
||||
bitrate: 64000,
|
||||
rtc_region: null,
|
||||
rtc_p2p: true,
|
||||
};
|
||||
expect(ChannelUpdateRequestBody.parse(input)).toEqual({...input, topic: 'Topic', parent_id: 123n});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {VOICE_P2P_MAX_PARTICIPANTS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {createNamedStringLiteralUnion, Int32Type, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {z} from 'zod';
|
||||
|
||||
const VOICE_P2P_CONNECTION_REPORTS_MAX = 8;
|
||||
|
||||
const VoiceP2pCandidateType = createNamedStringLiteralUnion(
|
||||
[
|
||||
['host', 'host', 'A local interface address'],
|
||||
['srflx', 'srflx', 'A public address a STUN server reflected'],
|
||||
['prflx', 'prflx', 'A public address learned from a peer during checks'],
|
||||
['relay', 'relay', 'A relayed address'],
|
||||
],
|
||||
'ICE candidate type of one end of the selected candidate pair',
|
||||
);
|
||||
|
||||
const VoiceP2pConnectionReport = z.object({
|
||||
channel_id: SnowflakeType.describe('The voice channel or private channel of the call'),
|
||||
guild_id: SnowflakeType.nullable().describe('The guild of the voice channel, null for a call'),
|
||||
participant_count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(2)
|
||||
.max(VOICE_P2P_MAX_PARTICIPANTS)
|
||||
.describe('Participants in the call when the peer connection settled'),
|
||||
outcome: createNamedStringLiteralUnion(
|
||||
[
|
||||
['connected', 'connected', 'The peer connection reached connected'],
|
||||
['failed', 'failed', 'The peer connection failed'],
|
||||
],
|
||||
'How the peer connection settled',
|
||||
),
|
||||
local_candidate_type: VoiceP2pCandidateType.nullable().describe('Local candidate type, null without a selected pair'),
|
||||
remote_candidate_type: VoiceP2pCandidateType.nullable().describe(
|
||||
'Remote candidate type, null without a selected pair',
|
||||
),
|
||||
ip_family: createNamedStringLiteralUnion(
|
||||
[
|
||||
['ipv4', 'ipv4', 'IPv4'],
|
||||
['ipv6', 'ipv6', 'IPv6'],
|
||||
],
|
||||
'Address family of the selected pair',
|
||||
)
|
||||
.nullable()
|
||||
.describe('Address family of the selected pair, null without one'),
|
||||
protocol: createNamedStringLiteralUnion(
|
||||
[
|
||||
['udp', 'udp', 'UDP'],
|
||||
['tcp', 'tcp', 'TCP'],
|
||||
],
|
||||
'Transport protocol of the selected pair',
|
||||
)
|
||||
.nullable()
|
||||
.describe('Transport protocol of the selected pair, null without one'),
|
||||
setup_ms: Int32Type.nullable().describe('Milliseconds from peer connection creation to first connected'),
|
||||
ice_restarted: z.boolean().describe('Whether the peer connection ran an ICE restart'),
|
||||
});
|
||||
|
||||
export const VoiceP2pConnectionReportsRequest = z.object({
|
||||
reports: z
|
||||
.array(VoiceP2pConnectionReport)
|
||||
.min(1)
|
||||
.max(VOICE_P2P_CONNECTION_REPORTS_MAX)
|
||||
.describe('One report for each remote peer that settled'),
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
|
||||
* Describes the file fluxer/user/preferences/v1/preferences.proto.
|
||||
*/
|
||||
export const file_fluxer_user_preferences_v1_preferences: GenFile = /*@__PURE__*/
|
||||
fileDesc("CixmbHV4ZXIvdXNlci9wcmVmZXJlbmNlcy92MS9wcmVmZXJlbmNlcy5wcm90bxIaZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEiwRYKEVN5bmNlZFByZWZlcmVuY2VzEkgKDWFjY2Vzc2liaWxpdHkYASABKAsyMS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5U2V0dGluZ3MSUwoXYWNjZXNzaWJpbGl0eV9vdmVycmlkZXMYAiABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5T3ZlcnJpZGVzEksKD3RleHR1YWxfcHJldmlldxgDIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlRleHR1YWxQcmV2aWV3U2V0dGluZ3MSQgoMZW1vamlfcGlja2VyGBQgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlQaWNrZXJTdGF0ZRJGCg5zdGlja2VyX3BpY2tlchgVIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN0aWNrZXJQaWNrZXJTdGF0ZRJCCgxtZW1lc19waWNrZXIYFiABKAsyLC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW1lc1BpY2tlclN0YXRlEjUKBWVtb2ppGBcgASgLMiYuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlTdGF0ZRJUChRlbW9qaV9zdGlja2VyX2xheW91dBgYIAEoCzI2LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkVtb2ppU3RpY2tlckxheW91dFNldHRpbmdzEkYKDWZhdm9yaXRlX2dpZnMYGSABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5GYXZvcml0ZUdpZlNldHRpbmdzEj0KCWZhdm9yaXRlcxgoIAEoCzIqLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkZhdm9yaXRlc1N0YXRlEksKD3JlY2VudF9tZW50aW9ucxgpIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlJlY2VudE1lbnRpb25zU2V0dGluZ3MSPwoHc2lkZWJhchgqIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlNpZGViYXJQcmVmZXJlbmNlcxJACgttZW1iZXJfbGlzdBgrIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLk1lbWJlckxpc3RTdGF0ZRJICg91bnJlYWRfY2hhbm5lbHMYLCABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5VbnJlYWRDaGFubmVsc1N0YXRlEkoKEG1lbnRpb25fZnJlY2VuY3kYLSABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW50aW9uRnJlY2VuY3lTdGF0ZRI9CgduYWdiYXJzGDwgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuTmFnYmFyRGlzbWlzc2FscxJHChFkaXNtaXNzZWRfdXBzZWxscxg9IAEoCzIsLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkRpc21pc3NlZFVwc2VsbHMSTgoVZ3VpbGRfbnNmd19hZ3JlZW1lbnRzGD4gASgLMi8uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGROc2Z3QWdyZWVtZW50cxI8Cgl3aGF0c19uZXcYPyABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5XaGF0c05ld1N0YXRlEj8KB3ByaXZhY3kYUCABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Qcml2YWN5UHJlZmVyZW5jZXMSUAoUbG9jYWxfc3BhbV9vdmVycmlkZXMYUSABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Mb2NhbFVzZXJTcGFtT3ZlcnJpZGVzEhUKDXNhbml0aXplX3VybHMYUiABKAgSOAoFc291bmQYZCABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Tb3VuZFNldHRpbmdzEkIKCnNwZWxsY2hlY2sYZSABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TcGVsbGNoZWNrU2V0dGluZ3MSSAoOc2VhcmNoX2VuZ2luZXMYZiABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TZWFyY2hFbmdpbmVTZXR0aW5ncxJPChFwZXJtaXNzaW9uX2xheW91dBhnIAEoCzI0LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlBlcm1pc3Npb25MYXlvdXRTZXR0aW5ncxJSChNndWlsZF9tZW1iZXJfbGF5b3V0GGggASgLMjUuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRNZW1iZXJMYXlvdXRTZXR0aW5ncxJLCg1ndWlsZF9mb2xkZXJzGGkgASgLMjQuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRGb2xkZXJFeHBhbmRlZFN0YXRlElAKFGhpZGRlbl9ndWlsZF9idXR0b25zGGogASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSGlkZGVuR3VpbGRMaXN0QnV0dG9ucxJPChNrZXlib2FyZF9tb2RlX2ludHJvGGsgASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuS2V5Ym9hcmRNb2RlSW50cm9TdGF0ZRJRChBpbnB1dF9tb25pdG9yaW5nGGwgASgLMjcuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSW5wdXRNb25pdG9yaW5nUHJvbXB0c1N0YXRlEkQKDXZvaWNlX3Byb21wdHMYbSABKAsyLS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Wb2ljZVByb21wdHNTdGF0ZRJACgtzdWRvX3Byb21wdBhuIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN1ZG9Qcm9tcHRTdGF0ZRI9CghrZXliaW5kcxhvIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLktleWJpbmRTZXR0aW5ncxJBCgpjaGF0X2lucHV0GHAgASgLMi0uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhdElucHV0U2V0dGluZ3MSKgodc2F2ZV9jYW1lcmFfdXBsb2Fkc190b19kZXZpY2UYcSABKAhIAIgBARJGChNkb3VibGVfdGFwX3JlYWN0aW9uGHIgASgLMikuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuUmVhY3Rpb25FbW9qaRJKChBjaGFubmVsX2ZyZWNlbmN5GHMgASgLMjAuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhbm5lbEZyZWNlbmN5U3RhdGUSUgoUYW5ub3VuY2VtZW50X3Byb21wdHMYdCABKAsyNC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Bbm5vdW5jZW1lbnRQcm9tcHRzU3RhdGUSRgoRZG91YmxlX3RhcF9hY3Rpb24YdSABKA4yKy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Eb3VibGVUYXBBY3Rpb25CIAoeX3NhdmVfY2FtZXJhX3VwbG9hZHNfdG9fZGV2aWNlIrABChJTcGVsbGNoZWNrU2V0dGluZ3MSFAoHZW5hYmxlZBgBIAEoCEgAiAEBEhEKCWxhbmd1YWdlcxgCIAMoCRIbChNwZXJzb25hbF9kaWN0aW9uYXJ5GAMgAygJEhgKC2F1dG9fZGV0ZWN0GAQgASgISAGIAQESEwoGZW5naW5lGAUgASgJSAKIAQFCCgoIX2VuYWJsZWRCDgoMX2F1dG9fZGV0ZWN0QgkKB19lbmdpbmUi5gEKFFNlYXJjaEVuZ2luZVNldHRpbmdzEiIKFXRleHRfc2VhcmNoX2VuZ2luZV9pZBgBIAEoCUgAiAEBEisKHnJldmVyc2VfaW1hZ2Vfc2VhcmNoX2VuZ2luZV9pZBgCIAEoCUgBiAEBEiQKF3RyYW5zbGF0aW9uX3Byb3ZpZGVyX2lkGAMgASgJSAKIAQFCGAoWX3RleHRfc2VhcmNoX2VuZ2luZV9pZEIhCh9fcmV2ZXJzZV9pbWFnZV9zZWFyY2hfZW5naW5lX2lkQhoKGF90cmFuc2xhdGlvbl9wcm92aWRlcl9pZCK1AQoSUHJpdmFjeVByZWZlcmVuY2VzEh8KF2Rpc2FibGVfc3RyZWFtX3ByZXZpZXdzGAEgASgIEhwKD3Nob3dfYWN0aXZlX25vdxgCIAEoCEgAiAEBEioKHXByZXVwbG9hZF9tZXNzYWdlX2F0dGFjaG1lbnRzGAMgASgISAGIAQFCEgoQX3Nob3dfYWN0aXZlX25vd0IgCh5fcHJldXBsb2FkX21lc3NhZ2VfYXR0YWNobWVudHMiUAoWTG9jYWxVc2VyU3BhbU92ZXJyaWRlcxIYChBzcGFtbWVyX3VzZXJfaWRzGAEgAygJEhwKFG5vdF9zcGFtbWVyX3VzZXJfaWRzGAIgAygJIisKFlRleHR1YWxQcmV2aWV3U2V0dGluZ3MSEQoJd3JhcF90ZXh0GAEgASgIItwBChJTaWRlYmFyUHJlZmVyZW5jZXMSHAoUaW5saW5lX2Rtc19jb2xsYXBzZWQYASABKAgSLAofc2hLine truncated
|
||||
fileDesc("CixmbHV4ZXIvdXNlci9wcmVmZXJlbmNlcy92MS9wcmVmZXJlbmNlcy5wcm90bxIaZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEiwRYKEVN5bmNlZFByZWZlcmVuY2VzEkgKDWFjY2Vzc2liaWxpdHkYASABKAsyMS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5U2V0dGluZ3MSUwoXYWNjZXNzaWJpbGl0eV9vdmVycmlkZXMYAiABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5T3ZlcnJpZGVzEksKD3RleHR1YWxfcHJldmlldxgDIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlRleHR1YWxQcmV2aWV3U2V0dGluZ3MSQgoMZW1vamlfcGlja2VyGBQgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlQaWNrZXJTdGF0ZRJGCg5zdGlja2VyX3BpY2tlchgVIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN0aWNrZXJQaWNrZXJTdGF0ZRJCCgxtZW1lc19waWNrZXIYFiABKAsyLC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW1lc1BpY2tlclN0YXRlEjUKBWVtb2ppGBcgASgLMiYuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlTdGF0ZRJUChRlbW9qaV9zdGlja2VyX2xheW91dBgYIAEoCzI2LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkVtb2ppU3RpY2tlckxheW91dFNldHRpbmdzEkYKDWZhdm9yaXRlX2dpZnMYGSABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5GYXZvcml0ZUdpZlNldHRpbmdzEj0KCWZhdm9yaXRlcxgoIAEoCzIqLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkZhdm9yaXRlc1N0YXRlEksKD3JlY2VudF9tZW50aW9ucxgpIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlJlY2VudE1lbnRpb25zU2V0dGluZ3MSPwoHc2lkZWJhchgqIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlNpZGViYXJQcmVmZXJlbmNlcxJACgttZW1iZXJfbGlzdBgrIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLk1lbWJlckxpc3RTdGF0ZRJICg91bnJlYWRfY2hhbm5lbHMYLCABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5VbnJlYWRDaGFubmVsc1N0YXRlEkoKEG1lbnRpb25fZnJlY2VuY3kYLSABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW50aW9uRnJlY2VuY3lTdGF0ZRI9CgduYWdiYXJzGDwgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuTmFnYmFyRGlzbWlzc2FscxJHChFkaXNtaXNzZWRfdXBzZWxscxg9IAEoCzIsLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkRpc21pc3NlZFVwc2VsbHMSTgoVZ3VpbGRfbnNmd19hZ3JlZW1lbnRzGD4gASgLMi8uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGROc2Z3QWdyZWVtZW50cxI8Cgl3aGF0c19uZXcYPyABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5XaGF0c05ld1N0YXRlEj8KB3ByaXZhY3kYUCABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Qcml2YWN5UHJlZmVyZW5jZXMSUAoUbG9jYWxfc3BhbV9vdmVycmlkZXMYUSABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Mb2NhbFVzZXJTcGFtT3ZlcnJpZGVzEhUKDXNhbml0aXplX3VybHMYUiABKAgSOAoFc291bmQYZCABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Tb3VuZFNldHRpbmdzEkIKCnNwZWxsY2hlY2sYZSABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TcGVsbGNoZWNrU2V0dGluZ3MSSAoOc2VhcmNoX2VuZ2luZXMYZiABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TZWFyY2hFbmdpbmVTZXR0aW5ncxJPChFwZXJtaXNzaW9uX2xheW91dBhnIAEoCzI0LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlBlcm1pc3Npb25MYXlvdXRTZXR0aW5ncxJSChNndWlsZF9tZW1iZXJfbGF5b3V0GGggASgLMjUuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRNZW1iZXJMYXlvdXRTZXR0aW5ncxJLCg1ndWlsZF9mb2xkZXJzGGkgASgLMjQuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRGb2xkZXJFeHBhbmRlZFN0YXRlElAKFGhpZGRlbl9ndWlsZF9idXR0b25zGGogASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSGlkZGVuR3VpbGRMaXN0QnV0dG9ucxJPChNrZXlib2FyZF9tb2RlX2ludHJvGGsgASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuS2V5Ym9hcmRNb2RlSW50cm9TdGF0ZRJRChBpbnB1dF9tb25pdG9yaW5nGGwgASgLMjcuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSW5wdXRNb25pdG9yaW5nUHJvbXB0c1N0YXRlEkQKDXZvaWNlX3Byb21wdHMYbSABKAsyLS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Wb2ljZVByb21wdHNTdGF0ZRJACgtzdWRvX3Byb21wdBhuIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN1ZG9Qcm9tcHRTdGF0ZRI9CghrZXliaW5kcxhvIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLktleWJpbmRTZXR0aW5ncxJBCgpjaGF0X2lucHV0GHAgASgLMi0uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhdElucHV0U2V0dGluZ3MSKgodc2F2ZV9jYW1lcmFfdXBsb2Fkc190b19kZXZpY2UYcSABKAhIAIgBARJGChNkb3VibGVfdGFwX3JlYWN0aW9uGHIgASgLMikuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuUmVhY3Rpb25FbW9qaRJKChBjaGFubmVsX2ZyZWNlbmN5GHMgASgLMjAuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhbm5lbEZyZWNlbmN5U3RhdGUSUgoUYW5ub3VuY2VtZW50X3Byb21wdHMYdCABKAsyNC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Bbm5vdW5jZW1lbnRQcm9tcHRzU3RhdGUSRgoRZG91YmxlX3RhcF9hY3Rpb24YdSABKA4yKy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Eb3VibGVUYXBBY3Rpb25CIAoeX3NhdmVfY2FtZXJhX3VwbG9hZHNfdG9fZGV2aWNlIrABChJTcGVsbGNoZWNrU2V0dGluZ3MSFAoHZW5hYmxlZBgBIAEoCEgAiAEBEhEKCWxhbmd1YWdlcxgCIAMoCRIbChNwZXJzb25hbF9kaWN0aW9uYXJ5GAMgAygJEhgKC2F1dG9fZGV0ZWN0GAQgASgISAGIAQESEwoGZW5naW5lGAUgASgJSAKIAQFCCgoIX2VuYWJsZWRCDgoMX2F1dG9fZGV0ZWN0QgkKB19lbmdpbmUi5gEKFFNlYXJjaEVuZ2luZVNldHRpbmdzEiIKFXRleHRfc2VhcmNoX2VuZ2luZV9pZBgBIAEoCUgAiAEBEisKHnJldmVyc2VfaW1hZ2Vfc2VhcmNoX2VuZ2luZV9pZBgCIAEoCUgBiAEBEiQKF3RyYW5zbGF0aW9uX3Byb3ZpZGVyX2lkGAMgASgJSAKIAQFCGAoWX3RleHRfc2VhcmNoX2VuZ2luZV9pZEIhCh9fcmV2ZXJzZV9pbWFnZV9zZWFyY2hfZW5naW5lX2lkQhoKGF90cmFuc2xhdGlvbl9wcm92aWRlcl9pZCK1AQoSUHJpdmFjeVByZWZlcmVuY2VzEh8KF2Rpc2FibGVfc3RyZWFtX3ByZXZpZXdzGAEgASgIEhwKD3Nob3dfYWN0aXZlX25vdxgCIAEoCEgAiAEBEioKHXByZXVwbG9hZF9tZXNzYWdlX2F0dGFjaG1lbnRzGAMgASgISAGIAQFCEgoQX3Nob3dfYWN0aXZlX25vd0IgCh5fcHJldXBsb2FkX21lc3NhZ2VfYXR0YWNobWVudHMiUAoWTG9jYWxVc2VyU3BhbU92ZXJyaWRlcxIYChBzcGFtbWVyX3VzZXJfaWRzGAEgAygJEhwKFG5vdF9zcGFtbWVyX3VzZXJfaWRzGAIgAygJIisKFlRleHR1YWxQcmV2aWV3U2V0dGluZ3MSEQoJd3JhcF90ZXh0GAEgASgIItwBChJTaWRlYmFyUHJlZmVyZW5jZXMSHAoUaW5saW5lX2Rtc19jb2xsYXBzZWQYASABKAgSLAofc2hLine truncated
|
||||
|
||||
/**
|
||||
* @generated from message fluxer.user.preferences.v1.SyncedPreferences
|
||||
@@ -892,6 +892,11 @@ export type VoicePromptsState = Message<"fluxer.user.preferences.v1.VoicePrompts
|
||||
* @generated from field: bool skip_hide_own_screenshare_confirm = 2;
|
||||
*/
|
||||
skipHideOwnScreenshareConfirm: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: bool skip_p2p_join_confirm = 3;
|
||||
*/
|
||||
skipP2pJoinConfirm: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user