Skip to content

Commit

Permalink
Add interaction handling and ping component
Browse files Browse the repository at this point in the history
  • Loading branch information
D4isDAVID committed Sep 9, 2024
1 parent d5a4162 commit 73b8346
Show file tree
Hide file tree
Showing 11 changed files with 905 additions and 34 deletions.
459 changes: 430 additions & 29 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dependencies": {
"@discordjs/core": "1.0.0",
"@discordjs/rest": "2.0.0",
"@discordjs/util": "1.0.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
Expand Down
75 changes: 75 additions & 0 deletions server/components/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Collection } from '@discordjs/collection';
import { RESTPutAPIApplicationCommandsJSONBody } from '@discordjs/core/http-only';
import EventEmitter from 'node:events';
import { inspect } from 'node:util';
import { rest } from '../utils/env.js';
import { isStatefulInteraction } from '../utils/stateful.js';
import ping from './ping/index.js';
import {
ApplicationCommand,
Component,
EventName,
EventsMap,
MessageComponent,
Modal,
} from './types.js';

export const interactions = {
commands: new Collection<string, ApplicationCommand>(),
messageComponents: new Collection<string, MessageComponent>(),
modals: new Collection<string, Modal>(),
};

export const commands: RESTPutAPIApplicationCommandsJSONBody = [];

export const statefuls = {
messageComponents: [],
modals: [],
} as { messageComponents: string[]; modals: string[] };

function registerEvent(emitter: EventEmitter, event: EventsMap[EventName]) {
emitter[event.type](event.name, async (...args) => {
try {
await event.execute(...args);
} catch (err) {
console.error(inspect(err));
}
});
}

function registerEvents(emitter: EventEmitter, events: EventsMap[EventName][]) {
for (const event of events) {
registerEvent(emitter, event);
}
}

function loadComponent({
restEvents,
commands: componentCommands,
messageComponents,
modals,
}: Component) {
restEvents && registerEvents(rest, restEvents);

componentCommands?.map((command) => {
interactions.commands.set(command.data.name, command);
commands.push(command.data);
});
messageComponents?.map((messageComponent) => {
const customId = messageComponent.data.custom_id;
interactions.messageComponents.set(customId, messageComponent);

if (isStatefulInteraction(messageComponent))
statefuls.messageComponents.push(customId);
});
modals?.map((modal) => {
const customId = modal.data.custom_id;
interactions.modals.set(customId, modal);

if (isStatefulInteraction(modal)) statefuls.modals.push(customId);
});
}

export function loadComponents() {
loadComponent(ping);
}
29 changes: 29 additions & 0 deletions server/components/ping/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ChatInputCommand } from '../types.js';

function pingMessage(p: string) {
return `🏓 Pong! \`${p}\``;
}

export const command = {
data: {
name: 'ping',
description: 'Ping command',
},
async execute({ api, data: interaction, cb }) {
cb();

const first = Date.now();
await api.interactions.reply(interaction.id, interaction.token, {
content: pingMessage('fetching...'),
});

const ping = Math.ceil((Date.now() - first) / 2);
await api.interactions.editReply(
interaction.application_id,
interaction.token,
{
content: pingMessage(`${ping}ms`),
},
);
},
} satisfies ChatInputCommand;
6 changes: 6 additions & 0 deletions server/components/ping/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Component } from '../types.js';
import { command } from './command.js';

export default {
commands: [command],
} satisfies Component;
140 changes: 140 additions & 0 deletions server/components/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
API,
APIApplicationCommandAutocompleteInteraction,
APIApplicationCommandInteraction,
APIButtonComponentWithCustomId,
APIChannelSelectComponent,
APIChatInputApplicationCommandInteraction,
APIContextMenuInteraction,
APIInteraction,
APIInteractionResponse,
APIMentionableSelectComponent,
APIMessageApplicationCommandInteraction,
APIMessageComponentButtonInteraction,
APIMessageComponentInteraction,
APIMessageComponentSelectMenuInteraction,
APIModalInteractionResponseCallbackData,
APIModalSubmitInteraction,
APIRoleSelectComponent,
APIStringSelectComponent,
APIUserApplicationCommandInteraction,
APIUserSelectComponent,
ApplicationCommandType,
ComponentType,
RESTPostAPIChatInputApplicationCommandsJSONBody,
RESTPostAPIContextMenuApplicationCommandsJSONBody,
} from '@discordjs/core/http-only';
import { RestEvents } from '@discordjs/rest';
import { Awaitable } from '@discordjs/util';

