445 lines
19 KiB
TypeScript
445 lines
19 KiB
TypeScript
/*
|
||
* 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 <https://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
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<typeof UserUpdateRequest>;
|
||
|
||
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<typeof UserUpdateWithVerificationRequest>;
|
||
|
||
export const EmailChangeTicketRequest = z.object({
|
||
ticket: createStringType().describe('Email change ticket identifier'),
|
||
});
|
||
|
||
export type EmailChangeTicketRequest = z.infer<typeof EmailChangeTicketRequest>;
|
||
|
||
export const EmailChangeVerifyOriginalRequest = EmailChangeTicketRequest.extend({
|
||
code: createStringType().describe('Verification code sent to the original email address'),
|
||
});
|
||
|
||
export type EmailChangeVerifyOriginalRequest = z.infer<typeof EmailChangeVerifyOriginalRequest>;
|
||
|
||
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<typeof EmailChangeRequestNewRequest>;
|
||
|
||
export const EmailChangeVerifyNewRequest = EmailChangeVerifyOriginalRequest.extend({
|
||
original_proof: createStringType().describe('Proof token obtained from verifying the original email'),
|
||
});
|
||
|
||
export type EmailChangeVerifyNewRequest = z.infer<typeof EmailChangeVerifyNewRequest>;
|
||
|
||
export const EmailChangeBouncedRequestNewRequest = z.object({
|
||
new_email: EmailType.describe('Replacement email address used when the current email has bounced'),
|
||
});
|
||
|
||
export type EmailChangeBouncedRequestNewRequest = z.infer<typeof EmailChangeBouncedRequestNewRequest>;
|
||
|
||
export const EmailChangeBouncedVerifyNewRequest = EmailChangeTicketRequest.extend({
|
||
code: createStringType().describe('Verification code sent to the replacement email address'),
|
||
});
|
||
|
||
export type EmailChangeBouncedVerifyNewRequest = z.infer<typeof EmailChangeBouncedVerifyNewRequest>;
|
||
|
||
export const PasswordChangeTicketRequest = z.object({
|
||
ticket: createStringType().describe('Password change ticket identifier'),
|
||
});
|
||
|
||
export type PasswordChangeTicketRequest = z.infer<typeof PasswordChangeTicketRequest>;
|
||
|
||
export const PasswordChangeVerifyRequest = PasswordChangeTicketRequest.extend({
|
||
code: createStringType().describe('Verification code sent to the email address'),
|
||
});
|
||
|
||
export type PasswordChangeVerifyRequest = z.infer<typeof PasswordChangeVerifyRequest>;
|
||
|
||
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<typeof PasswordChangeCompleteRequest>;
|
||
|
||
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<typeof FriendRequestByTagRequest>;
|
||
|
||
export const RelationshipNicknameUpdateRequest = z.object({
|
||
nickname: createStringType(0, 256).nullable().describe('Custom nickname for this friend (max 256 characters)'),
|
||
});
|
||
|
||
export type RelationshipNicknameUpdateRequest = z.infer<typeof RelationshipNicknameUpdateRequest>;
|
||
|
||
export const RelationshipTypePutRequest = z
|
||
.object({
|
||
type: withFieldDescription(RelationshipTypesSchema, 'Type of relationship to create').optional(),
|
||
})
|
||
.optional();
|
||
|
||
export type RelationshipTypePutRequest = z.infer<typeof RelationshipTypePutRequest>;
|
||
|
||
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<typeof CustomStatusPayload>;
|
||
|
||
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<typeof CreatePrivateChannelRequest>;
|
||
|
||
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<typeof UserSettingsUpdateRequest>;
|
||
|
||
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<typeof UserGuildSettingsUpdateRequest>;
|
||
|
||
export const EmptyBodyRequest = z.object({}).optional();
|
||
export type EmptyBodyRequest = z.infer<typeof EmptyBodyRequest>;
|
||
|
||
export const UserTagCheckQueryRequest = z.object({
|
||
username: UsernameType.describe('The username to check'),
|
||
discriminator: DiscriminatorType.describe('The discriminator to check'),
|
||
});
|
||
export type UserTagCheckQueryRequest = z.infer<typeof UserTagCheckQueryRequest>;
|
||
|
||
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<typeof UserProfileQueryRequest>;
|
||
|
||
export const UserNoteUpdateRequest = z.object({
|
||
note: createStringType(1, 256).nullish().describe('The note text (max 256 characters)'),
|
||
});
|
||
export type UserNoteUpdateRequest = z.infer<typeof UserNoteUpdateRequest>;
|
||
|
||
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<typeof PushSubscribeRequest>;
|
||
|
||
export const SubscriptionIdParam = z.object({
|
||
subscription_id: createStringType(1, 256).describe('The ID of the push subscription'),
|
||
});
|
||
export type SubscriptionIdParam = z.infer<typeof SubscriptionIdParam>;
|
||
|
||
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<typeof PreloadMessagesRequest>;
|
||
|
||
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<typeof UserMentionsQueryRequest>;
|
||
|
||
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<typeof UserSavedMessagesQueryRequest>;
|
||
|
||
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<typeof SaveMessageRequest>;
|