docs: Reaction roles bot example (#3361)

* Update docusaurus typescript setup for v3

And fix lint-staged and eslint

* Enable automatic JSX runtime

* Remove babel config and dependencies

* update yarn.lock

* add typecheck to site workflow

* update typedoc config

* downgrade docusaurus packages

* Update site.yml

* Type context and options in webpack-docusaurus-plugin.ts

* Add reaction-roles code from docs example

* Finish /roles reactions create command, missing event handler

* Add handler for the role buttons

* Initial update to reactionroles.md

+ code changes accordingly

* Finish reactionroles.md file

* Corrections to reactionroles.md

* update deps & add --strip-leading-paths to swc

* Add a note for the possibile ratelimit on command upsert

* Update website/docs/examples/reactionroles.md

Co-authored-by: LTS20050703 <lts20050703@gmail.com>

* Apply suggestions from code review

Co-authored-by: LTS20050703 <lts20050703@gmail.com>

* Update website/docs/examples/reactionroles.md

Co-authored-by: LTS20050703 <lts20050703@gmail.com>

* Use a register-commands.ts for app commands

* Apply suggestions from code review

Co-authored-by: LTS20050703 <lts20050703@gmail.com>

* Update for the latest version of discordeno

Also add the tsconfig for the reaction roles example as i forgot it and was using the root dir one

* Update deps, add typescript as dev deps, add .swcrc

---------

Co-authored-by: Matt Hatcher <3768988+MatthewSH@users.noreply.github.com>
Co-authored-by: Jonathan Ho <heiheiho000@gmail.com>
Co-authored-by: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com>
Co-authored-by: LTS20050703 <lts20050703@gmail.com>
This commit is contained in:
Fleny
2024-07-06 19:37:33 +02:00
committed by GitHub
parent 5cccaeaf06
commit 008834d389
18 changed files with 4117 additions and 167 deletions

View File

@@ -0,0 +1 @@
TOKEN= # Your discord bot token

35
examples/reaction-roles/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# dependencies
node_modules
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# testing
coverage
# build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2022",
"keepClassNames": true,
"loose": true
},
"module": {
"type": "es6",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
}

View File

@@ -0,0 +1,14 @@
# Reaction roles example bot
Example bot for reaction-roles using Discord Interactions and not classic reactions.
## Setup
1. Rename `.env.example` to `.env` and add your bot token in the `TOKEN` variable
1. Replace `REPLACE WITH YOUR GUILD ID` in `src/register-commands.ts` with your test guild id
## Run the bot
1. `yarn` to install the dependencies
1. `yarn build` to build the .ts files into .js
1. `yarn start` (or `node ./dist/index.js`) to run the bot

View File

