From eebdd06430ea7abf8f739c6a9782983731296a6d Mon Sep 17 00:00:00 2001 From: jowi Date: Wed, 18 Dec 2024 01:17:53 -0500 Subject: [PATCH 1/2] feat(discord-bot): create project - git clone https://github.com/RileCraft/DiscordBot-Template-TS.git apps/discord-bot - delete .git & .github - tidy up package.json - pnpm lint:fix --- apps/discord-bot/.gitignore | 4 + apps/discord-bot/LICENSE | 21 ++++ apps/discord-bot/README.md | 116 ++++++++++++++++++ apps/discord-bot/nodemon.json | 5 + apps/discord-bot/package.json | 25 ++++ apps/discord-bot/src/bot.ts | 53 ++++++++ apps/discord-bot/src/config.ts | 5 + apps/discord-bot/src/events/errorManager.ts | 15 +++ .../src/events/interactionCreate.ts | 68 ++++++++++ apps/discord-bot/src/events/messageCreate.ts | 30 +++++ apps/discord-bot/src/events/ready.ts | 61 +++++++++ .../src/interactions/buttons/deleteOutput.ts | 9 ++ .../interactions/contextMenus/getUsername.ts | 19 +++ .../interactions/modalForms/exampleModal.ts | 10 ++ .../selectMenus/selectMenuExample.ts | 12 ++ .../slashCommands/exampleModal.ts | 29 +++++ .../src/interactions/slashCommands/ping.ts | 11 ++ .../src/messageCommands/callSelectMenu.ts | 28 +++++ apps/discord-bot/src/messageCommands/eval.ts | 68 ++++++++++ apps/discord-bot/src/messageCommands/ping.ts | 15 +++ .../commandOptions/allClientPermissions.ts | 34 +++++ .../commandOptions/allUserPermissions.ts | 34 +++++ .../commandOptions/anyClientPermissions.ts | 31 +++++ .../commandOptions/anyUserPermissions.ts | 31 +++++ .../commandOptions/channelCooldown.ts | 37 ++++++ .../commandOptions/globalCooldown.ts | 37 ++++++ .../commandOptions/guildCooldown.ts | 37 ++++++ .../structures/commandOptions/onlyChannels.ts | 32 +++++ .../structures/commandOptions/onlyGuilds.ts | 32 +++++ .../structures/commandOptions/onlyRoles.ts | 32 +++++ .../structures/commandOptions/onlyUsers.ts | 32 +++++ .../structures/commandOptions/ownerOnly.ts | 33 +++++ .../structures/commandOptions/processor.ts | 37 ++++++ .../src/structures/managers/buttonCommands.ts | 18 +++ .../src/structures/managers/events.ts | 24 ++++ .../structures/managers/messageCommands.ts | 23 ++++ .../src/structures/managers/modalForms.ts | 18 +++ .../src/structures/managers/selectMenus.ts | 18 +++ .../src/structures/managers/slashCommands.ts | 113 +++++++++++++++++ apps/discord-bot/src/types.ts | 116 ++++++++++++++++++ apps/discord-bot/src/utils/fileReader.ts | 23 ++++ apps/discord-bot/tsconfig.json | 33 +++++ libs/external/aceternity/tailwind.config.ts | 2 +- libs/website/feature/faq/index.ts | 2 +- libs/website/feature/stats/index.ts | 2 +- 45 files changed, 1432 insertions(+), 3 deletions(-) create mode 100644 apps/discord-bot/.gitignore create mode 100644 apps/discord-bot/LICENSE create mode 100644 apps/discord-bot/README.md create mode 100644 apps/discord-bot/nodemon.json create mode 100644 apps/discord-bot/package.json create mode 100644 apps/discord-bot/src/bot.ts create mode 100644 apps/discord-bot/src/config.ts create mode 100644 apps/discord-bot/src/events/errorManager.ts create mode 100644 apps/discord-bot/src/events/interactionCreate.ts create mode 100644 apps/discord-bot/src/events/messageCreate.ts create mode 100644 apps/discord-bot/src/events/ready.ts create mode 100644 apps/discord-bot/src/interactions/buttons/deleteOutput.ts create mode 100644 apps/discord-bot/src/interactions/contextMenus/getUsername.ts create mode 100644 apps/discord-bot/src/interactions/modalForms/exampleModal.ts create mode 100644 apps/discord-bot/src/interactions/selectMenus/selectMenuExample.ts create mode 100644 apps/discord-bot/src/interactions/slashCommands/exampleModal.ts create mode 100644 apps/discord-bot/src/interactions/slashCommands/ping.ts create mode 100644 apps/discord-bot/src/messageCommands/callSelectMenu.ts create mode 100644 apps/discord-bot/src/messageCommands/eval.ts create mode 100644 apps/discord-bot/src/messageCommands/ping.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/allClientPermissions.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/allUserPermissions.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/anyClientPermissions.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/anyUserPermissions.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/channelCooldown.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/globalCooldown.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/guildCooldown.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/onlyChannels.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/onlyGuilds.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/onlyRoles.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/onlyUsers.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/ownerOnly.ts create mode 100644 apps/discord-bot/src/structures/commandOptions/processor.ts create mode 100755 apps/discord-bot/src/structures/managers/buttonCommands.ts create mode 100755 apps/discord-bot/src/structures/managers/events.ts create mode 100755 apps/discord-bot/src/structures/managers/messageCommands.ts create mode 100755 apps/discord-bot/src/structures/managers/modalForms.ts create mode 100755 apps/discord-bot/src/structures/managers/selectMenus.ts create mode 100755 apps/discord-bot/src/structures/managers/slashCommands.ts create mode 100644 apps/discord-bot/src/types.ts create mode 100644 apps/discord-bot/src/utils/fileReader.ts create mode 100644 apps/discord-bot/tsconfig.json diff --git a/apps/discord-bot/.gitignore b/apps/discord-bot/.gitignore new file mode 100644 index 00000000..cafed4a7 --- /dev/null +++ b/apps/discord-bot/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +cooldownDB.sqlite +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/apps/discord-bot/LICENSE b/apps/discord-bot/LICENSE new file mode 100644 index 00000000..05ab382f --- /dev/null +++ b/apps/discord-bot/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RileCraft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/discord-bot/README.md b/apps/discord-bot/README.md new file mode 100644 index 00000000..fcedf794 --- /dev/null +++ b/apps/discord-bot/README.md @@ -0,0 +1,116 @@ +

+
+ + + + + +

