Skip to content

feat: message builder #10793

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 14, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/builders/__tests__/messages/message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { AllowedMentionsTypes, MessageFlags } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { EmbedBuilder, MessageBuilder } from '../../src/index.js';

const base = {
allowed_mentions: undefined,
attachments: [],
components: [],
embeds: [],
message_reference: undefined,
poll: undefined,
};

describe('Message', () => {
test('GIVEN a message with pre-defined content THEN return valid toJSON data', () => {
const message = new MessageBuilder({ content: 'foo' });
expect(message.toJSON()).toStrictEqual({ ...base, content: 'foo' });
});

test('GIVEN bad action row THEN it throws', () => {
const message = new MessageBuilder().setComponents((row) =>
row.addTextInputComponent((input) => input.setCustomId('abc').setLabel('def')),
);
expect(() => message.toJSON()).toThrow();
});

test('GIVEN tons of data THEN return valid toJSON data', () => {
const message = new MessageBuilder()
.setContent('foo')
.setNonce(123)
.setTTS()
.addEmbeds(new EmbedBuilder().setTitle('foo').setDescription('bar'))
.setAllowedMentions({ parse: [AllowedMentionsTypes.Role], roles: ['123'] })
.setMessageReference({ channel_id: '123', message_id: '123' })
.setComponents((row) => row.addPrimaryButtonComponents((button) => button.setCustomId('abc').setLabel('def')))
.setStickerIds('123', '456')
.addAttachments((attachment) => attachment.setId('hi!').setFilename('abc'))
.setFlags(MessageFlags.Ephemeral)
.setEnforceNonce(false)
.updatePoll((poll) => poll.addAnswers({ poll_media: { text: 'foo' } }).setQuestion({ text: 'foo' }));

expect(message.toJSON()).toStrictEqual({
content: 'foo',
nonce: 123,
tts: true,
embeds: [{ title: 'foo', description: 'bar', author: undefined, fields: [], footer: undefined }],
allowed_mentions: { parse: ['roles'], roles: ['123'] },
message_reference: { channel_id: '123', message_id: '123' },
components: [
{
type: 1,
components: [{ type: 2, custom_id: 'abc', label: 'def', style: 1 }],
},
],
sticker_ids: ['123', '456'],
attachments: [{ id: 'hi!', filename: 'abc' }],
flags: 64,
enforce_nonce: false,
poll: {
question: { text: 'foo' },
answers: [{ poll_media: { text: 'foo' } }],
},
});
});
});
6 changes: 6 additions & 0 deletions packages/builders/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export * from './messages/poll/PollAnswerMedia.js';
export * from './messages/poll/PollMedia.js';
export * from './messages/poll/PollQuestion.js';

export * from './messages/AllowedMentions.js';
export * from './messages/Assertions.js';
export * from './messages/Attachment.js';
export * from './messages/Message.js';
export * from './messages/MessageReference.js';

export * from './util/componentUtil.js';
export * from './util/normalizeArray.js';
export * from './util/resolveBuilder.js';
Expand Down
164 changes: 164 additions & 0 deletions packages/builders/src/messages/AllowedMentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import type { JSONEncodable } from '@discordjs/util';
import type { AllowedMentionsTypes, APIAllowedMentions, Snowflake } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
import { validate } from '../util/validation.js';
import { allowedMentionPredicate } from './Assertions.js';

