mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(guild): render activity log entries deterministically (#2766)
This commit is contained in:
135 files changed
+75869
-2708
No files matched your search
@@ -27,6 +27,10 @@ import {
|
||||
ExperimentDeliveryConfigResponse,
|
||||
ExperimentDeliveryConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
GuildActivityLogPresentationConfigResponse,
|
||||
GuildActivityLogPresentationConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
MessageHoverTrackingConfigResponse,
|
||||
MessageHoverTrackingConfigUpdateRequest,
|
||||
@@ -641,6 +645,7 @@ export const InstanceConfigResponse = z.object({
|
||||
sso: SsoConfigResponse,
|
||||
gateway_rollout: GatewayRolloutConfigResponse,
|
||||
voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
guild_activity_log_presentation: GuildActivityLogPresentationConfigResponse,
|
||||
experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
message_hover_tracking: MessageHoverTrackingConfigResponse,
|
||||
message_keyboard_focus: MessageKeyboardFocusConfigResponse,
|
||||
@@ -680,6 +685,7 @@ const InstancePolicyUpdateSchema = z.object({
|
||||
export const InstanceConfigUpdateRequest = z.object({
|
||||
gateway_rollout: GatewayRolloutConfigUpdateRequest.nullish(),
|
||||
voice_noise_suppression: VoiceNoiseSuppressionConfigUpdateRequest.nullish(),
|
||||
guild_activity_log_presentation: GuildActivityLogPresentationConfigUpdateRequest.nullish(),
|
||||
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
|
||||
message_hover_tracking: MessageHoverTrackingConfigUpdateRequest.nullish(),
|
||||
message_keyboard_focus: MessageKeyboardFocusConfigUpdateRequest.nullish(),
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
BlockedMessageGroupsAssignmentResponse,
|
||||
INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {
|
||||
GuildActivityLogPresentationAssignmentResponse,
|
||||
INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {
|
||||
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
MessageHoverTrackingAssignmentResponse,
|
||||
@@ -57,6 +61,7 @@ const ExperimentAssignmentsSchema = z.object({
|
||||
message_hover_tracking: MessageHoverTrackingAssignmentResponse.optional(),
|
||||
message_keyboard_focus: MessageKeyboardFocusAssignmentResponse.optional(),
|
||||
blocked_message_groups: BlockedMessageGroupsAssignmentResponse.optional(),
|
||||
guild_activity_log_presentation: GuildActivityLogPresentationAssignmentResponse.optional(),
|
||||
});
|
||||
|
||||
export const ExperimentAssignmentsResponse = z.object({
|
||||
@@ -96,3 +101,9 @@ export function readBlockedMessageGroupsAssignment(
|
||||
): BlockedMessageGroupsAssignmentResponse {
|
||||
return response.assignments.blocked_message_groups ?? INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT;
|
||||
}
|
||||
|
||||
export function readGuildActivityLogPresentationAssignment(
|
||||
response: ExperimentAssignmentsResponse,
|
||||
): GuildActivityLogPresentationAssignmentResponse {
|
||||
return response.assignments.guild_activity_log_presentation ?? INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
|
||||
import type {z} from 'zod';
|
||||
|
||||
const guildActivityLogPresentation = createClientBehaviorExperiment({
|
||||
defaultRolloutSalt: 'guild-activity-log-presentation-v1',
|
||||
});
|
||||
|
||||
export const GuildActivityLogPresentationConfigSchema = guildActivityLogPresentation.ConfigSchema;
|
||||
|
||||
export type GuildActivityLogPresentationConfig = z.infer<typeof GuildActivityLogPresentationConfigSchema>;
|
||||
|
||||
export const DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG: GuildActivityLogPresentationConfig =
|
||||
guildActivityLogPresentation.DEFAULT_CONFIG;
|
||||
|
||||
export const GuildActivityLogPresentationConfigUpdateRequest = guildActivityLogPresentation.ConfigUpdateRequest;
|
||||
|
||||
export type GuildActivityLogPresentationConfigUpdateRequest = z.infer<
|
||||
typeof GuildActivityLogPresentationConfigUpdateRequest
|
||||
>;
|
||||
|
||||
export const GuildActivityLogPresentationConfigResponse = guildActivityLogPresentation.ConfigSchema;
|
||||
|
||||
export type GuildActivityLogPresentationConfigResponse = GuildActivityLogPresentationConfig;
|
||||
|
||||
export const GuildActivityLogPresentationAssignmentResponse = guildActivityLogPresentation.AssignmentResponse;
|
||||
|
||||
export type GuildActivityLogPresentationAssignmentResponse = z.infer<
|
||||
typeof GuildActivityLogPresentationAssignmentResponse
|
||||
>;
|
||||
|
||||
export const INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT: GuildActivityLogPresentationAssignmentResponse =
|
||||
guildActivityLogPresentation.INERT_ASSIGNMENT;
|
||||
|
||||
export function resolveGuildActivityLogPresentationAssignment(
|
||||
config: GuildActivityLogPresentationConfig,
|
||||
userId: string,
|
||||
): GuildActivityLogPresentationAssignmentResponse {
|
||||
return guildActivityLogPresentation.resolveAssignment(config, userId);
|
||||
}
|
||||
@@ -35,13 +35,27 @@ export type AuditLogChange = z.infer<typeof AuditLogChangeSchema>;
|
||||
const AuditLogOptionsSchema = z.object({
|
||||
channel_id: z.string().optional().describe('Channel ID for relevant actions'),
|
||||
count: z.number().optional().describe('Count of items affected'),
|
||||
delete_member_days: z.string().optional().describe('Number of days of messages to delete on member ban'),
|
||||
delete_member_days: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Deprecated. Whole days of messages deleted, written only by bans recorded before delete_message_seconds',
|
||||
),
|
||||
delete_message_seconds: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Seconds of the banned user's messages that the ban deleted, present only when positive"),
|
||||
id: z.string().optional().describe('ID of the affected entity'),
|
||||
integration_type: z.number().optional().describe('Type of integration'),
|
||||
message_id: z.string().optional().describe('Message ID for relevant actions'),
|
||||
members_removed: z.number().optional().describe('Number of members removed'),
|
||||
role_name: z.string().optional().describe('Name of the role'),
|
||||
type: z.number().optional().describe('Type identifier'),
|
||||
role_name: z.string().optional().describe('Name of the role when the entry was written'),
|
||||
type: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
'Channel type for CHANNEL_CREATE, CHANNEL_UPDATE and CHANNEL_DELETE. Overwrite target type (0 role, 1 member) for CHANNEL_OVERWRITE_*',
|
||||
),
|
||||
inviter_id: z.string().optional().describe('ID of the user who created the invite'),
|
||||
max_age: z.number().optional().describe('Maximum age of the invite in seconds'),
|
||||
max_uses: z.number().optional().describe('Maximum number of uses for the invite'),
|
||||
@@ -56,7 +70,12 @@ export const GuildAuditLogEntryResponse = z.object({
|
||||
action_type: AuditLogActionTypeSchema,
|
||||
user_id: SnowflakeStringType.nullish().describe('The user ID of the user who performed the action'),
|
||||
target_id: z.string().nullish().describe('The ID of the affected entity (user, channel, role, invite code, etc.)'),
|
||||
reason: z.string().optional().describe('The reason provided for the action'),
|
||||
reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The audit log reason. For bans and timeouts without an X-Audit-Log-Reason header this is the reason sent in the request body',
|
||||
),
|
||||
options: AuditLogOptionsSchema.optional().describe('Additional options depending on action type'),
|
||||
changes: z.array(AuditLogChangeSchema).optional().describe('Changes made to the target'),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {GuildAuditLogEntryResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function entryWith(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: '1420000000000000100',
|
||||
action_type: AuditLogActionType.MEMBER_BAN_ADD,
|
||||
user_id: '1420000000000000001',
|
||||
target_id: '1420000000000000002',
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GuildAuditLogEntryResponse', () => {
|
||||
it('parses delete_message_seconds as a number', () => {
|
||||
const parsed = GuildAuditLogEntryResponse.parse(entryWith({options: {delete_message_seconds: 3600}}));
|
||||
expect(parsed.options).toEqual({delete_message_seconds: 3600});
|
||||
});
|
||||
|
||||
it('rejects delete_message_seconds sent as a string', () => {
|
||||
const result = GuildAuditLogEntryResponse.safeParse(entryWith({options: {delete_message_seconds: '3600'}}));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('parses the legacy delete_member_days string', () => {
|
||||
const parsed = GuildAuditLogEntryResponse.parse(entryWith({options: {delete_member_days: '7'}}));
|
||||
expect(parsed.options).toEqual({delete_member_days: '7'});
|
||||
});
|
||||
|
||||
it('parses a permissions_diff change value', () => {
|
||||
const changes = [
|
||||
{key: 'permissions', old_value: '1024', new_value: '3072'},
|
||||
{key: 'permissions_diff', new_value: {added: ['SEND_MESSAGES'], removed: []}},
|
||||
];
|
||||
const parsed = GuildAuditLogEntryResponse.parse(
|
||||
entryWith({action_type: AuditLogActionType.ROLE_UPDATE, options: {role_name: 'Moderators'}, changes}),
|
||||
);
|
||||
expect(parsed.changes).toEqual(changes);
|
||||
expect(parsed.options).toEqual({role_name: 'Moderators'});
|
||||
});
|
||||
|
||||
it('strips unknown option keys', () => {
|
||||
const parsed = GuildAuditLogEntryResponse.parse(
|
||||
entryWith({
|
||||
action_type: AuditLogActionType.MEMBER_UPDATE,
|
||||
options: {channel_id: '1420000000000000003', timeout_reason: 'raw', future_option: 1},
|
||||
}),
|
||||
);
|
||||
expect(parsed.options).toEqual({channel_id: '1420000000000000003'});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
|
||||
import {
|
||||
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
|
||||
GuildActivityLogPresentationConfigSchema,
|
||||
resolveGuildActivityLogPresentationAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const USER_ID = '1485064866382176262';
|
||||
const OTHER_USER_ID = '1485064866382176263';
|
||||
|
||||
function config(overrides: Partial<ReturnType<typeof GuildActivityLogPresentationConfigSchema.parse>> = {}) {
|
||||
return GuildActivityLogPresentationConfigSchema.parse({...overrides});
|
||||
}
|
||||
|
||||
describe('guild activity log presentation configuration', () => {
|
||||
it('defaults to a disabled rollout that targets nobody', () => {
|
||||
expect(DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG).toEqual({
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: 'guild-activity-log-presentation-v1',
|
||||
included_user_ids: [],
|
||||
excluded_user_ids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a rollout wider than the bucket resolution', () => {
|
||||
expect(() => config({rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION + 1})).toThrow();
|
||||
});
|
||||
|
||||
it('refuses a target that is not a snowflake', () => {
|
||||
expect(() => config({included_user_ids: ['not-a-snowflake']})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('guild activity log presentation resolution', () => {
|
||||
it('reports the stored revision while the rollout is disabled', () => {
|
||||
const assignment = resolveGuildActivityLogPresentationAssignment(config({config_version: 3}), USER_ID);
|
||||
expect(assignment).toEqual({enabled: false, config_version: 3, user_targeted: false, source: null});
|
||||
});
|
||||
|
||||
it('targets an allowlisted account regardless of its bucket', () => {
|
||||
const assignment = resolveGuildActivityLogPresentationAssignment(
|
||||
config({enabled: true, included_user_ids: [USER_ID]}),
|
||||
USER_ID,
|
||||
);
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'user_rule'});
|
||||
});
|
||||
|
||||
it('keeps a blocklisted account out of a full rollout', () => {
|
||||
const assignment = resolveGuildActivityLogPresentationAssignment(
|
||||
config({
|
||||
enabled: true,
|
||||
rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION,
|
||||
excluded_user_ids: [USER_ID],
|
||||
}),
|
||||
USER_ID,
|
||||
);
|
||||
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
|
||||
});
|
||||
|
||||
it('prefers the blocklist over the allowlist', () => {
|
||||
const assignment = resolveGuildActivityLogPresentationAssignment(
|
||||
config({enabled: true, included_user_ids: [USER_ID], excluded_user_ids: [USER_ID]}),
|
||||
USER_ID,
|
||||
);
|
||||
expect(assignment.user_targeted).toBe(false);
|
||||
});
|
||||
|
||||
it('targets an account whose bucket falls inside the rollout', () => {
|
||||
const salt = DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG.rollout_salt;
|
||||
const bucket = experimentBucket(USER_ID, salt);
|
||||
const inside = resolveGuildActivityLogPresentationAssignment(
|
||||
config({enabled: true, rollout_basis_points: bucket + 1}),
|
||||
USER_ID,
|
||||
);
|
||||
const outside = resolveGuildActivityLogPresentationAssignment(
|
||||
config({enabled: true, rollout_basis_points: bucket}),
|
||||
USER_ID,
|
||||
);
|
||||
expect(inside).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'canary'});
|
||||
expect(outside).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
|
||||
});
|
||||
|
||||
it('separates two accounts under the same salt', () => {
|
||||
const salt = DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG.rollout_salt;
|
||||
expect(experimentBucket(USER_ID, salt)).not.toBe(experimentBucket(OTHER_USER_ID, salt));
|
||||
});
|
||||
|
||||
it('moves an account when the salt changes', () => {
|
||||
expect(experimentBucket(USER_ID, 'guild-activity-log-presentation-v1')).not.toBe(
|
||||
experimentBucket(USER_ID, 'guild-activity-log-presentation-v2'),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user