export type EventName = keyof RestEvents;

export type EventExecuteArgs<T extends EventName> = T extends keyof RestEvents
? RestEvents[T]
: never;

export interface IEvent<T extends EventName> {
readonly type: 'on' | 'once';
readonly name: T;
readonly execute: (...args: EventExecuteArgs<T>) => Awaitable<void>;
}

export type ContextMenuInteractionType<T extends APIContextMenuInteraction> =
T extends APIUserApplicationCommandInteraction
? ApplicationCommandType.User
: T extends APIMessageApplicationCommandInteraction
? ApplicationCommandType.Message
: never;

export interface MessageComponentDataMap {
[ComponentType.ActionRow]: never;
[ComponentType.Button]: APIButtonComponentWithCustomId;
[ComponentType.StringSelect]: APIStringSelectComponent;
[ComponentType.TextInput]: never;
[ComponentType.UserSelect]: APIUserSelectComponent;
[ComponentType.RoleSelect]: APIRoleSelectComponent;
[ComponentType.MentionableSelect]: APIMentionableSelectComponent;
[ComponentType.ChannelSelect]: APIChannelSelectComponent;
}

export type InteractionData<T extends APIInteraction> =
T extends APIApplicationCommandInteraction
? T extends APIChatInputApplicationCommandInteraction
? RESTPostAPIChatInputApplicationCommandsJSONBody
: T extends APIContextMenuInteraction
? RESTPostAPIContextMenuApplicationCommandsJSONBody & {
type: ContextMenuInteractionType<T>;
}
: never
: T extends APIMessageComponentInteraction
? MessageComponentDataMap[T['data']['component_type']]
: T extends APIModalSubmitInteraction
? APIModalInteractionResponseCallbackData
: never;

export type InteractionExecuteArgs<T extends APIInteraction> = {
api: API;
data: T;
cb: (response?: APIInteractionResponse) => void;
};

export interface IInteraction<T extends APIInteraction> {
readonly data: InteractionData<T>;
readonly execute: (props: InteractionExecuteArgs<T>) => Awaitable<void>;
readonly autocomplete?: T extends APIChatInputApplicationCommandInteraction
? (
props: InteractionExecuteArgs<APIApplicationCommandAutocompleteInteraction>,
) => Awaitable<void>
: never;
}

export type SelectMenuInteractionWithType<T extends ComponentType> =
APIMessageComponentSelectMenuInteraction & { data: { component_type: T } };

export type RestEvent<T extends keyof RestEvents> = IEvent<T>;

export type RestEventsMap = {
[T in keyof RestEvents]: RestEvent<T>;
};
export type EventsMap = RestEventsMap;

export type ChatInputCommand =
IInteraction<APIChatInputApplicationCommandInteraction>;
export type UserCommand = IInteraction<APIUserApplicationCommandInteraction>;
export type MessageCommand =
IInteraction<APIMessageApplicationCommandInteraction>;
export type ContextMenuCommand = UserCommand | MessageCommand;
export type ApplicationCommand = ChatInputCommand | ContextMenuCommand;

export type Button = IInteraction<APIMessageComponentButtonInteraction>;
export type StringSelect = IInteraction<
SelectMenuInteractionWithType<ComponentType.StringSelect>
>;
export type UserSelect = IInteraction<
SelectMenuInteractionWithType<ComponentType.UserSelect>
>;
export type RoleSelect = IInteraction<
SelectMenuInteractionWithType<ComponentType.RoleSelect>
>;
export type MentionableSelect = IInteraction<
SelectMenuInteractionWithType<ComponentType.MentionableSelect>
>;
export type ChannelSelect = IInteraction<
SelectMenuInteractionWithType<ComponentType.ChannelSelect>
>;
export type SelectMenu =
| StringSelect
| UserSelect
| RoleSelect
| MentionableSelect
| ChannelSelect;
export type MessageComponent = Button | SelectMenu;

