Skip to content

Commit

Permalink
Modal Support! (#54)
Browse files Browse the repository at this point in the history
* feat: modal support

* chore: formatting
  • Loading branch information
thewilloftheshadow authored Sep 4, 2024
1 parent 9e93027 commit ad9666b
Show file tree
Hide file tree
Showing 11 changed files with 290 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-turkeys-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buape/carbon": minor
---

feat: Modal support
64 changes: 64 additions & 0 deletions apps/rocko/src/commands/testing/modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Command,
type CommandInteraction,
Modal,
type ModalInteraction,
Row,
TextInput,
TextInputStyle
} from "@buape/carbon"

export default class ModalCommand extends Command {
name = "modal"
description = "Modal test"

async run(interaction: CommandInteraction) {
await interaction.showModal(new TestModal())
}
}

class TestModal extends Modal {
title = "Test Modal"
customId = "test-modal"

components = [
new Row([new TextInputHi()]),
new Row([new TextInputName()]),
new Row([new TextInputAge()]),
new Row([new TextInputColor()]),
new Row([new TextInputHeight()])
]

run(interaction: ModalInteraction) {
return interaction.reply({
content: `Hi ${interaction.values.name}, you are ${interaction.values.age} years old, and your favorite color is ${interaction.values.color}. You are ${interaction.values.height || "not"} tall.`
})
}
}

class TextInputHi extends TextInput {
label = "Hi, how are you?"
customId = "hi"
style = TextInputStyle.Paragraph
}

class TextInputColor extends TextInput {
label = "What is your favorite color?"
customId = "color"
}

class TextInputAge extends TextInput {
label = "How old are you?"
customId = "age"
}

class TextInputName extends TextInput {
label = "What is your name?"
customId = "name"
}

class TextInputHeight extends TextInput {
label = "How tall are you?"
customId = "height"
required = false
}
3 changes: 2 additions & 1 deletion apps/rocko/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const client = new Client(
mode: ClientMode.NodeJS,
requestOptions: {
queueRequests: false
}
},
autoDeploy: true
},
await loadCommands("commands", __dirname)
)
Expand Down
32 changes: 25 additions & 7 deletions packages/carbon/src/abstracts/BaseInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import {
type InteractionType,
Routes
} from "discord-api-types/v10"
import type { Client } from "../classes/Client.js"
import type { Row } from "../classes/Row.js"
import { channelFactory } from "../factories/channelFactory.js"
import { Guild } from "../structures/Guild.js"
import {
Base,
type Client,
Guild,
Message,
type Modal,
type Row,
User,
channelFactory
} from "../index.js"
import { GuildMember } from "../structures/GuildMember.js"
import { Message } from "../structures/Message.js"
import { User } from "../structures/User.js"
import { Base } from "./Base.js"

/**
* The data to reply to an interaction
Expand Down Expand Up @@ -188,4 +191,19 @@ export abstract class BaseInteraction<T extends APIInteraction> extends Base {
}
)
}

async showModal(modal: Modal) {
if (this._deferred)
throw new Error("You cannot defer an interaction that shows a modal")
this.client.modalHandler.registerModal(modal)
await this.client.rest.post(
Routes.interactionCallback(this.rawData.id, this.rawData.token),
{
body: {
type: InteractionResponseType.Modal,
data: modal.serialize()
}
}
)
}
}
23 changes: 23 additions & 0 deletions packages/carbon/src/classes/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AutoRouter, type IRequestStrict, StatusError, json } from "itty-router"
import type { BaseCommand } from "../abstracts/BaseCommand.js"
import { CommandHandler } from "../internals/CommandHandler.js"
import { ComponentHandler } from "../internals/ComponentHandler.js"
import { ModalHandler } from "../internals/ModalHandler.js"

/**
* The mode that the client is running in.
Expand Down Expand Up @@ -99,9 +100,19 @@ export class Client {
rest: RequestClient
/**
* The handler for the component interactions sent from Discord
* @internal
*/
componentHandler: ComponentHandler
/**
* The handler for the modal interactions sent from Discord
* @internal
*/
commandHandler: CommandHandler
/**
* The handler for the modal interactions sent from Discord
* @internal
*/
modalHandler: ModalHandler

/**
* Creates a new client
Expand All @@ -123,6 +134,7 @@ export class Client {
this.rest = new RequestClient(options.token, options.requestOptions)
this.componentHandler = new ComponentHandler(this)
this.commandHandler = new CommandHandler(this)
this.modalHandler = new ModalHandler(this)
this.setupRoutes()
if (this.options.autoDeploy) this.deployCommands()
}
Expand Down Expand Up @@ -219,6 +231,17 @@ export class Client {
await this.componentHandler.handleInteraction(rawInteraction)
}
}
if (rawInteraction.type === InteractionType.ModalSubmit) {
if (ctx?.waitUntil) {
ctx.waitUntil(
(async () => {
await this.modalHandler.handleInteraction(rawInteraction)
})()
)
} else {
await this.modalHandler.handleInteraction(rawInteraction)
}
}
return new Response(null, { status: 202 })
}

Expand Down
38 changes: 38 additions & 0 deletions packages/carbon/src/classes/Modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {
APIActionRowComponent,
APIModalInteractionResponseCallbackData,
APITextInputComponent
} from "discord-api-types/v10"
import type { ModalInteraction } from "../internals/ModalInteraction.js"
import type { Row } from "./Row.js"
import type { TextInput } from "./TextInput.js"

export abstract class Modal {
/**
* The title of the modal
*/
abstract title: string

