Skip to content

@sodacore/discord

The @sodacore/discord package provides a programmable Discord bot, and built-in OAuth2 features for SSO with Discord.

Installation

To install the plugin, simply install it via bun:

bash
bun install @sodacore/discord

And then within your main.ts file you can use the plugin like so:

typescript
import { Application } from '@sodacore/core';
import DiscordPlugin from '@sodacore/discord';

const app = new Application();

app.use(new DiscordPlugin({
	// Options...
}));

app.start().catch(console.error);

Configuration

The Discord plugin has the following configuration options:

ts
export type IConfig = {
	token?: string,					// The bot token to use, if not using OAuth2.
	clientId?: string,				// The client ID for OAuth2.
	clientSecret?: string,			// The client secret for OAuth2.
	baseUrl?: string,				// The base URL for the application.
	scopes?: string[],				// The OAuth2 scopes to request.
	guildId?: string,				// The ID of the guild to use for local registration of commands.
	clientOptions?: ClientOptions,	// Additional client options to pass to the Discord.js client.
	events?: Array<keyof ClientEventTypes>, // The events to listen to, i.e. `messageCreate`.
};

Commands

There are are two ways to create slash commands;

@Command() decorator

You can create commands using the @Command() decorator like so:

typescript
import { Command, On, SlashCommandBuilder } from '@sodacore/discord';

@Command(
	new SlashCommandBuilder()
		.setName('ping')
		.setDescription('Replies with Pong!'),
)
export class PingCommand {

	@On.Command()
	public async execute() {
		return 'Pong!';
	}
}

@SlashCommand decorator.

This method is a bit more inline with the way controllers work and keeps everything a bit more decorator-based instead of a huge builder at the top.

WARNING

Be aware this is still experimental, so please if you intend to use it, please provide feedback.

typescript
import { Option, SlashCommand, StringOption, SubCommand, UserOption } from '@sodacore/discord';

@SlashCommand({ name: 'greet', description: 'Collection of greetings!' })
export class GreetingsCommand {

	@SubCommand({ name: 'hello', description: 'Send a greeting message.' })
	@UserOption({ name: 'friend', description: 'Choose your friend.' })
	@StringOption({ name: 'message', description: 'Your message to your friend.' })
	public async hello(
		@Option('message') message?: string,
		@Option('friend') friend?: number,
	) {
		return `${message || 'Hello'}, <@${friend}>!`;
	}
}

IMPORTANT

When using the @SlashCommand decorator, you do NOT need to use the @On... decorators for the command/subcommands as the @SubCommand decorator handles that for you. Although you will still need to use @On... decorators for autocomplete, button and other style interactions.

@On. decorators

When using the @Command() decorator method, you can use the @On. decorators to listen to different events, such as:

  • @On.Command() - Listens for the root command execution.
  • @On.SubCommand('subcommandName') - Listens for a specific subcommand execution.
  • @On.Button(uniqueId: string) - Listens for button interactions with the specifically given unique ID.
  • @On.SelectMenu(uniqueId: string) - Listens for select menu interactions with the specifically given unique ID.
  • @On.ModalSubmit(uniqueId: string) - Listens for modal submit interactions with the specifically given unique ID.
  • @On.Autocomplete(optionName: string, subCommand?: string) - Listens for autocomplete interactions for a specific option, optionally within a specific subcommand or for all subcommands within the class.
  • @On.Event(eventName: keyof ClientEventTypes) - Listens for a specific Discord.js client event, such as messageCreate.

IMPORTANT

Any unique IDs prefixed with internal:... are ignored from routing, as they are expected to be listened to directly. By default a unique ID will be looked for when routing, to find the matching controller and method to execute.

These decorators can be added to methods within your command class to deal with the various interactions.

Parameter decorators

Like the HTTP plugin, the Discord plugin also offers parameter decorators to extract data from the interaction, such as:

  • @Interaction() - Injects the interaction object that triggered the command, this will be typed depending on the type of interaction, e.g. ChatInputCommandInteraction for slash commands.
  • @User() - Injects the user that triggered the interaction, typed as User from discord.js.
  • @Guild() - Injects the guild (server) where the interaction was triggered, typed as Guild from discord.js, or null if in a DM.
  • @Channel() - Injects the channel where the interaction was triggered, typed as GuildChannel | DMChannel | NewsChannel | ThreadChannel from discord.js.
  • @Client() - Injects the Discord.js client instance, typed as Client from discord.js.
  • @Query() - Injects the @On.Autocomplete() query string.
  • @Option(name: string) - Injects the value of a specific option from the command, typed as string | number | boolean | User | GuildMember | Role | Channel | Attachment | null depending on the option type.
  • @Field(name: string) - Injects the value of a specific field from a modal submit interaction, typed as string | null.

As you can see these decorators allow you to easily extract the data you need from the interaction without having to manually parse it yourself, or if you want to access the raw interaction object, you can do that too via the @Interaction() decorator.

Context menu commands

If you want to add context menu commands, (if you right-click a user or message in Discord), you can do so like this:

typescript
import { ContextMenu, Add } from '@sodacore/discord';

@ContextMenu()
export class MyContextMenus {

	@Add.User('Greet User')
	public async greetUser(
		@Interaction() interaction: ContextMenuCommandInteraction,
		@User() user: User,
	) {
		return `Hello, ${user.username}!`;
	}

	@Add.Message('Quote Message')
	public async quoteMessage(
		@Interaction() interaction: ContextMenuCommandInteraction,
		@Channel() channel: TextChannel,
	) {
		const message = await channel.messages.fetch(interaction.targetId);
		return `> ${message.content}\n- ${message.author.username}`;
	}
}

Events

