feat!: use zod v4 (#10922)

* feat: zod 4

* feat: zod v3, but v4

feat: validation error class

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>

* chore: bump

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
This commit is contained in:
Almeida
2025-07-03 01:02:45 +01:00
committed by GitHub
parent 4dbeed933b
commit a5bd4cfe73
20 changed files with 183 additions and 199 deletions

View File

@@ -1,5 +1,5 @@
import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
import { z } from 'zod';
import { z } from 'zod/v4';
import { embedPredicate } from './embed/Assertions.js';
import { pollPredicate } from './poll/Assertions.js';
@@ -17,7 +17,7 @@ export const attachmentPredicate = z.object({
export const allowedMentionPredicate = z
.object({
parse: z.nativeEnum(AllowedMentionsTypes).array().optional(),
parse: z.enum(AllowedMentionsTypes).array().optional(),
roles: z.string().array().max(100).optional(),
users: z.string().array().max(100).optional(),
replied_user: z.boolean().optional(),
@@ -29,7 +29,7 @@ export const allowedMentionPredicate = z
(data.parse?.includes(AllowedMentionsTypes.Role) && data.roles?.length)
),
{
message:
error:
'Cannot specify both parse: ["users"] and non-empty users array, or parse: ["roles"] and non-empty roles array. These are mutually exclusive',
},
);
@@ -39,7 +39,7 @@ export const messageReferencePredicate = z.object({
fail_if_not_exists: z.boolean().optional(),
guild_id: z.string().optional(),
message_id: z.string(),
type: z.nativeEnum(MessageReferenceType).optional(),
type: z.enum(MessageReferenceType).optional(),
});
const baseMessagePredicate = z.object({
@@ -55,13 +55,13 @@ const basicActionRowPredicate = z.object({
type: z.literal(ComponentType.ActionRow),
components: z
.object({
type: z.union([
z.literal(ComponentType.Button),
z.literal(ComponentType.ChannelSelect),
z.literal(ComponentType.MentionableSelect),
z.literal(ComponentType.RoleSelect),
z.literal(ComponentType.StringSelect),
z.literal(ComponentType.UserSelect),
type: z.literal([
ComponentType.Button,
ComponentType.ChannelSelect,
ComponentType.MentionableSelect,
ComponentType.RoleSelect,
ComponentType.StringSelect,
ComponentType.UserSelect,
]),
})
.array(),
@@ -75,15 +75,10 @@ const messageNoComponentsV2Predicate = baseMessagePredicate
poll: pollPredicate.optional(),
components: basicActionRowPredicate.array().max(5).optional(),
flags: z
.number()
.int()
.optional()
.refine((flags) => {
// If we have flags, ensure we don't have the ComponentsV2 flag
if (flags) {
return (flags & MessageFlags.IsComponentsV2) === 0;
}
return true;
.refine((flags) => !flags || (flags & MessageFlags.IsComponentsV2) === 0, {
error: 'Cannot set content, embeds, stickers, or poll with IsComponentsV2 flag set',
}),
})
.refine(
@@ -94,22 +89,22 @@ const messageNoComponentsV2Predicate = baseMessagePredicate
(data.attachments !== undefined && data.attachments.length > 0) ||
(data.components !== undefined && data.components.length > 0) ||
(data.sticker_ids !== undefined && data.sticker_ids.length > 0),
{ message: 'Messages must have content, embeds, a poll, attachments, components or stickers' },
{ error: 'Messages must have content, embeds, a poll, attachments, components or stickers' },
);
const allTopLevelComponentsPredicate = z
.union([
basicActionRowPredicate,
z.object({
type: z.union([
type: z.literal([
// Components v2
z.literal(ComponentType.Container),
z.literal(ComponentType.File),
z.literal(ComponentType.MediaGallery),
z.literal(ComponentType.Section),
z.literal(ComponentType.Separator),
z.literal(ComponentType.TextDisplay),
z.literal(ComponentType.Thumbnail),
ComponentType.Container,
ComponentType.File,
ComponentType.MediaGallery,
ComponentType.Section,
ComponentType.Separator,
ComponentType.TextDisplay,
ComponentType.Thumbnail,
]),
}),
])
@@ -119,7 +114,9 @@ const allTopLevelComponentsPredicate = z
const messageComponentsV2Predicate = baseMessagePredicate.extend({
components: allTopLevelComponentsPredicate,
flags: z.number().refine((flags) => (flags & MessageFlags.IsComponentsV2) === MessageFlags.IsComponentsV2),
flags: z.int().refine((flags) => (flags & MessageFlags.IsComponentsV2) === MessageFlags.IsComponentsV2, {
error: 'Must set IsComponentsV2 flag to use Components V2',
}),
// These fields cannot be set
content: z.string().length(0).nullish(),
embeds: z.array(z.never()).nullish(),

View File

@@ -1,20 +1,11 @@
import { z } from 'zod';
import { refineURLPredicate } from '../../Assertions.js';
import { z } from 'zod/v4';
import { embedLength } from '../../util/componentUtil.js';
const namePredicate = z.string().max(256);
const URLPredicate = z
.string()
.url()
.refine(refineURLPredicate(['http:', 'https:']), { message: 'Invalid protocol for URL. Must be http: or https:' });
const URLPredicate = z.url({ protocol: /^https?$/ });
const URLWithAttachmentProtocolPredicate = z
.string()
.url()
.refine(refineURLPredicate(['http:', 'https:', 'attachment:']), {
message: 'Invalid protocol for URL. Must be http:, https:, or attachment:',
});
const URLWithAttachmentProtocolPredicate = z.url({ protocol: /^(?:https?|attachment)$/ });
export const embedFieldPredicate = z.object({
name: namePredicate,
@@ -39,7 +30,7 @@ export const embedPredicate = z
description: z.string().min(1).max(4_096).optional(),
url: URLPredicate.optional(),
timestamp: z.string().optional(),
color: z.number().int().min(0).max(0xffffff).optional(),
color: z.int().min(0).max(0xffffff).optional(),
footer: embedFooterPredicate.optional(),
image: z.object({ url: URLWithAttachmentProtocolPredicate }).optional(),
thumbnail: z.object({ url: URLWithAttachmentProtocolPredicate }).optional(),
@@ -56,7 +47,7 @@ export const embedPredicate = z
embed.image !== undefined ||
embed.thumbnail !== undefined,
{
message: 'Embed must have at least a title, description, a field, a footer, an author, an image, OR a thumbnail.',
error: 'Embed must have at least a title, description, a field, a footer, an author, an image, OR a thumbnail.',
},
)
.refine((embed) => embedLength(embed) <= 6_000, { message: 'Embeds must not exceed 6000 characters in total.' });
.refine((embed) => embedLength(embed) <= 6_000, { error: 'Embeds must not exceed 6000 characters in total.' });

View File

@@ -1,5 +1,5 @@
import { PollLayoutType } from 'discord-api-types/v10';
import { z } from 'zod';
import { z } from 'zod/v4';
import { emojiPredicate } from '../../components/Assertions';
export const pollQuestionPredicate = z.object({ text: z.string().min(1).max(300) });
@@ -16,5 +16,5 @@ export const pollPredicate = z.object({
answers: z.array(pollAnswerPredicate).min(1).max(10),
duration: z.number().min(1).max(768).optional(),
allow_multiselect: z.boolean().optional(),
layout_type: z.nativeEnum(PollLayoutType).optional(),
layout_type: z.enum(PollLayoutType).optional(),
});