Skip to content

Commit

Permalink
feat: webhook events (#210)
Browse files Browse the repository at this point in the history
* feat: webhook events

* Apply suggestions from code review

Co-authored-by: Kodie <[email protected]>

* remove client.log

* fix: adjust docs code

* fix cloudo

* Re-add default for plugin array

Co-authored-by: Kodie <[email protected]>

---------

Co-authored-by: Kodie <[email protected]>
  • Loading branch information
thewilloftheshadow and apteryxxyz authored Jan 22, 2025
1 parent c6c60a6 commit 4b8d474
Show file tree
Hide file tree
Showing 13 changed files with 282 additions and 42 deletions.
6 changes: 6 additions & 0 deletions .changeset/little-donkeys-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@buape/carbon": minor
"create-carbon": minor
---

feat: webhook events
19 changes: 19 additions & 0 deletions apps/cloudo/src/events/authorized.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
ApplicationIntegrationType,
ApplicationWebhookEventType,
Listener,
type ListenerEventData
} from "@buape/carbon"

export default class ApplicationAuthorizedListener extends Listener {
type = ApplicationWebhookEventType.ApplicationAuthorized
async handle(
data: ListenerEventData<ApplicationWebhookEventType.ApplicationAuthorized>
) {
if (data.integration_type === ApplicationIntegrationType.GuildInstall) {
console.log(`Added to server ${data.guild?.name} (${data.guild?.id})`)
} else {
console.log(`Added to user ${data.user.username} (${data.user.id})`)
}
}
}
32 changes: 18 additions & 14 deletions apps/cloudo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import OptionsCommand from "./commands/testing/options.js"
import SubcommandsCommand from "./commands/testing/subcommand.js"
import SubcommandGroupsCommand from "./commands/testing/subcommandgroup.js"
import UserCommand from "./commands/testing/user_command.js"
import ApplicationAuthorizedListener from "./events/authorized.js"

