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>
This commit is contained in:
Rie
2025-12-01 00:30:04 +09:00
committed by GitHub
parent 19253f6b7b
commit d59857e901
5 changed files with 18 additions and 14 deletions

View File

@@ -6,7 +6,7 @@ import { AttachmentBuilder, MessageBuilder } from '../../src/index.js';
test('AttachmentBuilder stores and exposes file data', () => { test('AttachmentBuilder stores and exposes file data', () => {
const data = Buffer.from('hello world'); const data = Buffer.from('hello world');
const attachment = new AttachmentBuilder() const attachment = new AttachmentBuilder()
.setId('0') .setId(1)
.setFilename('greeting.txt') .setFilename('greeting.txt')
.setFileData(data) .setFileData(data)
.setFileContentType('text/plain'); .setFileContentType('text/plain');
@@ -14,7 +14,7 @@ test('AttachmentBuilder stores and exposes file data', () => {
expect(attachment.getRawFile()).toStrictEqual({ expect(attachment.getRawFile()).toStrictEqual({
contentType: 'text/plain', contentType: 'text/plain',
data, data,
key: 'files[0]', key: 'files[1]',
name: 'greeting.txt', name: 'greeting.txt',
}); });
@@ -27,7 +27,7 @@ test('AttachmentBuilder stores and exposes file data', () => {
test('MessageBuilder.toFileBody returns JSON body and files', () => { test('MessageBuilder.toFileBody returns JSON body and files', () => {
const msg = new MessageBuilder().setContent('here is a file').addAttachments( const msg = new MessageBuilder().setContent('here is a file').addAttachments(
new AttachmentBuilder() new AttachmentBuilder()
.setId('0') .setId(0)
.setFilename('file.bin') .setFilename('file.bin')
.setFileData(Buffer.from([1, 2, 3])) .setFileData(Buffer.from([1, 2, 3]))
.setFileContentType('application/octet-stream'), .setFileContentType('application/octet-stream'),
@@ -47,7 +47,9 @@ test('MessageBuilder.toFileBody returns JSON body and files', () => {
}); });
test('MessageBuilder.toFileBody returns empty files when attachments reference existing uploads', () => { test('MessageBuilder.toFileBody returns empty files when attachments reference existing uploads', () => {
const msg = new MessageBuilder().addAttachments(new AttachmentBuilder().setId('123').setFilename('existing.png')); const msg = new MessageBuilder().addAttachments(
new AttachmentBuilder().setId('1234567890123456789').setFilename('existing.png'),
);
const { body, files } = msg.toFileBody(); const { body, files } = msg.toFileBody();
expect(body).toEqual(msg.toJSON()); expect(body).toEqual(msg.toJSON());

View File

@@ -64,7 +64,7 @@ describe('Message', () => {
row.addPrimaryButtonComponents((button) => button.setCustomId('abc').setLabel('def')), row.addPrimaryButtonComponents((button) => button.setCustomId('abc').setLabel('def')),
) )
.setStickerIds('123', '456') .setStickerIds('123', '456')
.addAttachments((attachment) => attachment.setId('hi!').setFilename('abc')) .addAttachments((attachment) => attachment.setId(0).setFilename('abc'))
.setFlags(MessageFlags.Ephemeral) .setFlags(MessageFlags.Ephemeral)
.setEnforceNonce(false) .setEnforceNonce(false)
.updatePoll((poll) => poll.addAnswers({ poll_media: { text: 'foo' } }).setQuestion({ text: 'foo' })); .updatePoll((poll) => poll.addAnswers({ poll_media: { text: 'foo' } }).setQuestion({ text: 'foo' }));
@@ -83,7 +83,7 @@ describe('Message', () => {
}, },
], ],
sticker_ids: ['123', '456'], sticker_ids: ['123', '456'],
attachments: [{ id: 'hi!', filename: 'abc' }], attachments: [{ id: 0, filename: 'abc' }],
flags: 64, flags: 64,
enforce_nonce: false, enforce_nonce: false,
poll: { poll: {

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
export const idPredicate = z.int().min(0).max(2_147_483_647).optional(); export const idPredicate = z.int().min(0).max(2_147_483_647).optional();
export const customIdPredicate = z.string().min(1).max(100); export const customIdPredicate = z.string().min(1).max(100);
export const snowflakePredicate = z.string().regex(/^(?:0|[1-9]\d*)$/);
export const memberPermissionsPredicate = z.coerce.bigint(); export const memberPermissionsPredicate = z.coerce.bigint();

View File

@@ -1,12 +1,12 @@
import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10'; import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
import { z } from 'zod'; import { z } from 'zod';
import { idPredicate, customIdPredicate } from '../Assertions.js'; import { idPredicate, customIdPredicate, snowflakePredicate } from '../Assertions.js';
const labelPredicate = z.string().min(1).max(80); const labelPredicate = z.string().min(1).max(80);
export const emojiPredicate = z export const emojiPredicate = z
.strictObject({ .strictObject({
id: z.string().optional(), id: snowflakePredicate.optional(),
name: z.string().min(2).max(32).optional(), name: z.string().min(2).max(32).optional(),
animated: z.boolean().optional(), animated: z.boolean().optional(),
}) })
@@ -39,7 +39,7 @@ const buttonLinkPredicate = buttonPredicateBase.extend({
const buttonPremiumPredicate = buttonPredicateBase.extend({ const buttonPremiumPredicate = buttonPredicateBase.extend({
style: z.literal(ButtonStyle.Premium), style: z.literal(ButtonStyle.Premium),
sku_id: z.string(), sku_id: snowflakePredicate,
}); });
export const buttonPredicate = z.discriminatedUnion('style', [ export const buttonPredicate = z.discriminatedUnion('style', [
@@ -64,7 +64,7 @@ export const selectMenuChannelPredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.ChannelSelect), type: z.literal(ComponentType.ChannelSelect),
channel_types: z.enum(ChannelType).array().optional(), channel_types: z.enum(ChannelType).array().optional(),
default_values: z default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Channel) }) .object({ id: snowflakePredicate, type: z.literal(SelectMenuDefaultValueType.Channel) })
.array() .array()
.max(25) .max(25)
.optional(), .optional(),
@@ -74,7 +74,7 @@ export const selectMenuMentionablePredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.MentionableSelect), type: z.literal(ComponentType.MentionableSelect),
default_values: z default_values: z
.object({ .object({
id: z.string(), id: snowflakePredicate,
type: z.literal([SelectMenuDefaultValueType.Role, SelectMenuDefaultValueType.User]), type: z.literal([SelectMenuDefaultValueType.Role, SelectMenuDefaultValueType.User]),
}) })
.array() .array()
@@ -85,7 +85,7 @@ export const selectMenuMentionablePredicate = selectMenuBasePredicate.extend({
export const selectMenuRolePredicate = selectMenuBasePredicate.extend({ export const selectMenuRolePredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.RoleSelect), type: z.literal(ComponentType.RoleSelect),
default_values: z default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Role) }) .object({ id: snowflakePredicate, type: z.literal(SelectMenuDefaultValueType.Role) })
.array() .array()
.max(25) .max(25)
.optional(), .optional(),
@@ -142,7 +142,7 @@ export const selectMenuStringPredicate = selectMenuBasePredicate
export const selectMenuUserPredicate = selectMenuBasePredicate.extend({ export const selectMenuUserPredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.UserSelect), type: z.literal(ComponentType.UserSelect),
default_values: z default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.User) }) .object({ id: snowflakePredicate, type: z.literal(SelectMenuDefaultValueType.User) })
.array() .array()
.max(25) .max(25)
.optional(), .optional(),

View File

@@ -1,6 +1,7 @@
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10'; import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10';
import { z } from 'zod'; import { z } from 'zod';
import { snowflakePredicate } from '../Assertions.js';
import { embedPredicate } from './embed/Assertions.js'; import { embedPredicate } from './embed/Assertions.js';
import { pollPredicate } from './poll/Assertions.js'; import { pollPredicate } from './poll/Assertions.js';
@@ -15,7 +16,7 @@ export const rawFilePredicate = z.object({
export const attachmentPredicate = z.object({ export const attachmentPredicate = z.object({
// As a string it only makes sense for edits when we do have an attachment snowflake // As a string it only makes sense for edits when we do have an attachment snowflake
id: z.union([z.string(), z.number()]), id: z.union([snowflakePredicate, z.number()]),
description: z.string().max(1_024).optional(), description: z.string().max(1_024).optional(),
duration_secs: z duration_secs: z
.number() .number()