+ +# Discord Bot Template TS + +The Discord Bot Template provides a solid foundation for creating feature-rich Discord bots using Discord.js. It includes various managers for handling message commands, buttons, select menus, slash commands, context menus, and modal forms. The template offers customization options, colorful logging, and a simple code structure. + +## Changelog + +### IMPORTANT UPDATE 1.0.7 + +- **Fixed Windows Support and SlashCommands & ContextMenus not Registering.** +- Added dependency of `simple-json-db` for the cooldown system as i rage quit and can't do it with `fs` myself. +- Fixed subDirectories not working for commands. +- Latest Discord.js adaptation. +- Following JavaScript Naming Convention. +- Removed `node-recursive-directory` dependency. +- Support for `AutoCompleteInteraction` added. +- Converted from `CommonJS` to `ESM Module`. +- Improved handling of all events, commands with lower memory usage. +- Main file `bot.ts` has been shifted to `src`. +- Config file has been shifted to `Src`. +- Moved from `Collections` to `Map`. +- `messageCommandsAliases` has been renamed to `messageCommands_Aliases` +- `Quick.DB` has been removed and instead all cooldowns data will be now stored in `CooldownDB.txt` in the root directory using `fs`. +- Refactored command options. +- `chalk` has been replaced with `tasai`. +- Extended all command options support to interactions. +- All custom types and interfaces are exported from `./src/types.ts`. +- `SlashCommands` and `ContextMenus` has been seperated into different folders and managed differently. +- `SlashCommands` have been simplified as now instead of `Guilds//`, you can use `guilds: ["GUILD ID"]` +- In a slashCommand you do not need to assign the `type: ApplicationCommandType` property as the handler by default assumes it as `ChatInput`. + +## Documentation + +For detailed documentation on command options and managers, please refer to the following links: + +### Command Options + +- [ReturnErrors](/.github/DOCS/commandOptions/returnErrors.md) +- [Ignore](/.github/DOCS/commandOptions/ignore.md) +- [AllClientPermissions](/.github/DOCS/commandOptions/allClientPermissions.md) +- [AllowBots](/.github/DOCS/commandOptions/allowBots.md) +- [AllowInDms](/.github/DOCS/commandOptions/allowInDms.md) +- [AllUserPermissions](/.github/DOCS/commandOptions/allUserPermissions.md) +- [AnyClientPermissions](/.github/DOCS/commandOptions/anyClientPermissions.md) +- [AnyUserPermissions](/.github/DOCS/commandOptions/anyUserPermissions.md) +- [ChannelCooldown](/.github/DOCS/commandOptions/channelCooldown.md) +- [GlobalCooldown](/.github/DOCS/commandOptions/globalCooldown.md) +- [GuildCooldown](/.github/DOCS/commandOptions/guildCooldown.md) +- [OnlyChannels](/.github/DOCS/commandOptions/onlyChannels.md) +- [OnlyGuilds](/.github/DOCS/commandOptions/onlyGuilds.md) +- [OnlyRoles](/.github/DOCS/commandOptions/onlyRoles.md) +- [OnlyUsers](/.github/DOCS/commandOptions/onlyUsers.md) +- [OwnerOnly](/.github/DOCS/commandOptions/ownerOnly.md) + +### Managers + +- [MessageCommands](/.github/DOCS/managers/messageCommands.md) +- [SelectMenus](/.github/DOCS/managers/selectMenus.md) +- [Buttons](/.github/DOCS/managers/buttons.md) +- [Events](/.github/DOCS/managers/events.md) +- [ContextMenus](/.github/DOCS/managers/contextMenus.md) +- [SlashCommands](/.github/DOCS/managers/slashCommands.md) +- [ModalForms](/.github/DOCS/managers/modalForms.md) + +## Features + +- Colorful and organized logging. +- Customization options to suit your needs. +- Supports management of message commands, buttons, select menus, slash commands, context menus, and modal forms. +- Includes a variety of commonly used command options (not applicable to events). +- Supports management of custom events. +- Simple and understandable code structure. + +## Notes + +- Recommended Node.js version: 16 and above. +- Global slash commands and context menus may take time to refresh as it is controlled by Discord. +- Guild commands may take time to refresh if there are a large number of different guild commands. +- Collections where command and event data is stored and used: + - `.messageCommands`: Message commands cache + - `.messageCommands_Aliases`: Message command aliases cache + - `.events`: Client events cache + - `.buttonCommands`: Button interactions cache + - `.selectMenus`: Select menu interactions cache + - `.modalForms`: Modal form interactions cache + - `.slashCommands`: Slash commands cache + - `.contextMenus`: ContextMenus commands cache + +## Installation + +To get started with the Discord Bot Template, follow these steps: + +1. Clone the repository by downloading it as a ZIP file or running the command `git clone https://github.com/rilecraft/discordbot-template-ts`. +2. Navigate to the template's directory and run the command `npm install` (make sure npm is installed). +3. Once all the required modules are installed, open the `src/config.ts` file and fill in the necessary information. +4. Run the command `npm run build && npm run start` to start the bot. + +You can also use `npm run dev` to run the application in development mode, which will refresh the application after any +changes. If the interaction elements loading logs are excessive, set `LOG_READY_EXPLICIT` to `false` in `src/config.ts`. + +## Contribution + +Contributions to the Discord Bot Template are welcome. To contribute, please follow these guidelines: + +1. Fork the `Unstable` branch. **Important: All changes must be made to the Unstable branch.** +2. Make your changes in your forked repository. +3. Open a pull request to the `Unstable` branch, and it will be reviewed promptly. +4. If everything checks out, the pull request will be merged. diff --git a/apps/discord-bot/nodemon.json b/apps/discord-bot/nodemon.json new file mode 100644 index 00000000..0d5401fe --- /dev/null +++ b/apps/discord-bot/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "ts", + "exec": "tsc --incremental && node dist/bot.js" +} diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json new file mode 100644 index 00000000..153084bc --- /dev/null +++ b/apps/discord-bot/package.json @@ -0,0 +1,25 @@ +{ + "name": "discord-bot", + "type": "module", + "description": "", + "license": "", + "main": "./src/bot.ts", + "scripts": { + "build": "tsc && echo 'Finishing Building'", + "dev": "nodemon", + "start": "node ./dist/bot.js" + }, + "dependencies": { + "discord.js": "^14.14.1", + "ms": "^2.1.3", + "simple-json-db": "^2.0.0", + "tasai": "^1.0.0", + "undici-types": "^5.26.5" + }, + "devDependencies": { + "@types/ms": "^0.7.34", + "@types/node": "^20.11.5", + "nodemon": "^3.1.7", + "typescript": "^5.3.3" + } +} diff --git a/apps/discord-bot/src/bot.ts b/apps/discord-bot/src/bot.ts new file mode 100644 index 00000000..1db7eb43 --- /dev/null +++ b/apps/discord-bot/src/bot.ts @@ -0,0 +1,53 @@ +import type { DiscordClient } from 'discord.js' +import type { ButtonCommand, ClientEvent, ContextMenu, MessageCommand, ModalForm, SelectMenu, SlashCommand } from './types.js' +import { dirname } from 'node:path' +import { Client, GatewayIntentBits, Partials } from 'discord.js' +import JSONdb from 'simple-json-db' +import { BOT_TOKEN } from './config.js' +import { ButtonManager } from './structures/managers/buttonCommands.js' +import { EventManager } from './structures/managers/events.js' +import { MessageCMDManager } from './structures/managers/messageCommands.js' +import { ModalManager } from './structures/managers/modalForms.js' +import { SelectMenuManager } from './structures/managers/selectMenus.js' +import { SlashManager } from './structures/managers/slashCommands.js' + +const __dirname: string = dirname(import.meta.url) +export const rootPath = __dirname; + +(async (): Promise => { + const client: DiscordClient = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, // Only for bots with message content intent access. + GatewayIntentBits.DirectMessageReactions, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildWebhooks, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildInvites, + ], + partials: [Partials.Channel], + }) + + client.cooldownDB = new JSONdb('./cooldownDB.json') + + client.messageCommands = new Map() + client.messageCommands_Aliases = new Map() + client.events = new Map() + client.buttonCommands = new Map() + client.selectMenus = new Map() + client.modalForms = new Map() + client.contextMenus = new Map() + client.slashCommands = new Map() + + await MessageCMDManager(client, __dirname) + await EventManager(client, __dirname) + await ButtonManager(client, __dirname) + await SelectMenuManager(client, __dirname) + await ModalManager(client, __dirname) + await client.login(BOT_TOKEN) + await SlashManager(client, __dirname) // Includes context menu handling as they belong to same command type. +})() diff --git a/apps/discord-bot/src/config.ts b/apps/discord-bot/src/config.ts new file mode 100644 index 00000000..532d3165 --- /dev/null +++ b/apps/discord-bot/src/config.ts @@ -0,0 +1,5 @@ +export const LOG_READY_EXPLICIT = true // Set to 'false' to reduce terminal spam for development server + +export const PREFIX: Array = ['Bot Prefix'] +export const BOT_TOKEN: string = 'Bot Token' +export const OWNER_IDS: Array = ['Bot Owner Discord ID'] diff --git a/apps/discord-bot/src/events/errorManager.ts b/apps/discord-bot/src/events/errorManager.ts new file mode 100644 index 00000000..7ba4d7b8 --- /dev/null +++ b/apps/discord-bot/src/events/errorManager.ts @@ -0,0 +1,15 @@ +import type { ClientEvent } from '../types.js' +// import process from 'node:process' + +export const Event: ClientEvent = { + name: 'errorManager', + customEvent: true, + run: (): void => { + // process.on('unhandledRejection', (error: Error) => { + // console.log(error) + // }) + // process.on('uncaughtException', (error: Error) => { + // console.log(error) + // }) + }, +} // Error Handler to avoid the bot from crashing on error. diff --git a/apps/discord-bot/src/events/interactionCreate.ts b/apps/discord-bot/src/events/interactionCreate.ts new file mode 100644 index 00000000..02a6f236 --- /dev/null +++ b/apps/discord-bot/src/events/interactionCreate.ts @@ -0,0 +1,68 @@ +import type { DiscordClient, Interaction } from 'discord.js' +import type { ButtonCommand, ClientEvent, ContextMenu, ModalForm, SelectMenu, SlashCommand } from '../types.js' +import commandOptionsChecker from '../structures/commandOptions/processor.js' + +export const Event: ClientEvent = { + name: 'interactionCreate', + run: async (interaction: Interaction<'cached'>, client: DiscordClient): Promise => { + if (interaction.isChatInputCommand()) { + const slashCommand: SlashCommand | undefined = client.slashCommands?.get(interaction.commandName) + if (!slashCommand) + return + + if (!await commandOptionsChecker(client, interaction, slashCommand, 'SlashCommand')) + return + else slashCommand.run(interaction, client) + } + + else if (interaction.isAutocomplete()) { + const slashCommand: SlashCommand | undefined = client.slashCommands?.get(interaction.commandName) + if (!slashCommand || !slashCommand.autocomplete) + return + + if (!await commandOptionsChecker(client, interaction, slashCommand, 'SlashCommand')) + return + else slashCommand.autocomplete(interaction, client) + } + + else if (interaction.isContextMenuCommand()) { + const contextMenu: ContextMenu | undefined = client.contextMenus?.get(interaction.commandName) + if (!contextMenu) + return + + if (!await commandOptionsChecker(client, interaction, contextMenu, 'ContextMenu')) + return + else contextMenu.run(interaction, client) + } + + else if (interaction.isAnySelectMenu()) { + const selectMenuCommand: SelectMenu | undefined = client.selectMenus?.get(interaction.values[0]) ?? client.selectMenus?.get(interaction.customId) + if (!selectMenuCommand) + return + + if (!await commandOptionsChecker(client, interaction, selectMenuCommand, 'SelectMenu')) + return + else selectMenuCommand.run(interaction, client) + } + + else if (interaction.isButton()) { + const buttonInteraction: ButtonCommand | undefined = client.buttonCommands?.get(interaction.customId) + if (!buttonInteraction) + return + + if (!await commandOptionsChecker(client, interaction, buttonInteraction, 'Button')) + return + else buttonInteraction.run(interaction, client) + } + + else if (interaction.isModalSubmit()) { + const modalInteraction: ModalForm | undefined = client.modalForms?.get(interaction.customId) + if (!modalInteraction) + return + + if (!await commandOptionsChecker(client, interaction, modalInteraction, 'ModalForm')) + return + else modalInteraction.run(interaction, client) + }; + }, +} // InteractionCreate event to handle all interactions and execute them. diff --git a/apps/discord-bot/src/events/messageCreate.ts b/apps/discord-bot/src/events/messageCreate.ts new file mode 100644 index 00000000..1f773cbd --- /dev/null +++ b/apps/discord-bot/src/events/messageCreate.ts @@ -0,0 +1,30 @@ +import type { DiscordClient, Message } from 'discord.js' +import type { ClientEvent, MessageCommand } from '../types.js' +import { PREFIX } from '../config.js' +import commandOptionsChecker from '../structures/commandOptions/processor.js' + +export const Event: ClientEvent = { + name: 'messageCreate', + run: (message: Message, client: DiscordClient): void => { + if (!Array.isArray(PREFIX)) + return + PREFIX.forEach(async (botPrefix: string) => { + if (!message.content.startsWith(botPrefix)) + return + const commandName: string = message.content.toLowerCase().slice(botPrefix.length).trim().split(' ')[0] + const command: MessageCommand | undefined = client.messageCommands?.get(commandName) ?? client.messageCommands?.get(client.messageCommands_Aliases?.get(commandName) ?? '') + if (!command) + return + const args: Array = message.content.slice(botPrefix.length).trim().slice(commandName.length).trim().split(' ') + const processedCommandOptionsChecker: boolean = await commandOptionsChecker(client, message, command, 'MessageCommand') + + if (!command.allowInDms && !message.guild) + return + if (!command.allowBots && message.author.bot) + return + + if (processedCommandOptionsChecker) + await command.run(client, message, args) + }) + }, +} // MessageCreate event to handle all messages and execute messageCommands (if found). diff --git a/apps/discord-bot/src/events/ready.ts b/apps/discord-bot/src/events/ready.ts new file mode 100644 index 00000000..4b5dbec1 --- /dev/null +++ b/apps/discord-bot/src/events/ready.ts @@ -0,0 +1,61 @@ +import type { DiscordClient } from 'discord.js' +import type { ClientEvent, ContextMenu, SlashCommand } from '../types.js' +import { ActivityType } from 'discord.js' +import { rootPath } from '../bot.js' +import { LOG_READY_EXPLICIT } from '../config.js' +import { fileReader } from '../utils/fileReader.js' + +export const Event: ClientEvent = { + name: 'ready', + runOnce: true, + run: async (client: DiscordClient): Promise => { + client.user?.setActivity('Humans.', { + type: ActivityType.Watching, + }) + + let allSlashCommands: Array = fileReader(`${rootPath}/interactions/slashCommands`) + allSlashCommands = await allSlashCommands.reduce(async (array: any, slash: string): Promise> => { + const command: SlashCommand | undefined = (await import(`file:///${slash}`))?.Slash + + if (command?.ignore || !command?.name) + return array + else return (await array).concat(slash) + }, []) + + let allContextMenus: Array = fileReader(`${rootPath}/interactions/contextMenus`) + allContextMenus = await allContextMenus.reduce(async (array: any, context: string): Promise> => { + const command: ContextMenu | undefined = (await import(`file:///${context}`))?.Context + + if (command?.ignore || !command?.name || !command?.type) + return array + else return (await array).concat(context) + }, []) + + // console.log(t.bold.green.toFunction()('[Client] ') + t.bold.blue.toFunction()(`Logged into ${client.user?.tag}`)) + + if (!LOG_READY_EXPLICIT) + return + + if ((client.messageCommands?.size ?? 0) > 0) { + // console.log(t.bold.red.toFunction()('[MessageCommands] ') + t.bold.cyan.toFunction()(`Loaded ${(client.messageCommands?.size ?? 0)} MessageCommands with ${t.bold.white.toFunction()(`${client.messageCommands_Aliases?.size} Aliases`)}.`)) + if ((client.events?.size ?? 0) > 0) { + // console.log(t.bold.yellow.toFunction()('[Events] ') + t.bold.magenta.toFunction()(`Loaded ${(client.events?.size ?? 0)} Events.`)) + if ((client.buttonCommands?.size ?? 0) > 0) { + // console.log(t.bold.brightGreen.toFunction()('[ButtonCommands] ') + t.bold.brightYellow.toFunction()(`Loaded ${(client.buttonCommands?.size ?? 0)} Buttons.`)) + if ((client.selectMenus?.size ?? 0) > 0) { + // console.log(t.bold.red.toFunction()('[SelectMenus] ') + t.bold.brightBlue.toFunction()(`Loaded ${(client.selectMenus?.size ?? 0)} SelectMenus.`)) + if ((client.modalForms?.size ?? 0) > 0) { + // console.log(t.bold.brightCyan.toFunction()('[ModalForms] ') + t.bold.brightYellow.toFunction()(`Loaded ${(client.modalForms?.size ?? 0)} Modals.`)) + if (allSlashCommands?.length > 0) { + // console.log(t.bold.magenta.toFunction()('[SlashCommands] ') + t.bold.white.toFunction()(`Loaded ${allSlashCommands.length} SlashCommands.`)) + if (allContextMenus?.length > 0) { + // console.log(t.bold.magenta.toFunction()('[ContextMenus] ') + t.bold.white.toFunction()(`Loaded ${allContextMenus.length} ContextMenus.`)) + } + } + } + } + } + } + } + }, +} // Log all data about the client on login. diff --git a/apps/discord-bot/src/interactions/buttons/deleteOutput.ts b/apps/discord-bot/src/interactions/buttons/deleteOutput.ts new file mode 100644 index 00000000..e8153e48 --- /dev/null +++ b/apps/discord-bot/src/interactions/buttons/deleteOutput.ts @@ -0,0 +1,9 @@ +import type { ButtonCommand } from '../../types.js' + +export const Button: ButtonCommand = { + name: 'deleteOutput', + ownerOnly: true, + run: (interaction): void => { + interaction.message.delete() + }, +} // ButtonCommand of the deleteOutput button. diff --git a/apps/discord-bot/src/interactions/contextMenus/getUsername.ts b/apps/discord-bot/src/interactions/contextMenus/getUsername.ts new file mode 100644 index 00000000..3b8ee444 --- /dev/null +++ b/apps/discord-bot/src/interactions/contextMenus/getUsername.ts @@ -0,0 +1,19 @@ +import type { UserContextMenuCommandInteraction } from 'discord.js' +import type { ContextMenu } from '../../types.js' +import { ApplicationCommandType } from 'discord.js' + +export const Context: ContextMenu = { + name: 'getuser', + type: ApplicationCommandType.User, + run: (interaction): void => { + interaction = interaction as UserContextMenuCommandInteraction<'cached'> // If you want to use UserContextMenuCommandInteraction specifically. + + let member = interaction.guild.members.cache.get(interaction.targetId) + if (!member) + member = interaction.member + + interaction.reply({ + content: `That is ${member.user.tag}.`, + }) + }, +} // Simple UserContextMenu example diff --git a/apps/discord-bot/src/interactions/modalForms/exampleModal.ts b/apps/discord-bot/src/interactions/modalForms/exampleModal.ts new file mode 100644 index 00000000..edca036f --- /dev/null +++ b/apps/discord-bot/src/interactions/modalForms/exampleModal.ts @@ -0,0 +1,10 @@ +import type { ModalForm } from '../../types.js' + +export const Modal: ModalForm = { + name: 'ExampleModal', + run: (interaction): void => { + interaction.reply({ + content: 'This modal is correctly functioning.', + }) + }, +} // Code for the ExampleModal ModalForm diff --git a/apps/discord-bot/src/interactions/selectMenus/selectMenuExample.ts b/apps/discord-bot/src/interactions/selectMenus/selectMenuExample.ts new file mode 100644 index 00000000..9f369256 --- /dev/null +++ b/apps/discord-bot/src/interactions/selectMenus/selectMenuExample.ts @@ -0,0 +1,12 @@ +import type { StringSelectMenuInteraction } from 'discord.js' +import type { SelectMenu } from '../../types.js' + +export const Menu: SelectMenu = { + name: 'SelectMenuExample', + run: (interaction): void => { + interaction = interaction as StringSelectMenuInteraction<'cached'> // If you want to use StringSelectMenuInteraction specifically. + interaction.reply({ + content: 'Here is your cookie! :cookie:', + }) + }, +} // Code for SelectMenuExample SelectMenu diff --git a/apps/discord-bot/src/interactions/slashCommands/exampleModal.ts b/apps/discord-bot/src/interactions/slashCommands/exampleModal.ts new file mode 100644 index 00000000..f6f3cfa2 --- /dev/null +++ b/apps/discord-bot/src/interactions/slashCommands/exampleModal.ts @@ -0,0 +1,29 @@ +import type { SlashCommand } from '../../types.js' +import { ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js' + +export const Slash: SlashCommand = { + name: 'testmodal', + description: 'Test Modal', + run: async (interaction): Promise => { + const modal = new ModalBuilder() + .setCustomId('ExampleModal') + .setTitle('My Modal') + + const favoriteColorInput = new TextInputBuilder() + .setCustomId('favoriteColorInput') + .setLabel('What\'s your favorite color?') + .setStyle(TextInputStyle.Short) + + const hobbiesInput = new TextInputBuilder() + .setCustomId('hobbiesInput') + .setLabel('What\'s some of your favorite hobbies?') + .setStyle(TextInputStyle.Paragraph) + + const firstActionRow = new ActionRowBuilder().addComponents(favoriteColorInput) + const secondActionRow = new ActionRowBuilder().addComponents(hobbiesInput) + + modal.addComponents(firstActionRow, secondActionRow) + + await interaction.showModal(modal) + }, +} // Call an example modal on execution. diff --git a/apps/discord-bot/src/interactions/slashCommands/ping.ts b/apps/discord-bot/src/interactions/slashCommands/ping.ts new file mode 100644 index 00000000..6c12ad94 --- /dev/null +++ b/apps/discord-bot/src/interactions/slashCommands/ping.ts @@ -0,0 +1,11 @@ +import type { SlashCommand } from '../../types.js' + +export const Slash: SlashCommand = { + name: 'ping', + description: 'Pong', + run: (interaction, client): void => { + interaction.reply({ + content: `Ping is ${client.ws.ping}ms.`, + }) + }, +} // Simple /Ping command diff --git a/apps/discord-bot/src/messageCommands/callSelectMenu.ts b/apps/discord-bot/src/messageCommands/callSelectMenu.ts new file mode 100644 index 00000000..8fc4412d --- /dev/null +++ b/apps/discord-bot/src/messageCommands/callSelectMenu.ts @@ -0,0 +1,28 @@ +import type { MessageCommand } from '../types.js' +import { ActionRowBuilder, ChannelType, StringSelectMenuBuilder } from 'discord.js' + +export const MsgCommand: MessageCommand = { + name: 'callselectmenu', + run: (client, message): void => { + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return + + message.channel.send({ + content: 'Cookies SelectMenu', + components: [ + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('SelectMenuExample') + .setPlaceholder('Free Cookies!') + .addOptions( + [{ + label: 'Click for cookies!', + description: 'Freeee!', + value: 'CookieBox', + }], + ), + ), + ], + }) + }, +} // Calls the SelectMenuExample SelectMenu. diff --git a/apps/discord-bot/src/messageCommands/eval.ts b/apps/discord-bot/src/messageCommands/eval.ts new file mode 100644 index 00000000..83b86024 --- /dev/null +++ b/apps/discord-bot/src/messageCommands/eval.ts @@ -0,0 +1,68 @@ +import type { MessageCommand } from '../types.js' +import { inspect } from 'node:util' +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType } from 'discord.js' + +export const MsgCommand: MessageCommand = { + name: 'eval', + ownerOnly: true, + run: async (client, message, args): Promise => { + const deleteMessageComponent: ActionRowBuilder = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('deleteOutput') + .setLabel('Delete Output') + .setStyle(ButtonStyle.Danger), + ) + + let code: string = args.join(' ').trim() + let depth: number = 0 + const originalCode: string = code + + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return + + if (!code) { + message.channel.send({ content: 'Please specify something to Evaluate' }) + return + } + + try { + if (originalCode.includes('--str')) + code = `${code.replace('--str', '').trim()}.toString()` + else if (originalCode.includes('--send')) + code = `message.channel.send(${code.replace('--send', '').trim()})` + else if (originalCode.includes('--async')) + code = `(async () => {${code.replace('--async', '').trim()}})()` + else if (originalCode.includes('--depth=')) + depth = Number(originalCode.split('--depth=')[1]) + + code = code.split('--depth=')[0] + code = code.replace('--silent', '').trim() + // code = await eval(code) + code = inspect(code, { depth }) + + if (String(code).length > 1990) + code = 'Output is too long' + if (String(code).includes(client?.token ?? '')) { + code = 'This message contained client\'s token.' + } + // if (originalCode.includes('--silent')) { + // } + else { + message.reply({ + content: `\`\`\`js\n${code}\n\`\`\``, + components: [deleteMessageComponent], + allowedMentions: { + repliedUser: false, + }, + }) + } + } + catch (error) { + // console.log(error) + message.channel.send({ + content: `\`\`\`js\n${error}\n\`\`\``, + components: [deleteMessageComponent], + }) + } + }, +} // Eval code using message command. diff --git a/apps/discord-bot/src/messageCommands/ping.ts b/apps/discord-bot/src/messageCommands/ping.ts new file mode 100644 index 00000000..edca2d01 --- /dev/null +++ b/apps/discord-bot/src/messageCommands/ping.ts @@ -0,0 +1,15 @@ +import type { MessageCommand } from '../types.js' +import { ChannelType } from 'discord.js' + +export const MsgCommand: MessageCommand = { + name: 'ping', + aliases: ['pong'], + run: (client, message): void => { + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return + + message.channel.send({ + content: `My ping is ${client.ws.ping}ms.`, + }) + }, +} // Simple ping message command. diff --git a/apps/discord-bot/src/structures/commandOptions/allClientPermissions.ts b/apps/discord-bot/src/structures/commandOptions/allClientPermissions.ts new file mode 100644 index 00000000..225d5898 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/allClientPermissions.ts @@ -0,0 +1,34 @@ +import type { DiscordClient, Interaction, Message, PermissionsString } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function allClientPermissionsFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand): boolean { + if (!command.allClientPermissions || !Array.isArray(command.allClientPermissions) || !message.guild) + return true + const missingPermissions: Array | undefined = message.guild?.members?.me?.permissions.missing(command.allClientPermissions) + + if (!missingPermissions?.length) { + return true + } + else { + if (command.returnErrors === false || command.returnAllClientPermissionsError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [ + new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`The client is missing the set permissions which are necessary to run this command. Please provide the client these permissions to execute this command:\n${missingPermissions.map((permission: string) => `↳ \`${permission}\``).join('\n')}`), + ], + }) + return false + }; +} diff --git a/apps/discord-bot/src/structures/commandOptions/allUserPermissions.ts b/apps/discord-bot/src/structures/commandOptions/allUserPermissions.ts new file mode 100644 index 00000000..c5bf4d33 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/allUserPermissions.ts @@ -0,0 +1,34 @@ +import type { DiscordClient, Interaction, Message, PermissionsString } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function allUserPermissionsFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand): boolean { + if (!command.allUserPermissions || !Array.isArray(command.allUserPermissions) || !message.guild) + return true + const missingPermissions: Array | undefined = message.member?.permissions.missing(command.allUserPermissions) + + if (!missingPermissions?.length) { + return true + } + else { + if (command.returnErrors === false || command.returnAllUserPermissionsError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message?.channel.send({ + embeds: [ + new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`You are missing the set permissions which are necessary to run this command. Please acquire these permissions to execute this command:\n${missingPermissions.map((permission: string) => `↳ \`${permission}\``).join('\n')}`), + ], + }) + return false + }; +} diff --git a/apps/discord-bot/src/structures/commandOptions/anyClientPermissions.ts b/apps/discord-bot/src/structures/commandOptions/anyClientPermissions.ts new file mode 100644 index 00000000..53bcc4ed --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/anyClientPermissions.ts @@ -0,0 +1,31 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function anyClientPermissionsFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand): boolean { + if (!command.anyClientPermissions || !Array.isArray(command.anyClientPermissions) || !message.guild) + return true + if (command.anyClientPermissions.some((permission: bigint) => message.guild?.members.me?.permissions.has(permission))) { + return true + } + else { + if (command.returnErrors === false || command.returnAnyClientPermissionsError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`The client is missing any one of these permissions which are necessary to run this command. Please provide the client any one of these permissions to execute this command:\n${command.anyClientPermissions.map((permission: bigint) => `↳ \`${permission}\``).join('\n')}`), + ], + }) + return false + } +} diff --git a/apps/discord-bot/src/structures/commandOptions/anyUserPermissions.ts b/apps/discord-bot/src/structures/commandOptions/anyUserPermissions.ts new file mode 100644 index 00000000..d73975b3 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/anyUserPermissions.ts @@ -0,0 +1,31 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function anyUserPermissionsFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand): boolean { + if (!command.anyUserPermissions || !Array.isArray(command.anyUserPermissions) || !message.guild) + return true + if (command.anyUserPermissions.some((permission: bigint) => message.member?.permissions.has(permission))) { + return true + } + else { + if (command.returnErrors === false || command.returnAnyUserPermissionsError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`You are missing any one of these permissions which are necessary to run this command. Please acquire any one of these permissions to execute this command:\n${command.anyUserPermissions.map((permission: bigint) => `↳ \`${permission}\``).join('\n')}`), + ], + }) + return false + } +} diff --git a/apps/discord-bot/src/structures/commandOptions/channelCooldown.ts b/apps/discord-bot/src/structures/commandOptions/channelCooldown.ts new file mode 100644 index 00000000..0cee984c --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/channelCooldown.ts @@ -0,0 +1,37 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand, InteractionTypeOptions } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export async function channelCooldownFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand, interactionType: InteractionTypeOptions): Promise { + if (!command.channelCooldown || Number.isNaN(command.channelCooldown) || !message.guild) + return true + + const dbData = `channelCoolown.${message.channel?.id}.${interactionType}.${command.name}.${message.member?.id}` + const currentTime: number = Date.now() + const storedTime: number = client.cooldownDB?.get(dbData) ?? 0 + + if (Math.floor(currentTime - storedTime) >= command.channelCooldown || !storedTime) { + client.cooldownDB?.set(dbData, currentTime) + return true + } + else { + if (command.returnErrors === false || command.returnChannelCooldownError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`You are currently at cooldown. Please try again in .`), + ], + }) + return false + } +} diff --git a/apps/discord-bot/src/structures/commandOptions/globalCooldown.ts b/apps/discord-bot/src/structures/commandOptions/globalCooldown.ts new file mode 100644 index 00000000..9974936f --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/globalCooldown.ts @@ -0,0 +1,37 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand, InteractionTypeOptions } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export async function globalCooldownFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand, interactionType: InteractionTypeOptions): Promise { + if (!command.globalCooldown || Number.isNaN(command.globalCooldown)) + return true + + const dbData = `globalCooldown.${interactionType}.${command.name}.${message.member?.id}` + const currentTime: number = Date.now() + const storedTime: number = client.cooldownDB?.get(dbData) ?? 0 + + if (Math.floor(currentTime - storedTime) >= command.globalCooldown || !storedTime) { + client.cooldownDB?.set(dbData, currentTime) + return true + } + else { + if (command.returnErrors === false || command.returnGlobalCooldownError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`You are currently at cooldown. Please try again in .`), + ], + }) + return false + } +} diff --git a/apps/discord-bot/src/structures/commandOptions/guildCooldown.ts b/apps/discord-bot/src/structures/commandOptions/guildCooldown.ts new file mode 100644 index 00000000..2aebee0c --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/guildCooldown.ts @@ -0,0 +1,37 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand, InteractionTypeOptions } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export async function guildCooldownFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand, interactionType: InteractionTypeOptions): Promise { + if (!command.guildCooldown || Number.isNaN(command.guildCooldown) || !message.guild) + return true + + const dbData = `guildCooldown.${message.guild?.id}.${interactionType}.${command.name}.${message.member?.id}` + const currentTime: number = Date.now() + const storedTime: number = client.cooldownDB?.get(dbData) ?? 0 + + if (Math.floor(currentTime - storedTime) >= command.guildCooldown || !storedTime) { + client.cooldownDB?.set(dbData, currentTime) + return true + } + else { + if (command.returnErrors === false || command.returnGuildCooldownError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`You are currently at cooldown. Please try again in .`), + ], + }) + return false + } +} diff --git a/apps/discord-bot/src/structures/commandOptions/onlyChannels.ts b/apps/discord-bot/src/structures/commandOptions/onlyChannels.ts new file mode 100644 index 00000000..950d3cd4 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/onlyChannels.ts @@ -0,0 +1,32 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function onlyChannelsFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand) { + if (!command.onlyChannels || !Array.isArray(command.onlyChannels) || !message.guild) + return true + + if (command.onlyChannels.includes(message.channel?.id)) { + return true + } + else { + if (command.returnErrors === false || command.returnOnlyChannelsError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`The command you tried to execute cannot be ran in the current channel. Please execute the command in of these authorized channels:\n${command.onlyChannels.map(channelId => `↳ <#${channelId}>`).join('\n')}`), + ], + }) + return false + }; +} diff --git a/apps/discord-bot/src/structures/commandOptions/onlyGuilds.ts b/apps/discord-bot/src/structures/commandOptions/onlyGuilds.ts new file mode 100644 index 00000000..ebe515b9 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/onlyGuilds.ts @@ -0,0 +1,32 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function onlyGuildsFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand) { + if (!command.onlyGuilds || !Array.isArray(command.onlyGuilds) || !message.guild) + return true + + if (command.onlyGuilds.includes(message.guild?.id)) { + return true + } + else { + if (command.returnErrors === false || command.returnOnlyGuildsError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`The command you tried to execute cannot be ran in the current guild. Please execute the command in of these authorized guilds:\n${command.onlyGuilds.map((guildID: string) => `↳ <#${guildID}>`).join('\n')}`), + ], + }) + return false + }; +} diff --git a/apps/discord-bot/src/structures/commandOptions/onlyRoles.ts b/apps/discord-bot/src/structures/commandOptions/onlyRoles.ts new file mode 100644 index 00000000..b9270af1 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/onlyRoles.ts @@ -0,0 +1,32 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function onlyRolesFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand) { + if (!command.onlyRoles || !Array.isArray(command.onlyRoles) || !message.guild) + return true + + if (command.onlyRoles.some((roleID: string) => message.member?.roles.cache.has(roleID))) { + return true + } + else { + if (command.returnErrors === false || command.returnOnlyRolesError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription(`The command you tried to execute couldn't be executed as you are missing one of these required roles:\n${command.onlyRoles.map((roleID: string) => `↳ <#${roleID}>`).join('\n')}`), + ], + }) + return false + }; +} diff --git a/apps/discord-bot/src/structures/commandOptions/onlyUsers.ts b/apps/discord-bot/src/structures/commandOptions/onlyUsers.ts new file mode 100644 index 00000000..bb74f8d0 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/onlyUsers.ts @@ -0,0 +1,32 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' + +export function onlyUsersFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand) { + if (!command.onlyUsers || !Array.isArray(command.onlyUsers)) + return true + + if (command.onlyUsers.includes(((message as Interaction<'cached'>).user ?? (message as Message).author)?.id)) { + return true + } + else { + if (command.returnErrors === false || command.returnOnlyUsersError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription('The command you tried to execute couldn\'t be ran as you are not one of the authorized users.'), + ], + }) + return false + }; +} diff --git a/apps/discord-bot/src/structures/commandOptions/ownerOnly.ts b/apps/discord-bot/src/structures/commandOptions/ownerOnly.ts new file mode 100644 index 00000000..3d65eec6 --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/ownerOnly.ts @@ -0,0 +1,33 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand } from '../../types.js' +import { ChannelType, EmbedBuilder } from 'discord.js' +import { OWNER_IDS } from '../../config.js' + +export function ownerOnlyFN(client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand) { + if (!command.ownerOnly || typeof command.ownerOnly !== 'boolean') + return true + if (OWNER_IDS.includes(((message as Interaction<'cached'>).user ?? (message as Message).author)?.id)) { + return true + } + else { + if (command.returnErrors === false || command.returnOwnerOnlyError === false) + return false + if (!message.channel || message.channel.type !== ChannelType.GuildText) + return false + + message.channel.send({ + embeds: [ + new EmbedBuilder() + .setColor('DarkRed') + .setTimestamp() + .setAuthor({ + name: message.member?.user.globalName ?? message.member?.user.username ?? '', + iconURL: message.member?.user.displayAvatarURL(), + }) + .setThumbnail(client.user.displayAvatarURL()) + .setDescription('The command you tried to run is __restricted__ for the developers of this bot and thus the command failed to execute.'), + ], + }) + return false + }; +} diff --git a/apps/discord-bot/src/structures/commandOptions/processor.ts b/apps/discord-bot/src/structures/commandOptions/processor.ts new file mode 100644 index 00000000..fcbe479f --- /dev/null +++ b/apps/discord-bot/src/structures/commandOptions/processor.ts @@ -0,0 +1,37 @@ +import type { DiscordClient, Interaction, Message } from 'discord.js' +import type { AnyCommand, InteractionTypeOptions } from '../../types.js' + +import { allClientPermissionsFN } from './allClientPermissions.js' +import { allUserPermissionsFN } from './allUserPermissions.js' +import { anyClientPermissionsFN } from './anyClientPermissions.js' +import { anyUserPermissionsFN } from './anyUserPermissions.js' +import { channelCooldownFN } from './channelCooldown.js' +import { globalCooldownFN } from './globalCooldown.js' +import { guildCooldownFN } from './guildCooldown.js' +import { onlyChannelsFN } from './onlyChannels.js' +import { onlyGuildsFN } from './onlyGuilds.js' +import { onlyRolesFN } from './onlyRoles.js' +import { onlyUsersFN } from './onlyUsers.js' +import { ownerOnlyFN } from './ownerOnly.js' + +export default async (client: DiscordClient, message: Message | Interaction<'cached'>, command: AnyCommand, interactionType: InteractionTypeOptions) => { + const allClientPermissions: boolean = allClientPermissionsFN(client, message, command) + const anyClientPermissions: boolean = anyClientPermissionsFN(client, message, command) + const allUserPermissions: boolean = allUserPermissionsFN(client, message, command) + const anyUserPermissions: boolean = anyUserPermissionsFN(client, message, command) + + const channelCooldown: boolean = await channelCooldownFN(client, message, command, interactionType) + const globalCooldown: boolean = await globalCooldownFN(client, message, command, interactionType) + const guildCooldown: boolean = await guildCooldownFN(client, message, command, interactionType) + + const onlyChannels: boolean = onlyChannelsFN(client, message, command) + const onlyGuilds: boolean = onlyGuildsFN(client, message, command) + const onlyRoles: boolean = onlyRolesFN(client, message, command) + const onlyUsers: boolean = onlyUsersFN(client, message, command) + const ownerOnly: boolean = ownerOnlyFN(client, message, command) + + const finalCorrection: Array = [allClientPermissions, anyClientPermissions, allUserPermissions, anyUserPermissions, channelCooldown, guildCooldown, globalCooldown, onlyChannels, onlyGuilds, onlyRoles, onlyUsers, ownerOnly] + if (finalCorrection.includes(false)) + return false + else return true +} diff --git a/apps/discord-bot/src/structures/managers/buttonCommands.ts b/apps/discord-bot/src/structures/managers/buttonCommands.ts new file mode 100755 index 00000000..3c7ae504 --- /dev/null +++ b/apps/discord-bot/src/structures/managers/buttonCommands.ts @@ -0,0 +1,18 @@ +import type { DiscordClient } from 'discord.js' +import type { ButtonCommand } from '../../types.js' +import { fileReader } from '../../utils/fileReader.js' + +export async function ButtonManager(client: DiscordClient, rootPath: string): Promise { + const buttonCommandFiles: Array = fileReader(`${rootPath}/interactions/buttons`) + if (!buttonCommandFiles.length) + return + + for (const buttonCommandFile of buttonCommandFiles) { + const buttonCommand: ButtonCommand = (await import(`file:///${buttonCommandFile}`))?.Button + if (!buttonCommand) + continue + + if (!buttonCommand?.ignore && buttonCommand?.name) + client.buttonCommands?.set(buttonCommand?.name, buttonCommand) + }; +} diff --git a/apps/discord-bot/src/structures/managers/events.ts b/apps/discord-bot/src/structures/managers/events.ts new file mode 100755 index 00000000..196c3df8 --- /dev/null +++ b/apps/discord-bot/src/structures/managers/events.ts @@ -0,0 +1,24 @@ +import type { DiscordClient } from 'discord.js' +import type { ClientEvent } from '../../types.js' +import { fileReader } from '../../utils/fileReader.js' + +export async function EventManager(client: DiscordClient, rootPath: string): Promise { + const eventFiles: Array = fileReader(`${rootPath}/events`) + if (!eventFiles.length) + return + + for (const event of eventFiles) { + const clientEvent: ClientEvent = (await import(`file:///${event}`))?.Event + if (clientEvent.ignore) + continue + + client.events?.set(clientEvent.name, clientEvent) + + if (clientEvent.customEvent) + clientEvent.run(client) + else if (clientEvent.name && clientEvent.runOnce) + client.once(clientEvent.name, (...args: unknown[]) => clientEvent.run(...args, client)) + else if (clientEvent.name) + client.on(clientEvent.name, (...args: unknown[]) => clientEvent.run(...args, client)) + }; +} diff --git a/apps/discord-bot/src/structures/managers/messageCommands.ts b/apps/discord-bot/src/structures/managers/messageCommands.ts new file mode 100755 index 00000000..e0e86092 --- /dev/null +++ b/apps/discord-bot/src/structures/managers/messageCommands.ts @@ -0,0 +1,23 @@ +import type { DiscordClient } from 'discord.js' +import type { MessageCommand } from '../../types.js' +import { fileReader } from '../../utils/fileReader.js' + +export async function MessageCMDManager(client: DiscordClient, rootPath: string): Promise { + const messageCommandsFiles: Array = fileReader(`${rootPath}/messageCommands`) + if (!messageCommandsFiles.length) + return + + for (const messageCommandFile of messageCommandsFiles) { + const messageCommand: MessageCommand = (await import(`file:///${messageCommandFile}`))?.MsgCommand + if (!messageCommand) + continue + + if (!messageCommand.ignore && messageCommand.name) + client.messageCommands?.set(messageCommand.name.toLowerCase(), messageCommand) + if (!messageCommand.ignore && messageCommand.aliases && Array.isArray(messageCommand.aliases)) { + messageCommand.aliases.forEach((messageCommandAlias: string) => { + client.messageCommands_Aliases?.set(messageCommandAlias, messageCommand.name) + }) + } + }; +} diff --git a/apps/discord-bot/src/structures/managers/modalForms.ts b/apps/discord-bot/src/structures/managers/modalForms.ts new file mode 100755 index 00000000..716885bb --- /dev/null +++ b/apps/discord-bot/src/structures/managers/modalForms.ts @@ -0,0 +1,18 @@ +import type { DiscordClient } from 'discord.js' +import type { ModalForm } from '../../types.js' +import { fileReader } from '../../utils/fileReader.js' + +export async function ModalManager(client: DiscordClient, rootPath: string): Promise { + const modalFormFiles: Array = fileReader(`${rootPath}/interactions/modalForms`) + if (!modalFormFiles.length) + return + + for (const modalFormFile of modalFormFiles) { + const modalForm: ModalForm = (await import(`file:///${modalFormFile}`))?.Modal + if (!modalForm) + continue + + if (!modalForm.ignore && modalForm.name) + client.modalForms?.set(modalForm.name, modalForm) + }; +} diff --git a/apps/discord-bot/src/structures/managers/selectMenus.ts b/apps/discord-bot/src/structures/managers/selectMenus.ts new file mode 100755 index 00000000..12220472 --- /dev/null +++ b/apps/discord-bot/src/structures/managers/selectMenus.ts @@ -0,0 +1,18 @@ +import type { DiscordClient } from 'discord.js' +import type { SelectMenu } from '../../types.js' +import { fileReader } from '../../utils/fileReader.js' + +export async function SelectMenuManager(client: DiscordClient, rootPath: string): Promise { + const selectMenuFiles: Array = fileReader(`${rootPath}/interactions/selectMenus`) + if (!selectMenuFiles.length) + return + + for (const selectMenuFile of selectMenuFiles) { + const selectMenu: SelectMenu = (await import(`file:///${selectMenuFile}`))?.Menu + if (!selectMenu) + continue + + if (!selectMenu.ignore && selectMenu.name) + client.selectMenus?.set(selectMenu.name, selectMenu) + }; +} diff --git a/apps/discord-bot/src/structures/managers/slashCommands.ts b/apps/discord-bot/src/structures/managers/slashCommands.ts new file mode 100755 index 00000000..c9a51e8b --- /dev/null +++ b/apps/discord-bot/src/structures/managers/slashCommands.ts @@ -0,0 +1,113 @@ +import type { DiscordClient } from 'discord.js' +import type { ContextMenu, SlashCommand, SlashCommandOptions } from '../../types.js' +import { ApplicationCommandType, REST, Routes } from 'discord.js' +import { fileReader } from '../../utils/fileReader.js' + +export async function SlashManager(client: DiscordClient, rootPath: string): Promise { + const allSlashCommandsFiles = fileReader(`${rootPath}/interactions/slashCommands`) + const allContextMenusFiles = fileReader(`${rootPath}/interactions/contextMenus`) + const rest: REST = new REST({ version: '10' }).setToken(client.token) + + interface GlobalCommandArray { + name: string + nsfw?: boolean + type: ApplicationCommandType + description?: string + options?: Array + }; + + interface GuildCommandObjects { + [key: string]: Array<{ + name: string + nsfw?: boolean + description?: string + type: ApplicationCommandType + options?: Array + }> + }; + + const guildCommandsObject: GuildCommandObjects = {} + const globalCommandsArray: Array = [] + + if (allSlashCommandsFiles.length > 0) { + for (const slashCommandFile of allSlashCommandsFiles) { + const slashCommand: SlashCommand | undefined = (await import(`file:///${slashCommandFile}`))?.Slash + if (!slashCommand) + continue + + if (slashCommand?.ignore || !slashCommand?.name || !slashCommand.description) + continue + client.slashCommands?.set(slashCommand.name, slashCommand) + + if (slashCommand.guilds && Array.isArray(slashCommand.guilds)) { + for (const guild of slashCommand.guilds) { + if (!guildCommandsObject[guild]) + guildCommandsObject[guild] = [] + + guildCommandsObject[guild].push({ + name: slashCommand.name, + nsfw: slashCommand.nsfw ?? false, + description: slashCommand.description, + type: ApplicationCommandType.ChatInput, + options: slashCommand.options ?? [], + }) + }; + } + else { + globalCommandsArray.push({ + name: slashCommand.name, + nsfw: slashCommand.nsfw ?? false, + description: slashCommand.description, + type: ApplicationCommandType.ChatInput, + options: slashCommand.options ?? [], + }) + } + }; + }; + + if (allContextMenusFiles.length > 0) { + for (const contextMenuFile of allContextMenusFiles) { + const contextMenu: ContextMenu | undefined = (await import(`file:///${contextMenuFile}`))?.Context + if (!contextMenu) + continue + + if (contextMenu?.ignore || !contextMenu?.name || !contextMenu?.type) + continue + client.contextMenus?.set(contextMenu.name, contextMenu) + + if (contextMenu.guilds && Array.isArray(contextMenu.guilds)) { + for (const guild of contextMenu.guilds) { + if (!guildCommandsObject[guild]) + guildCommandsObject[guild] = [] + + guildCommandsObject[guild].push({ + name: contextMenu.name, + type: contextMenu.type, + }) + }; + } + + else { + globalCommandsArray.push({ + name: contextMenu.name, + type: contextMenu.type, + }) + } + }; + }; + + // try { + await rest.put(Routes.applicationCommands(client.application.id), { + body: globalCommandsArray, + }) + + for (const guildObject of Object.entries(guildCommandsObject)) { + await rest.put(Routes.applicationGuildCommands(client.application.id, guildObject[0]), { + body: guildObject[1], + }) + }; + // } + // catch (error: unknown) { + // console.log(error) + // }; +} diff --git a/apps/discord-bot/src/types.ts b/apps/discord-bot/src/types.ts new file mode 100644 index 00000000..3ced1d7a --- /dev/null +++ b/apps/discord-bot/src/types.ts @@ -0,0 +1,116 @@ +import type { AnySelectMenuInteraction, ApplicationCommandOptionType, ApplicationCommandType, AutocompleteInteraction, ButtonInteraction, ChatInputCommandInteraction, DiscordClient, Message, MessageContextMenuCommandInteraction, ModalSubmitInteraction, UserContextMenuCommandInteraction } from 'discord.js' +import type JSONdb from 'simple-json-db' + +// Main Types +declare module 'discord.js' { + export interface DiscordClient extends Client { + cooldownDB?: JSONdb + messageCommands?: Map + messageCommands_Aliases?: Map + events?: Map + buttonCommands?: Map + selectMenus?: Map + modalForms?: Map + slashCommands?: Map + contextMenus?: Map + } +}; // Extends Discord.js Client to allow for the Map caches. + +export interface CommandOptions { + allowInDms?: boolean + allClientPermissions?: Array + allUserPermissions?: Array + anyClientPermissions?: Array + anyUserPermissions?: Array + channelCooldown?: number + globalCooldown?: number + guildCooldown?: number + onlyChannels?: Array + onlyGuilds?: Array + onlyRoles?: Array + onlyUsers?: Array + ownerOnly?: boolean + + returnErrors?: boolean + returnAllClientPermissionsError?: boolean + returnAllUserPermissionsError?: boolean + returnAnyClientPermissionsError?: boolean + returnAnyUserPermissionsError?: boolean + returnChannelCooldownError?: boolean + returnGlobalCooldownError?: boolean + returnGuildCooldownError?: boolean + returnOnlyChannelsError?: boolean + returnOnlyGuildsError?: boolean + returnOnlyRolesError?: boolean + returnOnlyUsersError?: boolean + returnOwnerOnlyError?: boolean +}; // All the applicable command options + +export interface MessageCommand extends CommandOptions { + name: string + aliases?: Array + allowBots?: boolean + ignore?: boolean + run: (client: DiscordClient, message: Message, args: Array) => Promise | void +}; // MessageCommands Interface + +export interface ClientEvent { + name: string + runOnce?: boolean + customEvent?: boolean + ignore?: boolean + run: (...args: any[]) => Promise | void +}; // Events Interface + +export interface ButtonCommand extends CommandOptions { + name: string + ignore?: boolean + run: (interaction: ButtonInteraction<'cached'>, client: DiscordClient) => Promise | void +}; // ButtonCommands Interface + +export interface SelectMenu extends CommandOptions { + name: string + ignore?: boolean + run: (interaction: AnySelectMenuInteraction<'cached'>, client: DiscordClient) => Promise | void +}; // SelectMenus Interface + +export interface ModalForm extends CommandOptions { + name: string + ignore?: boolean + run: (interaction: ModalSubmitInteraction<'cached'>, client: DiscordClient) => Promise | void +}; // ModalForms Interface + +export interface SlashCommandOptions { + name: string + description: string + required?: boolean + autocomplete?: boolean + choices?: Array<{ + name: string + value: string + }> + type: ApplicationCommandOptionType + options?: Array +}; // Interface for slashCommands options + +export interface SlashCommand extends CommandOptions { + name: string + description: string + nsfw?: boolean + guilds?: Array + ignore?: boolean + options?: Array + autocomplete?: (interaction: AutocompleteInteraction<'cached'>, client: DiscordClient) => Promise | void + run: (interaction: ChatInputCommandInteraction<'cached'>, client: DiscordClient) => Promise | void +}; // SlashCommands Interface + +export interface ContextMenu extends CommandOptions { + name: string + type: ApplicationCommandType.Message | ApplicationCommandType.User + guilds?: Array + ignore?: boolean + run: (interaction: UserContextMenuCommandInteraction<'cached'> | MessageContextMenuCommandInteraction<'cached'>, client: DiscordClient) => Promise | void +}; // ContextMenus Interface + +export type AnyCommand = MessageCommand | ButtonCommand | SelectMenu | ModalForm | SlashCommand | ContextMenu +export type InteractionTypeOptions = 'MessageCommand' | 'SlashCommand' | 'ContextMenu' | 'SelectMenu' | 'Button' | 'ModalForm' diff --git a/apps/discord-bot/src/utils/fileReader.ts b/apps/discord-bot/src/utils/fileReader.ts new file mode 100644 index 00000000..6cf7ef90 --- /dev/null +++ b/apps/discord-bot/src/utils/fileReader.ts @@ -0,0 +1,23 @@ +import { existsSync, readdirSync, statSync } from 'node:fs' +import { extname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export function fileReader(dir: string): Array { + dir = fileURLToPath(dir) + if (!existsSync(dir)) + return [] + const files: Array = [] + const directoryData = readdirSync(dir) + + for (const file of directoryData) { + const filePath = join(dir, file) + const stats = statSync(filePath) + + if (stats.isFile() && extname(filePath) === '.js') + files.push(filePath) + else if (stats.isDirectory()) + files.push(...fileReader(`file:///${filePath}`)) + }; + + return files +} diff --git a/apps/discord-bot/tsconfig.json b/apps/discord-bot/tsconfig.json new file mode 100644 index 00000000..6790bdf7 --- /dev/null +++ b/apps/discord-bot/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "incremental": false, + "target": "ESNext", + "rootDir": "./src", + "module": "Node16", + "moduleResolution": "Node16", + "resolveJsonModule": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "allowUnreachableCode": false, + "allowUnusedLabels": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": false, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationMap": true, + "noEmitOnError": true, + "outDir": "./dist", + "stripInternal": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +} diff --git a/libs/external/aceternity/tailwind.config.ts b/libs/external/aceternity/tailwind.config.ts index 4b7448fa..503176e2 100644 --- a/libs/external/aceternity/tailwind.config.ts +++ b/libs/external/aceternity/tailwind.config.ts @@ -2,8 +2,8 @@ import type { Config } from 'tailwindcss' import { join } from 'node:path' import { createGlobPatternsForDependencies } from '@nx/react/tailwind' -import { flattenColorPalette } from 'tailwindcss/lib/util/flattenColorPalette' import TailwindAnimate from 'tailwindcss-animate' +import { flattenColorPalette } from 'tailwindcss/lib/util/flattenColorPalette' export function buildConfig( appDir: string, diff --git a/libs/website/feature/faq/index.ts b/libs/website/feature/faq/index.ts index 0defb3de..13964f73 100644 --- a/libs/website/feature/faq/index.ts +++ b/libs/website/feature/faq/index.ts @@ -1,2 +1,2 @@ -export { FAQSection } from './ui/faq.section' export { FAQPresenter } from './ui/faq-presenter/faq.presenter' +export { FAQSection } from './ui/faq.section' diff --git a/libs/website/feature/stats/index.ts b/libs/website/feature/stats/index.ts index cbf6be99..5e2ff652 100644 --- a/libs/website/feature/stats/index.ts +++ b/libs/website/feature/stats/index.ts @@ -1,4 +1,4 @@ export { STATS_CONSTANTS } from './constants/stats.constants' export { StatItem } from './ui/stat-item/stat-item' -export { StatSection } from './ui/stats.section' export { StatPresenter } from './ui/stats-presenter/stats.presenter' +export { StatSection } from './ui/stats.section' From d63677a6b7f4976eea95db12d386b6d40be496b2 Mon Sep 17 00:00:00 2001 From: jowi Date: Wed, 18 Dec 2024 01:46:01 -0500 Subject: [PATCH 2/2] chore(discord-bot): create template server - acts as the 3rd party app for the bot to communicate with --- apps/discord-bot/template-server/.gitignore | 4 + apps/discord-bot/template-server/README.md | 118 +++ .../template-server/package-lock.json | 744 ++++++++++++++++++ apps/discord-bot/template-server/package.json | 16 + .../discord-bot/template-server/src/config.ts | 18 + .../template-server/src/discord.ts | 160 ++++ .../template-server/src/register.ts | 63 ++ .../discord-bot/template-server/src/server.ts | 139 ++++ .../template-server/src/storage.ts | 9 + 9 files changed, 1271 insertions(+) create mode 100644 apps/discord-bot/template-server/.gitignore create mode 100644 apps/discord-bot/template-server/README.md create mode 100644 apps/discord-bot/template-server/package-lock.json create mode 100644 apps/discord-bot/template-server/package.json create mode 100644 apps/discord-bot/template-server/src/config.ts create mode 100644 apps/discord-bot/template-server/src/discord.ts create mode 100644 apps/discord-bot/template-server/src/register.ts create mode 100644 apps/discord-bot/template-server/src/server.ts create mode 100644 apps/discord-bot/template-server/src/storage.ts diff --git a/apps/discord-bot/template-server/.gitignore b/apps/discord-bot/template-server/.gitignore new file mode 100644 index 00000000..49b3ff50 --- /dev/null +++ b/apps/discord-bot/template-server/.gitignore @@ -0,0 +1,4 @@ +node_modules +config.json +.DS_Store +.env diff --git a/apps/discord-bot/template-server/README.md b/apps/discord-bot/template-server/README.md new file mode 100644 index 00000000..211bcfe4 --- /dev/null +++ b/apps/discord-bot/template-server/README.md @@ -0,0 +1,118 @@ +# Linked Role example app + +This repository contains the documentation and example for a linked role bot. + +> ❇️ A version of this code is also hosted [on Glitch 🎏](https://glitch.com/edit/#!/linked-role-discord-bot) + +## Project structure + +All of the files for the project are on the left-hand side. Here's a quick glimpse at the structure: + +``` +├── assets -> Images used in this tutorial +├── src +│ ├── config.js -> Parsing of local configuration +│ ├── discord.js -> Discord specific auth & API wrapper +│ ├── register.js -> Tool to register the metadata schema +│ ├── server.js -> Main entry point for the application +│ ├── storage.js -> Provider for storing OAuth2 tokens +├── .env -> your credentials and IDs +├── .gitignore +├── package.json +└── README.md +``` + +## Running app locally + +Before you start, you'll need to [create a Discord app](https://discord.com/developers/applications) with the `bot` scope + +Configuring the app is covered in detail in the [tutorial](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles). + +### Setup project + +First clone the project: + +``` +git clone https://github.com/discord/linked-roles-sample.git +``` + +Then navigate to its directory and install dependencies: + +``` +cd linked-roles-sample +npm install +``` + +### Get app credentials + +Fetch the credentials from your app's settings and add them to a `.env` file. You'll need your bot token (`DISCORD_TOKEN`), client ID (`DISCORD_CLIENT_ID`), client secret (`DISCORD_CLIENT_SECRET`). You'll also need a redirect URI (`DISCORD_REDIRECT_URI`) and a randomly generated UUID (`COOKIE_SECRET`), which are both explained below: + +``` +DISCORD_CLIENT_ID: +DISCORD_CLIENT_SECRET: +DISCORD_TOKEN: +DISCORD_REDIRECT_URI: https:///discord-oauth-callback +COOKIE_SECRET: +``` + +For the UUID (`COOKIE_SECRET`), you can run the following commands: + +``` +$ node +crypto.randomUUID() +``` + +Copy and paste the value into your `.env` file. + +Fetching credentials is covered in detail in the [linked roles tutorial](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles). + +### Running your app + +After your credentials are added, you can run your app: + +``` +$ node server.js +``` + +And, just once, you need to register you connection metadata schema. In a new window, run: + +``` +$ node src/register.js +``` + +### Set up interactivity + +The project needs a public endpoint where Discord can send requests. To develop and test locally, you can use something like [`ngrok`](https://ngrok.com/) to tunnel HTTP traffic. + +Install ngrok if you haven't already, then start listening on port `3000`: + +``` +$ ngrok http 3000 +``` + +You should see your connection open: + +``` +Tunnel Status online +Version 2.0/2.0 +Web Interface http://127.0.0.1:4040 +Forwarding http://1234-someurl.ngrok.io -> localhost:3000 +Forwarding https://1234-someurl.ngrok.io -> localhost:3000 + +Connections ttl opn rt1 rt5 p50 p90 + 0 0 0.00 0.00 0.00 0.00 +``` + +Copy the forwarding address that starts with `https`, in this case `https://1234-someurl.ngrok.io`, then go to your [app's settings](https://discord.com/developers/applications). + +On the **General Information** tab, there will be an **Linked Roles Verification URL**. Paste your ngrok address there, and append `/linked-role` (`https://1234-someurl.ngrok.io/linked-role` in the example). + +You should also paste your ngrok address into the `DISCORD_REDIRECT_URI` variable in your `.env` file, with `/discord-oauth-callback` appended (`https://1234-someurl.ngrok.io/discord-oauth-callback` in the example). Then go to the **General** tab under **OAuth2** in your [app's settings](https://discord.com/developers/applications), and add that same address to the list of **Redirects**. + +Click **Save Changes** and restart your app. + +## Other resources + +- Read **[the tutorial](https://discord.com/developers/docs/tutorials/configuring-app-metadata-for-linked-roles)** for in-depth information. +- Browse https://github.com/JustinBeckwith/fitbit-discord-bot/ for a more in-depth example using the Fitbit API +- Join the **[Discord Developers server](https://discord.gg/discord-developers)** to ask questions about the API, attend events hosted by the Discord API team, and interact with other devs. diff --git a/apps/discord-bot/template-server/package-lock.json b/apps/discord-bot/template-server/package-lock.json new file mode 100644 index 00000000..5376255a --- /dev/null +++ b/apps/discord-bot/template-server/package-lock.json @@ -0,0 +1,744 @@ +{ + "name": "linked-role-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linked-role-bot", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.5", + "express": "^4.21.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/apps/discord-bot/template-server/package.json b/apps/discord-bot/template-server/package.json new file mode 100644 index 00000000..0d339a50 --- /dev/null +++ b/apps/discord-bot/template-server/package.json @@ -0,0 +1,16 @@ +{ + "name": "template-server", + "type": "module", + "private": "true", + "keywords": [], + "main": "src/server.ts", + "scripts": { + "start": "node src/server.ts", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.5", + "express": "^4.21.1" + } +} diff --git a/apps/discord-bot/template-server/src/config.ts b/apps/discord-bot/template-server/src/config.ts new file mode 100644 index 00000000..d1980392 --- /dev/null +++ b/apps/discord-bot/template-server/src/config.ts @@ -0,0 +1,18 @@ +import process from 'node:process' +import * as dotenv from 'dotenv' + +/** + * Load environment variables from a .env file, if it exists. + */ + +dotenv.config() + +const config = { + DISCORD_TOKEN: process.env.DISCORD_TOKEN, + DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, + DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, + DISCORD_REDIRECT_URI: process.env.DISCORD_REDIRECT_URI, + COOKIE_SECRET: process.env.COOKIE_SECRET, +} + +export default config diff --git a/apps/discord-bot/template-server/src/discord.ts b/apps/discord-bot/template-server/src/discord.ts new file mode 100644 index 00000000..539747b3 --- /dev/null +++ b/apps/discord-bot/template-server/src/discord.ts @@ -0,0 +1,160 @@ +import crypto from 'node:crypto' + +import config from './config' +import * as storage from './storage' + +/** + * Code specific to communicating with the Discord API. + */ + +/** + * The following methods all facilitate OAuth2 communication with Discord. + * See https://discord.com/developers/docs/topics/oauth2 for more details. + */ + +/** + * Generate the url which the user will be directed to in order to approve the + * bot, and see the list of requested scopes. + */ +export function getOAuthUrl() { + const state = crypto.randomUUID() + + const url = new URL('https://discord.com/api/oauth2/authorize') + url.searchParams.set('client_id', config.DISCORD_CLIENT_ID) + url.searchParams.set('redirect_uri', config.DISCORD_REDIRECT_URI) + url.searchParams.set('response_type', 'code') + url.searchParams.set('state', state) + url.searchParams.set('scope', 'role_connections.write identify') + url.searchParams.set('prompt', 'consent') + return { state, url: url.toString() } +} + +/** + * Given an OAuth2 code from the scope approval page, make a request to Discord's + * OAuth2 service to retrieve an access token, refresh token, and expiration. + */ +export async function getOAuthTokens(code: string) { + const url = 'https://discord.com/api/v10/oauth2/token' + const body = new URLSearchParams({ + client_id: config.DISCORD_CLIENT_ID, + client_secret: config.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: config.DISCORD_REDIRECT_URI, + }) + + const response = await fetch(url, { + body, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + if (response.ok) { + const data = await response.json() + return data + } + else { + throw new Error(`Error fetching OAuth tokens: [${response.status}] ${response.statusText}`) + } +} + +/** + * The initial token request comes with both an access token and a refresh + * token. Check if the access token has expired, and if it has, use the + * refresh token to acquire a new, fresh access token. + */ +export async function getAccessToken(userId: any, tokens: any) { + if (Date.now() > tokens.expires_at) { + const url = 'https://discord.com/api/v10/oauth2/token' + const body = new URLSearchParams({ + client_id: config.DISCORD_CLIENT_ID, + client_secret: config.DISCORD_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: tokens.refresh_token, + }) + const response = await fetch(url, { + body, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + if (response.ok) { + const tokens = await response.json() + tokens.expires_at = Date.now() + tokens.expires_in * 1000 + await storage.storeDiscordTokens(userId, tokens) + return tokens.access_token + } + else { + throw new Error(`Error refreshing access token: [${response.status}] ${response.statusText}`) + } + } + return tokens.access_token +} + +/** + * Given a user based access token, fetch profile information for the current user. + */ +export async function getUserData(tokens: any) { + const url = 'https://discord.com/api/v10/oauth2/@me' + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }) + if (response.ok) { + const data = await response.json() + return data + } + else { + throw new Error(`Error fetching user data: [${response.status}] ${response.statusText}`) + } +} + +/** + * Given metadata that matches the schema, push that data to Discord on behalf + * of the current user. + */ +export async function pushMetadata(userId: string, tokens: any, metadata: any) { + // PUT /users/@me/applications/:id/role-connection + const url = `https://discord.com/api/v10/users/@me/applications/${config.DISCORD_CLIENT_ID}/role-connection` + const accessToken = await getAccessToken(userId, tokens) + const body = { + platform_name: 'Example Linked Role Discord Bot', + metadata, + } + const response = await fetch(url, { + method: 'PUT', + body: JSON.stringify(body), + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + if (!response.ok) { + throw new Error(`Error pushing discord metadata: [${response.status}] ${response.statusText}`) + } +} + +/** + * Fetch the metadata currently pushed to Discord for the currently logged + * in user, for this specific bot. + */ +export async function getMetadata(userId: string, tokens: any) { + // GET /users/@me/applications/:id/role-connection + const url = `https://discord.com/api/v10/users/@me/applications/${config.DISCORD_CLIENT_ID}/role-connection` + const accessToken = await getAccessToken(userId, tokens) + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + if (response.ok) { + const data = await response.json() + return data + } + else { + throw new Error(`Error getting discord metadata: [${response.status}] ${response.statusText}`) + } +} diff --git a/apps/discord-bot/template-server/src/register.ts b/apps/discord-bot/template-server/src/register.ts new file mode 100644 index 00000000..997976a0 --- /dev/null +++ b/apps/discord-bot/template-server/src/register.ts @@ -0,0 +1,63 @@ +import config from './config' + +/** + * Register the metadata to be stored by Discord. This should be a one time action. + * Note: uses a Bot token for authentication, not a user token. + */ +async function registerMetadata() { + const url = `https://discord.com/api/v10/applications/${config.DISCORD_CLIENT_ID}/role-connections/metadata` + + // supported types: number_lt=1, number_gt=2, number_eq=3 number_neq=4, datetime_lt=5, datetime_gt=6, boolean_eq=7, boolean_neq=8 + const body = [ + { + key: 'cookieseaten', + name: 'Cookies Eaten', + description: 'Cookies Eaten Greater Than', + type: 2, + }, + { + key: 'allergictonuts', + name: 'Allergic To Nuts', + description: 'Is Allergic To Nuts', + type: 7, + }, + { + key: 'bakingsince', + name: 'Baking Since', + description: 'Days since baking their first cookie', + type: 6, + }, + ] + + try { + const response = await fetch(url, { + method: 'PUT', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bot ${config.DISCORD_TOKEN}`, + }, + }) + + if (response.ok) { + const data = await response.json() + return data + } + else { + const errorText = await response.text() + throw new Error(`Error pushing discord metadata schema: [${response.status}] ${errorText}`) + } + } + catch (error) { + throw new Error(`Failed to register metadata: ${error.message}`) + } +} + +// Execute the registration +registerMetadata() +// .then((data) => { +// // Handle successful registration +// }) +// .catch((error) => { +// // Handle any errors +// }) diff --git a/apps/discord-bot/template-server/src/server.ts b/apps/discord-bot/template-server/src/server.ts new file mode 100644 index 00000000..e63082f5 --- /dev/null +++ b/apps/discord-bot/template-server/src/server.ts @@ -0,0 +1,139 @@ +import process from 'node:process' +import cookieParser from 'cookie-parser' +import express from 'express' + +import config from './config' +import * as discord from './discord' +import * as storage from './storage' + +/** + * Main HTTP server used for the bot. + */ + +const app = express() +const port = process.env.PORT || 3000 +app.use(cookieParser(config.COOKIE_SECRET)) + +/** + * Just a happy little route to show our server is up. + */ +app.get('/', (req, res) => { + res.send(`Authenticate: http://localhost:${port}/linked-role`) +}) + +/** + * Route configured in the Discord developer console which facilitates the + * connection between Discord and any additional services you may use. + * To start the flow, generate the OAuth2 consent dialog url for Discord, + * and redirect the user there. + */ +app.get('/linked-role', async (req, res) => { + const { url, state } = discord.getOAuthUrl() + + // Store the signed state param in the user's cookies so we can verify + // the value later. See: + // https://discord.com/developers/docs/topics/oauth2#state-and-security + res.cookie('clientState', state, { maxAge: 1000 * 60 * 5, signed: true }) + + // Send the user to the Discord owned OAuth2 authorization endpoint + res.redirect(url) +}) + +/** + * Route configured in the Discord developer console, the redirect Url to which + * the user is sent after approving the bot for their Discord account. This + * completes a few steps: + * 1. Uses the code to acquire Discord OAuth2 tokens + * 2. Uses the Discord Access Token to fetch the user profile + * 3. Stores the OAuth2 Discord Tokens in Redis / Firestore + * 4. Lets the user know it's all good and to go back to Discord + */ +app.get('/discord-oauth-callback', async (req, res) => { + try { + // 1. Uses the code and state to acquire Discord OAuth2 tokens + const code = req.query.code + const discordState = req.query.state + + // make sure the state parameter exists + const { clientState } = req.signedCookies + if (clientState !== discordState) { + console.error('State verification failed.') + return res.sendStatus(403) + } + + const tokens = await discord.getOAuthTokens(code) + + // 2. Uses the Discord Access Token to fetch the user profile + const meData = await discord.getUserData(tokens) + const userId = meData.user.id + await storage.storeDiscordTokens(userId, { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: Date.now() + tokens.expires_in * 1000, + }) + + // 3. Update the users metadata, assuming future updates will be posted to the `/update-metadata` endpoint + await updateMetadata(userId) + + res.send('You did it! Now go back to Discord.') + } + catch (e) { + console.error(e) + res.sendStatus(500) + } +}) + +/** + * Example route that would be invoked when an external data source changes. + * This example calls a common `updateMetadata` method that pushes static + * data to Discord. + */ +app.post('/update-metadata', async (req, res) => { + try { + const userId = req.body.userId + await updateMetadata(userId) + + res.sendStatus(204) + } + catch { + res.sendStatus(500) + } +}) + +/** + * Given a Discord UserId, push static make-believe data to the Discord + * metadata endpoint. + */ +async function updateMetadata(userId: string) { + // Fetch the Discord tokens from storage + const tokens = await storage.getDiscordTokens(userId) + + let metadata = {} + try { + // Fetch the new metadata you want to use from an external source. + // This data could be POST-ed to this endpoint, but every service + // is going to be different. To keep the example simple, we'll + // just generate some random data. + metadata = { + cookieseaten: 1483, + allergictonuts: 0, // 0 for false, 1 for true + firstcookiebaked: '2003-12-20', + } + } + catch (e: any) { + e.message = `Error fetching external data: ${e.message}` + console.error(e) + // If fetching the profile data for the external service fails for any reason, + // ensure metadata on the Discord side is nulled out. This prevents cases + // where the user revokes an external app permissions, and is left with + // stale linked role data. + } + + // Push the data to Discord. + await discord.pushMetadata(userId, tokens, metadata) +} + +app.listen(port, () => { + // console.log(`App listening on port ${port}`) + // console.log(`Authenticate on /linked-role`) +}) diff --git a/apps/discord-bot/template-server/src/storage.ts b/apps/discord-bot/template-server/src/storage.ts new file mode 100644 index 00000000..6cbac80c --- /dev/null +++ b/apps/discord-bot/template-server/src/storage.ts @@ -0,0 +1,9 @@ +const store = new Map() + +export async function storeDiscordTokens(userId: string, tokens: any) { + await store.set(`discord-${userId}`, tokens) +} + +export async function getDiscordTokens(userId: string) { + return store.get(`discord-${userId}`) +}