@@ -0,0 +1,24 @@
{
"name": "reaction-roles",
"description": "",
"main": "dist/index.js",
"type": "module",
"license": "ISC",
"private": true,
"packageManager": "yarn@4.0.2",
"scripts": {
"start": "node dist/index.js",
"build": "swc src --strip-leading-paths --delete-dir-on-start --out-dir dist",
"setup-dd": ""
},
"dependencies": {
"@discordeno/bot": "19.0.0-next.ad7e74c",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@swc/cli": "^0.3.12",
"@swc/core": "^1.5.25",
"@types/node": "^20.14.2",
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,12 @@
import { EventEmitter } from 'node:events'
// Extremely minimal collector class
export default class ItemCollector<T> extends EventEmitter {
onItem(callback: (item: T) => unknown): void {
this.on('item', callback)
}
collect(item: T): void {
this.emit('item', item)
}
}

View File

@@ -0,0 +1,12 @@
import type { Interaction } from '@discordeno/bot'
import type { CreateSlashApplicationCommand } from '@discordeno/types'
import roles from './roles.js'
export const commands = new Map<string, Command>([roles].map((cmd) => [cmd.name, cmd]))
export default commands
export interface Command extends CreateSlashApplicationCommand {
/** Handler that will be executed when this command is triggered */
execute: (interaction: Interaction, args: Record<string, unknown>) => Promise<unknown>
}

View File

@@ -0,0 +1,476 @@
import {
DiscordInteractionContextType,
MessageComponentTypes,
TextStyles,
type ActionRow,
type ButtonComponent,
type Interaction,
type Role,
type SelectMenuComponent,
} from '@discordeno/bot'
import { ApplicationCommandOptionTypes, ButtonStyles } from '@discordeno/types'
import ItemCollector from '../collector.js'
import { collectors } from '../events/interactionCreate.js'
import { bot } from '../index.js'
import type { Command } from './index.js'
const command: Command = {
name: 'roles',
description: 'Role management on your server.',
// Do not allow to run this command in DM. Only in guilds.
contexts: [DiscordInteractionContextType.Guild],
// Require the user to have Manage Guild and Manage Roles by-default, server admins can override this setting for their server
defaultMemberPermissions: ['MANAGE_GUILD', 'MANAGE_ROLES'],
options: [
{
name: 'reactions',
description: 'Manage the role reactions on your server.',
type: ApplicationCommandOptionTypes.SubCommandGroup,
options: [
{
name: 'create',
description: 'Create a reaction role on your server.',
type: ApplicationCommandOptionTypes.SubCommand,
options: [
{
name: 'role',
description: 'What role would you like to set for this button?',
type: ApplicationCommandOptionTypes.Role,
required: true,
},
{
name: 'emoji',
description: "What would you like to set as this button's emoji?",
type: ApplicationCommandOptionTypes.String,
required: true,
},
{
name: 'color',
description: "What color would you like to set as this button's color?",
type: ApplicationCommandOptionTypes.Integer,
required: true,
choices: [
{ name: 'Blue', value: ButtonStyles.Primary },
{ name: 'Green', value: ButtonStyles.Success },
{ name: 'Grey', value: ButtonStyles.Secondary },
{ name: 'Red', value: ButtonStyles.Danger },
],
},
{
required: false,
name: 'label',
description: 'What would you like to set for the name on this button?',
type: ApplicationCommandOptionTypes.String,
// Discord imposed limit for button
maxLength: 80,
},
],
},
],
},
],
async execute(interaction, args: CommandArgs) {
// Create a reaction role
if (args.reactions?.create) {
// Ensure that there is a channelId
if (!interaction.channelId) {
await interaction.respond('Could not get the current channel.', { isPrivate: true })
return
}
// This array is used to store all the roles for this reaction roles
let roles = [args.reactions.create]
// Send the message that uses will use to get the role
const roleMessage = await bot.helpers.sendMessage(interaction.channelId, {
content: 'Pick your roles',
components: getRoleButtons(roles),
})
// Create a copy of the actionRow for the main message
// NOTE: we use a copy so when we edit this actionRow the edits don't get applied to all the command executions, only this one, for example we do disable some buttons in some conditional cases
const messageActionRow = structuredClone(messageActionRowTemplate)
await interaction.defer(true)
const message = await interaction.respond(
{
content: 'Use the buttons in this message to edit the message below.',
components: [messageActionRow],
},
{ isPrivate: true },
)
if (!message) {
await interaction.respond('❌ Unable to send the message correctly. Cancelling', { isPrivate: true })
return
}
// Create the collector for the menu
const itemCollector = new ItemCollector<Interaction>()
collectors.add(itemCollector)
// For the new reaction role, we need to keep track of what the user gave us
let partialRoleInfo: Partial<(typeof roles)[number]> | undefined
itemCollector.onItem(async (i) => {
// We need to verify the interaction is for us.
if (i.message?.id !== message.id) {
return
}
// Save button
if (i.data?.customId === 'reactionRoles-save') {
// Remove this item collector from the list of collectors (we aren't correcting anymore)
collectors.delete(itemCollector)
// Delete the edit message
await i.deferEdit()
await i.delete()
return
}
// New button
if (i.data?.customId === 'reactionRoles-add') {
partialRoleInfo = {}
// Ask the user for the role
await i.edit({ content: 'Pick a role for the new reaction role', components: [selectRoleActionRow] })
return
}
// New button - role select menu
if (partialRoleInfo && i.data?.customId === 'reactionRoles-add-role') {
const roleToAdd = i.data?.resolved?.roles?.first()
// Verify that we could get the role from discord
if (!roleToAdd) {
throw new Error('Unable to get the information for the role to add')
}
// Save it to our partial role information
partialRoleInfo.role = roleToAdd
// Ask the user for the color of the button
await i.edit({
content: 'Pick a color for the reaction role',
components: [selectColorActionRow],
})
return
}
// New button - color select menu
if (partialRoleInfo && i.data?.customId === 'reactionRoles-add-color') {
const color = parseInt(i.data?.values?.[0] ?? 'NaN')
// Verify that we could get the color information
if (isNaN(color)) {
throw new Error('Unable to get the information for the role to add')
}
// Save the color to our partial
partialRoleInfo.color = color
// Ask the user to input the emoji and optionally a label for the button
await i.respond({
title: 'Pick an emoji and label for the reaction role',
components: [selectEmojiActionRow, selectLabelActionRow],
customId: 'reactionRoles-add-modal',
})
return
}
// New button - emoji & label modal
if (partialRoleInfo && i.data?.customId === 'reactionRoles-add-modal') {
// Ensure that we can get the channelId from the interaction
if (!interaction.channelId) {
throw new Error('Unable to get current channel')
}
// Get the data from discord
const emoji = i.data.components?.[0]?.components?.[0].value
const label = i.data.components?.[1]?.components?.[0].value
// Verify that the emoji was given
if (!emoji) {
throw new Error('Unable to get the information for the role to add')
}
// Save them to our partial
partialRoleInfo.emoji = emoji
partialRoleInfo.label = label
// Save role and display the new message editing the old one
// We are sure that in this place the entire object has been assembled
roles.push(partialRoleInfo as (typeof roles)[number])
await bot.helpers.editMessage(interaction.channelId, roleMessage.id, {
components: getRoleButtons(roles),
})
// Clear our partial roleInfo, we are done with it
partialRoleInfo = undefined
// In case the delete button was disabled (all the roles were deleted) re-enable it
messageActionRow.components[1]!.disabled = false
// Discord imposes a limit of 5 action rows and 5 buttons for actionRow = 25 buttons max
// more than 25 will give an error, so we disable the new button
if (roles.length === 25) {
const button = messageActionRow.components[0] as ButtonComponent
button.disabled = true
}
// Show again the main edit menu
await interaction.edit({
content: 'Use the buttons in this message to edit the message below.',
components: [messageActionRow],
})
// Respond to the modal. A modal submit (type 5) interaction can't edit the original response
await i.respond('Reaction role created successfully. You can use the message above to add/remove a role', { isPrivate: true })
return
}
// Remove button
if (i.data?.customId === 'reactionRoles-remove') {
// Clone the actionRow for the remove select menu, this is to prevent unwanted data to appear to other users
const removeActionRow = structuredClone(removeActionRowTemplate)
const selectMenu = removeActionRow.components[0] as SelectMenuComponent
// Add the possible values for this select menu
for (const roleInfo of roles) {
selectMenu.options.push({
label: `${roleInfo.emoji} ${roleInfo.label ?? ''}`,
value: roleInfo.role.id.toString(),
})
}
// Ask the user for what reaction role they want to remove
await i.edit({
content: 'Select what reaction role to remove',
components: [removeActionRow],
})
return
}
// Remove button - role select menu
if (i.data?.customId === 'reactionRoles-remove-selectMenu') {
// Ensure that we can get the channelId from the interaction
if (!interaction.channelId) {
throw new Error('Unable to get current channel')
}
// Get the role to delete from discord
const roleToRemove = i.data?.values?.[0]
// Ensure we got it
if (!roleToRemove) {
throw new Error('Unable to get the role to remove')
}
await i.deferEdit()
// Remove the role from the list
roles = roles.filter((roleInfo) => roleInfo.role.id.toString() !== roleToRemove)
// Edit the main button
await bot.helpers.editMessage(interaction.channelId, roleMessage.id, {
components: getRoleButtons(roles),
})
// If the new button was disabled (we were at 25 buttons) we re-enable it
const button = messageActionRow.components[0] as ButtonComponent
button.disabled = false
// If we are at 0 roles, and the user tried to delete a role they will get locked in the menu, so we disable it
if (roles.length === 0) {
messageActionRow.components[1]!.disabled = true
}
// Show the main edit ui (new, remove, save)
await i.edit({
content: 'Use the buttons in this message to edit the message below.',
components: [messageActionRow],
})
return
}
// We don't know what code to run for this interaction
throw new Error('Unknown button')
})
}
},
}
export default command
// Interface to type the arguments that we receive from discord
interface CommandArgs {
reactions?: {
create?: {
role: Role
emoji: string
color: ButtonStyles
label?: string
}
}
}
// Templates/ActionRows for the command to then be referenced in the various part of the code
const messageActionRowTemplate: ActionRow = {
type: MessageComponentTypes.ActionRow,
components: [
{
type: MessageComponentTypes.Button,
style: ButtonStyles.Success,
customId: 'reactionRoles-add',
emoji: {
name: '',
},
label: 'Add',
disabled: false,
},
{
type: MessageComponentTypes.Button,
style: ButtonStyles.Danger,
customId: 'reactionRoles-remove',
emoji: {
name: '',
},
label: 'Remove',
disabled: false,
},
{
type: MessageComponentTypes.Button,
style: ButtonStyles.Success,
customId: 'reactionRoles-save',
emoji: {
name: '✅',
},
label: 'Save',
},
],
} as const
const removeActionRowTemplate: ActionRow = {
type: MessageComponentTypes.ActionRow,
components: [
{
type: MessageComponentTypes.SelectMenu,
customId: 'reactionRoles-remove-selectMenu',
maxValues: 1,
minValues: 1,
placeholder: 'Select roles',
options: [],
},
],
} as const
const selectRoleActionRow: ActionRow = {
type: MessageComponentTypes.ActionRow,
components: [
{
type: MessageComponentTypes.SelectMenuRoles,
customId: 'reactionRoles-add-role',
maxValues: 1,
minValues: 1,
placeholder: 'Select a role',
},
],
} as const
const selectColorActionRow: ActionRow = {
type: MessageComponentTypes.ActionRow,
components: [
{
type: MessageComponentTypes.SelectMenu,
customId: 'reactionRoles-add-color',
options: [
{ label: 'Blue', value: ButtonStyles.Primary.toString() },
{ label: 'Green', value: ButtonStyles.Success.toString() },
{ label: 'Grey', value: ButtonStyles.Secondary.toString() },
{ label: 'Red', value: ButtonStyles.Danger.toString() },
],
},
],
} as const
const selectEmojiActionRow: ActionRow = {
type: MessageComponentTypes.ActionRow,
components: [
{
type: MessageComponentTypes.InputText,
style: TextStyles.Short,
customId: 'reactionRoles-add-emoji',
label: 'Emoji for the reaction role',
required: true,
},
],
} as const
const selectLabelActionRow: ActionRow = {
type: MessageComponentTypes.ActionRow,
components: [
{
type: MessageComponentTypes.InputText,
style: TextStyles.Short,
customId: 'reactionRoles-add-label',
label: 'Label for the reaction role [OPTIONAL]',
// Discord imposed limit for button labels
maxLength: 80,
},
],
} as const
// Function to get all the actionRows with buttons for the reaction roles message
function getRoleButtons(
roles: Array<{
role: Role
emoji: string
color: ButtonStyles
label?: string | undefined
}>,
): ActionRow[] {
const actionRows: ActionRow[] = []
// If there aren't any roles, we don't need any buttons
if (roles.length === 0) return actionRows
// We add the components later, so we need to make typescript know that we are sure that it will be a compatibile components array
actionRows.push({ type: MessageComponentTypes.ActionRow, components: [] as unknown as ActionRow['components'] })
for (const roleInfo of roles) {
let actionRow = actionRows.at(-1)
// Ensure that we were able to get the actionRow
if (!actionRow) {
throw new Error('Unable to get actionRow')
}
// If the actionRow is full (has 5 buttons) add a new one
if (actionRow.components.length === 5) {
actionRow = { type: MessageComponentTypes.ActionRow, components: [] as unknown as ActionRow['components'] }
actionRows.push(actionRow)
}
// Add the new button to this actionRow
actionRow?.components.push({
type: MessageComponentTypes.Button,
style: roleInfo.color,
emoji: {
name: roleInfo.emoji,
},
label: roleInfo.label,
customId: `reactionRoles-role-${roleInfo.role.id}`,
})
}
return actionRows
}

View File

@@ -0,0 +1,10 @@
import type { EventHandlers } from '@discordeno/bot'
import { event as interactionCreateEvent } from './interactionCreate.js'
import { event as readyEvent } from './ready.js'
export const events = {
interactionCreate: interactionCreateEvent,
ready: readyEvent,
} as Partial<EventHandlers>
export default events

View File

@@ -0,0 +1,58 @@
import { InteractionTypes, MessageComponentTypes, commandOptionsParser, type EventHandlers, type Interaction } from '@discordeno/bot'
import type ItemCollector from '../collector.js'
import commands from '../commands/index.js'
export const collectors = new Set<ItemCollector<Interaction>>()
export const event: EventHandlers['interactionCreate'] = async (interaction) => {
// Give to all the collectors the interaction to use
for (const collector of collectors) {
collector.collect(interaction)
}
// If the interaction is a command check if it is a command and run it
if (interaction.type === InteractionTypes.ApplicationCommand) {
if (!interaction.data) return
const command = commands.get(interaction.data.name)
if (!command) return
try {
await command.execute(interaction, commandOptionsParser(interaction))
} catch (error) {
console.error(error)
}
}
// If the interaction is a button it might be the button press on our reaction role message
if (interaction.type === InteractionTypes.MessageComponent && interaction.data?.componentType === MessageComponentTypes.Button) {
// The interaction is not a button press on the role button
if (!interaction.data?.customId?.startsWith('reactionRoles-role-')) return
if (!interaction.guildId || !interaction.member) return
// Remove the prefix and get the roleId
const roleId = BigInt(interaction.data.customId.slice('reactionRoles-role-'.length))
// Check if we need to remove or add the role to the user
const alreadyHasRole = !!interaction.member.roles.find((role) => role === roleId)
try {
if (alreadyHasRole) {
await interaction.bot.helpers.removeRole(interaction.guildId, interaction.user.id, roleId, `Reaction role button for role id ${roleId}`)
await interaction.respond(`I removed from you the <@&${roleId}> role.`, { isPrivate: true })
return
}
// You will get an invalid request made if the bot attempts to give a bot role, a role higher then him hightest role, a link role or if it does not have the Manage Roles permission
// This could be prevented by checking for the roles that the bot owns and the role that the bot is trying to add
await interaction.bot.helpers.addRole(interaction.guildId, interaction.user.id, roleId, `Reaction role button for role id ${roleId}`)
await interaction.respond(`I added to you the <@&${roleId}> role.`, { isPrivate: true })
} catch {
// Respond with an error message
await interaction.respond(
'I could not give you the role. Possible reasons are:\n- My permissions are not configured correctly, make sure i have the `Manage Roles` permission\n- The role is **above** my hightest role in the server setup\n- The role does not exist or is non-manageable (for example: bot roles, link roles or @everyone)',
{ isPrivate: true },
)
}
}
}

View File

@@ -0,0 +1,7 @@
import type { EventHandlers } from '@discordeno/bot'
import { bot } from '../index.js'
export const event: EventHandlers['ready'] = () => {
// Print to the console when the bot has connected to discord and is ready to handle the events
bot.logger.info('The bot is ready!')
}

View File

@@ -0,0 +1,39 @@
import { createBot } from '@discordeno/bot'
import { config } from 'dotenv'
import events from './events/index.js'
config()
const token = process.env.TOKEN
// Ensure the existence of the TOKEN env
if (!token) throw new Error('The TOKEN environment variable needs to be defined.')
export const bot = createBot({
token,
events,
})
// Setup for the desiredProperties
bot.transformers.desiredProperties.user.id = true
bot.transformers.desiredProperties.message.id = true
bot.transformers.desiredProperties.member.roles = true
bot.transformers.desiredProperties.interaction.id = true
bot.transformers.desiredProperties.interaction.data = true
bot.transformers.desiredProperties.interaction.type = true
bot.transformers.desiredProperties.interaction.user = true
bot.transformers.desiredProperties.interaction.token = true
bot.transformers.desiredProperties.interaction.member = true
bot.transformers.desiredProperties.interaction.message = true
bot.transformers.desiredProperties.interaction.guildId = true
bot.transformers.desiredProperties.interaction.channelId = true
bot.transformers.desiredProperties.role.id = true
await bot.start()
process.on('unhandledRejection', bot.logger.error)

View File

@@ -0,0 +1,8 @@
import commands from './commands/index.js'
import { bot } from './index.js'
const guildId = 'REPLACE WITH YOUR GUILD ID'
await bot.rest
.upsertGuildApplicationCommands(guildId, [...commands.values()])
.catch((e) => bot.logger.error('There was an error when updating the global commands', e))

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"moduleResolution": "node",
"skipDefaultLibCheck": true,
"skipLibCheck": true,
"strict": true,
"incremental": true
}
}

File diff suppressed because it is too large Load Diff