const linkedRoles = new LinkedRoles({
metadata: [
Expand Down Expand Up @@ -42,20 +43,23 @@ const client = new Client(
publicKey: process.env.DISCORD_PUBLIC_KEY,
token: process.env.DISCORD_BOT_TOKEN
},
[
// commands/*
new PingCommand(),
// commands/testing/*
new ButtonCommand(),
new EphemeralCommand(),
new EverySelectCommand(),
new MessageCommand(),
new ModalCommand(),
new OptionsCommand(),
new SubcommandsCommand(),
new SubcommandGroupsCommand(),
new UserCommand()
],
{
commands: [
// commands/*
new PingCommand(),
// commands/testing/*
new ButtonCommand(),
new EphemeralCommand(),
new EverySelectCommand(),
new MessageCommand(),
new ModalCommand(),
new OptionsCommand(),
new SubcommandsCommand(),
new SubcommandGroupsCommand(),
new UserCommand()
],
listeners: [new ApplicationAuthorizedListener()]
},
[linkedRoles]
)

Expand Down
2 changes: 1 addition & 1 deletion apps/cloudo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"lib": ["es2022"],
"lib": ["es2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": ".",
Expand Down
19 changes: 19 additions & 0 deletions apps/rocko/src/events/authorized.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
ApplicationIntegrationType,
ApplicationWebhookEventType,
Listener,
type ListenerEventData
} from "@buape/carbon"

export class ApplicationAuthorizedListener extends Listener {
type = ApplicationWebhookEventType.ApplicationAuthorized
override async handle(
data: ListenerEventData<ApplicationWebhookEventType.ApplicationAuthorized>
) {
if (data.integration_type === ApplicationIntegrationType.GuildInstall) {
console.log(`Added to server ${data.guild?.name} (${data.guild?.id})`)
} else {
console.log(`Added to user ${data.user.username} (${data.user.id})`)
}
}
}
44 changes: 28 additions & 16 deletions apps/rocko/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import OptionsCommand from "./commands/testing/options.js"
import SubcommandsCommand from "./commands/testing/subcommand.js"
import SubcommandGroupsCommand from "./commands/testing/subcommandgroup.js"
import UserCommand from "./commands/testing/user_command.js"
import { ApplicationAuthorizedListener } from "./events/authorized.js"

const linkedRoles = new LinkedRoles({
metadata: [
Expand Down Expand Up @@ -45,25 +46,36 @@ const client = new Client(
publicKey: process.env.DISCORD_PUBLIC_KEY,
token: process.env.DISCORD_BOT_TOKEN
},
[
// commands/*
new PingCommand(),
// commands/testing/*
new AttachmentCommand(),
new ButtonCommand(),
new EphemeralCommand(),
new EverySelectCommand(),
new MessageCommand(),
new ModalCommand(),
new OptionsCommand(),
new SubcommandsCommand(),
new SubcommandGroupsCommand(),
new UserCommand(),
new MentionsCommand()
],
{
commands: [
// commands/*
new PingCommand(),
// commands/testing/*
new AttachmentCommand(),
new ButtonCommand(),
new EphemeralCommand(),
new EverySelectCommand(),
new MessageCommand(),
new ModalCommand(),
new OptionsCommand(),
new SubcommandsCommand(),
new SubcommandGroupsCommand(),
new UserCommand(),
new MentionsCommand()
],
listeners: [new ApplicationAuthorizedListener()]
},
[linkedRoles]
)

console.log(
`Carbon initalized with routes:${client.routes
.filter((x) => !x.disabled)
.map((x) => {
return `\n\t${x.method} ${x.path}`
})}`
)

createServer(client, { port: 3000 })

declare global {
Expand Down
62 changes: 55 additions & 7 deletions packages/carbon/src/classes/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
type APIInteraction,
type APIRole,
type APIUser,
type APIWebhookEvent,
ApplicationWebhookType,
InteractionResponseType,
InteractionType,
Routes
Expand All @@ -15,12 +17,14 @@ import type { Context, Plugin, Route } from "../abstracts/Plugin.js"
import { channelFactory } from "../factories/channelFactory.js"
import { CommandHandler } from "../internals/CommandHandler.js"
import { ComponentHandler } from "../internals/ComponentHandler.js"
import { EventHandler } from "../internals/EventHandler.js"
import { ModalHandler } from "../internals/ModalHandler.js"
import { Guild } from "../structures/Guild.js"
import { GuildMember } from "../structures/GuildMember.js"
import { Role } from "../structures/Role.js"
import { User } from "../structures/User.js"
import { concatUint8Arrays, subtleCrypto, valueToUint8Array } from "../utils.js"
import type { Listener } from "./Listener.js"

/**
* The options used for initializing the client
Expand Down Expand Up @@ -67,10 +71,15 @@ export interface ClientOptions {
*/
disableDeployRoute?: boolean
/**
* Whether the interactions route should
* Whether the interactions route should be disabled
* @default false
*/
disableInteractionsRoute?: boolean
/**
* Whether the events route should be disabled
* @default false
*/
disableEventsRoute?: boolean
}

/**
Expand All @@ -93,6 +102,10 @@ export class Client {
* The commands that the client has registered
*/
commands: BaseCommand[]
/**
* The event listeners that the client has registered
*/
listeners: Listener[] = []
/**
* The rest client used to interact with the Discord API
*/
Expand All @@ -112,6 +125,11 @@ export class Client {
* @internal
*/
modalHandler: ModalHandler
/**
* The handler for events sent from Discord
* @internal
*/
eventHandler: EventHandler

/**
* Creates a new client
Expand All @@ -121,7 +139,10 @@ export class Client {
*/
constructor(
options: ClientOptions,
commands: BaseCommand[],
handlers: {
commands?: BaseCommand[]
listeners?: Listener[]
},
plugins: Plugin[] = []
) {
if (!options.clientId) throw new Error("Missing client ID")
Expand All @@ -131,14 +152,16 @@ export class Client {
throw new Error("Missing deploy secret")

this.options = options
this.commands = commands
this.commands = handlers.commands ?? []
this.listeners = handlers.listeners ?? []

// Remove trailing slashes from the base URL
options.baseUrl = options.baseUrl.replace(/\/+$/, "")

this.commandHandler = new CommandHandler(this)
this.componentHandler = new ComponentHandler(this)
this.modalHandler = new ModalHandler(this)
this.eventHandler = new EventHandler(this)

this.rest = new RequestClient(options.token, options.requestOptions)

Expand All @@ -150,7 +173,7 @@ export class Client {
}

if (!options.disableAutoRegister) {
for (const command of commands) {
for (const command of this.commands) {
for (const component of command.components)
this.componentHandler.registerComponent(new component())
for (const modal of command.modals)
Expand All @@ -176,6 +199,12 @@ export class Client {
handler: this.handleInteractionsRequest.bind(this),
disabled: this.options.disableInteractionsRoute
})
this.routes.push({
method: "POST",
path: "/events",
handler: this.handleEventsRequest.bind(this),
disabled: this.options.disableEventsRoute
})
}

/**
Expand All @@ -193,14 +222,33 @@ export class Client {
return new Response("OK", { status: 202 })
}

/**
* Handle an interaction request from Discord
* @param req The request to handle
* @returns A response
*/
public async handleEventsRequest(req: Request) {
const isValid = await this.validateDiscordRequest(req)
if (!isValid) return new Response("Unauthorized", { status: 401 })

const payload = (await req.json()) as APIWebhookEvent

// All ping webhooks should respond with 204 and an empty body
if (payload.type === ApplicationWebhookType.Ping)
return new Response(null, { status: 204 })

this.eventHandler.handleEvent(payload) // Events will never return anything to Discord
return new Response(null, { status: 204 })
}

/**
* Handle an interaction request from Discord
* @param req The request to handle
* @param ctx The context for the request
* @returns A response
*/
public async handleInteractionsRequest(req: Request, ctx: Context) {
const isValid = await this.validateInteractionRequest(req)
const isValid = await this.validateDiscordRequest(req)
if (!isValid) return new Response("Unauthorized", { status: 401 })

const interaction = (await req.json()) as APIInteraction
Expand Down Expand Up @@ -238,10 +286,10 @@ export class Client {
}

/**
* Validate the interaction request
* Validate a request from Discord
* @param req The request to validate
*/
private async validateInteractionRequest(req: Request) {
private async validateDiscordRequest(req: Request) {
const body = await req.clone().text()
const signature = req.headers.get("X-Signature-Ed25519")
const timestamp = req.headers.get("X-Signature-Timestamp")
Expand Down
21 changes: 21 additions & 0 deletions packages/carbon/src/classes/Listener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type {
APIWebhookEventApplicationAuthorizedData,
APIWebhookEventEntitlementCreateData,
ApplicationWebhookEventType
} from "discord-api-types/v10"
import type { Client } from "./Client.js"

export type ListenerEventData<T extends ApplicationWebhookEventType> =
T extends ApplicationWebhookEventType.ApplicationAuthorized
? APIWebhookEventApplicationAuthorizedData
: T extends ApplicationWebhookEventType.EntitlementCreate
? APIWebhookEventEntitlementCreateData
: never

export abstract class Listener {
abstract type: ApplicationWebhookEventType
abstract handle(
data: ListenerEventData<typeof this.type>,
client: Client
): Promise<void>
}
1 change: 1 addition & 0 deletions packages/carbon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./classes/Command.js"
export * from "./classes/CommandWithSubcommandGroups.js"
export * from "./classes/CommandWithSubcommands.js"
export * from "./classes/Embed.js"
export * from "./classes/Listener.js"
export * from "./classes/MentionableSelectMenu.js"
export * from "./classes/Modal.js"
export * from "./classes/RoleSelectMenu.js"
Expand Down
20 changes: 20 additions & 0 deletions packages/carbon/src/internals/EventHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
type APIWebhookEvent,
ApplicationWebhookType
} from "discord-api-types/v10"
import { Base } from "../abstracts/Base.js"
import type { Listener } from "../classes/Listener.js"

export class EventHandler extends Base {
listeners: Listener[] = []

registerListener(listener: (typeof this.listeners)[number]) {
this.listeners.push(listener)
}

async handleEvent(payload: APIWebhookEvent) {
if (payload.type !== ApplicationWebhookType.Event) return
const listener = this.listeners.find((x) => x.type === payload.event.type)
if (listener) return await listener.handle(payload.event.data, this.client)
}
}
Loading

0 comments on commit 4b8d474

Please sign in to comment.