Skip to content

Better component handling #240

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 7 commits into from
Apr 29, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/polite-taxis-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buape/carbon": minor
---

feat: mount components when used, allowing for custom constructor setups
5 changes: 5 additions & 0 deletions .changeset/salty-worms-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buape/carbon": minor
---

feat: implement a custom ID parser system for component data specific to each usage of a component
14 changes: 10 additions & 4 deletions apps/cloudo/src/commands/testing/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ButtonStyle,
Command,
type CommandInteraction,
type ComponentData,
LinkButton,
Row
} from "@buape/carbon"
Expand All @@ -13,8 +14,6 @@ export default class ButtonCommand extends Command {
description = "A simple command with a button!"
defer = true

components = [ClickMeButton]

async run(interaction: CommandInteraction) {
await interaction.reply({
content: "Look at this button!",
Expand All @@ -28,8 +27,15 @@ class ClickMeButton extends Button {
label = "Click me!"
style = ButtonStyle.Primary

async run(interaction: ButtonInteraction) {
await interaction.reply("You clicked the button!")
constructor() {
super()
this.customId = `click-me:time=${Date.now()}`
}

async run(interaction: ButtonInteraction, data: ComponentData) {
await interaction.reply(
`You clicked the button that was generated at time ${data.time}, ${interaction.user?.username ?? "friend"}!`
)
}
}

Expand Down
8 changes: 0 additions & 8 deletions apps/cloudo/src/commands/testing/every_select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,6 @@ export default class EverySelectCommand extends Command {
description = "Send every select menu"
defer = true

components = [
StringSelect,
RoleSelect,
MentionableSelect,
ChannelSelect,
UserSelect
]

async run(interaction: CommandInteraction) {
const stringRow = new Row([new StringSelect()])
const roleRow = new Row([new RoleSelect()])
Expand Down
2 changes: 0 additions & 2 deletions apps/cloudo/src/commands/testing/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export default class ModalCommand extends Command {
name = "modal"
description = "Modal test"

modals = [TestModal]

async run(interaction: CommandInteraction) {
await interaction.showModal(new TestModal())
}
Expand Down
14 changes: 10 additions & 4 deletions apps/rocko/src/commands/testing/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ButtonStyle,
Command,
type CommandInteraction,
type ComponentData,
LinkButton,
Row
} from "@buape/carbon"
Expand All @@ -13,8 +14,6 @@ export default class ButtonCommand extends Command {
description = "A simple command with a button!"
defer = true

components = [ClickMeButton]

async run(interaction: CommandInteraction) {
await interaction.reply({
content: "Look at this button!",
Expand All @@ -28,8 +27,15 @@ class ClickMeButton extends Button {
label = "Click me!"
style = ButtonStyle.Primary

async run(interaction: ButtonInteraction) {
await interaction.reply("You clicked the button!")
constructor() {
super()
this.customId = `click-me:time=${Date.now()}`
}

async run(interaction: ButtonInteraction, data: ComponentData) {
await interaction.reply(
`You clicked the button that was generated at time ${data.time}, ${interaction.user?.username ?? "friend"}!`
)
}
}

Expand Down
8 changes: 0 additions & 8 deletions apps/rocko/src/commands/testing/every_select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,6 @@ export default class EverySelectCommand extends Command {
description = "Send every select menu"
defer = true

components = [
StringSelect,
RoleSelect,
MentionableSelect,
ChannelSelect,
UserSelect
]

async run(interaction: CommandInteraction) {
const stringRow = new Row([new StringSelect()])
const roleRow = new Row([new RoleSelect()])
Expand Down
2 changes: 0 additions & 2 deletions apps/rocko/src/commands/testing/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export default class ModalCommand extends Command {
name = "modal"
description = "Modal test"

modals = [TestModal]

async run(interaction: CommandInteraction) {
await interaction.showModal(new TestModal())
}
Expand Down
5 changes: 5 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pre-commit:
commands:
check:
run: pnpm lint
stage_fixed: true
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@changesets/cli": "2.29.1",
"@net-tech-/env-cmd": "1.0.3",
"@turbo/gen": "2.5.0",
"lefthook": "1.11.12",
"tsc-watch": "6.2.1",
"turbo": "2.5.0",
"type-fest": "4.40.0",
Expand Down
7 changes: 5 additions & 2 deletions packages/carbon/src/abstracts/AnySelectMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import type {
APIUserSelectComponent,
ComponentType
} from "discord-api-types/v10"
import type { ComponentData } from "../types/index.js"
import type { AnySelectMenuInteraction } from "./AnySelectMenuInteraction.js"
import { BaseMessageInteractiveComponent } from "./BaseMessageInteractiveComponent.js"

export type AnySelectMenuComponentType =
| ComponentType.ChannelSelect
| ComponentType.RoleSelect
Expand All @@ -19,7 +19,10 @@ export type AnySelectMenuComponentType =

export abstract class AnySelectMenu extends BaseMessageInteractiveComponent {
abstract type: AnySelectMenuComponentType
abstract run(interaction: AnySelectMenuInteraction): Promise<void>
abstract run(
interaction: AnySelectMenuInteraction,
data: ComponentData
): Promise<void>

minValues?: number
maxValues?: number
Expand Down
4 changes: 0 additions & 4 deletions packages/carbon/src/abstracts/AnySelectMenuInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,10 @@ import {
InteractionType
} from "discord-api-types/v10"
import type { Client } from "../classes/Client.js"
import { splitCustomId } from "../utils.js"
import { BaseComponentInteraction } from "./BaseComponentInteraction.js"
import type { InteractionDefaults } from "./BaseInteraction.js"

export abstract class AnySelectMenuInteraction extends BaseComponentInteraction {
customId: string = splitCustomId(
(this.rawData.data as APIMessageSelectMenuInteractionData).custom_id
)[0]
constructor(
client: Client,
data: APIMessageComponentSelectMenuInteraction,
Expand Down
2 changes: 1 addition & 1 deletion packages/carbon/src/abstracts/BaseChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
Routes
} from "discord-api-types/v10"
import type { Client } from "../classes/Client.js"
import type { IfPartial } from "../utils.js"
import type { IfPartial } from "../types/index.js"
import { Base } from "./Base.js"

export abstract class BaseChannel<
Expand Down
14 changes: 0 additions & 14 deletions packages/carbon/src/abstracts/BaseCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import {
import {
ApplicationIntegrationType,
type ArrayOrSingle,
type BaseMessageInteractiveComponent,
InteractionContextType,
type Modal,
type Permission
} from "../index.js"

Expand Down Expand Up @@ -57,18 +55,6 @@ export abstract class BaseCommand {
*/
permission?: ArrayOrSingle<(typeof Permission)[keyof typeof Permission]>

/**
* The components that the command is able to use.
* You pass these here so the handler can listen for them..
*/
components: (new () => BaseMessageInteractiveComponent)[] = []

/**
* All the modals that the command is able to use.
* You pass these here so the handler can listen for them.
*/
modals: (new () => Modal)[] = []

/**
* Serializes the command into a JSON object that can be sent to Discord
* @internal
Expand Down
4 changes: 1 addition & 3 deletions packages/carbon/src/abstracts/BaseComponentInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ import {
} from "discord-api-types/v10"
import type { Client } from "../classes/Client.js"
import type { MessagePayload } from "../types/index.js"
import { serializePayload, splitCustomId } from "../utils.js"
import { serializePayload } from "../utils/index.js"
import { BaseInteraction, type InteractionDefaults } from "./BaseInteraction.js"

export class BaseComponentInteraction extends BaseInteraction<APIMessageComponentInteraction> {
customId: string
componentType: ComponentType
constructor(
client: Client,
Expand All @@ -21,7 +20,6 @@ export class BaseComponentInteraction extends BaseInteraction<APIMessageComponen
super(client, data, defaults)
if (!data.data)
throw new Error("Invalid interaction data was used to create this class")
this.customId = splitCustomId(data.data.custom_id)[0]
this.componentType = data.data.component_type
}

Expand Down
4 changes: 2 additions & 2 deletions packages/carbon/src/abstracts/BaseGuildChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
import { Guild } from "../structures/Guild.js"
import type { GuildCategoryChannel } from "../structures/GuildCategoryChannel.js"
import type { MessagePayload } from "../types/index.js"
import type { IfPartial } from "../utils.js"
import { serializePayload } from "../utils.js"
import type { IfPartial } from "../types/index.js"
import { serializePayload } from "../utils/index.js"
import { BaseChannel } from "./BaseChannel.js"

export abstract class BaseGuildChannel<
Expand Down
2 changes: 1 addition & 1 deletion packages/carbon/src/abstracts/BaseGuildTextChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "discord-api-types/v10"
import { GuildThreadChannel } from "../structures/GuildThreadChannel.js"
import { Message } from "../structures/Message.js"
import type { IfPartial } from "../utils.js"
import type { IfPartial } from "../types/index.js"
import { BaseGuildChannel } from "./BaseGuildChannel.js"

export abstract class BaseGuildTextChannel<
Expand Down
44 changes: 43 additions & 1 deletion packages/carbon/src/abstracts/BaseInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ import {
Routes
} from "discord-api-types/v10"
import {
BaseMessageInteractiveComponent,
type Client,
Embed,
Guild,
Message,
type Modal,
Row,
User,
channelFactory
} from "../index.js"
import { GuildMember } from "../structures/GuildMember.js"
import type { MessagePayload } from "../types/index.js"
import { serializePayload } from "../utils.js"
import { serializePayload } from "../utils/index.js"
import { Base } from "./Base.js"

export type InteractionDefaults = {
Expand Down Expand Up @@ -93,13 +95,40 @@ export abstract class BaseInteraction<T extends APIInteraction> extends Base {
return new GuildMember(this.client, this.rawData.member, this.guild)
}

private autoRegisterComponents(data: MessagePayload) {
if (typeof data !== "string" && data.components) {
for (const component of data.components) {
if (component instanceof Row) {
for (const childComponent of component.components) {
if (childComponent instanceof BaseMessageInteractiveComponent) {
const key = childComponent.customIdParser(
childComponent.customId
).key
const existingComponent =
this.client.componentHandler.components.find(
(comp) => comp.customIdParser(comp.customId).key === key
)
if (!existingComponent) {
this.client.componentHandler.registerComponent(childComponent)
}
}
}
}
}
}
}

/**
* Reply to an interaction.
* If the interaction is deferred, this will edit the original response.
* @param data The response data
*/
async reply(data: MessagePayload) {
const serialized = serializePayload(data, this.defaultEphemeral)

// Auto-register any components in the message
this.autoRegisterComponents(data)

if (this._deferred) {
await this.client.rest.patch(
Routes.webhookMessage(
Expand Down Expand Up @@ -155,6 +184,15 @@ 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")

const key = modal.customIdParser(modal.customId).key
const existingModal = this.client.modalHandler.modals.find(
(m) => m.customIdParser(m.customId).key === key
)
if (!existingModal) {
this.client.modalHandler.registerModal(modal)
}

await this.client.rest.post(
Routes.interactionCallback(this.rawData.id, this.rawData.token),
{
Expand All @@ -171,6 +209,10 @@ export abstract class BaseInteraction<T extends APIInteraction> extends Base {
*/
async followUp(reply: MessagePayload) {
const serialized = serializePayload(reply)

// Auto-register any components in the message
this.autoRegisterComponents(reply)

await this.client.rest.post(
Routes.webhook(this.client.options.clientId, this.rawData.token),
{
Expand Down
Loading