/**
* The components of the modal
*/
components: Row<TextInput>[] = []

/**
* The custom ID of the modal
*/
abstract customId: string

abstract run(interaction: ModalInteraction): Promise<void>

serialize = (): APIModalInteractionResponseCallbackData => {
return {
title: this.title,
custom_id: this.customId,
components: this.components.map(
(row) =>
row.serialize() as unknown as APIActionRowComponent<APITextInputComponent>
)
}
}
}
20 changes: 13 additions & 7 deletions packages/carbon/src/classes/Row.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import type {
APIActionRowComponent,
APIActionRowComponentTypes
} from "discord-api-types/v10"
import type { BaseComponent } from "../abstracts/BaseComponent.js"

export class Row {
export class Row<T extends BaseComponent = BaseComponent> {
/**
* The components in the action row
*/
components: BaseComponent[] = []
components: T[] = []

constructor(components?: BaseComponent[]) {
constructor(components?: T[]) {
if (components) this.components = components
}

/**
* Add a component to the action row
* @param component The component to add
*/
addComponent(component: BaseComponent) {
addComponent(component: T) {
this.components.push(component)
}

/**
* Remove a component from the action row
* @param component The component to remove
*/
removeComponent(component: BaseComponent) {
removeComponent(component: T) {
const index = this.components.indexOf(component)
if (index === -1) return
this.components.splice(index, 1)
Expand All @@ -35,10 +39,12 @@ export class Row {
this.components = []
}

serialize() {
serialize = (): APIActionRowComponent<APIActionRowComponentTypes> => {
return {
type: 1,
components: this.components.map((component) => component.serialize())
components: this.components.map((component) =>
component.serialize()
) as APIActionRowComponentTypes[]
}
}
}
65 changes: 65 additions & 0 deletions packages/carbon/src/classes/TextInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
type APITextInputComponent,
ComponentType,
TextInputStyle
} from "discord-api-types/v10"
import { BaseComponent } from "../abstracts/BaseComponent.js"

export abstract class TextInput extends BaseComponent {
type = ComponentType.TextInput

/**
* The custom ID of the text input
*/
abstract customId: string

/**
* The label of the text input
*/
abstract label: string

/**
* The style of the text input
* @default TextInputStyle.Short
*/
style: TextInputStyle = TextInputStyle.Short

/**
* The minimum length of the text input
*/
minLength?: number

/**
* The maximum length of the text input
*/
maxLength?: number

/**
* Whether the text input is required
*/
required?: boolean

/**
* The value of the text input
*/
value?: string

/**
* The placeholder of the text input
*/
placeholder?: string

serialize = (): APITextInputComponent => {
return {
type: ComponentType.TextInput,
custom_id: this.customId,
style: this.style,
label: this.label,
min_length: this.minLength,
max_length: this.maxLength,
required: this.required,
value: this.value,
placeholder: this.placeholder
}
}
}
3 changes: 3 additions & 0 deletions packages/carbon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ export * from "./classes/Command.js"
export * from "./classes/CommandWithSubcommandGroups.js"
export * from "./classes/CommandWithSubcommands.js"
export * from "./classes/MentionableSelectMenu.js"
export * from "./classes/Modal.js"
export * from "./classes/RoleSelectMenu.js"
export * from "./classes/Row.js"
export * from "./classes/TextInput.js"
export * from "./classes/StringSelectMenu.js"
export * from "./classes/UserSelectMenu.js"

Expand All @@ -35,6 +37,7 @@ export * from "./internals/CommandHandler.js"
export * from "./internals/CommandInteraction.js"
export * from "./internals/ComponentHandler.js"
export * from "./internals/MentionableSelectMenuInteraction.js"
export * from "./internals/ModalInteraction.js"
export * from "./internals/OptionsHandler.js"
export * from "./internals/RoleSelectMenuInteraction.js"
export * from "./internals/StringSelectMenuInteraction.js"
Expand Down
27 changes: 27 additions & 0 deletions packages/carbon/src/internals/ModalHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { APIModalSubmitInteraction } from "discord-api-types/v10"
import { Base } from "../abstracts/Base.js"
import type { Modal } from "../classes/Modal.js"
import { ModalInteraction } from "./ModalInteraction.js"

export class ModalHandler extends Base {
modals: Modal[] = []
/**
* Register a modal with the handler
* @internal
*/
registerModal(modal: Modal) {
if (!this.modals.find((x) => x.customId === modal.customId)) {
this.modals.push(modal)
}
}
/**
* Handle an interaction
* @internal
*/
async handleInteraction(data: APIModalSubmitInteraction) {
const modal = this.modals.find((x) => x.customId === data.data.custom_id)
if (!modal) return false

modal.run(new ModalInteraction(this.client, data))
}
}
Loading

0 comments on commit ad9666b

Please sign in to comment.