If you want to listen to an event, or create a collection of event listeners in one class, you can do so like:

typescript
import { Event, On } from '@sodacore/discord';

@Event()
export class MyEvents {
	@On.Event('messageCreate')
	public async onMessageCreate(
		@Interaction() message: Message,
	) {
		if (message.content === 'ping') {
			message.channel.send('Pong!');
		}
	}
}

Handlers

If you want to create a generic handler that might be a generic location for modal submissions for example.

typescript
import { Handler, On } from '@sodacore/discord';

@Handler()
export class MyHandlers {
	@On.ModalSubmit('myModal')
	public async onMyModalSubmit(
		@Interaction() interaction: ModalSubmitInteraction,
		@Field('inputField') inputField: string,
	) {
		await interaction.reply(`You submitted: ${inputField}`);
	}
}

Authentication

The Discord plugin provides a series of authentication decorators to deal with command access control, such as:

  • @Auth.HasRole(roleId: string) - Ensures the user has a specific role in the guild.
  • @Auth.HasRoles(roleIds: string[]) - Ensures the user has all of the specified roles in the guild.
  • @Auth.UserCustom(callback: IAuthFunctionUser) - Passes a custom callback with the user as the parameter, expects a boolean return value.
  • @Auth.GuildMemberCustom(callback: IAuthFunctionGuildMember) - Passes a custom callback with the guild as the parameter, expects a boolean return value.
  • @Auth.IsDirectMessage() - Ensures the command is being run in a DM (Direct Message).
  • @Auth.IsGuildMessage() - Ensures the command is being run in a guild (server).

Types

ts
export type IAuthFunctionUser = (user: User) => boolean | Promise<boolean>;
export type IAuthFunctionGuildMember = (user: GuildMember) => boolean | Promise<boolean>;
export type IAuthFunction = (user: User | GuildMember) => boolean | Promise<boolean>;

SlashCommandsProvider

The SlashCommandsProvider is a built-in provider that can be used to register, unregister global or guild-specific slash commands. The provider is used by the built-in CLI commands, but you can also use it programmatically like so:

typescript
import { Controller, Get } from '@sodacore/http';
import { SlashCommandsProvider } from '@sodacore/discord';
import { Inject } from '@sodacore/di';

@Controller('/discord')
export class DiscordController {
	@Inject() private slashCommands!: SlashCommandsProvider;

	@Get('/register')
	public async registerCommands() {
		await this.slashCommands.registerGlobal();
	}
}

The available methods are:

  • registerGlobal() - Registers all global commands.
  • registerGuild(guildId: string) - Registers all commands for a specific guild.
  • unregisterGlobal() - Unregisters all global commands.
  • unregisterGuild(guildId: string) - Unregisters all commands for a
  • getCommands() - Gets all known commands from the application (in code, not from the API).

Types

typescript
export type IPromptsQuestionOptions = {
	timeout?: number,
	description?: string,
	ephemeral?: boolean,
	timestamp?: boolean,
	fields?: APIEmbedField[],
};

export type IPromptsConfirmOptions = IPromptsQuestionOptions & {
	acceptLabel?: string,
	rejectLabel?: string,
};

export type IPromptsChoiceOptions = IPromptsQuestionOptions & {
	placeholder?: string,
};

Scripts

The Discord plugin also provides a series of built-in scripts to help manage your bot, such as:

  • discord:commands:register - Registers all commands, either globally or for a specific guild.
  • discord:commands:unregister - Unregisters all commands, either globally or for a specific guild.

Discord OAuth2

The Discord plugin provides built-in OAuth2 support, allowing you to authenticate users via Discord, and retrieve their basic profile information.

To use Discord OAuth2, you will need to configure the plugin with your Discord application's client ID, client secret and redirect URI.

You can import it like so:

typescript
import { Controller, Get } from '@sodacore/http';
import { OAuthProvider } from '@sodacore/discord';

@Controller('/auth/discord')
export class DiscordAuthController {
	@Inject() private oauth!: OAuthProvider;

	@Get('/login')
	public async login() {
		const redirectUrl = '<yourDomain>/auth/discord/accept';
		return this.oauth.doAuthorisation(redirectUrl);
	}

	@Get('/accept')
	public async accept(
		@Query() query: URLSearchParams,
	) {
		const redirectUrl = '<yourDomain>/auth/discord/accept';
		const data = this.oauth.doAccept(query, redirectUrl);
		console.log(data);
	}
}

You can then use the built-in authentication decorators to protect your routes, such as:

  • doAuthorisation(redirectUri: string, state?: string): Response - Generates the Discord OAuth2 authorization URL to redirect the user to.
  • doAccept(query: URLSearchParams, redirectUri: string): Promise<ITokenResult> - Accepts the OAuth2 callback and retrieves the access token and user information.
  • refreshToken(refreshToken: string): Promise<ITokenResult> - Refreshes the access token using the provided refresh token.
  • getUserM<T = Record<string, any>>(accessToken: string): Promise<T> - Retrieves the user's Discord profile information using the access token.
  • getCustom(apiPath: string, accessToken: string): Promise<any> - Makes a custom API request to the Discord API using the access token.
  • canUseSso(): boolean - Checks if SSO can be used (i.e. if client ID and secret are configured).
  • getUrl(redirectUri: string, state?: string): string - Gets the OAuth2 authorization URL.

TIP

We suggest setting a script up to automate refreshing tokens for you, the return will tell you when the token expires, so you can store it and refresh it as needed, we do not handle this automatically.

Types

ts
export type ITokenResult = {
	tokenType: string,
	expiresIn: number,
	refreshToken: string,
	accessToken: string,
	scope: string,
};

Released under the Apache-2.0 License.