Files
discord.js/packages/builders/src/messages/Assertions.ts
Rie d59857e901 fix(builders): add proper snowflake validation (#11290)
* fix(builders): add proper snowflake validation

close #11289

* fix(builders): use snowflake validation for attachment id

* test(builders): add validation tests for snowflake attachment IDs

* fix: better regex

Co-authored-by: Almeida <github@almeidx.dev>

* test(builders): fix snowflake validation in fileBody test

* Update packages/builders/src/Assertions.ts

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* fix: update regex

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-11-30 15:30:04 +00:00

148 lines
4.8 KiB
TypeScript

import { Buffer } from 'node:buffer';
import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
import { z } from 'zod';
import { snowflakePredicate } from '../Assertions.js';
import { embedPredicate } from './embed/Assertions.js';
import { pollPredicate } from './poll/Assertions.js';
const fileKeyRegex = /^files\[(?<placeholder>\d+?)]$/;
export const rawFilePredicate = z.object({
data: z.union([z.instanceof(Buffer), z.instanceof(Uint8Array), z.string()]),
name: z.string().min(1),
contentType: z.string().optional(),
key: z.string().regex(fileKeyRegex).optional(),
});
export const attachmentPredicate = z.object({
// As a string it only makes sense for edits when we do have an attachment snowflake
id: z.union([snowflakePredicate, z.number()]),
description: z.string().max(1_024).optional(),
duration_secs: z
.number()
.max(2 ** 31 - 1)
.optional(),
filename: z.string().max(1_024).optional(),
title: z.string().max(1_024).optional(),
waveform: z.string().max(400).optional(),
});
export const allowedMentionPredicate = z
.object({
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(),
})
.refine(
(data) =>
!(
(data.parse?.includes(AllowedMentionsTypes.User) && data.users?.length) ||
(data.parse?.includes(AllowedMentionsTypes.Role) && data.roles?.length)
),
{
error:
'Cannot specify both parse: ["users"] and non-empty users array, or parse: ["roles"] and non-empty roles array. These are mutually exclusive',
},
);
export const messageReferencePredicate = z.object({
channel_id: z.string().optional(),
fail_if_not_exists: z.boolean().optional(),
guild_id: z.string().optional(),
message_id: z.string(),
type: z.enum(MessageReferenceType).optional(),
});
const baseMessagePredicate = z.object({
nonce: z.union([z.string().max(25), z.number()]).optional(),
tts: z.boolean().optional(),
allowed_mentions: allowedMentionPredicate.optional(),
message_reference: messageReferencePredicate.optional(),
attachments: attachmentPredicate.array().max(10).optional(),
enforce_nonce: z.boolean().optional(),
});
const basicActionRowPredicate = z.object({
type: z.literal(ComponentType.ActionRow),
components: z
.object({
type: z.literal([
ComponentType.Button,
ComponentType.ChannelSelect,
ComponentType.MentionableSelect,
ComponentType.RoleSelect,
ComponentType.StringSelect,
ComponentType.UserSelect,
]),
})
.array(),
});
const messageNoComponentsV2Predicate = baseMessagePredicate
.extend({
content: z.string().max(2_000).optional(),
embeds: embedPredicate.array().max(10).optional(),
sticker_ids: z.array(z.string()).max(3).optional(),
poll: pollPredicate.optional(),
components: basicActionRowPredicate.array().max(5).optional(),
flags: z
.int()
.optional()
.refine((flags) => !flags || (flags & MessageFlags.IsComponentsV2) === 0, {
error: 'Cannot set content, embeds, stickers, or poll with IsComponentsV2 flag set',
}),
})
.refine(
(data) =>
data.content !== undefined ||
(data.embeds !== undefined && data.embeds.length > 0) ||
data.poll !== undefined ||
(data.attachments !== undefined && data.attachments.length > 0) ||
(data.components !== undefined && data.components.length > 0) ||
(data.sticker_ids !== undefined && data.sticker_ids.length > 0),
{ error: 'Messages must have content, embeds, a poll, attachments, components or stickers' },
);
const allTopLevelComponentsPredicate = z
.union([
basicActionRowPredicate,
z.object({
type: z.literal([
// Components v2
ComponentType.Container,
ComponentType.File,
ComponentType.MediaGallery,
ComponentType.Section,
ComponentType.Separator,
ComponentType.TextDisplay,
ComponentType.Thumbnail,
]),
}),
])
.array()
.min(1)
.max(10);
const messageComponentsV2Predicate = baseMessagePredicate.extend({
components: allTopLevelComponentsPredicate,
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(),
sticker_ids: z.array(z.never()).nullish(),
poll: z.null().optional(),
});
export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]);
// This validator does not assert file.key <-> attachment.id coherence. This is fine, because the builders
// should effectively guarantee that.
export const fileBodyMessagePredicate = z.object({
body: messagePredicate,
// No min length to support message edits
files: rawFilePredicate.array().max(10),
});