fix(guild): render activity log entries deterministically (#2766)

This commit is contained in:
Hampus Kraft committed 2026-09-14 17:39:19 +02:00
1 parent ed10f9d323
commit a3d6cf37cb
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'),
);
});
});