feat(experiments): target rollouts by guild and premium status (#3012)

This commit is contained in:
Hampus
2026-09-28 14:34:19 +02:00
committed by GitHub
parent 564c5ae164
commit eaee820216
22 changed files with 803 additions and 65 deletions
+60
View File
@@ -15210,6 +15210,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15228,6 +15234,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15249,6 +15261,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15277,6 +15295,12 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
@@ -15343,6 +15367,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15356,6 +15387,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids"
],
"additionalProperties": false
@@ -15379,6 +15412,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15395,6 +15435,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_enabled",
"cost",
@@ -15421,6 +15463,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15436,6 +15485,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
@@ -15482,6 +15533,13 @@
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15513,6 +15571,8 @@
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"guild_overrides",
"suppression_strength"
@@ -503,6 +503,8 @@ pub struct VoiceNoiseSuppressionConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
@@ -519,6 +521,8 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
@@ -543,6 +547,10 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
@@ -572,6 +580,8 @@ pub struct DomainMigrationConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
@@ -585,6 +595,8 @@ impl Default for DomainMigrationConfigResponse {
rollout_basis_points: 0,
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
@@ -603,6 +615,10 @@ pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_rollout_basis_points: Option<u32>,
@@ -618,6 +634,8 @@ pub struct AltchaCaptchaConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_enabled: bool,
pub cost: u32,
@@ -632,6 +650,8 @@ impl Default for AltchaCaptchaConfigResponse {
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
@@ -651,6 +671,10 @@ pub struct AltchaCaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_enabled: Option<bool>,
@@ -668,6 +692,8 @@ pub struct ProfileTimezoneConfigResponse {
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
@@ -679,6 +705,8 @@ impl Default for ProfileTimezoneConfigResponse {
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
@@ -695,6 +723,10 @@ pub struct ProfileTimezoneConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
+64 -1
View File
@@ -643,6 +643,12 @@ fn build_voice_noise_suppression_update(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("voice_ns_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
@@ -693,6 +699,12 @@ fn build_domain_migration_update(
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
@@ -730,6 +742,12 @@ fn build_altcha_captcha_update(
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("altcha_captcha_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("altcha_captcha_excluded_user_ids")
.unwrap_or_default(),
@@ -777,6 +795,12 @@ fn build_profile_timezone_update(
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("profile_timezone_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("profile_timezone_excluded_user_ids")
.unwrap_or_default(),
@@ -1431,6 +1455,8 @@ mod tests {
"allow_user_override": false,
"enabled_backends": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"guild_overrides": [],
}})
@@ -1747,6 +1773,8 @@ mod tests {
serde_json::json!({"domain_migration": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
@@ -1844,6 +1872,8 @@ mod tests {
serde_json::json!({"altcha_captcha": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"anonymous_enabled": false,
}})
@@ -1877,7 +1907,7 @@ mod tests {
#[test]
fn build_profile_timezone_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002",
b"profile_timezone_enabled=true&profile_timezone_rollout_basis_points=%20500%20&profile_timezone_rollout_salt=%20profile-timezone-v2%20&profile_timezone_included_user_ids=1500000000000000001&profile_timezone_excluded_user_ids=1500000000000000002&profile_timezone_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&profile_timezone_include_premium_users=true",
);
let update = build_profile_timezone_update(&form)
.expect("valid form")
@@ -1886,6 +1916,14 @@ mod tests {
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("profile-timezone-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
@@ -1905,11 +1943,36 @@ mod tests {
serde_json::json!({"profile_timezone": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn every_experiment_update_rejects_an_invalid_included_guild_id() {
for (prefix, build) in [
(
"voice_ns",
build_voice_noise_suppression_update
as fn(&MultiValueForm) -> Result<InstanceConfigUpdateRequest, String>,
),
("domain_migration", build_domain_migration_update),
("altcha_captcha", build_altcha_captcha_update),
("profile_timezone", build_profile_timezone_update),
] {
let form = MultiValueForm::parse(
format!("{prefix}_included_guild_ids=1500000000000000005%0Anot-a-guild").as_bytes(),
);
assert_eq!(
build(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
"{prefix}"
);
}
}
#[test]
fn build_profile_timezone_update_rejects_a_rollout_above_everybody() {
let form = MultiValueForm::parse(b"profile_timezone_rollout_basis_points=10001");
@@ -1127,6 +1127,38 @@ fn voice_noise_suppression_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_ns_include_premium_users",
"true",
"Include premium users",
voice_noise_suppression.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_noise_suppression.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
@@ -1363,6 +1395,38 @@ fn domain_migration_section(
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"domain_migration_include_premium_users",
"true",
"Include premium users",
domain_migration.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&domain_migration.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
domain_migration.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_excluded_user_ids",
@@ -1484,6 +1548,38 @@ fn altcha_captcha_section(
regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"altcha_captcha_include_premium_users",
"true",
"Include premium users",
altcha_captcha.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&altcha_captcha.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
altcha_captcha.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_excluded_user_ids",
@@ -1609,6 +1705,38 @@ fn profile_timezone_section(
timezone regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"profile_timezone_include_premium_users",
"true",
"Include premium users",
profile_timezone.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&profile_timezone.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
profile_timezone.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_excluded_user_ids",
+11
View File
@@ -403,6 +403,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
@@ -421,6 +423,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"future_migration_knob": 9,
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
@@ -435,6 +439,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"anonymous_enabled": true,
"cost": 5000,
"max_counter": 10000,
"included_guild_ids": [],
"include_premium_users": false,
"future_altcha_knob": "argon2id"
},
"profile_timezone": {
@@ -444,6 +450,8 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"rollout_salt": "profile-timezone-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_profile_timezone_knob": true
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
@@ -591,6 +599,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert!(resp.profile_timezone.enabled);
assert_eq!(resp.profile_timezone.config_version, 2);
assert_eq!(resp.profile_timezone.included_user_ids.len(), 1);
assert_eq!(resp.profile_timezone.included_guild_ids.len(), 1);
assert!(resp.profile_timezone.include_premium_users);
assert!(resp.voice_noise_suppression.include_premium_users);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -39,15 +40,22 @@ export function ExperimentController(app: HonoApp) {
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getProfileTimezoneConfig(),
]);
const userId = ctx.get('user').id.toString();
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [
voiceConfig,
domainMigrationConfig,
altchaCaptchaConfig,
profileTimezoneConfig,
]);
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId),
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId),
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId, targeting),
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
profile_timezone: resolveProfileTimezoneAssignment(profileTimezoneConfig, userId, targeting),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import type {ExperimentTargeting} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
interface TargetableExperimentConfig {
readonly enabled: boolean;
readonly included_guild_ids: ReadonlyArray<string>;
}
const NO_GUILDS: ReadonlySet<string> = new Set();
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
memberGuildIds: NO_GUILDS,
premium: false,
};
export async function resolveExperimentTargeting(
user: User,
configs: ReadonlyArray<TargetableExperimentConfig>,
): Promise<ExperimentTargeting> {
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()};
}
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
import {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, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
@@ -250,6 +253,109 @@ describe('GET /experiments', () => {
expect(body.assignments.profile_timezone).toEqual({enabled: true});
});
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);
const guild = await createGuild(harness, owner.token, 'Experiment Guild');
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);
const repository = getInstanceConfigRepository();
await repository.setVoiceNoiseSuppressionConfig({
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: true, source: 'user_rule'});
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
expect(memberBody.assignments.profile_timezone).toEqual({enabled: true});
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.voice_noise_suppression).toMatchObject({user_targeted: false, source: null});
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
expect(outsiderBody.assignments.profile_timezone).toEqual({enabled: false});
});
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
const subscriber = await createTestAccount(harness);
const visionary = await createTestAccount(harness);
const free = await createTestAccount(harness);
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
include_premium_users: true,
});
for (const [account, expected] of [
[subscriber, true],
[visionary, true],
[free, false],
] as const) {
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(body.assignments.profile_timezone).toEqual({enabled: expected});
}
});
it('stores the guild ids 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<
Record<
'voice_noise_suppression' | 'domain_migration' | 'altcha_captcha' | 'profile_timezone',
{included_guild_ids: Array<string>; include_premium_users: boolean}
>
>(harness, admin.token)
.patch('/admin/instance/config')
.body({
voice_noise_suppression: {included_guild_ids: guildIds, include_premium_users: true},
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
profile_timezone: {included_guild_ids: guildIds, include_premium_users: true},
})
.execute();
expect(body.voice_noise_suppression.included_guild_ids).toEqual(guildIds);
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
expect(body.profile_timezone.included_guild_ids).toEqual(guildIds);
for (const section of [
body.voice_noise_suppression,
body.domain_migration,
body.altcha_captcha,
body.profile_timezone,
]) {
expect(section.include_premium_users).toBe(true);
}
});
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExperimentDeliveryConfig({
@@ -2,6 +2,7 @@
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
@@ -45,7 +46,8 @@ async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Recor
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null)) return null;
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
return createAltchaProvider(config);
}
+4 -2
View File
@@ -2,6 +2,7 @@
import {Config} from '@app/api/Config';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {getCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
@@ -291,7 +292,8 @@ export function isBugHunterBotUser(user: Pick<User, 'flags' | 'isBot'>): boolean
return user.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
}
export async function canUseProfileTimezone(user: Pick<User, 'id'>): Promise<boolean> {
export async function canUseProfileTimezone(user: User): Promise<boolean> {
const config = await getInstanceConfigRepository().getProfileTimezoneConfig();
return resolveProfileTimezoneAssignment(config, user.id.toString()).enabled;
const targeting = await resolveExperimentTargeting(user, [config]);
return resolveProfileTimezoneAssignment(config, user.id.toString(), targeting).enabled;
}
@@ -168,6 +168,21 @@ describe('User Profile Timezone Visibility', () => {
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBeNull();
});
it('lets members of an included guild set and show a timezone', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
const guild = await createGuild(harness, targetAccount.token, 'Timezone Rollout Guild');
await getInstanceConfigRepository().setProfileTimezoneConfig({
...DEFAULT_PROFILE_TIMEZONE_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const updated = await updateProfileTimezone(harness, targetAccount.token, {timezone: TEST_TIMEZONE});
expect(updated.timezone).toBe(TEST_TIMEZONE);
await createFriendship(harness, targetAccount, viewerAccount);
const profile = await getUserProfile(harness, viewerAccount.token, targetAccount.userId);
expect(profile.timezone_offset).toBe(TEST_TIMEZONE_OFFSET);
});
it('hides stored profile timezone after the user leaves the experiment', async () => {
const targetAccount = await createTestAccount(harness);
const viewerAccount = await createTestAccount(harness);
@@ -114,13 +114,15 @@ The instance rollout of client-side noise suppression. [Experiments](/http-api/e
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
| rollout_salt | string | Salt of the sampling hash (1-64 characters, default `voice-ns-v1`) |
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
| included_guild_ids | array[snowflake] | Guilds whose members the rollout always selects, up to 1000 entries (default empty) |
| include_premium_users | boolean | Whether the rollout always selects accounts with active premium (default false) |
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
| guild_overrides | array[[guild override](/http-api/experiments/#noise-suppression-guild-override-object) object] | Per-guild replacements, up to 200 entries (default empty) |
| suppression_strength | integer | Suppression strength (0-100, default 80) |
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both. A `default_backend` or `guild_overrides` entry naming a backend outside `enabled_backends` is dropped from what a client is served, and the stored value is kept as written.
`excluded_user_ids` wins over every other rule. Otherwise the rollout selects an account in `included_user_ids`, a member of a guild in `included_guild_ids`, or a premium account while `include_premium_users` is true, whatever `rollout_basis_points` says. A `default_backend` or `guild_overrides` entry naming a backend outside `enabled_backends` is dropped from what a client is served, and the stored value is kept as written.
How often a client revalidates this rollout is not set here. It is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) below.
@@ -153,13 +155,15 @@ The instance rollout that moves the official web client from its legacy origin t
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `domain-migration-v1`) |
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
| included_guild_ids | array[snowflake] | Guilds whose members the rollout always selects, up to 1000 entries (default empty) |
| include_premium_users | boolean | Whether the rollout always selects accounts with active premium (default false) |
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
| anonymous_rollout_basis_points | integer | Share of logged-out devices the rollout moves, in basis points (0-10000, default 0) |
| standalone_forwarding | boolean | Whether installed desktop web apps forward to the new origin once their data has moved (default false) |
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both.
`excluded_user_ids` wins over every other rule. Otherwise the rollout selects an account in `included_user_ids`, a member of a guild in `included_guild_ids`, or a premium account while `include_premium_users` is true, whatever `rollout_basis_points` says.
An installed Chromium desktop web app moves its data like a browser tab, then stays on the legacy origin and offers to install the app from the new one. Set `standalone_forwarding` once the web app manifest lists the new origin in `scope_extensions` and the new origin serves the matching association file. From then on the installed app forwards like a browser tab. Installed mobile and Safari web apps never forward, whatever the value.
@@ -182,6 +186,8 @@ The instance rollout that replaces the configured captcha provider with an ALTCH
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `altcha-captcha-v1`) |
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
| included_guild_ids | array[snowflake] | Guilds whose members the rollout always selects, up to 1000 entries (default empty) |
| include_premium_users | boolean | Whether the rollout always selects accounts with active premium (default false) |
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
| anonymous_enabled | boolean | Whether logged-out requests get ALTCHA (default false) |
| cost | integer | PBKDF2 iterations per solving attempt (1000-100000, default 5000) |
@@ -189,7 +195,7 @@ The instance rollout that replaces the configured captcha provider with an ALTCH
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both. `anonymous_enabled` has no effect on signed-in requests, and the account rules have no effect on logged-out ones.
`excluded_user_ids` wins over every other rule. Otherwise the rollout selects an account in `included_user_ids`, a member of a guild in `included_guild_ids`, or a premium account while `include_premium_users` is true, whatever `rollout_basis_points` says. `anonymous_enabled` has no effect on signed-in requests, and the account rules have no effect on logged-out ones.
Each challenge hides its counter between half of `max_counter` and `max_counter`, and a client tries counters from 0 upward. Solve time grows with `cost` times `max_counter`. Fluxer spends one attempt at `cost` to issue each challenge.
@@ -208,11 +214,13 @@ The instance rollout that lets an account set a profile timezone and show its lo
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
| rollout_salt | string | Salt of the sampling hash (1-64 printable ASCII characters, default `profile-timezone-v1`) |
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
| included_guild_ids | array[snowflake] | Guilds whose members the rollout always selects, up to 1000 entries (default empty) |
| include_premium_users | boolean | Whether the rollout always selects accounts with active premium (default false) |
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
Every field is present on read. An absent document or missing field uses the defaults above.
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both.
`excluded_user_ids` wins over every other rule. Otherwise the rollout selects an account in `included_user_ids`, a member of a guild in `included_guild_ids`, or a premium account while `include_premium_users` is true, whatever `rollout_basis_points` says.
An account that leaves the rollout keeps its stored timezone, but Fluxer stops showing it on the profile and refuses changes to it until the account is selected again.
@@ -85,7 +85,7 @@ The outcomes below set `user_targeted` to false, and they differ in what else th
2. The operator has excluded the caller. `enabled` is true, and every other field is as in the first outcome.
3. The caller was not drawn. `enabled` is true, and `enabled_backends`, `guild_overrides`, and `allow_user_override` all hold their configured values.
A caller is drawn either by the operator's allowlist, which sets `source` to `user_rule`, or by the sampled share of the account population, which sets `source` to `canary`. A caller that is drawn while `backend` is absent from `enabled_backends` is reported as not drawn, with `user_targeted` false and both `backend` and `source` null.
A caller is drawn by the operator's account or guild allowlist, by having premium when the operator includes premium users, or by the sampled share of the account population. The sampled share sets `source` to `canary`, and every other rule sets it to `user_rule`. A caller that is drawn while `backend` is absent from `enabled_backends` is reported as not drawn, with `user_targeted` false and both `backend` and `source` null.
A client branches on `user_targeted` rather than on `enabled_backends`, because the third outcome keeps the array populated. A `guild_overrides` entry applies in its guild whether or not the caller was drawn.
@@ -114,7 +114,7 @@ One resolution of the instance web domain migration rollout against one account.
| --- | --- | --- |
| enabled | boolean | Whether the caller's web client moves to the new web origin |
A caller is drawn either by the operator's allowlist or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
A caller is drawn by the operator's account or guild allowlist, by having premium when the operator includes premium users, or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
Only the official web client acts on this assignment, and only on its legacy origins. Every other client ignores it. The logged-out share and the instance-wide switch are published in the [instance discovery document](/http-api/instance/#domain-migration-object) instead, because a client that holds no credential cannot read this route.
@@ -128,7 +128,7 @@ One resolution of the instance ALTCHA captcha rollout against one account. This
| --- | --- | --- |
| enabled | boolean | Whether the caller's captcha challenges are ALTCHA proof-of-work challenges |
A caller is drawn either by the operator's allowlist or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
A caller is drawn by the operator's account or guild allowlist, by having premium when the operator includes premium users, or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
The assignment is informational. The server applies the same resolution to every request that needs a captcha and names the provider in the [captcha error](/topics/captcha/#altcha-proof-of-work), so a client needs no copy of this value to answer a challenge.
@@ -142,7 +142,7 @@ One resolution of the instance profile timezone rollout against one account. Thi
| --- | --- | --- |
| enabled | boolean | Whether the caller can set a profile timezone and show it on their profile |
A caller is drawn either by the operator's allowlist or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
A caller is drawn by the operator's account or guild allowlist, by having premium when the operator includes premium users, or by the sampled share of the account population. `enabled` is false in every other case, the rollout being off included.
The server applies the same resolution on its own. [Modify current user](/http-api/users/current-user/#modify-current-user) drops `timezone` and `timezone_privacy_flags` for an account outside the rollout, and a [user profile](/http-api/users/#get-user-profile) reports `timezone_offset` as null for a target outside it.
@@ -8,9 +8,11 @@ import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
resolveAltchaCaptchaAssignment,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
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 TARGETED_USER_ID = '1000000000000000001';
function createConfig(overrides: Partial<AltchaCaptchaConfig> = {}): AltchaCaptchaConfig {
@@ -30,6 +32,8 @@ describe('altcha captcha configuration', () => {
rollout_salt: 'altcha-captcha-v1',
included_user_ids: [],
excluded_user_ids: [],
included_guild_ids: [],
include_premium_users: false,
anonymous_enabled: false,
cost: 5000,
max_counter: 10000,
@@ -48,7 +52,7 @@ describe('altcha captcha configuration', () => {
describe('resolveAltchaCaptchaAssignment', () => {
test('serves nobody while disabled, even included users', () => {
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('applies exclusions before inclusions', () => {
@@ -57,18 +61,18 @@ describe('resolveAltchaCaptchaAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('serves included users at zero rollout', () => {
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true});
});
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(resolveAltchaCaptchaAssignment(config, userId).enabled).toBe(
expect(resolveAltchaCaptchaAssignment(config, userId, NO_TARGETING).enabled).toBe(
experimentBucket(userId, config.rollout_salt) < 2500,
);
}
@@ -77,13 +81,65 @@ describe('resolveAltchaCaptchaAssignment', () => {
describe('altchaCaptchaAppliesTo', () => {
test('serves anonymous requests only when anonymous_enabled is set', () => {
expect(altchaCaptchaAppliesTo(createConfig({enabled: true}), null)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true, anonymous_enabled: true}), null)).toBe(true);
expect(altchaCaptchaAppliesTo(createConfig({anonymous_enabled: true}), null)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true}), null, NO_TARGETING)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true, anonymous_enabled: true}), null, NO_TARGETING)).toBe(
true,
);
expect(altchaCaptchaAppliesTo(createConfig({anonymous_enabled: true}), null, NO_TARGETING)).toBe(false);
});
test('keeps signed-in users on their own bucket regardless of the anonymous switch', () => {
const config = createConfig({enabled: true, anonymous_enabled: true, excluded_user_ids: [TARGETED_USER_ID]});
expect(altchaCaptchaAppliesTo(config, TARGETED_USER_ID)).toBe(false);
expect(altchaCaptchaAppliesTo(config, TARGETED_USER_ID, NO_TARGETING)).toBe(false);
});
});
describe('resolveAltchaCaptchaAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('serves members of an included guild at zero rollout', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
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(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
test('serves no guild members while disabled', () => {
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
});
describe('resolveAltchaCaptchaAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('serves premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveAltchaCaptchaAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
expect(resolveAltchaCaptchaAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
expect(resolveAltchaCaptchaAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
enabled: false,
});
});
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(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
@@ -23,6 +28,8 @@ const altchaCaptchaConfigFields = {
rollout_basis_points: z.number().int().min(0).max(ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(ALTCHA_CAPTCHA_SALT_PATTERN),
included_user_ids: AltchaCaptchaTargetedUserIdsSchema,
included_guild_ids: AltchaCaptchaTargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: AltchaCaptchaTargetedUserIdsSchema,
anonymous_enabled: z.boolean(),
cost: z.number().int().min(ALTCHA_CAPTCHA_MIN_COST).max(ALTCHA_CAPTCHA_MAX_COST),
@@ -35,6 +42,8 @@ export const AltchaCaptchaConfigSchema = z.object({
rollout_basis_points: altchaCaptchaConfigFields.rollout_basis_points.default(0),
rollout_salt: altchaCaptchaConfigFields.rollout_salt.default(DEFAULT_ALTCHA_CAPTCHA_SALT),
included_user_ids: altchaCaptchaConfigFields.included_user_ids.default([]),
included_guild_ids: altchaCaptchaConfigFields.included_guild_ids.default([]),
include_premium_users: altchaCaptchaConfigFields.include_premium_users.default(false),
excluded_user_ids: altchaCaptchaConfigFields.excluded_user_ids.default([]),
anonymous_enabled: altchaCaptchaConfigFields.anonymous_enabled.default(false),
cost: altchaCaptchaConfigFields.cost.default(5000),
@@ -69,14 +78,20 @@ export const INERT_ALTCHA_CAPTCHA_ASSIGNMENT: AltchaCaptchaAssignmentResponse =
export function resolveAltchaCaptchaAssignment(
config: AltchaCaptchaConfig,
userId: string,
targeting: ExperimentTargeting,
): AltchaCaptchaAssignmentResponse {
if (!config.enabled) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
export function altchaCaptchaAppliesTo(config: AltchaCaptchaConfig, userId: string | null): boolean {
export function altchaCaptchaAppliesTo(
config: AltchaCaptchaConfig,
userId: string | null,
targeting: ExperimentTargeting,
): boolean {
if (userId === null) return config.enabled && config.anonymous_enabled;
return resolveAltchaCaptchaAssignment(config, userId).enabled;
return resolveAltchaCaptchaAssignment(config, userId, targeting).enabled;
}
@@ -9,9 +9,11 @@ import {
resolveDomainMigrationAssignment,
toDomainMigrationDiscovery,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
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 TARGETED_USER_ID = '1000000000000000001';
const OTHER_USER_ID = '1000000000000000002';
@@ -35,7 +37,7 @@ function syntheticUserIds(count: number): Array<string> {
function targetedUserIds(config: DomainMigrationConfig, userIds: ReadonlyArray<string>): Set<string> {
const targeted = new Set<string>();
for (const userId of userIds) {
if (resolveDomainMigrationAssignment(config, userId).enabled) {
if (resolveDomainMigrationAssignment(config, userId, NO_TARGETING).enabled) {
targeted.add(userId);
}
}
@@ -110,17 +112,19 @@ describe('resolveDomainMigrationAssignment', () => {
rollout_basis_points: 10000,
included_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID)).toEqual(INERT_DOMAIN_MIGRATION_ASSIGNMENT);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
);
});
test('returns the inert assignment for the default config', () => {
expect(resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID)).toEqual(
expect(resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID, NO_TARGETING)).toEqual(
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
);
});
test('never hands back the shared inert object', () => {
const assignment = resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID);
const assignment = resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID, NO_TARGETING);
expect(assignment).not.toBe(INERT_DOMAIN_MIGRATION_ASSIGNMENT);
});
@@ -130,7 +134,7 @@ describe('resolveDomainMigrationAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING).enabled).toBe(false);
});
test('denylist beats the bucket', () => {
@@ -139,8 +143,8 @@ describe('resolveDomainMigrationAssignment', () => {
rollout_basis_points: 10000,
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID).enabled).toBe(true);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID, NO_TARGETING).enabled).toBe(true);
});
test('allowlist beats the bucket', () => {
@@ -149,8 +153,8 @@ describe('resolveDomainMigrationAssignment', () => {
rollout_basis_points: 0,
included_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(true);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING).enabled).toBe(true);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID, NO_TARGETING).enabled).toBe(false);
});
test.each([
@@ -159,7 +163,7 @@ describe('resolveDomainMigrationAssignment', () => {
])('a rollout of $basisPoints basis points targets $enabled', ({basisPoints, enabled}) => {
const config = createConfig({enabled: true, rollout_basis_points: basisPoints});
for (const userId of syntheticUserIds(200)) {
expect(resolveDomainMigrationAssignment(config, userId).enabled).toBe(enabled);
expect(resolveDomainMigrationAssignment(config, userId, NO_TARGETING).enabled).toBe(enabled);
}
});
@@ -167,13 +171,17 @@ describe('resolveDomainMigrationAssignment', () => {
const salt = DEFAULT_DOMAIN_MIGRATION_CONFIG.rollout_salt;
const bucket = experimentBucket(TARGETED_USER_ID, salt);
expect(
resolveDomainMigrationAssignment(createConfig({enabled: true, rollout_basis_points: bucket}), TARGETED_USER_ID)
.enabled,
resolveDomainMigrationAssignment(
createConfig({enabled: true, rollout_basis_points: bucket}),
TARGETED_USER_ID,
NO_TARGETING,
).enabled,
).toBe(false);
expect(
resolveDomainMigrationAssignment(
createConfig({enabled: true, rollout_basis_points: bucket + 1}),
TARGETED_USER_ID,
NO_TARGETING,
).enabled,
).toBe(true);
});
@@ -232,3 +240,53 @@ describe('toDomainMigrationDiscovery', () => {
});
});
});
describe('resolveDomainMigrationAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('serves members of an included guild at zero rollout', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
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(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
test('serves no guild members while disabled', () => {
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
});
describe('resolveDomainMigrationAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('serves premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveDomainMigrationAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
expect(resolveDomainMigrationAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
expect(resolveDomainMigrationAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
enabled: false,
});
});
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(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
@@ -20,6 +25,8 @@ const domainMigrationConfigFields = {
rollout_basis_points: z.number().int().min(0).max(DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(DOMAIN_MIGRATION_SALT_PATTERN),
included_user_ids: DomainMigrationTargetedUserIdsSchema,
included_guild_ids: DomainMigrationTargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: DomainMigrationTargetedUserIdsSchema,
anonymous_rollout_basis_points: z.number().int().min(0).max(DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX),
standalone_forwarding: z.boolean(),
@@ -31,6 +38,8 @@ export const DomainMigrationConfigSchema = z.object({
rollout_basis_points: domainMigrationConfigFields.rollout_basis_points.default(0),
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([]),
include_premium_users: domainMigrationConfigFields.include_premium_users.default(false),
excluded_user_ids: domainMigrationConfigFields.excluded_user_ids.default([]),
anonymous_rollout_basis_points: domainMigrationConfigFields.anonymous_rollout_basis_points.default(0),
standalone_forwarding: domainMigrationConfigFields.standalone_forwarding.default(false),
@@ -64,10 +73,12 @@ export const INERT_DOMAIN_MIGRATION_ASSIGNMENT: DomainMigrationAssignmentRespons
export function resolveDomainMigrationAssignment(
config: DomainMigrationConfig,
userId: string,
targeting: ExperimentTargeting,
): DomainMigrationAssignmentResponse {
if (!config.enabled) return {...INERT_DOMAIN_MIGRATION_ASSIGNMENT};
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};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
@@ -7,9 +7,11 @@ import {
ProfileTimezoneConfigUpdateRequest,
resolveProfileTimezoneAssignment,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
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 TARGETED_USER_ID = '1000000000000000001';
function createConfig(overrides: Partial<ProfileTimezoneConfig> = {}): ProfileTimezoneConfig {
@@ -29,6 +31,8 @@ describe('profile timezone configuration', () => {
rollout_salt: 'profile-timezone-v1',
included_user_ids: [],
excluded_user_ids: [],
included_guild_ids: [],
include_premium_users: false,
});
});
@@ -41,7 +45,7 @@ describe('profile timezone configuration', () => {
describe('resolveProfileTimezoneAssignment', () => {
test('serves nobody while disabled, even included users', () => {
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('applies exclusions before inclusions', () => {
@@ -50,20 +54,70 @@ describe('resolveProfileTimezoneAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('serves included users at zero rollout', () => {
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true});
});
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(resolveProfileTimezoneAssignment(config, userId).enabled).toBe(
expect(resolveProfileTimezoneAssignment(config, userId, NO_TARGETING).enabled).toBe(
experimentBucket(userId, config.rollout_salt) < 2500,
);
}
});
});
describe('resolveProfileTimezoneAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('serves members of an included guild at zero rollout', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
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(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
test('serves no guild members while disabled', () => {
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
});
describe('resolveProfileTimezoneAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('serves premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
expect(resolveProfileTimezoneAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
enabled: false,
});
});
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(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
@@ -20,6 +25,8 @@ const profileTimezoneConfigFields = {
rollout_basis_points: z.number().int().min(0).max(PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(PROFILE_TIMEZONE_SALT_PATTERN),
included_user_ids: ProfileTimezoneTargetedUserIdsSchema,
included_guild_ids: ProfileTimezoneTargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: ProfileTimezoneTargetedUserIdsSchema,
};
@@ -29,6 +36,8 @@ export const ProfileTimezoneConfigSchema = z.object({
rollout_basis_points: profileTimezoneConfigFields.rollout_basis_points.default(0),
rollout_salt: profileTimezoneConfigFields.rollout_salt.default(DEFAULT_PROFILE_TIMEZONE_SALT),
included_user_ids: profileTimezoneConfigFields.included_user_ids.default([]),
included_guild_ids: profileTimezoneConfigFields.included_guild_ids.default([]),
include_premium_users: profileTimezoneConfigFields.include_premium_users.default(false),
excluded_user_ids: profileTimezoneConfigFields.excluded_user_ids.default([]),
});
@@ -60,9 +69,11 @@ export const INERT_PROFILE_TIMEZONE_ASSIGNMENT: ProfileTimezoneAssignmentRespons
export function resolveProfileTimezoneAssignment(
config: ProfileTimezoneConfig,
userId: string,
targeting: ExperimentTargeting,
): ProfileTimezoneAssignmentResponse {
if (!config.enabled) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
@@ -11,9 +11,11 @@ import {
VoiceNoiseSuppressionConfigSchema,
VoiceNoiseSuppressionConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
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 TARGETED_USER_ID = '1000000000000000001';
const OTHER_USER_ID = '1000000000000000002';
const GUILD_ID = '2000000000000000001';
@@ -95,7 +97,7 @@ function syntheticUserIds(count: number): Array<string> {
function targetedUserIds(config: VoiceNoiseSuppressionConfig, userIds: ReadonlyArray<string>): Set<string> {
const targeted = new Set<string>();
for (const userId of userIds) {
if (resolveVoiceNoiseSuppressionAssignment(config, userId).user_targeted) {
if (resolveVoiceNoiseSuppressionAssignment(config, userId, NO_TARGETING).user_targeted) {
targeted.add(userId);
}
}
@@ -139,14 +141,14 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
guild_overrides: [{guild_id: GUILD_ID, backend: 'rnnoise'}],
});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID)).toEqual({
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({
...INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
config_version: 4,
});
});
test('returns the inert assignment for the default config', () => {
expect(resolveVoiceNoiseSuppressionAssignment(createConfig(), TARGETED_USER_ID)).toEqual(
expect(resolveVoiceNoiseSuppressionAssignment(createConfig(), TARGETED_USER_ID, NO_TARGETING)).toEqual(
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
);
});
@@ -157,7 +159,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.user_targeted).toBe(false);
expect(assignment.backend).toBeNull();
expect(assignment.source).toBeNull();
@@ -169,10 +171,10 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
rollout_basis_points: 10000,
excluded_user_ids: [TARGETED_USER_ID],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.user_targeted).toBe(false);
expect(assignment.source).toBeNull();
expect(resolveVoiceNoiseSuppressionAssignment(config, OTHER_USER_ID).user_targeted).toBe(true);
expect(resolveVoiceNoiseSuppressionAssignment(config, OTHER_USER_ID, NO_TARGETING).user_targeted).toBe(true);
});
test('denylist strips guild overrides and the client-side knobs', () => {
@@ -183,7 +185,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
excluded_user_ids: [TARGETED_USER_ID],
guild_overrides: [{guild_id: GUILD_ID, backend: 'rnnoise'}],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.guild_overrides).toEqual([]);
expect(assignment.enabled_backends).toEqual([]);
expect(assignment.allow_user_override).toBe(false);
@@ -196,7 +198,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
rollout_basis_points: 0,
included_user_ids: [TARGETED_USER_ID],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.user_targeted).toBe(true);
expect(assignment.backend).toBe('gtcrn');
expect(assignment.source).toBe('user_rule');
@@ -208,7 +210,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
])('canary at $basisPoints basis points targets $targeted', ({basisPoints, targeted}) => {
const config = createConfig({enabled: true, rollout_basis_points: basisPoints});
for (const userId of syntheticUserIds(200)) {
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId, NO_TARGETING);
expect(assignment.user_targeted).toBe(targeted);
expect(assignment.source).toBe(targeted ? 'canary' : null);
}
@@ -247,7 +249,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
included_user_ids: [OTHER_USER_ID],
});
for (const userId of [TARGETED_USER_ID, OTHER_USER_ID]) {
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId, NO_TARGETING);
expect(assignment.user_targeted).toBe(false);
expect(assignment.backend).toBeNull();
expect(assignment.source).toBeNull();
@@ -265,7 +267,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
{guild_id: OTHER_GUILD_ID, backend: 'deep_filter'},
],
});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID).guild_overrides).toEqual([
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).guild_overrides).toEqual([
{guild_id: GUILD_ID, backend: 'rnnoise'},
]);
});
@@ -280,14 +282,14 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
{guild_id: OTHER_GUILD_ID, backend: 'gtcrn'},
],
});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID).guild_overrides).toEqual([
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).guild_overrides).toEqual([
{guild_id: GUILD_ID, backend: 'rnnoise'},
]);
});
test('echoes the config version on every path', () => {
const config = createConfig({enabled: true, config_version: 11, rollout_basis_points: 10000});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID).config_version).toBe(11);
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).config_version).toBe(11);
});
});
@@ -412,3 +414,46 @@ describe('resolveVoiceNoiseSuppressionForCall', () => {
});
});
});
describe('resolveVoiceNoiseSuppressionAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('targets members of an included guild the way it targets included users', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS);
expect(assignment.user_targeted).toBe(true);
expect(assignment.source).toBe('user_rule');
expect(assignment.backend).toBe(config.default_backend);
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).user_targeted).toBe(false);
});
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],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS);
expect(assignment.user_targeted).toBe(false);
expect(assignment.source).toBeNull();
});
});
describe('resolveVoiceNoiseSuppressionAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('targets premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveVoiceNoiseSuppressionAssignment(on, TARGETED_USER_ID, PREMIUM)).toMatchObject({
user_targeted: true,
source: 'user_rule',
});
expect(resolveVoiceNoiseSuppressionAssignment(on, TARGETED_USER_ID, NO_TARGETING).user_targeted).toBe(false);
const off = createConfig({enabled: true});
expect(resolveVoiceNoiseSuppressionAssignment(off, TARGETED_USER_ID, PREMIUM).user_targeted).toBe(false);
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
export const VOICE_NOISE_SUPPRESSION_BACKENDS = [
@@ -39,6 +44,8 @@ const voiceConfigFields = {
rollout_basis_points: z.number().int().min(0).max(VOICE_NOISE_SUPPRESSION_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64),
included_user_ids: TargetedUserIdsSchema,
included_guild_ids: TargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: TargetedUserIdsSchema,
guild_overrides: z.array(VoiceNoiseSuppressionGuildOverrideSchema).max(VOICE_NOISE_SUPPRESSION_MAX_GUILD_OVERRIDES),
suppression_strength: z.number().int().min(0).max(100),
@@ -53,6 +60,8 @@ export const VoiceNoiseSuppressionConfigSchema = z.object({
rollout_basis_points: voiceConfigFields.rollout_basis_points.default(0),
rollout_salt: voiceConfigFields.rollout_salt.default(DEFAULT_VOICE_NOISE_SUPPRESSION_SALT),
included_user_ids: voiceConfigFields.included_user_ids.default([]),
included_guild_ids: voiceConfigFields.included_guild_ids.default([]),
include_premium_users: voiceConfigFields.include_premium_users.default(false),
excluded_user_ids: voiceConfigFields.excluded_user_ids.default([]),
guild_overrides: voiceConfigFields.guild_overrides.default([]),
suppression_strength: voiceConfigFields.suppression_strength.default(80),
@@ -107,6 +116,7 @@ export const INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT: VoiceNoiseSuppressionAssi
export function resolveVoiceNoiseSuppressionAssignment(
config: VoiceNoiseSuppressionConfig,
userId: string,
targeting: ExperimentTargeting,
): VoiceNoiseSuppressionAssignmentResponse {
if (!config.enabled) {
return {
@@ -136,7 +146,7 @@ export function resolveVoiceNoiseSuppressionAssignment(
const guildOverrides = config.guild_overrides.filter((override) =>
config.enabled_backends.includes(override.backend),
);
if (config.included_user_ids.includes(userId)) {
if (config.included_user_ids.includes(userId) || experimentAudienceIncludes(config, targeting)) {
return {
...shared,
user_targeted: backendIsUsable,
@@ -14,3 +14,18 @@ export function experimentBucket(userId: string, salt: string): number {
}
return hash % EXPERIMENT_BUCKET_RESOLUTION;
}
export interface ExperimentTargeting {
readonly memberGuildIds: ReadonlySet<string>;
readonly premium: boolean;
}
interface ExperimentAudienceRules {
readonly included_guild_ids: ReadonlyArray<string>;
readonly include_premium_users: boolean;
}
export function experimentAudienceIncludes(rules: ExperimentAudienceRules, targeting: ExperimentTargeting): boolean {
if (rules.include_premium_users && targeting.premium) return true;
return rules.included_guild_ids.some((guildId) => targeting.memberGuildIds.has(guildId));
}