/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {AVATAR_MAX_SIZE} from '@fluxer/constants/src/LimitConstants'; import {StatusTypes} from '@fluxer/constants/src/StatusConstants'; import { DEFAULT_GUILD_FOLDER_ICON, FriendSourceFlags, FriendSourceFlagsDescriptions, GroupDmAddPermissionFlags, GroupDmAddPermissionFlagsDescriptions, GuildFolderFlags, GuildFolderFlagsDescriptions, GuildFolderIcons, IncomingCallFlags, IncomingCallFlagsDescriptions, ThemeTypes, } from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas'; import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators'; import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators'; import {LocaleSchema} from '@fluxer/schema/src/primitives/LocaleSchema'; import {createQueryIntegerType, DateTimeType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators'; import { ColorType, createBitflagInt32Type, createNamedStringLiteralUnion, createStringType, Int32Type, SignedInt32Type, SnowflakeStringType, SnowflakeType, withFieldDescription, withOpenApiType, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {URLType} from '@fluxer/schema/src/primitives/UrlValidators'; import { RelationshipTypesSchema, RenderSpoilersSchema, StickerAnimationOptionsSchema, TimeFormatTypesSchema, UserNotificationSettingsSchema, } from '@fluxer/schema/src/primitives/UserSettingsValidators'; import { DiscriminatorType, EmailType, GlobalNameType, PasswordType, UsernameType, } from '@fluxer/schema/src/primitives/UserValidators'; import {z} from 'zod'; export const UserUpdateRequest = z .object({ username: UsernameType.describe('The username for the account (1-32 characters)'), discriminator: DiscriminatorType.describe('The 4-digit discriminator tag'), global_name: GlobalNameType.nullish().describe('The display name shown to other users'), email: EmailType.describe('The email address for the account'), new_password: PasswordType.describe('The new password to set'), password: PasswordType.describe('The current password for verification'), avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33) .nullish() .describe('Base64-encoded avatar image'), banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33) .nullish() .describe('Base64-encoded profile banner image'), bio: createStringType(1, 320).nullish().describe('User biography text (max 320 characters)'), pronouns: createStringType(1, 40).nullish().describe('User pronouns (max 40 characters)'), accent_color: ColorType.nullish().describe('Profile accent color as integer'), premium_badge_hidden: z.boolean().describe('Whether to hide the premium badge'), premium_badge_masked: z.boolean().describe('Whether to mask the premium badge'), premium_badge_timestamp_hidden: z.boolean().describe('Whether to hide premium badge timestamp'), premium_badge_sequence_hidden: z.boolean().describe('Whether to hide premium badge sequence'), premium_enabled_override: z.boolean().describe('Override premium enabled state'), has_dismissed_premium_onboarding: z.boolean().describe('Whether user dismissed premium onboarding'), has_unread_gift_inventory: z.boolean().describe('Whether user has unread gifts'), used_mobile_client: z.boolean().describe('Whether user has used mobile client'), }) .partial(); export type UserUpdateRequest = z.infer; const EmailTokenType = createStringType(1, 256); export const UserUpdateWithVerificationRequest = UserUpdateRequest.merge( z.object({ email_token: EmailTokenType.optional().describe('Email change token for updating email'), }), ) .merge(SudoVerificationSchema) .superRefine((data, ctx) => { if (data.email !== undefined) { ctx.addIssue({ code: 'custom', message: ValidationErrorCodes.EMAIL_MUST_BE_CHANGED_VIA_TOKEN, path: ['email'], }); } }); export type UserUpdateWithVerificationRequest = z.infer; export const EmailChangeTicketRequest = z.object({ ticket: createStringType().describe('Email change ticket identifier'), }); export type EmailChangeTicketRequest = z.infer; export const EmailChangeVerifyOriginalRequest = EmailChangeTicketRequest.extend({ code: createStringType().describe('Verification code sent to the original email address'), }); export type EmailChangeVerifyOriginalRequest = z.infer; export const EmailChangeRequestNewRequest = EmailChangeTicketRequest.extend({ new_email: EmailType.describe('New email address to switch to'), original_proof: createStringType().describe('Proof token obtained from verifying the original email'), }); export type EmailChangeRequestNewRequest = z.infer; export const EmailChangeVerifyNewRequest = EmailChangeVerifyOriginalRequest.extend({ original_proof: createStringType().describe('Proof token obtained from verifying the original email'), }); export type EmailChangeVerifyNewRequest = z.infer; export const EmailChangeBouncedRequestNewRequest = z.object({ new_email: EmailType.describe('Replacement email address used when the current email has bounced'), }); export type EmailChangeBouncedRequestNewRequest = z.infer; export const EmailChangeBouncedVerifyNewRequest = EmailChangeTicketRequest.extend({ code: createStringType().describe('Verification code sent to the replacement email address'), }); export type EmailChangeBouncedVerifyNewRequest = z.infer; export const PasswordChangeTicketRequest = z.object({ ticket: createStringType().describe('Password change ticket identifier'), }); export type PasswordChangeTicketRequest = z.infer; export const PasswordChangeVerifyRequest = PasswordChangeTicketRequest.extend({ code: createStringType().describe('Verification code sent to the email address'), }); export type PasswordChangeVerifyRequest = z.infer; export const PasswordChangeCompleteRequest = PasswordChangeTicketRequest.extend({ verification_proof: createStringType().describe('Proof token obtained from verifying the email code'), new_password: PasswordType.describe('The new password to set'), }); export type PasswordChangeCompleteRequest = z.infer; export const FriendRequestByTagRequest = z.object({ username: UsernameType.describe('Username of the user to send friend request'), discriminator: DiscriminatorType.describe('Discriminator tag of the user'), }); export type FriendRequestByTagRequest = z.infer; export const RelationshipNicknameUpdateRequest = z.object({ nickname: createStringType(0, 256).nullable().describe('Custom nickname for this friend (max 256 characters)'), }); export type RelationshipNicknameUpdateRequest = z.infer; export const RelationshipTypePutRequest = z .object({ type: withFieldDescription(RelationshipTypesSchema, 'Type of relationship to create').optional(), }) .optional(); export type RelationshipTypePutRequest = z.infer; export const CustomStatusPayload = z .object({ text: createStringType(1, 128).nullish().describe('Custom status text (max 128 characters)'), expires_at: DateTimeType.nullish().describe('When the custom status expires'), emoji_id: SnowflakeType.nullish().describe('ID of custom emoji to display'), emoji_name: createStringType(2, 32).nullish().describe('Unicode emoji or custom emoji name'), }) .transform((value) => { if (value.emoji_id != null) { return {...value, emoji_name: undefined}; } return value; }) .refine((value) => value.emoji_name == null || isValidSingleUnicodeEmoji(value.emoji_name), { message: 'Emoji name must be a valid Unicode emoji', path: ['emoji_name'], }) .refine((value) => value.expires_at == null || value.expires_at.getTime() > Date.now(), { message: 'expires_at must be in the future', path: ['expires_at'], }); export type CustomStatusPayload = z.infer; const GuildFolderIconSchema = withOpenApiType( createNamedStringLiteralUnion( [ [GuildFolderIcons.FOLDER, 'FOLDER', 'Classic folder icon'], [GuildFolderIcons.STAR, 'STAR', 'Star icon'], [GuildFolderIcons.HEART, 'HEART', 'Heart icon'], [GuildFolderIcons.BOOKMARK, 'BOOKMARK', 'Bookmark icon'], [GuildFolderIcons.GAME_CONTROLLER, 'GAME_CONTROLLER', 'Game controller icon'], [GuildFolderIcons.SHIELD, 'SHIELD', 'Shield icon'], [GuildFolderIcons.MUSIC_NOTE, 'MUSIC_NOTE', 'Music note icon'], ] as const, 'Guild folder icon', ), 'GuildFolderIconType', ); export const CreatePrivateChannelRequest = z .object({ recipient_id: SnowflakeType.optional().describe('User ID for creating a DM channel'), recipients: z.array(SnowflakeType).max(9).optional().describe('Array of user IDs for creating a group DM (max 9)'), }) .refine((data) => (data.recipient_id && !data.recipients) || (!data.recipient_id && data.recipients), { message: 'Either recipient_id or recipients must be provided, but not both', }); export type CreatePrivateChannelRequest = z.infer; const GuildFolderSchema = z.object({ id: SignedInt32Type.describe('Unique identifier for the folder (-1 for uncategorized)'), name: createStringType(0, 100).nullish().describe('Display name of the folder'), color: Int32Type.nullish().describe('Color of the folder as integer'), flags: createBitflagInt32Type( GuildFolderFlags, GuildFolderFlagsDescriptions, 'Bitfield for guild folder display behaviour', 'GuildFolderFlags', ) .default(0) .describe('Bitfield for guild folder display behaviour'), icon: GuildFolderIconSchema.default(DEFAULT_GUILD_FOLDER_ICON).describe('Selected icon for the guild folder'), guild_ids: z.array(SnowflakeType).max(200).describe('Guild IDs in this folder'), }); export const UserStatusType = withOpenApiType( createNamedStringLiteralUnion( [ [StatusTypes.ONLINE, 'ONLINE', 'User is online and available'], [StatusTypes.DND, 'DND', 'Do not disturb – notifications are suppressed'], [StatusTypes.IDLE, 'IDLE', 'User is away or inactive'], [StatusTypes.INVISIBLE, 'INVISIBLE', 'User appears offline but can still receive messages'], ] as const, 'User online status', ), 'UserStatusType', ); export const UserThemeType = withOpenApiType( createNamedStringLiteralUnion( [ [ThemeTypes.DARK, 'DARK', 'Dark colour theme'], [ThemeTypes.COAL, 'COAL', 'Coal/darker colour theme'], [ThemeTypes.LIGHT, 'LIGHT', 'Light colour theme'], [ThemeTypes.SYSTEM, 'SYSTEM', 'Follow system colour preference'], ] as const, 'UI theme preference', ), 'UserThemeType', ); export const UserSettingsUpdateRequest = z .object({ flags: createBitflagInt32Type( FriendSourceFlags, FriendSourceFlagsDescriptions, 'Friend source flags', 'FriendSourceFlags', ), status: UserStatusType, status_resets_at: DateTimeType.nullish().describe('When status resets'), status_resets_to: UserStatusType.nullish(), theme: UserThemeType, locale: LocaleSchema, restricted_guilds: z.array(SnowflakeType).max(200).describe('Guilds with DM restrictions'), bot_restricted_guilds: z.array(SnowflakeType).max(200).describe('Guilds with bot DM restrictions'), default_guilds_restricted: z.boolean().describe('Default DM restriction for new guilds'), bot_default_guilds_restricted: z.boolean().describe('Default bot DM restriction for new guilds'), inline_attachment_media: z.boolean().describe('Display attachments inline'), inline_embed_media: z.boolean().describe('Display embed media inline'), gif_auto_play: z.boolean().describe('Auto-play GIFs'), render_embeds: z.boolean().describe('Render message embeds'), render_reactions: z.boolean().describe('Display reactions'), animate_emoji: z.boolean().describe('Animate custom emoji'), animate_stickers: withFieldDescription(StickerAnimationOptionsSchema, 'Sticker animation preference'), render_spoilers: withFieldDescription(RenderSpoilersSchema, 'Spoiler rendering preference'), message_display_compact: z.boolean().describe('Compact message display'), friend_source_flags: createBitflagInt32Type( FriendSourceFlags, FriendSourceFlagsDescriptions, 'Friend request source permissions', 'FriendSourceFlags', ), incoming_call_flags: createBitflagInt32Type( IncomingCallFlags, IncomingCallFlagsDescriptions, 'Incoming call settings', 'IncomingCallFlags', ), group_dm_add_permission_flags: createBitflagInt32Type( GroupDmAddPermissionFlags, GroupDmAddPermissionFlagsDescriptions, 'Group DM add permissions', 'GroupDmAddPermissionFlags', ), guild_folders: z.array(GuildFolderSchema).max(200).describe('Guild folder organization'), custom_status: CustomStatusPayload.nullish().describe('Custom status'), afk_timeout: z.number().int().describe('AFK timeout in seconds'), time_format: withFieldDescription(TimeFormatTypesSchema, 'Time format preference'), developer_mode: z.boolean().describe('Developer mode enabled'), trusted_domains: z .array(z.string().min(1).max(253)) .max(1000) .describe('Trusted external link domains. Use "*" to trust all domains.'), default_hide_muted_channels: z.boolean().describe('Hide muted channels by default in new guilds'), }) .partial(); export type UserSettingsUpdateRequest = z.infer; const MuteConfigSchema = z .object({ end_time: DateTimeType.nullish().describe('When the mute expires'), selected_time_window: z.number().int().describe('Selected mute duration'), }) .nullish(); const ChannelOverrideSchema = z.object({ collapsed: z.boolean().describe('Channel category collapsed'), message_notifications: withFieldDescription(UserNotificationSettingsSchema, 'Channel notification level'), muted: z.boolean().describe('Channel muted'), mute_config: MuteConfigSchema.describe('Channel mute configuration'), }); export const UserGuildSettingsUpdateRequest = z .object({ message_notifications: withFieldDescription(UserNotificationSettingsSchema, 'Default guild notification level'), muted: z.boolean().describe('Guild muted'), mute_config: MuteConfigSchema.describe('Guild mute configuration'), mobile_push: z.boolean().describe('Mobile push notifications enabled'), suppress_everyone: z.boolean().describe('Suppress @everyone mentions'), suppress_roles: z.boolean().describe('Suppress role mentions'), hide_muted_channels: z.boolean().describe('Hide muted channels'), channel_overrides: z .record(SnowflakeStringType, ChannelOverrideSchema) .nullable() .describe('Per-channel overrides'), }) .partial(); export type UserGuildSettingsUpdateRequest = z.infer; export const EmptyBodyRequest = z.object({}).optional(); export type EmptyBodyRequest = z.infer; export const UserTagCheckQueryRequest = z.object({ username: UsernameType.describe('The username to check'), discriminator: DiscriminatorType.describe('The discriminator to check'), }); export type UserTagCheckQueryRequest = z.infer; export const UserProfileQueryRequest = z.object({ guild_id: SnowflakeType.optional().describe('Optional guild ID for guild-specific profile'), with_mutual_friends: QueryBooleanType.describe('Whether to include mutual friends'), with_mutual_guilds: QueryBooleanType.describe('Whether to include mutual guilds'), }); export type UserProfileQueryRequest = z.infer; export const UserNoteUpdateRequest = z.object({ note: createStringType(1, 256).nullish().describe('The note text (max 256 characters)'), }); export type UserNoteUpdateRequest = z.infer; export const PushSubscribeRequest = z.object({ endpoint: URLType.describe('The push subscription endpoint URL'), keys: z.object({ p256dh: createStringType(1, 1024).describe('The P-256 ECDH public key'), auth: createStringType(1, 1024).describe('The authentication secret'), }), user_agent: createStringType(1, 1024).optional().describe('The user agent string'), }); export type PushSubscribeRequest = z.infer; export const SubscriptionIdParam = z.object({ subscription_id: createStringType(1, 256).describe('The ID of the push subscription'), }); export type SubscriptionIdParam = z.infer; export const PreloadMessagesRequest = z.object({ channels: z.array(SnowflakeType).max(100).describe('Array of channel IDs to preload messages from (max 100)'), }); export type PreloadMessagesRequest = z.infer; export const UserMentionsQueryRequest = z.object({ limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25}).describe( 'Maximum number of mentions to return (1-100, default 25)', ), roles: QueryBooleanType.optional().default(true).describe('Whether to include role mentions'), everyone: QueryBooleanType.optional().default(true).describe('Whether to include @everyone mentions'), guilds: QueryBooleanType.optional().default(true).describe('Whether to include guild mentions'), before: SnowflakeType.optional().describe('Get mentions before this message ID'), }); export type UserMentionsQueryRequest = z.infer; export const UserSavedMessagesQueryRequest = z.object({ limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25}).describe( 'Maximum number of saved messages to return (1-100, default 25)', ), }); export type UserSavedMessagesQueryRequest = z.infer; export const SaveMessageRequest = z.object({ channel_id: SnowflakeType.describe('The ID of the channel containing the message'), message_id: SnowflakeType.describe('The ID of the message to save'), }); export type SaveMessageRequest = z.infer;