feat(api): track privacy setup and open community DMs by default (#3322)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 20:47:22 +02:00
1 parent ae820ba4ea
commit e9a4f33a7e
13 files changed
+122 -6

No files matched your search

@@ -101,12 +101,11 @@ describe('DM Privacy Bidirectional Enforcement', () => {
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message from a non-friend guild member to a new account by default', async () => {
it('allows message from a non-friend guild member to a new account by default', async () => {
const sender = await createTestAccount(harness);
const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await createFriendship(harness, sender, target);
const guild = await createGuild(harness, sender.token, 'Default Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
@@ -116,8 +115,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
await removeRelationship(harness, sender.token, target.userId);
await createBuilder(harness, sender.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'default restricted target'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.body({content: 'default open target'})
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message when sender restricts the only mutual guild', async () => {
@@ -274,6 +274,8 @@ export interface UserSettingsRow {
synced_preferences: Nullish<string>;
profile_privacy: Nullish<number>;
default_share_voice_activity: Nullish<boolean>;
privacy_setup_version: Nullish<number>;
privacy_setup_completed_at: Nullish<Date>;
version: number;
}
@@ -513,6 +515,8 @@ export const USER_SETTINGS_COLUMNS = [
'synced_preferences',
'profile_privacy',
'default_share_voice_activity',
'privacy_setup_version',
'privacy_setup_completed_at',
'version',
] as const satisfies ReadonlyArray<keyof UserSettingsRow>;
export const USER_GUILD_SETTINGS_COLUMNS = [
+9 -1
View File
@@ -68,6 +68,8 @@ export class UserSettings {
readonly syncedPreferences: string;
readonly profilePrivacy: ProfilePrivacyLevel;
readonly defaultShareVoiceActivity: boolean;
readonly privacySetupVersion: number;
readonly privacySetupCompletedAt: Date | null;
readonly version: number;
constructor(row: UserSettingsRow) {
@@ -115,6 +117,8 @@ export class UserSettings {
this.syncedPreferences = normalizeStoredSyncedPreferences(row.synced_preferences);
this.profilePrivacy = (row.profile_privacy ?? ProfilePrivacyLevels.ALL_GUILDS) as ProfilePrivacyLevel;
this.defaultShareVoiceActivity = row.default_share_voice_activity ?? true;
this.privacySetupVersion = row.privacy_setup_version ?? 0;
this.privacySetupCompletedAt = row.privacy_setup_completed_at ?? null;
this.version = row.version;
}
@@ -171,6 +175,8 @@ export class UserSettings {
synced_preferences: this.syncedPreferences === '' ? null : this.syncedPreferences,
profile_privacy: this.profilePrivacy,
default_share_voice_activity: this.defaultShareVoiceActivity,
privacy_setup_version: this.privacySetupVersion,
privacy_setup_completed_at: this.privacySetupCompletedAt,
version: this.version,
};
}
@@ -216,7 +222,7 @@ export class UserSettings {
friend_source_flags: friendSourceFlags,
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
default_guilds_restricted: true,
default_guilds_restricted: false,
bot_default_guilds_restricted: false,
restricted_guilds: new Set(),
bot_restricted_guilds: new Set(),
@@ -244,6 +250,8 @@ export class UserSettings {
synced_preferences: null,
profile_privacy: ProfilePrivacyLevels.ALL_GUILDS,
default_share_voice_activity: true,
privacy_setup_version: 0,
privacy_setup_completed_at: null,
version: 1,
};
}
+26 -1
View File
@@ -24983,6 +24983,12 @@
"type": "boolean",
"description": "Default share_voice_activity applied to new friend relationships"
},
"privacy_setup_version": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"description": "Version of the privacy setup the user just reviewed. The server also records privacy_setup_completed_at as the current time."
},
"synced_preferences": {
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it.",
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}]
@@ -25136,6 +25142,23 @@
"default_share_voice_activity": {
"type": "boolean",
"description": "Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing."
},
"privacy_setup_version": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Latest privacy setup version the user has reviewed, 0 if never reviewed"
},
"privacy_setup_completed_at": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When the user last completed the privacy setup, or null if never"
}
},
"required": [
@@ -25173,7 +25196,9 @@
"staff_dm_access_user_ids",
"synced_preferences",
"profile_privacy",
"default_share_voice_activity"
"default_share_voice_activity",
"privacy_setup_version",
"privacy_setup_completed_at"
],
"additionalProperties": false
},
+2
View File
@@ -353,6 +353,8 @@ export function mapUserSettingsToResponse(params: {settings: UserSettings}): Use
synced_preferences: settings.syncedPreferences,
profile_privacy: settings.profilePrivacy,
default_share_voice_activity: settings.defaultShareVoiceActivity,
privacy_setup_version: settings.privacySetupVersion,
privacy_setup_completed_at: settings.privacySetupCompletedAt?.toISOString() ?? null,
};
}
@@ -137,6 +137,10 @@ export class UserAccountSettingsService {
if (data.bot_default_guilds_restricted !== undefined) {
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
}
if (data.privacy_setup_version !== undefined) {
updatedRowData.privacy_setup_version = data.privacy_setup_version;
updatedRowData.privacy_setup_completed_at = new Date();
}
if (data.inline_attachment_media !== undefined) {
updatedRowData.inline_attachment_media = data.inline_attachment_media;
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {fetchUserSettings, updateUserSettings} from '@app/api/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('User settings privacy setup', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('new accounts allow community DMs and have not reviewed the privacy setup', async () => {
const account = await createTestAccount(harness);
const {json} = await fetchUserSettings(harness, account.token);
expect(json.default_guilds_restricted).toBe(false);
expect(json.privacy_setup_version).toBe(0);
expect(json.privacy_setup_completed_at).toBeNull();
});
test('completing the privacy setup stores the version, the time and the chosen setting in one update', async () => {
const account = await createTestAccount(harness);
const before = Date.now();
const {json} = await updateUserSettings(harness, account.token, {
default_guilds_restricted: true,
privacy_setup_version: 1,
});
expect(json.default_guilds_restricted).toBe(true);
expect(json.privacy_setup_version).toBe(1);
expect(Date.parse(json.privacy_setup_completed_at ?? '')).toBeGreaterThanOrEqual(before - 1000);
const {json: reread} = await fetchUserSettings(harness, account.token);
expect(reread.privacy_setup_version).toBe(1);
expect(reread.privacy_setup_completed_at).toBe(json.privacy_setup_completed_at);
await createBuilder(harness, account.token)
.patch('/users/@me/settings')
.body({privacy_setup_version: 0})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
@@ -569,6 +569,8 @@ export function buildUserDataJson(params: UserDataJsonParams) {
staff_dm_access_user_ids: Array.from(userSettings.staffDmAccessUserIds).map((id) => id.toString()),
synced_preferences: syncedPreferencesToJson(decodeSyncedPreferencesLenient(userSettings.syncedPreferences)),
profile_privacy: userSettings.profilePrivacy,
privacy_setup_version: userSettings.privacySetupVersion,
privacy_setup_completed_at: userSettings.privacySetupCompletedAt?.toISOString() ?? null,
}
: null,
guild_memberships: guildMemberships