refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,40 @@
/*
* 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/>.
*/
declare const GuildIdBrand: unique symbol;
declare const ChannelIdBrand: unique symbol;
declare const UserIdBrand: unique symbol;
declare const RoleIdBrand: unique symbol;
declare const MessageIdBrand: unique symbol;
declare const WebhookIdBrand: unique symbol;
declare const EmojiIdBrand: unique symbol;
declare const StickerIdBrand: unique symbol;
declare const AttachmentIdBrand: unique symbol;
declare const InviteCodeBrand: unique symbol;
export type GuildId = string & {readonly __brand: typeof GuildIdBrand};
export type ChannelId = string & {readonly __brand: typeof ChannelIdBrand};
export type UserId = string & {readonly __brand: typeof UserIdBrand};
export type RoleId = string & {readonly __brand: typeof RoleIdBrand};
export type MessageId = string & {readonly __brand: typeof MessageIdBrand};
export type WebhookId = string & {readonly __brand: typeof WebhookIdBrand};
export type EmojiId = string & {readonly __brand: typeof EmojiIdBrand};
export type StickerId = string & {readonly __brand: typeof StickerIdBrand};
export type AttachmentId = string & {readonly __brand: typeof AttachmentIdBrand};
export type InviteCode = string & {readonly __brand: typeof InviteCodeBrand};

View File

@@ -0,0 +1,43 @@
/*
* 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/>.
*/
export interface SearchOptions {
hitsPerPage?: number;
page?: number;
limit?: number;
offset?: number;
}
export interface SearchResult<TResult> {
hits: Array<TResult>;
total: number;
}
export interface ISearchAdapter<TFilters, TResult> {
initialize(): Promise<void>;
shutdown(): Promise<void>;
indexDocument(doc: TResult): Promise<void>;
indexDocuments(docs: Array<TResult>): Promise<void>;
updateDocument(doc: TResult): Promise<void>;
deleteDocument(id: string): Promise<void>;
deleteDocuments(ids: Array<string>): Promise<void>;
deleteAllDocuments(): Promise<void>;
search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TResult>>;
isAvailable(): boolean;
}

View File

@@ -0,0 +1,243 @@
/*
* 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/>.
*/
export interface SearchableMessage {
readonly [key: string]: unknown;
id: string;
channelId: string;
guildId: string | null;
authorId: string | null;
authorType: 'user' | 'bot' | 'webhook';
content: string | null;
createdAt: number;
editedAt: number | null;
isPinned: boolean;
mentionedUserIds: Array<string>;
mentionEveryone: boolean;
hasLink: boolean;
hasEmbed: boolean;
hasPoll: boolean;
hasFile: boolean;
hasVideo: boolean;
hasImage: boolean;
hasSound: boolean;
hasSticker: boolean;
hasForward: boolean;
embedTypes: Array<string>;
embedProviders: Array<string>;
linkHostnames: Array<string>;
attachmentFilenames: Array<string>;
attachmentExtensions: Array<string>;
}
export interface MessageSearchFilters {
maxId?: string;
minId?: string;
content?: string;
contents?: Array<string>;
exactPhrases?: Array<string>;
guildId?: string;
channelId?: string;
channelIds?: Array<string>;
excludeChannelIds?: Array<string>;
authorId?: Array<string>;
authorType?: Array<string>;
excludeAuthorType?: Array<string>;
excludeAuthorIds?: Array<string>;
mentions?: Array<string>;
excludeMentions?: Array<string>;
mentionEveryone?: boolean;
pinned?: boolean;
has?: Array<string>;
excludeHas?: Array<string>;
embedType?: Array<'image' | 'video' | 'sound' | 'article'>;
excludeEmbedTypes?: Array<'image' | 'video' | 'sound' | 'article'>;
embedProvider?: Array<string>;
excludeEmbedProviders?: Array<string>;
linkHostname?: Array<string>;
excludeLinkHostnames?: Array<string>;
attachmentFilename?: Array<string>;
excludeAttachmentFilenames?: Array<string>;
attachmentExtension?: Array<string>;
excludeAttachmentExtensions?: Array<string>;
sortBy?: 'timestamp' | 'relevance';
sortOrder?: 'asc' | 'desc';
includeNsfw?: boolean;
}
export interface SearchableGuild {
id: string;
ownerId: string;
name: string;
vanityUrlCode: string | null;
iconHash: string | null;
bannerHash: string | null;
splashHash: string | null;
features: Array<string>;
verificationLevel: number;
mfaLevel: number;
nsfwLevel: number;
memberCount: number;
createdAt: number;
discoveryDescription: string | null;
discoveryCategory: number | null;
isDiscoverable: boolean;
onlineCount: number;
}
export interface GuildSearchFilters {
ownerId?: string;
minMembers?: number;
maxMembers?: number;
verificationLevel?: number;
mfaLevel?: number;
nsfwLevel?: number;
hasFeature?: Array<string>;
isDiscoverable?: boolean;
discoveryCategory?: number;
sortBy?: 'createdAt' | 'memberCount' | 'onlineCount' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export interface SearchableUser {
id: string;
username: string;
discriminator: number;
email: string | null;
phone: string | null;
isBot: boolean;
isSystem: boolean;
flags: string;
premiumType: number | null;
emailVerified: boolean;
emailBounced: boolean;
suspiciousActivityFlags: number;
acls: Array<string>;
createdAt: number;
lastActiveAt: number | null;
tempBannedUntil: number | null;
pendingDeletionAt: number | null;
stripeSubscriptionId: string | null;
stripeCustomerId: string | null;
}
export interface UserSearchFilters {
isBot?: boolean;
isSystem?: boolean;
emailVerified?: boolean;
emailBounced?: boolean;
hasPremium?: boolean;
isTempBanned?: boolean;
isPendingDeletion?: boolean;
hasAcl?: Array<string>;
minSuspiciousActivityFlags?: number;
createdAtGreaterThanOrEqual?: number;
createdAtLessThanOrEqual?: number;
sortBy?: 'createdAt' | 'lastActiveAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export interface SearchableReport {
id: string;
reporterId: string;
reportedAt: number;
status: number;
reportType: number;
category: string;
additionalInfo: string | null;
reportedUserId: string | null;
reportedGuildId: string | null;
reportedGuildName: string | null;
reportedMessageId: string | null;
reportedChannelId: string | null;
reportedChannelName: string | null;
guildContextId: string | null;
resolvedAt: number | null;
resolvedByAdminId: string | null;
publicComment: string | null;
createdAt: number;
}
export interface ReportSearchFilters {
reporterId?: string;
status?: number;
reportType?: number;
category?: string;
reportedUserId?: string;
reportedGuildId?: string;
reportedMessageId?: string;
guildContextId?: string;
resolvedByAdminId?: string;
isResolved?: boolean;
sortBy?: 'createdAt' | 'reportedAt' | 'resolvedAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export interface SearchableAuditLog {
id: string;
logId: string;
adminUserId: string;
targetType: string;
targetId: string;
action: string;
auditLogReason: string | null;
createdAt: number;
}
export interface AuditLogSearchFilters {
adminUserId?: string;
targetType?: string;
targetId?: string;
action?: string;
sortBy?: 'createdAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export interface SearchableGuildMember {
readonly [key: string]: unknown;
id: string;
guildId: string;
userId: string;
username: string;
discriminator: string;
globalName: string | null;
nickname: string | null;
roleIds: Array<string>;
joinedAt: number;
joinSourceType: number | null;
sourceInviteCode: string | null;
inviterId: string | null;
userCreatedAt: number;
isBot: boolean;
}
export interface GuildMemberSearchFilters {
guildId: string;
query?: string;
roleIds?: Array<string>;
joinedAtGte?: number;
joinedAtLte?: number;
joinSourceType?: Array<number>;
sourceInviteCode?: Array<string>;
userCreatedAtGte?: number;
userCreatedAtLte?: number;
isBot?: boolean;
sortBy?: 'joinedAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}

View File

@@ -0,0 +1,229 @@
/*
* 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 {GuildOperations, GuildOperationsDescriptions} from '@fluxer/constants/src/GuildConstants';
import {GuildBanCreateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {GuildFeatureSchema} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {VanityURLCodeType} from '@fluxer/schema/src/primitives/ChannelValidators';
import {
DefaultMessageNotificationsSchema,
GuildExplicitContentFilterSchema,
GuildMFALevelSchema,
GuildVerificationLevelSchema,
NSFWLevelSchema,
} from '@fluxer/schema/src/primitives/GuildValidators';
import {
createBitflagInt32Type,
createNamedStringLiteralUnion,
createStringType,
Int32Type,
SnowflakeStringType,
SnowflakeType,
withFieldDescription,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const GuildAdminResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this guild'),
name: z.string().describe('The name of the guild'),
features: z.array(GuildFeatureSchema).max(100).describe('Array of guild feature flags'),
owner_id: SnowflakeStringType.describe('The ID of the guild owner'),
icon: z.string().nullable().describe('The hash of the guild icon'),
banner: z.string().nullable().describe('The hash of the guild banner'),
member_count: Int32Type.describe('The number of members in the guild'),
});
export type GuildAdminResponse = z.infer<typeof GuildAdminResponse>;
export const ListUserGuildsResponse = z.object({
guilds: z.array(GuildAdminResponse).max(200),
});
export type ListUserGuildsResponse = z.infer<typeof ListUserGuildsResponse>;
export const ListUserGuildsRequest = z.object({
user_id: SnowflakeType,
before: SnowflakeType.optional(),
after: SnowflakeType.optional(),
limit: z.number().int().min(1).max(200).default(200),
with_counts: z.boolean().default(false),
});
export type ListUserGuildsRequest = z.infer<typeof ListUserGuildsRequest>;
export const LookupGuildRequest = z.object({
guild_id: SnowflakeType,
});
export type LookupGuildRequest = z.infer<typeof LookupGuildRequest>;
export const ListGuildMembersRequest = z.object({
guild_id: SnowflakeType,
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
});
export type ListGuildMembersRequest = z.infer<typeof ListGuildMembersRequest>;
export const BanGuildMemberRequest = GuildBanCreateRequest.extend({
guild_id: SnowflakeType,
user_id: SnowflakeType,
});
export type BanGuildMemberRequest = z.infer<typeof BanGuildMemberRequest>;
export const KickGuildMemberRequest = z.object({
guild_id: SnowflakeType,
user_id: SnowflakeType,
});
export type KickGuildMemberRequest = z.infer<typeof KickGuildMemberRequest>;
export const SearchGuildsRequest = z.object({
query: createStringType(1, 1024).optional(),
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
});
export type SearchGuildsRequest = z.infer<typeof SearchGuildsRequest>;
export const ReloadGuildRequest = z.object({
guild_id: SnowflakeType,
});
export type ReloadGuildRequest = z.infer<typeof ReloadGuildRequest>;
export const ShutdownGuildRequest = z.object({
guild_id: SnowflakeType,
});
export type ShutdownGuildRequest = z.infer<typeof ShutdownGuildRequest>;
export const GetProcessMemoryStatsRequest = z.object({
limit: z.number().int().min(1).max(100).default(25),
});
export type GetProcessMemoryStatsRequest = z.infer<typeof GetProcessMemoryStatsRequest>;
export const UpdateGuildFeaturesRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to update'),
add_features: z.array(GuildFeatureSchema).max(100).default([]).describe('Guild features to add'),
remove_features: z.array(GuildFeatureSchema).max(100).default([]).describe('Guild features to remove'),
});
export type UpdateGuildFeaturesRequest = z.infer<typeof UpdateGuildFeaturesRequest>;
export const ForceAddUserToGuildRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to add to the guild'),
guild_id: SnowflakeType.describe('ID of the guild to add the user to'),
});
export type ForceAddUserToGuildRequest = z.infer<typeof ForceAddUserToGuildRequest>;
const GuildImageFieldEnum = createNamedStringLiteralUnion(
[
['icon', 'icon', 'Guild icon image'],
['banner', 'banner', 'Guild banner image'],
['splash', 'splash', 'Guild invite splash image'],
['embed_splash', 'embed_splash', 'Guild embedded invite splash image'],
],
'Guild image field that can be cleared',
);
export const ClearGuildFieldsRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to clear fields for'),
fields: z.array(GuildImageFieldEnum).max(10).describe('List of guild image fields to clear'),
});
export type ClearGuildFieldsRequest = z.infer<typeof ClearGuildFieldsRequest>;
export const DeleteGuildRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to delete'),
});
export type DeleteGuildRequest = z.infer<typeof DeleteGuildRequest>;
export const UpdateGuildVanityRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to update'),
vanity_url_code: VanityURLCodeType.nullable().describe('New vanity URL code, or null to remove'),
});
export type UpdateGuildVanityRequest = z.infer<typeof UpdateGuildVanityRequest>;
export const UpdateGuildNameRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to update'),
name: createStringType(1, 100).describe('New name for the guild'),
});
export type UpdateGuildNameRequest = z.infer<typeof UpdateGuildNameRequest>;
export const UpdateGuildSettingsRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to update'),
verification_level: withFieldDescription(
GuildVerificationLevelSchema,
'Required verification level for guild members',
).optional(),
mfa_level: withFieldDescription(GuildMFALevelSchema, 'Required MFA level for moderators').optional(),
nsfw_level: withFieldDescription(NSFWLevelSchema, 'NSFW content level for the guild').optional(),
explicit_content_filter: withFieldDescription(
GuildExplicitContentFilterSchema,
'Explicit content filter level',
).optional(),
default_message_notifications: withFieldDescription(
DefaultMessageNotificationsSchema,
'Default notification setting for new members',
).optional(),
disabled_operations: createBitflagInt32Type(
GuildOperations,
GuildOperationsDescriptions,
'Bitmask of disabled guild operations',
'GuildOperations',
).optional(),
});
export type UpdateGuildSettingsRequest = z.infer<typeof UpdateGuildSettingsRequest>;
export const TransferGuildOwnershipRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to transfer'),
new_owner_id: SnowflakeType.describe('ID of the user to transfer ownership to'),
});
export type TransferGuildOwnershipRequest = z.infer<typeof TransferGuildOwnershipRequest>;
export const BulkUpdateGuildFeaturesRequest = z.object({
guild_ids: z.array(SnowflakeType).max(1000).describe('List of guild IDs to update'),
add_features: z
.array(GuildFeatureSchema)
.max(100)
.default([])
.describe('Guild features to add to all specified guilds'),
remove_features: z
.array(GuildFeatureSchema)
.max(100)
.default([])
.describe('Guild features to remove from all specified guilds'),
});
export type BulkUpdateGuildFeaturesRequest = z.infer<typeof BulkUpdateGuildFeaturesRequest>;
export const BulkAddGuildMembersRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to add members to'),
user_ids: z.array(SnowflakeType).max(1000).describe('List of user IDs to add as members'),
});
export type BulkAddGuildMembersRequest = z.infer<typeof BulkAddGuildMembersRequest>;

View File

@@ -0,0 +1,89 @@
/*
* 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 {FilenameType} from '@fluxer/schema/src/primitives/FileValidators';
import {Int32Type, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const LookupMessageRequest = z.object({
channel_id: SnowflakeType,
message_id: SnowflakeType,
context_limit: z.number().int().min(1).max(100).default(50),
});
export type LookupMessageRequest = z.infer<typeof LookupMessageRequest>;
export const LookupMessageByAttachmentRequest = z.object({
channel_id: SnowflakeType,
attachment_id: SnowflakeType,
filename: FilenameType,
context_limit: z.number().int().min(1).max(100).default(50),
});
export type LookupMessageByAttachmentRequest = z.infer<typeof LookupMessageByAttachmentRequest>;
export const DeleteMessageRequest = z.object({
channel_id: SnowflakeType,
message_id: SnowflakeType,
});
export type DeleteMessageRequest = z.infer<typeof DeleteMessageRequest>;
const MessageShredEntryType = z.object({
channel_id: SnowflakeType,
message_id: SnowflakeType,
});
export const MessageShredRequest = z.object({
user_id: SnowflakeType,
entries: z.array(MessageShredEntryType).min(1).max(1000),
});
export type MessageShredRequest = z.infer<typeof MessageShredRequest>;
export const MessageShredResponse = z.object({
success: z.literal(true),
job_id: z.string(),
requested: z.number().int().min(0).optional(),
});
export type MessageShredResponse = z.infer<typeof MessageShredResponse>;
export const MessageShredStatusRequest = z.object({
job_id: z.string(),
});
export type MessageShredStatusRequest = z.infer<typeof MessageShredStatusRequest>;
export const DeleteAllUserMessagesRequest = z.object({
user_id: SnowflakeType,
dry_run: z.boolean().default(true),
});
export type DeleteAllUserMessagesRequest = z.infer<typeof DeleteAllUserMessagesRequest>;
export const DeleteAllUserMessagesResponse = z.object({
success: z.literal(true),
dry_run: z.boolean(),
channel_count: Int32Type,
message_count: Int32Type,
job_id: z.string().optional(),
});
export type DeleteAllUserMessagesResponse = z.infer<typeof DeleteAllUserMessagesResponse>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,416 @@
/*
* 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 {
SuspiciousActivityFlags,
SuspiciousActivityFlagsDescriptions,
UserFlags,
UserFlagsDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {
createBitflagInt32Type,
createBitflagStringType,
createNamedStringLiteralUnion,
createStringType,
Int32Type,
SnowflakeStringType,
SnowflakeType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {EmailType, UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
import {z} from 'zod';
export const UserAdminResponseSchema = z.object({
id: SnowflakeStringType,
username: z.string(),
discriminator: Int32Type,
global_name: z.string().nullable(),
bot: z.boolean(),
system: z.boolean(),
flags: createBitflagStringType(UserFlags, UserFlagsDescriptions, 'User account flags (64-bit)', 'UserFlags'),
avatar: z.string().nullable(),
banner: z.string().nullable(),
bio: z.string().nullable(),
pronouns: z.string().nullable(),
accent_color: Int32Type.nullable(),
email: z.string().nullable(),
email_verified: z.boolean(),
email_bounced: z.boolean(),
phone: z.string().nullable(),
date_of_birth: z.string().nullable(),
locale: z.string().nullable(),
premium_type: Int32Type.nullable(),
premium_since: z.string().nullable(),
premium_until: z.string().nullable(),
suspicious_activity_flags: createBitflagInt32Type(
SuspiciousActivityFlags,
SuspiciousActivityFlagsDescriptions,
'Suspicious activity indicators',
'SuspiciousActivityFlags',
),
temp_banned_until: z.string().nullable(),
pending_deletion_at: z.string().nullable(),
pending_bulk_message_deletion_at: z.string().nullable(),
deletion_reason_code: Int32Type.nullable(),
deletion_public_reason: z.string().nullable(),
acls: z.array(z.string()).max(100),
traits: z.array(z.string()).max(100),
has_totp: z.boolean(),
authenticator_types: z.array(Int32Type).max(10),
last_active_at: z.string().nullable(),
last_active_ip: z.string().nullable(),
last_active_ip_reverse: z.string().nullable(),
last_active_location: z.string().nullable(),
});
export type UserAdminResponse = z.infer<typeof UserAdminResponseSchema>;
export const LookupUserByQueryRequest = z.object({
query: createStringType(1, 1024),
});
export type LookupUserByQueryRequest = z.infer<typeof LookupUserByQueryRequest>;
export const LookupUserByIdsRequest = z.object({
user_ids: z.array(SnowflakeType).max(100),
});
export type LookupUserByIdsRequest = z.infer<typeof LookupUserByIdsRequest>;
export const LookupUserRequest = z.union([LookupUserByQueryRequest, LookupUserByIdsRequest]);
export type LookupUserRequest = z.infer<typeof LookupUserRequest>;
export const SearchUsersRequest = z.object({
query: createStringType(1, 1024).optional(),
limit: z.number().int().min(1).max(200).default(50),
offset: z.number().int().min(0).default(0),
});
export type SearchUsersRequest = z.infer<typeof SearchUsersRequest>;
export const ListUserSessionsRequest = z.object({
user_id: SnowflakeType,
});
export type ListUserSessionsRequest = z.infer<typeof ListUserSessionsRequest>;
export const UserContactChangeLogEntrySchema = z.object({
event_id: z.string(),
field: z.string(),
old_value: z.string().nullable(),
new_value: z.string().nullable(),
reason: z.string().nullable(),
actor_user_id: z.string().nullable(),
event_at: z.string(),
});
export type UserContactChangeLogEntry = z.infer<typeof UserContactChangeLogEntrySchema>;
export const ListUserChangeLogResponseSchema = z.object({
entries: z.array(UserContactChangeLogEntrySchema).max(200),
next_page_token: z.string().nullable(),
});
export type ListUserChangeLogResponse = z.infer<typeof ListUserChangeLogResponseSchema>;
export const AdminUsersMeResponse = z.object({
user: UserAdminResponseSchema,
});
export type AdminUsersMeResponse = z.infer<typeof AdminUsersMeResponse>;
export const UserMutationResponse = z.object({
user: UserAdminResponseSchema,
});
export type UserMutationResponse = z.infer<typeof UserMutationResponse>;
export const VerificationActionResponse = z.object({
success: z.boolean(),
});
export type VerificationActionResponse = z.infer<typeof VerificationActionResponse>;
export const LookupUserResponse = z.object({
users: z.array(UserAdminResponseSchema).max(100),
});
export type LookupUserResponse = z.infer<typeof LookupUserResponse>;
export const UserSessionResponse = z.object({
session_id_hash: createStringType(8, 256).describe('Hashed session identifier (base64url)'),
created_at: z.string().describe('ISO timestamp when the session was created'),
approx_last_used_at: z.string().describe('ISO timestamp of the session last usage (approximate)'),
client_ip: createStringType(1, 64).describe('Client IP address'),
client_ip_reverse: z.string().nullable().describe('Reverse DNS hostname for the client IP (PTR), if available'),
client_os: z.string().nullable().describe('Client operating system, if detected'),
client_platform: z.string().nullable().describe('Client platform, if detected'),
client_location: z.string().nullable().describe('Approximate geo location label for the client IP, if available'),
});
export type UserSessionResponse = z.infer<typeof UserSessionResponse>;
export const ListUserSessionsResponse = z.object({
sessions: z.array(UserSessionResponse).max(100),
});
export type ListUserSessionsResponse = z.infer<typeof ListUserSessionsResponse>;
export const ListUserDmChannelsRequest = z
.object({
user_id: SnowflakeType.describe('ID of the user to list DM channels for'),
before: SnowflakeType.optional().describe('Return channels with IDs lower than this channel ID'),
after: SnowflakeType.optional().describe('Return channels with IDs higher than this channel ID'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum number of DM channels to return'),
})
.refine((value) => value.before === undefined || value.after === undefined, {
message: 'before and after cannot both be provided',
});
export type ListUserDmChannelsRequest = z.infer<typeof ListUserDmChannelsRequest>;
export const AdminUserDmChannelSchema = z.object({
channel_id: SnowflakeStringType,
channel_type: Int32Type.nullable(),
recipient_ids: z.array(SnowflakeStringType).max(100),
last_message_id: SnowflakeStringType.nullable(),
is_open: z.boolean(),
});
export type AdminUserDmChannel = z.infer<typeof AdminUserDmChannelSchema>;
export const ListUserDmChannelsResponse = z.object({
channels: z.array(AdminUserDmChannelSchema).max(200),
});
export type ListUserDmChannelsResponse = z.infer<typeof ListUserDmChannelsResponse>;
export const TerminateSessionsResponse = z.object({
terminated_count: Int32Type,
});
export type TerminateSessionsResponse = z.infer<typeof TerminateSessionsResponse>;
const UserFlagValueType = createBitflagStringType(
UserFlags,
UserFlagsDescriptions,
'A single user flag value to add or remove',
'UserFlags',
);
export const UpdateUserFlagsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to update'),
add_flags: z.array(UserFlagValueType).max(64).default([]).describe('User flags to add'),
remove_flags: z.array(UserFlagValueType).max(64).default([]).describe('User flags to remove'),
});
export type UpdateUserFlagsRequest = z.infer<typeof UpdateUserFlagsRequest>;
export const DisableMfaRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to disable MFA for'),
});
export type DisableMfaRequest = z.infer<typeof DisableMfaRequest>;
export const CancelBulkMessageDeletionRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to cancel bulk message deletion for'),
});
export type CancelBulkMessageDeletionRequest = z.infer<typeof CancelBulkMessageDeletionRequest>;
const UserProfileFieldEnum = createNamedStringLiteralUnion(
[
['avatar', 'avatar', 'User profile avatar image'],
['banner', 'banner', 'User profile banner image'],
['bio', 'bio', 'User biography text'],
['pronouns', 'pronouns', 'User pronouns'],
['global_name', 'global_name', 'User display name'],
],
'User profile field that can be cleared',
);
export const ClearUserFieldsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to clear fields for'),
fields: z.array(UserProfileFieldEnum).max(10).describe('List of profile fields to clear'),
});
export type ClearUserFieldsRequest = z.infer<typeof ClearUserFieldsRequest>;
export const SetUserBotStatusRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to update'),
bot: z.boolean().describe('Whether the user should be marked as a bot'),
});
export type SetUserBotStatusRequest = z.infer<typeof SetUserBotStatusRequest>;
export const SetUserSystemStatusRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to update'),
system: z.boolean().describe('Whether the user should be marked as a system user'),
});
export type SetUserSystemStatusRequest = z.infer<typeof SetUserSystemStatusRequest>;
export const VerifyUserEmailRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to verify email for'),
});
export type VerifyUserEmailRequest = z.infer<typeof VerifyUserEmailRequest>;
export const SendPasswordResetRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to send password reset to'),
});
export type SendPasswordResetRequest = z.infer<typeof SendPasswordResetRequest>;
export const ChangeUsernameRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to change username for'),
username: UsernameType.describe('New username for the user'),
discriminator: Int32Type.optional().describe('Legacy discriminator value'),
});
export type ChangeUsernameRequest = z.infer<typeof ChangeUsernameRequest>;
export const ChangeEmailRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to change email for'),
email: EmailType.describe('New email address for the user'),
});
export type ChangeEmailRequest = z.infer<typeof ChangeEmailRequest>;
export const TerminateSessionsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to terminate sessions for'),
});
export type TerminateSessionsRequest = z.infer<typeof TerminateSessionsRequest>;
export const TempBanUserRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to temporarily ban'),
duration_hours: z
.number()
.int()
.min(0)
.max(8760)
.describe('Duration of the ban in hours. Use 0 for a permanent ban (until manually unbanned).'),
reason: createStringType(0, 512).optional().describe('Reason for the temporary ban'),
});
export type TempBanUserRequest = z.infer<typeof TempBanUserRequest>;
export const ScheduleAccountDeletionRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to schedule deletion for'),
reason_code: Int32Type.describe('Code indicating the reason for deletion'),
public_reason: createStringType(0, 512).optional().describe('Public-facing reason for the deletion'),
days_until_deletion: z
.number()
.int()
.min(1)
.max(365)
.default(60)
.describe('Number of days until the account is deleted'),
});
export type ScheduleAccountDeletionRequest = z.infer<typeof ScheduleAccountDeletionRequest>;
export const SetUserAclsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to set ACLs for'),
acls: z.array(createStringType(1, 64)).max(100).describe('List of access control permissions to assign'),
});
export type SetUserAclsRequest = z.infer<typeof SetUserAclsRequest>;
export const SetUserTraitsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to set traits for'),
traits: z.array(createStringType(1, 64)).max(100).describe('List of traits to assign to the user'),
});
export type SetUserTraitsRequest = z.infer<typeof SetUserTraitsRequest>;
export const UnlinkPhoneRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to unlink phone from'),
});
export type UnlinkPhoneRequest = z.infer<typeof UnlinkPhoneRequest>;
export const ChangeDobRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to change date of birth for'),
date_of_birth: createStringType(10, 10)
.refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), 'Invalid date format')
.describe('New date of birth in YYYY-MM-DD format'),
});
export type ChangeDobRequest = z.infer<typeof ChangeDobRequest>;
export const UpdateSuspiciousActivityFlagsRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to update suspicious activity flags for'),
flags: createBitflagInt32Type(
SuspiciousActivityFlags,
SuspiciousActivityFlagsDescriptions,
'Bitmask of suspicious activity flags',
'SuspiciousActivityFlags',
),
});
export type UpdateSuspiciousActivityFlagsRequest = z.infer<typeof UpdateSuspiciousActivityFlagsRequest>;
export const DisableForSuspiciousActivityRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to disable for suspicious activity'),
flags: createBitflagInt32Type(
SuspiciousActivityFlags,
SuspiciousActivityFlagsDescriptions,
'Bitmask of suspicious activity flags that triggered the disable',
'SuspiciousActivityFlags',
),
});
export type DisableForSuspiciousActivityRequest = z.infer<typeof DisableForSuspiciousActivityRequest>;
export const BulkUpdateUserFlagsRequest = z.object({
user_ids: z.array(SnowflakeType).max(1000).describe('List of user IDs to update'),
add_flags: z.array(UserFlagValueType).max(64).default([]).describe('User flags to add to all specified users'),
remove_flags: z
.array(UserFlagValueType)
.max(64)
.default([])
.describe('User flags to remove from all specified users'),
});
export type BulkUpdateUserFlagsRequest = z.infer<typeof BulkUpdateUserFlagsRequest>;
export const BulkScheduleUserDeletionRequest = z.object({
user_ids: z.array(SnowflakeType).max(1000).describe('List of user IDs to schedule deletion for'),
reason_code: Int32Type.describe('Code indicating the reason for deletion'),
public_reason: createStringType(0, 512).optional().describe('Public-facing reason for the deletion'),
days_until_deletion: z
.number()
.int()
.min(1)
.max(365)
.default(60)
.describe('Number of days until the accounts are deleted'),
});
export type BulkScheduleUserDeletionRequest = z.infer<typeof BulkScheduleUserDeletionRequest>;
export const ListUserChangeLogRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to list change logs for'),
limit: z.number().min(1).max(200).default(50).describe('Maximum number of entries to return'),
page_token: z.string().optional().describe('Pagination token for the next page of results'),
});
export type ListUserChangeLogRequest = z.infer<typeof ListUserChangeLogRequest>;

View File

@@ -0,0 +1,266 @@
/*
* 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 {createStringType, SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const VoiceRegionAdminResponse = z.object({
id: z.string().describe('Unique identifier for the voice region'),
name: z.string().describe('Display name of the voice region'),
emoji: z.string().describe('Emoji representing the region'),
latitude: z.number().describe('Geographic latitude coordinate'),
longitude: z.number().describe('Geographic longitude coordinate'),
is_default: z.boolean().describe('Whether this is the default region'),
vip_only: z.boolean().describe('Whether this region is restricted to VIP users'),
required_guild_features: z.array(z.string()).max(100).describe('Guild features required to use this region'),
allowed_guild_ids: z.array(SnowflakeStringType).max(1000).describe('Guild IDs explicitly allowed to use this region'),
allowed_user_ids: z.array(SnowflakeStringType).max(1000).describe('User IDs explicitly allowed to use this region'),
created_at: z.string().nullable().describe('ISO 8601 timestamp when the region was created'),
updated_at: z.string().nullable().describe('ISO 8601 timestamp when the region was last updated'),
});
export type VoiceRegionAdminResponse = z.infer<typeof VoiceRegionAdminResponse>;
export const VoiceServerAdminResponse = z.object({
region_id: z.string().describe('ID of the region this server belongs to'),
server_id: z.string().describe('Unique identifier for the voice server'),
endpoint: z.url().describe('Client signal WebSocket endpoint URL for the voice server'),
is_active: z.boolean().describe('Whether the server is currently active'),
vip_only: z.boolean().describe('Whether this server is restricted to VIP users'),
required_guild_features: z.array(z.string()).max(100).describe('Guild features required to use this server'),
allowed_guild_ids: z.array(SnowflakeStringType).max(1000).describe('Guild IDs explicitly allowed to use this server'),
allowed_user_ids: z.array(SnowflakeStringType).max(1000).describe('User IDs explicitly allowed to use this server'),
created_at: z.string().nullable().describe('ISO 8601 timestamp when the server was created'),
updated_at: z.string().nullable().describe('ISO 8601 timestamp when the server was last updated'),
});
export type VoiceServerAdminResponse = z.infer<typeof VoiceServerAdminResponse>;
export const CreateVoiceRegionRequest = z.object({
id: createStringType(1, 64).describe('Unique identifier for the voice region'),
name: createStringType(1, 100).describe('Display name of the voice region'),
emoji: createStringType(1, 64).describe('Emoji representing the region'),
latitude: z.number().describe('Geographic latitude coordinate'),
longitude: z.number().describe('Geographic longitude coordinate'),
is_default: z.boolean().optional().default(false).describe('Whether this is the default region'),
vip_only: z.boolean().optional().default(false).describe('Whether this region is restricted to VIP users'),
required_guild_features: z
.array(createStringType(1, 64))
.max(100)
.optional()
.default([])
.describe('Guild features required to use this region'),
allowed_guild_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.default([])
.describe('Guild IDs explicitly allowed to use this region'),
allowed_user_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.default([])
.describe('User IDs explicitly allowed to use this region'),
});
export type CreateVoiceRegionRequest = z.infer<typeof CreateVoiceRegionRequest>;
export const UpdateVoiceRegionRequest = z.object({
id: createStringType(1, 64).describe('Unique identifier for the voice region'),
name: createStringType(1, 100).optional().describe('Display name of the voice region'),
emoji: createStringType(1, 64).optional().describe('Emoji representing the region'),
latitude: z.number().optional().describe('Geographic latitude coordinate'),
longitude: z.number().optional().describe('Geographic longitude coordinate'),
is_default: z.boolean().optional().describe('Whether this is the default region'),
vip_only: z.boolean().optional().describe('Whether this region is restricted to VIP users'),
required_guild_features: z
.array(createStringType(1, 64))
.max(100)
.optional()
.describe('Guild features required to use this region'),
allowed_guild_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.describe('Guild IDs explicitly allowed to use this region'),
allowed_user_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.describe('User IDs explicitly allowed to use this region'),
});
export type UpdateVoiceRegionRequest = z.infer<typeof UpdateVoiceRegionRequest>;
export const DeleteVoiceRegionRequest = z.object({
id: createStringType(1, 64).describe('ID of the voice region to delete'),
});
export type DeleteVoiceRegionRequest = z.infer<typeof DeleteVoiceRegionRequest>;
export const CreateVoiceServerRequest = z.object({
region_id: createStringType(1, 64).describe('ID of the region this server belongs to'),
server_id: createStringType(1, 64).describe('Unique identifier for the voice server'),
endpoint: z.url().describe('Client signal WebSocket endpoint URL for the voice server'),
api_key: createStringType(1, 256).describe('API key for authenticating with the voice server'),
api_secret: createStringType(1, 256).describe('API secret for authenticating with the voice server'),
is_active: z.boolean().optional().default(true).describe('Whether the server is currently active'),
vip_only: z.boolean().optional().default(false).describe('Whether this server is restricted to VIP users'),
required_guild_features: z
.array(createStringType(1, 64))
.max(100)
.optional()
.default([])
.describe('Guild features required to use this server'),
allowed_guild_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.default([])
.describe('Guild IDs explicitly allowed to use this server'),
allowed_user_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.default([])
.describe('User IDs explicitly allowed to use this server'),
});
export type CreateVoiceServerRequest = z.infer<typeof CreateVoiceServerRequest>;
export const UpdateVoiceServerRequest = z.object({
region_id: createStringType(1, 64).describe('ID of the region this server belongs to'),
server_id: createStringType(1, 64).describe('Unique identifier for the voice server'),
endpoint: z.url().optional().describe('Client signal WebSocket endpoint URL for the voice server'),
api_key: createStringType(1, 256).optional().describe('API key for authenticating with the voice server'),
api_secret: createStringType(1, 256).optional().describe('API secret for authenticating with the voice server'),
is_active: z.boolean().optional().describe('Whether the server is currently active'),
vip_only: z.boolean().optional().describe('Whether this server is restricted to VIP users'),
required_guild_features: z
.array(createStringType(1, 64))
.max(100)
.optional()
.describe('Guild features required to use this server'),
allowed_guild_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.describe('Guild IDs explicitly allowed to use this server'),
allowed_user_ids: z
.array(SnowflakeType)
.max(1000)
.optional()
.describe('User IDs explicitly allowed to use this server'),
});
export type UpdateVoiceServerRequest = z.infer<typeof UpdateVoiceServerRequest>;
export const DeleteVoiceServerRequest = z.object({
region_id: createStringType(1, 64).describe('ID of the region the server belongs to'),
server_id: createStringType(1, 64).describe('ID of the voice server to delete'),
});
export type DeleteVoiceServerRequest = z.infer<typeof DeleteVoiceServerRequest>;
export const ListVoiceRegionsRequest = z.object({
include_servers: z.boolean().optional().default(false).describe('Whether to include voice servers in the response'),
});
export type ListVoiceRegionsRequest = z.infer<typeof ListVoiceRegionsRequest>;
export const GetVoiceRegionRequest = z.object({
id: createStringType(1, 64).describe('ID of the voice region to retrieve'),
include_servers: z.boolean().optional().default(true).describe('Whether to include voice servers in the response'),
});
export type GetVoiceRegionRequest = z.infer<typeof GetVoiceRegionRequest>;
export const ListVoiceServersRequest = z.object({
region_id: createStringType(1, 64).describe('ID of the region to list servers for'),
});
export type ListVoiceServersRequest = z.infer<typeof ListVoiceServersRequest>;
export const GetVoiceServerRequest = z.object({
region_id: createStringType(1, 64).describe('ID of the region the server belongs to'),
server_id: createStringType(1, 64).describe('ID of the voice server to retrieve'),
});
export type GetVoiceServerRequest = z.infer<typeof GetVoiceServerRequest>;
export const ListVoiceRegionsResponse = z.object({
regions: z.array(VoiceRegionAdminResponse).max(100).describe('List of voice regions'),
});
export type ListVoiceRegionsResponse = z.infer<typeof ListVoiceRegionsResponse>;
export const VoiceRegionWithServersResponse = VoiceRegionAdminResponse.extend({
servers: z.array(VoiceServerAdminResponse).max(100).optional().describe('Voice servers in this region'),
});
export type VoiceRegionWithServersResponse = z.infer<typeof VoiceRegionWithServersResponse>;
export const GetVoiceRegionResponse = z.object({
region: VoiceRegionWithServersResponse.nullable().describe('Voice region details or null if not found'),
});
export type GetVoiceRegionResponse = z.infer<typeof GetVoiceRegionResponse>;
export const CreateVoiceRegionResponse = z.object({
region: VoiceRegionAdminResponse.describe('Created voice region'),
});
export type CreateVoiceRegionResponse = z.infer<typeof CreateVoiceRegionResponse>;
export const UpdateVoiceRegionResponse = z.object({
region: VoiceRegionAdminResponse.describe('Updated voice region'),
});
export type UpdateVoiceRegionResponse = z.infer<typeof UpdateVoiceRegionResponse>;
export const ListVoiceServersResponse = z.object({
servers: z.array(VoiceServerAdminResponse).max(100).describe('List of voice servers'),
});
export type ListVoiceServersResponse = z.infer<typeof ListVoiceServersResponse>;
export const GetVoiceServerResponse = z.object({
server: VoiceServerAdminResponse.nullable().describe('Voice server details or null if not found'),
});
export type GetVoiceServerResponse = z.infer<typeof GetVoiceServerResponse>;
export const CreateVoiceServerResponse = z.object({
server: VoiceServerAdminResponse.describe('Created voice server'),
});
export type CreateVoiceServerResponse = z.infer<typeof CreateVoiceServerResponse>;
export const UpdateVoiceServerResponse = z.object({
server: VoiceServerAdminResponse.describe('Updated voice server'),
});
export type UpdateVoiceServerResponse = z.infer<typeof UpdateVoiceServerResponse>;
export const DeleteVoiceResponse = z.object({
success: z.boolean().describe('Whether the deletion was successful'),
});
export type DeleteVoiceResponse = z.infer<typeof DeleteVoiceResponse>;

View File

@@ -0,0 +1,372 @@
/*
* 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 {
createNamedStringLiteralUnion,
createStringType,
SnowflakeStringType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {
EmailType,
GlobalNameType,
PasswordType,
PhoneNumberType,
UsernameType,
} from '@fluxer/schema/src/primitives/UserValidators';
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/server';
import {z} from 'zod';
export const RegisterRequest = z.object({
email: EmailType.optional().describe('Email address for the new account'),
username: UsernameType.optional().describe('Username for the new account (1-32 characters)'),
global_name: GlobalNameType.optional().describe('Display name shown to other users'),
password: PasswordType.optional().describe('Password for the new account'),
date_of_birth: createStringType(10, 10)
.refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), 'Invalid date format')
.describe('Date of birth in YYYY-MM-DD format'),
consent: z.boolean().describe('Whether user consents to terms of service'),
invite_code: createStringType(0, 256).nullish().describe('Guild invite code to join after registration'),
});
export type RegisterRequest = z.infer<typeof RegisterRequest>;
export const UsernameSuggestionsRequest = z.object({
global_name: GlobalNameType.describe('Display name to generate username suggestions from'),
});
export type UsernameSuggestionsRequest = z.infer<typeof UsernameSuggestionsRequest>;
export const LoginRequest = z.object({
email: EmailType.describe('Email address for authentication'),
password: PasswordType.describe('Account password'),
invite_code: createStringType(0, 256).nullish().describe('Guild invite code to join after login'),
});
export type LoginRequest = z.infer<typeof LoginRequest>;
export const LogoutAuthSessionsRequest = z.object({
session_id_hashes: z.array(createStringType()).max(100).describe('Array of session ID hashes to log out (max 100)'),
password: PasswordType.optional().describe('Account password for verification'),
});
export type LogoutAuthSessionsRequest = z.infer<typeof LogoutAuthSessionsRequest>;
export const ForgotPasswordRequest = z.object({
email: EmailType.describe('Email address to send password reset link'),
});
export type ForgotPasswordRequest = z.infer<typeof ForgotPasswordRequest>;
export const ResetPasswordRequest = z.object({
token: createStringType(64, 64).describe('Password reset token from email'),
password: PasswordType.describe('New password to set'),
});
export type ResetPasswordRequest = z.infer<typeof ResetPasswordRequest>;
export const EmailRevertRequest = z.object({
token: createStringType(64, 64).describe('Email revert token from email'),
password: PasswordType.describe('Account password for verification'),
});
export type EmailRevertRequest = z.infer<typeof EmailRevertRequest>;
export const VerifyEmailRequest = z.object({
token: createStringType(64, 64).describe('Email verification token from email'),
});
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequest>;
export const SudoVerificationSchema = z.object({
password: PasswordType.optional().describe('Account password for sudo verification'),
mfa_method: createNamedStringLiteralUnion(
[
['totp', 'TOTP', 'Time-based one-time password authentication via authenticator app'],
['sms', 'SMS', 'One-time password sent via text message'],
['webauthn', 'WebAuthn', 'Security key or biometric authentication'],
],
'MFA method to use for verification',
).optional(),
mfa_code: createStringType(1, 32).optional().describe('MFA verification code from authenticator app or SMS'),
webauthn_response: z.custom<AuthenticationResponseJSON>().optional().describe('WebAuthn authentication response'),
webauthn_challenge: createStringType().optional().describe('WebAuthn challenge string'),
});
export const SsoStatusResponse = z.object({
enabled: z.boolean().describe('Whether SSO is enabled for this instance'),
enforced: z.boolean().describe('Whether SSO is required for all users'),
display_name: z.string().nullable().describe('Display name of the SSO provider'),
redirect_uri: z.string().describe('OAuth redirect URI for SSO'),
});
export type SsoStatusResponse = z.infer<typeof SsoStatusResponse>;
export const SsoStartResponse = z.object({
authorization_url: z.string().describe('URL to redirect user to for SSO authentication'),
state: z.string().describe('State parameter for CSRF protection'),
redirect_uri: z.string().describe('Redirect URI after SSO completion'),
});
export type SsoStartResponse = z.infer<typeof SsoStartResponse>;
export const SsoCompleteResponse = z.object({
token: z.string().describe('Authentication token for the session'),
user_id: SnowflakeStringType.describe('ID of the authenticated user'),
redirect_to: z.string().describe('URL to redirect the user to after completion'),
});
export type SsoCompleteResponse = z.infer<typeof SsoCompleteResponse>;
export const AuthTokenResponse = z.object({
token: z.string().describe('Authentication token for API requests'),
});
export type AuthTokenResponse = z.infer<typeof AuthTokenResponse>;
export const AuthTokenWithUserIdResponse = z.object({
token: z.string().describe('Authentication token for API requests'),
user_id: SnowflakeStringType.describe('ID of the authenticated user'),
});
export type AuthTokenWithUserIdResponse = z.infer<typeof AuthTokenWithUserIdResponse>;
export const AuthMfaRequiredResponse = z.object({
mfa: z.literal(true).describe('Indicates MFA is required to complete authentication'),
ticket: z.string().describe('MFA ticket to use when completing MFA verification'),
allowed_methods: z.array(z.string()).max(10).describe('List of allowed MFA methods'),
sms_phone_hint: z.string().nullish().describe('Masked phone number hint for SMS MFA'),
sms: z.boolean().describe('Whether SMS MFA is available'),
totp: z.boolean().describe('Whether TOTP authenticator MFA is available'),
webauthn: z.boolean().describe('Whether WebAuthn security key MFA is available'),
});
export type AuthMfaRequiredResponse = z.infer<typeof AuthMfaRequiredResponse>;
export const AuthLoginResponse = z.union([AuthTokenWithUserIdResponse, AuthMfaRequiredResponse]);
export type AuthLoginResponse = z.infer<typeof AuthLoginResponse>;
export const AuthRegisterResponse = z.union([AuthTokenWithUserIdResponse, AuthMfaRequiredResponse]);
export type AuthRegisterResponse = z.infer<typeof AuthRegisterResponse>;
export const AuthSessionLocation = z.object({
city: z.string().nullish().describe('The city name reported by the client'),
region: z.string().nullish().describe('The region reported by the client'),
country: z.string().nullish().describe('The country reported by the client'),
});
export type AuthSessionLocation = z.infer<typeof AuthSessionLocation>;
export const AuthSessionClientInfo = z.object({
platform: z.string().nullish().describe('The platform reported by the client'),
os: z.string().nullish().describe('The operating system reported by the client'),
browser: z.string().nullish().describe('The browser reported by the client'),
location: AuthSessionLocation.nullish().describe('The geolocation data sent by the client'),
});
export type AuthSessionClientInfo = z.infer<typeof AuthSessionClientInfo>;
export const AuthSessionResponse = z.object({
id_hash: z.string().describe('The base64url-encoded session id hash'),
client_info: AuthSessionClientInfo.nullish().describe('Client metadata recorded for this session'),
approx_last_used_at: z.iso.datetime().nullish().describe('Approximate timestamp of the last session activity'),
current: z.boolean().describe('Whether this is the current session making the request'),
});
export type AuthSessionResponse = z.infer<typeof AuthSessionResponse>;
export const AuthSessionsResponse = z.array(AuthSessionResponse);
export type AuthSessionsResponse = z.infer<typeof AuthSessionsResponse>;
export const WebAuthnAuthenticationOptionsResponse = z.custom<PublicKeyCredentialCreationOptionsJSON>();
export type WebAuthnAuthenticationOptionsResponse = z.infer<typeof WebAuthnAuthenticationOptionsResponse>;
export const UsernameSuggestionsResponse = z.object({
suggestions: z.array(z.string()).max(20).describe('List of suggested usernames'),
});
export type UsernameSuggestionsResponse = z.infer<typeof UsernameSuggestionsResponse>;
export const HandoffInitiateResponse = z.object({
code: z.string().describe('Handoff code to share with the receiving device'),
expires_at: z.iso.datetime().describe('ISO 8601 timestamp when the handoff code expires'),
});
export type HandoffInitiateResponse = z.infer<typeof HandoffInitiateResponse>;
export const HandoffStatusResponse = z.object({
status: z.string().describe('Current status of the handoff (pending, completed, expired)'),
token: z.string().nullish().describe('Authentication token if handoff is complete'),
user_id: SnowflakeStringType.nullish().describe('User ID if handoff is complete'),
});
export type HandoffStatusResponse = z.infer<typeof HandoffStatusResponse>;
export const SsoStartRequest = z.object({
redirect_to: createStringType(1, 2048).nullish().describe('URL to redirect to after SSO completion'),
});
export type SsoStartRequest = z.infer<typeof SsoStartRequest>;
export const SsoCompleteRequest = z.object({
code: createStringType().describe('Authorization code from the SSO provider'),
state: createStringType().describe('State parameter for CSRF protection'),
});
export type SsoCompleteRequest = z.infer<typeof SsoCompleteRequest>;
export const MfaTotpRequest = z.object({
code: createStringType().describe('The TOTP code from the authenticator app'),
ticket: createStringType().describe('The MFA ticket from the login response'),
});
export type MfaTotpRequest = z.infer<typeof MfaTotpRequest>;
export const MfaSmsRequest = z.object({
code: createStringType().describe('The SMS verification code'),
ticket: createStringType().describe('The MFA ticket from the login response'),
});
export type MfaSmsRequest = z.infer<typeof MfaSmsRequest>;
export const MfaTicketRequest = z.object({
ticket: createStringType().describe('The MFA ticket from the login response'),
});
export type MfaTicketRequest = z.infer<typeof MfaTicketRequest>;
export const AuthorizeIpRequest = z.object({
token: createStringType().describe('The IP authorization token from email'),
});
export type AuthorizeIpRequest = z.infer<typeof AuthorizeIpRequest>;
export const IpAuthorizationPollQuery = z.object({
ticket: createStringType().describe('The IP authorization ticket'),
});
export type IpAuthorizationPollQuery = z.infer<typeof IpAuthorizationPollQuery>;
export const IpAuthorizationPollResponse = z.object({
completed: z.boolean().describe('Whether the IP authorization has been completed'),
token: z.string().nullish().describe('Authentication token if authorization is complete'),
user_id: SnowflakeStringType.nullish().describe('User ID if authorization is complete'),
});
export type IpAuthorizationPollResponse = z.infer<typeof IpAuthorizationPollResponse>;
export const WebAuthnAuthenticateRequest = z.object({
response: z.custom<AuthenticationResponseJSON>().describe('WebAuthn authentication response'),
challenge: createStringType().describe('The challenge string from authentication options'),
});
export type WebAuthnAuthenticateRequest = z.infer<typeof WebAuthnAuthenticateRequest>;
export const WebAuthnMfaRequest = z.object({
response: z.custom<AuthenticationResponseJSON>().describe('WebAuthn authentication response'),
challenge: createStringType().describe('The challenge string from authentication options'),
ticket: createStringType().describe('The MFA ticket from the login response'),
});
export type WebAuthnMfaRequest = z.infer<typeof WebAuthnMfaRequest>;
export const HandoffCompleteRequest = z.object({
code: createStringType().describe('The handoff code from the initiating session'),
token: createStringType().describe('The authentication token to transfer'),
user_id: createStringType().describe('The user ID associated with the token'),
});
export type HandoffCompleteRequest = z.infer<typeof HandoffCompleteRequest>;
export const HandoffCodeParam = z.object({
code: createStringType().describe('The handoff code'),
});
export type HandoffCodeParam = z.infer<typeof HandoffCodeParam>;
export const EnableMfaTotpRequest = z
.object({
secret: createStringType(1, 256).describe('The TOTP secret key'),
code: createStringType(1, 32).describe('The TOTP verification code'),
})
.merge(SudoVerificationSchema);
export type EnableMfaTotpRequest = z.infer<typeof EnableMfaTotpRequest>;
export const DisableTotpRequest = z
.object({
code: createStringType(1, 32).describe('The TOTP code to verify'),
password: PasswordType.optional().describe('Account password for verification'),
})
.merge(SudoVerificationSchema);
export type DisableTotpRequest = z.infer<typeof DisableTotpRequest>;
export const MfaBackupCodesRequest = z
.object({
regenerate: z.boolean().describe('Whether to regenerate backup codes'),
password: PasswordType.optional().describe('Account password for verification'),
})
.merge(SudoVerificationSchema);
export type MfaBackupCodesRequest = z.infer<typeof MfaBackupCodesRequest>;
export const MfaBackupCodeResponse = z.object({
code: z.string().describe('The backup code'),
consumed: z.boolean().describe('Whether the code has been used'),
});
export type MfaBackupCodeResponse = z.infer<typeof MfaBackupCodeResponse>;
export const MfaBackupCodesResponse = z.object({
backup_codes: z.array(MfaBackupCodeResponse).describe('List of backup codes'),
});
export type MfaBackupCodesResponse = z.infer<typeof MfaBackupCodesResponse>;
export const PhoneSendVerificationRequest = z.object({
phone: PhoneNumberType.describe('Phone number to send verification code'),
});
export type PhoneSendVerificationRequest = z.infer<typeof PhoneSendVerificationRequest>;
export const PhoneVerifyRequest = z.object({
phone: PhoneNumberType.describe('Phone number being verified'),
code: createStringType(1, 32).describe('The verification code'),
});
export type PhoneVerifyRequest = z.infer<typeof PhoneVerifyRequest>;
export const PhoneVerifyResponse = z.object({
phone_token: z.string().describe('Token to use when adding phone to account'),
});
export type PhoneVerifyResponse = z.infer<typeof PhoneVerifyResponse>;
export const PhoneAddRequest = z
.object({
phone_token: createStringType(1, 256).describe('Token from phone verification'),
})
.merge(SudoVerificationSchema);
export type PhoneAddRequest = z.infer<typeof PhoneAddRequest>;
export const WebAuthnCredentialResponse = z.object({
id: z.string().describe('The credential ID'),
name: z.string().describe('User-assigned name for the credential'),
created_at: z.string().describe('When the credential was registered'),
last_used_at: z.string().nullable().describe('When the credential was last used'),
});
export type WebAuthnCredentialResponse = z.infer<typeof WebAuthnCredentialResponse>;
export const WebAuthnCredentialListResponse = z.array(WebAuthnCredentialResponse);
export type WebAuthnCredentialListResponse = z.infer<typeof WebAuthnCredentialListResponse>;
export const WebAuthnChallengeResponse = z
.object({
challenge: z.string().describe('The WebAuthn challenge'),
})
.passthrough();
export type WebAuthnChallengeResponse = z.infer<typeof WebAuthnChallengeResponse>;
export const WebAuthnRegisterRequest = z
.object({
response: z.custom<RegistrationResponseJSON>().describe('WebAuthn registration response'),
challenge: createStringType(1, 1024).describe('The challenge from registration options'),
name: createStringType(1, 100).describe('User-assigned name for the credential'),
})
.merge(SudoVerificationSchema);
export type WebAuthnRegisterRequest = z.infer<typeof WebAuthnRegisterRequest>;
export const WebAuthnCredentialUpdateRequest = z
.object({
name: createStringType(1, 100).describe('New name for the credential'),
})
.merge(SudoVerificationSchema);
export type WebAuthnCredentialUpdateRequest = z.infer<typeof WebAuthnCredentialUpdateRequest>;
export const SudoMfaMethodsResponse = z.object({
totp: z.boolean().describe('Whether TOTP is enabled'),
sms: z.boolean().describe('Whether SMS MFA is enabled'),
webauthn: z.boolean().describe('Whether WebAuthn is enabled'),
has_mfa: z.boolean().describe('Whether any MFA method is enabled'),
});
export type SudoMfaMethodsResponse = z.infer<typeof SudoMfaMethodsResponse>;

View File

@@ -0,0 +1,271 @@
/*
* 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 {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {
AVATAR_MAX_SIZE,
CHANNEL_RATE_LIMIT_PER_USER_MAX,
CHANNEL_RATE_LIMIT_PER_USER_MIN,
CHANNEL_TOPIC_MAX_LENGTH,
CHANNEL_TOPIC_MIN_LENGTH,
RTC_REGION_ID_MAX_LENGTH,
RTC_REGION_ID_MIN_LENGTH,
VOICE_CHANNEL_BITRATE_MAX,
VOICE_CHANNEL_BITRATE_MIN,
VOICE_CHANNEL_USER_LIMIT_MAX,
VOICE_CHANNEL_USER_LIMIT_MIN,
} from '@fluxer/constants/src/LimitConstants';
import {ChannelNicknameOverrides} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ChannelOverwriteTypeSchema, GeneralChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
createNamedLiteral,
createNamedLiteralUnion,
createStringType,
SnowflakeType,
UnsignedInt64Type,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {URLType} from '@fluxer/schema/src/primitives/UrlValidators';
import {z} from 'zod';
export const ChannelOverwriteRequest = z.object({
id: SnowflakeType.describe('The ID of the role or user to overwrite permissions for'),
type: createNamedLiteralUnion(
[
[0, 'ROLE'],
[1, 'MEMBER'],
],
'The type of overwrite (0 = role, 1 = member)',
),
allow: UnsignedInt64Type.optional().describe('fluxer:UnsignedInt64Type Bitwise value of allowed permissions'),
deny: UnsignedInt64Type.optional().describe('fluxer:UnsignedInt64Type Bitwise value of denied permissions'),
});
export type ChannelOverwriteRequest = z.infer<typeof ChannelOverwriteRequest>;
const ChannelCommonBase = z.object({
topic: createStringType(CHANNEL_TOPIC_MIN_LENGTH, CHANNEL_TOPIC_MAX_LENGTH)
.nullish()
.describe(`The channel topic (${CHANNEL_TOPIC_MIN_LENGTH}-${CHANNEL_TOPIC_MAX_LENGTH} characters)`),
url: URLType.nullish().describe('External URL for link channels'),
parent_id: SnowflakeType.nullish().describe('ID of the parent category for this channel'),
bitrate: z
.number()
.int()
.min(VOICE_CHANNEL_BITRATE_MIN)
.max(VOICE_CHANNEL_BITRATE_MAX)
.nullish()
.describe(`Voice channel bitrate in bits per second (${VOICE_CHANNEL_BITRATE_MIN}-${VOICE_CHANNEL_BITRATE_MAX})`),
user_limit: z
.number()
.int()
.min(VOICE_CHANNEL_USER_LIMIT_MIN)
.max(VOICE_CHANNEL_USER_LIMIT_MAX)
.nullish()
.describe(
`Maximum users allowed in voice channel (${VOICE_CHANNEL_USER_LIMIT_MIN}-${VOICE_CHANNEL_USER_LIMIT_MAX}, ${VOICE_CHANNEL_USER_LIMIT_MIN} means unlimited)`,
),
permission_overwrites: z
.array(ChannelOverwriteRequest)
.optional()
.describe('Permission overwrites for roles and members'),
});
const ChannelCreateCommon = ChannelCommonBase.extend({
nsfw: z.boolean().default(false).describe('Whether the channel is marked as NSFW'),
});
const ChannelUpdateCommon = ChannelCommonBase.extend({
nsfw: z.boolean().nullish().describe('Whether the channel is marked as NSFW'),
rate_limit_per_user: z
.number()
.int()
.min(CHANNEL_RATE_LIMIT_PER_USER_MIN)
.max(CHANNEL_RATE_LIMIT_PER_USER_MAX)
.nullish()
.describe(`Slowmode delay in seconds (${CHANNEL_RATE_LIMIT_PER_USER_MIN}-${CHANNEL_RATE_LIMIT_PER_USER_MAX})`),
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded icon image for group DM channels'),
owner_id: SnowflakeType.nullish().describe('ID of the new owner for group DM channels'),
nicks: ChannelNicknameOverrides.optional().describe('Custom nicknames for users in this channel'),
rtc_region: createStringType(RTC_REGION_ID_MIN_LENGTH, RTC_REGION_ID_MAX_LENGTH)
.nullish()
.describe(
`Voice region ID for the voice channel (${RTC_REGION_ID_MIN_LENGTH}-${RTC_REGION_ID_MAX_LENGTH} characters)`,
),
});
export const ChannelCreateTextRequest = ChannelCreateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_TEXT, 'GUILD_TEXT', 'Channel type (text channel)'),
name: GeneralChannelNameType.describe('The name of the channel'),
});
export type ChannelCreateTextRequest = z.infer<typeof ChannelCreateTextRequest>;
export const ChannelCreateVoiceRequest = ChannelCreateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_VOICE, 'GUILD_VOICE', 'Channel type (voice channel)'),
name: GeneralChannelNameType.describe('The name of the channel'),
});
export type ChannelCreateVoiceRequest = z.infer<typeof ChannelCreateVoiceRequest>;
export const ChannelCreateCategoryRequest = ChannelCreateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_CATEGORY, 'GUILD_CATEGORY', 'Channel type (category)'),
name: GeneralChannelNameType.describe('The name of the category'),
});
export type ChannelCreateCategoryRequest = z.infer<typeof ChannelCreateCategoryRequest>;
export const ChannelCreateLinkRequest = ChannelCreateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_LINK, 'GUILD_LINK', 'Channel type (link channel)'),
name: GeneralChannelNameType.describe('The name of the channel'),
});
export type ChannelCreateLinkRequest = z.infer<typeof ChannelCreateLinkRequest>;
export const ChannelCreateRequest = z.discriminatedUnion('type', [
ChannelCreateTextRequest,
ChannelCreateVoiceRequest,
ChannelCreateCategoryRequest,
ChannelCreateLinkRequest,
]);
export type ChannelCreateRequest = z.infer<typeof ChannelCreateRequest>;
export const ChannelUpdateTextRequest = ChannelUpdateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_TEXT, 'GUILD_TEXT', 'Channel type (text channel)'),
name: GeneralChannelNameType.nullish().describe('The name of the channel'),
});
export type ChannelUpdateTextRequest = z.infer<typeof ChannelUpdateTextRequest>;
export const ChannelUpdateVoiceRequest = ChannelUpdateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_VOICE, 'GUILD_VOICE', 'Channel type (voice channel)'),
name: GeneralChannelNameType.nullish().describe('The name of the channel'),
});
export type ChannelUpdateVoiceRequest = z.infer<typeof ChannelUpdateVoiceRequest>;
export const ChannelUpdateCategoryRequest = ChannelUpdateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_CATEGORY, 'GUILD_CATEGORY', 'Channel type (category)'),
name: GeneralChannelNameType.nullish().describe('The name of the category'),
});
export type ChannelUpdateCategoryRequest = z.infer<typeof ChannelUpdateCategoryRequest>;
export const ChannelUpdateLinkRequest = ChannelUpdateCommon.extend({
type: createNamedLiteral(ChannelTypes.GUILD_LINK, 'GUILD_LINK', 'Channel type (link channel)'),
name: GeneralChannelNameType.nullish().describe('The name of the channel'),
});
export type ChannelUpdateLinkRequest = z.infer<typeof ChannelUpdateLinkRequest>;
export const ChannelUpdateGroupDmRequest = z.object({
type: createNamedLiteral(ChannelTypes.GROUP_DM, 'GROUP_DM', 'Channel type (group DM)'),
name: GeneralChannelNameType.nullish().describe('The name of the group DM'),
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded icon image for the group DM'),
owner_id: SnowflakeType.nullish().describe('ID of the new owner of the group DM'),
nicks: ChannelNicknameOverrides.nullish().describe('Custom nicknames for users in this group DM'),
});
export type ChannelUpdateGroupDmRequest = z.infer<typeof ChannelUpdateGroupDmRequest>;
export const ChannelUpdateRequest = z.discriminatedUnion('type', [
ChannelUpdateTextRequest,
ChannelUpdateVoiceRequest,
ChannelUpdateCategoryRequest,
ChannelUpdateLinkRequest,
ChannelUpdateGroupDmRequest,
]);
export type ChannelUpdateRequest = z.infer<typeof ChannelUpdateRequest>;
export const PermissionOverwriteCreateRequest = z.object({
type: ChannelOverwriteTypeSchema.describe('The type of overwrite (0 = role, 1 = member)'),
allow: UnsignedInt64Type.nullish().describe('fluxer:UnsignedInt64Type Bitwise value of allowed permissions'),
deny: UnsignedInt64Type.nullish().describe('fluxer:UnsignedInt64Type Bitwise value of denied permissions'),
});
export type PermissionOverwriteCreateRequest = z.infer<typeof PermissionOverwriteCreateRequest>;
export const DeleteChannelQuery = z.object({
silent: QueryBooleanType.describe('Whether to suppress the system message when leaving a group DM'),
});
export type DeleteChannelQuery = z.infer<typeof DeleteChannelQuery>;
export const ReadStateAckBulkRequest = z.object({
read_states: z
.array(
z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
message_id: SnowflakeType.describe('The ID of the last read message'),
}),
)
.min(1)
.max(100)
.describe('Array of channel/message pairs to acknowledge'),
});
export type ReadStateAckBulkRequest = z.infer<typeof ReadStateAckBulkRequest>;
export const ChannelPositionUpdateRequest = z.array(
z.object({
id: SnowflakeType.describe('The ID of the channel to reposition'),
position: z.number().int().nonnegative().optional().describe('New position for the channel'),
parent_id: SnowflakeType.nullish().describe('New parent category ID'),
lock_permissions: z.boolean().optional().describe('Whether to sync permissions with the new parent'),
}),
);
export type ChannelPositionUpdateRequest = z.infer<typeof ChannelPositionUpdateRequest>;
export const CallUpdateBodySchema = z.object({
region: createStringType(RTC_REGION_ID_MIN_LENGTH, RTC_REGION_ID_MAX_LENGTH)
.nullish()
.describe(
`The preferred voice region for the call (${RTC_REGION_ID_MIN_LENGTH}-${RTC_REGION_ID_MAX_LENGTH} characters). Omit or set to null for automatic region selection.`,
),
});
export type CallUpdateBodySchema = z.infer<typeof CallUpdateBodySchema>;
export const CallRingBodySchema = z.object({
recipients: z.array(SnowflakeType).optional().describe('User IDs to ring for the call'),
});
export type CallRingBodySchema = z.infer<typeof CallRingBodySchema>;
export const StreamUpdateBodySchema = z.object({
region: createStringType(RTC_REGION_ID_MIN_LENGTH, RTC_REGION_ID_MAX_LENGTH)
.optional()
.describe(
`The preferred voice region for the stream (${RTC_REGION_ID_MIN_LENGTH}-${RTC_REGION_ID_MAX_LENGTH} characters)`,
),
});
export type StreamUpdateBodySchema = z.infer<typeof StreamUpdateBodySchema>;
export const StreamPreviewUploadBodySchema = z.object({
channel_id: SnowflakeType.describe('The ID of the channel where the stream is active'),
thumbnail: createStringType(1, 2_000_000).describe('Base64-encoded thumbnail image data'),
content_type: createStringType(1, 64).optional().describe('MIME type of the thumbnail image'),
});
export type StreamPreviewUploadBodySchema = z.infer<typeof StreamPreviewUploadBodySchema>;

View File

@@ -0,0 +1,155 @@
/*
* 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 {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {ChannelOverwriteTypeSchema, ChannelTypeSchema} from '@fluxer/schema/src/primitives/ChannelValidators';
import {PermissionStringType} from '@fluxer/schema/src/primitives/PermissionValidators';
import {createStringType, Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ChannelOverwriteResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for the role or user this overwrite applies to'),
type: ChannelOverwriteTypeSchema.describe('The type of entity the overwrite applies to'),
allow: PermissionStringType.describe('fluxer:PermissionStringType The bitwise value of allowed permissions'),
deny: PermissionStringType.describe('fluxer:PermissionStringType The bitwise value of denied permissions'),
});
export type ChannelOverwriteResponse = z.infer<typeof ChannelOverwriteResponse>;
export const RtcRegionResponse = z.object({
id: z.string().describe('The unique identifier for this RTC region'),
name: z.string().describe('The display name of the RTC region'),
emoji: z.string().describe('The emoji associated with this RTC region'),
});
export type RtcRegionResponse = z.infer<typeof RtcRegionResponse>;
export const CallEligibilityResponse = z.object({
ringable: z.boolean().describe('Whether the current user can ring this call'),
silent: z.boolean().describe('Whether the call should be joined silently'),
});
export type CallEligibilityResponse = z.infer<typeof CallEligibilityResponse>;
export const ChannelResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier (snowflake) for this channel'),
guild_id: SnowflakeStringType.optional().describe('The ID of the guild this channel belongs to'),
name: z.string().optional().describe('The name of the channel'),
topic: z.string().nullish().describe('The topic of the channel'),
url: z.url().nullish().describe('The URL associated with the channel'),
icon: z.string().nullish().describe('The icon hash of the channel (for group DMs)'),
owner_id: SnowflakeStringType.nullish().describe('The ID of the owner of the channel (for group DMs)'),
type: ChannelTypeSchema.describe('The type of the channel'),
position: Int32Type.optional().describe('The sorting position of the channel'),
parent_id: SnowflakeStringType.nullish().describe('The ID of the parent category for this channel'),
bitrate: Int32Type.nullish().describe('The bitrate of the voice channel in bits per second'),
user_limit: Int32Type.nullish().describe('The maximum number of users allowed in the voice channel'),
rtc_region: z.string().nullish().describe('The voice region ID for the voice channel'),
last_message_id: SnowflakeStringType.nullish().describe('The ID of the last message sent in this channel'),
last_pin_timestamp: z.iso
.datetime()
.nullish()
.describe('The ISO 8601 timestamp of when the last pinned message was pinned'),
permission_overwrites: z
.array(ChannelOverwriteResponse)
.max(500)
.optional()
.describe('The permission overwrites for this channel'),
recipients: z
.array(z.lazy(() => UserPartialResponse))
.max(10)
.optional()
.describe('The recipients of the DM channel'),
nsfw: z.boolean().optional().describe('Whether the channel is marked as NSFW'),
rate_limit_per_user: Int32Type.optional().describe('The slowmode rate limit in seconds'),
nicks: z
.record(z.string(), createStringType(1, 32))
.optional()
.describe('Custom nicknames for users in this channel (for group DMs)'),
});
export type ChannelResponse = z.infer<typeof ChannelResponse>;
export const ChannelNicknameOverrides = z
.record(
z.string().describe('User ID'),
z.union([createStringType(0, 32), z.null()]).describe('Nickname or null to clear'),
)
.describe('User nickname overrides (user ID to nickname mapping)');
export type ChannelNicknameOverrides = z.infer<typeof ChannelNicknameOverrides>;
export const ChannelPartialRecipientResponse = z.object({
username: z.string().describe('The username of the recipient'),
});
export type ChannelPartialRecipientResponse = z.infer<typeof ChannelPartialRecipientResponse>;
export const ChannelPartialResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier (snowflake) for this channel'),
name: z.string().nullish().describe('The name of the channel'),
type: ChannelTypeSchema.describe('The type of the channel'),
recipients: z.array(ChannelPartialRecipientResponse).max(10).optional().describe('The recipients of the DM channel'),
});
export type ChannelPartialResponse = z.infer<typeof ChannelPartialResponse>;
export const ChannelListResponse = z.array(ChannelResponse).max(500).describe('A list of channels');
export type ChannelListResponse = z.infer<typeof ChannelListResponse>;
export const RtcRegionListResponse = z.array(RtcRegionResponse).max(100).describe('A list of RTC regions');
export type RtcRegionListResponse = z.infer<typeof RtcRegionListResponse>;
export interface ChannelOverwrite {
readonly id: string;
readonly type: number;
readonly allow: string;
readonly deny: string;
}
export interface DefaultReactionEmoji {
readonly emoji_id: string | null;
readonly emoji_name: string | null;
}
export interface Channel {
readonly id: string;
readonly guild_id?: string;
readonly name?: string;
readonly topic?: string | null;
readonly url?: string | null;
readonly icon?: string | null;
readonly owner_id?: string | null;
readonly type: number;
readonly position?: number;
readonly parent_id?: string | null;
readonly bitrate?: number | null;
readonly user_limit?: number | null;
readonly rtc_region?: string | null;
readonly last_message_id?: string | null;
readonly last_pin_timestamp?: string | null;
readonly permission_overwrites?: ReadonlyArray<ChannelOverwrite>;
readonly recipients?: ReadonlyArray<UserPartial>;
readonly nsfw?: boolean;
readonly rate_limit_per_user?: number;
readonly nicks?: Readonly<Record<string, string>>;
readonly flags?: number;
readonly member_count?: number;
readonly message_count?: number;
readonly total_message_sent?: number;
readonly default_reaction_emoji?: DefaultReactionEmoji | null;
}

View File

@@ -0,0 +1,264 @@
/*
* 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 {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
export interface ChannelOrderingChannel<Id extends string | bigint = string> {
id: Id;
parentId?: Id | null | undefined;
type: number;
position?: number | null | undefined;
}
export type GuildChannelReorderErrorCode =
| 'TARGET_CHANNEL_NOT_FOUND'
| 'PRECEDING_CHANNEL_NOT_FOUND'
| 'CANNOT_POSITION_RELATIVE_TO_SELF_BLOCK'
| 'PRECEDING_PARENT_MISMATCH'
| 'PARENT_NOT_FOUND'
| 'PARENT_NOT_CATEGORY'
| 'CATEGORIES_CANNOT_HAVE_PARENTS'
| 'PARENT_NOT_IN_GUILD_LIST'
| 'PRECEDING_NOT_IN_GUILD_LIST';
export interface GuildChannelReorderOperation<Id extends string | bigint> {
channelId: Id;
parentId: Id | null | undefined;
precedingSiblingId: Id | null | undefined;
}
export interface GuildChannelReorderPlan<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>> {
orderedChannels: Array<Channel>;
finalChannels: Array<Channel>;
desiredParentById: Map<Id, Id | null>;
orderUnchanged: boolean;
}
function idToString<Id extends string | bigint>(id: Id): string {
return String(id);
}
export function compareChannelOrdering<Id extends string | bigint>(
a: ChannelOrderingChannel<Id>,
b: ChannelOrderingChannel<Id>,
): number {
const aPos = a.position ?? 0;
const bPos = b.position ?? 0;
if (aPos !== bPos) return aPos - bPos;
return idToString(a.id).localeCompare(idToString(b.id));
}
export function sortChannelsForOrdering<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>(
channels: ReadonlyArray<Channel>,
): Array<Channel> {
return [...channels].sort(compareChannelOrdering);
}
export function computeChannelMoveBlockIds<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>({
channels,
targetId,
}: {
channels: ReadonlyArray<Channel>;
targetId: Id;
}): Set<Id> {
const channelById = new Map<Id, Channel>(channels.map((ch) => [ch.id, ch]));
const target = channelById.get(targetId);
const blockIds = new Set<Id>();
blockIds.add(targetId);
if (target?.type === ChannelTypes.GUILD_CATEGORY) {
for (const channel of channels) {
if (channel.parentId === targetId) {
blockIds.add(channel.id);
}
}
}
return blockIds;
}
export function findCategorySpanInOrderedList<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>(
orderedChannels: ReadonlyArray<Channel>,
categoryId: Id,
): {start: number; end: number} {
const start = orderedChannels.findIndex((ch) => ch.id === categoryId);
if (start === -1) return {start: -1, end: -1};
let end = start + 1;
while (end < orderedChannels.length && orderedChannels[end].parentId === categoryId) {
end++;
}
return {start, end};
}
export function computePrecedingSiblingIdFromPosition<
Id extends string | bigint,
Channel extends ChannelOrderingChannel<Id>,
>({
channels,
targetId,
desiredParentId,
position,
}: {
channels: ReadonlyArray<Channel>;
targetId: Id;
desiredParentId: Id | null;
position: number;
}): Id | null {
const siblings = sortChannelsForOrdering(channels).filter((ch) => (ch.parentId ?? null) === desiredParentId);
const blockIds = computeChannelMoveBlockIds({channels, targetId});
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
const clamped = Math.min(Math.max(position, 0), siblingsWithoutBlock.length);
return clamped === 0 ? null : siblingsWithoutBlock[clamped - 1]!.id;
}
export function computePositionFromPrecedingSiblingId<
Id extends string | bigint,
Channel extends ChannelOrderingChannel<Id>,
>({
channels,
targetId,
desiredParentId,
precedingSiblingId,
}: {
channels: ReadonlyArray<Channel>;
targetId: Id;
desiredParentId: Id | null;
precedingSiblingId: Id | null;
}): number | null {
const siblings = sortChannelsForOrdering(channels).filter((ch) => (ch.parentId ?? null) === desiredParentId);
const blockIds = computeChannelMoveBlockIds({channels, targetId});
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
if (!precedingSiblingId) return 0;
const index = siblingsWithoutBlock.findIndex((ch) => ch.id === precedingSiblingId);
if (index === -1) return null;
return index + 1;
}
export function computeGuildChannelReorderPlan<Id extends string | bigint, Channel extends ChannelOrderingChannel<Id>>({
channels,
operation,
}: {
channels: ReadonlyArray<Channel>;
operation: GuildChannelReorderOperation<Id>;
}): {ok: true; plan: GuildChannelReorderPlan<Id, Channel>} | {ok: false; code: GuildChannelReorderErrorCode} {
const orderedChannels = sortChannelsForOrdering(channels);
const channelById = new Map<Id, Channel>(orderedChannels.map((ch) => [ch.id, ch]));
const targetChannel = channelById.get(operation.channelId);
if (!targetChannel) {
return {ok: false, code: 'TARGET_CHANNEL_NOT_FOUND'};
}
const requestedParentId = operation.parentId;
const desiredParentId =
targetChannel.type === ChannelTypes.GUILD_CATEGORY
? null
: requestedParentId !== undefined
? requestedParentId
: (targetChannel.parentId ?? null);
if (targetChannel.type === ChannelTypes.GUILD_CATEGORY && operation.parentId) {
return {ok: false, code: 'CATEGORIES_CANNOT_HAVE_PARENTS'};
}
if (desiredParentId) {
const parentChannel = channelById.get(desiredParentId);
if (!parentChannel) {
return {ok: false, code: 'PARENT_NOT_FOUND'};
}
if (parentChannel.type !== ChannelTypes.GUILD_CATEGORY) {
return {ok: false, code: 'PARENT_NOT_CATEGORY'};
}
}
const precedingId = operation.precedingSiblingId ?? null;
if (precedingId && !channelById.has(precedingId)) {
return {ok: false, code: 'PRECEDING_CHANNEL_NOT_FOUND'};
}
const blockIds = computeChannelMoveBlockIds({channels: orderedChannels, targetId: targetChannel.id});
if (precedingId && blockIds.has(precedingId)) {
return {ok: false, code: 'CANNOT_POSITION_RELATIVE_TO_SELF_BLOCK'};
}
const remainingChannels = orderedChannels.filter((ch) => !blockIds.has(ch.id));
const blockChannels = orderedChannels.filter((ch) => blockIds.has(ch.id));
const expectedParent = desiredParentId ?? null;
if (precedingId) {
const precedingChannel = channelById.get(precedingId)!;
const precedingParent = precedingChannel.parentId ?? null;
if (precedingParent !== expectedParent) {
return {ok: false, code: 'PRECEDING_PARENT_MISMATCH'};
}
}
let insertIndex = 0;
if (precedingId) {
const precedingIndex = remainingChannels.findIndex((ch) => ch.id === precedingId);
if (precedingIndex === -1) {
return {ok: false, code: 'PRECEDING_NOT_IN_GUILD_LIST'};
}
const precedingChannel = channelById.get(precedingId)!;
if (precedingChannel.type === ChannelTypes.GUILD_CATEGORY) {
const span = findCategorySpanInOrderedList(remainingChannels, precedingChannel.id);
insertIndex = span.end;
} else {
insertIndex = precedingIndex + 1;
}
} else if (desiredParentId) {
const parentIndex = remainingChannels.findIndex((ch) => ch.id === desiredParentId);
if (parentIndex === -1) {
return {ok: false, code: 'PARENT_NOT_IN_GUILD_LIST'};
}
insertIndex = parentIndex + 1;
} else {
insertIndex = 0;
}
const finalChannels = [...remainingChannels];
finalChannels.splice(insertIndex, 0, ...blockChannels);
const desiredParentById = new Map<Id, Id | null>();
for (const channel of finalChannels) {
if (channel.id === targetChannel.id) {
desiredParentById.set(channel.id, desiredParentId ?? null);
} else {
desiredParentById.set(channel.id, channel.parentId ?? null);
}
}
const orderUnchanged =
finalChannels.length === orderedChannels.length &&
finalChannels.every((channel, index) => channel.id === orderedChannels[index]!.id) &&
(targetChannel.parentId ?? null) === (desiredParentById.get(targetChannel.id) ?? null);
return {
ok: true,
plan: {
orderedChannels,
finalChannels,
desiredParentById,
orderUnchanged,
},
};
}

View File

@@ -0,0 +1,252 @@
/*
* 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 {createStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const GuildIdParam = z.object({
guild_id: SnowflakeType.describe('The ID of the guild'),
});
export type GuildIdParam = z.infer<typeof GuildIdParam>;
export const ChannelIdParam = z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
});
export type ChannelIdParam = z.infer<typeof ChannelIdParam>;
export const UserIdParam = z.object({
user_id: SnowflakeType.describe('The ID of the user'),
});
export type UserIdParam = z.infer<typeof UserIdParam>;
export const MessageIdParam = z.object({
message_id: SnowflakeType.describe('The ID of the message'),
});
export type MessageIdParam = z.infer<typeof MessageIdParam>;
export const RoleIdParam = z.object({
role_id: SnowflakeType.describe('The ID of the role'),
});
export type RoleIdParam = z.infer<typeof RoleIdParam>;
export const WebhookIdParam = z.object({
webhook_id: SnowflakeType.describe('The ID of the webhook'),
});
export type WebhookIdParam = z.infer<typeof WebhookIdParam>;
export const InviteCodeParam = z.object({
invite_code: createStringType().describe('The unique invite code'),
});
export type InviteCodeParam = z.infer<typeof InviteCodeParam>;
export const PackIdParam = z.object({
pack_id: SnowflakeType.describe('The ID of the pack'),
});
export type PackIdParam = z.infer<typeof PackIdParam>;
export const ApplicationIdParam = z.object({
id: SnowflakeType.describe('The ID of the application'),
});
export type ApplicationIdParam = z.infer<typeof ApplicationIdParam>;
export const GuildIdUserIdParam = z.object({
guild_id: SnowflakeType.describe('The ID of the guild'),
user_id: SnowflakeType.describe('The ID of the user'),
});
export type GuildIdUserIdParam = z.infer<typeof GuildIdUserIdParam>;
export const GuildIdRoleIdParam = z.object({
guild_id: SnowflakeType.describe('The ID of the guild'),
role_id: SnowflakeType.describe('The ID of the role'),
});
export type GuildIdRoleIdParam = z.infer<typeof GuildIdRoleIdParam>;
export const GuildIdUserIdRoleIdParam = z.object({
guild_id: SnowflakeType.describe('The ID of the guild'),
user_id: SnowflakeType.describe('The ID of the user'),
role_id: SnowflakeType.describe('The ID of the role'),
});
export type GuildIdUserIdRoleIdParam = z.infer<typeof GuildIdUserIdRoleIdParam>;
export const ChannelIdMessageIdParam = z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
message_id: SnowflakeType.describe('The ID of the message'),
});
export type ChannelIdMessageIdParam = z.infer<typeof ChannelIdMessageIdParam>;
export const ChannelIdUserIdParam = z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
user_id: SnowflakeType.describe('The ID of the user'),
});
export type ChannelIdUserIdParam = z.infer<typeof ChannelIdUserIdParam>;
export const ChannelIdOverwriteIdParam = z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
overwrite_id: SnowflakeType.describe('The ID of the permission overwrite'),
});
export type ChannelIdOverwriteIdParam = z.infer<typeof ChannelIdOverwriteIdParam>;
export const ChannelIdMessageIdAttachmentIdParam = z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
message_id: SnowflakeType.describe('The ID of the message'),
attachment_id: SnowflakeType.describe('The ID of the attachment'),
});
export type ChannelIdMessageIdAttachmentIdParam = z.infer<typeof ChannelIdMessageIdAttachmentIdParam>;
export const WebhookIdTokenParam = z.object({
webhook_id: SnowflakeType.describe('The ID of the webhook'),
token: createStringType().describe('The webhook token'),
});
export type WebhookIdTokenParam = z.infer<typeof WebhookIdTokenParam>;
export const TargetIdParam = z.object({
target_id: SnowflakeType.describe('The ID of the target user'),
});
export type TargetIdParam = z.infer<typeof TargetIdParam>;
export const ApplicationAuthorizationIdParam = z.object({
applicationId: SnowflakeType.describe('The ID of the application'),
});
export type ApplicationAuthorizationIdParam = z.infer<typeof ApplicationAuthorizationIdParam>;
export const SuccessResponse = z.object({
success: z.literal(true).describe('Whether the operation succeeded'),
});
export type SuccessResponse = z.infer<typeof SuccessResponse>;
export const EnabledToggleRequest = z.object({
enabled: z.boolean().describe('Whether to enable or disable the feature'),
});
export type EnabledToggleRequest = z.infer<typeof EnabledToggleRequest>;
export const EmojiIdParam = z.object({
emoji_id: SnowflakeType.describe('The ID of the emoji'),
});
export type EmojiIdParam = z.infer<typeof EmojiIdParam>;
export const StickerIdParam = z.object({
sticker_id: SnowflakeType.describe('The ID of the sticker'),
});
export type StickerIdParam = z.infer<typeof StickerIdParam>;
export const GuildIdEmojiIdParam = z.object({
guild_id: SnowflakeType.describe('The ID of the guild'),
emoji_id: SnowflakeType.describe('The ID of the emoji'),
});
export type GuildIdEmojiIdParam = z.infer<typeof GuildIdEmojiIdParam>;
export const GuildIdStickerIdParam = z.object({
guild_id: SnowflakeType.describe('The ID of the guild'),
sticker_id: SnowflakeType.describe('The ID of the sticker'),
});
export type GuildIdStickerIdParam = z.infer<typeof GuildIdStickerIdParam>;
export const PackIdEmojiIdParam = z.object({
pack_id: SnowflakeType.describe('The ID of the pack'),
emoji_id: SnowflakeType.describe('The ID of the emoji'),
});
export type PackIdEmojiIdParam = z.infer<typeof PackIdEmojiIdParam>;
export const PackIdStickerIdParam = z.object({
pack_id: SnowflakeType.describe('The ID of the pack'),
sticker_id: SnowflakeType.describe('The ID of the sticker'),
});
export type PackIdStickerIdParam = z.infer<typeof PackIdStickerIdParam>;
export const GiftCodeParam = z.object({
code: createStringType(1, 32).describe('The gift code'),
});
export type GiftCodeParam = z.infer<typeof GiftCodeParam>;
export const StreamKeyParam = z.object({
stream_key: createStringType(1, 256).describe('The stream key'),
});
export type StreamKeyParam = z.infer<typeof StreamKeyParam>;
export const EmojiParam = z.object({
emoji: createStringType(1, 64).describe('The emoji identifier'),
});
export type EmojiParam = z.infer<typeof EmojiParam>;
export const ChannelIdMessageIdEmojiParam = z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
message_id: SnowflakeType.describe('The ID of the message'),
emoji: createStringType(1, 64).describe('The emoji identifier'),
});
export type ChannelIdMessageIdEmojiParam = z.infer<typeof ChannelIdMessageIdEmojiParam>;
export const ChannelIdMessageIdEmojiTargetIdParam = z.object({
channel_id: SnowflakeType.describe('The ID of the channel'),
message_id: SnowflakeType.describe('The ID of the message'),
emoji: createStringType(1, 64).describe('The emoji identifier'),
target_id: SnowflakeType.describe('The ID of the target user'),
});
export type ChannelIdMessageIdEmojiTargetIdParam = z.infer<typeof ChannelIdMessageIdEmojiTargetIdParam>;
export const ReportIdParam = z.object({
report_id: SnowflakeType.describe('The ID of the report'),
});
export type ReportIdParam = z.infer<typeof ReportIdParam>;
export const KeyIdParam = z.object({
keyId: createStringType(1, 64).describe('The ID of the key'),
});
export type KeyIdParam = z.infer<typeof KeyIdParam>;
export const MemeIdParam = z.object({
meme_id: SnowflakeType.describe('The ID of the favorite meme'),
});
export type MemeIdParam = z.infer<typeof MemeIdParam>;
export const SessionIdQuerySchema = z.object({
session_id: createStringType(1, 64).optional().describe('The session ID for synchronization'),
});
export type SessionIdQuerySchema = z.infer<typeof SessionIdQuerySchema>;
export const CredentialIdParam = z.object({
credential_id: createStringType(1, 2048).describe('The ID of the WebAuthn credential'),
});
export type CredentialIdParam = z.infer<typeof CredentialIdParam>;
export const ScheduledMessageIdParam = z.object({
scheduled_message_id: SnowflakeType.describe('The ID of the scheduled message'),
});
export type ScheduledMessageIdParam = z.infer<typeof ScheduledMessageIdParam>;
export const JobIdParam = z.object({
job_id: SnowflakeType.describe('The ID of the job'),
});
export type JobIdParam = z.infer<typeof JobIdParam>;
export const ArchiveSubjectTypeEnum = z
.enum(['user', 'guild'])
.describe('Type of entity being archived: user for user data archives, guild for guild data archives');
export type ArchiveSubjectType = z.infer<typeof ArchiveSubjectTypeEnum>;
export const ArchivePathParam = z.object({
subjectType: ArchiveSubjectTypeEnum.describe('The type of subject (user or guild)'),
subjectId: SnowflakeType.describe('The ID of the subject'),
archiveId: SnowflakeType.describe('The ID of the archive'),
});
export type ArchivePathParam = z.infer<typeof ArchivePathParam>;
export const HarvestIdParam = z.object({
harvestId: SnowflakeType.describe('The ID of the harvest request'),
});
export type HarvestIdParam = z.infer<typeof HarvestIdParam>;

View File

@@ -0,0 +1,27 @@
/*
* 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 {QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {z} from 'zod';
export const PurgeQuery = z.object({
purge: QueryBooleanType.optional().describe('Whether to also purge the asset from storage'),
});
export type PurgeQuery = z.infer<typeof PurgeQuery>;

View File

@@ -0,0 +1,30 @@
/*
* 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 {z} from 'zod';
export const BlueskyAuthorizeRequest = z.object({
handle: z.string().min(1).max(253).describe('The Bluesky handle to connect (e.g. alice.bsky.social)'),
});
export type BlueskyAuthorizeRequest = z.infer<typeof BlueskyAuthorizeRequest>;
export const BlueskyAuthorizeResponse = z.object({
authorize_url: z.string().describe('The URL to redirect the user to for Bluesky authorisation'),
});
export type BlueskyAuthorizeResponse = z.infer<typeof BlueskyAuthorizeResponse>;

View File

@@ -0,0 +1,110 @@
/*
* 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 {
ConnectionTypes,
ConnectionVisibilityFlags,
ConnectionVisibilityFlagsDescriptions,
} from '@fluxer/constants/src/ConnectionConstants';
import {
createBitflagInt32Type,
createNamedStringLiteralUnion,
Int32Type,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ConnectionTypeSchema = withOpenApiType(
createNamedStringLiteralUnion(
[
[ConnectionTypes.BLUESKY, 'BLUESKY', 'Bluesky social account connection'],
[ConnectionTypes.DOMAIN, 'DOMAIN', 'Custom domain ownership connection'],
] as const,
'The type of external connection',
),
'ConnectionType',
);
export const ConnectionResponse = z.object({
id: z.string().describe('The unique identifier for this connection'),
type: ConnectionTypeSchema.describe('The type of connection'),
name: z.string().describe('The display name of the connection (handle or domain)'),
verified: z.boolean().describe('Whether the connection has been verified'),
visibility_flags: createBitflagInt32Type(
ConnectionVisibilityFlags,
ConnectionVisibilityFlagsDescriptions,
'Bitfield for connection visibility settings',
'ConnectionVisibilityFlags',
).describe('Bitfield controlling who can see this connection'),
sort_order: Int32Type.describe('The display order of this connection'),
});
export type ConnectionResponse = z.infer<typeof ConnectionResponse>;
export const ConnectionListResponse = z.array(ConnectionResponse);
export type ConnectionListResponse = z.infer<typeof ConnectionListResponse>;
export const ConnectionVerificationResponse = z.object({
token: z.string().describe('The verification token to place in DNS or profile'),
type: ConnectionTypeSchema.describe('The type of connection being verified'),
id: z.string().describe('The connection identifier (handle or domain)'),
instructions: z.string().describe('Human-readable instructions for completing verification'),
initiation_token: z.string().describe('Signed token the client sends back at verify time'),
});
export type ConnectionVerificationResponse = z.infer<typeof ConnectionVerificationResponse>;
export const VerifyAndCreateConnectionRequest = z.object({
initiation_token: z.string().describe('The signed initiation token returned from the create endpoint'),
visibility_flags: Int32Type.optional().describe('Bitfield controlling who can see this connection'),
});
export type VerifyAndCreateConnectionRequest = z.infer<typeof VerifyAndCreateConnectionRequest>;
export const CreateConnectionRequest = z.object({
type: ConnectionTypeSchema.describe('The type of connection to create'),
identifier: z.string().min(1).max(253).describe('The connection identifier (handle or domain)'),
visibility_flags: Int32Type.optional().describe('Bitfield controlling who can see this connection'),
});
export type CreateConnectionRequest = z.infer<typeof CreateConnectionRequest>;
export const UpdateConnectionRequest = z.object({
visibility_flags: Int32Type.optional().describe('Bitfield controlling who can see this connection'),
sort_order: Int32Type.optional().describe('The display order of this connection'),
});
export type UpdateConnectionRequest = z.infer<typeof UpdateConnectionRequest>;
export const ReorderConnectionsRequest = z.object({
connection_ids: z
.array(z.string())
.min(1)
.max(20)
.describe('Ordered list of connection IDs defining the new display order'),
});
export type ReorderConnectionsRequest = z.infer<typeof ReorderConnectionsRequest>;
export const ConnectionTypeParam = z.object({
type: ConnectionTypeSchema,
connection_id: z.string().describe('The unique identifier of the connection'),
});
export type ConnectionTypeParam = z.infer<typeof ConnectionTypeParam>;

View File

@@ -0,0 +1,43 @@
/*
* 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 {z} from 'zod';
export const DonationRequestLinkRequest = z.object({
email: z.email().max(254).describe('Email address to send the magic link to'),
});
export type DonationRequestLinkRequest = z.infer<typeof DonationRequestLinkRequest>;
export const DonationManageQuery = z.object({
token: z.string().length(64).describe('Magic link token for donor authentication'),
});
export type DonationManageQuery = z.infer<typeof DonationManageQuery>;
export const DonationCheckoutRequest = z.object({
email: z.email().max(254).describe('Donor email address'),
amount_cents: z.number().int().min(500).max(100000).describe('Donation amount in cents (500-100000)'),
currency: z.enum(['usd', 'eur']).describe('Currency for the donation'),
interval: z.enum(['month', 'year']).nullable().describe('Billing interval (null for one-time donation)'),
});
export type DonationCheckoutRequest = z.infer<typeof DonationCheckoutRequest>;
export const DonationCheckoutResponse = z.object({
url: z.url().describe('Stripe checkout URL to redirect the user to'),
});
export type DonationCheckoutResponse = z.infer<typeof DonationCheckoutResponse>;

View File

@@ -0,0 +1,130 @@
/*
* 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 {createNamedStringLiteralUnion, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const DesktopChannelEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['stable', 'Stable', 'The stable release channel for production use'],
['canary', 'Canary', 'The canary release channel for early access to new features'],
],
'The release channel',
),
'DesktopChannel',
);
export type DesktopChannel = z.infer<typeof DesktopChannelEnum>;
export const DesktopPlatformEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['win32', 'Windows', 'Microsoft Windows operating system'],
['darwin', 'macOS', 'Apple macOS operating system'],
['linux', 'Linux', 'Linux operating system'],
],
'The operating system platform',
),
'DesktopPlatform',
);
export type DesktopPlatform = z.infer<typeof DesktopPlatformEnum>;
export const DesktopArchEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['x64', 'x64', '64-bit x86 architecture (Intel/AMD)'],
['arm64', 'ARM64', '64-bit ARM architecture (Apple Silicon, ARM processors)'],
],
'The CPU architecture',
),
'DesktopArch',
);
export type DesktopArch = z.infer<typeof DesktopArchEnum>;
export const DesktopFormatEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['setup', 'Setup', 'Windows installer executable'],
['dmg', 'DMG', 'macOS disk image'],
['zip', 'ZIP', 'Compressed archive'],
['appimage', 'AppImage', 'Linux portable application'],
['deb', 'DEB', 'Debian/Ubuntu package'],
['rpm', 'RPM', 'Red Hat/Fedora package'],
['tar_gz', 'TAR.GZ', 'Compressed tarball archive'],
],
'The package format',
),
'DesktopFormat',
);
export type DesktopFormat = z.infer<typeof DesktopFormatEnum>;
export const VersionString = z
.string()
.regex(/^\d+\.\d+\.\d+$/u)
.describe('Semantic version string');
export const DesktopRedirectParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
format: DesktopFormatEnum,
});
export type DesktopRedirectParam = z.infer<typeof DesktopRedirectParam>;
export const DesktopVersionedRedirectParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
version: VersionString,
format: DesktopFormatEnum,
});
export type DesktopVersionedRedirectParam = z.infer<typeof DesktopVersionedRedirectParam>;
export const DesktopVersionsParam = z.object({
channel: DesktopChannelEnum,
plat: DesktopPlatformEnum,
arch: DesktopArchEnum,
});
export type DesktopVersionsParam = z.infer<typeof DesktopVersionsParam>;
export const DesktopVersionsQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(25).describe('Maximum number of versions to return'),
before: VersionString.optional().describe('Return versions before this version'),
after: VersionString.optional().describe('Return versions after this version'),
});
export type DesktopVersionsQuery = z.infer<typeof DesktopVersionsQuery>;
export const VersionFileResponse = z.object({
url: z.string().describe('Download URL for this file'),
sha256: z.string().nullable().describe('SHA-256 hash of the file for verification'),
});
export type VersionFileResponse = z.infer<typeof VersionFileResponse>;
export const VersionInfoResponse = z.object({
version: z.string().describe('Semantic version string (e.g., 1.0.0)'),
pub_date: z.string().describe('ISO 8601 date when this version was published'),
files: z.record(DesktopFormatEnum, VersionFileResponse).describe('Map of package format to download files'),
});
export type VersionInfoResponse = z.infer<typeof VersionInfoResponse>;
export const DesktopVersionsResponse = z.object({
versions: z.array(VersionInfoResponse).max(100).describe('Array of available versions'),
has_more: z.boolean().describe('Whether more versions are available to fetch'),
});
export type DesktopVersionsResponse = z.infer<typeof DesktopVersionsResponse>;

View File

@@ -0,0 +1,55 @@
/*
* 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 {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {APIErrorCodesDescriptions} from '@fluxer/constants/src/ApiErrorCodesDescriptions';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodesDescriptions} from '@fluxer/constants/src/ValidationErrorCodesDescriptions';
import {createFlexibleStringLiteralUnion} from '@fluxer/schema/src/primitives/SchemaPrimitives';
type APIErrorCodePair = readonly [string, string, string?];
function buildAPIErrorCodePairs(): ReadonlyArray<APIErrorCodePair> {
return Object.entries(APIErrorCodes).map(([key, value]) => {
const description = APIErrorCodesDescriptions[key as keyof typeof APIErrorCodesDescriptions];
return [value, key, description] as const;
});
}
function buildValidationErrorCodePairs(): ReadonlyArray<APIErrorCodePair> {
return Object.entries(ValidationErrorCodes).map(([key, value]) => {
const description = ValidationErrorCodesDescriptions[key as keyof typeof ValidationErrorCodesDescriptions];
return [value, key, description] as const;
});
}
export const APIErrorCodeSchema = createFlexibleStringLiteralUnion(
buildAPIErrorCodePairs(),
'Error codes returned by API operations',
);
export const ValidationErrorCodeSchema = createFlexibleStringLiteralUnion(
buildValidationErrorCodePairs(),
'Error codes for field validation issues',
);
export const AnyErrorCodeSchema = createFlexibleStringLiteralUnion(
[...buildAPIErrorCodePairs()],
'Any error code in top-level error response',
);

View File

@@ -0,0 +1,35 @@
/*
* 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 {AnyErrorCodeSchema, ValidationErrorCodeSchema} from '@fluxer/schema/src/domains/error/ErrorCodeSchemas';
import {z} from 'zod';
export const ValidationErrorItem = z.object({
path: z.string().describe('The field path where the validation error occurred'),
message: z.string().describe('A human-readable description of the validation issue'),
code: ValidationErrorCodeSchema.optional().describe('The validation error code for this issue'),
});
export type ValidationErrorItem = z.infer<typeof ValidationErrorItem>;
export const ErrorSchema = z.object({
code: AnyErrorCodeSchema.describe('Error code identifier'),
message: z.string().describe('Human-readable error message'),
errors: z.array(ValidationErrorItem).optional().describe('Field-specific validation errors'),
});
export type ErrorResponse = z.infer<typeof ErrorSchema>;

View File

@@ -0,0 +1,121 @@
/*
* 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 {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const StatusTypeValues = Object.values(StatusTypes) as Array<ValueOf<typeof StatusTypes>>;
export const StatusTypeSchema = z.enum(
StatusTypeValues as [ValueOf<typeof StatusTypes>, ...Array<ValueOf<typeof StatusTypes>>],
);
export const CustomStatusResponse = z.object({
text: z.string().describe('The custom status text'),
emoji_id: SnowflakeStringType.nullable().describe('The ID of the custom emoji used in the status'),
emoji_name: z.string().nullable().describe('The name of the emoji used in the status'),
expires_at: z.string().nullable().describe('ISO8601 timestamp when the custom status expires'),
});
export type CustomStatusResponse = z.infer<typeof CustomStatusResponse>;
export const PresenceResponse = z.object({
user: UserPartialResponse.describe('The user this presence is for'),
status: StatusTypeSchema.describe('The current online status of the user'),
mobile: z.boolean().describe('Whether the user is on a mobile device'),
afk: z.boolean().describe('Whether the user is marked as AFK'),
custom_status: CustomStatusResponse.nullable().describe('The custom status set by the user'),
});
export type PresenceResponse = z.infer<typeof PresenceResponse>;
export const SessionResponse = z.object({
session_id: z.string().describe('The session identifier, or "all" for the aggregate session'),
status: StatusTypeSchema.describe('The status for this session'),
mobile: z.boolean().describe('Whether this session is on a mobile device'),
afk: z.boolean().describe('Whether this session is marked as AFK'),
});
export type SessionResponse = z.infer<typeof SessionResponse>;
export const VoiceStateResponse = z.object({
guild_id: SnowflakeStringType.nullable().describe('The guild ID this voice state is for, null if in a DM call'),
channel_id: SnowflakeStringType.nullable().describe('The channel ID the user is connected to, null if disconnected'),
user_id: SnowflakeStringType.describe('The user ID this voice state is for'),
connection_id: z.string().nullable().optional().describe('The unique connection identifier'),
session_id: z.string().optional().describe('The session ID for this voice state'),
member: GuildMemberResponse.optional().describe('The guild member data, if in a guild voice channel'),
mute: z.boolean().describe('Whether the user is server muted'),
deaf: z.boolean().describe('Whether the user is server deafened'),
self_mute: z.boolean().describe('Whether the user has muted themselves'),
self_deaf: z.boolean().describe('Whether the user has deafened themselves'),
self_video: z.boolean().optional().describe('Whether the user has their camera enabled'),
self_stream: z.boolean().optional().describe('Whether the user is streaming'),
is_mobile: z.boolean().optional().describe('Whether the user is connected from a mobile device'),
viewer_stream_keys: z
.array(z.string())
.nullable()
.optional()
.describe('The stream keys the user is currently viewing'),
version: Int32Type.optional().describe('The voice state version for ordering updates'),
});
export type VoiceStateResponse = z.infer<typeof VoiceStateResponse>;
export const ReadStateResponse = z.object({
id: SnowflakeStringType.describe('The channel ID for this read state'),
mention_count: Int32Type.describe('Number of unread mentions in the channel'),
last_message_id: SnowflakeStringType.nullable().describe('The ID of the last message read'),
last_pin_timestamp: z.string().nullable().describe('ISO8601 timestamp of the last pinned message acknowledged'),
});
export type ReadStateResponse = z.infer<typeof ReadStateResponse>;
export const GuildReadyResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this guild'),
unavailable: z.boolean().optional().describe('Whether the guild is unavailable due to an outage'),
name: z.string().optional().describe('The name of the guild'),
icon: z.string().nullish().describe('The hash of the guild icon'),
owner_id: SnowflakeStringType.optional().describe('The ID of the guild owner'),
member_count: Int32Type.optional().describe('Total number of members in the guild'),
lazy: z.boolean().optional().describe('Whether this guild uses lazy loading'),
large: z.boolean().optional().describe('Whether this guild is considered large'),
joined_at: z.string().optional().describe('ISO8601 timestamp of when the user joined'),
});
export type GuildReadyResponse = z.infer<typeof GuildReadyResponse>;
export const GatewayBotResponse = z.object({
url: z.string().describe('WebSocket URL to connect to the gateway'),
shards: z.number().int().describe('Recommended number of shards to use when connecting'),
session_start_limit: z
.object({
total: z.number().int().describe('Total number of session starts allowed'),
remaining: z.number().int().describe('Remaining number of session starts'),
reset_after: z.number().int().describe('Milliseconds until the limit resets'),
max_concurrency: z.number().int().describe('Maximum number of concurrent IDENTIFY requests'),
})
.describe('Session start rate limit information'),
});
export type GatewayBotResponse = z.infer<typeof GatewayBotResponse>;

View File

@@ -0,0 +1,114 @@
/*
* 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 {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {AuditLogActionTypeSchema} from '@fluxer/schema/src/primitives/AuditLogValidators';
import {
coerceNumberFromString,
Int32Type,
SnowflakeStringType,
SnowflakeType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {WebhookTypeSchema} from '@fluxer/schema/src/primitives/WebhookValidators';
import {z} from 'zod';
const PermissionsDiffSchema = z.object({
added: z.array(z.string()),
removed: z.array(z.string()),
});
const AuditLogChangeValueSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.array(z.number()),
PermissionsDiffSchema,
z.null(),
]);
export const AuditLogChangeSchema = z.object({
key: z.string().describe('The field that changed'),
old_value: AuditLogChangeValueSchema.optional().describe('Value before the change'),
new_value: AuditLogChangeValueSchema.optional().describe('Value after the change'),
});
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'),
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'),
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'),
temporary: z.boolean().optional().describe('Whether the invite grants temporary membership'),
uses: z.number().optional().describe('Number of times the invite has been used'),
});
export const GuildAuditLogEntryResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this audit log entry'),
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'),
options: AuditLogOptionsSchema.optional().describe('Additional options depending on action type'),
changes: z.array(AuditLogChangeSchema).optional().describe('Changes made to the target'),
});
export type GuildAuditLogEntryResponse = z.infer<typeof GuildAuditLogEntryResponse>;
export const AuditLogWebhookResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this webhook'),
type: WebhookTypeSchema,
guild_id: SnowflakeStringType.nullish().describe('The guild ID this webhook belongs to'),
channel_id: SnowflakeStringType.nullish().describe('The channel ID this webhook posts to'),
name: z.string().describe('The name of the webhook'),
avatar_hash: z.string().nullish().describe('The hash of the webhook avatar'),
});
export type AuditLogWebhookResponse = z.infer<typeof AuditLogWebhookResponse>;
export const GuildAuditLogListResponse = z.object({
audit_log_entries: z.array(GuildAuditLogEntryResponse).max(100).describe('Array of audit log entries'),
users: z.array(UserPartialResponse).max(100).describe('Users referenced in the audit log entries'),
webhooks: z.array(AuditLogWebhookResponse).max(100).describe('Webhooks referenced in the audit log entries'),
});
export type GuildAuditLogListResponse = z.infer<typeof GuildAuditLogListResponse>;
export const GuildAuditLogListQuery = z.object({
limit: coerceNumberFromString(Int32Type.max(100))
.optional()
.describe('Maximum number of audit log entries to return (1-100)'),
before: SnowflakeType.optional().describe('Get entries before this audit log entry ID'),
after: SnowflakeType.optional().describe('Get entries after this audit log entry ID'),
user_id: SnowflakeType.optional().describe('Filter entries by the user who performed the action'),
action_type: coerceNumberFromString(AuditLogActionTypeSchema)
.optional()
.describe('Filter entries by the type of action'),
});
export type GuildAuditLogListQuery = z.infer<typeof GuildAuditLogListQuery>;

View File

@@ -0,0 +1,128 @@
/*
* 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 {
DISCOVERY_DESCRIPTION_MAX_LENGTH,
DISCOVERY_DESCRIPTION_MIN_LENGTH,
} from '@fluxer/constants/src/DiscoveryConstants';
import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const DiscoveryApplicationRequest = z.object({
description: z
.string()
.min(DISCOVERY_DESCRIPTION_MIN_LENGTH)
.max(DISCOVERY_DESCRIPTION_MAX_LENGTH)
.describe('Description for discovery listing'),
category_id: z.number().int().min(0).max(8).describe('Discovery category ID'),
});
export type DiscoveryApplicationRequest = z.infer<typeof DiscoveryApplicationRequest>;
export const DiscoveryApplicationPatchRequest = z.object({
description: z
.string()
.min(DISCOVERY_DESCRIPTION_MIN_LENGTH)
.max(DISCOVERY_DESCRIPTION_MAX_LENGTH)
.optional()
.describe('Updated description for discovery listing'),
category_id: z.number().int().min(0).max(8).optional().describe('Updated discovery category ID'),
});
export type DiscoveryApplicationPatchRequest = z.infer<typeof DiscoveryApplicationPatchRequest>;
export const DiscoverySearchQuery = z.object({
query: z.string().max(100).optional().describe('Search query'),
category: z.coerce.number().int().min(0).max(8).optional().describe('Filter by category'),
sort_by: z.enum(['member_count', 'online_count', 'relevance']).optional().describe('Sort order'),
limit: z.coerce.number().int().min(1).max(48).optional().default(24).describe('Number of results to return'),
offset: z.coerce.number().int().min(0).optional().default(0).describe('Pagination offset'),
});
export type DiscoverySearchQuery = z.infer<typeof DiscoverySearchQuery>;
export const DiscoveryGuildResponse = z.object({
id: SnowflakeStringType.describe('Guild ID'),
name: z.string().describe('Guild name'),
icon: z.string().nullish().describe('Guild icon hash'),
description: z.string().nullish().describe('Discovery description'),
category_id: z.number().describe('Discovery category ID'),
member_count: z.number().describe('Approximate member count'),
online_count: z.number().describe('Approximate online member count'),
features: z.array(z.string()).describe('Guild feature flags'),
verification_level: z.number().describe('Verification level'),
});
export type DiscoveryGuildResponse = z.infer<typeof DiscoveryGuildResponse>;
export const DiscoveryGuildListResponse = z.object({
guilds: z.array(DiscoveryGuildResponse).describe('Discovery guild results'),
total: z.number().describe('Total number of matching guilds'),
});
export type DiscoveryGuildListResponse = z.infer<typeof DiscoveryGuildListResponse>;
export const DiscoveryApplicationResponse = z.object({
guild_id: SnowflakeStringType.describe('Guild ID'),
status: z.string().describe('Application status'),
description: z.string().describe('Discovery description'),
category_id: z.number().describe('Discovery category ID'),
applied_at: z.string().describe('Application timestamp'),
reviewed_at: z.string().nullish().describe('Review timestamp'),
review_reason: z.string().nullish().describe('Review reason'),
});
export type DiscoveryApplicationResponse = z.infer<typeof DiscoveryApplicationResponse>;
export const DiscoveryCategoryResponse = z.object({
id: z.number().describe('Category ID'),
name: z.string().describe('Category display name'),
});
export type DiscoveryCategoryResponse = z.infer<typeof DiscoveryCategoryResponse>;
export const DiscoveryCategoryListResponse = z.array(DiscoveryCategoryResponse);
export type DiscoveryCategoryListResponse = z.infer<typeof DiscoveryCategoryListResponse>;
export const DiscoveryAdminReviewRequest = z.object({
reason: z.string().max(500).optional().describe('Review reason'),
});
export type DiscoveryAdminReviewRequest = z.infer<typeof DiscoveryAdminReviewRequest>;
export const DiscoveryAdminRejectRequest = z.object({
reason: z.string().min(1).max(500).describe('Rejection reason'),
});
export type DiscoveryAdminRejectRequest = z.infer<typeof DiscoveryAdminRejectRequest>;
export const DiscoveryAdminRemoveRequest = z.object({
reason: z.string().min(1).max(500).describe('Removal reason'),
});
export type DiscoveryAdminRemoveRequest = z.infer<typeof DiscoveryAdminRemoveRequest>;
export const DiscoveryAdminListQuery = z.object({
status: z.enum(['pending', 'approved', 'rejected', 'removed']).optional().default('pending'),
limit: z.coerce.number().int().min(1).max(100).optional().default(25),
cursor: z.string().optional(),
});
export type DiscoveryAdminListQuery = z.infer<typeof DiscoveryAdminListQuery>;

View File

@@ -0,0 +1,122 @@
/*
* 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 {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const GuildEmojiResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this emoji'),
name: z.string().describe('The name of the emoji'),
animated: z.boolean().describe('Whether this emoji is animated'),
});
export type GuildEmojiResponse = z.infer<typeof GuildEmojiResponse>;
export const GuildEmojiWithUserResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this emoji'),
name: z.string().describe('The name of the emoji'),
animated: z.boolean().describe('Whether this emoji is animated'),
user: z.lazy(() => UserPartialResponse).describe('The user who uploaded this emoji'),
});
export type GuildEmojiWithUserResponse = z.infer<typeof GuildEmojiWithUserResponse>;
export const GuildStickerResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this sticker'),
name: z.string().describe('The name of the sticker'),
description: z.string().describe('The description of the sticker'),
tags: z.array(z.string()).max(100).describe('Autocomplete/suggestion tags for the sticker'),
animated: z.boolean().describe('Whether this sticker is animated'),
});
export type GuildStickerResponse = z.infer<typeof GuildStickerResponse>;
export const GuildStickerWithUserResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this sticker'),
name: z.string().describe('The name of the sticker'),
description: z.string().describe('The description of the sticker'),
tags: z.array(z.string()).max(100).describe('Autocomplete/suggestion tags for the sticker'),
animated: z.boolean().describe('Whether this sticker is animated'),
user: z.lazy(() => UserPartialResponse).describe('The user who uploaded this sticker'),
});
export type GuildStickerWithUserResponse = z.infer<typeof GuildStickerWithUserResponse>;
export const GuildEmojiBulkCreateResponse = z.object({
success: z.array(GuildEmojiResponse).max(500).describe('Successfully created emojis'),
failed: z
.array(
z.object({
name: z.string().describe('The name of the emoji that failed to create'),
error: z.string().describe('The error message explaining why the emoji failed to create'),
}),
)
.max(500)
.describe('Emojis that failed to create'),
});
export type GuildEmojiBulkCreateResponse = z.infer<typeof GuildEmojiBulkCreateResponse>;
export const GuildStickerBulkCreateResponse = z.object({
success: z.array(GuildStickerResponse).max(500).describe('Successfully created stickers'),
failed: z
.array(
z.object({
name: z.string().describe('The name of the sticker that failed to create'),
error: z.string().describe('The error message explaining why the sticker failed to create'),
}),
)
.max(500)
.describe('Stickers that failed to create'),
});
export type GuildStickerBulkCreateResponse = z.infer<typeof GuildStickerBulkCreateResponse>;
export const GuildEmojiWithUserListResponse = z.array(GuildEmojiWithUserResponse);
export type GuildEmojiWithUserListResponse = z.infer<typeof GuildEmojiWithUserListResponse>;
export const GuildStickerWithUserListResponse = z.array(GuildStickerWithUserResponse);
export type GuildStickerWithUserListResponse = z.infer<typeof GuildStickerWithUserListResponse>;
export interface GuildEmoji {
readonly id: string;
readonly name: string;
readonly animated: boolean;
readonly user?: UserPartial;
}
export interface GuildEmojiWithUser extends GuildEmoji {
readonly user: UserPartial;
}
export interface GuildSticker {
readonly id: string;
readonly name: string;
readonly description: string;
readonly tags: Array<string>;
readonly animated: boolean;
readonly user?: UserPartial;
}
export interface GuildStickerWithUser extends GuildSticker {
readonly user: UserPartial;
}

View File

@@ -0,0 +1,77 @@
/*
* 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 {GuildMemberProfileFlags, GuildMemberProfileFlagsDescriptions} from '@fluxer/constants/src/GuildConstants';
import {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {createBitflagInt32Type, Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const GuildMemberResponse = z.object({
user: z.lazy(() => UserPartialResponse).describe('The user this guild member represents'),
nick: z.string().nullish().describe('The nickname of the member in this guild'),
avatar: z.string().nullish().describe('The hash of the member guild-specific avatar'),
banner: z.string().nullish().describe('The hash of the member guild-specific banner'),
accent_color: Int32Type.nullish().describe('The accent colour of the member guild profile as an integer'),
roles: z.array(z.string()).max(250).describe('Array of role IDs the member has'),
joined_at: z.iso.datetime().describe('ISO8601 timestamp of when the user joined the guild'),
mute: z.boolean().describe('Whether the member is muted in voice channels'),
deaf: z.boolean().describe('Whether the member is deafened in voice channels'),
communication_disabled_until: z.iso
.datetime()
.nullish()
.describe('ISO8601 timestamp until which the member is timed out'),
profile_flags: createBitflagInt32Type(
GuildMemberProfileFlags,
GuildMemberProfileFlagsDescriptions,
'Member profile flags',
'GuildMemberProfileFlags',
).nullish(),
});
export type GuildMemberResponse = z.infer<typeof GuildMemberResponse>;
export const GuildBanResponse = z.object({
user: z.lazy(() => UserPartialResponse).describe('The banned user'),
reason: z.string().nullish().describe('The reason for the ban'),
moderator_id: SnowflakeStringType.describe('The ID of the moderator who issued the ban'),
banned_at: z.iso.datetime().describe('ISO8601 timestamp of when the ban was issued'),
expires_at: z.iso.datetime().nullish().describe('ISO8601 timestamp of when the ban expires (null if permanent)'),
});
export type GuildBanResponse = z.infer<typeof GuildBanResponse>;
export const GuildMemberListResponse = z.array(GuildMemberResponse).max(1000).describe('A list of guild members');
export type GuildMemberListResponse = z.infer<typeof GuildMemberListResponse>;
export const GuildBanListResponse = z.array(GuildBanResponse).max(1000).describe('A list of guild bans');
export type GuildBanListResponse = z.infer<typeof GuildBanListResponse>;
export interface GuildMemberData {
readonly user: UserPartialResponse;
readonly nick?: string | null;
readonly avatar?: string | null;
readonly banner?: string | null;
readonly accent_color?: number | null;
readonly roles: ReadonlyArray<string>;
readonly joined_at: string;
readonly mute?: boolean;
readonly deaf?: boolean;
readonly communication_disabled_until?: string | null;
readonly profile_flags?: number | null;
}

View File

@@ -0,0 +1,87 @@
/*
* 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 {JoinSourceTypeSchema} from '@fluxer/schema/src/primitives/GuildValidators';
import {z} from 'zod';
export const GuildMemberSearchRequest = z.object({
query: z.string().max(100).optional().describe('Text to search for in usernames, global names, and nicknames'),
limit: z.number().int().min(1).max(100).default(25).describe('Maximum number of results to return'),
offset: z.number().int().min(0).default(0).describe('Number of results to skip for pagination'),
role_ids: z
.array(z.string())
.max(10)
.optional()
.describe('Filter by role IDs (member must have all specified roles)'),
joined_at_gte: z.number().int().optional().describe('Filter members who joined at or after this unix timestamp'),
joined_at_lte: z.number().int().optional().describe('Filter members who joined at or before this unix timestamp'),
join_source_type: z.array(z.number().int()).max(10).optional().describe('Filter by join source types'),
source_invite_code: z.array(z.string()).max(10).optional().describe('Filter by invite codes used to join'),
is_bot: z.boolean().optional().describe('Filter by bot status'),
user_created_at_gte: z
.number()
.int()
.optional()
.describe('Filter members whose account was created at or after this unix timestamp'),
user_created_at_lte: z
.number()
.int()
.optional()
.describe('Filter members whose account was created at or before this unix timestamp'),
sort_by: z.enum(['joinedAt', 'relevance']).optional().describe('Sort results by field'),
sort_order: z.enum(['asc', 'desc']).optional().describe('Sort order'),
});
export type GuildMemberSearchRequest = z.infer<typeof GuildMemberSearchRequest>;
export const GuildMemberSearchSupplemental = z.object({
join_source_type: JoinSourceTypeSchema.nullish().describe('How the member joined'),
source_invite_code: z.string().nullable().describe('Invite code used to join'),
inviter_id: z.string().nullable().describe('User ID of the member who sent the invite'),
});
export type GuildMemberSearchSupplemental = z.infer<typeof GuildMemberSearchSupplemental>;
export const GuildMemberSearchResult = z.object({
id: z.string().describe('Composite ID (guildId:userId)'),
guild_id: z.string().describe('Guild ID'),
user_id: z.string().describe('User ID'),
username: z.string().describe('Username'),
discriminator: z.string().describe('Zero-padded 4-digit discriminator'),
global_name: z.string().nullable().describe('Global display name'),
nickname: z.string().nullable().describe('Guild nickname'),
role_ids: z.array(z.string()).describe('Role IDs'),
joined_at: z.number().describe('Unix timestamp of when the member joined'),
supplemental: GuildMemberSearchSupplemental.describe(
'Supplemental members-search-only metadata that is not part of the base guild member payload',
),
is_bot: z.boolean().describe('Whether the user is a bot'),
});
export type GuildMemberSearchResult = z.infer<typeof GuildMemberSearchResult>;
export const GuildMemberSearchResponse = z.object({
guild_id: z.string().describe('Guild ID'),
members: z.array(GuildMemberSearchResult).describe('Matching members'),
page_result_count: z.number().int().describe('Number of results in this page'),
total_result_count: z.number().int().describe('Total number of matching results'),
indexing: z.boolean().describe('Whether the guild members are currently being indexed'),
});
export type GuildMemberSearchResponse = z.infer<typeof GuildMemberSearchResponse>;

View File

@@ -0,0 +1,335 @@
/*
* 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 {
GuildMemberProfileFlags,
GuildMemberProfileFlagsDescriptions,
SystemChannelFlags,
SystemChannelFlagsDescriptions,
} from '@fluxer/constants/src/GuildConstants';
import {
AVATAR_MAX_SIZE,
EMOJI_MAX_SIZE,
STICKER_MAX_SIZE,
VALID_TEMP_BAN_DURATIONS,
} from '@fluxer/constants/src/LimitConstants';
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {VanityURLCodeType} from '@fluxer/schema/src/primitives/ChannelValidators';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {
DefaultMessageNotificationsSchema,
GuildExplicitContentFilterSchema,
GuildMFALevelSchema,
GuildVerificationLevelSchema,
NSFWLevelSchema,
SplashCardAlignmentSchema,
} from '@fluxer/schema/src/primitives/GuildValidators';
import {QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
ColorType,
createBitflagInt32Type,
createStringType,
SnowflakeType,
UnsignedInt64Type,
withFieldDescription,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {PasswordType} from '@fluxer/schema/src/primitives/UserValidators';
import {z} from 'zod';
export const GuildCreateRequest = z.object({
name: createStringType(1, 100).describe('The name of the guild (1-100 characters)'),
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the guild icon'),
empty_features: z.boolean().optional().describe('Whether to create the guild without default features'),
});
export type GuildCreateRequest = z.infer<typeof GuildCreateRequest>;
export const GuildUpdateRequest = z
.object({
name: createStringType(1, 100).describe('The name of the guild (1-100 characters)'),
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the guild icon'),
system_channel_id: SnowflakeType.nullish().describe('The ID of the channel where system messages are sent'),
system_channel_flags: createBitflagInt32Type(
SystemChannelFlags,
SystemChannelFlagsDescriptions,
'Bitfield of system channel flags controlling which messages are suppressed',
'SystemChannelFlags',
),
afk_channel_id: SnowflakeType.nullish().describe('The ID of the AFK voice channel'),
afk_timeout: z
.number()
.int()
.min(60)
.max(3600)
.describe('AFK timeout in seconds (60-3600) before moving users to the AFK channel'),
default_message_notifications: withFieldDescription(
DefaultMessageNotificationsSchema,
'Default notification level for new members',
),
verification_level: withFieldDescription(
GuildVerificationLevelSchema,
'Required verification level for members to participate',
),
mfa_level: withFieldDescription(GuildMFALevelSchema, 'Required MFA level for moderation actions'),
nsfw_level: withFieldDescription(NSFWLevelSchema, 'The NSFW level of the guild'),
explicit_content_filter: withFieldDescription(
GuildExplicitContentFilterSchema,
'Level of content filtering for explicit media',
),
banner: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the guild banner'),
splash: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the guild splash screen'),
embed_splash: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the embedded invite splash'),
splash_card_alignment: SplashCardAlignmentSchema.optional().describe(
'Alignment of the splash card (center, left, or right)',
),
features: z.array(z.string()).describe('Array of guild feature strings'),
message_history_cutoff: z.iso
.datetime()
.nullish()
.describe(
'ISO8601 timestamp controlling how far back members without Read Message History can access messages. Set to null to disable historical access.',
),
})
.partial()
.merge(SudoVerificationSchema);
export type GuildUpdateRequest = z.infer<typeof GuildUpdateRequest>;
export const GuildMemberUpdateRequest = z.object({
nick: createStringType(1, 32).nullish().describe('The nickname to set for the member (1-32 characters)'),
roles: z
.array(SnowflakeType)
.max(100, 'Maximum 100 roles allowed')
.optional()
.transform((ids) => (ids ? new Set(ids) : undefined))
.describe('Array of role IDs to assign to the member (max 100)'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the member guild avatar'),
banner: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('Base64-encoded image data for the member guild banner'),
bio: createStringType(1, 320).nullish().describe('The member guild profile bio (1-320 characters)'),
pronouns: createStringType(1, 40).nullish().describe('The member guild profile pronouns (1-40 characters)'),
accent_color: ColorType.nullish().describe('The accent color for the member guild profile as an integer'),
profile_flags: createBitflagInt32Type(
GuildMemberProfileFlags,
GuildMemberProfileFlagsDescriptions,
'Bitfield of profile flags for the member',
'GuildMemberProfileFlags',
).nullish(),
mute: z.boolean().optional().describe('Whether the member is muted in voice channels'),
deaf: z.boolean().optional().describe('Whether the member is deafened in voice channels'),
communication_disabled_until: z.iso
.datetime()
.nullish()
.describe('ISO8601 timestamp until which the member is timed out'),
timeout_reason: createStringType(1, 512)
.nullish()
.describe('The reason for timing out the member (1-512 characters)'),
channel_id: SnowflakeType.nullish().describe('The voice channel ID to move the member to'),
connection_id: createStringType(1, 32).nullish().describe('The voice connection ID for the member'),
});
export type GuildMemberUpdateRequest = z.infer<typeof GuildMemberUpdateRequest>;
export const MyGuildMemberUpdateRequest = GuildMemberUpdateRequest.omit({roles: true}).partial();
export type MyGuildMemberUpdateRequest = z.infer<typeof MyGuildMemberUpdateRequest>;
export const GuildRoleCreateRequest = z.object({
name: createStringType(1, 100).describe('The name of the role (1-100 characters)'),
color: ColorType.default(0x000000).describe('The color of the role as an integer (default: 0)'),
permissions: UnsignedInt64Type.optional().describe('fluxer:UnsignedInt64Type The permissions bitfield for the role'),
});
export type GuildRoleCreateRequest = z.infer<typeof GuildRoleCreateRequest>;
export const GuildRoleUpdateRequest = z.object({
name: createStringType(1, 100).optional().describe('The name of the role (1-100 characters)'),
color: ColorType.optional().describe('The color of the role as an integer'),
permissions: UnsignedInt64Type.optional().describe('fluxer:UnsignedInt64Type The permissions bitfield for the role'),
hoist: z.boolean().optional().describe('Whether the role should be displayed separately in the member list'),
hoist_position: z.number().int().nullish().describe('The position of the role in the hoisted member list'),
mentionable: z.boolean().optional().describe('Whether the role can be mentioned by anyone'),
});
export type GuildRoleUpdateRequest = z.infer<typeof GuildRoleUpdateRequest>;
export const GuildEmojiCreateRequest = z.object({
name: createStringType(2, 32)
.refine((value) => /^[a-zA-Z0-9_]+$/.test(value), 'Emoji name can only contain letters, numbers, and underscores')
.describe('The name of the emoji (2-32 characters, alphanumeric and underscores only)'),
image: createBase64StringType(1, Math.ceil(EMOJI_MAX_SIZE * (4 / 3))).describe(
'Base64-encoded image data for the emoji',
),
});
export type GuildEmojiCreateRequest = z.infer<typeof GuildEmojiCreateRequest>;
export const GuildEmojiUpdateRequest = GuildEmojiCreateRequest.pick({name: true});
export type GuildEmojiUpdateRequest = z.infer<typeof GuildEmojiUpdateRequest>;
export const GuildEmojiBulkCreateRequest = z.object({
emojis: z
.array(GuildEmojiCreateRequest)
.min(1, 'At least one emoji is required')
.max(50, 'Maximum 50 emojis per batch')
.describe('Array of emoji objects to create (1-50 emojis per batch)'),
});
export type GuildEmojiBulkCreateRequest = z.infer<typeof GuildEmojiBulkCreateRequest>;
export const GuildStickerCreateRequest = z.object({
name: createStringType(2, 30).describe('The name of the sticker (2-30 characters)'),
description: createStringType(1, 500).nullish().describe('Description of the sticker (1-500 characters)'),
tags: z
.array(createStringType(1, 30))
.min(0)
.max(10)
.optional()
.default([])
.describe('Array of autocomplete/suggestion tags (max 10 tags, each 1-30 characters)'),
image: createBase64StringType(1, Math.ceil(STICKER_MAX_SIZE * (4 / 3))).describe(
'Base64-encoded image data for the sticker',
),
});
export type GuildStickerCreateRequest = z.infer<typeof GuildStickerCreateRequest>;
export const GuildStickerUpdateRequest = GuildStickerCreateRequest.pick({
name: true,
description: true,
tags: true,
});
export type GuildStickerUpdateRequest = z.infer<typeof GuildStickerUpdateRequest>;
export const GuildStickerBulkCreateRequest = z.object({
stickers: z
.array(GuildStickerCreateRequest)
.min(1, 'At least one sticker is required')
.max(50, 'Maximum 50 stickers per batch')
.describe('Array of sticker objects to create (1-50 stickers per batch)'),
});
export type GuildStickerBulkCreateRequest = z.infer<typeof GuildStickerBulkCreateRequest>;
export const GuildTransferOwnershipRequest = z.object({
new_owner_id: SnowflakeType.describe('The ID of the user to transfer ownership to'),
password: PasswordType.optional().describe('The current owner password for verification'),
});
export type GuildTransferOwnershipRequest = z.infer<typeof GuildTransferOwnershipRequest>;
export const GuildBanCreateRequest = z.object({
delete_message_days: z
.number()
.int()
.min(0)
.max(7)
.default(0)
.describe('Number of days of messages to delete from the banned user (0-7)'),
reason: createStringType(0, 512).nullish().describe('The reason for the ban (max 512 characters)'),
ban_duration_seconds: z
.number()
.int()
.refine((val) => val === 0 || VALID_TEMP_BAN_DURATIONS.has(val), {
message: `Ban duration must be 0 (permanent) or one of the valid durations: ${Array.from(VALID_TEMP_BAN_DURATIONS).join(', ')} seconds`,
})
.optional()
.describe('Duration of the ban in seconds (0 for permanent, or a valid temporary duration)'),
});
export type GuildBanCreateRequest = z.infer<typeof GuildBanCreateRequest>;
export const GuildListQuery = z.object({
before: SnowflakeType.optional().describe('Get guilds before this guild ID'),
after: SnowflakeType.optional().describe('Get guilds after this guild ID'),
limit: z.coerce.number().int().min(1).max(200).default(200).describe('Maximum number of guilds to return (1-200)'),
with_counts: QueryBooleanType.describe('Include approximate member and presence counts'),
});
export type GuildListQuery = z.infer<typeof GuildListQuery>;
export const GuildDeleteRequest = z
.object({
password: PasswordType.optional().describe('The owner password for verification'),
})
.merge(SudoVerificationSchema);
export type GuildDeleteRequest = z.infer<typeof GuildDeleteRequest>;
export const GuildVanityURLUpdateRequest = z.object({
code: VanityURLCodeType.nullish().describe('The new vanity URL code (2-32 characters, alphanumeric and hyphens)'),
});
export type GuildVanityURLUpdateRequest = z.infer<typeof GuildVanityURLUpdateRequest>;
export const GuildVanityURLUpdateResponse = z.object({
code: createStringType(2, 32).describe('The new vanity URL code'),
});
export type GuildVanityURLUpdateResponse = z.infer<typeof GuildVanityURLUpdateResponse>;
export const GuildRoleHoistPositionItem = z.object({
id: SnowflakeType.describe('The ID of the role'),
hoist_position: z.number().int().describe('The new hoist position for the role'),
});
export type GuildRoleHoistPositionItem = z.infer<typeof GuildRoleHoistPositionItem>;
export const GuildRoleHoistPositionsRequest = z.array(GuildRoleHoistPositionItem);
export type GuildRoleHoistPositionsRequest = z.infer<typeof GuildRoleHoistPositionsRequest>;
export const GuildRolePositionItem = z.object({
id: SnowflakeType.describe('The ID of the role'),
position: z.number().int().optional().describe('The new position for the role'),
});
export type GuildRolePositionItem = z.infer<typeof GuildRolePositionItem>;
export const GuildRolePositionsRequest = z.array(GuildRolePositionItem);
export type GuildRolePositionsRequest = z.infer<typeof GuildRolePositionsRequest>;
export const GuildMemberListQuery = z.object({
limit: z.coerce
.number()
.int()
.min(1)
.max(1000)
.default(1)
.describe('Maximum number of members to return (1-1000, default 1)'),
after: SnowflakeType.optional().describe('Get members after this user ID for pagination'),
});
export type GuildMemberListQuery = z.infer<typeof GuildMemberListQuery>;

View File

@@ -0,0 +1,218 @@
/*
* 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 type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
import {
GuildFeatures,
GuildOperations,
GuildOperationsDescriptions,
SystemChannelFlags,
SystemChannelFlagsDescriptions,
} from '@fluxer/constants/src/GuildConstants';
import {
DefaultMessageNotificationsSchema,
GuildExplicitContentFilterSchema,
GuildMFALevelSchema,
GuildVerificationLevelSchema,
NSFWLevelSchema,
SplashCardAlignmentSchema,
} from '@fluxer/schema/src/primitives/GuildValidators';
import {PermissionStringType} from '@fluxer/schema/src/primitives/PermissionValidators';
import {
createBitflagInt32Type,
createFlexibleStringLiteralUnion,
Int32Type,
SnowflakeStringType,
withFieldDescription,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
function normalizeGuildFeatures(features: Array<string>): Array<string> {
return Array.from(new Set(features)).sort((first, second) => first.localeCompare(second));
}
export const GuildFeatureSchema = withOpenApiType(
createFlexibleStringLiteralUnion(
[
[GuildFeatures.ANIMATED_ICON, 'ANIMATED_ICON', 'Guild can have an animated icon'],
[GuildFeatures.ANIMATED_BANNER, 'ANIMATED_BANNER', 'Guild can have an animated banner'],
[GuildFeatures.BANNER, 'BANNER', 'Guild can have a banner'],
[GuildFeatures.DETACHED_BANNER, 'DETACHED_BANNER', 'Guild banner is detached from splash'],
[GuildFeatures.INVITE_SPLASH, 'INVITE_SPLASH', 'Guild can have an invite splash'],
[GuildFeatures.INVITES_DISABLED, 'INVITES_DISABLED', 'Guild has invites disabled'],
[
GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES,
'TEXT_CHANNEL_FLEXIBLE_NAMES',
'Guild allows flexible text channel names',
],
[GuildFeatures.MORE_EMOJI, 'MORE_EMOJI', 'Guild has increased emoji slots'],
[GuildFeatures.MORE_STICKERS, 'MORE_STICKERS', 'Guild has increased sticker slots'],
[GuildFeatures.UNLIMITED_EMOJI, 'UNLIMITED_EMOJI', 'Guild has unlimited emoji slots'],
[GuildFeatures.UNLIMITED_STICKERS, 'UNLIMITED_STICKERS', 'Guild has unlimited sticker slots'],
[GuildFeatures.EXPRESSION_PURGE_ALLOWED, 'EXPRESSION_PURGE_ALLOWED', 'Guild allows purging expressions'],
[GuildFeatures.VANITY_URL, 'VANITY_URL', 'Guild can have a vanity URL'],
[GuildFeatures.VERIFIED, 'VERIFIED', 'Guild is verified'],
[GuildFeatures.VIP_VOICE, 'VIP_VOICE', 'Guild has VIP voice features'],
[GuildFeatures.UNAVAILABLE_FOR_EVERYONE, 'UNAVAILABLE_FOR_EVERYONE', 'Guild is unavailable for everyone'],
[
GuildFeatures.UNAVAILABLE_FOR_EVERYONE_BUT_STAFF,
'UNAVAILABLE_FOR_EVERYONE_BUT_STAFF',
'Guild is unavailable except for staff',
],
[GuildFeatures.VISIONARY, 'VISIONARY', 'Guild is a visionary guild'],
[GuildFeatures.OPERATOR, 'OPERATOR', 'Guild is an operator guild'],
[GuildFeatures.LARGE_GUILD_OVERRIDE, 'LARGE_GUILD_OVERRIDE', 'Guild has large guild overrides enabled'],
[GuildFeatures.VERY_LARGE_GUILD, 'VERY_LARGE_GUILD', 'Guild has increased member capacity enabled'],
[GuildFeatures.MANAGED_MESSAGE_SCHEDULING, 'MT_MESSAGE_SCHEDULING', 'Guild has managed message scheduling'],
[GuildFeatures.MANAGED_EXPRESSION_PACKS, 'MT_EXPRESSION_PACKS', 'Guild has managed expression packs'],
],
'A guild feature flag',
),
'GuildFeature',
);
const GuildFeatureListSchema = z
.array(GuildFeatureSchema)
.max(100)
.transform(normalizeGuildFeatures)
.describe('Array of guild feature flags');
export const GuildResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this guild'),
name: z.string().describe('The name of the guild'),
icon: z.string().nullish().describe('The hash of the guild icon'),
banner: z.string().nullish().describe('The hash of the guild banner'),
banner_width: Int32Type.nullish().describe('The width of the guild banner in pixels'),
banner_height: Int32Type.nullish().describe('The height of the guild banner in pixels'),
splash: z.string().nullish().describe('The hash of the guild splash screen'),
splash_width: Int32Type.nullish().describe('The width of the guild splash in pixels'),
splash_height: Int32Type.nullish().describe('The height of the guild splash in pixels'),
splash_card_alignment: SplashCardAlignmentSchema.describe('The alignment of the splash card'),
embed_splash: z.string().nullish().describe('The hash of the embedded invite splash'),
embed_splash_width: Int32Type.nullish().describe('The width of the embedded invite splash in pixels'),
embed_splash_height: Int32Type.nullish().describe('The height of the embedded invite splash in pixels'),
vanity_url_code: z.string().nullish().describe('The vanity URL code for the guild'),
owner_id: SnowflakeStringType.describe('The ID of the guild owner'),
system_channel_id: SnowflakeStringType.nullish().describe('The ID of the channel where system messages are sent'),
system_channel_flags: createBitflagInt32Type(
SystemChannelFlags,
SystemChannelFlagsDescriptions,
'System channel message flags',
'SystemChannelFlags',
),
rules_channel_id: SnowflakeStringType.nullish().describe('The ID of the rules channel'),
afk_channel_id: SnowflakeStringType.nullish().describe('The ID of the AFK voice channel'),
afk_timeout: Int32Type.describe('AFK timeout in seconds before moving users to the AFK channel'),
features: GuildFeatureListSchema,
verification_level: withFieldDescription(
GuildVerificationLevelSchema,
'Required verification level for members to participate',
),
mfa_level: withFieldDescription(GuildMFALevelSchema, 'Required MFA level for moderation actions'),
nsfw_level: withFieldDescription(NSFWLevelSchema, 'The NSFW level of the guild'),
explicit_content_filter: withFieldDescription(
GuildExplicitContentFilterSchema,
'Level of content filtering for explicit media',
),
default_message_notifications: withFieldDescription(
DefaultMessageNotificationsSchema,
'Default notification level for new members',
),
disabled_operations: createBitflagInt32Type(
GuildOperations,
GuildOperationsDescriptions,
'Bitfield of disabled operations in the guild',
'GuildOperations',
),
message_history_cutoff: z.iso
.datetime()
.nullish()
.describe(
'ISO8601 timestamp controlling how far back members without Read Message History can access messages. When null, no historical access is allowed.',
),
permissions: PermissionStringType.describe(
'fluxer:PermissionStringType The current user permissions in this guild',
).nullish(),
});
export type GuildResponse = z.infer<typeof GuildResponse>;
export const GuildPartialResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this guild'),
name: z.string().describe('The name of the guild'),
icon: z.string().nullish().describe('The hash of the guild icon'),
banner: z.string().nullish().describe('The hash of the guild banner'),
banner_width: Int32Type.nullish().describe('The width of the guild banner in pixels'),
banner_height: Int32Type.nullish().describe('The height of the guild banner in pixels'),
splash: z.string().nullish().describe('The hash of the guild splash screen'),
splash_width: Int32Type.nullish().describe('The width of the guild splash in pixels'),
splash_height: Int32Type.nullish().describe('The height of the guild splash in pixels'),
splash_card_alignment: SplashCardAlignmentSchema.describe('The alignment of the splash card'),
embed_splash: z.string().nullish().describe('The hash of the embedded invite splash'),
embed_splash_width: Int32Type.nullish().describe('The width of the embedded invite splash in pixels'),
embed_splash_height: Int32Type.nullish().describe('The height of the embedded invite splash in pixels'),
features: GuildFeatureListSchema,
});
export type GuildPartialResponse = z.infer<typeof GuildPartialResponse>;
export const GuildVanityURLResponse = z.object({
code: z.string().nullish().describe('The vanity URL code for the guild'),
uses: Int32Type.describe('The number of times this vanity URL has been used'),
});
export type GuildVanityURLResponse = z.infer<typeof GuildVanityURLResponse>;
export const GuildListResponse = z.array(GuildResponse).max(200).describe('A list of guilds');
export type GuildListResponse = z.infer<typeof GuildListResponse>;
export interface Guild {
readonly id: string;
readonly name: string;
readonly icon: string | null;
readonly banner?: string | null;
readonly banner_width?: number | null;
readonly banner_height?: number | null;
readonly splash?: string | null;
readonly splash_width?: number | null;
readonly splash_height?: number | null;
readonly splash_card_alignment?: GuildSplashCardAlignmentValue;
readonly embed_splash?: string | null;
readonly embed_splash_width?: number | null;
readonly embed_splash_height?: number | null;
readonly vanity_url_code: string | null;
readonly owner_id: string;
readonly system_channel_id: string | null;
readonly system_channel_flags?: number;
readonly rules_channel_id?: string | null;
readonly afk_channel_id?: string | null;
readonly afk_timeout?: number;
readonly features: ReadonlyArray<string>;
readonly verification_level?: number;
readonly mfa_level?: number;
readonly nsfw_level?: number;
readonly explicit_content_filter?: number;
readonly default_message_notifications?: number;
readonly disabled_operations?: number;
readonly message_history_cutoff?: string | null;
readonly joined_at?: string;
readonly unavailable?: boolean;
readonly member_count?: number;
}

View File

@@ -0,0 +1,51 @@
/*
* 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 {PermissionStringType} from '@fluxer/schema/src/primitives/PermissionValidators';
import {Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const GuildRoleResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this role'),
name: z.string().describe('The name of the role'),
color: Int32Type.describe('The colour of the role as an integer'),
position: Int32Type.describe('The position of the role in the role hierarchy'),
hoist_position: Int32Type.nullish().describe('The position of the role in the hoisted member list'),
permissions: PermissionStringType.describe('fluxer:PermissionStringType The permissions bitfield for the role'),
hoist: z.boolean().describe('Whether this role is displayed separately in the member list'),
mentionable: z.boolean().describe('Whether this role can be mentioned by anyone'),
unicode_emoji: z.string().nullish().describe('The unicode emoji for this role'),
});
export type GuildRoleResponse = z.infer<typeof GuildRoleResponse>;
export const GuildRoleListResponse = z.array(GuildRoleResponse).max(250).describe('A list of guild roles');
export type GuildRoleListResponse = z.infer<typeof GuildRoleListResponse>;
export interface GuildRole {
readonly id: string;
readonly name: string;
readonly color: number;
readonly position: number;
readonly hoist_position?: number | null;
readonly permissions: string;
readonly hoist: boolean;
readonly mentionable: boolean;
readonly unicode_emoji?: string | null;
}

View File

@@ -0,0 +1,199 @@
/*
* 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 {SsoStatusResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {z} from 'zod';
export const LimitFilterResponse = z.object({
traits: z.array(z.string()).optional().describe('Trait filters for this limit rule'),
guildFeatures: z.array(z.string()).optional().describe('Guild feature filters for this limit rule'),
});
export type LimitFilterResponse = z.infer<typeof LimitFilterResponse>;
export const LimitRuleResponse = z.object({
id: z.string().describe('Unique identifier for this limit rule'),
filters: LimitFilterResponse.optional().describe('Filters that determine when this rule applies'),
overrides: z
.record(z.string(), z.number())
.describe('Map of limit keys to their override values (differences from defaults)'),
});
export type LimitRuleResponse = z.infer<typeof LimitRuleResponse>;
export const LimitConfigResponse = z.object({
version: z.literal(2).describe('Wire format version'),
traitDefinitions: z.array(z.string()).describe('Available trait definitions (e.g., "premium")'),
rules: z.array(LimitRuleResponse).describe('Array of limit rules to evaluate'),
defaultsHash: z.string().describe('Hash of the default limit values for cache invalidation'),
});
export type LimitConfigResponse = z.infer<typeof LimitConfigResponse>;
export const AppPublicConfigResponse = z.object({
sentry_dsn: z.string().describe('Sentry DSN for client-side error reporting'),
sentry_proxy_path: z.string().describe('Proxy path for Sentry requests'),
sentry_report_host: z.string().describe('Host for Sentry error reports'),
sentry_project_id: z.string().describe('Sentry project ID'),
sentry_public_key: z.string().describe('Sentry public key'),
});
export type AppPublicConfigResponse = z.infer<typeof AppPublicConfigResponse>;
export const InstanceInfoResponse = z.object({
api_code_version: z.number().int().describe('Version of the API server code'),
endpoints: z
.object({
api: z.string().describe('Base URL for authenticated API requests'),
api_client: z.string().describe('Base URL for client API requests'),
api_public: z.string().describe('Base URL for public API requests'),
gateway: z.string().describe('WebSocket URL for the gateway'),
media: z.string().describe('Base URL for the media proxy'),
static_cdn: z.string().describe('Base URL for static assets (avatars, emojis, etc.)'),
marketing: z.string().describe('Base URL for the marketing website'),
admin: z.string().describe('Base URL for the admin panel'),
invite: z.string().describe('Base URL for invite links'),
gift: z.string().describe('Base URL for gift links'),
webapp: z.string().describe('Base URL for the web application'),
})
.describe('Endpoint URLs for various services'),
captcha: z
.object({
provider: z.string().describe('Captcha provider name (hcaptcha, turnstile, none)'),
hcaptcha_site_key: z.string().nullable().describe('hCaptcha site key if using hCaptcha'),
turnstile_site_key: z.string().nullable().describe('Cloudflare Turnstile site key if using Turnstile'),
})
.describe('Captcha configuration'),
features: z
.object({
sms_mfa_enabled: z.boolean().describe('Whether SMS-based MFA is available'),
voice_enabled: z.boolean().describe('Whether voice/video calling is enabled'),
stripe_enabled: z.boolean().describe('Whether Stripe payments are enabled'),
self_hosted: z.boolean().describe('Whether this is a self-hosted instance'),
manual_review_enabled: z.boolean().describe('Whether manual review mode is enabled for registrations'),
})
.describe('Feature flags for this instance'),
gif: z
.object({
provider: z.enum(['klipy', 'tenor']).describe('GIF provider used by the instance GIF picker'),
})
.describe('GIF provider configuration for clients'),
sso: SsoStatusResponse.describe('Single sign-on configuration'),
limits: LimitConfigResponse.describe('Limit configuration with rules and trait definitions'),
push: z
.object({
public_vapid_key: z.string().nullable().describe('VAPID public key for web push notifications'),
})
.describe('Push notification configuration'),
app_public: AppPublicConfigResponse.describe('Public application configuration for client-side features'),
federation: z
.object({
enabled: z.boolean().describe('Whether federation is enabled on this instance'),
version: z.number().int().describe('Federation protocol version'),
})
.optional()
.describe('Federation configuration'),
public_key: z
.object({
id: z.string().describe('Key identifier'),
algorithm: z.literal('x25519').describe('Key algorithm'),
public_key_base64: z.string().describe('Base64-encoded public key'),
})
.optional()
.describe('Public key for E2E encryption'),
oauth2: z
.object({
authorization_endpoint: z.string().describe('OAuth2 authorization endpoint URL'),
token_endpoint: z.string().describe('OAuth2 token endpoint URL'),
userinfo_endpoint: z.string().describe('OAuth2 userinfo endpoint URL'),
scopes_supported: z.array(z.string()).describe('Supported OAuth2 scopes'),
})
.optional()
.describe('OAuth2 endpoints for federation'),
});
export type InstanceInfoResponse = z.infer<typeof InstanceInfoResponse>;
export const WellKnownFluxerResponse = z.object({
api_code_version: z.number().int().describe('Version of the API server code'),
endpoints: z
.object({
api: z.string().describe('Base URL for authenticated API requests'),
api_client: z.string().describe('Base URL for client API requests'),
api_public: z.string().describe('Base URL for public API requests'),
gateway: z.string().describe('WebSocket URL for the gateway'),
media: z.string().describe('Base URL for the media proxy'),
static_cdn: z.string().describe('Base URL for static assets (avatars, emojis, etc.)'),
marketing: z.string().describe('Base URL for the marketing website'),
admin: z.string().describe('Base URL for the admin panel'),
invite: z.string().describe('Base URL for invite links'),
gift: z.string().describe('Base URL for gift links'),
webapp: z.string().describe('Base URL for the web application'),
})
.describe('Endpoint URLs for various services'),
captcha: z
.object({
provider: z.string().describe('Captcha provider name (hcaptcha, turnstile, none)'),
hcaptcha_site_key: z.string().nullable().describe('hCaptcha site key if using hCaptcha'),
turnstile_site_key: z.string().nullable().describe('Cloudflare Turnstile site key if using Turnstile'),
})
.describe('Captcha configuration'),
features: z
.object({
sms_mfa_enabled: z.boolean().describe('Whether SMS-based MFA is available'),
voice_enabled: z.boolean().describe('Whether voice/video calling is enabled'),
stripe_enabled: z.boolean().describe('Whether Stripe payments are enabled'),
self_hosted: z.boolean().describe('Whether this is a self-hosted instance'),
manual_review_enabled: z.boolean().describe('Whether manual review mode is enabled for registrations'),
})
.describe('Feature flags for this instance'),
gif: z
.object({
provider: z.enum(['klipy', 'tenor']).describe('GIF provider used by the instance GIF picker'),
})
.describe('GIF provider configuration for clients'),
sso: SsoStatusResponse.describe('Single sign-on configuration'),
limits: LimitConfigResponse.describe('Limit configuration with rules and trait definitions'),
push: z
.object({
public_vapid_key: z.string().nullable().describe('VAPID public key for web push notifications'),
})
.describe('Push notification configuration'),
app_public: AppPublicConfigResponse.describe('Public application configuration for client-side features'),
federation: z
.object({
enabled: z.boolean().describe('Whether federation is enabled on this instance'),
version: z.number().int().describe('Federation protocol version'),
})
.optional()
.describe('Federation configuration'),
public_key: z
.object({
id: z.string().describe('Key identifier'),
algorithm: z.literal('x25519').describe('Key algorithm'),
public_key_base64: z.string().describe('Base64-encoded public key'),
})
.optional()
.describe('Public key for E2E encryption'),
oauth2: z
.object({
authorization_endpoint: z.string().describe('OAuth2 authorization endpoint URL'),
token_endpoint: z.string().describe('OAuth2 token endpoint URL'),
userinfo_endpoint: z.string().describe('OAuth2 userinfo endpoint URL'),
scopes_supported: z.array(z.string()).describe('Supported OAuth2 scopes'),
})
.optional()
.describe('OAuth2 endpoints for federation'),
});
export type WellKnownFluxerResponse = z.infer<typeof WellKnownFluxerResponse>;

View File

@@ -0,0 +1,231 @@
/*
* 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 {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
import {MAX_INVITE_AGE_SECONDS, MAX_INVITE_USES} from '@fluxer/constants/src/LimitConstants';
import {type Channel, ChannelPartialResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {type Guild, GuildPartialResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {PackType} from '@fluxer/schema/src/domains/pack/PackSchemas';
import {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ChannelInviteCreateRequest = z.object({
max_uses: z
.number()
.int()
.min(0)
.max(MAX_INVITE_USES)
.nullish()
.default(0)
.describe('Maximum number of times this invite can be used (0 for unlimited)'),
max_age: z
.number()
.int()
.min(0)
.max(MAX_INVITE_AGE_SECONDS)
.nullish()
.default(0)
.describe('Duration in seconds before the invite expires (0 for never)'),
unique: z
.boolean()
.nullish()
.default(false)
.describe('Whether to create a new unique invite or reuse an existing one'),
temporary: z
.boolean()
.nullish()
.default(false)
.describe('Whether members that joined via this invite should be kicked after disconnecting'),
});
export type ChannelInviteCreateRequest = z.infer<typeof ChannelInviteCreateRequest>;
export const PackInviteCreateRequest = z.object({
max_uses: z
.number()
.int()
.min(0)
.max(MAX_INVITE_USES)
.nullish()
.default(0)
.describe('Maximum number of times this invite can be used (0 for unlimited)'),
max_age: z
.number()
.int()
.min(0)
.max(MAX_INVITE_AGE_SECONDS)
.nullish()
.default(0)
.describe('Duration in seconds before the invite expires (0 for never)'),
unique: z
.boolean()
.nullish()
.default(false)
.describe('Whether to create a new unique invite or reuse an existing one'),
});
export type PackInviteCreateRequest = z.infer<typeof PackInviteCreateRequest>;
export const GuildInviteResponse = z.object({
code: z.string().describe('The unique invite code'),
type: z.literal(InviteTypes.GUILD).describe('The type of invite (guild)'),
guild: GuildPartialResponse.describe('The guild this invite is for'),
channel: z.lazy(() => ChannelPartialResponse).describe('The channel this invite is for'),
inviter: z
.lazy(() => UserPartialResponse)
.nullish()
.describe('The user who created the invite'),
member_count: Int32Type.describe('The approximate total member count of the guild'),
presence_count: Int32Type.describe('The approximate online member count of the guild'),
expires_at: z.iso.datetime().nullish().describe('ISO8601 timestamp of when the invite expires'),
temporary: z.boolean().describe('Whether the invite grants temporary membership'),
});
export type GuildInviteResponse = z.infer<typeof GuildInviteResponse>;
export const GroupDmInviteResponse = z.object({
code: z.string().describe('The unique invite code'),
type: z.literal(InviteTypes.GROUP_DM).describe('The type of invite (group DM)'),
channel: z.lazy(() => ChannelPartialResponse).describe('The group DM channel this invite is for'),
inviter: z
.lazy(() => UserPartialResponse)
.nullish()
.describe('The user who created the invite'),
member_count: Int32Type.describe('The current member count of the group DM'),
expires_at: z.iso.datetime().nullish().describe('ISO8601 timestamp of when the invite expires'),
temporary: z.boolean().describe('Whether the invite grants temporary membership'),
});
export type GroupDmInviteResponse = z.infer<typeof GroupDmInviteResponse>;
const PackInfoResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for the pack'),
name: z.string().describe('The display name of the pack'),
description: z.string().nullish().describe('The description of the pack'),
type: PackType.describe('The type of pack (emoji or sticker)'),
creator_id: SnowflakeStringType.describe('The ID of the user who created the pack'),
created_at: z.iso.datetime().describe('ISO8601 timestamp of when the pack was created'),
updated_at: z.iso.datetime().describe('ISO8601 timestamp of when the pack was last updated'),
creator: z.lazy(() => UserPartialResponse).describe('The user who created the pack'),
});
export const PackInviteResponse = z.object({
code: z.string().describe('The unique invite code'),
type: z
.union([z.literal(InviteTypes.EMOJI_PACK), z.literal(InviteTypes.STICKER_PACK)])
.describe('The type of pack invite (emoji or sticker pack)'),
pack: PackInfoResponse.describe('The pack this invite is for'),
inviter: z
.lazy(() => UserPartialResponse)
.nullish()
.describe('The user who created the invite'),
expires_at: z.iso.datetime().nullish().describe('ISO8601 timestamp of when the invite expires'),
temporary: z.boolean().describe('Whether the invite grants temporary access'),
});
export type PackInviteResponse = z.infer<typeof PackInviteResponse>;
export const PackInviteMetadataResponse = z.object({
...PackInviteResponse.shape,
created_at: z.iso.datetime().describe('ISO8601 timestamp of when the invite was created'),
uses: Int32Type.describe('The number of times this invite has been used'),
max_uses: Int32Type.describe('The maximum number of times this invite can be used'),
});
export type PackInviteMetadataResponse = z.infer<typeof PackInviteMetadataResponse>;
export const GuildInviteMetadataResponse = z.object({
...GuildInviteResponse.shape,
created_at: z.iso.datetime().describe('ISO8601 timestamp of when the invite was created'),
uses: Int32Type.describe('The number of times this invite has been used'),
max_uses: Int32Type.describe('The maximum number of times this invite can be used'),
max_age: Int32Type.describe('The duration in seconds before the invite expires'),
});
export type GuildInviteMetadataResponse = z.infer<typeof GuildInviteMetadataResponse>;
export const GroupDmInviteMetadataResponse = z.object({
...GroupDmInviteResponse.shape,
created_at: z.iso.datetime().describe('ISO8601 timestamp of when the invite was created'),
uses: Int32Type.describe('The number of times this invite has been used'),
max_uses: Int32Type.describe('The maximum number of times this invite can be used'),
});
export type GroupDmInviteMetadataResponse = z.infer<typeof GroupDmInviteMetadataResponse>;
export const InviteResponseSchema = z.union([GuildInviteResponse, GroupDmInviteResponse, PackInviteResponse]);
export type InviteResponseSchema = z.infer<typeof InviteResponseSchema>;
export const InviteMetadataResponseSchema = z.union([
GuildInviteMetadataResponse,
GroupDmInviteMetadataResponse,
PackInviteMetadataResponse,
]);
export type InviteMetadataResponseSchema = z.infer<typeof InviteMetadataResponseSchema>;
export const InviteMetadataListResponse = z
.array(InviteMetadataResponseSchema)
.max(1000)
.describe('A list of invite metadata');
export type InviteMetadataListResponse = z.infer<typeof InviteMetadataListResponse>;
export interface InviteBase {
readonly code: string;
readonly type: number;
readonly inviter?: UserPartialResponse | null;
readonly expires_at: string | null;
readonly temporary: boolean;
}
export interface GuildInvite extends InviteBase {
readonly type: typeof InviteTypes.GUILD;
readonly guild: Guild;
readonly channel: Channel;
readonly member_count: number;
readonly presence_count: number;
readonly uses?: number;
readonly max_uses?: number;
readonly created_at?: string;
}
export interface GroupDmInvite extends InviteBase {
readonly type: typeof InviteTypes.GROUP_DM;
readonly channel: Channel;
readonly member_count: number;
}
export interface PackSummary {
readonly id: string;
readonly name: string;
readonly description?: string | null;
readonly type: 'emoji' | 'sticker';
readonly item_count: number;
readonly preview_items: ReadonlyArray<{
readonly id: string;
readonly name: string;
}>;
}
export interface PackInvite extends InviteBase {
readonly type: typeof InviteTypes.EMOJI_PACK | typeof InviteTypes.STICKER_PACK;
readonly pack: PackSummary & {readonly creator: UserPartialResponse};
}
export type Invite = GuildInvite | GroupDmInvite | PackInvite;

View File

@@ -0,0 +1,72 @@
/*
* 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 {LocaleSchema} from '@fluxer/schema/src/primitives/LocaleSchema';
import {createStringType, Int32Type} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const LocaleType = LocaleSchema.default('en-US').transform((v) => v.replace('-', '_'));
export const KlipySearchQuery = z.object({
q: createStringType(1, 256).describe('The search query'),
locale: LocaleType,
});
export type KlipySearchQuery = z.infer<typeof KlipySearchQuery>;
export const KlipyLocaleQuery = z.object({
locale: LocaleType,
});
export type KlipyLocaleQuery = z.infer<typeof KlipyLocaleQuery>;
export const KlipyRegisterShareRequest = z.object({
id: createStringType(1, 64).describe('The Klipy clip slug'),
q: createStringType(0, 256).nullish().describe('The search query used to find the clip'),
locale: LocaleType,
});
export type KlipyRegisterShareRequest = z.infer<typeof KlipyRegisterShareRequest>;
export const KlipyGifResponse = z.object({
id: z.string().describe('The unique Klipy clip slug'),
title: z.string().describe('The title/description of the clip'),
url: z.string().describe('The Klipy page URL for the clip'),
src: z.string().describe('Direct URL to the clip media file'),
proxy_src: z.string().describe('Proxied URL to the clip media file'),
width: Int32Type.describe('Width of the clip in pixels'),
height: Int32Type.describe('Height of the clip in pixels'),
});
export type KlipyGifResponse = z.infer<typeof KlipyGifResponse>;
export const KlipyCategoryTagResponse = z.object({
name: z.string().describe('The category/tag name'),
src: z.string().describe('URL to the category preview image'),
proxy_src: z.string().describe('Proxied URL to the category preview image'),
});
export type KlipyCategoryTagResponse = z.infer<typeof KlipyCategoryTagResponse>;
export const KlipyFeaturedResponse = z.object({
gifs: z.array(KlipyGifResponse).max(50).describe('Array of featured/trending clips'),
categories: z.array(KlipyCategoryTagResponse).max(100).describe('Array of clip categories'),
});
export type KlipyFeaturedResponse = z.infer<typeof KlipyFeaturedResponse>;

View File

@@ -0,0 +1,145 @@
/*
* 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 {
DEFAULT_MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUE,
MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES,
} from '@fluxer/constants/src/MediaProxyImageSizes';
import {z} from 'zod';
export const ImageSizeEnum = z.enum(MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUES);
export type ImageSize = z.infer<typeof ImageSizeEnum>;
export const ImageFormatEnum = z.enum(['png', 'jpg', 'jpeg', 'webp', 'gif']);
export type ImageFormat = z.infer<typeof ImageFormatEnum>;
export const ImageQualityEnum = z.enum(['high', 'low', 'lossless']);
export type ImageQuality = z.infer<typeof ImageQualityEnum>;
export const ImageQueryParams = z.object({
size: ImageSizeEnum.optional().default(DEFAULT_MEDIA_PROXY_IMAGE_SIZE_QUERY_VALUE),
format: ImageFormatEnum.optional().default('webp'),
quality: ImageQualityEnum.optional().default('high'),
animated: z.enum(['true', 'false']).optional().default('false'),
});
export type ImageQueryParams = z.infer<typeof ImageQueryParams>;
export const ExternalMediaQueryParams = z.object({
width: z.coerce.number().int().min(1).max(4096).optional(),
height: z.coerce.number().int().min(1).max(4096).optional(),
format: ImageFormatEnum.optional(),
quality: ImageQualityEnum.optional().default('lossless'),
animated: z.enum(['true', 'false']).optional().default('false'),
});
export type ExternalMediaQueryParams = z.infer<typeof ExternalMediaQueryParams>;
export const MetadataRequestExternal = z.object({
type: z.literal('external'),
url: z.url(),
with_base64: z.boolean().optional(),
isNSFWAllowed: z.boolean(),
});
export type MetadataRequestExternal = z.infer<typeof MetadataRequestExternal>;
export const MetadataRequestUpload = z.object({
type: z.literal('upload'),
upload_filename: z.string(),
isNSFWAllowed: z.boolean(),
});
export type MetadataRequestUpload = z.infer<typeof MetadataRequestUpload>;
export const MetadataRequestBase64 = z.object({
type: z.literal('base64'),
base64: z.string(),
isNSFWAllowed: z.boolean(),
});
export type MetadataRequestBase64 = z.infer<typeof MetadataRequestBase64>;
export const MetadataRequestS3 = z.object({
type: z.literal('s3'),
bucket: z.string(),
key: z.string(),
with_base64: z.boolean().optional(),
isNSFWAllowed: z.boolean(),
});
export type MetadataRequestS3 = z.infer<typeof MetadataRequestS3>;
export const MetadataRequest = z.discriminatedUnion('type', [
MetadataRequestExternal,
MetadataRequestUpload,
MetadataRequestBase64,
MetadataRequestS3,
]);
export type MetadataRequest = z.infer<typeof MetadataRequest>;
export const MetadataResponse = z.object({
format: z.string(),
content_type: z.string(),
content_hash: z.string(),
size: z.number(),
width: z.number().optional(),
height: z.number().optional(),
duration: z.number().optional(),
placeholder: z.string().optional(),
base64: z.string().optional(),
animated: z.boolean().optional(),
nsfw: z.boolean(),
nsfw_probability: z.number().optional(),
nsfw_predictions: z.record(z.string(), z.number()).optional(),
});
export type MetadataResponse = z.infer<typeof MetadataResponse>;
export const ThumbnailRequestBody = z.object({
upload_filename: z.string(),
});
export type ThumbnailRequestBody = z.infer<typeof ThumbnailRequestBody>;
export const ThumbnailResponse = z.object({
thumbnail: z.string(),
mime_type: z.string(),
});
export type ThumbnailResponse = z.infer<typeof ThumbnailResponse>;
export const FrameRequestUpload = z.object({
type: z.literal('upload'),
upload_filename: z.string(),
});
export type FrameRequestUpload = z.infer<typeof FrameRequestUpload>;
export const FrameRequestS3 = z.object({
type: z.literal('s3'),
bucket: z.string(),
key: z.string(),
});
export type FrameRequestS3 = z.infer<typeof FrameRequestS3>;
export const FrameRequest = z.discriminatedUnion('type', [FrameRequestUpload, FrameRequestS3]);
export type FrameRequest = z.infer<typeof FrameRequest>;
export const ExtractedFrame = z.object({
timestamp: z.number(),
mime_type: z.string(),
base64: z.string(),
});
export type ExtractedFrame = z.infer<typeof ExtractedFrame>;
export const FrameResponse = z.object({
frames: z.array(ExtractedFrame),
});
export type FrameResponse = z.infer<typeof FrameResponse>;

View File

@@ -0,0 +1,89 @@
/*
* 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 {createStringType, SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const FavoriteMemeBase = z.object({
name: createStringType(1, 100).describe('Display name for the meme'),
alt_text: createStringType(0, 500).nullish().describe('Alternative text description for accessibility'),
tags: z
.array(createStringType(1, 30))
.nullish()
.default([])
.transform((tags) => (tags || []).filter((t) => t.trim().length > 0))
.describe('Tags for categorizing and searching the meme'),
});
export const CreateFavoriteMemeBodySchema = FavoriteMemeBase.extend({
attachment_id: SnowflakeType.nullish().describe('ID of the message attachment to save as a meme'),
embed_index: z.number().int().min(0).nullish().describe('Index of the message embed to save as a meme'),
}).refine((data) => data.attachment_id !== undefined || data.embed_index !== undefined, {
message: 'Either attachment_id or embed_index must be provided',
});
export type CreateFavoriteMemeBodySchema = z.infer<typeof CreateFavoriteMemeBodySchema>;
export const CreateFavoriteMemeFromUrlBodySchema = FavoriteMemeBase.extend({
url: z.url().describe('URL of the image or video to save as a favorite meme'),
klipy_slug: createStringType(1, 100).nullish().describe('Klipy clip slug if the URL is from Klipy'),
tenor_slug_id: createStringType(1, 300)
.nullish()
.describe('Tenor view/<slug>-<id> identifier if the URL is from Tenor'),
})
.omit({name: true})
.extend({
name: createStringType(1, 100).nullish().describe('Display name for the meme'),
});
export type CreateFavoriteMemeFromUrlBodySchema = z.infer<typeof CreateFavoriteMemeFromUrlBodySchema>;
export const UpdateFavoriteMemeBodySchema = FavoriteMemeBase.partial()
.omit({tags: true})
.extend({
tags: z
.array(createStringType(1, 30))
.nullish()
.transform((tags) => (tags ? tags.filter((t) => t.trim().length > 0) : undefined))
.describe('New tags for categorizing and searching the meme'),
});
export type UpdateFavoriteMemeBodySchema = z.infer<typeof UpdateFavoriteMemeBodySchema>;
export const FavoriteMemeResponse = z.object({
id: SnowflakeStringType.describe('Unique identifier for the favorite meme'),
user_id: SnowflakeStringType.describe('ID of the user who owns this favorite meme'),
name: z.string().describe('Display name of the meme'),
alt_text: z.string().nullish().describe('Alternative text description for accessibility'),
tags: z.array(z.string()).describe('Tags for categorizing and searching the meme'),
attachment_id: SnowflakeStringType.describe('ID of the attachment storing the meme'),
filename: z.string().describe('Original filename of the meme'),
content_type: z.string().describe('MIME type of the meme file'),
content_hash: z.string().nullish().describe('Hash of the file content for deduplication'),
size: z.number().describe('File size in bytes'),
width: z.number().int().nullish().describe('Width of the image or video in pixels'),
height: z.number().int().nullish().describe('Height of the image or video in pixels'),
duration: z.number().nullish().describe('Duration of the video in seconds'),
url: z.string().describe('CDN URL to access the meme'),
is_gifv: z.boolean().default(false).describe('Whether the meme is a video converted from GIF'),
klipy_slug: z.string().nullish().describe('Klipy clip slug if the meme was sourced from Klipy'),
tenor_slug_id: z.string().nullish().describe('Tenor view/<slug>-<id> identifier if the meme was sourced from Tenor'),
});
export type FavoriteMemeResponse = z.infer<typeof FavoriteMemeResponse>;
export const FavoriteMemeListResponse = z.array(FavoriteMemeResponse);
export type FavoriteMemeListResponse = z.infer<typeof FavoriteMemeListResponse>;

View File

@@ -0,0 +1,60 @@
/*
* 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 {MessageAttachmentFlags, MessageAttachmentFlagsDescriptions} from '@fluxer/constants/src/ChannelConstants';
import {FilenameType} from '@fluxer/schema/src/primitives/FileValidators';
import {
coerceNumberFromString,
createBitflagInt32Type,
createStringType,
Int32Type,
SnowflakeType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const ClientAttachmentBase = z.object({
title: createStringType(1, 1024).nullish().describe('A title for the attachment (1-1024 characters)'),
description: createStringType(1, 4096)
.nullish()
.describe('An alt text description of the attachment (1-4096 characters)'),
flags: coerceNumberFromString(
createBitflagInt32Type(
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
'Attachment flags',
'MessageAttachmentFlags',
),
).default(0),
duration: Int32Type.nullish().describe('The duration of the audio file in seconds'),
waveform: createStringType(1, 4096).nullish().describe('Base64 encoded audio waveform data'),
});
export const ClientAttachmentRequest = ClientAttachmentBase.extend({
id: coerceNumberFromString(Int32Type).describe('The client-side identifier for this attachment'),
filename: FilenameType.describe('The name of the file being uploaded'),
});
export type ClientAttachmentRequest = z.infer<typeof ClientAttachmentRequest>;
export const ClientAttachmentReferenceRequest = ClientAttachmentBase.extend({
id: z
.union([Int32Type, SnowflakeType])
.describe('The identifier of the attachment being referenced (snowflake ID or file index)'),
filename: FilenameType.optional().describe('A new filename for the attachment'),
});
export type ClientAttachmentReferenceRequest = z.infer<typeof ClientAttachmentReferenceRequest>;

View File

@@ -0,0 +1,193 @@
/*
* 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 {EmbedMediaFlags, EmbedMediaFlagsDescriptions} from '@fluxer/constants/src/ChannelConstants';
import {createBitflagInt32Type, Int32Type} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const EmbedAuthorResponse = z.object({
name: z.string().describe('The name of the author'),
url: z.url().nullish().describe('The URL of the author'),
icon_url: z.url().nullish().describe('The URL of the author icon'),
proxy_icon_url: z.url().nullish().describe('The proxied URL of the author icon'),
});
export type EmbedAuthorResponse = z.infer<typeof EmbedAuthorResponse>;
export const EmbedFooterResponse = z.object({
text: z.string().describe('The footer text'),
icon_url: z.url().nullish().describe('The URL of the footer icon'),
proxy_icon_url: z.url().nullish().describe('The proxied URL of the footer icon'),
});
export type EmbedFooterResponse = z.infer<typeof EmbedFooterResponse>;
export const EmbedMediaResponse = z.object({
url: z.string().describe('The URL of the media'),
proxy_url: z.url().nullish().describe('The proxied URL of the media'),
content_type: z.string().nullish().describe('The MIME type of the media'),
content_hash: z.string().nullish().describe('The hash of the media content'),
width: Int32Type.nullish().describe('The width of the media in pixels'),
height: Int32Type.nullish().describe('The height of the media in pixels'),
description: z.string().nullish().describe('The description of the media'),
placeholder: z.string().nullish().describe('The base64 encoded placeholder image for lazy loading'),
duration: Int32Type.nullish().describe('The duration of the media in seconds'),
flags: createBitflagInt32Type(
EmbedMediaFlags,
EmbedMediaFlagsDescriptions,
'The bitwise flags for this media',
'EmbedMediaFlags',
),
});
export type EmbedMediaResponse = z.infer<typeof EmbedMediaResponse>;
export const EmbedFieldResponse = z.object({
name: z.string().describe('The name of the field'),
value: z.string().describe('The value of the field'),
inline: z.boolean().describe('Whether the field should be displayed inline'),
});
export type EmbedFieldResponse = z.infer<typeof EmbedFieldResponse>;
interface MessageEmbedChildResponseData {
type: string;
url?: string | null;
title?: string | null;
color?: number | null;
timestamp?: string | null;
description?: string | null;
author?: EmbedAuthorResponse | null;
image?: EmbedMediaResponse | null;
thumbnail?: EmbedMediaResponse | null;
footer?: EmbedFooterResponse | null;
fields?: Array<EmbedFieldResponse> | null;
provider?: EmbedAuthorResponse | null;
video?: EmbedMediaResponse | null;
audio?: EmbedMediaResponse | null;
nsfw?: boolean | null;
}
interface MessageEmbedResponseData extends MessageEmbedChildResponseData {
children?: Array<MessageEmbedChildResponseData> | null;
}
export const MessageEmbedChildResponse = z.object({
type: z.string().describe('The type of embed (e.g., rich, image, video, gifv, article, link)'),
url: z.url().nullish().describe('The URL of the embed'),
title: z.string().nullish().describe('The title of the embed'),
color: Int32Type.nullish().describe('The color code of the embed sidebar'),
timestamp: z.iso.datetime().nullish().describe('The ISO 8601 timestamp of the embed content'),
description: z.string().nullish().describe('The description of the embed'),
author: EmbedAuthorResponse.nullish().describe('The author information of the embed'),
image: EmbedMediaResponse.nullish().describe('The image of the embed'),
thumbnail: EmbedMediaResponse.nullish().describe('The thumbnail of the embed'),
footer: EmbedFooterResponse.nullish().describe('The footer of the embed'),
fields: z.array(EmbedFieldResponse).max(25).nullish().describe('The fields of the embed'),
provider: EmbedAuthorResponse.nullish().describe('The provider of the embed (e.g., YouTube, Twitter)'),
video: EmbedMediaResponse.nullish().describe('The video of the embed'),
audio: EmbedMediaResponse.nullish().describe('The audio of the embed'),
nsfw: z.boolean().nullish().describe('Whether the embed is flagged as NSFW'),
});
export type MessageEmbedChildResponse = z.infer<typeof MessageEmbedChildResponse>;
export const MessageEmbedResponse: z.ZodType<MessageEmbedResponseData> = MessageEmbedChildResponse.extend({
children: z
.array(MessageEmbedChildResponse)
.max(1)
.nullish()
.describe('Internal nested embeds generated by unfurlers'),
});
export type MessageEmbedResponse = z.infer<typeof MessageEmbedResponse>;
export interface EmbedAuthor {
readonly name: string;
readonly url?: string;
readonly icon_url?: string;
readonly proxy_icon_url?: string;
}
export interface EmbedFooter {
readonly text: string;
readonly icon_url?: string;
readonly proxy_icon_url?: string;
}
export interface EmbedMedia {
readonly url: string;
readonly proxy_url?: string;
readonly content_type?: string;
readonly content_hash?: string | null;
readonly width?: number;
readonly height?: number;
readonly placeholder?: string;
readonly flags: number;
readonly description?: string;
readonly duration?: number;
readonly nsfw?: boolean;
}
export interface EmbedField {
readonly name: string;
readonly value: string;
readonly inline: boolean;
}
export interface MessageEmbed {
readonly id?: string;
readonly type: string;
readonly url?: string;
readonly title?: string;
readonly color?: number;
readonly timestamp?: string;
readonly description?: string;
readonly author?: EmbedAuthor;
readonly image?: EmbedMedia;
readonly thumbnail?: EmbedMedia;
readonly footer?: EmbedFooter;
readonly fields?: ReadonlyArray<EmbedField>;
readonly provider?: EmbedAuthor;
readonly video?: EmbedMedia;
readonly audio?: EmbedMedia;
readonly children?: ReadonlyArray<MessageEmbedChild>;
readonly flags?: number;
readonly nsfw?: boolean;
}
export interface MessageEmbedChild {
readonly id?: string;
readonly type: string;
readonly url?: string;
readonly title?: string;
readonly color?: number;
readonly timestamp?: string;
readonly description?: string;
readonly author?: EmbedAuthor;
readonly image?: EmbedMedia;
readonly thumbnail?: EmbedMedia;
readonly footer?: EmbedFooter;
readonly fields?: ReadonlyArray<EmbedField>;
readonly provider?: EmbedAuthor;
readonly video?: EmbedMedia;
readonly audio?: EmbedMedia;
readonly flags?: number;
readonly nsfw?: boolean;
}

View File

@@ -0,0 +1,330 @@
/*
* 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 {MessageFlags, MessageFlagsDescriptions} from '@fluxer/constants/src/ChannelConstants';
import {
ClientAttachmentReferenceRequest,
ClientAttachmentRequest,
} from '@fluxer/schema/src/domains/message/AttachmentSchemas';
import {AllowedMentionsRequest, MessageReferenceRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
import {createQueryIntegerType, DateTimeType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
ColorType,
createBitflagInt32Type,
createNamedStringLiteralUnion,
createStringType,
createUnboundedStringType,
Int32Type,
SnowflakeType,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {AttachmentURLType, URLType} from '@fluxer/schema/src/primitives/UrlValidators';
import {z} from 'zod';
export const RichEmbedAuthorRequest = z.object({
name: createStringType().describe('Name of the embed author'),
url: URLType.nullish().describe('URL to link from the author name'),
icon_url: URLType.nullish().describe('URL of the author icon'),
});
export type RichEmbedAuthorRequest = z.infer<typeof RichEmbedAuthorRequest>;
export const RichEmbedMediaRequest = z.object({
url: AttachmentURLType.describe('URL of the media (image, video, etc.)'),
description: createStringType(1, 4096).nullish().describe('Alt text description of the media'),
});
export type RichEmbedMediaRequest = z.infer<typeof RichEmbedMediaRequest>;
export const RichEmbedFooterRequest = z.object({
text: createStringType(1, 2048).describe('Footer text (1-2048 characters)'),
icon_url: URLType.nullish().describe('URL of the footer icon'),
});
export type RichEmbedFooterRequest = z.infer<typeof RichEmbedFooterRequest>;
export const RichEmbedFieldRequest = z.object({
name: createStringType().describe('Name of the field'),
value: createStringType(1, 1024).describe('Value of the field (1-1024 characters)'),
inline: z.boolean().default(false).describe('Whether the field should display inline'),
});
export type RichEmbedFieldRequest = z.infer<typeof RichEmbedFieldRequest>;
export const RichEmbedRequest = z.object({
url: URLType.nullish().describe('URL of the embed'),
title: createStringType().nullish().describe('Title of the embed'),
color: ColorType.nullish().describe('Color code of the embed (hex integer)'),
timestamp: DateTimeType.nullish().describe('ISO8601 timestamp for the embed'),
description: createStringType(1, 4096).nullish().describe('Description of the embed (1-4096 characters)'),
author: RichEmbedAuthorRequest.nullish().describe('Author information'),
image: RichEmbedMediaRequest.nullish().describe('Image to display in the embed'),
thumbnail: RichEmbedMediaRequest.nullish().describe('Thumbnail image for the embed'),
footer: RichEmbedFooterRequest.nullish().describe('Footer information'),
fields: z.array(RichEmbedFieldRequest).max(25).nullish().describe('Array of field objects (max 25)'),
});
export type RichEmbedRequest = z.infer<typeof RichEmbedRequest>;
export const MessageAuthorType = withOpenApiType(
createNamedStringLiteralUnion(
[
['user', 'user', 'A regular user account'],
['bot', 'bot', 'An automated bot account'],
['webhook', 'webhook', 'A webhook-generated message'],
],
'The type of author who sent the message',
),
'MessageAuthorType',
);
export type MessageAuthorType = z.infer<typeof MessageAuthorType>;
export const MessageContentType = withOpenApiType(
createNamedStringLiteralUnion(
[
['image', 'image', 'Message contains an image attachment'],
['sound', 'sound', 'Message contains an audio attachment'],
['video', 'video', 'Message contains a video attachment'],
['file', 'file', 'Message contains a file attachment'],
['sticker', 'sticker', 'Message contains a sticker'],
['embed', 'embed', 'Message contains an embed'],
['link', 'link', 'Message contains a URL link'],
['poll', 'poll', 'Message contains a poll'],
['snapshot', 'snapshot', 'Message contains a forwarded message snapshot'],
],
'The type of content contained in a message',
),
'MessageContentType',
);
export type MessageContentType = z.infer<typeof MessageContentType>;
export const MessageEmbedType = withOpenApiType(
createNamedStringLiteralUnion(
[
['image', 'image', 'An image embed from a linked URL'],
['video', 'video', 'A video embed from a linked URL'],
['sound', 'sound', 'An audio embed from a linked URL'],
['article', 'article', 'An article or webpage embed with metadata'],
],
'The type of embed content',
),
'MessageEmbedType',
);
export type MessageEmbedType = z.infer<typeof MessageEmbedType>;
export const MessageSortField = withOpenApiType(
createNamedStringLiteralUnion(
[
['timestamp', 'timestamp', 'Sort results by message timestamp'],
['relevance', 'relevance', 'Sort results by search relevance score'],
],
'The field to sort search results by',
),
'MessageSortField',
);
export type MessageSortField = z.infer<typeof MessageSortField>;
export const MessageSortOrder = withOpenApiType(
createNamedStringLiteralUnion(
[
['asc', 'asc', 'Sort in ascending order (oldest/lowest first)'],
['desc', 'desc', 'Sort in descending order (newest/highest first)'],
],
'The order to sort search results',
),
'MessageSortOrder',
);
export type MessageSortOrder = z.infer<typeof MessageSortOrder>;
export const MessageSearchScope = withOpenApiType(
createNamedStringLiteralUnion(
[
['current', 'current', 'Search only in the current channel or community context'],
['open_dms', 'open_dms', 'Search across all DMs you currently have open'],
['all_dms', 'all_dms', "Search across all DMs you've ever been in"],
['all_guilds', 'all_guilds', "Search across all Communities you're currently in"],
['all', 'all', "Search across all DMs you've ever been in and all Communities you're currently in"],
[
'open_dms_and_all_guilds',
'open_dms_and_all_guilds',
"Search across all DMs you currently have open and all Communities you're currently in",
],
],
'Search scope for message searches',
),
'MessageSearchScope',
);
export type MessageSearchScope = z.infer<typeof MessageSearchScope>;
export const MessageSearchRequest = z.object({
hits_per_page: z.number().int().min(1).max(25).default(25).describe('Number of results per page (1-25)'),
page: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(1).describe('Page number for pagination'),
max_id: SnowflakeType.optional().describe('Maximum message ID to include in results'),
min_id: SnowflakeType.optional().describe('Minimum message ID to include in results'),
content: createStringType(1, 1024).optional().describe('Text content to search for'),
contents: z.array(createStringType(1, 1024)).max(100).optional().describe('Multiple content queries to search for'),
exact_phrases: z
.array(createStringType(1, 1024))
.max(10)
.optional()
.describe('Exact phrases that must appear contiguously in message content'),
channel_id: z.array(SnowflakeType).max(500).optional().describe('Channel IDs to search in'),
exclude_channel_id: z.array(SnowflakeType).max(500).optional().describe('Channel IDs to exclude from search'),
author_type: z.array(MessageAuthorType).optional().describe('Author types to filter by'),
exclude_author_type: z.array(MessageAuthorType).optional().describe('Author types to exclude'),
author_id: z.array(SnowflakeType).optional().describe('Author user IDs to filter by'),
exclude_author_id: z.array(SnowflakeType).optional().describe('Author user IDs to exclude'),
mentions: z.array(SnowflakeType).optional().describe('User IDs that must be mentioned'),
exclude_mentions: z.array(SnowflakeType).optional().describe('User IDs that must not be mentioned'),
mention_everyone: z.boolean().optional().describe('Filter by whether message mentions everyone'),
pinned: z.boolean().optional().describe('Filter by pinned status'),
has: z.array(MessageContentType).optional().describe('Content types the message must have'),
exclude_has: z.array(MessageContentType).optional().describe('Content types the message must not have'),
embed_type: z.array(MessageEmbedType).optional().describe('Embed types to filter by'),
exclude_embed_type: z.array(MessageEmbedType).optional().describe('Embed types to exclude'),
embed_provider: z.array(createStringType(1, 256)).optional().describe('Embed providers to filter by'),
exclude_embed_provider: z.array(createStringType(1, 256)).optional().describe('Embed providers to exclude'),
link_hostname: z.array(createStringType(1, 255)).optional().describe('Link hostnames to filter by'),
exclude_link_hostname: z.array(createStringType(1, 255)).optional().describe('Link hostnames to exclude'),
attachment_filename: z.array(createStringType(1, 1024)).optional().describe('Attachment filenames to filter by'),
exclude_attachment_filename: z
.array(createStringType(1, 1024))
.optional()
.describe('Attachment filenames to exclude'),
attachment_extension: z.array(createStringType(1, 32)).optional().describe('File extensions to filter by'),
exclude_attachment_extension: z.array(createStringType(1, 32)).optional().describe('File extensions to exclude'),
sort_by: MessageSortField.default('timestamp').describe('Field to sort results by'),
sort_order: MessageSortOrder.default('desc').describe('Sort order for results'),
include_nsfw: z.boolean().default(false).describe('Whether to include NSFW channel results'),
scope: MessageSearchScope.optional().describe('Scope to search within when querying messages'),
});
export type MessageSearchRequest = z.infer<typeof MessageSearchRequest>;
export const GlobalSearchMessagesRequest = MessageSearchRequest.extend({
context_channel_id: SnowflakeType.optional().describe(
'Channel ID for context when searching across multiple channels',
),
context_guild_id: SnowflakeType.optional().describe('Guild ID for context when searching across multiple guilds'),
channel_ids: z.array(SnowflakeType).max(500).optional().describe('Specific channel IDs to search in'),
});
export type GlobalSearchMessagesRequest = z.infer<typeof GlobalSearchMessagesRequest>;
export const MessageRequestSchema = z
.object({
content: createUnboundedStringType().nullish().describe('The message content (up to 2000 characters)'),
embeds: z.array(RichEmbedRequest).describe('Array of embed objects to include in the message'),
attachments: z.array(ClientAttachmentRequest).describe('Array of attachment objects'),
message_reference: MessageReferenceRequest.nullish().describe(
'Reference to another message (for replies or forwards)',
),
allowed_mentions: AllowedMentionsRequest.nullish().describe('Controls which mentions trigger notifications'),
flags: createBitflagInt32Type(
MessageFlags,
MessageFlagsDescriptions,
'Message flags bitfield',
'MessageFlags',
).default(0),
nonce: createStringType(1, 32).describe('Client-generated identifier for the message'),
favorite_meme_id: SnowflakeType.nullish().describe('ID of a favorite meme to attach'),
sticker_ids: z.array(SnowflakeType).max(3).nullish().describe('Array of sticker IDs to include (max 3)'),
tts: z.boolean().optional().describe('Whether this is a text-to-speech message'),
})
.partial();
export type MessageRequestSchemaType = z.infer<typeof MessageRequestSchema>;
export const MessageUpdateRequestSchema = MessageRequestSchema.pick({
content: true,
embeds: true,
allowed_mentions: true,
}).extend({
flags: createBitflagInt32Type(
MessageFlags,
MessageFlagsDescriptions,
'Message flags bitfield',
'MessageFlags',
).optional(),
attachments: z
.array(ClientAttachmentReferenceRequest)
.optional()
.describe('Array of attachment objects to keep or add'),
});
export type MessageUpdateRequestSchemaType = z.infer<typeof MessageUpdateRequestSchema>;
export const MessagesQuery = z.object({
limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 100}).describe(
'Number of messages to return (1-100, default 50)',
),
before: SnowflakeType.optional().describe('Get messages before this message ID'),
after: SnowflakeType.optional().describe('Get messages after this message ID'),
around: SnowflakeType.optional().describe('Get messages around this message ID'),
});
export type MessagesQuery = z.infer<typeof MessagesQuery>;
export const BulkDeleteMessagesRequest = z.object({
message_ids: z.array(SnowflakeType).describe('Array of message IDs to delete'),
});
export type BulkDeleteMessagesRequest = z.infer<typeof BulkDeleteMessagesRequest>;
export const MessageAckRequest = z.object({
mention_count: Int32Type.optional().describe('Number of mentions to acknowledge'),
manual: z.boolean().optional().describe('Whether this is a manual acknowledgement'),
});
export type MessageAckRequest = z.infer<typeof MessageAckRequest>;
export const ChannelPinsQuerySchema = z.object({
limit: z.coerce
.number()
.int()
.min(1)
.max(50)
.optional()
.describe('Maximum number of pinned messages to return (1-50)'),
before: z.coerce.date().optional().describe('Get pinned messages before this timestamp'),
});
export type ChannelPinsQuerySchema = z.infer<typeof ChannelPinsQuerySchema>;
export const ReactionUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional().describe('Maximum number of users to return (1-100)'),
after: SnowflakeType.optional().describe('Get users after this user ID'),
});
export type ReactionUsersQuerySchema = z.infer<typeof ReactionUsersQuerySchema>;

View File

@@ -0,0 +1,343 @@
/*
* 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 {
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
MessageFlags,
MessageFlagsDescriptions,
} from '@fluxer/constants/src/ChannelConstants';
import {MAX_REACTIONS_PER_MESSAGE} from '@fluxer/constants/src/LimitConstants';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {type MessageEmbed, MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {MessageReferenceTypeSchema, MessageTypeSchema} from '@fluxer/schema/src/primitives/MessageValidators';
import {createBitflagInt32Type, Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const MessageAttachmentResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for this attachment'),
filename: z.string().describe('The name of the attached file'),
title: z.string().nullish().describe('The title of the attachment'),
description: z.string().nullish().describe('The description of the attachment'),
content_type: z.string().nullish().describe('The MIME type of the attachment'),
content_hash: z.string().nullish().describe('The hash of the attachment content'),
size: Int32Type.describe('The size of the attachment in bytes'),
url: z.string().nullish().describe('The URL of the attachment'),
proxy_url: z.string().nullish().describe('The proxied URL of the attachment'),
width: Int32Type.nullish().describe('The width of the attachment in pixels (for images/videos)'),
height: Int32Type.nullish().describe('The height of the attachment in pixels (for images/videos)'),
placeholder: z.string().nullish().describe('The base64 encoded placeholder image for lazy loading'),
flags: createBitflagInt32Type(
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
'The bitwise flags for this attachment',
'MessageAttachmentFlags',
),
nsfw: z.boolean().nullish().describe('Whether the attachment is flagged as NSFW'),
duration: Int32Type.nullish().describe('The duration of the media in seconds'),
waveform: z.string().nullish().describe('The base64 encoded audio waveform data'),
expires_at: z.string().nullish().describe('The ISO 8601 timestamp when the attachment URL expires'),
expired: z.boolean().nullish().describe('Whether the attachment URL has expired'),
});
export type MessageAttachmentResponse = z.infer<typeof MessageAttachmentResponse>;
export const MessageReferenceResponse = z.object({
channel_id: SnowflakeStringType.describe('The ID of the channel containing the referenced message'),
message_id: SnowflakeStringType.describe('The ID of the referenced message'),
guild_id: SnowflakeStringType.nullish().describe('The ID of the guild containing the referenced message'),
type: MessageReferenceTypeSchema,
});
export type MessageReferenceResponse = z.infer<typeof MessageReferenceResponse>;
export const ReactionEmojiResponse = z.object({
id: SnowflakeStringType.nullish().describe('The ID of the custom emoji (null for Unicode emojis)'),
name: z.string().describe('The name of the emoji (or Unicode character for standard emojis)'),
animated: z.boolean().nullish().describe('Whether the emoji is animated'),
});
export type ReactionEmojiResponse = z.infer<typeof ReactionEmojiResponse>;
export const MessageReactionResponse = z.object({
emoji: ReactionEmojiResponse.describe('The emoji used for the reaction'),
count: Int32Type.describe('The total number of times this reaction has been used'),
me: z.literal(true).nullish().describe('Whether the current user has reacted with this emoji'),
});
export type MessageReactionResponse = z.infer<typeof MessageReactionResponse>;
export const MessageStickerResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier of the sticker'),
name: z.string().describe('The name of the sticker'),
animated: z.boolean().describe('Whether the sticker is animated'),
});
export type MessageStickerResponse = z.infer<typeof MessageStickerResponse>;
export const MessageSnapshotResponse = z.object({
content: z.string().nullish().describe('The text content of the snapshot'),
timestamp: z.iso.datetime().describe('The ISO 8601 timestamp of when the original message was created'),
edited_timestamp: z.iso
.datetime()
.nullish()
.describe('The ISO 8601 timestamp of when the original message was last edited'),
mentions: z.array(z.string()).max(100).nullish().describe('The user IDs mentioned in the snapshot'),
mention_roles: z.array(z.string()).max(100).nullish().describe('The role IDs mentioned in the snapshot'),
embeds: z.array(MessageEmbedResponse).max(10).nullish().describe('The embeds included in the snapshot'),
attachments: z
.array(MessageAttachmentResponse)
.max(10)
.nullish()
.describe('The attachments included in the snapshot'),
stickers: z.array(MessageStickerResponse).max(3).nullish().describe('The stickers included in the snapshot'),
type: MessageTypeSchema,
flags: createBitflagInt32Type(
MessageFlags,
MessageFlagsDescriptions,
'The bitwise flags of the original message',
'MessageFlags',
),
});
export type MessageSnapshotResponse = z.infer<typeof MessageSnapshotResponse>;
export const MessageCallResponse = z.object({
participants: z.array(z.string()).max(100).describe('The user IDs of participants in the call'),
ended_timestamp: z.iso.datetime().nullish().describe('The ISO 8601 timestamp of when the call ended'),
});
export type MessageCallResponse = z.infer<typeof MessageCallResponse>;
export const MessageBaseResponseSchema = z.object({
id: SnowflakeStringType.describe('The unique identifier (snowflake) for this message'),
channel_id: SnowflakeStringType.describe('The ID of the channel this message was sent in'),
author: z.lazy(() => UserPartialResponse).describe('The author of the message'),
webhook_id: SnowflakeStringType.nullish().describe('The ID of the webhook that sent this message'),
type: MessageTypeSchema,
flags: createBitflagInt32Type(
MessageFlags,
MessageFlagsDescriptions,
'The bitwise flags for this message',
'MessageFlags',
),
content: z.string().describe('The text content of the message'),
timestamp: z.iso.datetime().describe('The ISO 8601 timestamp of when the message was created'),
edited_timestamp: z.iso.datetime().nullish().describe('The ISO 8601 timestamp of when the message was last edited'),
pinned: z.boolean().describe('Whether the message is pinned'),
mention_everyone: z.boolean().describe('Whether the message mentions @everyone'),
tts: z.boolean().optional().describe('Whether the message was sent as text-to-speech'),
mentions: z
.array(z.lazy(() => UserPartialResponse))
.max(100)
.nullish()
.describe('The users mentioned in the message'),
mention_roles: z.array(z.string()).max(100).nullish().describe('The role IDs mentioned in the message'),
embeds: z.array(MessageEmbedResponse).max(10).nullish().describe('The embeds attached to the message'),
attachments: z.array(MessageAttachmentResponse).max(10).nullish().describe('The files attached to the message'),
stickers: z.array(MessageStickerResponse).max(3).nullish().describe('The stickers sent with the message'),
reactions: z
.array(MessageReactionResponse)
.max(MAX_REACTIONS_PER_MESSAGE)
.nullish()
.describe('The reactions on the message'),
message_reference: MessageReferenceResponse.nullish().describe('Reference data for replies or forwards'),
message_snapshots: z.array(MessageSnapshotResponse).max(10).nullish().describe('Snapshots of forwarded messages'),
nonce: z.string().nullish().describe('A client-provided value for message deduplication'),
call: MessageCallResponse.nullish().describe('Call information if this message represents a call'),
});
export type MessageBaseResponse = z.infer<typeof MessageBaseResponseSchema>;
export interface MessageResponse extends MessageBaseResponse {
referenced_message?: MessageResponse | null;
}
export const MessageResponseSchema = MessageBaseResponseSchema.extend({
referenced_message: MessageBaseResponseSchema.nullish().describe(
'The message that this message is replying to or forwarding',
),
});
export const ChannelPinMessageResponse = MessageResponseSchema.omit({
referenced_message: true,
reactions: true,
}).describe('The pinned message');
export type ChannelPinMessageResponse = z.infer<typeof ChannelPinMessageResponse>;
export const ChannelPinResponse = z.object({
message: ChannelPinMessageResponse,
pinned_at: z.iso.datetime().describe('The ISO 8601 timestamp of when the message was pinned'),
});
export type ChannelPinResponse = z.infer<typeof ChannelPinResponse>;
export const ChannelPinsResponse = z.object({
items: z.array(ChannelPinResponse).describe('Pinned messages in this channel'),
has_more: z.boolean().describe('Whether more pins can be fetched with pagination'),
});
export type ChannelPinsResponse = z.infer<typeof ChannelPinsResponse>;
export const ReactionUsersListResponse = z.array(z.lazy(() => UserPartialResponse));
export type ReactionUsersListResponse = z.infer<typeof ReactionUsersListResponse>;
export const MessageSearchResultsResponse = z.object({
messages: z
.array(MessageResponseSchema.omit({referenced_message: true}))
.max(100)
.describe('The messages matching the search query'),
total: Int32Type.describe('The total number of messages matching the search'),
hits_per_page: Int32Type.describe('The maximum number of messages returned per page'),
page: Int32Type.describe('The current page number'),
});
export type MessageSearchResultsResponse = z.infer<typeof MessageSearchResultsResponse>;
export const MessageSearchIndexingResponse = z.object({
indexing: z.literal(true).describe('Indicates that one or more channels are being indexed'),
});
export type MessageSearchIndexingResponse = z.infer<typeof MessageSearchIndexingResponse>;
export const MessageSearchResponse = z.union([MessageSearchResultsResponse, MessageSearchIndexingResponse]);
export type MessageSearchResponse = z.infer<typeof MessageSearchResponse>;
export const MessageListResponse = z.array(MessageResponseSchema);
export type MessageListResponse = z.infer<typeof MessageListResponse>;
export interface MessageReference {
readonly message_id: string;
readonly channel_id: string;
readonly guild_id?: string;
readonly type?: number;
}
export interface ReactionEmoji {
readonly id?: string | null;
readonly name: string;
readonly animated?: boolean;
readonly url?: string | null;
}
export interface MessageReaction {
readonly emoji: ReactionEmoji;
readonly count: number;
readonly me?: true;
readonly me_burst?: boolean;
readonly count_details?: {
readonly burst: number;
readonly normal: number;
};
}
export interface MessageAttachment {
readonly id: string;
readonly filename: string;
readonly title?: string;
readonly description?: string;
readonly content_type?: string;
readonly size: number;
readonly url: string | null;
readonly proxy_url: string | null;
readonly width?: number;
readonly height?: number;
readonly placeholder?: string;
readonly flags: number;
readonly duration?: number;
readonly waveform?: string;
readonly content_hash?: string | null;
readonly nsfw?: boolean;
readonly expires_at?: string | null;
readonly expired?: boolean;
}
export interface MessageCall {
readonly participants: ReadonlyArray<string>;
readonly ended_timestamp?: string | null;
}
export interface MessageSnapshot {
readonly type: number;
readonly content: string;
readonly embeds?: ReadonlyArray<MessageEmbed>;
readonly attachments?: ReadonlyArray<MessageAttachment>;
readonly timestamp: string;
}
export interface MessageStickerItem {
readonly id: string;
readonly name: string;
readonly animated: boolean;
}
export interface AllowedMentions {
readonly parse?: ReadonlyArray<'roles' | 'users' | 'everyone'>;
readonly roles?: ReadonlyArray<string>;
readonly users?: ReadonlyArray<string>;
readonly replied_user?: boolean;
}
export interface ChannelMention {
readonly id: string;
readonly guild_id: string;
readonly type: number;
readonly name: string;
readonly parent_id?: string | null;
}
export interface MessageMention extends UserPartial {
readonly member?: Omit<GuildMemberData, 'user'>;
}
export interface Message {
readonly id: string;
readonly channel_id: string;
readonly guild_id?: string;
readonly author: UserPartial;
readonly member?: Omit<GuildMemberData, 'user'>;
readonly webhook_id?: string;
readonly type: number;
readonly flags: number;
readonly pinned: boolean;
readonly tts?: boolean;
readonly mention_everyone: boolean;
readonly content: string;
readonly timestamp: string;
readonly edited_timestamp?: string;
readonly mentions?: ReadonlyArray<MessageMention>;
readonly mention_roles?: ReadonlyArray<string>;
readonly mention_channels?: ReadonlyArray<ChannelMention>;
readonly embeds?: ReadonlyArray<MessageEmbed>;
readonly attachments?: ReadonlyArray<MessageAttachment>;
readonly stickers?: ReadonlyArray<MessageStickerItem>;
readonly reactions?: ReadonlyArray<MessageReaction>;
readonly message_reference?: MessageReference;
readonly referenced_message?: Message | null;
readonly message_snapshots?: ReadonlyArray<MessageSnapshot>;
readonly call?: MessageCall | null;
readonly state?: string;
readonly nonce?: string;
readonly blocked?: boolean;
readonly loggingName?: string;
readonly _allowedMentions?: AllowedMentions;
readonly _favoriteMemeId?: string;
}

View File

@@ -0,0 +1,125 @@
/*
* 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 {MessageFlags, MessageFlagsDescriptions} from '@fluxer/constants/src/ChannelConstants';
import {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {
MessageAttachmentResponse,
MessageStickerResponse,
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {
AllowedMentionParseTypeSchema,
MessageReferenceTypeSchema,
} from '@fluxer/schema/src/primitives/MessageValidators';
import {
createBitflagInt32Type,
createNamedStringLiteralUnion,
SnowflakeStringType,
withFieldDescription,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ScheduledMessageAllowedMentionsSchema = z.object({
parse: z.array(AllowedMentionParseTypeSchema).optional().describe('Types of mentions to parse from content'),
users: z.array(SnowflakeStringType).optional().describe('Array of user IDs to mention'),
roles: z.array(SnowflakeStringType).optional().describe('Array of role IDs to mention'),
replied_user: z.boolean().optional().describe('Whether to mention the author of the replied message'),
});
export type ScheduledMessageAllowedMentionsSchema = z.infer<typeof ScheduledMessageAllowedMentionsSchema>;
export const ScheduledMessageReferenceSchema = z.object({
message_id: SnowflakeStringType.describe('ID of the message being referenced'),
channel_id: SnowflakeStringType.optional().describe('ID of the channel containing the referenced message'),
guild_id: SnowflakeStringType.optional().describe('ID of the guild containing the referenced message'),
type: withFieldDescription(MessageReferenceTypeSchema, 'The type of message reference').optional(),
});
export type ScheduledMessageReferenceSchema = z.infer<typeof ScheduledMessageReferenceSchema>;
export const ScheduledMessagePayloadResponseSchema = z.object({
content: z.string().nullish().describe('The text content of the scheduled message'),
tts: z.boolean().optional().describe('Whether this is a text-to-speech message'),
embeds: z.array(MessageEmbedResponse).max(10).optional().describe('Array of embed objects attached to the message'),
attachments: z
.array(MessageAttachmentResponse)
.max(10)
.optional()
.describe('Array of attachment objects for the message'),
stickers: z
.array(MessageStickerResponse)
.max(3)
.optional()
.describe('Array of sticker objects attached to the message'),
sticker_ids: z
.array(SnowflakeStringType)
.max(3)
.optional()
.describe('Array of sticker IDs to include in the message'),
allowed_mentions: ScheduledMessageAllowedMentionsSchema.optional().describe(
'Controls which mentions trigger notifications',
),
message_reference: ScheduledMessageReferenceSchema.optional().describe(
'Reference to another message (for replies or forwards)',
),
flags: createBitflagInt32Type(MessageFlags, MessageFlagsDescriptions, 'Message flags', 'MessageFlags').optional(),
nonce: z.string().optional().describe('Client-generated identifier for the message'),
favorite_meme_id: SnowflakeStringType.optional().describe('ID of a favorite meme to attach'),
});
export type ScheduledMessagePayloadResponseSchema = z.infer<typeof ScheduledMessagePayloadResponseSchema>;
export const ScheduledMessageStatus = withOpenApiType(
createNamedStringLiteralUnion(
[
['pending', 'pending', 'The message is pending validation and has not yet been scheduled'],
['invalid', 'invalid', 'The message failed validation and cannot be sent'],
['scheduled', 'scheduled', 'The message has been validated and is scheduled for delivery'],
['sent', 'sent', 'The message has been successfully sent'],
['failed', 'failed', 'The message failed to send after being scheduled'],
['cancelled', 'cancelled', 'The scheduled message was cancelled by the user'],
],
'The current status of the scheduled message',
),
'ScheduledMessageStatus',
);
export type ScheduledMessageStatus = z.infer<typeof ScheduledMessageStatus>;
export const ScheduledMessageResponseSchema = z.object({
id: SnowflakeStringType.describe('The unique identifier for this scheduled message'),
channel_id: SnowflakeStringType.describe('The ID of the channel this message will be sent to'),
scheduled_at: z.string().describe('The ISO 8601 UTC timestamp when the message is scheduled to be sent'),
scheduled_local_at: z.string().describe('The ISO 8601 timestamp in the user local timezone'),
timezone: z.string().describe('The IANA timezone identifier used for scheduling'),
status: ScheduledMessageStatus.describe('The current status of the scheduled message'),
status_reason: z.string().nullable().describe('A human-readable reason for the current status, if applicable'),
payload: ScheduledMessagePayloadResponseSchema.describe('The message content and metadata to be sent'),
created_at: z.string().describe('The ISO 8601 timestamp when this scheduled message was created'),
invalidated_at: z.string().nullable().describe('The ISO 8601 timestamp when the message was marked invalid'),
});
export type ScheduledMessageResponseSchema = z.infer<typeof ScheduledMessageResponseSchema>;
export const ScheduledMessageListResponse = z
.array(ScheduledMessageResponseSchema)
.max(100)
.describe('A list of scheduled messages');
export type ScheduledMessageListResponse = z.infer<typeof ScheduledMessageListResponse>;

View File

@@ -0,0 +1,62 @@
/*
* 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 {AllowedMentionParseTypes, MessageReferenceTypes} from '@fluxer/constants/src/ChannelConstants';
import {
AllowedMentionParseTypeSchema,
MessageReferenceTypeSchema,
} from '@fluxer/schema/src/primitives/MessageValidators';
import {SnowflakeType, withFieldDescription} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ALLOWED_MENTIONS_PARSE = [
AllowedMentionParseTypes.USERS,
AllowedMentionParseTypes.ROLES,
AllowedMentionParseTypes.EVERYONE,
] as const;
export const AllowedMentionsRequest = z.object({
parse: z.array(AllowedMentionParseTypeSchema).optional().describe('Types of mentions to parse from content'),
users: z.array(SnowflakeType).max(100).optional().describe('Array of user IDs to mention (max 100)'),
roles: z.array(SnowflakeType).max(100).optional().describe('Array of role IDs to mention (max 100)'),
replied_user: z.boolean().optional().describe('Whether to mention the author of the replied message'),
});
export type AllowedMentionsRequest = z.infer<typeof AllowedMentionsRequest>;
export const MessageReferenceRequest = z
.object({
message_id: SnowflakeType.describe('ID of the message being referenced'),
channel_id: SnowflakeType.optional().describe('ID of the channel containing the referenced message'),
guild_id: SnowflakeType.optional().describe('ID of the guild containing the referenced message'),
type: withFieldDescription(MessageReferenceTypeSchema, 'Type of reference (0 = default, 1 = forward)').optional(),
})
.refine(
(data) => {
if (data.type === MessageReferenceTypes.FORWARD) {
return data.channel_id !== undefined && data.message_id !== undefined;
}
return true;
},
{
message: 'Forward message reference must include channel_id and message_id',
},
);
export type MessageReferenceRequest = z.infer<typeof MessageReferenceRequest>;

View File

@@ -0,0 +1,424 @@
/*
* 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 {ApplicationFlags, BotFlags, BotFlagsDescriptions} from '@fluxer/constants/src/BotConstants';
import {AVATAR_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
import {
PublicUserFlags,
PublicUserFlagsDescriptions,
UserAuthenticatorTypes,
UserAuthenticatorTypesDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {
createBitflagInt32Type,
createInt32EnumType,
createNamedStringLiteralUnion,
createStringType,
Int32Type,
SnowflakeStringType,
SnowflakeType,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {DiscriminatorType, UsernameType} from '@fluxer/schema/src/primitives/UserValidators';
import {z} from 'zod';
const RedirectURIString = createStringType(1).refine((value) => {
try {
const u = new URL(value);
return !!u.protocol && !!u.host;
} catch {
return false;
}
}, 'Invalid URL format');
export const OAuthScopes = ['identify', 'email', 'guilds', 'bot'] as const;
export type OAuthScope = (typeof OAuthScopes)[number];
const AuthenticatorTypeEnum = withOpenApiType(
createInt32EnumType(
[
[UserAuthenticatorTypes.TOTP, 'TOTP', UserAuthenticatorTypesDescriptions.TOTP],
[UserAuthenticatorTypes.SMS, 'SMS', UserAuthenticatorTypesDescriptions.SMS],
[UserAuthenticatorTypes.WEBAUTHN, 'WEBAUTHN', UserAuthenticatorTypesDescriptions.WEBAUTHN],
],
'The type of authenticator',
'AuthenticatorType',
),
'AuthenticatorType',
);
const PromptType = withOpenApiType(
createNamedStringLiteralUnion(
[
['consent', 'CONSENT', 'Always prompt the user for consent'],
['none', 'NONE', 'Do not prompt the user for consent if already authorized'],
] as const,
'Whether to prompt the user for consent',
),
'PromptType',
);
const DisableGuildSelectType = withOpenApiType(
createNamedStringLiteralUnion(
[
['true', 'TRUE', 'Disable guild selection'],
['false', 'FALSE', 'Allow guild selection'],
] as const,
'Whether to disable guild selection',
),
'DisableGuildSelectType',
);
export const AuthorizeRequest = z.object({
response_type: z.literal('code').optional().describe('The OAuth2 response type, must be "code"'),
client_id: SnowflakeType.describe('The application client ID'),
redirect_uri: RedirectURIString.optional().describe('The URI to redirect to after authorization'),
scope: createStringType(1).describe('The space-separated list of requested scopes'),
state: createStringType(1).optional().describe('A random string for CSRF protection'),
prompt: PromptType.optional(),
guild_id: SnowflakeType.optional().describe('The guild ID to pre-select for bot authorization'),
permissions: z.string().optional().describe('The bot permissions to request'),
disable_guild_select: DisableGuildSelectType.optional(),
});
export type AuthorizeRequest = z.infer<typeof AuthorizeRequest>;
export const AuthorizeConsentRequest = z.object({
response_type: z.string().optional().describe('The OAuth2 response type'),
client_id: SnowflakeType.describe('The application client ID'),
redirect_uri: RedirectURIString.optional().describe('The URI to redirect to after authorization'),
scope: createStringType(1).describe('The space-separated list of requested scopes'),
state: createStringType(1).optional().describe('A random string for CSRF protection'),
permissions: z.string().optional().describe('The bot permissions to request'),
guild_id: SnowflakeType.optional().describe('The guild ID to add the bot to'),
});
export type AuthorizeConsentRequest = z.infer<typeof AuthorizeConsentRequest>;
export const TokenRequest = z.discriminatedUnion('grant_type', [
z.object({
grant_type: z.literal('authorization_code').describe('The grant type for exchanging an authorization code'),
code: createStringType(1).describe('The authorization code received from the authorize endpoint'),
redirect_uri: RedirectURIString.describe('The redirect URI used in the authorization request'),
client_id: SnowflakeType.optional().describe('The application client ID'),
client_secret: createStringType(1).optional().describe('The application client secret'),
}),
z.object({
grant_type: z.literal('refresh_token').describe('The grant type for refreshing an access token'),
refresh_token: createStringType(1).describe('The refresh token to exchange for a new access token'),
client_id: SnowflakeType.optional().describe('The application client ID'),
client_secret: createStringType(1).optional().describe('The application client secret'),
}),
]);
export type TokenRequest = z.infer<typeof TokenRequest>;
export const IntrospectRequestForm = z.object({
token: createStringType(1).describe('The token to introspect'),
client_id: SnowflakeType.optional().describe('The application client ID'),
client_secret: createStringType(1).optional().describe('The application client secret'),
});
export type IntrospectRequestForm = z.infer<typeof IntrospectRequestForm>;
const TokenTypeHint = withOpenApiType(
createNamedStringLiteralUnion(
[
['access_token', 'ACCESS_TOKEN', 'An OAuth2 access token'],
['refresh_token', 'REFRESH_TOKEN', 'An OAuth2 refresh token'],
] as const,
'A hint about the type of token being revoked',
),
'TokenTypeHint',
);
export const RevokeRequestForm = z.object({
token: createStringType(1).describe('The token to revoke'),
token_type_hint: TokenTypeHint.optional(),
client_id: SnowflakeType.optional().describe('The application client ID'),
client_secret: createStringType(1).optional().describe('The application client secret'),
});
export type RevokeRequestForm = z.infer<typeof RevokeRequestForm>;
export const ApplicationBotResponse = z
.object({
id: SnowflakeStringType.describe('The unique identifier of the bot user'),
username: z.string().describe('The username of the bot'),
discriminator: z.string().describe('The discriminator of the bot'),
avatar: z.string().nullable().optional().describe('The avatar hash of the bot'),
banner: z.string().nullable().optional().describe('The banner hash of the bot'),
bio: z.string().nullable().describe('The bio or description of the bot'),
token: z.string().optional().describe('The bot token for authentication'),
mfa_enabled: z.boolean().optional().describe('Whether the bot has MFA enabled'),
authenticator_types: z
.array(AuthenticatorTypeEnum)
.max(10)
.optional()
.describe('The types of authenticators enabled'),
flags: createBitflagInt32Type(BotFlags, BotFlagsDescriptions, 'The bot user flags', 'BotFlags'),
})
.describe('Detailed bot user metadata');
export type ApplicationBotResponse = z.infer<typeof ApplicationBotResponse>;
export const ApplicationResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier of the application'),
name: z.string().describe('The name of the application'),
redirect_uris: z.array(z.string()).max(20).describe('The registered redirect URIs for OAuth2'),
bot_public: z.boolean().describe('Whether the bot can be invited by anyone'),
bot_require_code_grant: z.boolean().describe('Whether the bot requires OAuth2 code grant'),
client_secret: z.string().optional().describe('The client secret for OAuth2 authentication'),
bot: ApplicationBotResponse.optional().describe('The bot user associated with the application'),
});
export type ApplicationResponse = z.infer<typeof ApplicationResponse>;
export const ApplicationListResponse = z.array(ApplicationResponse);
export type ApplicationListResponse = z.infer<typeof ApplicationListResponse>;
export const BotTokenResetResponse = z.object({
token: z.string().describe('The new bot token'),
bot: ApplicationBotResponse,
});
export type BotTokenResetResponse = z.infer<typeof BotTokenResetResponse>;
export const BotProfileResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier of the bot user'),
username: z.string().describe('The username of the bot'),
discriminator: z.string().describe('The discriminator of the bot'),
avatar: z.string().nullable().describe('The avatar hash of the bot'),
banner: z.string().nullable().describe('The banner hash of the bot'),
bio: z.string().nullable().describe('The bio or description of the bot'),
});
export type BotProfileResponse = z.infer<typeof BotProfileResponse>;
export const OAuth2TokenResponse = z.object({
access_token: z.string().describe('The access token for API authorization'),
token_type: z.string().describe('The type of token, typically "Bearer"'),
expires_in: Int32Type.describe('The number of seconds until the access token expires'),
refresh_token: z.string().describe('The refresh token for obtaining new access tokens'),
scope: z.string().describe('The space-separated list of granted scopes'),
});
export type OAuth2TokenResponse = z.infer<typeof OAuth2TokenResponse>;
export const OAuth2UserInfoResponse = z.object({
sub: SnowflakeStringType.describe('The subject identifier of the user'),
id: SnowflakeStringType.describe('The unique identifier of the user'),
username: z.string().describe('The username of the user'),
discriminator: z.string().describe('The discriminator of the user'),
global_name: z.string().nullable().describe('The global display name of the user'),
avatar: z.string().nullable().describe('The avatar hash of the user'),
email: z.string().nullable().optional().describe('The email address of the user'),
verified: z.boolean().nullable().optional().describe('Whether the user has verified their email'),
flags: createBitflagInt32Type(
PublicUserFlags,
PublicUserFlagsDescriptions,
'The user flags',
'PublicUserFlags',
).optional(),
});
export type OAuth2UserInfoResponse = z.infer<typeof OAuth2UserInfoResponse>;
export const OAuth2IntrospectResponse = z.object({
active: z.boolean().describe('Whether the token is currently active'),
scope: z.string().optional().describe('The space-separated list of scopes'),
client_id: SnowflakeStringType.optional().describe('The client identifier for the token'),
username: z.string().optional().describe('The username of the token owner'),
token_type: z.string().optional().describe('The type of token'),
exp: Int32Type.optional().describe('The expiration timestamp in seconds'),
iat: Int32Type.optional().describe('The issued-at timestamp in seconds'),
sub: SnowflakeStringType.optional().describe('The subject identifier (user ID)'),
});
export type OAuth2IntrospectResponse = z.infer<typeof OAuth2IntrospectResponse>;
export const OAuth2ConsentResponse = z.object({
redirect_to: z.string().describe('The URL to redirect the user to after consent'),
});
export type OAuth2ConsentResponse = z.infer<typeof OAuth2ConsentResponse>;
export const OAuth2MeResponse = z.object({
application: z
.object({
id: SnowflakeStringType.describe('The unique identifier of the application'),
name: z.string().describe('The name of the application'),
icon: z.null().describe('The icon hash of the application'),
description: z.null().describe('The description of the application'),
bot_public: z.boolean().describe('Whether the bot can be invited by anyone'),
bot_require_code_grant: z.boolean().describe('Whether the bot requires OAuth2 code grant'),
flags: createBitflagInt32Type(ApplicationFlags, 'The application flags', undefined, 'ApplicationFlags'),
})
.describe('The application associated with the token'),
scopes: z.array(z.string()).max(50).describe('The list of granted OAuth2 scopes'),
expires: z.string().describe('The expiration timestamp of the token'),
user: z
.object({
id: SnowflakeStringType.describe('The unique identifier of the user'),
username: z.string().describe('The username of the user'),
discriminator: z.string().describe('The discriminator of the user'),
global_name: z.string().nullable().describe('The global display name of the user'),
avatar: z.string().nullable().describe('The avatar hash of the user'),
avatar_color: Int32Type.nullable().describe('The default avatar color of the user'),
bot: z.boolean().optional().describe('Whether the user is a bot'),
system: z.boolean().optional().describe('Whether the user is a system user'),
flags: createBitflagInt32Type(PublicUserFlags, PublicUserFlagsDescriptions, 'The user flags', 'PublicUserFlags'),
email: z.string().nullable().optional().describe('The email address of the user'),
verified: z.boolean().nullable().optional().describe('Whether the user has verified their email'),
})
.optional()
.describe('The user associated with the token'),
});
export type OAuth2MeResponse = z.infer<typeof OAuth2MeResponse>;
export const ApplicationPublicResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier of the application'),
name: z.string().describe('The name of the application'),
icon: z.string().nullable().describe('The icon hash of the application'),
description: z.null().describe('The description of the application'),
redirect_uris: z.array(z.string()).max(20).describe('The registered redirect URIs for OAuth2'),
scopes: z.array(z.string()).max(50).describe('The available OAuth2 scopes'),
bot_public: z.boolean().describe('Whether the bot can be invited by anyone'),
bot: ApplicationBotResponse.nullable().describe('The bot user associated with the application'),
});
export type ApplicationPublicResponse = z.infer<typeof ApplicationPublicResponse>;
export const ApplicationsMeResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier of the application'),
name: z.string().describe('The name of the application'),
icon: z.null().describe('The icon hash of the application'),
description: z.null().describe('The description of the application'),
bot_public: z.boolean().describe('Whether the bot can be invited by anyone'),
bot_require_code_grant: z.boolean().describe('Whether the bot requires OAuth2 code grant'),
flags: createBitflagInt32Type(ApplicationFlags, 'The application flags', undefined, 'ApplicationFlags'),
bot: ApplicationBotResponse.optional().describe('The bot user associated with the application'),
});
export type ApplicationsMeResponse = z.infer<typeof ApplicationsMeResponse>;
export const OAuth2AuthorizationResponse = z.object({
application: z
.object({
id: SnowflakeStringType.describe('The unique identifier of the application'),
name: z.string().describe('The name of the application'),
icon: z.string().nullable().describe('The icon hash of the application'),
description: z.null().describe('The description of the application'),
bot_public: z.boolean().describe('Whether the bot can be invited by anyone'),
})
.describe('The application that was authorized'),
scopes: z.array(z.string()).max(50).describe('The list of granted OAuth2 scopes'),
authorized_at: z.string().describe('The timestamp when the authorization was granted'),
});
export type OAuth2AuthorizationResponse = z.infer<typeof OAuth2AuthorizationResponse>;
export const OAuth2AuthorizationsListResponse = z.array(OAuth2AuthorizationResponse);
export type OAuth2AuthorizationsListResponse = z.infer<typeof OAuth2AuthorizationsListResponse>;
function isLoopbackHost(hostname: string) {
const lowercaseHost = hostname.toLowerCase();
return (
lowercaseHost === 'localhost' ||
lowercaseHost === '127.0.0.1' ||
lowercaseHost === '[::1]' ||
lowercaseHost.endsWith('.localhost')
);
}
function isValidRedirectURI(value: string, allowAnyHttp: boolean) {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
if (!allowAnyHttp && url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
return false;
}
return !!url.host;
} catch {
return false;
}
}
const createRedirectURIType = (allowAnyHttp: boolean, message: string) =>
createStringType(1).refine((value) => isValidRedirectURI(value, allowAnyHttp), message);
export const OAuth2RedirectURICreateType = createRedirectURIType(
false,
'Redirect URIs must use HTTPS, or HTTP for localhost only',
);
export const OAuth2RedirectURIUpdateType = createRedirectURIType(true, 'Redirect URIs must use HTTP or HTTPS');
export const ApplicationCreateRequest = z.object({
name: createStringType(1, 100).describe('The name of the application'),
redirect_uris: z
.array(OAuth2RedirectURICreateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.optional()
.nullable()
.transform((value) => value ?? [])
.describe('The redirect URIs for OAuth2 flows'),
bot_public: z.boolean().optional().describe('Whether the bot can be invited by anyone'),
bot_require_code_grant: z.boolean().optional().describe('Whether the bot requires OAuth2 code grant'),
});
export type ApplicationCreateRequest = z.infer<typeof ApplicationCreateRequest>;
export const ApplicationUpdateRequest = z.object({
name: createStringType(1, 100).optional().describe('The name of the application'),
redirect_uris: z
.array(OAuth2RedirectURIUpdateType)
.max(10, 'Maximum of 10 redirect URIs allowed')
.optional()
.nullable()
.transform((value) => (value === undefined ? undefined : (value ?? [])))
.describe('The redirect URIs for OAuth2 flows'),
bot_public: z.boolean().optional().describe('Whether the bot can be invited by anyone'),
bot_require_code_grant: z.boolean().optional().describe('Whether the bot requires OAuth2 code grant'),
});
export type ApplicationUpdateRequest = z.infer<typeof ApplicationUpdateRequest>;
export const BotProfileUpdateRequest = z.object({
username: UsernameType.optional().describe('The username of the bot'),
discriminator: DiscriminatorType.optional().describe('The discriminator of the bot'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('The avatar image as base64'),
banner: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('The banner image as base64'),
bio: createStringType(0, 1024).nullish().describe('The bio or description of the bot'),
bot_flags: createBitflagInt32Type(BotFlags, BotFlagsDescriptions, 'The bot user flags', 'BotFlags').optional(),
});
export type BotProfileUpdateRequest = z.infer<typeof BotProfileUpdateRequest>;

View File

@@ -0,0 +1,93 @@
/*
* 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 {createStringType, Int32Type, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const FEDERATION_SCOPES = {
identify: 'Read your user profile',
guilds: 'Access guilds you are a member of',
'guilds.join': 'Join guilds on your behalf',
'messages.read': 'Read messages in guilds',
'messages.write': 'Send messages in guilds',
voice: 'Connect to voice channels',
} as const;
export type FederationScopeKey = keyof typeof FEDERATION_SCOPES;
export const FederationScope = z.enum([
'identify',
'guilds',
'guilds.join',
'messages.read',
'messages.write',
'voice',
]);
export type FederationScope = z.infer<typeof FederationScope>;
const RedirectURIString = createStringType(1).refine((value) => {
try {
const u = new URL(value);
return !!u.protocol && !!u.host;
} catch {
return false;
}
}, 'Invalid URL format');
export const FederationOAuth2AuthorizeRequest = z.object({
response_type: z.literal('code').describe('The OAuth2 response type, must be "code"'),
client_id: SnowflakeType.describe('The application client ID'),
redirect_uri: RedirectURIString.describe('The URI to redirect to after authorization'),
scope: createStringType(1).describe('The space-separated list of requested scopes'),
state: createStringType(1).optional().describe('A random string for CSRF protection'),
code_challenge: createStringType(1).optional().describe('The PKCE code challenge'),
code_challenge_method: z.literal('S256').optional().describe('The PKCE code challenge method'),
});
export type FederationOAuth2AuthorizeRequest = z.infer<typeof FederationOAuth2AuthorizeRequest>;
export const FederationOAuth2TokenRequest = z.discriminatedUnion('grant_type', [
z.object({
grant_type: z.literal('authorization_code').describe('The grant type for exchanging an authorization code'),
code: createStringType(1).describe('The authorization code received from the authorize endpoint'),
redirect_uri: RedirectURIString.describe('The redirect URI used in the authorization request'),
client_id: SnowflakeType.describe('The application client ID'),
client_secret: createStringType(1).optional().describe('The application client secret'),
code_verifier: createStringType(1).optional().describe('The PKCE code verifier'),
}),
z.object({
grant_type: z.literal('refresh_token').describe('The grant type for refreshing an access token'),
refresh_token: createStringType(1).describe('The refresh token to exchange for a new access token'),
client_id: SnowflakeType.describe('The application client ID'),
client_secret: createStringType(1).optional().describe('The application client secret'),
}),
]);
export type FederationOAuth2TokenRequest = z.infer<typeof FederationOAuth2TokenRequest>;
export const FederationOAuth2TokenResponse = z.object({
access_token: z.string().describe('The access token for API authorization'),
token_type: z.literal('Bearer').describe('The type of token, always "Bearer"'),
expires_in: Int32Type.describe('The number of seconds until the access token expires'),
refresh_token: z.string().describe('The refresh token for obtaining new access tokens'),
scope: z.string().describe('The space-separated list of granted scopes'),
});
export type FederationOAuth2TokenResponse = z.infer<typeof FederationOAuth2TokenResponse>;

View File

@@ -0,0 +1,89 @@
/*
* 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 {
createNamedStringLiteralUnion,
createStringType,
Int32Type,
SnowflakeStringType,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const PackType = withOpenApiType(
createNamedStringLiteralUnion(
[
['emoji', 'EMOJI', 'A pack containing custom emoji'],
['sticker', 'STICKER', 'A pack containing custom stickers'],
],
'The type of expression pack',
),
'PackType',
);
export const PackTypeParam = z.object({
pack_type: PackType.describe('The type of expression pack (emoji or sticker)'),
});
export type PackTypeParam = z.infer<typeof PackTypeParam>;
export const PackCreateRequest = z.object({
name: createStringType(1, 64).describe('The name of the pack'),
description: createStringType(1, 256).nullish().describe('The description of the pack'),
});
export type PackCreateRequest = z.infer<typeof PackCreateRequest>;
export const PackUpdateRequest = z.object({
name: createStringType(1, 64).optional().describe('The new name of the pack'),
description: createStringType(1, 256).nullish().optional().describe('The new description of the pack'),
});
export type PackUpdateRequest = z.infer<typeof PackUpdateRequest>;
export type PackType = z.infer<typeof PackType>;
export const PackSummaryResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier (snowflake) for the pack'),
name: z.string().describe('The display name of the pack'),
description: z.string().nullable().describe('The description of the pack'),
type: PackType.describe('The type of expression pack (emoji or sticker)'),
creator_id: SnowflakeStringType.describe('The ID of the user who created the pack'),
created_at: z.iso.datetime().describe('ISO8601 timestamp of when the pack was created'),
updated_at: z.iso.datetime().describe('ISO8601 timestamp of when the pack was last updated'),
installed_at: z.iso.datetime().optional().describe('ISO8601 timestamp of when the pack was installed by the user'),
});
export type PackSummaryResponse = z.infer<typeof PackSummaryResponse>;
export const PackDashboardSectionResponse = z.object({
installed_limit: Int32Type.describe('Maximum number of packs the user can install'),
created_limit: Int32Type.describe('Maximum number of packs the user can create'),
installed: z.array(PackSummaryResponse).max(100).describe('List of packs the user has installed'),
created: z.array(PackSummaryResponse).max(100).describe('List of packs the user has created'),
});
export type PackDashboardSectionResponse = z.infer<typeof PackDashboardSectionResponse>;
export const PackDashboardResponse = z.object({
emoji: PackDashboardSectionResponse.describe('Dashboard section for emoji packs'),
sticker: PackDashboardSectionResponse.describe('Dashboard section for sticker packs'),
});
export type PackDashboardResponse = z.infer<typeof PackDashboardResponse>;

View File

@@ -0,0 +1,54 @@
/*
* 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 {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const CreateCheckoutSessionRequest = z.object({
price_id: createStringType().describe('The Stripe price ID for the subscription plan'),
});
export type CreateCheckoutSessionRequest = z.infer<typeof CreateCheckoutSessionRequest>;
export const GiftCodeResponse = z.object({
code: z.string().describe('The unique gift code string'),
duration_months: z.number().int().describe('Duration of the subscription gift in months'),
redeemed: z.boolean().describe('Whether the gift code has been redeemed'),
created_by: z
.lazy(() => UserPartialResponse)
.nullish()
.describe('The user who created the gift code'),
});
export type GiftCodeResponse = z.infer<typeof GiftCodeResponse>;
export const GiftCodeMetadataResponse = z.object({
code: z.string().describe('The unique gift code string'),
duration_months: z.number().int().describe('Duration of the subscription gift in months'),
created_at: z.iso.datetime().describe('Timestamp when the gift code was created'),
created_by: z.lazy(() => UserPartialResponse).describe('The user who created the gift code'),
redeemed_at: z.iso.datetime().nullish().describe('Timestamp when the gift code was redeemed'),
redeemed_by: z
.lazy(() => UserPartialResponse)
.nullish()
.describe('The user who redeemed the gift code'),
});
export type GiftCodeMetadataResponse = z.infer<typeof GiftCodeMetadataResponse>;

View File

@@ -0,0 +1,45 @@
/*
* 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 {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const WebhookReceivedResponse = z.object({
received: z.boolean().describe('Whether the webhook was successfully received'),
});
export type WebhookReceivedResponse = z.infer<typeof WebhookReceivedResponse>;
export const UrlResponse = z.object({
url: z.string().describe('The URL to redirect to'),
});
export type UrlResponse = z.infer<typeof UrlResponse>;
export const PriceIdsResponse = z.object({
monthly: z.string().nullish().describe('Stripe price ID for the monthly subscription'),
yearly: z.string().nullish().describe('Stripe price ID for the yearly subscription'),
gift_1_month: z.string().nullish().describe('Stripe price ID for the 1 month gift'),
gift_1_year: z.string().nullish().describe('Stripe price ID for the 1 year gift'),
currency: z.enum(['USD', 'EUR']).describe('Currency for the prices'),
});
export type PriceIdsResponse = z.infer<typeof PriceIdsResponse>;
export const PriceIdsQueryRequest = z.object({
country_code: createStringType(2, 2).optional().describe('Two-letter country code for regional pricing'),
});
export type PriceIdsQueryRequest = z.infer<typeof PriceIdsQueryRequest>;

View File

@@ -0,0 +1,98 @@
/*
* 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 {z} from 'zod';
export const RelayInfoResponse = z.object({
id: z.string().describe('Unique identifier for the relay'),
name: z.string().describe('Human-readable name of the relay'),
url: z.url().describe('Base URL of the relay service'),
latitude: z.number().describe('Geographic latitude of the relay'),
longitude: z.number().describe('Geographic longitude of the relay'),
region: z.string().describe('Region identifier (e.g., eu-west, us-east)'),
capacity: z.number().int().min(0).describe('Maximum number of connections this relay can handle'),
current_connections: z.number().int().min(0).describe('Current number of active connections'),
public_key: z.string().describe('Base64-encoded X25519 public key for E2E encryption'),
registered_at: z.iso.datetime().describe('ISO 8601 timestamp when the relay was registered'),
last_seen_at: z.iso.datetime().describe('ISO 8601 timestamp of the last health check or heartbeat'),
healthy: z.boolean().describe('Whether the relay is currently healthy'),
});
export type RelayInfoResponse = z.infer<typeof RelayInfoResponse>;
export const RelayWithDistanceResponse = RelayInfoResponse.extend({
distance_km: z.number().min(0).describe('Distance from client location in kilometres'),
});
export type RelayWithDistanceResponse = z.infer<typeof RelayWithDistanceResponse>;
export const RelayListResponse = z.object({
relays: z.array(z.union([RelayInfoResponse, RelayWithDistanceResponse])).describe('List of available relays'),
count: z.number().int().min(0).describe('Total number of relays returned'),
});
export type RelayListResponse = z.infer<typeof RelayListResponse>;
export const RelayStatusResponse = z.object({
id: z.string().describe('Unique identifier for the relay'),
name: z.string().describe('Human-readable name of the relay'),
url: z.url().describe('Base URL of the relay service'),
region: z.string().describe('Region identifier'),
healthy: z.boolean().describe('Whether the relay is currently healthy'),
current_connections: z.number().int().min(0).describe('Current number of active connections'),
capacity: z.number().int().min(0).describe('Maximum connection capacity'),
last_seen_at: z.iso.datetime().describe('ISO 8601 timestamp of the last health check or heartbeat'),
});
export type RelayStatusResponse = z.infer<typeof RelayStatusResponse>;
export const RelayHeartbeatResponse = z.object({
status: z.literal('ok').describe('Confirmation that heartbeat was received'),
});
export type RelayHeartbeatResponse = z.infer<typeof RelayHeartbeatResponse>;
export const RelayDeletedResponse = z.object({
status: z.literal('deleted').describe('Confirmation that relay was deleted'),
});
export type RelayDeletedResponse = z.infer<typeof RelayDeletedResponse>;
export const RegisterRelayRequest = z.object({
name: z.string().min(1).max(100).describe('Human-readable name of the relay'),
url: z.url().describe('Base URL of the relay service'),
latitude: z.number().min(-90).max(90).describe('Geographic latitude of the relay'),
longitude: z.number().min(-180).max(180).describe('Geographic longitude of the relay'),
region: z.string().min(1).max(50).optional().default('unknown').describe('Region identifier'),
capacity: z.number().int().min(1).optional().default(1000).describe('Maximum connection capacity'),
public_key: z.string().min(1).describe('Base64-encoded X25519 public key for E2E encryption'),
});
export type RegisterRelayRequest = z.infer<typeof RegisterRelayRequest>;
export const RelayIdParam = z.object({
id: z.string().uuid().describe('Relay UUID'),
});
export type RelayIdParam = z.infer<typeof RelayIdParam>;
export const RelayListQuery = z.object({
lat: z.string().optional().describe('Client latitude for proximity sorting'),
lon: z.string().optional().describe('Client longitude for proximity sorting'),
limit: z.string().optional().describe('Maximum number of relays to return'),
});
export type RelayListQuery = z.infer<typeof RelayListQuery>;
export const HealthCheckResponse = z.object({
status: z.literal('ok').describe('Health status'),
timestamp: z.iso.datetime().describe('Current server timestamp'),
});
export type HealthCheckResponse = z.infer<typeof HealthCheckResponse>;

View File

@@ -0,0 +1,228 @@
/*
* 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 {
createNamedStringLiteralUnion,
createStringType,
SnowflakeStringType,
SnowflakeType,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
import {z} from 'zod';
export const MessageReportCategoryEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['harassment', 'harassment', 'Content that harasses, bullies, or intimidates users'],
['hate_speech', 'hate_speech', 'Content promoting hatred against protected groups'],
['violent_content', 'violent_content', 'Content depicting or promoting violence'],
['spam', 'spam', 'Unsolicited bulk messages or promotional content'],
['nsfw_violation', 'nsfw_violation', 'Adult content posted outside age-restricted channels'],
['illegal_activity', 'illegal_activity', 'Content promoting or facilitating illegal activities'],
['doxxing', 'doxxing', 'Content revealing private personal information'],
['self_harm', 'self_harm', 'Content promoting self-harm or suicide'],
['child_safety', 'child_safety', 'Content that endangers minors or depicts child abuse'],
['malicious_links', 'malicious_links', 'Links to malware, phishing, or other malicious sites'],
['impersonation', 'impersonation', 'Content falsely claiming to be another person or entity'],
['other', 'other', 'Other violations not covered by specific categories'],
],
'Category of the message report',
),
'MessageReportCategory',
);
export const UserReportCategoryEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['harassment', 'harassment', 'User engages in harassment, bullying, or intimidation'],
['hate_speech', 'hate_speech', 'User promotes hatred against protected groups'],
['spam_account', 'spam_account', 'Account used for spamming or bulk messaging'],
['impersonation', 'impersonation', 'User falsely claims to be another person or entity'],
['underage_user', 'underage_user', 'User appears to be under the minimum required age'],
['inappropriate_profile', 'inappropriate_profile', 'Profile contains inappropriate or offensive content'],
['other', 'other', 'Other violations not covered by specific categories'],
],
'Category of the user report',
),
'UserReportCategory',
);
export const GuildReportCategoryEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['harassment', 'harassment', 'Guild facilitates harassment, bullying, or intimidation'],
['hate_speech', 'hate_speech', 'Guild promotes hatred against protected groups'],
['extremist_community', 'extremist_community', 'Guild promotes extremist or terrorist ideologies'],
['illegal_activity', 'illegal_activity', 'Guild promotes or facilitates illegal activities'],
['child_safety', 'child_safety', 'Guild endangers minors or hosts child abuse content'],
['raid_coordination', 'raid_coordination', 'Guild coordinates attacks on other communities'],
['spam', 'spam', 'Guild used for spamming or bulk messaging'],
['malware_distribution', 'malware_distribution', 'Guild distributes malware or malicious software'],
['other', 'other', 'Other violations not covered by specific categories'],
],
'Category of the guild report',
),
'GuildReportCategory',
);
export const ReportResponse = z.object({
report_id: SnowflakeStringType.describe('The unique identifier for this report'),
status: z.string().describe('Current status of the report (pending, reviewed, resolved)'),
reported_at: z.string().describe('ISO 8601 timestamp when the report was submitted'),
});
export type ReportResponse = z.infer<typeof ReportResponse>;
export const OkResponse = z.object({
ok: z.boolean().describe('Whether the operation was successful'),
});
export type OkResponse = z.infer<typeof OkResponse>;
export const TicketResponse = z.object({
ticket: z.string().describe('A temporary ticket token for subsequent operations'),
});
export type TicketResponse = z.infer<typeof TicketResponse>;
const FLUXER_TAG_REGEX = /^([^#]{1,32})#([0-9]{4})$/;
const FLUXER_TAG_TYPE = z
.string()
.min(3)
.max(37)
.refine((value) => FLUXER_TAG_REGEX.test(value), 'Fluxer tag must be in the format username#1234')
.describe('A Fluxer username tag in the format username#1234');
const EU_COUNTRY_CODE_ENUM = createNamedStringLiteralUnion(
[
['AT', 'AT', 'Austria'],
['BE', 'BE', 'Belgium'],
['BG', 'BG', 'Bulgaria'],
['HR', 'HR', 'Croatia'],
['CY', 'CY', 'Cyprus'],
['CZ', 'CZ', 'Czechia'],
['DK', 'DK', 'Denmark'],
['EE', 'EE', 'Estonia'],
['FI', 'FI', 'Finland'],
['FR', 'FR', 'France'],
['DE', 'DE', 'Germany'],
['GR', 'GR', 'Greece'],
['HU', 'HU', 'Hungary'],
['IE', 'IE', 'Ireland'],
['IT', 'IT', 'Italy'],
['LV', 'LV', 'Latvia'],
['LT', 'LT', 'Lithuania'],
['LU', 'LU', 'Luxembourg'],
['MT', 'MT', 'Malta'],
['NL', 'NL', 'Netherlands'],
['PL', 'PL', 'Poland'],
['PT', 'PT', 'Portugal'],
['RO', 'RO', 'Romania'],
['SK', 'SK', 'Slovakia'],
['SI', 'SI', 'Slovenia'],
['ES', 'ES', 'Spain'],
['SE', 'SE', 'Sweden'],
] as const,
'EU country code of residence',
);
export const ReportMessageRequest = z.object({
channel_id: SnowflakeType.describe('ID of the channel containing the reported message'),
message_id: SnowflakeType.describe('ID of the message being reported'),
category: MessageReportCategoryEnum,
additional_info: z.optional(createStringType(0, 1000)).describe('Additional context or details about the report'),
});
export type ReportMessageRequest = z.infer<typeof ReportMessageRequest>;
export const ReportUserRequest = z.object({
user_id: SnowflakeType.describe('ID of the user being reported'),
category: UserReportCategoryEnum,
additional_info: z.optional(createStringType(0, 1000)).describe('Additional context or details about the report'),
guild_id: z.optional(SnowflakeType).describe('ID of the guild where the violation occurred'),
});
export type ReportUserRequest = z.infer<typeof ReportUserRequest>;
export const ReportGuildRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild being reported'),
category: GuildReportCategoryEnum,
additional_info: z.optional(createStringType(0, 1000)).describe('Additional context or details about the report'),
});
export type ReportGuildRequest = z.infer<typeof ReportGuildRequest>;
const DSA_VERIFICATION_CODE_TYPE = createStringType(9, 9).refine(
(value) => /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(value),
'Verification code must have the format XXXX-XXXX (uppercase letters and digits)',
);
export const DsaReportEmailSendRequest = z.object({
email: EmailType.describe('Email address to send the DSA verification code to'),
});
export type DsaReportEmailSendRequest = z.infer<typeof DsaReportEmailSendRequest>;
export const DsaReportEmailVerifyRequest = z.object({
email: EmailType.describe('Email address that received the verification code'),
code: DSA_VERIFICATION_CODE_TYPE.describe('Verification code received via email'),
});
export type DsaReportEmailVerifyRequest = z.infer<typeof DsaReportEmailVerifyRequest>;
const DsaReportBase = z.object({
ticket: createStringType(1, 128).describe('Verification ticket obtained from email verification'),
additional_info: z.optional(createStringType(0, 1000)).describe('Additional context or details about the report'),
reporter_full_legal_name: createStringType(1, 160).describe('Full legal name of the person filing the report'),
reporter_country_of_residence: EU_COUNTRY_CODE_ENUM.describe('EU country code of the reporter residence'),
reporter_fluxer_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the reporter if they have an account'),
});
export const DsaReportMessageRequest = DsaReportBase.extend({
report_type: z.literal('message').describe('Type of report'),
category: MessageReportCategoryEnum,
message_link: createStringType(1, 2048).describe('Link to the message being reported'),
reported_user_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the user who sent the message'),
});
export type DsaReportMessageRequest = z.infer<typeof DsaReportMessageRequest>;
export const DsaReportUserRequest = DsaReportBase.extend({
report_type: z.literal('user').describe('Type of report'),
category: UserReportCategoryEnum,
user_id: SnowflakeType.optional().describe('ID of the user being reported'),
user_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the user being reported'),
}).superRefine((value, ctx) => {
if (!value.user_id && !value.user_tag) {
ctx.addIssue({
code: 'custom',
message: 'Either user_id or user_tag must be provided for user reports',
path: [],
});
}
});
export type DsaReportUserRequest = z.infer<typeof DsaReportUserRequest>;
export const DsaReportGuildRequest = DsaReportBase.extend({
report_type: z.literal('guild').describe('Type of report'),
category: GuildReportCategoryEnum,
guild_id: SnowflakeType.describe('ID of the guild being reported'),
invite_code: z.optional(createStringType(1, 64)).describe('Invite code used to access the guild'),
});
export type DsaReportGuildRequest = z.infer<typeof DsaReportGuildRequest>;
export const DsaReportRequest = z.discriminatedUnion('report_type', [
DsaReportMessageRequest,
DsaReportUserRequest,
DsaReportGuildRequest,
]);
export type DsaReportRequest = z.infer<typeof DsaReportRequest>;

View File

@@ -0,0 +1,351 @@
/*
* 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 {RTC_REGION_ID_MAX_LENGTH, RTC_REGION_ID_MIN_LENGTH} from '@fluxer/constants/src/LimitConstants';
import {ChannelResponse, RtcRegionResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {GuildEmojiResponse, GuildStickerResponse} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
import {FavoriteMemeResponse} from '@fluxer/schema/src/domains/meme/MemeSchemas';
import {CustomStatusPayload} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {
CustomStatusResponse,
RelationshipResponse,
UserGuildSettingsResponse,
UserPrivateResponse,
UserSettingsResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {createStringType, SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ReadStateResponse = z.object({
id: SnowflakeStringType.describe('The channel ID for this read state'),
mention_count: z.number().describe('Number of unread mentions in the channel'),
last_message_id: SnowflakeStringType.nullish().describe('ID of the last read message'),
last_pin_timestamp: z.string().nullish().describe('Timestamp of the last pinned message'),
});
export type ReadStateResponse = z.infer<typeof ReadStateResponse>;
export const RpcRequest = z.discriminatedUnion('type', [
z.object({
type: z.literal('session').describe('Request type for session initialization'),
token: createStringType().describe('Authentication token for the session'),
version: z.literal(1).describe('RPC protocol version'),
ip: createStringType(1, 45).optional().describe('Client IP address'),
latitude: createStringType(1, 32).optional().describe('Client latitude for region selection'),
longitude: createStringType(1, 32).optional().describe('Client longitude for region selection'),
}),
z.object({
type: z.literal('guild').describe('Request type for fetching guild data'),
guild_id: SnowflakeType.describe('ID of the guild to fetch'),
}),
z.object({
type: z.literal('log_guild_crash').describe('Request type for logging guild crashes'),
guild_id: SnowflakeType.describe('ID of the guild that crashed'),
stacktrace: z.string().describe('Error stacktrace from the crash'),
}),
z.object({
type: z.literal('get_user_guild_settings').describe('Request type for fetching user guild settings'),
user_ids: z.array(SnowflakeType).describe('IDs of users to fetch settings for'),
guild_id: SnowflakeType.describe('ID of the guild'),
}),
z.object({
type: z.literal('get_push_subscriptions').describe('Request type for fetching push notification subscriptions'),
user_ids: z.array(SnowflakeType).describe('IDs of users to fetch subscriptions for'),
}),
z.object({
type: z.literal('get_badge_counts').describe('Request type for fetching notification badge counts'),
user_ids: z.array(SnowflakeType).describe('IDs of users to fetch badge counts for'),
}),
z.object({
type: z.literal('geoip_lookup').describe('Request type for IP geolocation lookup'),
ip: createStringType(1, 45).describe('IP address to lookup'),
}),
z.object({
type: z.literal('delete_push_subscriptions').describe('Request type for deleting push notification subscriptions'),
subscriptions: z
.array(
z.object({
user_id: SnowflakeType.describe('ID of the user'),
subscription_id: createStringType().describe('ID of the subscription to delete'),
}),
)
.describe('List of subscriptions to delete'),
}),
z.object({
type: z.literal('get_user_blocked_ids').describe('Request type for fetching blocked user IDs'),
user_ids: z.array(SnowflakeType).describe('IDs of users to fetch blocked lists for'),
}),
z.object({
type: z.literal('voice_get_token').describe('Request type for getting voice connection token'),
guild_id: SnowflakeType.optional().describe('ID of the guild for the voice channel'),
channel_id: SnowflakeType.describe('ID of the voice channel'),
user_id: SnowflakeType.describe('ID of the user joining voice'),
connection_id: createStringType().optional().describe('Existing connection ID for reconnection'),
rtc_region: createStringType(RTC_REGION_ID_MIN_LENGTH, RTC_REGION_ID_MAX_LENGTH)
.optional()
.describe(
`Preferred voice region for the connection (${RTC_REGION_ID_MIN_LENGTH}-${RTC_REGION_ID_MAX_LENGTH} characters)`,
),
latitude: createStringType(1, 32).optional().describe('Client latitude for region selection'),
longitude: createStringType(1, 32).optional().describe('Client longitude for region selection'),
can_speak: z.boolean().optional().describe('Whether the user can speak in the channel'),
can_stream: z.boolean().optional().describe('Whether the user can stream in the channel'),
can_video: z.boolean().optional().describe('Whether the user can use video in the channel'),
token_nonce: createStringType(1, 64).optional().describe('Token nonce for replay prevention'),
}),
z.object({
type: z.literal('kick_temporary_member').describe('Request type for kicking temporary guild members'),
user_id: SnowflakeType.describe('ID of the user to kick'),
guild_ids: z.array(SnowflakeType).describe('IDs of guilds to kick the user from'),
}),
z.object({
type: z
.literal('voice_force_disconnect_participant')
.describe('Request type for force disconnecting a voice participant'),
guild_id: SnowflakeType.optional().describe('ID of the guild'),
channel_id: SnowflakeType.describe('ID of the voice channel'),
user_id: SnowflakeType.describe('ID of the user to disconnect'),
connection_id: createStringType().describe('Connection ID of the user'),
}),
z.object({
type: z.literal('voice_update_participant').describe('Request type for updating voice participant state'),
guild_id: SnowflakeType.optional().describe('ID of the guild'),
channel_id: SnowflakeType.describe('ID of the voice channel'),
user_id: SnowflakeType.describe('ID of the user to update'),
mute: z.boolean().describe('Whether the user is muted'),
deaf: z.boolean().describe('Whether the user is deafened'),
}),
z.object({
type: z
.literal('voice_force_disconnect_channel')
.describe('Request type for force disconnecting all participants from a channel'),
guild_id: SnowflakeType.optional().describe('ID of the guild'),
channel_id: SnowflakeType.describe('ID of the voice channel to clear'),
}),
z.object({
type: z
.literal('voice_update_participant_permissions')
.describe('Request type for updating voice participant permissions'),
guild_id: SnowflakeType.optional().describe('ID of the guild'),
channel_id: SnowflakeType.describe('ID of the voice channel'),
user_id: SnowflakeType.describe('ID of the user to update'),
connection_id: createStringType().describe('Connection ID of the user'),
can_speak: z.boolean().describe('Whether the user can speak'),
can_stream: z.boolean().describe('Whether the user can stream'),
can_video: z.boolean().describe('Whether the user can use video'),
}),
z.object({
type: z.literal('call_ended').describe('Request type for notifying that a call has ended'),
channel_id: SnowflakeType.describe('ID of the channel where the call ended'),
message_id: SnowflakeType.describe('ID of the call start message'),
participants: z.array(SnowflakeType).describe('IDs of users who participated in the call'),
ended_timestamp: z.number().describe('Unix timestamp when the call ended'),
}),
z.object({
type: z.literal('get_dm_channel').describe('Request type for fetching a DM channel'),
channel_id: SnowflakeType.describe('ID of the DM channel'),
user_id: SnowflakeType.describe('ID of the user requesting the channel'),
}),
z.object({
type: z.literal('validate_custom_status').describe('Request type for validating a custom status'),
user_id: SnowflakeType.describe('ID of the user'),
custom_status: CustomStatusPayload.nullish().describe('Custom status data to validate'),
}),
]);
export type RpcRequest = z.infer<typeof RpcRequest>;
export const RpcResponseSessionData = z.object({
auth_session_id_hash: z.string().nullish().describe('Hash of the authentication session ID'),
user: UserPrivateResponse.describe('Private user data for the authenticated user'),
user_settings: UserSettingsResponse.nullish().describe('User settings configuration'),
user_guild_settings: z.array(UserGuildSettingsResponse).describe('Per-guild settings for the user'),
notes: z.record(SnowflakeStringType, z.string()).describe('User notes keyed by user ID'),
read_states: z.array(ReadStateResponse).describe('Read state for each channel'),
private_channels: z.array(ChannelResponse).describe('List of DM and group DM channels'),
relationships: z.array(RelationshipResponse).describe('User relationships (friends, blocked, etc.)'),
favorite_memes: z.array(FavoriteMemeResponse).describe('List of user favorite memes'),
guild_ids: z.array(SnowflakeStringType).describe('IDs of guilds the user is a member of'),
pinned_dms: z.array(SnowflakeStringType).describe('IDs of pinned DM channels'),
country_code: z.string().describe('Two-letter country code from IP geolocation'),
rtc_regions: z.array(RtcRegionResponse).describe('Available voice server regions'),
version: z.number().int().describe('Session data version for cache invalidation'),
});
export type RpcResponseSessionData = z.infer<typeof RpcResponseSessionData>;
export const RpcResponseGuildData = z.object({
guild: GuildResponse.describe('Guild information'),
roles: z.array(GuildRoleResponse).describe('List of roles in the guild'),
channels: z.array(ChannelResponse).describe('List of channels in the guild'),
emojis: z.array(GuildEmojiResponse).describe('List of custom emojis in the guild'),
stickers: z.array(GuildStickerResponse).describe('List of custom stickers in the guild'),
members: z.array(GuildMemberResponse).describe('List of guild members'),
});
export type RpcResponseGuildData = z.infer<typeof RpcResponseGuildData>;
export const RpcResponseValidateCustomStatus = z.object({
custom_status: CustomStatusResponse.nullish().describe('Validated custom status or null if invalid'),
});
export type RpcResponseValidateCustomStatus = z.infer<typeof RpcResponseValidateCustomStatus>;
export const RpcResponse = z.discriminatedUnion('type', [
z.object({
type: z.literal('session').describe('Response type for session initialization'),
data: RpcResponseSessionData.describe('Session initialization data'),
}),
z.object({
type: z.literal('log_guild_crash').describe('Response type for guild crash logging'),
data: z
.object({
success: z.boolean().describe('Whether the crash was logged successfully'),
})
.describe('Crash logging result'),
}),
z.object({
type: z.literal('guild').describe('Response type for guild data'),
data: RpcResponseGuildData.describe('Guild data'),
}),
z.object({
type: z.literal('get_user_guild_settings').describe('Response type for user guild settings'),
data: z
.object({
user_guild_settings: z
.array(UserGuildSettingsResponse.nullable())
.describe('Guild settings for each requested user'),
})
.describe('User guild settings data'),
}),
z.object({
type: z.literal('get_push_subscriptions').describe('Response type for push subscriptions'),
data: z
.record(
SnowflakeStringType,
z.array(
z.object({
subscription_id: z.string().describe('Unique identifier for the subscription'),
endpoint: z.string().describe('Push notification endpoint URL'),
p256dh_key: z.string().describe('P-256 Diffie-Hellman public key'),
auth_key: z.string().describe('Authentication secret key'),
}),
),
)
.describe('Push subscriptions keyed by user ID'),
}),
z.object({
type: z.literal('delete_push_subscriptions').describe('Response type for push subscription deletion'),
data: z.object({success: z.boolean().describe('Whether the deletion was successful')}).describe('Deletion result'),
}),
z.object({
type: z.literal('get_user_blocked_ids').describe('Response type for blocked user IDs'),
data: z.record(SnowflakeStringType, z.array(SnowflakeStringType)).describe('Blocked user IDs keyed by user ID'),
}),
z.object({
type: z.literal('voice_get_token').describe('Response type for voice connection token'),
data: z
.object({
token: z.string().describe('Voice server authentication token'),
endpoint: z.string().describe('Voice server endpoint URL'),
connectionId: z.string().describe('Unique connection identifier'),
tokenNonce: z.string().describe('Token nonce for webhook confirmation'),
})
.describe('Voice connection credentials'),
}),
z.object({
type: z.literal('kick_temporary_member').describe('Response type for temporary member kick'),
data: z
.object({
success: z.boolean().describe('Whether the kick was successful'),
})
.describe('Kick result'),
}),
z.object({
type: z.literal('get_badge_counts').describe('Response type for badge counts'),
data: z
.object({
badge_counts: z.record(SnowflakeStringType, z.number().int().min(0)).describe('Badge counts keyed by user ID'),
})
.describe('Badge count data'),
}),
z.object({
type: z.literal('voice_force_disconnect_participant').describe('Response type for force disconnect participant'),
data: z
.object({
success: z.boolean().describe('Whether the disconnect was successful'),
})
.describe('Disconnect result'),
}),
z.object({
type: z.literal('voice_update_participant').describe('Response type for voice participant update'),
data: z
.object({
success: z.boolean().describe('Whether the update was successful'),
})
.describe('Update result'),
}),
z.object({
type: z.literal('voice_force_disconnect_channel').describe('Response type for force disconnect channel'),
data: z
.object({
success: z.boolean().describe('Whether the operation was successful'),
disconnected_count: z.number().optional().describe('Number of participants disconnected'),
message: z.string().optional().describe('Additional status message'),
})
.describe('Channel disconnect result'),
}),
z.object({
type: z.literal('voice_update_participant_permissions').describe('Response type for voice permissions update'),
data: z
.object({
success: z.boolean().describe('Whether the permissions update was successful'),
})
.describe('Permissions update result'),
}),
z.object({
type: z.literal('call_ended').describe('Response type for call ended notification'),
data: z
.object({
success: z.boolean().describe('Whether the notification was processed successfully'),
})
.describe('Call ended result'),
}),
z.object({
type: z.literal('validate_custom_status').describe('Response type for custom status validation'),
data: RpcResponseValidateCustomStatus.describe('Custom status validation result'),
}),
z.object({
type: z.literal('geoip_lookup').describe('Response type for IP geolocation lookup'),
data: z.object({country_code: z.string().describe('Two-letter country code')}).describe('Geolocation result'),
}),
z.object({
type: z.literal('get_dm_channel').describe('Response type for DM channel fetch'),
data: z
.object({
channel: ChannelResponse.nullish().describe('The DM channel or null if not found'),
})
.describe('DM channel result'),
}),
]);
export type RpcResponse = z.infer<typeof RpcResponse>;

View File

@@ -0,0 +1,72 @@
/*
* 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 {LocaleSchema} from '@fluxer/schema/src/primitives/LocaleSchema';
import {createStringType, Int32Type} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const LocaleType = LocaleSchema.default('en-US').transform((v) => v.replace('-', '_'));
export const TenorSearchQuery = z.object({
q: createStringType(1, 256).describe('The search query'),
locale: LocaleType,
});
export type TenorSearchQuery = z.infer<typeof TenorSearchQuery>;
export const TenorLocaleQuery = z.object({
locale: LocaleType,
});
export type TenorLocaleQuery = z.infer<typeof TenorLocaleQuery>;
export const TenorRegisterShareRequest = z.object({
id: createStringType(1, 64).describe('The Tenor result id'),
q: createStringType(0, 256).nullish().describe('The search query used to find the GIF'),
locale: LocaleType,
});
export type TenorRegisterShareRequest = z.infer<typeof TenorRegisterShareRequest>;
export const TenorGifResponse = z.object({
id: z.string().describe('The unique Tenor result id'),
title: z.string().describe('The title/description of the GIF'),
url: z.string().describe('The Tenor page URL for the GIF'),
src: z.string().describe('Direct URL to the GIF media file'),
proxy_src: z.string().describe('Proxied URL to the GIF media file'),
width: Int32Type.describe('Width of the GIF in pixels'),
height: Int32Type.describe('Height of the GIF in pixels'),
});
export type TenorGifResponse = z.infer<typeof TenorGifResponse>;
export const TenorCategoryTagResponse = z.object({
name: z.string().describe('The category search term'),
src: z.string().describe('URL to the category preview image'),
proxy_src: z.string().describe('Proxied URL to the category preview image'),
});
export type TenorCategoryTagResponse = z.infer<typeof TenorCategoryTagResponse>;
export const TenorFeaturedResponse = z.object({
gifs: z.array(TenorGifResponse).max(50).describe('Array of featured GIFs'),
categories: z.array(TenorCategoryTagResponse).max(100).describe('Array of GIF categories'),
});
export type TenorFeaturedResponse = z.infer<typeof TenorFeaturedResponse>;

View File

@@ -0,0 +1,257 @@
/*
* 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 {
ChannelOverwriteResponse,
ChannelPartialResponse,
ChannelResponse,
} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {describe, expect, it} from 'vitest';
describe('ChannelOverwriteResponse', () => {
it('accepts valid channel overwrite', () => {
const result = ChannelOverwriteResponse.safeParse({
id: '123456789012345678',
type: 0,
allow: '8',
deny: '0',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('123456789012345678');
expect(result.data.type).toBe(0);
}
});
it('requires all fields', () => {
const result = ChannelOverwriteResponse.safeParse({
id: '123456789012345678',
type: 0,
});
expect(result.success).toBe(false);
});
it('requires type to be integer', () => {
const result = ChannelOverwriteResponse.safeParse({
id: '123456789012345678',
type: 'role',
allow: '8',
deny: '0',
});
expect(result.success).toBe(false);
});
it('accepts member type (1)', () => {
const result = ChannelOverwriteResponse.safeParse({
id: '123456789012345678',
type: 1,
allow: '8',
deny: '0',
});
expect(result.success).toBe(true);
});
});
describe('ChannelResponse', () => {
const validChannel = {
id: '123456789012345678',
type: 0,
guild_id: '987654321098765432',
name: 'general',
topic: 'General discussion',
position: 0,
nsfw: false,
rate_limit_per_user: 0,
};
it('accepts valid channel response', () => {
const result = ChannelResponse.safeParse(validChannel);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('123456789012345678');
expect(result.data.name).toBe('general');
}
});
it('requires id and type', () => {
const {id, ...channelWithoutId} = validChannel;
const result = ChannelResponse.safeParse(channelWithoutId);
expect(result.success).toBe(false);
});
it('accepts DM channel without guild_id', () => {
const dmChannel = {
id: '123456789012345678',
type: 1,
recipients: [
{
id: '111111111111111111',
username: 'testuser',
discriminator: '0001',
global_name: null,
avatar: null,
avatar_color: null,
flags: 0,
},
],
};
const result = ChannelResponse.safeParse(dmChannel);
expect(result.success).toBe(true);
});
it('accepts channel with permission_overwrites', () => {
const channel = {
...validChannel,
permission_overwrites: [
{id: '111111111111111111', type: 0, allow: '8', deny: '0'},
{id: '222222222222222222', type: 1, allow: '0', deny: '2048'},
],
};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.permission_overwrites).toHaveLength(2);
}
});
it('accepts voice channel properties', () => {
const voiceChannel = {
...validChannel,
type: 2,
bitrate: 64000,
user_limit: 10,
rtc_region: 'us-west',
};
const result = ChannelResponse.safeParse(voiceChannel);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.bitrate).toBe(64000);
expect(result.data.user_limit).toBe(10);
}
});
it('accepts null optional fields', () => {
const channel = {
...validChannel,
topic: null,
parent_id: null,
last_message_id: null,
last_pin_timestamp: null,
};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(true);
});
it('accepts channel with nicks', () => {
const channel = {
id: '123456789012345678',
type: 3,
nicks: {
'111111111111111111': 'Friend 1',
'222222222222222222': 'Friend 2',
},
};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.nicks).toBeDefined();
}
});
it('accepts valid last_pin_timestamp', () => {
const channel = {
...validChannel,
last_pin_timestamp: '2024-01-15T12:30:00.000Z',
};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(true);
});
it('accepts valid url', () => {
const channel = {
...validChannel,
url: 'https://example.com',
};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(true);
});
it('requires type to be integer', () => {
const channel = {...validChannel, type: 'text'};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(false);
});
it('requires position to be integer when present', () => {
const channel = {...validChannel, position: 1.5};
const result = ChannelResponse.safeParse(channel);
expect(result.success).toBe(false);
});
});
describe('ChannelPartialResponse', () => {
it('accepts valid partial channel', () => {
const result = ChannelPartialResponse.safeParse({
id: '123456789012345678',
name: 'general',
type: 0,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('123456789012345678');
expect(result.data.name).toBe('general');
}
});
it('requires id and type', () => {
const result = ChannelPartialResponse.safeParse({
name: 'general',
});
expect(result.success).toBe(false);
});
it('accepts null name', () => {
const result = ChannelPartialResponse.safeParse({
id: '123456789012345678',
name: null,
type: 1,
});
expect(result.success).toBe(true);
});
it('accepts DM channel with recipients', () => {
const result = ChannelPartialResponse.safeParse({
id: '123456789012345678',
type: 1,
recipients: [{username: 'testuser'}],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.recipients).toHaveLength(1);
}
});
it('accepts channel without name or recipients', () => {
const result = ChannelPartialResponse.safeParse({
id: '123456789012345678',
type: 0,
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,364 @@
/*
* 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 {
EmbedAuthorResponse,
EmbedFieldResponse,
EmbedFooterResponse,
EmbedMediaResponse,
MessageEmbedResponse,
} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {describe, expect, it} from 'vitest';
describe('EmbedAuthorResponse', () => {
it('accepts valid author with name only', () => {
const result = EmbedAuthorResponse.safeParse({
name: 'Test Author',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Test Author');
}
});
it('accepts author with all fields', () => {
const result = EmbedAuthorResponse.safeParse({
name: 'Test Author',
url: 'https://example.com',
icon_url: 'https://example.com/icon.png',
proxy_icon_url: 'https://proxy.example.com/icon.png',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('https://example.com');
}
});
it('accepts null optional fields', () => {
const result = EmbedAuthorResponse.safeParse({
name: 'Test Author',
url: null,
icon_url: null,
});
expect(result.success).toBe(true);
});
it('requires name', () => {
const result = EmbedAuthorResponse.safeParse({
url: 'https://example.com',
});
expect(result.success).toBe(false);
});
});
describe('EmbedFooterResponse', () => {
it('accepts valid footer with text only', () => {
const result = EmbedFooterResponse.safeParse({
text: 'Footer text',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.text).toBe('Footer text');
}
});
it('accepts footer with all fields', () => {
const result = EmbedFooterResponse.safeParse({
text: 'Footer text',
icon_url: 'https://example.com/icon.png',
proxy_icon_url: 'https://proxy.example.com/icon.png',
});
expect(result.success).toBe(true);
});
it('accepts null icon urls', () => {
const result = EmbedFooterResponse.safeParse({
text: 'Footer text',
icon_url: null,
proxy_icon_url: null,
});
expect(result.success).toBe(true);
});
it('requires text', () => {
const result = EmbedFooterResponse.safeParse({
icon_url: 'https://example.com/icon.png',
});
expect(result.success).toBe(false);
});
});
describe('EmbedMediaResponse', () => {
it('accepts valid media with url and flags', () => {
const result = EmbedMediaResponse.safeParse({
url: 'https://example.com/image.png',
flags: 0,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.url).toBe('https://example.com/image.png');
}
});
it('accepts media with all fields', () => {
const result = EmbedMediaResponse.safeParse({
url: 'https://example.com/image.png',
proxy_url: 'https://proxy.example.com/image.png',
content_type: 'image/png',
content_hash: 'abc123',
width: 800,
height: 600,
description: 'An image',
placeholder: 'placeholder-data',
duration: 0,
flags: 0,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.width).toBe(800);
expect(result.data.height).toBe(600);
}
});
it('accepts null optional fields', () => {
const result = EmbedMediaResponse.safeParse({
url: 'https://example.com/image.png',
proxy_url: null,
content_type: null,
content_hash: null,
width: null,
height: null,
flags: 0,
});
expect(result.success).toBe(true);
});
it('requires url', () => {
const result = EmbedMediaResponse.safeParse({
flags: 0,
});
expect(result.success).toBe(false);
});
it('requires flags', () => {
const result = EmbedMediaResponse.safeParse({
url: 'https://example.com/image.png',
});
expect(result.success).toBe(false);
});
it('requires width and height to be integers', () => {
const result = EmbedMediaResponse.safeParse({
url: 'https://example.com/image.png',
width: 800.5,
height: 600,
flags: 0,
});
expect(result.success).toBe(false);
});
});
describe('EmbedFieldResponse', () => {
it('accepts valid field', () => {
const result = EmbedFieldResponse.safeParse({
name: 'Field Name',
value: 'Field Value',
inline: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Field Name');
expect(result.data.value).toBe('Field Value');
}
});
it('accepts inline field', () => {
const result = EmbedFieldResponse.safeParse({
name: 'Field Name',
value: 'Field Value',
inline: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.inline).toBe(true);
}
});
it('requires all fields', () => {
const result = EmbedFieldResponse.safeParse({
name: 'Field Name',
value: 'Field Value',
});
expect(result.success).toBe(false);
});
it('requires name', () => {
const result = EmbedFieldResponse.safeParse({
value: 'Field Value',
inline: false,
});
expect(result.success).toBe(false);
});
it('requires value', () => {
const result = EmbedFieldResponse.safeParse({
name: 'Field Name',
inline: false,
});
expect(result.success).toBe(false);
});
});
describe('MessageEmbedResponse', () => {
it('accepts valid embed with type only', () => {
const result = MessageEmbedResponse.safeParse({
type: 'rich',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe('rich');
}
});
it('accepts full rich embed', () => {
const result = MessageEmbedResponse.safeParse({
type: 'rich',
url: 'https://example.com',
title: 'Embed Title',
color: 0xff5500,
timestamp: '2024-01-15T12:30:00.000Z',
description: 'This is a description',
author: {
name: 'Author Name',
url: 'https://example.com/author',
},
image: {
url: 'https://example.com/image.png',
flags: 0,
},
thumbnail: {
url: 'https://example.com/thumb.png',
flags: 0,
},
footer: {
text: 'Footer text',
},
fields: [
{name: 'Field 1', value: 'Value 1', inline: false},
{name: 'Field 2', value: 'Value 2', inline: true},
],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.title).toBe('Embed Title');
expect(result.data.fields).toHaveLength(2);
}
});
it('accepts video embed', () => {
const result = MessageEmbedResponse.safeParse({
type: 'video',
url: 'https://example.com/video',
video: {
url: 'https://example.com/video.mp4',
flags: 0,
width: 1920,
height: 1080,
},
provider: {
name: 'Video Provider',
},
});
expect(result.success).toBe(true);
});
it('accepts audio embed', () => {
const result = MessageEmbedResponse.safeParse({
type: 'audio',
audio: {
url: 'https://example.com/audio.mp3',
flags: 0,
duration: 180,
},
});
expect(result.success).toBe(true);
});
it('accepts null optional fields', () => {
const result = MessageEmbedResponse.safeParse({
type: 'rich',
url: null,
title: null,
color: null,
timestamp: null,
description: null,
author: null,
image: null,
thumbnail: null,
footer: null,
fields: null,
});
expect(result.success).toBe(true);
});
it('requires type', () => {
const result = MessageEmbedResponse.safeParse({
title: 'Title without type',
});
expect(result.success).toBe(false);
});
it('accepts nsfw flag', () => {
const result = MessageEmbedResponse.safeParse({
type: 'image',
nsfw: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.nsfw).toBe(true);
}
});
it('accepts color as integer', () => {
const result = MessageEmbedResponse.safeParse({
type: 'rich',
color: 16711680,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.color).toBe(16711680);
}
});
it('rejects non-integer color', () => {
const result = MessageEmbedResponse.safeParse({
type: 'rich',
color: 123.45,
});
expect(result.success).toBe(false);
});
it('accepts valid ISO timestamp', () => {
const result = MessageEmbedResponse.safeParse({
type: 'rich',
timestamp: '2024-01-15T12:30:00Z',
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,164 @@
/*
* 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 {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {
type ChannelOrderingChannel,
computeGuildChannelReorderPlan,
computePositionFromPrecedingSiblingId,
computePrecedingSiblingIdFromPosition,
} from '@fluxer/schema/src/domains/channel/GuildChannelOrdering';
import {describe, expect, it} from 'vitest';
type Ch = ChannelOrderingChannel<string>;
const createFixture = (): Array<Ch> => [
{id: 'cat1', parentId: null, type: ChannelTypes.GUILD_CATEGORY, position: 1},
{id: 'c1_text1', parentId: 'cat1', type: ChannelTypes.GUILD_TEXT, position: 2},
{id: 'c1_voice1', parentId: 'cat1', type: ChannelTypes.GUILD_VOICE, position: 3},
{id: 'cat2', parentId: null, type: ChannelTypes.GUILD_CATEGORY, position: 4},
{id: 'c2_text1', parentId: 'cat2', type: ChannelTypes.GUILD_TEXT, position: 5},
{id: 'c2_voice1', parentId: 'cat2', type: ChannelTypes.GUILD_VOICE, position: 6},
{id: 'top_text', parentId: null, type: ChannelTypes.GUILD_TEXT, position: 7},
];
describe('GuildChannelOrdering', () => {
it('round-trips position <-> preceding sibling for a parent', () => {
const channels = createFixture();
const preceding = computePrecedingSiblingIdFromPosition<string, Ch>({
channels,
targetId: 'c2_voice1',
desiredParentId: 'cat2',
position: 1,
});
expect(preceding).toBe('c2_text1');
const position = computePositionFromPrecedingSiblingId<string, Ch>({
channels,
targetId: 'c2_voice1',
desiredParentId: 'cat2',
precedingSiblingId: preceding,
});
expect(position).toBe(1);
});
it('inserts after a preceding category block (category + children)', () => {
const channels = createFixture();
const result = computeGuildChannelReorderPlan<string, Ch>({
channels,
operation: {channelId: 'cat1', parentId: null, precedingSiblingId: 'cat2'},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.plan.finalChannels.map((c) => c.id)).toEqual([
'cat2',
'c2_text1',
'c2_voice1',
'cat1',
'c1_text1',
'c1_voice1',
'top_text',
]);
});
it('rejects positioning relative to a channel inside the moved category block', () => {
const channels = createFixture();
const result = computeGuildChannelReorderPlan<string, Ch>({
channels,
operation: {channelId: 'cat1', parentId: null, precedingSiblingId: 'c1_text1'},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.code).toBe('CANNOT_POSITION_RELATIVE_TO_SELF_BLOCK');
});
it('computes sibling positions against the list with the moved block removed', () => {
const channels = createFixture();
const position = computePositionFromPrecedingSiblingId<string, Ch>({
channels,
targetId: 'c1_text1',
desiredParentId: 'cat1',
precedingSiblingId: 'c1_voice1',
});
expect(position).toBe(1);
});
it('moves a channel across categories via preceding sibling', () => {
const channels = createFixture();
const result = computeGuildChannelReorderPlan<string, Ch>({
channels,
operation: {channelId: 'c1_text1', parentId: 'cat2', precedingSiblingId: 'c2_text1'},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.plan.desiredParentById.get('c1_text1')).toBe('cat2');
const ids = result.plan.finalChannels.map((c) => c.id);
const c2TextIdx = ids.indexOf('c2_text1');
const c1TextIdx = ids.indexOf('c1_text1');
expect(c1TextIdx).toBe(c2TextIdx + 1);
});
it('moves a channel to the first position inside a category (no preceding sibling)', () => {
const channels = createFixture();
const result = computeGuildChannelReorderPlan<string, Ch>({
channels,
operation: {channelId: 'top_text', parentId: 'cat1', precedingSiblingId: null},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.plan.desiredParentById.get('top_text')).toBe('cat1');
const ids = result.plan.finalChannels.map((c) => c.id);
const cat1Idx = ids.indexOf('cat1');
const topTextIdx = ids.indexOf('top_text');
expect(topTextIdx).toBe(cat1Idx + 1);
});
it('returns orderUnchanged when the channel is already in the requested position', () => {
const channels = createFixture();
const result = computeGuildChannelReorderPlan<string, Ch>({
channels,
operation: {channelId: 'c1_text1', parentId: 'cat1', precedingSiblingId: null},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.plan.orderUnchanged).toBe(true);
});
it('rejects PRECEDING_PARENT_MISMATCH when sibling is in wrong parent', () => {
const channels = createFixture();
const result = computeGuildChannelReorderPlan<string, Ch>({
channels,
operation: {channelId: 'c1_text1', parentId: 'cat1', precedingSiblingId: 'c2_text1'},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.code).toBe('PRECEDING_PARENT_MISMATCH');
});
});

View File

@@ -0,0 +1,286 @@
/*
* 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 {GuildFeatures, GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
import {
GuildPartialResponse,
GuildResponse,
GuildVanityURLResponse,
} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {describe, expect, it} from 'vitest';
describe('GuildResponse', () => {
const validGuild = {
id: '123456789012345678',
name: 'Test Guild',
icon: 'icon_hash',
banner: 'banner_hash',
banner_width: 1920,
banner_height: 1080,
splash: 'splash_hash',
splash_width: 1920,
splash_height: 1080,
splash_card_alignment: GuildSplashCardAlignment.CENTER,
embed_splash: 'embed_splash_hash',
embed_splash_width: 800,
embed_splash_height: 600,
vanity_url_code: 'myserver',
owner_id: '987654321098765432',
system_channel_id: '111111111111111111',
system_channel_flags: 0,
rules_channel_id: '222222222222222222',
afk_channel_id: '333333333333333333',
afk_timeout: 300,
features: [GuildFeatures.VERIFIED, GuildFeatures.VANITY_URL],
verification_level: 2,
mfa_level: 1,
nsfw_level: 0,
explicit_content_filter: 2,
default_message_notifications: 1,
disabled_operations: 0,
permissions: '8',
};
it('accepts valid guild response', () => {
const result = GuildResponse.safeParse(validGuild);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('123456789012345678');
expect(result.data.name).toBe('Test Guild');
}
});
it('accepts guild with null optional fields', () => {
const guild = {
...validGuild,
icon: null,
banner: null,
splash: null,
vanity_url_code: null,
system_channel_id: null,
rules_channel_id: null,
afk_channel_id: null,
permissions: null,
};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(true);
});
it('accepts all splash_card_alignment values', () => {
for (const alignment of [
GuildSplashCardAlignment.LEFT,
GuildSplashCardAlignment.CENTER,
GuildSplashCardAlignment.RIGHT,
]) {
const guild = {...validGuild, splash_card_alignment: alignment};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(true);
}
});
it('rejects invalid splash_card_alignment', () => {
const guild = {...validGuild, splash_card_alignment: 'invalid'};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(false);
});
it('rejects missing required fields', () => {
const {id, ...guildWithoutId} = validGuild;
const result = GuildResponse.safeParse(guildWithoutId);
expect(result.success).toBe(false);
});
it('requires owner_id', () => {
const {owner_id, ...guildWithoutOwner} = validGuild;
const result = GuildResponse.safeParse(guildWithoutOwner);
expect(result.success).toBe(false);
});
it('requires name', () => {
const {name, ...guildWithoutName} = validGuild;
const result = GuildResponse.safeParse(guildWithoutName);
expect(result.success).toBe(false);
});
it('requires features array', () => {
const {features, ...guildWithoutFeatures} = validGuild;
const result = GuildResponse.safeParse(guildWithoutFeatures);
expect(result.success).toBe(false);
});
it('accepts empty features array', () => {
const guild = {...validGuild, features: []};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(true);
});
it('preserves unknown features in guild response', () => {
const guild = {
...validGuild,
features: [GuildFeatures.VERIFIED, 'DISALLOW_UNCLAIMED_ACCOUNTS'],
};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
}
});
it('deduplicates and sorts guild features while preserving values', () => {
const guild = {
...validGuild,
features: [GuildFeatures.VERIFIED, 'DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED],
};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
}
});
it('rejects non-integer system_channel_flags', () => {
const guild = {...validGuild, system_channel_flags: 1.5};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(false);
});
it('rejects non-integer verification_level', () => {
const guild = {...validGuild, verification_level: 'high'};
const result = GuildResponse.safeParse(guild);
expect(result.success).toBe(false);
});
});
describe('GuildPartialResponse', () => {
const validPartialGuild = {
id: '123456789012345678',
name: 'Test Guild',
icon: 'icon_hash',
banner: 'banner_hash',
banner_width: 1920,
banner_height: 1080,
splash: 'splash_hash',
splash_width: 1920,
splash_height: 1080,
splash_card_alignment: GuildSplashCardAlignment.CENTER,
embed_splash: 'embed_splash_hash',
embed_splash_width: 800,
embed_splash_height: 600,
features: [GuildFeatures.VERIFIED],
};
it('accepts valid partial guild response', () => {
const result = GuildPartialResponse.safeParse(validPartialGuild);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('123456789012345678');
expect(result.data.name).toBe('Test Guild');
}
});
it('accepts partial guild with null optional fields', () => {
const guild = {
...validPartialGuild,
icon: null,
banner: null,
splash: null,
embed_splash: null,
};
const result = GuildPartialResponse.safeParse(guild);
expect(result.success).toBe(true);
});
it('requires id', () => {
const {id, ...guildWithoutId} = validPartialGuild;
const result = GuildPartialResponse.safeParse(guildWithoutId);
expect(result.success).toBe(false);
});
it('requires name', () => {
const {name, ...guildWithoutName} = validPartialGuild;
const result = GuildPartialResponse.safeParse(guildWithoutName);
expect(result.success).toBe(false);
});
it('requires features array', () => {
const {features, ...guildWithoutFeatures} = validPartialGuild;
const result = GuildPartialResponse.safeParse(guildWithoutFeatures);
expect(result.success).toBe(false);
});
it('preserves unknown features in partial guild response', () => {
const guild = {
...validPartialGuild,
features: [GuildFeatures.VERIFIED, 'DISALLOW_UNCLAIMED_ACCOUNTS'],
};
const result = GuildPartialResponse.safeParse(guild);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
}
});
});
describe('GuildVanityURLResponse', () => {
it('accepts valid vanity URL response', () => {
const result = GuildVanityURLResponse.safeParse({
code: 'myserver',
uses: 42,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.code).toBe('myserver');
expect(result.data.uses).toBe(42);
}
});
it('accepts null code', () => {
const result = GuildVanityURLResponse.safeParse({
code: null,
uses: 0,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.code).toBeNull();
}
});
it('requires uses field', () => {
const result = GuildVanityURLResponse.safeParse({
code: 'myserver',
});
expect(result.success).toBe(false);
});
it('requires uses to be integer', () => {
const result = GuildVanityURLResponse.safeParse({
code: 'myserver',
uses: 1.5,
});
expect(result.success).toBe(false);
});
it('accepts zero uses', () => {
const result = GuildVanityURLResponse.safeParse({
code: 'myserver',
uses: 0,
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,233 @@
/*
* 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 {
CustomStatusResponse,
RelationshipResponse,
UserPartialResponse,
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {describe, expect, it} from 'vitest';
describe('UserPartialResponse', () => {
const validUser = {
id: '123456789012345678',
username: 'testuser',
discriminator: '0001',
global_name: 'Test User',
avatar: 'avatar_hash',
avatar_color: 0xff5500,
flags: 0,
};
it('accepts valid user partial response', () => {
const result = UserPartialResponse.safeParse(validUser);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('123456789012345678');
expect(result.data.username).toBe('testuser');
}
});
it('accepts null global_name and avatar', () => {
const user = {
...validUser,
global_name: null,
avatar: null,
avatar_color: null,
};
const result = UserPartialResponse.safeParse(user);
expect(result.success).toBe(true);
});
it('accepts optional bot flag', () => {
const user = {...validUser, bot: true};
const result = UserPartialResponse.safeParse(user);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.bot).toBe(true);
}
});
it('accepts optional system flag', () => {
const user = {...validUser, system: true};
const result = UserPartialResponse.safeParse(user);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.system).toBe(true);
}
});
it('requires id', () => {
const {id, ...userWithoutId} = validUser;
const result = UserPartialResponse.safeParse(userWithoutId);
expect(result.success).toBe(false);
});
it('requires username', () => {
const {username, ...userWithoutUsername} = validUser;
const result = UserPartialResponse.safeParse(userWithoutUsername);
expect(result.success).toBe(false);
});
it('requires discriminator', () => {
const {discriminator, ...userWithoutDiscriminator} = validUser;
const result = UserPartialResponse.safeParse(userWithoutDiscriminator);
expect(result.success).toBe(false);
});
it('requires flags', () => {
const {flags, ...userWithoutFlags} = validUser;
const result = UserPartialResponse.safeParse(userWithoutFlags);
expect(result.success).toBe(false);
});
});
describe('CustomStatusResponse', () => {
it('accepts valid custom status', () => {
const result = CustomStatusResponse.safeParse({
text: 'Working on a project',
expires_at: '2024-01-15T18:00:00.000Z',
emoji_id: '123456789012345678',
emoji_name: 'custom_emoji',
emoji_animated: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.text).toBe('Working on a project');
}
});
it('accepts minimal custom status', () => {
const result = CustomStatusResponse.safeParse({
emoji_animated: false,
});
expect(result.success).toBe(true);
});
it('accepts null optional fields', () => {
const result = CustomStatusResponse.safeParse({
text: null,
expires_at: null,
emoji_id: null,
emoji_name: null,
emoji_animated: false,
});
expect(result.success).toBe(true);
});
it('accepts unicode emoji name', () => {
const result = CustomStatusResponse.safeParse({
emoji_name: '\uD83D\uDE00',
emoji_animated: false,
});
expect(result.success).toBe(true);
});
it('requires emoji_animated', () => {
const result = CustomStatusResponse.safeParse({
text: 'Hello',
});
expect(result.success).toBe(false);
});
it('accepts animated emoji', () => {
const result = CustomStatusResponse.safeParse({
emoji_id: '123456789012345678',
emoji_name: 'animated_emoji',
emoji_animated: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.emoji_animated).toBe(true);
}
});
});
describe('RelationshipResponse', () => {
const validRelationship = {
id: '123456789012345678',
type: 1,
user: {
id: '987654321098765432',
username: 'friend',
discriminator: '0001',
global_name: 'Friend',
avatar: null,
avatar_color: null,
flags: 0,
},
nickname: 'Best Friend',
};
it('accepts valid relationship', () => {
const result = RelationshipResponse.safeParse(validRelationship);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('123456789012345678');
expect(result.data.type).toBe(1);
}
});
it('accepts relationship with null nickname', () => {
const relationship = {...validRelationship, nickname: null};
const result = RelationshipResponse.safeParse(relationship);
expect(result.success).toBe(true);
});
it('accepts relationship with since date', () => {
const relationship = {...validRelationship, since: '2024-01-01T00:00:00.000Z'};
const result = RelationshipResponse.safeParse(relationship);
expect(result.success).toBe(true);
});
it('requires id', () => {
const {id, ...relationshipWithoutId} = validRelationship;
const result = RelationshipResponse.safeParse(relationshipWithoutId);
expect(result.success).toBe(false);
});
it('requires type', () => {
const {type, ...relationshipWithoutType} = validRelationship;
const result = RelationshipResponse.safeParse(relationshipWithoutType);
expect(result.success).toBe(false);
});
it('requires user', () => {
const {user, ...relationshipWithoutUser} = validRelationship;
const result = RelationshipResponse.safeParse(relationshipWithoutUser);
expect(result.success).toBe(false);
});
it('validates nested user object', () => {
const relationship = {
...validRelationship,
user: {id: 'invalid'},
};
const result = RelationshipResponse.safeParse(relationship);
expect(result.success).toBe(false);
});
it('accepts different relationship types', () => {
for (const type of [0, 1, 2, 3, 4]) {
const relationship = {...validRelationship, type};
const result = RelationshipResponse.safeParse(relationship);
expect(result.success).toBe(true);
}
});
});

View File

@@ -0,0 +1,33 @@
/*
* 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 {HexString16Type} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ThemeCreateRequest = z.object({
css: z.string().min(1).describe('CSS text to store and share'),
});
export type ThemeCreateRequest = z.infer<typeof ThemeCreateRequest>;
export const ThemeCreateResponse = z.object({
id: HexString16Type.describe('The unique identifier for the created theme'),
});
export type ThemeCreateResponse = z.infer<typeof ThemeCreateResponse>;

View File

@@ -0,0 +1,77 @@
/*
* 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 {
createNamedStringLiteralUnion,
createStringType,
SnowflakeStringType,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const HarvestStatusEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
['pending', 'pending', 'The harvest job is waiting to be processed'],
['processing', 'processing', 'The harvest job is currently being processed'],
['completed', 'completed', 'The harvest job has finished successfully'],
['failed', 'failed', 'The harvest job encountered an error and could not complete'],
],
'Current status of the harvest request',
),
'HarvestStatus',
);
export const HarvestCreationResponseSchema = z.object({
harvest_id: SnowflakeStringType.describe('Unique identifier for the harvest request'),
status: HarvestStatusEnum,
created_at: z.string().describe('ISO 8601 timestamp when the harvest request was created'),
});
export const HarvestStatusResponseSchema = HarvestCreationResponseSchema.extend({
started_at: z.string().nullable().describe('ISO 8601 timestamp when the harvest started, or null if pending'),
completed_at: z.string().nullable().describe('ISO 8601 timestamp when the harvest completed, or null otherwise'),
failed_at: z.string().nullable().describe('ISO 8601 timestamp when the harvest failed, or null otherwise'),
file_size: z
.string()
.nullable()
.describe('Final file size of the downloaded data, expressed as a string, or null if not available'),
progress_percent: z.number().describe('Harvest progress as a percentage value between 0 and 100'),
progress_step: z.string().nullable().describe('Textual description of the current harvest step, if available'),
error_message: z.string().nullable().describe('Error message when the harvest fails, or null otherwise'),
download_url_expires_at: z
.string()
.nullable()
.describe('ISO 8601 timestamp when the download URL expires, or null if unavailable'),
expires_at: z
.string()
.nullable()
.describe('ISO 8601 timestamp when the harvest download expires, or null if unavailable'),
});
export type HarvestCreationResponse = z.infer<typeof HarvestCreationResponseSchema>;
export type HarvestStatusResponse = z.infer<typeof HarvestStatusResponseSchema>;
export const HarvestStatusResponseSchemaNullable = HarvestStatusResponseSchema.nullable();
export const HarvestDownloadUrlResponse = z.object({
download_url: createStringType(1, 2048).describe('The presigned URL to download the harvest archive'),
expires_at: z.string().describe('ISO 8601 timestamp when the harvest download expires'),
});
export type HarvestDownloadUrlResponse = z.infer<typeof HarvestDownloadUrlResponse>;

View File

@@ -0,0 +1,444 @@
/*
* 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>;

View File

@@ -0,0 +1,521 @@
/*
* 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 {
DEFAULT_GUILD_FOLDER_ICON,
FriendSourceFlags,
FriendSourceFlagsDescriptions,
GroupDmAddPermissionFlags,
GroupDmAddPermissionFlagsDescriptions,
GuildFolderFlags,
GuildFolderFlagsDescriptions,
GuildFolderIcons,
IncomingCallFlags,
IncomingCallFlagsDescriptions,
PublicUserFlags,
PublicUserFlagsDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {ConnectionResponse} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
import {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {LocaleSchema} from '@fluxer/schema/src/primitives/LocaleSchema';
import {
createBitflagInt32Type,
createNamedStringLiteralUnion,
createStringType,
HexString32Type,
Int32Type,
SignedInt32Type,
SnowflakeStringType,
withFieldDescription,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {
RelationshipTypesSchema,
RenderSpoilersSchema,
StickerAnimationOptionsSchema,
TimeFormatTypesSchema,
UserAuthenticatorTypesSchema,
UserNotificationSettingsSchema,
UserPremiumTypesSchema,
} from '@fluxer/schema/src/primitives/UserSettingsValidators';
import {z} from 'zod';
export const UserPartialResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier (snowflake) for this user'),
username: z.string().describe('The username of the user, not unique across the platform'),
discriminator: z.string().describe('The four-digit discriminator tag of the user'),
global_name: z.string().nullable().describe('The display name of the user, if set'),
avatar: z.string().nullable().describe('The hash of the user avatar image'),
avatar_color: Int32Type.nullable().describe('The dominant avatar color of the user as an integer'),
bot: z.boolean().optional().describe('Whether the user is a bot account'),
system: z.boolean().optional().describe('Whether the user is an official system user'),
flags: createBitflagInt32Type(
PublicUserFlags,
PublicUserFlagsDescriptions,
'The public flags on the user account',
'PublicUserFlags',
),
});
export type UserPartialResponse = z.infer<typeof UserPartialResponse>;
export const UserPrivateResponse = UserPartialResponse.extend({
is_staff: z.boolean().describe('Whether the user has staff permissions'),
acls: z.array(z.string()).max(100).describe('Access control list entries for the user'),
traits: z.array(z.string()).max(100).describe('Special traits assigned to the user account'),
email: z.string().nullable().describe('The email address associated with the account'),
email_bounced: z
.boolean()
.optional()
.describe('Whether the current email address is marked as bounced by the mail provider'),
phone: z.string().nullable().describe('The phone number associated with the account'),
bio: z.string().nullable().describe('The user biography text'),
pronouns: z.string().nullable().describe('The preferred pronouns of the user'),
accent_color: Int32Type.nullable().describe('The user-selected accent color as an integer'),
banner: z.string().nullable().describe('The hash of the user profile banner image'),
banner_color: Int32Type.nullable().describe('The default banner color if no custom banner is set'),
mfa_enabled: z.boolean().describe('Whether multi-factor authentication is enabled'),
authenticator_types: z
.array(UserAuthenticatorTypesSchema)
.max(10)
.optional()
.describe('The types of authenticators configured for MFA'),
verified: z.boolean().describe('Whether the email address has been verified'),
premium_type: withFieldDescription(UserPremiumTypesSchema, 'The type of premium subscription').nullable(),
premium_since: z.string().nullable().describe('ISO8601 timestamp of when premium was first activated'),
premium_until: z.string().nullable().describe('ISO8601 timestamp of when the current premium period ends'),
premium_will_cancel: z.boolean().describe('Whether premium is set to cancel at the end of the billing period'),
premium_billing_cycle: z.string().nullable().describe('The billing cycle for the premium subscription'),
premium_lifetime_sequence: Int32Type.nullable().describe('The sequence number for lifetime premium subscribers'),
premium_badge_hidden: z.boolean().describe('Whether the premium badge is hidden on the profile'),
premium_badge_masked: z.boolean().describe('Whether the premium badge shows a masked appearance'),
premium_badge_timestamp_hidden: z.boolean().describe('Whether the premium start timestamp is hidden'),
premium_badge_sequence_hidden: z.boolean().describe('Whether the lifetime sequence number is hidden'),
premium_purchase_disabled: z.boolean().describe('Whether premium purchases are disabled for this account'),
premium_enabled_override: z.boolean().describe('Whether premium features are enabled via override'),
password_last_changed_at: z.string().nullable().describe('ISO8601 timestamp of the last password change'),
required_actions: z
.array(z.string())
.max(20)
.nullable()
.describe('Actions the user must complete before full access'),
nsfw_allowed: z.boolean().describe('Whether the user is allowed to view NSFW content'),
has_dismissed_premium_onboarding: z.boolean().describe('Whether the user has dismissed the premium onboarding flow'),
has_ever_purchased: z.boolean().describe('Whether the user has ever made a purchase'),
has_unread_gift_inventory: z.boolean().describe('Whether there are unread items in the gift inventory'),
unread_gift_inventory_count: Int32Type.describe('The number of unread gift inventory items'),
used_mobile_client: z.boolean().describe('Whether the user has ever used the mobile client'),
pending_bulk_message_deletion: z
.object({
scheduled_at: z.string().describe('ISO8601 timestamp of when the deletion was scheduled'),
channel_count: Int32Type.describe('The number of channels with messages to delete'),
message_count: Int32Type.describe('The total number of messages to delete'),
})
.nullable()
.describe('Information about a pending bulk message deletion request'),
});
export type UserPrivateResponse = z.infer<typeof UserPrivateResponse>;
export const EmailChangeStartResponse = z.object({
ticket: z.string().describe('Ticket returned for email change actions'),
require_original: z.boolean().describe('Whether verification of the original email is required'),
original_email: z.string().nullable().describe('The original email address on record'),
original_proof: z
.string()
.nullable()
.describe('Proof token generated when original email verification is not required'),
original_code_expires_at: z
.string()
.nullable()
.describe('ISO8601 timestamp when the original verification code expires'),
resend_available_at: z
.string()
.nullable()
.describe('ISO8601 timestamp when the original verification code can be resent'),
});
export type EmailChangeStartResponse = z.infer<typeof EmailChangeStartResponse>;
export const EmailChangeVerifyOriginalResponse = z.object({
original_proof: z.string().describe('Proof token issued after verifying the original email'),
});
export type EmailChangeVerifyOriginalResponse = z.infer<typeof EmailChangeVerifyOriginalResponse>;
export const EmailChangeRequestNewResponse = z.object({
ticket: z.string().describe('Ticket associated with the email change attempt'),
new_email: z.string().describe('The new email address the user wants to verify'),
new_code_expires_at: z.string().describe('ISO8601 timestamp when the new email code expires'),
resend_available_at: z.string().nullable().describe('ISO8601 timestamp when the new email code can be resent'),
});
export type EmailChangeRequestNewResponse = z.infer<typeof EmailChangeRequestNewResponse>;
export const PasswordChangeStartResponse = z.object({
ticket: z.string().describe('Ticket for password change actions'),
code_expires_at: z.string().describe('ISO8601 timestamp when the verification code expires'),
resend_available_at: z.string().nullable().describe('ISO8601 timestamp when the code can be resent'),
});
export type PasswordChangeStartResponse = z.infer<typeof PasswordChangeStartResponse>;
export const PasswordChangeVerifyResponse = z.object({
verification_proof: z.string().describe('Proof token issued after verifying the email code'),
});
export type PasswordChangeVerifyResponse = z.infer<typeof PasswordChangeVerifyResponse>;
export interface UserProfileResponse {
bio: string | null;
pronouns: string | null;
banner: string | null;
banner_color?: number | null;
accent_color: number | null;
}
export const CustomStatusResponse = z.object({
text: z.string().nullish().describe('The custom status message text'),
expires_at: z.iso.datetime().nullish().describe('ISO8601 timestamp of when the custom status expires'),
emoji_id: SnowflakeStringType.nullish().describe('The ID of the custom emoji used in the status'),
emoji_name: z.string().nullish().describe('The name of the emoji used in the status'),
emoji_animated: z.boolean().describe('Whether the status emoji is animated'),
});
export type CustomStatusResponse = z.infer<typeof CustomStatusResponse>;
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 UserSettingsResponse = z.object({
status: z.string().describe('The current online status of the user'),
status_resets_at: z.iso.datetime().nullish().describe('ISO8601 timestamp of when the status will reset'),
status_resets_to: z.string().nullish().describe('The status to reset to after the scheduled reset'),
theme: z.string().describe('The UI theme preference'),
locale: LocaleSchema,
restricted_guilds: z.array(SnowflakeStringType).max(200).describe('Guild IDs where direct messages are restricted'),
bot_restricted_guilds: z
.array(SnowflakeStringType)
.max(200)
.describe('Guild IDs where bot direct messages are restricted'),
default_guilds_restricted: z.boolean().describe('Whether new guilds have DM restrictions by default'),
bot_default_guilds_restricted: z.boolean().describe('Whether new guilds have bot DM restrictions by default'),
inline_attachment_media: z.boolean().describe('Whether to display attachments inline in chat'),
inline_embed_media: z.boolean().describe('Whether to display embed media inline in chat'),
gif_auto_play: z.boolean().describe('Whether GIFs auto-play in chat'),
render_embeds: z.boolean().describe('Whether to render message embeds'),
render_reactions: z.boolean().describe('Whether to display reactions on messages'),
animate_emoji: z.boolean().describe('Whether to animate custom emoji'),
animate_stickers: withFieldDescription(StickerAnimationOptionsSchema, 'Sticker animation preference setting'),
render_spoilers: withFieldDescription(RenderSpoilersSchema, 'Spoiler rendering preference setting'),
message_display_compact: z.boolean().describe('Whether to use compact message display mode'),
friend_source_flags: createBitflagInt32Type(
FriendSourceFlags,
FriendSourceFlagsDescriptions,
'Bitfield for friend request source permissions',
'FriendSourceFlags',
),
incoming_call_flags: createBitflagInt32Type(
IncomingCallFlags,
IncomingCallFlagsDescriptions,
'Bitfield for incoming call notification settings',
'IncomingCallFlags',
),
group_dm_add_permission_flags: createBitflagInt32Type(
GroupDmAddPermissionFlags,
GroupDmAddPermissionFlagsDescriptions,
'Bitfield for group DM add permissions',
'GroupDmAddPermissionFlags',
),
guild_folders: z
.array(
z.object({
id: SignedInt32Type.nullish().describe('The unique identifier for the folder (-1 for uncategorized)'),
name: z.string().nullish().describe('The display name of the folder'),
color: Int32Type.nullish().describe('The color of the folder as an 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(SnowflakeStringType).max(200).describe('The IDs of guilds contained in this folder'),
}),
)
.max(200)
.describe('The folder structure for organizing guilds in the sidebar'),
custom_status: CustomStatusResponse.nullable().describe('The custom status set by the user'),
afk_timeout: Int32Type.describe('The idle timeout in seconds before going AFK'),
time_format: withFieldDescription(TimeFormatTypesSchema, 'The preferred time format setting'),
developer_mode: z.boolean().describe('Whether developer mode is enabled'),
trusted_domains: z.array(z.string()).max(1000).describe('List of trusted external link domains'),
default_hide_muted_channels: z.boolean().describe('Whether muted channels are hidden by default in new guilds'),
});
export type UserSettingsResponse = z.infer<typeof UserSettingsResponse>;
const UserGuildMuteConfig = z
.object({
end_time: z.string().nullable().describe('ISO8601 timestamp of when the mute expires'),
selected_time_window: Int32Type.describe('The selected mute duration in seconds'),
})
.nullable();
const UserGuildChannelOverride = z.object({
collapsed: z.boolean().describe('Whether the channel category is collapsed in the sidebar'),
message_notifications: withFieldDescription(
UserNotificationSettingsSchema,
'The notification level override for this channel',
),
muted: z.boolean().describe('Whether notifications are muted for this channel'),
mute_config: UserGuildMuteConfig.describe('The mute configuration for this channel'),
});
export const UserGuildSettingsResponse = z.object({
guild_id: SnowflakeStringType.nullable().describe('The ID of the guild these settings apply to'),
message_notifications: withFieldDescription(
UserNotificationSettingsSchema,
'The default notification level for the guild',
),
muted: z.boolean().describe('Whether the guild is muted'),
mute_config: UserGuildMuteConfig.describe('The mute configuration for the guild'),
mobile_push: z.boolean().describe('Whether mobile push notifications are enabled'),
suppress_everyone: z.boolean().describe('Whether @everyone mentions are suppressed'),
suppress_roles: z.boolean().describe('Whether role mentions are suppressed'),
hide_muted_channels: z.boolean().describe('Whether muted channels are hidden in the sidebar'),
channel_overrides: z
.record(SnowflakeStringType, UserGuildChannelOverride)
.nullable()
.describe('Per-channel notification overrides'),
version: Int32Type.describe('The version number of these settings for sync'),
});
export type UserGuildSettingsResponse = z.infer<typeof UserGuildSettingsResponse>;
export const RelationshipResponse = z.object({
id: SnowflakeStringType.describe('The unique identifier for the relationship'),
type: withFieldDescription(RelationshipTypesSchema, 'The type of relationship (friend, blocked, pending, etc.)'),
user: z.lazy(() => UserPartialResponse).describe('The user involved in this relationship'),
since: z.iso.datetime().optional().describe('ISO8601 timestamp of when the relationship was established'),
nickname: z.string().nullable().describe('A custom nickname set for the related user'),
});
export type RelationshipResponse = z.infer<typeof RelationshipResponse>;
export const RelationshipListResponse = z
.array(RelationshipResponse)
.max(10000)
.describe('A list of user relationships');
export type RelationshipListResponse = z.infer<typeof RelationshipListResponse>;
export type RequiredAction =
| 'REQUIRE_VERIFIED_EMAIL'
| 'REQUIRE_REVERIFIED_EMAIL'
| 'REQUIRE_VERIFIED_PHONE'
| 'REQUIRE_REVERIFIED_PHONE'
| 'REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE'
| 'REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE'
| 'REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE'
| 'REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE';
export interface BackupCode {
readonly code: string;
readonly consumed: boolean;
}
export interface PendingBulkMessageDeletion {
readonly scheduled_at: string;
readonly channel_count: number;
readonly message_count: number;
}
export interface UserProfile {
readonly bio: string | null;
readonly banner: string | null;
readonly banner_color?: number | null;
readonly pronouns: string | null;
readonly accent_color: number | null;
}
export interface UserPartial {
readonly id: string;
readonly username: string;
readonly discriminator: string;
readonly global_name: string | null;
readonly avatar: string | null;
readonly avatar_color: number | null;
readonly bot?: boolean;
readonly system?: boolean;
readonly flags: number;
}
export interface UserPrivate extends UserPartial, UserProfile {
readonly is_staff: boolean;
readonly email: string | null;
readonly email_bounced?: boolean;
readonly mfa_enabled: boolean;
readonly phone: string | null;
readonly authenticator_types: ReadonlyArray<number>;
readonly verified: boolean;
readonly premium_type: number | null;
readonly premium_since: string | null;
readonly premium_until: string | null;
readonly premium_will_cancel: boolean;
readonly premium_billing_cycle: string | null;
readonly premium_lifetime_sequence: number | null;
readonly premium_badge_hidden: boolean;
readonly premium_badge_masked: boolean;
readonly premium_badge_timestamp_hidden: boolean;
readonly premium_badge_sequence_hidden: boolean;
readonly premium_purchase_disabled: boolean;
readonly premium_enabled_override: boolean;
readonly password_last_changed_at: string | null;
readonly required_actions: ReadonlyArray<RequiredAction> | null;
readonly nsfw_allowed: boolean;
readonly pending_bulk_message_deletion: PendingBulkMessageDeletion | null;
readonly has_dismissed_premium_onboarding: boolean;
readonly has_ever_purchased: boolean;
readonly has_unread_gift_inventory: boolean;
readonly unread_gift_inventory_count: number;
readonly used_mobile_client: boolean;
readonly traits: ReadonlyArray<string>;
}
export type User = UserPartial & Partial<UserPrivate>;
export const SavedMessageStatusSchema = withOpenApiType(
createNamedStringLiteralUnion(
[
['available', 'Available', 'The saved message is available and can be retrieved'],
['missing_permissions', 'Missing Permissions', 'The user no longer has permission to view the message'],
],
'Availability status of a saved message',
),
'SavedMessageStatus',
);
export type SavedMessageStatus = z.infer<typeof SavedMessageStatusSchema>;
export const SavedMessageEntryResponse = z.object({
id: SnowflakeStringType.describe('Unique identifier for the saved message entry'),
channel_id: SnowflakeStringType.describe('ID of the channel containing the message'),
message_id: SnowflakeStringType.describe('ID of the saved message'),
status: SavedMessageStatusSchema.describe('Availability status of the saved message'),
message: MessageResponseSchema.nullable().describe('The message content if available'),
});
export type SavedMessageEntryResponse = z.infer<typeof SavedMessageEntryResponse>;
export const SavedMessageEntryListResponse = z.array(SavedMessageEntryResponse);
export type SavedMessageEntryListResponse = z.infer<typeof SavedMessageEntryListResponse>;
export const EmailTokenResponse = z.object({
email_token: createStringType(1, 256).describe('The email change token to use for updating email'),
});
export type EmailTokenResponse = z.infer<typeof EmailTokenResponse>;
export const UserTagCheckResponse = z.object({
taken: z.boolean().describe('Whether the username/discriminator combination is already taken'),
});
export type UserTagCheckResponse = z.infer<typeof UserTagCheckResponse>;
export const UserProfileDataResponse = z.object({
bio: z.string().nullable().describe('User biography text'),
pronouns: z.string().nullable().describe('User pronouns'),
banner: z.string().nullable().describe('Hash of the profile banner image'),
banner_color: Int32Type.nullable().optional().describe('Default banner color if no custom banner'),
accent_color: Int32Type.nullable().describe('User-selected accent color'),
});
export type UserProfileDataResponse = z.infer<typeof UserProfileDataResponse>;
export const GuildMemberProfileDataResponse = z
.object({
bio: z.string().nullable().describe('Guild-specific biography text'),
pronouns: z.string().nullable().describe('Guild-specific pronouns'),
banner: z.string().nullable().describe('Hash of the guild-specific banner image'),
accent_color: Int32Type.nullable().describe('Guild-specific accent color'),
})
.nullable()
.optional();
export type GuildMemberProfileDataResponse = z.infer<typeof GuildMemberProfileDataResponse>;
export const MutualGuildResponse = z.object({
id: SnowflakeStringType.describe('The ID of the mutual guild'),
nick: z.string().nullable().describe('The nickname of the target user in this guild'),
});
export type MutualGuildResponse = z.infer<typeof MutualGuildResponse>;
export const UserProfileFullResponse = z.object({
user: UserPartialResponse.describe('The user object'),
user_profile: UserProfileDataResponse.describe('The user profile data'),
guild_member: z
.lazy(() => GuildMemberResponse)
.optional()
.describe('The guild member data if guild_id was provided'),
guild_member_profile: GuildMemberProfileDataResponse.describe('Guild-specific profile data'),
premium_type: withFieldDescription(UserPremiumTypesSchema, 'The type of premium subscription').optional(),
premium_since: z.string().optional().describe('ISO8601 timestamp of when premium was activated'),
premium_lifetime_sequence: Int32Type.optional().describe('Sequence number for lifetime premium'),
mutual_friends: z.array(UserPartialResponse).optional().describe('Array of mutual friends'),
mutual_guilds: z.array(MutualGuildResponse).optional().describe('Array of mutual guilds'),
connected_accounts: z.array(ConnectionResponse).optional().describe('Array of verified external connections'),
});
export type UserProfileFullResponse = z.infer<typeof UserProfileFullResponse>;
export const UserNotesRecordResponse = z
.record(SnowflakeStringType, z.string())
.describe('A map of user IDs to note text');
export type UserNotesRecordResponse = z.infer<typeof UserNotesRecordResponse>;
export const UserNoteResponse = z.object({
note: z.string().describe('The note text for this user'),
});
export type UserNoteResponse = z.infer<typeof UserNoteResponse>;
export const PushSubscribeResponse = z.object({
subscription_id: HexString32Type.describe('The unique identifier for the push subscription'),
});
export type PushSubscribeResponse = z.infer<typeof PushSubscribeResponse>;
export const PushSubscriptionItemResponse = z.object({
subscription_id: HexString32Type.describe('The unique identifier for the push subscription'),
user_agent: z.string().nullable().describe('The user agent that registered this subscription'),
});
export type PushSubscriptionItemResponse = z.infer<typeof PushSubscriptionItemResponse>;
export const PushSubscriptionsListResponse = z.object({
subscriptions: z.array(PushSubscriptionItemResponse).describe('Array of push subscriptions'),
});
export type PushSubscriptionsListResponse = z.infer<typeof PushSubscriptionsListResponse>;
export const PreloadMessagesResponse = z
.record(SnowflakeStringType, MessageResponseSchema.nullable())
.describe('A map of channel IDs to the latest message in each channel');
export type PreloadMessagesResponse = z.infer<typeof PreloadMessagesResponse>;

View File

@@ -0,0 +1,144 @@
/*
* 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 {createStringType, Int32Type, Int64Type} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {URLType, URLWithFragmentType} from '@fluxer/schema/src/primitives/UrlValidators';
import {z} from 'zod';
const GitHubUser = z.object({
id: Int32Type,
login: createStringType(0, 152133),
html_url: URLType,
avatar_url: URLType,
});
const GitHubCheckPullRequest = z.object({
number: Int32Type,
});
const GitHubCheckApp = z.object({
name: createStringType(0, 152133),
});
const GitHubCheckSuite = z.object({
conclusion: createStringType(0, 152133).nullish(),
head_branch: createStringType(0, 152133).nullish(),
head_sha: createStringType(0, 152133),
pull_requests: z.array(GitHubCheckPullRequest).nullish(),
app: GitHubCheckApp,
});
const GitHubCheckRunOutput = z.object({
title: createStringType(0, 152133).nullish(),
summary: createStringType(0, 152133).nullish(),
});
const GitHubAuthor = z.object({
username: createStringType(0, 152133).nullish(),
name: createStringType(0, 152133),
});
const GitHubCheckRun = z.object({
conclusion: createStringType(0, 152133).nullish(),
name: createStringType(0, 152133),
html_url: URLType,
check_suite: GitHubCheckSuite,
details_url: URLType.nullish(),
output: GitHubCheckRunOutput.nullish(),
pull_requests: z.array(GitHubCheckPullRequest).nullish(),
});
const GitHubComment = z.object({
id: Int64Type,
html_url: URLWithFragmentType,
user: GitHubUser,
commit_id: createStringType(0, 152133).nullish(),
body: createStringType(0, 152133),
});
const GitHubCommit = z.object({
id: createStringType(0, 152133),
url: URLType,
message: createStringType(0, 152133),
author: GitHubAuthor,
});
const GitHubDiscussion = z.object({
title: createStringType(0, 152133),
number: Int32Type,
html_url: URLType,
answer_html_url: URLWithFragmentType.nullish(),
body: createStringType(0, 152133).nullish(),
user: GitHubUser,
});
const GitHubIssue = z.object({
id: Int64Type,
number: Int32Type,
html_url: URLType,
user: GitHubUser,
title: createStringType(0, 152133),
body: createStringType(0, 152133).nullish(),
});
const GitHubRelease = z.object({
id: Int32Type,
tag_name: createStringType(0, 152133),
html_url: URLType,
body: createStringType(0, 152133).nullish(),
});
const GitHubService = z.object({
id: Int32Type,
html_url: URLType,
name: createStringType(0, 152133),
full_name: createStringType(0, 152133),
});
const GitHubReview = z.object({
user: GitHubUser,
body: createStringType(0, 152133).nullish(),
html_url: URLType,
state: createStringType(0, 152133),
});
export const GitHubWebhook = z.object({
action: createStringType(0, 152133).nullish(),
answer: GitHubComment.nullish(),
check_run: GitHubCheckRun.nullish(),
check_suite: GitHubCheckSuite.nullish(),
comment: GitHubComment.nullish(),
commits: z.array(GitHubCommit).nullish(),
compare: URLType.nullish(),
discussion: GitHubDiscussion.nullish(),
forced: z.boolean().nullish(),
forkee: GitHubService.nullish(),
head_commit: GitHubCommit.nullish(),
issue: GitHubIssue.nullish(),
member: GitHubUser.nullish(),
pull_request: GitHubIssue.nullish(),
ref_type: createStringType(0, 152133).nullish(),
ref: createStringType(0, 152133).nullish(),
release: GitHubRelease.nullish(),
repository: GitHubService.nullish(),
review: GitHubReview.nullish(),
sender: GitHubUser,
});
export type GitHubWebhook = z.infer<typeof GitHubWebhook>;

View File

@@ -0,0 +1,75 @@
/*
* 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 {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {URLType} from '@fluxer/schema/src/primitives/UrlValidators';
import {z} from 'zod';
const SentryProject = z.object({
id: createStringType(0, 152133),
name: createStringType(0, 152133),
slug: createStringType(0, 152133),
platform: createStringType(0, 152133),
});
const SentryMetadata = z.object({
value: createStringType(0, 4096),
type: createStringType(0, 152133),
});
const SentryIssue = z.object({
id: createStringType(0, 152133),
shortId: createStringType(0, 152133),
title: createStringType(0, 512),
culprit: createStringType(0, 512).optional(),
permalink: URLType,
level: createStringType(0, 64),
status: createStringType(0, 64),
platform: createStringType(0, 64),
project: SentryProject,
type: createStringType(0, 64),
metadata: SentryMetadata.loose(),
count: createStringType(0, 64),
userCount: z.number(),
firstSeen: createStringType(0, 64),
lastSeen: createStringType(0, 64),
});
const SentryInstallation = z.object({
uuid: createStringType(0, 152133),
});
const SentryActor = z.object({
type: createStringType(0, 64),
id: createStringType(0, 152133),
name: createStringType(0, 152133),
});
const SentryIssueData = z.object({
issue: SentryIssue,
});
export const SentryWebhook = z.object({
action: createStringType(0, 64).nullish(),
installation: SentryInstallation.nullish(),
data: SentryIssueData.nullish(),
actor: SentryActor.nullish(),
});
export type SentryWebhook = z.infer<typeof SentryWebhook>;

View File

@@ -0,0 +1,170 @@
/*
* 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 {
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
MessageFlags,
MessageFlagsDescriptions,
} from '@fluxer/constants/src/ChannelConstants';
import {AVATAR_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
import {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {AllowedMentionsRequest, MessageReferenceRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
coerceNumberFromString,
createBitflagInt32Type,
createStringType,
createUnboundedStringType,
Int32Type,
SnowflakeType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {URLType} from '@fluxer/schema/src/primitives/UrlValidators';
import {WebhookNameType} from '@fluxer/schema/src/primitives/UserValidators';
import {z} from 'zod';
export const WebhookCreateRequest = z.object({
name: WebhookNameType.describe('The name of the webhook'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('The avatar image as a base64-encoded data URI'),
});
export type WebhookCreateRequest = z.infer<typeof WebhookCreateRequest>;
export const WebhookUpdateRequest = z
.object({
name: WebhookNameType.describe('The new name of the webhook'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('The new avatar image as a base64-encoded data URI'),
channel_id: SnowflakeType.describe('The ID of the channel to move the webhook to'),
})
.partial();
export type WebhookUpdateRequest = z.infer<typeof WebhookUpdateRequest>;
export const WebhookTokenUpdateRequest = z
.object({
name: WebhookNameType.describe('The new name of the webhook'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
.nullish()
.describe('The new avatar image as a base64-encoded data URI'),
})
.partial()
.strict();
export type WebhookTokenUpdateRequest = z.infer<typeof WebhookTokenUpdateRequest>;
export const WebhookAttachmentRequest = z.object({
id: z
.union([SnowflakeType, coerceNumberFromString(Int32Type)])
.optional()
.describe('Attachment ID for referencing uploaded files'),
filename: createStringType(1, 1024).optional().describe('Name of the file (1-1024 characters)'),
description: createStringType(1, 4096).optional().describe('Description for the attachment (max 4096 characters)'),
content_type: createStringType(1, 256).optional().describe('MIME type of the file'),
size: z.number().int().optional().describe('Size of the file in bytes'),
url: URLType.optional().describe('URL of the attachment'),
proxy_url: URLType.optional().describe('Proxied URL of the attachment'),
height: z.number().int().optional().describe('Height of the image/video in pixels'),
width: z.number().int().optional().describe('Width of the image/video in pixels'),
ephemeral: z.boolean().optional().describe('Whether this attachment is ephemeral'),
duration: z.number().optional().describe('Duration of audio file in seconds'),
waveform: createStringType().optional().describe('Base64-encoded bytearray of audio waveform'),
flags: createBitflagInt32Type(
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
'Attachment flags bitfield',
'MessageAttachmentFlags',
).optional(),
});
export type WebhookAttachmentRequest = z.infer<typeof WebhookAttachmentRequest>;
export const WebhookMessageRequest = z
.object({
content: createUnboundedStringType().nullish().describe('The message content (up to 2000 characters)'),
embeds: z.array(RichEmbedRequest).optional().describe('Array of embed objects to include in the message'),
attachments: z.array(WebhookAttachmentRequest).optional().describe('Array of attachment objects'),
message_reference: MessageReferenceRequest.nullish().describe(
'Reference to another message (for replies or forwards)',
),
allowed_mentions: AllowedMentionsRequest.nullish().describe('Controls which mentions trigger notifications'),
flags: createBitflagInt32Type(
MessageFlags,
MessageFlagsDescriptions,
'Message flags bitfield',
'MessageFlags',
).default(0),
nonce: createStringType(1, 32).optional().describe('Client-generated identifier for the message'),
favorite_meme_id: SnowflakeType.nullish().describe('ID of a favorite meme to attach'),
sticker_ids: z.array(SnowflakeType).max(3).nullish().describe('Array of sticker IDs to include (max 3)'),
tts: z.boolean().optional().describe('Whether this is a text-to-speech message'),
username: WebhookNameType.nullish().describe('Override the default username of the webhook for this message'),
avatar_url: URLType.nullish().describe('Override the default avatar URL of the webhook for this message'),
})
.partial();
export type WebhookMessageRequest = z.infer<typeof WebhookMessageRequest>;
export const WebhookExecuteQueryRequest = z.object({
wait: QueryBooleanType.optional().default(false).describe('Whether to wait for the webhook response'),
});
export type WebhookExecuteQueryRequest = z.infer<typeof WebhookExecuteQueryRequest>;
const SlackAttachmentFieldSchema = z.object({
title: createUnboundedStringType().optional().describe('Title of the field'),
value: createUnboundedStringType().optional().describe('Value of the field'),
short: z.boolean().optional().describe('Whether the field should be displayed as a short column'),
});
const SlackUnixSecondsSchema = coerceNumberFromString(z.number().int().nonnegative());
const SlackAttachmentSchema = z.object({
fallback: createUnboundedStringType().optional().describe('Fallback text for notifications'),
pretext: createUnboundedStringType().optional().describe('Text that appears above the attachment block'),
text: createUnboundedStringType().optional().describe('Main text content of the attachment'),
color: createUnboundedStringType().optional().describe('Colour of the attachment sidebar (hex code or preset)'),
title: createUnboundedStringType().optional().describe('Title of the attachment'),
title_link: createUnboundedStringType().optional().describe('URL to link from the title'),
fields: z.array(SlackAttachmentFieldSchema).optional().describe('Array of field objects'),
footer: createUnboundedStringType().optional().describe('Footer text displayed at the bottom'),
ts: SlackUnixSecondsSchema.optional().describe('Unix timestamp for the attachment footer'),
author_name: createUnboundedStringType().optional().describe('Name of the author'),
author_link: createUnboundedStringType().optional().describe('URL to link from the author name'),
author_icon: createUnboundedStringType().optional().describe('URL for the author icon image'),
image_url: createUnboundedStringType().optional().describe('URL of the main image to display'),
thumb_url: createUnboundedStringType().optional().describe('URL of a thumbnail image'),
});
export const SlackWebhookRequest = z.object({
text: createUnboundedStringType().optional().describe('Main text content of the message'),
username: WebhookNameType.optional().describe('Override the default username of the webhook'),
icon_url: createUnboundedStringType().optional().describe('Override the default icon of the webhook'),
attachments: z.array(SlackAttachmentSchema).optional().describe('Array of attachment objects'),
});
export type SlackWebhookRequest = z.infer<typeof SlackWebhookRequest>;

View File

@@ -0,0 +1,53 @@
/*
* 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 {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const WebhookBaseResponse = {
id: SnowflakeStringType.describe('The unique identifier (snowflake) for the webhook'),
guild_id: SnowflakeStringType.describe('The ID of the guild this webhook belongs to'),
channel_id: SnowflakeStringType.describe('The ID of the channel this webhook posts to'),
name: z.string().describe('The display name of the webhook'),
avatar: z.string().nullish().describe('The hash of the webhook avatar image'),
token: z.string().describe('The secure token used to execute the webhook'),
};
export const WebhookTokenResponse = z.object(WebhookBaseResponse);
export type WebhookTokenResponse = z.infer<typeof WebhookTokenResponse>;
export const WebhookResponse = WebhookTokenResponse.extend({
user: z.lazy(() => UserPartialResponse).describe('The user who created the webhook'),
});
export type WebhookResponse = z.infer<typeof WebhookResponse>;
export const WebhookListResponse = z.array(WebhookResponse).max(15).describe('A list of webhooks');
export type WebhookListResponse = z.infer<typeof WebhookListResponse>;
export interface Webhook {
readonly id: string;
readonly guild_id: string;
readonly channel_id: string;
readonly user: UserPartialResponse;
readonly name: string;
readonly avatar: string | null;
readonly token: string;
}

View File

@@ -0,0 +1,66 @@
/*
* 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 {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const AuditLogActionTypeSchema = withOpenApiType(
createInt32EnumType(
[
[AuditLogActionType.GUILD_UPDATE, 'GUILD_UPDATE', 'Guild settings were updated'],
[AuditLogActionType.CHANNEL_CREATE, 'CHANNEL_CREATE', 'Channel was created'],
[AuditLogActionType.CHANNEL_UPDATE, 'CHANNEL_UPDATE', 'Channel was updated'],
[AuditLogActionType.CHANNEL_DELETE, 'CHANNEL_DELETE', 'Channel was deleted'],
[AuditLogActionType.CHANNEL_OVERWRITE_CREATE, 'CHANNEL_OVERWRITE_CREATE', 'Permission overwrite was created'],
[AuditLogActionType.CHANNEL_OVERWRITE_UPDATE, 'CHANNEL_OVERWRITE_UPDATE', 'Permission overwrite was updated'],
[AuditLogActionType.CHANNEL_OVERWRITE_DELETE, 'CHANNEL_OVERWRITE_DELETE', 'Permission overwrite was deleted'],
[AuditLogActionType.MEMBER_KICK, 'MEMBER_KICK', 'Member was kicked'],
[AuditLogActionType.MEMBER_PRUNE, 'MEMBER_PRUNE', 'Members were pruned'],
[AuditLogActionType.MEMBER_BAN_ADD, 'MEMBER_BAN_ADD', 'Member was banned'],
[AuditLogActionType.MEMBER_BAN_REMOVE, 'MEMBER_BAN_REMOVE', 'Member ban was removed'],
[AuditLogActionType.MEMBER_UPDATE, 'MEMBER_UPDATE', 'Member was updated'],
[AuditLogActionType.MEMBER_ROLE_UPDATE, 'MEMBER_ROLE_UPDATE', 'Member roles were updated'],
[AuditLogActionType.MEMBER_MOVE, 'MEMBER_MOVE', 'Member was moved to a different voice channel'],
[AuditLogActionType.MEMBER_DISCONNECT, 'MEMBER_DISCONNECT', 'Member was disconnected from a voice channel'],
[AuditLogActionType.BOT_ADD, 'BOT_ADD', 'Bot was added to the guild'],
[AuditLogActionType.ROLE_CREATE, 'ROLE_CREATE', 'Role was created'],
[AuditLogActionType.ROLE_UPDATE, 'ROLE_UPDATE', 'Role was updated'],
[AuditLogActionType.ROLE_DELETE, 'ROLE_DELETE', 'Role was deleted'],
[AuditLogActionType.INVITE_CREATE, 'INVITE_CREATE', 'Invite was created'],
[AuditLogActionType.INVITE_UPDATE, 'INVITE_UPDATE', 'Invite was updated'],
[AuditLogActionType.INVITE_DELETE, 'INVITE_DELETE', 'Invite was deleted'],
[AuditLogActionType.WEBHOOK_CREATE, 'WEBHOOK_CREATE', 'Webhook was created'],
[AuditLogActionType.WEBHOOK_UPDATE, 'WEBHOOK_UPDATE', 'Webhook was updated'],
[AuditLogActionType.WEBHOOK_DELETE, 'WEBHOOK_DELETE', 'Webhook was deleted'],
[AuditLogActionType.EMOJI_CREATE, 'EMOJI_CREATE', 'Emoji was created'],
[AuditLogActionType.EMOJI_UPDATE, 'EMOJI_UPDATE', 'Emoji was updated'],
[AuditLogActionType.EMOJI_DELETE, 'EMOJI_DELETE', 'Emoji was deleted'],
[AuditLogActionType.STICKER_CREATE, 'STICKER_CREATE', 'Sticker was created'],
[AuditLogActionType.STICKER_UPDATE, 'STICKER_UPDATE', 'Sticker was updated'],
[AuditLogActionType.STICKER_DELETE, 'STICKER_DELETE', 'Sticker was deleted'],
[AuditLogActionType.MESSAGE_DELETE, 'MESSAGE_DELETE', 'Message was deleted'],
[AuditLogActionType.MESSAGE_BULK_DELETE, 'MESSAGE_BULK_DELETE', 'Messages were bulk deleted'],
[AuditLogActionType.MESSAGE_PIN, 'MESSAGE_PIN', 'Message was pinned'],
[AuditLogActionType.MESSAGE_UNPIN, 'MESSAGE_UNPIN', 'Message was unpinned'],
],
'The type of action that occurred',
'AuditLogActionType',
),
'AuditLogActionType',
);

View File

@@ -0,0 +1,178 @@
/*
* 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 {
ChannelOverwriteTypes,
ChannelOverwriteTypesDescriptions,
ChannelTypes,
} from '@fluxer/constants/src/ChannelConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
createInt32EnumType,
createNamedLiteralUnion,
MAX_STRING_PROCESSING_LENGTH,
normalizeString,
normalizeWhitespace,
stripInvisibles,
withOpenApiType,
withStringLengthRangeValidation,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ChannelTypeSchema = withOpenApiType(
createInt32EnumType(
[
[ChannelTypes.GUILD_TEXT, 'GUILD_TEXT', 'A text channel within a guild'],
[ChannelTypes.DM, 'DM', 'A direct message between users'],
[ChannelTypes.GUILD_VOICE, 'GUILD_VOICE', 'A voice channel within a guild'],
[ChannelTypes.GROUP_DM, 'GROUP_DM', 'A group direct message between users'],
[ChannelTypes.GUILD_CATEGORY, 'GUILD_CATEGORY', 'A category that contains channels'],
[ChannelTypes.GUILD_LINK, 'GUILD_LINK', 'A link channel for external resources'],
[ChannelTypes.DM_PERSONAL_NOTES, 'DM_PERSONAL_NOTES', 'Personal notes DM channel'],
],
'The type of the channel',
),
'ChannelType',
);
export const ChannelOverwriteTypeSchema = withOpenApiType(
createNamedLiteralUnion(
[
[ChannelOverwriteTypes.ROLE, 'ROLE', ChannelOverwriteTypesDescriptions.ROLE],
[ChannelOverwriteTypes.MEMBER, 'MEMBER', ChannelOverwriteTypesDescriptions.MEMBER],
] as const,
'The type of entity the overwrite applies to',
),
'ChannelOverwriteType',
);
const WHITESPACE_REGEX = /\s+/g;
const MULTIPLE_HYPHENS_REGEX = /-{2,}/g;
const VANITY_URL_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const DISALLOWED_CHARS = new Set(' !"#$%&\'()*+,/:;<=>?@[\\]^`{|}~');
function sanitizeChannelName(value: string): string {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
let s = normalizeString(value);
s = stripInvisibles(s);
s = normalizeWhitespace(s);
return s;
}
export const ChannelNameType = z
.string()
.superRefine((value, ctx) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.STRING_LENGTH_INVALID,
params: {min: 1, max: 100},
});
return z.NEVER;
}
const normalized = normalizeString(value);
const processed =
normalized
.toLowerCase()
.replace(WHITESPACE_REGEX, '-')
.split('')
.filter((char) => !DISALLOWED_CHARS.has(char))
.join('') || '-';
if (processed.length < 1) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.CHANNEL_NAME_EMPTY_AFTER_NORMALIZATION,
});
return z.NEVER;
}
})
.transform((value) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
const normalized = normalizeString(value);
return (
normalized
.toLowerCase()
.replace(WHITESPACE_REGEX, '-')
.split('')
.filter((char) => !DISALLOWED_CHARS.has(char))
.join('') || '-'
);
})
.pipe(withStringLengthRangeValidation(z.string(), 1, 100, ValidationErrorCodes.STRING_LENGTH_INVALID));
export const GeneralChannelNameType = z
.string()
.superRefine((value, ctx) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.STRING_LENGTH_INVALID,
params: {min: 1, max: 100},
});
return z.NEVER;
}
})
.transform((value) => {
let sanitized = sanitizeChannelName(value);
sanitized = sanitized.replace(WHITESPACE_REGEX, ' ');
return sanitized;
})
.refine((v) => v.trim().length > 0, ValidationErrorCodes.NAME_EMPTY_AFTER_NORMALIZATION)
.pipe(withStringLengthRangeValidation(z.string(), 1, 100, ValidationErrorCodes.STRING_LENGTH_INVALID));
export const VanityURLCodeType = z
.string()
.superRefine((value, ctx) => {
const normalized = normalizeString(value);
const processed = normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
if (!VANITY_URL_REGEX.test(processed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.VANITY_URL_INVALID_CHARACTERS,
});
return z.NEVER;
}
})
.transform((value) => {
const normalized = normalizeString(value);
return normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
})
.pipe(withStringLengthRangeValidation(z.string(), 2, 32, ValidationErrorCodes.VANITY_URL_CODE_LENGTH_INVALID));
const AUDIT_LOG_REASON_MAX_LENGTH = 512;
export const AuditLogReasonType = z
.string()
.nullable()
.optional()
.transform((value) => {
if (!value || value.trim().length === 0) {
return null;
}
const normalized = normalizeString(value);
if (normalized.length < 1 || normalized.length > AUDIT_LOG_REASON_MAX_LENGTH) {
return null;
}
return normalized;
});

View File

@@ -0,0 +1,61 @@
/*
* 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 {DeletionReasons} from '@fluxer/constants/src/Core';
import {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const DeletionReasonSchema = withOpenApiType(
createInt32EnumType(
[
[DeletionReasons.USER_REQUESTED, 'USER_REQUESTED', 'User requested account deletion'],
[DeletionReasons.OTHER, 'OTHER', 'Other reason'],
[DeletionReasons.SPAM, 'SPAM', 'Spam or unwanted content'],
[DeletionReasons.CHEATING_OR_EXPLOITATION, 'CHEATING_OR_EXPLOITATION', 'Cheating or exploitation'],
[DeletionReasons.COORDINATED_RAIDING, 'COORDINATED_RAIDING', 'Coordinated raiding'],
[DeletionReasons.AUTOMATION_OR_SELFBOT, 'AUTOMATION_OR_SELFBOT', 'Automation or selfbot usage'],
[DeletionReasons.NONCONSENSUAL_SEXUAL_CONTENT, 'NONCONSENSUAL_SEXUAL_CONTENT', 'Non-consensual sexual content'],
[DeletionReasons.SCAM_OR_SOCIAL_ENGINEERING, 'SCAM_OR_SOCIAL_ENGINEERING', 'Scam or social engineering'],
[DeletionReasons.CHILD_SEXUAL_CONTENT, 'CHILD_SEXUAL_CONTENT', 'Child sexual abuse material'],
[DeletionReasons.PRIVACY_VIOLATION_OR_DOXXING, 'PRIVACY_VIOLATION_OR_DOXXING', 'Privacy violation or doxxing'],
[DeletionReasons.HARASSMENT_OR_BULLYING, 'HARASSMENT_OR_BULLYING', 'Harassment or bullying'],
[DeletionReasons.PAYMENT_FRAUD, 'PAYMENT_FRAUD', 'Payment fraud'],
[DeletionReasons.CHILD_SAFETY_VIOLATION, 'CHILD_SAFETY_VIOLATION', 'Child safety violation'],
[DeletionReasons.BILLING_DISPUTE_OR_ABUSE, 'BILLING_DISPUTE_OR_ABUSE', 'Billing dispute or abuse'],
[DeletionReasons.UNSOLICITED_EXPLICIT_CONTENT, 'UNSOLICITED_EXPLICIT_CONTENT', 'Unsolicited explicit content'],
[DeletionReasons.GRAPHIC_VIOLENCE, 'GRAPHIC_VIOLENCE', 'Graphic violence'],
[DeletionReasons.BAN_EVASION, 'BAN_EVASION', 'Ban evasion'],
[DeletionReasons.TOKEN_OR_CREDENTIAL_SCAM, 'TOKEN_OR_CREDENTIAL_SCAM', 'Token or credential scam'],
[DeletionReasons.INACTIVITY, 'INACTIVITY', 'Account inactivity'],
[
DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
'HATE_SPEECH_OR_EXTREMIST_CONTENT',
'Hate speech or extremist content',
],
[DeletionReasons.MALICIOUS_LINKS_OR_MALWARE, 'MALICIOUS_LINKS_OR_MALWARE', 'Malicious links or malware'],
[
DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY,
'IMPERSONATION_OR_FAKE_IDENTITY',
'Impersonation or fake identity',
],
],
'Reason for account deletion',
'DeletionReason',
),
'DeletionReason',
);

View File

@@ -0,0 +1,49 @@
/*
* 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 emojiRegex from 'emoji-regex';
const EMOJI_REGEX = emojiRegex();
const REGIONAL_INDICATOR_START = 0x1f1e6;
const REGIONAL_INDICATOR_END = 0x1f1ff;
function isSingleRegionalIndicator(value: string): boolean {
const codePoints = [...value];
if (codePoints.length !== 1) {
return false;
}
const codePoint = codePoints[0].codePointAt(0);
return codePoint !== undefined && codePoint >= REGIONAL_INDICATOR_START && codePoint <= REGIONAL_INDICATOR_END;
}
export function isValidSingleUnicodeEmoji(value: string): boolean {
if (!value || value.length === 0) {
return false;
}
EMOJI_REGEX.lastIndex = 0;
const match = EMOJI_REGEX.exec(value);
if (match && match.index === 0 && match[0] === value) {
return true;
}
return isSingleRegionalIndicator(value);
}

View File

@@ -0,0 +1,160 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
normalizeString,
withOpenApiType,
withStringLengthRangeValidation,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const WHITESPACE_REGEX = /\s+/g;
const NON_FILENAME_CHARS_REGEX = /[^\p{L}\p{N}\p{M}_.-]/gu;
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
function isValidBase64(value: string): boolean {
if (value.length % 4 !== 0) {
return false;
}
let padding = 0;
for (let i = value.length - 1; i >= 0; i--) {
if (value.charCodeAt(i) !== 61) {
break;
}
padding++;
}
if (padding > 2) {
return false;
}
const boundary = value.length - padding;
for (let i = 0; i < boundary; i++) {
const code = value.charCodeAt(i);
const isUpper = code >= 65 && code <= 90;
const isLower = code >= 97 && code <= 122;
const isDigit = code >= 48 && code <= 57;
const isPlus = code === 43;
const isSlash = code === 47;
if (!(isUpper || isLower || isDigit || isPlus || isSlash)) {
return false;
}
}
for (let i = boundary; i < value.length; i++) {
if (value.charCodeAt(i) !== 61) {
return false;
}
}
try {
const decoded = Buffer.from(value, 'base64');
if (decoded.length === 0) {
return value === '';
}
return decoded.toString('base64') === value;
} catch {
return false;
}
}
function normalizeFilename(value: string): string {
let normalized = normalizeString(value);
// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte filtering is intentional for security
normalized = normalized.replace(/\x00/g, '');
normalized = normalized.replace(/[/\\]/g, '_');
normalized = normalized.replace(/\.{2,}/g, '_');
while (normalized.includes('..')) {
normalized = normalized.replace(/\.\./g, '_');
}
normalized = normalized.replace(/[<>:"|?*]/g, '');
if (WINDOWS_RESERVED_NAMES.test(normalized)) {
normalized = `_${normalized}`;
}
normalized = normalized.replace(WHITESPACE_REGEX, '_');
normalized = normalized.replace(NON_FILENAME_CHARS_REGEX, '');
normalized = normalized.replace(/\.\./g, '_');
normalized = normalized.replace(/[/\\]/g, '_');
if (!normalized || /^[._]+$/.test(normalized)) {
normalized = 'unnamed';
}
return normalized;
}
export const FilenameType = withStringLengthRangeValidation(
z.string(),
1,
255,
ValidationErrorCodes.FILENAME_LENGTH_INVALID,
)
.transform(normalizeFilename)
.refine((value) => value.length >= 1, ValidationErrorCodes.FILENAME_EMPTY_AFTER_NORMALIZATION)
.refine((value) => FILENAME_SAFE_REGEX.test(value), ValidationErrorCodes.FILENAME_INVALID_CHARACTERS);
export function createBase64StringType(minLength = 1, maxLength = 256) {
return withOpenApiType(
z
.string()
.superRefine((value, ctx) => {
const normalized = normalizeString(value);
const commaIndex = normalized.indexOf(',');
const base64 = commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
if (base64.length < minLength || base64.length > maxLength) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.BASE64_LENGTH_INVALID,
params: {min: minLength, maxLength},
});
return z.NEVER;
}
if (base64.length < 1 || !isValidBase64(base64)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_BASE64_FORMAT,
});
return z.NEVER;
}
})
.transform((value) => {
const normalized = normalizeString(value);
const commaIndex = normalized.indexOf(',');
return commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
}),
'Base64ImageType',
);
}

View File

@@ -0,0 +1,112 @@
/*
* 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 {
GuildExplicitContentFilterTypes,
GuildMFALevel,
GuildNSFWLevel,
GuildSplashCardAlignment,
GuildVerificationLevel,
JoinSourceTypes,
} from '@fluxer/constants/src/GuildConstants';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {
createInt32EnumType,
createNamedLiteralUnion,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const GuildVerificationLevelSchema = createInt32EnumType(
[
[GuildVerificationLevel.NONE, 'NONE', 'Unrestricted'],
[GuildVerificationLevel.LOW, 'LOW', 'Must have verified email'],
[GuildVerificationLevel.MEDIUM, 'MEDIUM', 'Registered for more than 5 minutes'],
[GuildVerificationLevel.HIGH, 'HIGH', 'Member of the server for more than 10 minutes'],
[GuildVerificationLevel.VERY_HIGH, 'VERY_HIGH', 'Must have a verified phone number'],
],
'Required verification level for members',
'GuildVerificationLevel',
);
export const GuildMFALevelSchema = createInt32EnumType(
[
[GuildMFALevel.NONE, 'NONE', 'Guild has no MFA requirement'],
[GuildMFALevel.ELEVATED, 'ELEVATED', 'Guild requires 2FA for moderation actions'],
],
'Required MFA level for moderation actions',
'GuildMFALevel',
);
export const GuildExplicitContentFilterSchema = createInt32EnumType(
[
[GuildExplicitContentFilterTypes.DISABLED, 'DISABLED', 'Media content will not be scanned'],
[
GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES,
'MEMBERS_WITHOUT_ROLES',
'Media content from members without roles will be scanned',
],
[GuildExplicitContentFilterTypes.ALL_MEMBERS, 'ALL_MEMBERS', 'Media content from all members will be scanned'],
],
'Level of content filtering for explicit media',
'GuildExplicitContentFilter',
);
export const DefaultMessageNotificationsSchema = createInt32EnumType(
[
[MessageNotifications.ALL_MESSAGES, 'ALL_MESSAGES', 'Notify on all messages'],
[MessageNotifications.ONLY_MENTIONS, 'ONLY_MENTIONS', 'Notify only on mentions'],
],
'Default notification level for new members',
'DefaultMessageNotifications',
);
export const NSFWLevelSchema = createInt32EnumType(
[
[GuildNSFWLevel.DEFAULT, 'DEFAULT', 'Default NSFW level'],
[GuildNSFWLevel.EXPLICIT, 'EXPLICIT', 'Guild has explicit content'],
[GuildNSFWLevel.SAFE, 'SAFE', 'Guild is safe'],
[GuildNSFWLevel.AGE_RESTRICTED, 'AGE_RESTRICTED', 'Guild is age-restricted'],
],
'The NSFW level of the guild',
'NSFWLevel',
);
export const SplashCardAlignmentSchema = createNamedLiteralUnion(
[
[GuildSplashCardAlignment.CENTER, 'CENTER', 'Splash card is centred'],
[GuildSplashCardAlignment.LEFT, 'LEFT', 'Splash card is aligned to the left'],
[GuildSplashCardAlignment.RIGHT, 'RIGHT', 'Splash card is aligned to the right'],
] as const,
'Alignment of the guild splash card',
);
export const JoinSourceTypeSchema = withOpenApiType(
createInt32EnumType(
[
[JoinSourceTypes.CREATOR, 'CREATOR', 'Member created the guild'],
[JoinSourceTypes.INSTANT_INVITE, 'INSTANT_INVITE', 'Member joined via an instant invite'],
[JoinSourceTypes.VANITY_URL, 'VANITY_URL', 'Member joined via the vanity URL'],
[JoinSourceTypes.BOT_INVITE, 'BOT_INVITE', 'Member was added via a bot invite'],
[JoinSourceTypes.ADMIN_FORCE_ADD, 'ADMIN_FORCE_ADD', 'Member was force-added by a platform administrator'],
],
'How the member joined the guild',
'JoinSourceType',
),
'JoinSourceType',
);

View File

@@ -0,0 +1,35 @@
/*
* 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 {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
import {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const InviteTypeSchema = withOpenApiType(
createInt32EnumType(
[
[InviteTypes.GUILD, 'GUILD', 'Invite to a guild'],
[InviteTypes.GROUP_DM, 'GROUP_DM', 'Invite to a group DM'],
[InviteTypes.EMOJI_PACK, 'EMOJI_PACK', 'Invite to an emoji pack'],
[InviteTypes.STICKER_PACK, 'STICKER_PACK', 'Invite to a sticker pack'],
],
'The type of invite',
'InviteType',
),
'InviteType',
);

View File

@@ -0,0 +1,98 @@
/*
* 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 {createNamedStringLiteralUnion, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const LocaleSchema = withOpenApiType(
createNamedStringLiteralUnion(
[
['ar', 'AR', 'Arabic'],
['bg', 'BG', 'Bulgarian'],
['cs', 'CS', 'Czech'],
['da', 'DA', 'Danish'],
['de', 'DE', 'German'],
['el', 'EL', 'Greek'],
['en-GB', 'EN_GB', 'English (United Kingdom)'],
['en-US', 'EN_US', 'English (United States)'],
['es-ES', 'ES_ES', 'Spanish (Spain)'],
['es-419', 'ES_419', 'Spanish (Latin America)'],
['fi', 'FI', 'Finnish'],
['fr', 'FR', 'French'],
['he', 'HE', 'Hebrew'],
['hi', 'HI', 'Hindi'],
['hr', 'HR', 'Croatian'],
['hu', 'HU', 'Hungarian'],
['id', 'ID', 'Indonesian'],
['it', 'IT', 'Italian'],
['ja', 'JA', 'Japanese'],
['ko', 'KO', 'Korean'],
['lt', 'LT', 'Lithuanian'],
['nl', 'NL', 'Dutch'],
['no', 'NO', 'Norwegian'],
['pl', 'PL', 'Polish'],
['pt-BR', 'PT_BR', 'Portuguese (Brazil)'],
['ro', 'RO', 'Romanian'],
['ru', 'RU', 'Russian'],
['sv-SE', 'SV_SE', 'Swedish'],
['th', 'TH', 'Thai'],
['tr', 'TR', 'Turkish'],
['uk', 'UK', 'Ukrainian'],
['vi', 'VI', 'Vietnamese'],
['zh-CN', 'ZH_CN', 'Chinese (Simplified)'],
['zh-TW', 'ZH_TW', 'Chinese (Traditional)'],
] as const,
'The locale code for the user interface language',
),
'Locale',
);
export type Locale =
| 'ar'
| 'bg'
| 'cs'
| 'da'
| 'de'
| 'el'
| 'en-GB'
| 'en-US'
| 'es-ES'
| 'es-419'
| 'fi'
| 'fr'
| 'he'
| 'hi'
| 'hr'
| 'hu'
| 'id'
| 'it'
| 'ja'
| 'ko'
| 'lt'
| 'nl'
| 'no'
| 'pl'
| 'pt-BR'
| 'ro'
| 'ru'
| 'sv-SE'
| 'th'
| 'tr'
| 'uk'
| 'vi'
| 'zh-CN'
| 'zh-TW';

View File

@@ -0,0 +1,116 @@
/*
* 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 {
AllowedMentionParseTypes,
AllowedMentionParseTypesDescriptions,
EmbedMediaFlags,
EmbedMediaFlagsDescriptions,
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
MessageEmbedTypes,
MessageFlags,
MessageFlagsDescriptions,
MessageReferenceTypes,
MessageReferenceTypesDescriptions,
MessageTypes,
} from '@fluxer/constants/src/ChannelConstants';
import {
createBitflagInt32Type,
createInt32EnumType,
createNamedStringLiteralUnion,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const MessageTypeSchema = withOpenApiType(
createInt32EnumType(
[
[MessageTypes.DEFAULT, 'DEFAULT', 'A regular message'],
[MessageTypes.RECIPIENT_ADD, 'RECIPIENT_ADD', 'A system message indicating a user was added to the conversation'],
[
MessageTypes.RECIPIENT_REMOVE,
'RECIPIENT_REMOVE',
'A system message indicating a user was removed from the conversation',
],
[MessageTypes.CALL, 'CALL', 'A message representing a call'],
[MessageTypes.CHANNEL_NAME_CHANGE, 'CHANNEL_NAME_CHANGE', 'A system message indicating the channel name changed'],
[MessageTypes.CHANNEL_ICON_CHANGE, 'CHANNEL_ICON_CHANGE', 'A system message indicating the channel icon changed'],
[
MessageTypes.CHANNEL_PINNED_MESSAGE,
'CHANNEL_PINNED_MESSAGE',
'A system message indicating a message was pinned',
],
[MessageTypes.USER_JOIN, 'USER_JOIN', 'A system message indicating a user joined'],
[MessageTypes.REPLY, 'REPLY', 'A reply message'],
],
'The type of message',
),
'MessageType',
);
export const MessageReferenceTypeSchema = createInt32EnumType(
[
[MessageReferenceTypes.DEFAULT, 'DEFAULT', MessageReferenceTypesDescriptions.DEFAULT],
[MessageReferenceTypes.FORWARD, 'FORWARD', MessageReferenceTypesDescriptions.FORWARD],
],
'The type of message reference',
'MessageReferenceType',
);
export const AllowedMentionParseTypeSchema = createNamedStringLiteralUnion(
[
[AllowedMentionParseTypes.USERS, 'USERS', AllowedMentionParseTypesDescriptions.USERS],
[AllowedMentionParseTypes.ROLES, 'ROLES', AllowedMentionParseTypesDescriptions.ROLES],
[AllowedMentionParseTypes.EVERYONE, 'EVERYONE', AllowedMentionParseTypesDescriptions.EVERYONE],
],
'Types of mentions to parse from content',
);
export const MessageFlagsSchema = withOpenApiType(
createBitflagInt32Type(MessageFlags, MessageFlagsDescriptions, 'Message bitflags', 'MessageFlags'),
'MessageFlags',
);
export const MessageAttachmentFlagsSchema = withOpenApiType(
createBitflagInt32Type(
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
'Message attachment bitflags',
'MessageAttachmentFlags',
),
'MessageAttachmentFlags',
);
export const EmbedMediaFlagsSchema = withOpenApiType(
createBitflagInt32Type(EmbedMediaFlags, EmbedMediaFlagsDescriptions, 'Embed media bitflags', 'EmbedMediaFlags'),
'EmbedMediaFlags',
);
export const MessageEmbedTypeSchema = createNamedStringLiteralUnion(
[
[MessageEmbedTypes.RICH, 'RICH', 'Rich embed with custom content'],
[MessageEmbedTypes.ARTICLE, 'ARTICLE', 'Article embed from a link'],
[MessageEmbedTypes.LINK, 'LINK', 'Link embed'],
[MessageEmbedTypes.IMAGE, 'IMAGE', 'Image embed'],
[MessageEmbedTypes.VIDEO, 'VIDEO', 'Video embed'],
[MessageEmbedTypes.AUDIO, 'AUDIO', 'Audio embed'],
[MessageEmbedTypes.GIFV, 'GIFV', 'Animated GIF video embed'],
],
'The type of embed',
);

View File

@@ -0,0 +1,26 @@
/*
* 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 {Permissions, PermissionsDescriptions} from '@fluxer/constants/src/ChannelConstants';
import {createPermissionStringType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const PermissionStringType = withOpenApiType(
createPermissionStringType(Permissions, PermissionsDescriptions, 'Permission bitfield as string', 'Permissions'),
'Permissions',
);

View File

@@ -0,0 +1,98 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {z} from 'zod';
const TRUE_VALUES = ['true', 'True', '1'];
const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
export const QueryBooleanType = z
.string()
.trim()
.optional()
.default('false')
.transform((value) => TRUE_VALUES.includes(value));
export function createQueryIntegerType({defaultValue = 0, minValue = 0, maxValue = 2147483647} = {}) {
return z
.string()
.trim()
.optional()
.default(defaultValue.toString())
.superRefine((value, ctx) => {
const num = Number.parseInt(value, 10);
if (Number.isNaN(num)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE,
params: {minValue, maxValue},
});
return z.NEVER;
}
if (!Number.isInteger(num) || num < minValue || num > maxValue) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE,
params: {minValue, maxValue},
});
return z.NEVER;
}
})
.transform((value) => {
const num = Number.parseInt(value, 10);
if (Number.isNaN(num)) {
throw new Error(ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE);
}
return num;
});
}
export const DateTimeType = z.union([
z
.string()
.regex(ISO_TIMESTAMP_REGEX, ValidationErrorCodes.INVALID_ISO_TIMESTAMP)
.superRefine((value, ctx) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_ISO_TIMESTAMP,
});
return z.NEVER;
}
})
.transform((value) => new Date(value)),
z
.number()
.int()
.min(0)
.max(8640000000000000)
.superRefine((value, ctx) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_ISO_TIMESTAMP,
});
return z.NEVER;
}
})
.transform((value) => new Date(value)),
]);

View File

@@ -0,0 +1,463 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {ZodTypeAny} from 'zod';
import {z} from 'zod';
export function withOpenApiType<T extends ZodTypeAny>(schema: T, typeName: string): T {
(schema as Record<string, unknown>).__fluxer_custom_type__ = typeName;
return schema;
}
export function withFieldDescription<T extends z.ZodTypeAny>(schema: T, fieldDescription: string): T {
const currentDesc = schema.description ?? '';
const newDesc = currentDesc ? `${currentDesc}|fieldDesc:${fieldDescription}` : `|fieldDesc:${fieldDescription}`;
return schema.describe(newDesc) as T;
}
const RTL_OVERRIDE_REGEX = /\u202E/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
const FORM_FEED_REGEX = /\u000C/g;
export const MAX_STRING_PROCESSING_LENGTH = 10_000;
export function normalizeString(value: string): string {
return (
value
.replace(RTL_OVERRIDE_REGEX, '')
.replace(FORM_FEED_REGEX, '')
// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte and control character filtering is intentional for security
.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g, '')
.trim()
);
}
export const Int64Type = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
try {
const bigInt = BigInt(trimmed);
if (bigInt < -9223372036854775808n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:Int64Type');
export const UnsignedInt64Type = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
if (!/^\d+$/.test(trimmed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
try {
const bigInt = BigInt(trimmed);
if (bigInt < 0n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:UnsignedInt64Type');
export const Int64StringType = z
.string()
.regex(/^-?\d+$/)
.describe('fluxer:Int64StringType');
const SNOWFLAKE_REGEX = /^(0|[1-9][0-9]*)$/;
const UNSIGNED_INT64_STRING_REGEX = /^\d+$/;
export const SnowflakeStringType = z.string().regex(SNOWFLAKE_REGEX).describe('fluxer:SnowflakeStringType');
export const BitflagStringType = z.string().regex(UNSIGNED_INT64_STRING_REGEX).describe('fluxer:BitflagStringType');
const HEX_STRING_16_REGEX = /^[a-f0-9]{16}$/;
export const HexString16Type = z.string().regex(HEX_STRING_16_REGEX).describe('fluxer:HexString16Type');
const HEX_STRING_32_REGEX = /^[a-f0-9]{32}$/;
export const HexString32Type = z.string().regex(HEX_STRING_32_REGEX).describe('fluxer:HexString32Type');
export const SnowflakeType = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
if (!SNOWFLAKE_REGEX.test(trimmed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
try {
const bigInt = BigInt(trimmed);
if (bigInt < 0n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.SNOWFLAKE_OUT_OF_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:SnowflakeType');
export const ColorType = z
.number()
.int()
.min(0x000000, ValidationErrorCodes.COLOR_VALUE_TOO_LOW)
.max(0xffffff, ValidationErrorCodes.COLOR_VALUE_TOO_HIGH)
.describe('fluxer:ColorType');
export const Int32Type = z.number().int().min(0).max(2147483647).describe('fluxer:Int32Type');
export const SignedInt32Type = z.number().int().min(-2147483648).max(2147483647).describe('fluxer:SignedInt32Type');
const INTEGER_STRING_REGEX = /^[+-]?\d+$/;
function coerceNumericStringToNumber(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (trimmed.length === 0 || !INTEGER_STRING_REGEX.test(trimmed)) {
return value;
}
const parsed = Number(trimmed);
return Number.isNaN(parsed) ? value : parsed;
}
export function coerceNumberFromString<T extends z.ZodNumber>(schema: T) {
return z.preprocess((value) => coerceNumericStringToNumber(value), schema);
}
export function withStringLengthRangeValidation(
schema: z.ZodString,
minLength: number,
maxLength: number,
errorCode: string,
) {
return schema.superRefine((value, ctx) => {
if (value.length < minLength || value.length > maxLength) {
const params: Record<string, unknown> = {min: minLength, max: maxLength};
if (minLength === maxLength) {
params.length = minLength;
}
ctx.addIssue({code: 'custom', message: errorCode, params});
}
});
}
export function createStringType(minLength = 1, maxLength = 256) {
const errorMessage =
minLength === maxLength ? ValidationErrorCodes.STRING_LENGTH_EXACT : ValidationErrorCodes.STRING_LENGTH_INVALID;
return z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), minLength, maxLength, errorMessage));
}
export function createUnboundedStringType() {
return z.string().transform(normalizeString);
}
const C0_C1_CTRL_REGEX =
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g;
const JOIN_CONTROLS_REGEX = /(?:\u200C|\u200D)/g;
const WJ_BOM_REGEX = /(?:\u2060|\uFEFF)/g;
const BIDI_CTRL_REGEX = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
const MISC_INVISIBLES_REGEX = /[\u00AD\u180E\uFFFE\uFFFF]/g;
const TAG_CHARS_REGEX = /[\u{E0000}-\u{E007F}]/gu;
const VARIATION_SELECTORS_BASIC = /[\uFE00-\uFE0F]/g;
const VARIATION_SELECTORS_IDEOGRAPHIC = /[\u{E0100}-\u{E01EF}]/gu;
const UNICODE_SPACES_REGEX = /[\s\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g;
export function removeStandaloneSurrogates(value: string): string {
return Array.from(value)
.filter((char) => {
if (char.length > 1) {
return true;
}
const codePoint = char.codePointAt(0);
if (codePoint === undefined) {
return false;
}
return codePoint < 0xd800 || codePoint > 0xdfff;
})
.join('');
}
export function normalizeWhitespace(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s.replace(UNICODE_SPACES_REGEX, ' ').replace(/\s+/g, ' ').trim();
}
export function stripInvisibles(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s
.replace(C0_C1_CTRL_REGEX, '')
.replace(JOIN_CONTROLS_REGEX, '')
.replace(WJ_BOM_REGEX, '')
.replace(BIDI_CTRL_REGEX, '')
.replace(MISC_INVISIBLES_REGEX, '')
.replace(TAG_CHARS_REGEX, '');
}
export function stripVariationSelectors(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s.replace(VARIATION_SELECTORS_BASIC, '').replace(VARIATION_SELECTORS_IDEOGRAPHIC, '');
}
interface EnumEntryJson {
n: string;
v: string | number;
d?: string;
}
export function createNamedLiteral<T extends number>(value: T, name: string, description?: string) {
const entry: EnumEntryJson = {n: name, v: value};
if (description) entry.d = description;
return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`);
}
export function createNamedLiteralUnion<T extends number>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
return z
.union(literals as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...Array<z.ZodLiteral<T>>])
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createNamedStringLiteral<T extends string>(value: T, name: string, description?: string) {
const entry: EnumEntryJson = {n: name, v: value};
if (description) entry.d = description;
return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`);
}
export function createNamedStringLiteralUnion<T extends string>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
return z
.union(literals as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...Array<z.ZodLiteral<T>>])
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createFlexibleStringLiteralUnion<T extends string>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
const flexibleUnionOperands = [...literals, z.string()] as unknown as [
z.ZodLiteral<T>,
z.ZodLiteral<T>,
...Array<z.ZodLiteral<T> | z.ZodString>,
];
return z.union(flexibleUnionOperands).describe(`fluxer:FlexibleEnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createInt32EnumType<T extends number>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
typeName?: string,
) {
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = description ? ` ${description}` : '';
return Int32Type.describe(`fluxer:Int32Enum${typeNamePart}:${JSON.stringify(entries)}${descPart}`);
}
type BitflagConstantsObject = Readonly<Record<string, number | bigint>>;
type BitflagDescriptionsObject<T extends BitflagConstantsObject> = Readonly<Partial<Record<keyof T, string>>>;
interface BitflagEntryJson {
n: string;
v: string;
d?: string;
}
function formatBitflagAnnotation<T extends BitflagConstantsObject>(
constants: T,
descriptions?: BitflagDescriptionsObject<T>,
): string {
const entries: Array<BitflagEntryJson> = Object.entries(constants)
.filter(([, value]) => typeof value === 'number' || typeof value === 'bigint')
.map(([name, value]) => {
const desc = descriptions?.[name as keyof T];
const entry: BitflagEntryJson = {n: name, v: value.toString()};
if (desc) entry.d = desc;
return entry;
});
return JSON.stringify(entries);
}
export function createBitflagStringType<T extends BitflagConstantsObject>(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return BitflagStringType.describe(`fluxer:Bitflags64${typeNamePart}:${annotation}${descPart}`);
}
export function createBitflagInt32Type<T extends BitflagConstantsObject>(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return Int32Type.describe(`fluxer:Bitflags32${typeNamePart}:${annotation}${descPart}`);
}
export function createPermissionStringType<T extends BitflagConstantsObject>(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return z
.string()
.regex(UNSIGNED_INT64_STRING_REGEX)
.describe(`fluxer:Permissions${typeNamePart}:${annotation}${descPart}`);
}

View File

@@ -0,0 +1,106 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {normalizeString, withStringLengthRangeValidation} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import validator from 'validator';
import {z} from 'zod';
const PROTOCOLS = ['http', 'https'];
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
const URL_VALIDATOR_OPTIONS = {
require_protocol: true,
require_host: true,
disallow_auth: true,
allow_trailing_dot: false,
allow_protocol_relative_urls: false,
allow_fragments: false,
validate_length: true,
protocols: ['http', 'https'] as Array<string>,
} as const;
let isDevelopment = false;
export function setIsDevelopment(value: boolean): void {
isDevelopment = value;
}
function createUrlSchema(allowFragments: boolean) {
return z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 1, 2048, ValidationErrorCodes.URL_LENGTH_INVALID))
.refine((value) => {
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return false;
}
try {
const url = new URL(value);
return PROTOCOLS.includes(url.protocol.slice(0, -1));
} catch {
return false;
}
}, ValidationErrorCodes.INVALID_URL_FORMAT)
.refine(
(value) =>
validator.isURL(value, {
...URL_VALIDATOR_OPTIONS,
allow_fragments: allowFragments,
require_tld: !isDevelopment,
}),
ValidationErrorCodes.INVALID_URL_FORMAT,
);
}
export const URLType = createUrlSchema(false);
export const URLWithFragmentType = createUrlSchema(true);
export const AttachmentURLType = z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 1, 2048, ValidationErrorCodes.URL_LENGTH_INVALID))
.refine((value) => {
if (value.startsWith('attachment://')) {
const filename = value.slice(13);
if (filename.length === 0) {
return false;
}
return FILENAME_SAFE_REGEX.test(filename);
}
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return false;
}
try {
const url = new URL(value);
return PROTOCOLS.includes(url.protocol.slice(0, -1));
} catch {
return false;
}
}, ValidationErrorCodes.INVALID_URL_OR_ATTACHMENT_FORMAT)
.refine((value) => {
if (value.startsWith('attachment://')) {
return true;
}
return validator.isURL(value, {
...URL_VALIDATOR_OPTIONS,
require_tld: !isDevelopment,
});
}, ValidationErrorCodes.INVALID_URL_FORMAT);

View File

@@ -0,0 +1,127 @@
/*
* 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 {
RelationshipTypes,
RelationshipTypesDescriptions,
RenderSpoilers,
RenderSpoilersDescriptions,
StickerAnimationOptions,
StickerAnimationOptionsDescriptions,
TimeFormatTypes,
TimeFormatTypesDescriptions,
UserAuthenticatorTypes,
UserAuthenticatorTypesDescriptions,
UserExplicitContentFilterTypes,
UserNotificationSettings,
UserNotificationSettingsDescriptions,
UserPremiumTypes,
UserPremiumTypesDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {createInt32EnumType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const StickerAnimationOptionsSchema = createInt32EnumType(
[
[StickerAnimationOptions.ALWAYS_ANIMATE, 'ALWAYS_ANIMATE', StickerAnimationOptionsDescriptions.ALWAYS_ANIMATE],
[
StickerAnimationOptions.ANIMATE_ON_INTERACTION,
'ANIMATE_ON_INTERACTION',
StickerAnimationOptionsDescriptions.ANIMATE_ON_INTERACTION,
],
[StickerAnimationOptions.NEVER_ANIMATE, 'NEVER_ANIMATE', StickerAnimationOptionsDescriptions.NEVER_ANIMATE],
],
'Sticker animation preference',
'StickerAnimationOptions',
);
export const RenderSpoilersSchema = createInt32EnumType(
[
[RenderSpoilers.ALWAYS, 'ALWAYS', RenderSpoilersDescriptions.ALWAYS],
[RenderSpoilers.ON_CLICK, 'ON_CLICK', RenderSpoilersDescriptions.ON_CLICK],
[RenderSpoilers.IF_MODERATOR, 'IF_MODERATOR', RenderSpoilersDescriptions.IF_MODERATOR],
],
'Spoiler rendering preference',
'RenderSpoilers',
);
export const TimeFormatTypesSchema = createInt32EnumType(
[
[TimeFormatTypes.AUTO, 'AUTO', TimeFormatTypesDescriptions.AUTO],
[TimeFormatTypes.TWELVE_HOUR, 'TWELVE_HOUR', TimeFormatTypesDescriptions.TWELVE_HOUR],
[TimeFormatTypes.TWENTY_FOUR_HOUR, 'TWENTY_FOUR_HOUR', TimeFormatTypesDescriptions.TWENTY_FOUR_HOUR],
],
'Time format preference',
'TimeFormatTypes',
);
export const UserNotificationSettingsSchema = createInt32EnumType(
[
[UserNotificationSettings.ALL_MESSAGES, 'ALL_MESSAGES', UserNotificationSettingsDescriptions.ALL_MESSAGES],
[UserNotificationSettings.ONLY_MENTIONS, 'ONLY_MENTIONS', UserNotificationSettingsDescriptions.ONLY_MENTIONS],
[UserNotificationSettings.NO_MESSAGES, 'NO_MESSAGES', UserNotificationSettingsDescriptions.NO_MESSAGES],
[UserNotificationSettings.INHERIT, 'INHERIT', UserNotificationSettingsDescriptions.INHERIT],
],
'Notification level preference',
'UserNotificationSettings',
);
export const RelationshipTypesSchema = createInt32EnumType(
[
[RelationshipTypes.FRIEND, 'FRIEND', RelationshipTypesDescriptions.FRIEND],
[RelationshipTypes.BLOCKED, 'BLOCKED', RelationshipTypesDescriptions.BLOCKED],
[RelationshipTypes.INCOMING_REQUEST, 'INCOMING_REQUEST', RelationshipTypesDescriptions.INCOMING_REQUEST],
[RelationshipTypes.OUTGOING_REQUEST, 'OUTGOING_REQUEST', RelationshipTypesDescriptions.OUTGOING_REQUEST],
],
'Relationship type',
'RelationshipTypes',
);
export const UserPremiumTypesSchema = createInt32EnumType(
[
[UserPremiumTypes.NONE, 'NONE', UserPremiumTypesDescriptions.NONE],
[UserPremiumTypes.SUBSCRIPTION, 'SUBSCRIPTION', UserPremiumTypesDescriptions.SUBSCRIPTION],
[UserPremiumTypes.LIFETIME, 'LIFETIME', UserPremiumTypesDescriptions.LIFETIME],
],
'Premium subscription type',
'UserPremiumTypes',
);
export const UserAuthenticatorTypesSchema = createInt32EnumType(
[
[UserAuthenticatorTypes.TOTP, 'TOTP', UserAuthenticatorTypesDescriptions.TOTP],
[UserAuthenticatorTypes.SMS, 'SMS', UserAuthenticatorTypesDescriptions.SMS],
[UserAuthenticatorTypes.WEBAUTHN, 'WEBAUTHN', UserAuthenticatorTypesDescriptions.WEBAUTHN],
],
'Authenticator type',
'UserAuthenticatorTypes',
);
export const UserExplicitContentFilterTypesSchema = createInt32EnumType(
[
[UserExplicitContentFilterTypes.DISABLED, 'DISABLED', 'Explicit content filter disabled'],
[UserExplicitContentFilterTypes.NON_FRIENDS, 'NON_FRIENDS', 'Filter explicit content from non-friends only'],
[
UserExplicitContentFilterTypes.FRIENDS_AND_NON_FRIENDS,
'FRIENDS_AND_NON_FRIENDS',
'Filter explicit content from all users',
],
],
'Explicit content filter level',
'UserExplicitContentFilterTypes',
);

View File

@@ -0,0 +1,157 @@
/*
* 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 {
PublicUserFlags,
PublicUserFlagsDescriptions,
UserFlags,
UserFlagsDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
createBitflagInt32Type,
createBitflagStringType,
MAX_STRING_PROCESSING_LENGTH,
normalizeString,
normalizeWhitespace,
removeStandaloneSurrogates,
stripInvisibles,
stripVariationSelectors,
withOpenApiType,
withStringLengthRangeValidation,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const EMAIL_LOCAL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
const DISCRIMINATOR_REGEX = /^\d{1,4}$/;
const FLUXER_TAG_REGEX = /^[a-zA-Z0-9_]+$/;
export const PHONE_E164_REGEX = /^\+[1-9]\d{1,14}$/;
function sanitizeUsername(value: string): string {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
let s = normalizeString(value);
s = removeStandaloneSurrogates(s);
s = stripInvisibles(s);
s = stripVariationSelectors(s);
s = normalizeWhitespace(s);
return s;
}
export const EmailType = withOpenApiType(
z
.string()
.transform(normalizeString)
.pipe(
withStringLengthRangeValidation(
z.string().email(ValidationErrorCodes.INVALID_EMAIL_FORMAT),
1,
254,
ValidationErrorCodes.EMAIL_LENGTH_INVALID,
),
)
.refine((value: string) => {
const atIndex = value.indexOf('@');
if (atIndex === -1) return false;
const local = value.slice(0, atIndex);
return EMAIL_LOCAL_REGEX.test(local);
}, ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART),
'EmailType',
);
export const DiscriminatorType = z
.union([z.string(), z.number()])
.transform((value) => String(value))
.pipe(z.string().regex(DISCRIMINATOR_REGEX, ValidationErrorCodes.DISCRIMINATOR_INVALID_FORMAT))
.transform((value) => {
return Number.parseInt(value, 10);
});
export const UsernameType = withOpenApiType(
z
.string()
.transform((value) => value.trim())
.pipe(withStringLengthRangeValidation(z.string(), 1, 32, ValidationErrorCodes.USERNAME_LENGTH_INVALID))
.refine((value) => FLUXER_TAG_REGEX.test(value), ValidationErrorCodes.USERNAME_INVALID_CHARACTERS)
.refine((value) => {
const lowerValue = value.toLowerCase();
return lowerValue !== 'everyone' && lowerValue !== 'here';
}, ValidationErrorCodes.USERNAME_RESERVED_VALUE)
.refine((value) => {
const lowerValue = value.toLowerCase();
return !lowerValue.includes('fluxer') && !lowerValue.includes('system message');
}, ValidationErrorCodes.USERNAME_CANNOT_CONTAIN_RESERVED_TERMS),
'UsernameType',
);
export const GlobalNameType = z
.string()
.superRefine((value, ctx) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.GLOBAL_NAME_LENGTH_INVALID,
params: {min: 1, max: 32},
});
return z.NEVER;
}
})
.transform(sanitizeUsername)
.pipe(withStringLengthRangeValidation(z.string(), 1, 32, ValidationErrorCodes.GLOBAL_NAME_LENGTH_INVALID))
.refine((value) => {
const lowerValue = value.toLowerCase();
return lowerValue !== 'everyone' && lowerValue !== 'here';
}, ValidationErrorCodes.GLOBAL_NAME_RESERVED_VALUE)
.refine((value) => {
const lowerValue = value.toLowerCase();
return !lowerValue.includes('system message');
}, ValidationErrorCodes.GLOBAL_NAME_CANNOT_CONTAIN_RESERVED_TERMS);
export const PasswordType = withOpenApiType(
z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 8, 256, ValidationErrorCodes.PASSWORD_LENGTH_INVALID)),
'PasswordType',
);
export const PhoneNumberType = withOpenApiType(
z
.string()
.transform(normalizeString)
.refine((value) => PHONE_E164_REGEX.test(value), ValidationErrorCodes.PHONE_NUMBER_INVALID_FORMAT),
'PhoneNumberType',
);
export const WebhookNameType = z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 1, 80, ValidationErrorCodes.WEBHOOK_NAME_LENGTH_INVALID));
export const UserFlagsSchema = withOpenApiType(
createBitflagStringType(UserFlags, UserFlagsDescriptions, 'User bitflags (64-bit)', 'UserFlags'),
'UserFlags',
);
export const PublicUserFlagsSchema = withOpenApiType(
createBitflagInt32Type(PublicUserFlags, PublicUserFlagsDescriptions, 'Public user bitflags', 'PublicUserFlags'),
'PublicUserFlags',
);

View File

@@ -0,0 +1,32 @@
/*
* 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 {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const WebhookTypeSchema = withOpenApiType(
createInt32EnumType(
[
[1, 'INCOMING', 'Incoming webhook'],
[2, 'CHANNEL_FOLLOWER', 'Channel follower webhook'],
],
'The type of webhook',
'WebhookType',
),
'WebhookType',
);

View File

@@ -0,0 +1,69 @@
/*
* 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 {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {AuditLogActionTypeSchema} from '@fluxer/schema/src/primitives/AuditLogValidators';
import {describe, expect, it} from 'vitest';
describe('AuditLogActionTypeSchema', () => {
it('accepts valid audit log action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.GUILD_UPDATE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.GUILD_UPDATE);
}
});
it('accepts channel action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.CHANNEL_CREATE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.CHANNEL_CREATE);
}
});
it('accepts member action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.MEMBER_KICK);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.MEMBER_KICK);
}
});
it('accepts role action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.ROLE_CREATE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.ROLE_CREATE);
}
});
it('accepts message action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.MESSAGE_DELETE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.MESSAGE_DELETE);
}
});
it('rejects non-numeric values', () => {
const result = AuditLogActionTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,282 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
AuditLogReasonType,
ChannelNameType,
GeneralChannelNameType,
VanityURLCodeType,
} from '@fluxer/schema/src/primitives/ChannelValidators';
import {describe, expect, it} from 'vitest';
describe('ChannelNameType', () => {
it('accepts valid channel names', () => {
const result = ChannelNameType.safeParse('general');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('general');
}
});
it('converts to lowercase', () => {
const result = ChannelNameType.safeParse('GENERAL');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('general');
}
});
it('replaces spaces with hyphens', () => {
const result = ChannelNameType.safeParse('my channel');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-channel');
}
});
it('removes disallowed characters', () => {
const result = ChannelNameType.safeParse('my#channel@name');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('mychannelname');
}
});
it('handles unicode characters', () => {
const result = ChannelNameType.safeParse('channel-name');
expect(result.success).toBe(true);
});
it('rejects channels exceeding max length', () => {
const result = ChannelNameType.safeParse('a'.repeat(101));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
});
it('falls back to hyphen for empty result', () => {
const result = ChannelNameType.safeParse('###');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('-');
}
});
it('normalizes and trims input', () => {
const result = ChannelNameType.safeParse(' my-channel ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-channel');
}
});
});
describe('GeneralChannelNameType', () => {
it('accepts valid channel names with spaces', () => {
const result = GeneralChannelNameType.safeParse('General Chat');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('General Chat');
}
});
it('preserves case', () => {
const result = GeneralChannelNameType.safeParse('My Channel');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Channel');
}
});
it('normalizes multiple spaces to single space', () => {
const result = GeneralChannelNameType.safeParse('My Channel');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Channel');
}
});
it('trims whitespace', () => {
const result = GeneralChannelNameType.safeParse(' My Channel ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Channel');
}
});
it('rejects empty names after normalization', () => {
const result = GeneralChannelNameType.safeParse(' ');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.NAME_EMPTY_AFTER_NORMALIZATION);
}
});
it('rejects names exceeding max length', () => {
const result = GeneralChannelNameType.safeParse('a'.repeat(101));
expect(result.success).toBe(false);
});
});
describe('VanityURLCodeType', () => {
it('accepts valid vanity URL codes', () => {
const result = VanityURLCodeType.safeParse('myserver');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('myserver');
}
});
it('accepts codes with hyphens', () => {
const result = VanityURLCodeType.safeParse('my-server');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-server');
}
});
it('accepts codes with numbers', () => {
const result = VanityURLCodeType.safeParse('server123');
expect(result.success).toBe(true);
});
it('converts to lowercase', () => {
const result = VanityURLCodeType.safeParse('MyServer');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('myserver');
}
});
it('replaces spaces with hyphens', () => {
const result = VanityURLCodeType.safeParse('my server');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-server');
}
});
it('collapses multiple hyphens', () => {
const result = VanityURLCodeType.safeParse('my--server');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-server');
}
});
it('rejects codes that are too short', () => {
const result = VanityURLCodeType.safeParse('a');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VANITY_URL_CODE_LENGTH_INVALID);
}
});
it('rejects codes that are too long', () => {
const result = VanityURLCodeType.safeParse('a'.repeat(33));
expect(result.success).toBe(false);
});
it('rejects codes starting with hyphen', () => {
const result = VanityURLCodeType.safeParse('-myserver');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VANITY_URL_INVALID_CHARACTERS);
}
});
it('rejects codes ending with hyphen', () => {
const result = VanityURLCodeType.safeParse('myserver-');
expect(result.success).toBe(false);
});
it('rejects codes with invalid characters', () => {
const result = VanityURLCodeType.safeParse('my_server');
expect(result.success).toBe(false);
});
});
describe('AuditLogReasonType', () => {
it('accepts valid audit log reasons', () => {
const result = AuditLogReasonType.safeParse('User was spamming');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('User was spamming');
}
});
it('accepts null', () => {
const result = AuditLogReasonType.safeParse(null);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('accepts undefined', () => {
const result = AuditLogReasonType.safeParse(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('returns null for empty string', () => {
const result = AuditLogReasonType.safeParse('');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('returns null for whitespace-only string', () => {
const result = AuditLogReasonType.safeParse(' ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('returns null for strings exceeding max length', () => {
const result = AuditLogReasonType.safeParse('a'.repeat(513));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('accepts strings at max length', () => {
const reason = 'a'.repeat(512);
const result = AuditLogReasonType.safeParse(reason);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(reason);
}
});
it('trims and normalizes reason', () => {
const result = AuditLogReasonType.safeParse(' Reason here ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Reason here');
}
});
});

View File

@@ -0,0 +1,195 @@
/*
* 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 {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
import {describe, expect, it} from 'vitest';
describe('isValidSingleUnicodeEmoji', () => {
describe('valid single emojis', () => {
it('accepts simple emoji', () => {
expect(isValidSingleUnicodeEmoji('👍')).toBe(true);
});
it('accepts common face emojis', () => {
expect(isValidSingleUnicodeEmoji('😀')).toBe(true);
expect(isValidSingleUnicodeEmoji('😂')).toBe(true);
expect(isValidSingleUnicodeEmoji('🥺')).toBe(true);
expect(isValidSingleUnicodeEmoji('😡')).toBe(true);
expect(isValidSingleUnicodeEmoji('🤔')).toBe(true);
});
it('accepts emoji with skin tone modifier', () => {
expect(isValidSingleUnicodeEmoji('👍🏿')).toBe(true);
expect(isValidSingleUnicodeEmoji('👍🏻')).toBe(true);
expect(isValidSingleUnicodeEmoji('👍🏽')).toBe(true);
});
it('accepts ZWJ sequence emojis', () => {
expect(isValidSingleUnicodeEmoji('👨‍👩‍👧‍👦')).toBe(true);
expect(isValidSingleUnicodeEmoji('👩‍💻')).toBe(true);
expect(isValidSingleUnicodeEmoji('🧑‍🎄')).toBe(true);
});
it('accepts ZWJ sequence with skin tone at correct position', () => {
expect(isValidSingleUnicodeEmoji('🧑🏿‍🎄')).toBe(true);
expect(isValidSingleUnicodeEmoji('👩🏻‍💻')).toBe(true);
});
it('accepts flag emojis', () => {
expect(isValidSingleUnicodeEmoji('🇺🇸')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇬🇧')).toBe(true);
expect(isValidSingleUnicodeEmoji('🏳️‍🌈')).toBe(true);
});
it('accepts single regional indicator symbols', () => {
expect(isValidSingleUnicodeEmoji('🇦')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇧')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇵')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇿')).toBe(true);
});
it('accepts all 26 regional indicator symbols', () => {
for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) {
expect(isValidSingleUnicodeEmoji(String.fromCodePoint(cp))).toBe(true);
}
});
it('accepts keycap emojis', () => {
expect(isValidSingleUnicodeEmoji('1⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('#️⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('*️⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('0⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('9⃣')).toBe(true);
});
it('accepts variation selector emojis', () => {
expect(isValidSingleUnicodeEmoji('❤️')).toBe(true);
expect(isValidSingleUnicodeEmoji('☀️')).toBe(true);
});
it('accepts text-style emojis without variation selector', () => {
expect(isValidSingleUnicodeEmoji('❤')).toBe(true);
expect(isValidSingleUnicodeEmoji('☀')).toBe(true);
expect(isValidSingleUnicodeEmoji('☺')).toBe(true);
});
it('accepts copyright, registered, and trademark symbols', () => {
expect(isValidSingleUnicodeEmoji('©')).toBe(true);
expect(isValidSingleUnicodeEmoji('©️')).toBe(true);
expect(isValidSingleUnicodeEmoji('®')).toBe(true);
expect(isValidSingleUnicodeEmoji('®️')).toBe(true);
expect(isValidSingleUnicodeEmoji('™')).toBe(true);
expect(isValidSingleUnicodeEmoji('™️')).toBe(true);
});
it('accepts animal and nature emojis', () => {
expect(isValidSingleUnicodeEmoji('🐱')).toBe(true);
expect(isValidSingleUnicodeEmoji('🌸')).toBe(true);
expect(isValidSingleUnicodeEmoji('🌍')).toBe(true);
});
it('accepts food and object emojis', () => {
expect(isValidSingleUnicodeEmoji('🍕')).toBe(true);
expect(isValidSingleUnicodeEmoji('🎸')).toBe(true);
expect(isValidSingleUnicodeEmoji('💎')).toBe(true);
});
it('accepts symbol emojis', () => {
expect(isValidSingleUnicodeEmoji('✅')).toBe(true);
expect(isValidSingleUnicodeEmoji('❌')).toBe(true);
expect(isValidSingleUnicodeEmoji('⚠️')).toBe(true);
expect(isValidSingleUnicodeEmoji('💯')).toBe(true);
});
});
describe('invalid inputs', () => {
it('rejects empty string', () => {
expect(isValidSingleUnicodeEmoji('')).toBe(false);
});
it('rejects plain text', () => {
expect(isValidSingleUnicodeEmoji('hello')).toBe(false);
expect(isValidSingleUnicodeEmoji('abc')).toBe(false);
});
it('rejects single ascii characters', () => {
expect(isValidSingleUnicodeEmoji('a')).toBe(false);
expect(isValidSingleUnicodeEmoji('1')).toBe(false);
expect(isValidSingleUnicodeEmoji('#')).toBe(false);
expect(isValidSingleUnicodeEmoji(' ')).toBe(false);
});
it('rejects multiple emojis', () => {
expect(isValidSingleUnicodeEmoji('👍👍')).toBe(false);
expect(isValidSingleUnicodeEmoji('🎉🎊')).toBe(false);
expect(isValidSingleUnicodeEmoji('👨‍👩‍👧‍👦👨‍👩‍👧')).toBe(false);
});
it('rejects multiple regional indicator symbols', () => {
expect(isValidSingleUnicodeEmoji('\u{1F1E6}\u{1F1E7}')).toBe(false);
});
it('rejects emoji with trailing text', () => {
expect(isValidSingleUnicodeEmoji('👍abc')).toBe(false);
expect(isValidSingleUnicodeEmoji('🎉!')).toBe(false);
});
it('rejects emoji with leading text', () => {
expect(isValidSingleUnicodeEmoji('abc👍')).toBe(false);
expect(isValidSingleUnicodeEmoji('!🎉')).toBe(false);
});
it('rejects unicode characters that are not emoji', () => {
expect(isValidSingleUnicodeEmoji('é')).toBe(false);
expect(isValidSingleUnicodeEmoji('中')).toBe(false);
expect(isValidSingleUnicodeEmoji('α')).toBe(false);
});
it('rejects regional indicator with trailing text', () => {
expect(isValidSingleUnicodeEmoji('\u{1F1F5}abc')).toBe(false);
});
it('rejects regional indicator with leading text', () => {
expect(isValidSingleUnicodeEmoji('abc\u{1F1F5}')).toBe(false);
});
});
describe('malformed emoji sequences', () => {
it('rejects skin tone at wrong position in ZWJ sequence', () => {
expect(isValidSingleUnicodeEmoji('🧑‍🎄🏿')).toBe(false);
});
it('accepts standalone skin tone modifier as valid emoji', () => {
expect(isValidSingleUnicodeEmoji('🏿')).toBe(true);
expect(isValidSingleUnicodeEmoji('🏻')).toBe(true);
});
it('rejects standalone ZWJ character', () => {
expect(isValidSingleUnicodeEmoji('\u200D')).toBe(false);
});
it('rejects emoji followed by standalone skin tone', () => {
expect(isValidSingleUnicodeEmoji('🎄🏿')).toBe(false);
});
it('rejects double skin tone modifiers', () => {
expect(isValidSingleUnicodeEmoji('👍🏿🏻')).toBe(false);
});
});
});

View File

@@ -0,0 +1,198 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {createBase64StringType, FilenameType} from '@fluxer/schema/src/primitives/FileValidators';
import {describe, expect, it} from 'vitest';
describe('FilenameType', () => {
it('accepts valid filenames', () => {
const result = FilenameType.safeParse('document.pdf');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('document.pdf');
}
});
it('accepts filenames with unicode characters', () => {
const result = FilenameType.safeParse('archivo.txt');
expect(result.success).toBe(true);
});
it('accepts filenames with numbers', () => {
const result = FilenameType.safeParse('file123.txt');
expect(result.success).toBe(true);
});
it('accepts filenames with underscores and hyphens', () => {
const result = FilenameType.safeParse('my_file-name.txt');
expect(result.success).toBe(true);
});
it('replaces path separators with underscores', () => {
const result = FilenameType.safeParse('path/to/file.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('path_to_file.txt');
}
});
it('replaces backslashes with underscores', () => {
const result = FilenameType.safeParse('path\\to\\file.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('path_to_file.txt');
}
});
it('replaces double dots with underscores', () => {
const result = FilenameType.safeParse('..file..txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toContain('..');
}
});
it('handles Windows reserved names by prefixing', () => {
const result = FilenameType.safeParse('CON.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('_CON.txt');
}
});
it('replaces spaces with underscores', () => {
const result = FilenameType.safeParse('my file.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my_file.txt');
}
});
it('removes null bytes', () => {
const result = FilenameType.safeParse('file\x00name.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toContain('\x00');
}
});
it('falls back to "unnamed" for invalid filenames', () => {
const result = FilenameType.safeParse('...');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('unnamed');
}
});
it('rejects filenames exceeding max length', () => {
const result = FilenameType.safeParse('a'.repeat(256));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.FILENAME_LENGTH_INVALID);
}
});
it('rejects empty filenames', () => {
const result = FilenameType.safeParse('');
expect(result.success).toBe(false);
});
it('trims whitespace', () => {
const result = FilenameType.safeParse(' file.txt ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('file.txt');
}
});
});
describe('createBase64StringType', () => {
it('accepts valid base64 strings', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('SGVsbG8gV29ybGQ=');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('SGVsbG8gV29ybGQ=');
}
});
it('accepts base64 with data URL prefix and extracts base64 part', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('data:image/png;base64,SGVsbG8gV29ybGQ=');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('SGVsbG8gV29ybGQ=');
}
});
it('accepts base64 without padding', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('YQ==');
expect(result.success).toBe(true);
});
it('rejects invalid base64 characters', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('Invalid!Base64@');
expect(result.success).toBe(false);
});
it('rejects base64 exceeding max length', () => {
const Base64Type = createBase64StringType(1, 10);
const result = Base64Type.safeParse('SGVsbG8gV29ybGQ=');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.BASE64_LENGTH_INVALID);
}
});
it('rejects base64 shorter than min length', () => {
const Base64Type = createBase64StringType(100, 200);
const result = Base64Type.safeParse('YQ==');
expect(result.success).toBe(false);
});
it('rejects base64 with incorrect padding', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('YQ===');
expect(result.success).toBe(false);
});
it('rejects empty base64 even when min is 0', () => {
const Base64Type = createBase64StringType(0, 100);
const result = Base64Type.safeParse('');
expect(result.success).toBe(false);
});
it('handles base64 with plus and slash characters', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('YWJj+/8=');
expect(result.success).toBe(true);
});
it('trims whitespace before validation', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse(' SGVsbG8gV29ybGQ= ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('SGVsbG8gV29ybGQ=');
}
});
});

View File

@@ -0,0 +1,196 @@
/*
* 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 {
GuildExplicitContentFilterTypes,
GuildMFALevel,
GuildNSFWLevel,
GuildSplashCardAlignment,
GuildVerificationLevel,
JoinSourceTypes,
} from '@fluxer/constants/src/GuildConstants';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {
DefaultMessageNotificationsSchema,
GuildExplicitContentFilterSchema,
GuildMFALevelSchema,
GuildVerificationLevelSchema,
JoinSourceTypeSchema,
NSFWLevelSchema,
SplashCardAlignmentSchema,
} from '@fluxer/schema/src/primitives/GuildValidators';
import {describe, expect, it} from 'vitest';
describe('GuildVerificationLevelSchema', () => {
it('accepts none verification level', () => {
const result = GuildVerificationLevelSchema.safeParse(GuildVerificationLevel.NONE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildVerificationLevel.NONE);
}
});
it('accepts low verification level', () => {
const result = GuildVerificationLevelSchema.safeParse(GuildVerificationLevel.LOW);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildVerificationLevel.LOW);
}
});
it('accepts very high verification level', () => {
const result = GuildVerificationLevelSchema.safeParse(GuildVerificationLevel.VERY_HIGH);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildVerificationLevel.VERY_HIGH);
}
});
});
describe('GuildMFALevelSchema', () => {
it('accepts none MFA level', () => {
const result = GuildMFALevelSchema.safeParse(GuildMFALevel.NONE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildMFALevel.NONE);
}
});
it('accepts elevated MFA level', () => {
const result = GuildMFALevelSchema.safeParse(GuildMFALevel.ELEVATED);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildMFALevel.ELEVATED);
}
});
});
describe('GuildExplicitContentFilterSchema', () => {
it('accepts disabled filter', () => {
const result = GuildExplicitContentFilterSchema.safeParse(GuildExplicitContentFilterTypes.DISABLED);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildExplicitContentFilterTypes.DISABLED);
}
});
it('accepts members without roles filter', () => {
const result = GuildExplicitContentFilterSchema.safeParse(GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES);
}
});
it('accepts all members filter', () => {
const result = GuildExplicitContentFilterSchema.safeParse(GuildExplicitContentFilterTypes.ALL_MEMBERS);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildExplicitContentFilterTypes.ALL_MEMBERS);
}
});
});
describe('DefaultMessageNotificationsSchema', () => {
it('accepts all messages notification level', () => {
const result = DefaultMessageNotificationsSchema.safeParse(MessageNotifications.ALL_MESSAGES);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageNotifications.ALL_MESSAGES);
}
});
it('accepts only mentions notification level', () => {
const result = DefaultMessageNotificationsSchema.safeParse(MessageNotifications.ONLY_MENTIONS);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageNotifications.ONLY_MENTIONS);
}
});
});
describe('NSFWLevelSchema', () => {
it('accepts default NSFW level', () => {
const result = NSFWLevelSchema.safeParse(GuildNSFWLevel.DEFAULT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildNSFWLevel.DEFAULT);
}
});
it('accepts explicit NSFW level', () => {
const result = NSFWLevelSchema.safeParse(GuildNSFWLevel.EXPLICIT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildNSFWLevel.EXPLICIT);
}
});
it('accepts age-restricted NSFW level', () => {
const result = NSFWLevelSchema.safeParse(GuildNSFWLevel.AGE_RESTRICTED);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildNSFWLevel.AGE_RESTRICTED);
}
});
});
describe('SplashCardAlignmentSchema', () => {
it('accepts centre alignment', () => {
const result = SplashCardAlignmentSchema.safeParse(GuildSplashCardAlignment.CENTER);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildSplashCardAlignment.CENTER);
}
});
it('accepts left alignment', () => {
const result = SplashCardAlignmentSchema.safeParse(GuildSplashCardAlignment.LEFT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildSplashCardAlignment.LEFT);
}
});
it('accepts right alignment', () => {
const result = SplashCardAlignmentSchema.safeParse(GuildSplashCardAlignment.RIGHT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildSplashCardAlignment.RIGHT);
}
});
it('rejects invalid alignments', () => {
const result = SplashCardAlignmentSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});
describe('JoinSourceTypeSchema', () => {
it('accepts all valid join source types', () => {
for (const value of Object.values(JoinSourceTypes)) {
const result = JoinSourceTypeSchema.safeParse(value);
expect(result.success).toBe(true);
}
});
it('rejects non-numeric values', () => {
expect(JoinSourceTypeSchema.safeParse('invalid').success).toBe(false);
});
});

View File

@@ -0,0 +1,84 @@
/*
* 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 {MessageReferenceTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {MessageReferenceTypeSchema, MessageTypeSchema} from '@fluxer/schema/src/primitives/MessageValidators';
import {describe, expect, it} from 'vitest';
describe('MessageTypeSchema', () => {
it('accepts default message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.DEFAULT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.DEFAULT);
}
});
it('accepts recipient add message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.RECIPIENT_ADD);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.RECIPIENT_ADD);
}
});
it('accepts call message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.CALL);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.CALL);
}
});
it('accepts reply message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.REPLY);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.REPLY);
}
});
it('rejects non-numeric values', () => {
const result = MessageTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});
describe('MessageReferenceTypeSchema', () => {
it('accepts default reference type', () => {
const result = MessageReferenceTypeSchema.safeParse(MessageReferenceTypes.DEFAULT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageReferenceTypes.DEFAULT);
}
});
it('accepts forward reference type', () => {
const result = MessageReferenceTypeSchema.safeParse(MessageReferenceTypes.FORWARD);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageReferenceTypes.FORWARD);
}
});
it('rejects non-numeric values', () => {
const result = MessageReferenceTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,259 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {createQueryIntegerType, DateTimeType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {describe, expect, it} from 'vitest';
describe('QueryBooleanType', () => {
it('parses "true" as true', () => {
const result = QueryBooleanType.safeParse('true');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
it('parses "True" as true', () => {
const result = QueryBooleanType.safeParse('True');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
it('parses "1" as true', () => {
const result = QueryBooleanType.safeParse('1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
it('parses "false" as false', () => {
const result = QueryBooleanType.safeParse('false');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('parses "0" as false', () => {
const result = QueryBooleanType.safeParse('0');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('defaults to false when undefined', () => {
const result = QueryBooleanType.safeParse(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('parses any other string as false', () => {
const result = QueryBooleanType.safeParse('yes');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('trims whitespace', () => {
const result = QueryBooleanType.safeParse(' true ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
});
describe('createQueryIntegerType', () => {
it('parses valid integer strings', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('42');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(42);
}
});
it('uses default value when undefined', () => {
const IntType = createQueryIntegerType({defaultValue: 10});
const result = IntType.safeParse(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(10);
}
});
it('respects minimum value', () => {
const IntType = createQueryIntegerType({minValue: 5});
const result = IntType.safeParse('3');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE);
}
});
it('respects maximum value', () => {
const IntType = createQueryIntegerType({maxValue: 100});
const result = IntType.safeParse('150');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE);
}
});
it('accepts value at minimum', () => {
const IntType = createQueryIntegerType({minValue: 5});
const result = IntType.safeParse('5');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(5);
}
});
it('accepts value at maximum', () => {
const IntType = createQueryIntegerType({maxValue: 100});
const result = IntType.safeParse('100');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(100);
}
});
it('rejects non-integer strings', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('not-a-number');
expect(result.success).toBe(false);
});
it('rejects floating point strings', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('3.14');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(3);
}
});
it('trims whitespace', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse(' 42 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(42);
}
});
it('handles zero correctly', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('0');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0);
}
});
});
describe('DateTimeType', () => {
it('accepts valid ISO timestamp strings', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00Z');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
expect(result.data.toISOString()).toBe('2024-01-15T12:30:00.000Z');
}
});
it('accepts ISO timestamps with milliseconds', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00.123Z');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts ISO timestamps with timezone offset', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00+05:00');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts ISO timestamps with negative timezone offset', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00-08:00');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts valid Unix timestamps as numbers', () => {
const result = DateTimeType.safeParse(1705323000000);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts zero timestamp', () => {
const result = DateTimeType.safeParse(0);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
expect(result.data.getTime()).toBe(0);
}
});
it('rejects invalid ISO timestamp format', () => {
const result = DateTimeType.safeParse('2024-01-15 12:30:00');
expect(result.success).toBe(false);
});
it('rejects non-date strings', () => {
const result = DateTimeType.safeParse('not-a-date');
expect(result.success).toBe(false);
});
it('rejects negative timestamps', () => {
const result = DateTimeType.safeParse(-1);
expect(result.success).toBe(false);
});
it('rejects timestamps exceeding maximum', () => {
const result = DateTimeType.safeParse(8640000000000001);
expect(result.success).toBe(false);
});
it('accepts maximum valid timestamp', () => {
const result = DateTimeType.safeParse(8640000000000000);
expect(result.success).toBe(true);
});
it('rejects floating point timestamps', () => {
const result = DateTimeType.safeParse(1705323000000.5);
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,469 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
ColorType,
coerceNumberFromString,
createStringType,
createUnboundedStringType,
Int32Type,
Int64StringType,
Int64Type,
normalizeString,
normalizeWhitespace,
removeStandaloneSurrogates,
stripInvisibles,
stripVariationSelectors,
UnsignedInt64Type,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {describe, expect, it} from 'vitest';
import {z} from 'zod';
describe('normalizeString', () => {
it('removes RTL override characters', () => {
const input = 'hello\u202Eworld';
expect(normalizeString(input)).toBe('helloworld');
});
it('removes form feed characters', () => {
const input = 'hello\u000Cworld';
expect(normalizeString(input)).toBe('helloworld');
});
it('removes null bytes and control characters', () => {
const input = 'hello\x00\x01\x02\x03world';
expect(normalizeString(input)).toBe('helloworld');
});
it('trims whitespace', () => {
const input = ' hello world ';
expect(normalizeString(input)).toBe('hello world');
});
it('handles empty strings', () => {
expect(normalizeString('')).toBe('');
});
it('handles strings with only whitespace', () => {
expect(normalizeString(' ')).toBe('');
});
it('preserves normal text', () => {
const input = 'Hello, World!';
expect(normalizeString(input)).toBe('Hello, World!');
});
});
describe('Int64Type', () => {
it('accepts valid integer strings', () => {
const result = Int64Type.safeParse('12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
it('accepts valid integer numbers', () => {
const result = Int64Type.safeParse(12345);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
it('accepts negative integers', () => {
const result = Int64Type.safeParse('-9223372036854775808');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(-9223372036854775808n);
}
});
it('accepts maximum int64 value', () => {
const result = Int64Type.safeParse('9223372036854775807');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(9223372036854775807n);
}
});
it('rejects values exceeding int64 range', () => {
const result = Int64Type.safeParse('9223372036854775808');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE);
}
});
it('rejects values below int64 range', () => {
const result = Int64Type.safeParse('-9223372036854775809');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE);
}
});
it('rejects invalid integer strings', () => {
const result = Int64Type.safeParse('not-a-number');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_INTEGER_FORMAT);
}
});
it('rejects unsafe JavaScript integers', () => {
const result = Int64Type.safeParse(Number.MAX_SAFE_INTEGER + 1);
expect(result.success).toBe(false);
});
it('handles whitespace in string input', () => {
const result = Int64Type.safeParse(' 12345 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
});
describe('Int64StringType', () => {
it('accepts positive integer strings', () => {
const result = Int64StringType.safeParse('12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('12345');
}
});
it('accepts negative integer strings', () => {
const result = Int64StringType.safeParse('-12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('-12345');
}
});
it('rejects non-integer strings', () => {
const result = Int64StringType.safeParse('not-a-number');
expect(result.success).toBe(false);
});
});
describe('UnsignedInt64Type', () => {
it('accepts positive integer strings', () => {
const result = UnsignedInt64Type.safeParse('12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
it('rejects negative integer strings', () => {
const result = UnsignedInt64Type.safeParse('-12345');
expect(result.success).toBe(false);
});
});
describe('ColorType', () => {
it('accepts valid color values', () => {
const result = ColorType.safeParse(0xff5500);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0xff5500);
}
});
it('accepts minimum color value (black)', () => {
const result = ColorType.safeParse(0x000000);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0x000000);
}
});
it('accepts maximum color value (white)', () => {
const result = ColorType.safeParse(0xffffff);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0xffffff);
}
});
it('rejects negative color values', () => {
const result = ColorType.safeParse(-1);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.COLOR_VALUE_TOO_LOW);
}
});
it('rejects color values exceeding max', () => {
const result = ColorType.safeParse(0x1000000);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.COLOR_VALUE_TOO_HIGH);
}
});
it('rejects non-integer values', () => {
const result = ColorType.safeParse(123.45);
expect(result.success).toBe(false);
});
});
describe('Int32Type', () => {
it('accepts valid int32 values', () => {
const result = Int32Type.safeParse(1000);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1000);
}
});
it('accepts zero', () => {
const result = Int32Type.safeParse(0);
expect(result.success).toBe(true);
});
it('accepts maximum int32 value', () => {
const result = Int32Type.safeParse(2147483647);
expect(result.success).toBe(true);
});
it('rejects negative values', () => {
const result = Int32Type.safeParse(-1);
expect(result.success).toBe(false);
});
it('rejects values exceeding int32 max', () => {
const result = Int32Type.safeParse(2147483648);
expect(result.success).toBe(false);
});
});
describe('coerceNumberFromString', () => {
it('coerces valid integer strings to numbers', () => {
const schema = coerceNumberFromString(z.number().int().min(0).max(100));
const result = schema.safeParse('50');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(50);
}
});
it('coerces negative integer strings', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse('-42');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(-42);
}
});
it('passes through numbers unchanged', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse(42);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(42);
}
});
it('does not coerce non-integer strings', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse('not-a-number');
expect(result.success).toBe(false);
});
it('handles empty strings', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse('');
expect(result.success).toBe(false);
});
it('handles whitespace trimming', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse(' 123 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(123);
}
});
});
describe('createStringType', () => {
it('validates string within length bounds', () => {
const StringType = createStringType(1, 10);
const result = StringType.safeParse('hello');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('hello');
}
});
it('normalizes and trims input', () => {
const StringType = createStringType(1, 10);
const result = StringType.safeParse(' hello ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('hello');
}
});
it('rejects strings shorter than minimum', () => {
const StringType = createStringType(5, 10);
const result = StringType.safeParse('hi');
expect(result.success).toBe(false);
});
it('rejects strings longer than maximum', () => {
const StringType = createStringType(1, 5);
const result = StringType.safeParse('hello world');
expect(result.success).toBe(false);
});
it('uses STRING_LENGTH_EXACT for exact length requirement', () => {
const StringType = createStringType(5, 5);
const result = StringType.safeParse('hi');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.STRING_LENGTH_EXACT);
}
});
it('uses STRING_LENGTH_INVALID for range requirement', () => {
const StringType = createStringType(5, 10);
const result = StringType.safeParse('hi');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
});
});
describe('createUnboundedStringType', () => {
it('normalizes string without length validation', () => {
const UnboundedStringType = createUnboundedStringType();
const result = UnboundedStringType.safeParse(' hello\x00world ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('helloworld');
}
});
it('accepts empty strings', () => {
const UnboundedStringType = createUnboundedStringType();
const result = UnboundedStringType.safeParse('');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('');
}
});
});
describe('removeStandaloneSurrogates', () => {
it('preserves valid characters', () => {
expect(removeStandaloneSurrogates('hello')).toBe('hello');
});
it('preserves valid emoji (surrogate pairs)', () => {
expect(removeStandaloneSurrogates('hello\uD83D\uDE00world')).toBe('hello\uD83D\uDE00world');
});
it('removes standalone high surrogates', () => {
expect(removeStandaloneSurrogates('hello\uD83Dworld')).toBe('helloworld');
});
it('removes standalone low surrogates', () => {
expect(removeStandaloneSurrogates('hello\uDE00world')).toBe('helloworld');
});
it('handles empty strings', () => {
expect(removeStandaloneSurrogates('')).toBe('');
});
});
describe('normalizeWhitespace', () => {
it('collapses multiple spaces to single space', () => {
expect(normalizeWhitespace('hello world')).toBe('hello world');
});
it('normalizes unicode spaces', () => {
expect(normalizeWhitespace('hello\u00A0world')).toBe('hello world');
});
it('trims leading and trailing whitespace', () => {
expect(normalizeWhitespace(' hello world ')).toBe('hello world');
});
it('handles strings with only whitespace', () => {
expect(normalizeWhitespace(' ')).toBe('');
});
it('throws on excessively long strings', () => {
const longString = 'a'.repeat(10001);
expect(() => normalizeWhitespace(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID);
});
});
describe('stripInvisibles', () => {
it('removes C0 and C1 control characters', () => {
expect(stripInvisibles('hello\x00\x01\x02world')).toBe('helloworld');
});
it('removes zero-width joiner and non-joiner', () => {
expect(stripInvisibles('hello\u200C\u200Dworld')).toBe('helloworld');
});
it('removes word joiner and BOM', () => {
expect(stripInvisibles('hello\u2060\uFEFFworld')).toBe('helloworld');
});
it('removes bidirectional control characters', () => {
expect(stripInvisibles('hello\u200E\u200F\u202Aworld')).toBe('helloworld');
});
it('preserves normal text', () => {
expect(stripInvisibles('Hello, World!')).toBe('Hello, World!');
});
it('throws on excessively long strings', () => {
const longString = 'a'.repeat(10001);
expect(() => stripInvisibles(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID);
});
});
describe('stripVariationSelectors', () => {
it('removes basic variation selectors', () => {
expect(stripVariationSelectors('hello\uFE0Fworld')).toBe('helloworld');
});
it('removes ideographic variation selectors', () => {
expect(stripVariationSelectors('hello\u{E0100}world')).toBe('helloworld');
});
it('preserves normal text', () => {
expect(stripVariationSelectors('Hello, World!')).toBe('Hello, World!');
});
it('throws on excessively long strings', () => {
const longString = 'a'.repeat(10001);
expect(() => stripVariationSelectors(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID);
});
});

View File

@@ -0,0 +1,235 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
AttachmentURLType,
setIsDevelopment,
URLType,
URLWithFragmentType,
} from '@fluxer/schema/src/primitives/UrlValidators';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('URLType', () => {
it('accepts valid HTTPS URLs', () => {
const result = URLType.safeParse('https://example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('https://example.com');
}
});
it('accepts valid HTTP URLs', () => {
const result = URLType.safeParse('http://example.com');
expect(result.success).toBe(true);
});
it('accepts URLs with paths', () => {
const result = URLType.safeParse('https://example.com/path/to/resource');
expect(result.success).toBe(true);
});
it('accepts URLs with query parameters', () => {
const result = URLType.safeParse('https://example.com/page?param=value');
expect(result.success).toBe(true);
});
it('accepts URLs with subdomains', () => {
const result = URLType.safeParse('https://sub.domain.example.com');
expect(result.success).toBe(true);
});
it('accepts URLs with ports', () => {
const result = URLType.safeParse('https://example.com:8080');
expect(result.success).toBe(true);
});
it('rejects URLs without protocol', () => {
const result = URLType.safeParse('example.com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_URL_FORMAT);
}
});
it('rejects URLs with fragments', () => {
const result = URLType.safeParse('https://example.com#section');
expect(result.success).toBe(false);
});
it('rejects FTP URLs', () => {
const result = URLType.safeParse('ftp://example.com');
expect(result.success).toBe(false);
});
it('rejects file URLs', () => {
const result = URLType.safeParse('file:///path/to/file');
expect(result.success).toBe(false);
});
it('rejects URLs exceeding max length', () => {
const longPath = 'a'.repeat(2040);
const result = URLType.safeParse(`https://example.com/${longPath}`);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.URL_LENGTH_INVALID);
}
});
it('rejects empty strings', () => {
const result = URLType.safeParse('');
expect(result.success).toBe(false);
});
it('trims whitespace', () => {
const result = URLType.safeParse(' https://example.com ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('https://example.com');
}
});
});
describe('URLWithFragmentType', () => {
it('accepts URLs with fragments', () => {
const result = URLWithFragmentType.safeParse('https://example.com#section');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('https://example.com#section');
}
});
it('accepts URLs without fragments', () => {
const result = URLWithFragmentType.safeParse('https://example.com');
expect(result.success).toBe(true);
});
it('accepts complex fragments', () => {
const result = URLWithFragmentType.safeParse('https://example.com/page#section-1.2');
expect(result.success).toBe(true);
});
});
describe('AttachmentURLType', () => {
it('accepts valid HTTPS URLs', () => {
const result = AttachmentURLType.safeParse('https://example.com/image.png');
expect(result.success).toBe(true);
});
it('accepts valid attachment:// URLs', () => {
const result = AttachmentURLType.safeParse('attachment://image.png');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('attachment://image.png');
}
});
it('accepts attachment URLs with unicode filenames', () => {
const result = AttachmentURLType.safeParse('attachment://archivo.png');
expect(result.success).toBe(true);
});
it('accepts attachment URLs with numbers', () => {
const result = AttachmentURLType.safeParse('attachment://file123.txt');
expect(result.success).toBe(true);
});
it('accepts attachment URLs with underscores and hyphens', () => {
const result = AttachmentURLType.safeParse('attachment://my_file-name.txt');
expect(result.success).toBe(true);
});
it('rejects attachment URLs with empty filename', () => {
const result = AttachmentURLType.safeParse('attachment://');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_URL_OR_ATTACHMENT_FORMAT);
}
});
it('rejects attachment URLs with invalid filename characters', () => {
const result = AttachmentURLType.safeParse('attachment://file name.txt');
expect(result.success).toBe(false);
});
it('rejects attachment URLs with path separators', () => {
const result = AttachmentURLType.safeParse('attachment://path/file.txt');
expect(result.success).toBe(false);
});
it('rejects URLs without valid protocol', () => {
const result = AttachmentURLType.safeParse('ftp://example.com/file.txt');
expect(result.success).toBe(false);
});
it('rejects URLs exceeding max length', () => {
const longFilename = 'a'.repeat(2040);
const result = AttachmentURLType.safeParse(`https://example.com/${longFilename}`);
expect(result.success).toBe(false);
});
it('trims and normalizes input', () => {
const result = AttachmentURLType.safeParse(' attachment://file.txt ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('attachment://file.txt');
}
});
});
describe('URL validators in development mode', () => {
beforeEach(() => {
setIsDevelopment(true);
});
afterEach(() => {
setIsDevelopment(false);
});
it('accepts localhost URLs in development mode', () => {
const result = URLType.safeParse('http://localhost:3000');
expect(result.success).toBe(true);
});
it('accepts URLs without TLD in development mode', () => {
const result = URLType.safeParse('http://myservice:8080');
expect(result.success).toBe(true);
});
it('accepts attachment URLs to local services', () => {
const result = AttachmentURLType.safeParse('http://localhost:3000/image.png');
expect(result.success).toBe(true);
});
});
describe('URL validators in production mode', () => {
beforeEach(() => {
setIsDevelopment(false);
});
it('rejects localhost URLs in production mode', () => {
const result = URLType.safeParse('http://localhost:3000');
expect(result.success).toBe(false);
});
it('rejects URLs without TLD in production mode', () => {
const result = URLType.safeParse('http://myservice:8080');
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,389 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
DiscriminatorType,
EmailType,
GlobalNameType,
PasswordType,
PhoneNumberType,
UsernameType,
WebhookNameType,
} from '@fluxer/schema/src/primitives/UserValidators';
import {describe, expect, it} from 'vitest';
describe('EmailType', () => {
it('accepts valid email addresses', () => {
const result = EmailType.safeParse('user@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('user@example.com');
}
});
it('accepts emails with subdomains', () => {
const result = EmailType.safeParse('user@mail.example.com');
expect(result.success).toBe(true);
});
it('accepts emails with plus addressing', () => {
const result = EmailType.safeParse('user+tag@example.com');
expect(result.success).toBe(true);
});
it('rejects emails without @ symbol', () => {
const result = EmailType.safeParse('userexample.com');
expect(result.success).toBe(false);
});
it('rejects emails without domain', () => {
const result = EmailType.safeParse('user@');
expect(result.success).toBe(false);
});
it('rejects emails with invalid local part characters', () => {
const result = EmailType.safeParse('user name@example.com');
expect(result.success).toBe(false);
});
it('rejects emails exceeding max length', () => {
const longLocal = 'a'.repeat(250);
const result = EmailType.safeParse(`${longLocal}@example.com`);
expect(result.success).toBe(false);
});
it('trims emails with leading/trailing whitespace', () => {
const result = EmailType.safeParse(' user@example.com ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('user@example.com');
}
});
});
describe('DiscriminatorType', () => {
it('accepts valid single digit discriminators', () => {
const result = DiscriminatorType.safeParse('1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1);
}
});
it('accepts valid four digit discriminators', () => {
const result = DiscriminatorType.safeParse('1234');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1234);
}
});
it('accepts zero as discriminator', () => {
const result = DiscriminatorType.safeParse('0');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0);
}
});
it('accepts discriminators with leading zeros', () => {
const result = DiscriminatorType.safeParse('0001');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1);
}
});
it('rejects discriminators with more than 4 digits', () => {
const result = DiscriminatorType.safeParse('12345');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.DISCRIMINATOR_INVALID_FORMAT);
}
});
it('rejects non-numeric discriminators', () => {
const result = DiscriminatorType.safeParse('abc');
expect(result.success).toBe(false);
});
it('rejects negative discriminators', () => {
const result = DiscriminatorType.safeParse('-1');
expect(result.success).toBe(false);
});
});
describe('UsernameType', () => {
it('accepts valid alphanumeric usernames', () => {
const result = UsernameType.safeParse('testuser');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('testuser');
}
});
it('accepts usernames with underscores', () => {
const result = UsernameType.safeParse('test_user');
expect(result.success).toBe(true);
});
it('accepts usernames with numbers', () => {
const result = UsernameType.safeParse('user123');
expect(result.success).toBe(true);
});
it('trims whitespace', () => {
const result = UsernameType.safeParse(' testuser ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('testuser');
}
});
it('rejects empty usernames', () => {
const result = UsernameType.safeParse('');
expect(result.success).toBe(false);
});
it('rejects usernames exceeding max length', () => {
const result = UsernameType.safeParse('a'.repeat(33));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_LENGTH_INVALID);
}
});
it('rejects usernames with invalid characters', () => {
const result = UsernameType.safeParse('test-user');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_INVALID_CHARACTERS);
}
});
it('rejects "everyone" as username', () => {
const result = UsernameType.safeParse('everyone');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_RESERVED_VALUE);
}
});
it('rejects "here" as username', () => {
const result = UsernameType.safeParse('here');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_RESERVED_VALUE);
}
});
it('rejects usernames containing "fluxer"', () => {
const result = UsernameType.safeParse('fluxeruser');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_CANNOT_CONTAIN_RESERVED_TERMS);
}
});
it('rejects reserved terms case-insensitively', () => {
const result = UsernameType.safeParse('EVERYONE');
expect(result.success).toBe(false);
});
});
describe('GlobalNameType', () => {
it('accepts valid display names', () => {
const result = GlobalNameType.safeParse('Test User');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Test User');
}
});
it('accepts names with unicode characters', () => {
const result = GlobalNameType.safeParse('Test User');
expect(result.success).toBe(true);
});
it('sanitizes and normalizes input', () => {
const result = GlobalNameType.safeParse(' Test User ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Test User');
}
});
it('rejects empty names', () => {
const result = GlobalNameType.safeParse('');
expect(result.success).toBe(false);
});
it('rejects names exceeding max length', () => {
const result = GlobalNameType.safeParse('a'.repeat(33));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_LENGTH_INVALID);
}
});
it('rejects "everyone" as global name', () => {
const result = GlobalNameType.safeParse('everyone');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_RESERVED_VALUE);
}
});
it('rejects "here" as global name', () => {
const result = GlobalNameType.safeParse('here');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_RESERVED_VALUE);
}
});
it('rejects names containing "system message"', () => {
const result = GlobalNameType.safeParse('My System Message');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_CANNOT_CONTAIN_RESERVED_TERMS);
}
});
});
describe('PasswordType', () => {
it('accepts valid passwords', () => {
const result = PasswordType.safeParse('securepassword123');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('securepassword123');
}
});
it('accepts passwords at minimum length', () => {
const result = PasswordType.safeParse('12345678');
expect(result.success).toBe(true);
});
it('rejects passwords shorter than minimum', () => {
const result = PasswordType.safeParse('1234567');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.PASSWORD_LENGTH_INVALID);
}
});
it('rejects passwords exceeding maximum length', () => {
const result = PasswordType.safeParse('a'.repeat(257));
expect(result.success).toBe(false);
});
it('trims and normalizes password', () => {
const result = PasswordType.safeParse(' password123 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('password123');
}
});
});
describe('PhoneNumberType', () => {
it('accepts valid E.164 phone numbers', () => {
const result = PhoneNumberType.safeParse('+14155551234');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('+14155551234');
}
});
it('accepts international phone numbers', () => {
const result = PhoneNumberType.safeParse('+442071234567');
expect(result.success).toBe(true);
});
it('rejects phone numbers without plus prefix', () => {
const result = PhoneNumberType.safeParse('14155551234');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.PHONE_NUMBER_INVALID_FORMAT);
}
});
it('rejects phone numbers starting with +0', () => {
const result = PhoneNumberType.safeParse('+04155551234');
expect(result.success).toBe(false);
});
it('rejects phone numbers with invalid characters', () => {
const result = PhoneNumberType.safeParse('+1-415-555-1234');
expect(result.success).toBe(false);
});
it('rejects phone numbers that are too short', () => {
const result = PhoneNumberType.safeParse('+1');
expect(result.success).toBe(false);
});
it('rejects phone numbers that are too long', () => {
const result = PhoneNumberType.safeParse('+1234567890123456');
expect(result.success).toBe(false);
});
});
describe('WebhookNameType', () => {
it('accepts valid webhook names', () => {
const result = WebhookNameType.safeParse('My Webhook');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Webhook');
}
});
it('accepts single character names', () => {
const result = WebhookNameType.safeParse('A');
expect(result.success).toBe(true);
});
it('accepts names at maximum length', () => {
const result = WebhookNameType.safeParse('a'.repeat(80));
expect(result.success).toBe(true);
});
it('rejects empty names', () => {
const result = WebhookNameType.safeParse('');
expect(result.success).toBe(false);
});
it('rejects names exceeding maximum length', () => {
const result = WebhookNameType.safeParse('a'.repeat(81));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.WEBHOOK_NAME_LENGTH_INVALID);
}
});
it('trims and normalizes webhook names', () => {
const result = WebhookNameType.safeParse(' My Webhook ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Webhook');
}
});
});

View File

@@ -0,0 +1,44 @@
/*
* 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 {WebhookTypeSchema} from '@fluxer/schema/src/primitives/WebhookValidators';
import {describe, expect, it} from 'vitest';
describe('WebhookTypeSchema', () => {
it('accepts incoming webhook type', () => {
const result = WebhookTypeSchema.safeParse(1);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1);
}
});
it('accepts channel follower webhook type', () => {
const result = WebhookTypeSchema.safeParse(2);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(2);
}
});
it('rejects non-numeric values', () => {
const result = WebhookTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});