Skip to content

Commit

Permalink
feat: message builder
Browse files Browse the repository at this point in the history
  • Loading branch information
didinele committed Mar 7, 2025
1 parent 2d47290 commit 0d75c94
Show file tree
Hide file tree
Showing 6 changed files with 952 additions and 0 deletions.
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
172 changes: 172 additions & 0 deletions packages/builders/src/messages/AllowedMentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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;
}

/**
* Clear the types of mentions to parse from the content.
*/
public clearParse(): this {
this.data.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/en-US/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/en-US/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;
}
}
67 changes: 67 additions & 0 deletions packages/builders/src/messages/Assertions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
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()]),
Expand All @@ -8,3 +11,67 @@ export const attachmentPredicate = z.object({
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(), 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 just have at least one field set' },
);
Loading

0 comments on commit 0d75c94

Please sign in to comment.