-
-
Notifications
You must be signed in to change notification settings - Fork 4k
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
feat: message builder #10793
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
53d0b03
feat: attachment builder
didinele 408ca33
feat: message builder
didinele a445a6c
chore: nits
didinele 8438f94
fix: nonce assertion
didinele edd4d92
chore: strip bad method
didinele 693550a
chore: nit
didinele 46cc669
chore: nits
didinele 643d1d3
chore: address final review
didinele File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' } }], | ||
}, | ||
}); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
didinele marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
didinele marked this conversation as resolved.
Show resolved
Hide resolved
|
||
.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' }, | ||
); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.