/**
* A builder that creates API-compatible JSON data for allowed mentions.
*/
export class AllowedMentionsBuilder implements JSONEncodable<APIAllowedMentions> {
private readonly data: Partial<APIAllowedMentions>;

/**
* Creates new allowed mention builder from API data.
*
* @param data - The API data to create this attachment with
*/
public constructor(data: Partial<APIAllowedMentions> = {}) {
this.data = structuredClone(data);
}

/**
* Sets the types of mentions to parse from the content.
*
* @param parse - The types of mentions to parse from the content
*/
public setParse(...parse: RestOrArray<AllowedMentionsTypes>): this {
this.data.parse = normalizeArray(parse);
return this;
}

/**
* Sets the roles to mention.
*
* @param roles - The roles to mention
*/
public setRoles(...roles: RestOrArray<Snowflake>): this {
this.data.roles = normalizeArray(roles);
return this;
}

/**
* Adds roles to mention.
*
* @param roles - The roles to mention
*/
public addRoles(...roles: RestOrArray<Snowflake>): this {
this.data.roles ??= [];
this.data.roles.push(...normalizeArray(roles));

return this;
}

/**
* Removes, replaces, or inserts roles.
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}.
*
* It's useful for modifying and adjusting order of the already-existing roles.
* @example
* Remove the first role:
* ```ts
* allowedMentions.spliceRoles(0, 1);
* ```
* @example
* Remove the first n role:
* ```ts
* const n = 4;
* allowedMentions.spliceRoles(0, n);
* ```
* @example
* Remove the last role:
* ```ts
* allowedMentions.spliceRoles(-1, 1);
* ```
* @param index - The index to start at
* @param deleteCount - The number of roles to remove
* @param roles - The replacing role ids
*/
public spliceRoles(index: number, deleteCount: number, ...roles: RestOrArray<Snowflake>): this {
this.data.roles ??= [];
this.data.roles.splice(index, deleteCount, ...normalizeArray(roles));
return this;
}

/**
* Sets the users to mention.
*
* @param users - The users to mention
*/
public setUsers(...users: RestOrArray<Snowflake>): this {
this.data.users = normalizeArray(users);
return this;
}

/**
* Adds users to mention.
*
* @param users - The users to mention
*/
public addUsers(...users: RestOrArray<Snowflake>): this {
this.data.users ??= [];
this.data.users.push(...normalizeArray(users));
return this;
}

/**
* Removes, replaces, or inserts users.
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}.
*
* It's useful for modifying and adjusting order of the already-existing users.
* @example
* Remove the first user:
* ```ts
* allowedMentions.spliceUsers(0, 1);
* ```
* @example
* Remove the first n user:
* ```ts
* const n = 4;
* allowedMentions.spliceUsers(0, n);
* ```
* @example
* Remove the last user:
* ```ts
* allowedMentions.spliceUsers(-1, 1);
* ```
* @param index - The index to start at
* @param deleteCount - The number of users to remove
* @param users - The replacing user ids
*/
public spliceUsers(index: number, deleteCount: number, ...users: RestOrArray<Snowflake>): this {
this.data.users ??= [];
this.data.users.splice(index, deleteCount, ...normalizeArray(users));
return this;
}

/**
* For replies, sets whether to mention the author of the message being replied to
*/
public setRepliedUser(repliedUser = true): this {
this.data.replied_user = repliedUser;
return this;
}

/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIAllowedMentions {
const clone = structuredClone(this.data);
validate(allowedMentionPredicate, clone, validationOverride);

return clone as APIAllowedMentions;
}
}
77 changes: 77 additions & 0 deletions packages/builders/src/messages/Assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { AllowedMentionsTypes, ComponentType, MessageReferenceType } from 'discord-api-types/v10';
import { z } from 'zod';
import { embedPredicate } from './embed/Assertions.js';
import { pollPredicate } from './poll/Assertions.js';

export const attachmentPredicate = z.object({
id: z.union([z.string(), z.number()]),
description: z.string().optional(),
duration_secs: z.number().optional(),
filename: z.string().optional(),
title: z.string().optional(),
waveform: z.string().optional(),
});

export const allowedMentionPredicate = z.object({
parse: z.nativeEnum(AllowedMentionsTypes).array().optional(),
roles: z.string().array().optional(),
users: z.string().array().optional(),
replied_user: z.boolean().optional(),
});

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.nativeEnum(MessageReferenceType).optional(),
});

export const messagePredicate = z
.object({
content: z.string().optional(),
nonce: z.union([z.string().max(25), z.number()]).optional(),
tts: z.boolean().optional(),
embeds: embedPredicate.array().max(10).optional(),
allowed_mentions: allowedMentionPredicate.optional(),
message_reference: messageReferencePredicate.optional(),
// Partial validation here to ensure the components are valid,
// rest of the validation is done in the action row predicate
components: 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),
]),
})
.array(),
})
.array()
.max(5)
.optional(),
sticker_ids: z.array(z.string()).min(0).max(3).optional(),
attachments: attachmentPredicate.array().max(10).optional(),
flags: z.number().optional(),
enforce_nonce: z.boolean().optional(),
poll: pollPredicate.optional(),
})
.refine(
(data) => {
return (
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)
);
},
{ message: 'Messages must have content, embeds, a poll, attachments, components, or stickers' },
);
Loading