export type Modal = IInteraction<APIModalSubmitInteraction>;

export interface Component {
readonly restEvents?: RestEventsMap[keyof RestEvents][];
readonly commands?: ApplicationCommand[];
readonly messageComponents?: MessageComponent[];
readonly modals?: Modal[];
}
109 changes: 104 additions & 5 deletions server/http/handler.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,101 @@
import {
APIInteraction,
APIInteractionResponse,
ApplicationCommandType,
InteractionResponseType,
InteractionType,
} from '@discordjs/core/http-only';
import { inspect } from 'util';
import { interactions, statefuls } from '../components/loader.js';
import { api } from '../utils/env.js';
import { HttpHandlerRequest, HttpHandlerResponse } from './types.js';
import { verifyDiscordRequest } from './verify.js';

async function handleInteraction(
function findStateful(id: string, list: string[]): string | undefined {
return list
.filter((s) => id.startsWith(s))
.sort((a, b) => b.length - a.length)[0];
}

function handleInteraction(
interaction: APIInteraction,
): Promise<APIInteractionResponse | void> {
cb: (response?: APIInteractionResponse) => void,
) {
const props = { data: interaction, cb, api };

switch (interaction.type) {
case InteractionType.Ping:
return { type: InteractionResponseType.Pong };
cb({ type: InteractionResponseType.Pong });
break;

case InteractionType.ApplicationCommand:
case InteractionType.ApplicationCommandAutocomplete:
const command = interactions.commands.get(interaction.data.name);

if (!command)
throw new Error(
`Command not defined for ${interaction.data.name}`,
);

if (
interaction.type === InteractionType.ApplicationCommand &&
(command.data.type ?? ApplicationCommandType.ChatInput) ===
interaction.data.type
)
//@ts-ignore
command.execute(props);
else if (command.autocomplete)
//@ts-ignore
command.autocomplete(props);
break;

case InteractionType.MessageComponent:
const componentId = interaction.data.custom_id;

let component = interactions.messageComponents.get(componentId);

if (!component) {
const staticId = findStateful(
componentId,
statefuls.messageComponents,
);

if (staticId)
component = interactions.messageComponents.get(staticId);
}

if (!component)
throw new Error(
`Message component not defined for ${interaction.data.custom_id}.`,
);

if (component.data.type === interaction.data.component_type)
//@ts-ignore
component.execute(props);
break;

case InteractionType.ModalSubmit:
const modalId = interaction.data.custom_id;

let modal = interactions.modals.get(modalId);

if (!modal) {
const staticId = findStateful(modalId, statefuls.modals);

if (staticId) modal = interactions.modals.get(staticId);
}

if (!modal)
throw new Error(
`Modal not defined for ${interaction.data.custom_id}.`,
);

//@ts-ignore
modal.execute(props);
break;

default:
break;
}
}

Expand All @@ -30,8 +113,24 @@ export function setupHttpHandler(verifyKey: string) {
}

const interaction = JSON.parse(body) as APIInteraction;
const result = await handleInteraction(interaction);
result && response.send(JSON.stringify(result));
try {
handleInteraction(interaction, (interactionResponse) => {
if (!interactionResponse) {
response.writeHead(202);
return response.send();
}

response.writeHead(200, {
'Content-Type': 'application/json',
});
response.write(JSON.stringify(interactionResponse));
response.send();
});
} catch (e) {
console.error(e instanceof Error ? e.message : inspect(e));
response.writeHead(500);
return response.send('internal server error');
}
});
}

Expand Down
File renamed without changes.
Loading

0 comments on commit 73b8346

Please sign in to comment.