diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0a461bb --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +CLIENT_TOKEN= +CLIENT_ID= +GUILD_ID= \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e51a184 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Code of Conduct + +## Purpose + +The community is dedicated to creating a welcoming, inclusive, and respectful environment for everyone involved. This Code of Conduct outlines our shared values and the standards we expect from all community members. + +## Our Values + +1. **Inclusivity:** We welcome individuals from all backgrounds and experiences. Diversity enriches our community, and we strive to make everyone feel valued and included. + +2. **Respect:** Treat others with kindness, empathy, and respect. Disagreements may arise, but it's essential to maintain a courteous and constructive atmosphere. + +3. **Collaboration:** Foster a spirit of collaboration and teamwork. Together, we can achieve more and create a positive impact on the community. + +4. **Open-Mindedness:** Embrace diverse perspectives and ideas. Be open to learning from others and adapting to new approaches. + +5. **Constructive Feedback:** Provide feedback in a constructive manner. Help each other improve and grow without undermining or belittling. + +## Unacceptable Behavior + +The following behaviors are considered unacceptable within the Discord Bot Handler community: + +1. **Harassment:** Any form of harassment, discrimination, or unwelcome behavior is strictly prohibited. This includes but is not limited to offensive language, intimidation, or any other harmful actions. + +2. **Disrespectful Conduct:** Treating others disrespectfully, whether through language, actions, or tone, is not tolerated. + +3. **Trolling or Flaming:** Deliberately provocative, offensive, or inflammatory comments or actions are not allowed. + +4. **Impersonation:** Impersonating others or creating misleading profiles is not allowed. + +5. **Spam and Advertising:** Excessive self-promotion, spam, or unrelated advertising is not permitted. + +## Reporting Violations + +If you witness or experience any behavior that violates this Code of Conduct, please report it to the project maintainers promptly. You can contact us via [Discord](https://discord.com/users/lukasbaum). + +## Enforcement + +Project maintainers have the right and responsibility to address any reported violations. Actions may include warnings, temporary or permanent bans from the community, or other appropriate responses. + +## Acknowledgment + +By participating in the Discord Bot Handler community, you agree to abide by this Code of Conduct. We appreciate your commitment to fostering a positive and inclusive environment for all. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..8965c8d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to Discord Bot Handler + +We're excited that you're interested in contributing to our Discord Bot Handler project! This document provides guidelines for contributing and aims to make the contribution process as smooth and pleasant as possible. + +## Getting Started + +Before you begin, make sure you have: + +- A GitHub account +- Basic knowledge of TypeScript and Discord bot development +- Familiarity with the project's codebase (read through the documentation) + +## How to Contribute + +You can contribute in several ways: + +1. **Reporting Bugs:** + - Use the GitHub Issues section to report bugs. + - Clearly describe the issue, including steps to reproduce and the expected outcome. + - Include screenshots or code snippets if applicable. + +2. **Suggesting Enhancements:** + - Submit enhancement suggestions through GitHub Issues. + - Provide a clear description of the enhancement and its benefits. + +3. **Pull Requests:** + - Fork the repository and create your branch from `main`. + - Write clear, concise code and ensure it adheres to the project's coding standards. + - Write a clear, detailed pull request description explaining your changes. + - Ensure your code does not introduce new issues. + +## Coding Standards + +- Follow the existing code style. +- Use meaningful variable and function names. +- Comment your code where necessary. +- Keep your changes concise and focused on solving a single problem. + +## Pull Request Process + +1. Update the README.md with details of changes if necessary. +2. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request represents. +3. Your pull request will be reviewed by the maintainers, who may request changes or provide feedback. Be responsive to feedback to ensure a swift review process. + +## Community Guidelines + +Be respectful and inclusive. Treat everyone with respect and kindness, and foster an environment of collaboration and learning. + +## Questions? + +If you have any questions or need assistance, feel free to reach out to the maintainers via [Discord](https://discord.com/users/lukasbaum). + +Thank you for your interest in contributing to our Discord Bot Handler, and we look forward to your contributions! diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md new file mode 100644 index 0000000..285f476 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -0,0 +1,41 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG]' +labels: 'bug' +assignees: '' + +--- + +# Bug Report + +## Description +Please provide a clear and concise description of what the bug is. + +## To Reproduce +Describe the steps to reproduce the behavior: +1. Provide context on what you were trying to achieve. +2. Specify the command or interaction used. +3. Outline any parameters or options you included. +4. Describe the unexpected behavior or error encountered. + +## Expected Behavior +A clear and concise description of what you expected to happen. + +## Screenshots +If applicable, add screenshots to help explain your problem. + +## Environment (please complete the following information): +- OS: [e.g., Windows, macOS, Linux] +- Version of Discord Bot Handler: +- Version of Node.js: + +## Additional Context +Add any other context about the problem here. This could include the section of the code you suspect is causing the issue, any log files, or anything else that might be relevant. + +## Possible Solution (optional) +If you have a suggestion for how to fix the issue, please describe it here. + +--- + +Thank you for taking the time to report a bug. Your feedback is valuable to us and helps improve the quality of the Discord Bot Handler. diff --git a/.github/ISSUE_TEMPLATE/CODE_IMPROVEMENT.md b/.github/ISSUE_TEMPLATE/CODE_IMPROVEMENT.md new file mode 100644 index 0000000..e3efb64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/CODE_IMPROVEMENT.md @@ -0,0 +1,27 @@ +--- +name: Code Improvement/Suggestion +about: Propose improvements or suggest changes to the Discord Bot Handler's codebase +title: '[IMPROVEMENT]: ' +labels: 'code improvement', 'suggestion' +assignees: '' + +--- + +**Describe Your Suggestion or Improvement** +A clear and concise description of the improvement or change you are proposing. Explain the problem or limitation you have identified, and how your suggestion addresses it. + +**Code Segment (if applicable)** +If your suggestion involves specific code changes, provide a code snippet or reference to the relevant part of the codebase. This can help in understanding your proposed changes better. + +**Benefits** +Explain how your suggested improvement benefits the Discord Bot Handler in terms of performance, maintainability, readability, or any other relevant aspect. + +**Potential Drawbacks** +Are there any potential downsides or drawbacks to your suggestion that should be considered? If so, please mention them. + +**Additional Context** +Provide any additional context or information that can help in understanding the context of your suggestion. + +**Would you like to work on this improvement?** +- [ ] Yes +- [ ] No diff --git a/.github/ISSUE_TEMPLATE/DOCUMENTATION_IMPROVEMENT.md b/.github/ISSUE_TEMPLATE/DOCUMENTATION_IMPROVEMENT.md new file mode 100644 index 0000000..47bcde7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/DOCUMENTATION_IMPROVEMENT.md @@ -0,0 +1,21 @@ +--- +name: Documentation Improvement +about: Suggest an improvement or report an issue in the documentation +title: '[DOC]: ' +labels: 'documentation' +assignees: '' + +--- + +**Describe the Improvement or Issue** +A clear and concise description of what the improvement is or what the issue with the current documentation is. If it's an issue, please explain how it can be misleading, incomplete, or incorrect. + +**Suggested Change or Addition** +If you have a specific suggestion or content to add, please outline it here. Be as detailed as possible. + +**Additional Context** +Add any other context, links, or screenshots about the documentation improvement here. If this is regarding a specific section or page in the documentation, please provide a direct link. + +**Would you like to work on it?** +- [ ] Yes +- [ ] No diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md new file mode 100644 index 0000000..361448d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -0,0 +1,23 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +## Feature Description +A clear and concise description of what the feature is. + +## Problem and Motivation +Describe the problem you're trying to solve with this feature. Explain why this feature is important for the project. + +## Proposed Solution +If you have an idea of how to implement this feature, please share it here. Details on how you envision it working are helpful. + +## Alternatives Considered +Have you considered any alternatives that could solve the same problem? If so, please describe them. + +## Additional Context +Add any other context or screenshots about the feature request here. If this feature is inspired by something else, please provide references. diff --git a/.github/ISSUE_TEMPLATE/QUESTION_SUPPORT.md b/.github/ISSUE_TEMPLATE/QUESTION_SUPPORT.md new file mode 100644 index 0000000..a68560f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/QUESTION_SUPPORT.md @@ -0,0 +1,33 @@ +--- +name: Question/Support +about: Ask a question or seek support regarding the Discord Bot Handler +title: '[SUPPORT]: ' +labels: 'question', 'support' +assignees: '' + +--- + +**Describe Your Question or Issue** +A clear and concise description of what the question or issue is. Please provide as much context as possible to help us understand your situation better. + +**What Have You Tried?** +Outline any steps you have taken to resolve or understand the issue on your own. This helps us to provide more targeted assistance. + +**Expected Behavior** +If applicable, describe what you expected to happen. + +**Screenshots/Code Snippets** +If you think it would be helpful, add screenshots of the issue or code snippets that demonstrate the problem or part of your question. + +**Environment (please complete the following information):** +- OS: [e.g., Windows, MacOS, Linux] +- Discord Bot Handler Version: +- Node.js Version: +- Discord.js Version: + +**Additional Context** +Add any other context or details about the question or issue here. + +**Would you like to be contacted for further support?** +- [ ] Yes +- [ ] No diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db93cac --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea/ +.vscode/ +dist/ +node_modules/ +.env +package-lock.json +yarn.lock \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c0d8cbf --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "discordjs-command-handler", + "version": "1.0.0", + "description": "A comprehensive TypeScript command handler for Discord.js, featuring slash commands, prefix commands, message events, automatic intents, and more.", + "main": "src/index.ts", + "scripts": { + "clean": "rm -rf dist", + "compile": "npm run clean && tsc", + "start": "node dist/index.js", + "watch": "tsc -w", + "cs": "npm run compile && npm run start" + }, + "keywords": [], + "author": "Lukas Baum", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.11.6", + "typescript": "^5.3.3" + }, + "dependencies": { + "discord.js": "^14.14.1", + "dotenv": "^16.4.0", + "glob": "^10.3.10" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..99de52d --- /dev/null +++ b/readme.md @@ -0,0 +1,425 @@ +

Discord Bot Handler

+

🚀 Elevate Your Discord Programming Experience with Advanced Features!

+

+ Transform your Discord bot development with our advanced handler. It simplifies command management, event handling, and offers robust solutions for seamless bot operations. +

+

+ node - v20.7+ + npm - v10.1+ + discord.js - v14.14.1 + Maintenance - Active +

+ +## 🤔 Why? +- **Focused Development:** Craft your bot with intuitive tools that make development both enjoyable and powerful. + +- **Rich Feature Set:** Equipped with a wide range of capabilities, from simple command structures to advanced functionalities. + +- **TypeScript Powered:** Leverage TypeScript's robustness for error-free coding and enhanced performance. + +- **Scalability Made Simple:** Designed for ease of use by beginners and adaptability for complex projects as you grow. + +- **Cutting-Edge Updates:** Stay ahead with regular updates that incorporate the latest features of Discord.js. + +- **Community-Driven Potential:** Join a growing platform where contributions from developers are welcomed, paving the way for shared learning and continuous enhancement. + + +## 🌟 Features +This Discord Bot comes packed with a variety of features designed to enhance your server's functionality and user experience: + +- **Command Handling** + - Slash Commands + - Prefix Commands + - Message Commands + - Ping Commands + - Autocomplete Commands + - Context Menus + +- **Component Handling** + - Buttons + - Select Menus + - Modals + +- **Event Handling** + - Efficient and easy + +- **Advanced Options** + - Command Cooldowns + - Many Command Options + - Customizable Config + +- **Automatic Intent Handling** + - No need to specify Intents yourself + +- **Utility Features** + - Colored Message Builder + - Discord Timestamp Formatter + +- **And More...** + - Continuously updated with new features and enhancements + +## 🚀 Getting Started +Follow these steps to install and set up the Discord Bot Handler. + +### 1. 📋 Prerequisites +Before you begin, ensure you have the following: +- Node.js (v20.7 or higher) +- npm (v10.1 or higher) +- A Discord Bot Application (see [Discord's developer portal](https://discord.com/developers/applications) to create a bot) + +### 2. 📥 Cloning the Repository +Clone the repository using Git: +```bash +git clone https://github.com/lukazbaum/discordjs-command-handler +``` +Alternatively, [download](https://www.youtube.com) it as a ZIP file and extract it. + +Navigate to the directory: +```bash +cd discord-bot-handler +``` + +### 3. 🔧 Installing Dependencies +Install the necessary Node.js packages: +```bash +npm install +# or +yarn install +``` + +### 4. ⚙️ Configuration +Rename `.env.example` to `.env` in your project root and fill in your details: + +The `CLIENT_ID` can be found in your Bot Application under OAuth2. +```text +CLIENT_TOKEN=your_discord_bot_token +CLIENT_ID=your_discord_client_id +GUILD_ID=your_discord_guild_id +``` + +### 5. 🤖 Running the Bot +Use the following commands to compile and start your bot: + +- Compile the Typescript project: + ```bash + npm run compile + # or + yarn run compile + ``` +- Start the compiled code: + ```bash + npm run start + # or + yarn run start + ``` +- Alternatively, run both compile and start with a single command: + ```bash + npm run cs + # or + yarn run cs + ``` + +## 📚 Documentation +Explore the documentation for in-depth insights on using and optimizing the Discord Bot Handler in your projects. + +### 🛠️ Command Arguments +Command arguments are optional settings that can be applied to each command to control its behavior. These settings allow for flexible command management, including permissions and usage restrictions. Here are the base command arguments you can use: + +- `cooldown?`: Number of seconds to wait before the command can be used again. Defaults to no cooldown if not specified. +- `ownerOnly?`: If true, only the bot owner (as defined in the config) can use the command. +- `userWhitelist?`: Array of user IDs allowed to use the command. An empty array means no restrictions. +- `userBlacklist?`: Array of user IDs prohibited from using the command. +- `channelWhitelist?`: Array of channel IDs where the command can be used. +- `channelBlacklist?`: Array of channel IDs where the command cannot be used. +- `guildWhitelist?`: Array of guild IDs where the command can be used. +- `guildBlacklist?`: Array of guild IDs where the command cannot be used. +- `roleWhitelist?`: Array of role IDs allowed to use the command. +- `roleBlacklist?`: Array of role IDs prohibited from using the command. +- `nsfw?`: If true, the command can only be used in age-restricted (NSFW) channels. +- `disabled?`: If true, the command won't be registered and thus, will be unavailable for use. + +#### 🛠️ Example Usage: +```typescript +export = { + cooldown: 10, + ownerOnly: false, + channelWhitelist: ["123456789012345678", "987654321098765432"] + // ... other arguments +} as SlashCommandModule; +``` + +### ⚔️ Slash Commands +Below is an example of a typical Slash Command module: + +```typescript +interface SlashCommandModule { + type: CommandTypes.SlashCommand; + register: RegisterTypes; + data: SlashCommandBuilder; + execute: (...args: any[]) => Promise; + autocomplete?: (interaction: AutocompleteInteraction) => Promise; +} +``` + +- `type: CommandTypes.Slashcommand`: Identifies the command as a slash command. +- `register: RegisterTypes`: Determines where the command should be registered. Use `.Guild` for server-specific commands or `.Global` for commands available across all servers where the bot is present. +- `data: SlashcommandBuilder`: Defines the command's details, including name, description, and options. You can also set permissions required to use the command here. +- `execute: (interaction: CommandInteraction) => Promise`: The function that will be executed when the Slash Command is triggered. +- `autocomplete?: (interaction: AutocompleteInteraction) => Promise`: A function for handling autocomplete interactions. + +#### ⚔️ Example Slash Command: +Here's a practical example of a Slash Command: + +```typescript +export = { + type: CommandTypes.SlashCommand, + register: RegisterTypes.Guild, + data: new SlashCommandBuilder() + .setName("ping") + .setDescription("Replies with pong!") + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), + async execute(interaction: CommandInteraction): Promise { + await interaction.reply({ content: "Pong" }); + } +} as SlashCommandModule; +``` + +### 🔧 Prefix/Ping/Message Commands +Prefix, Ping and Message Commands follow the same structure. Below is an example of a typical Prefix Command module: + +```typescript +interface PrefixCommandModule { + name: string; + aliases?: string[]; + permissions?: string[]; + type: CommandTypes.PrefixCommand; + execute: (message: Message) => Promise; +} +``` + +- `name`: Specifies the unique name of the command. +- `aliases?`: An array of alternative names for the command. Use these to provide users with flexibility in invoking the command. +- `permissions?`: An array of required permissions for using the command. Define the necessary permissions to control access to the command. +- `type: CommandTypes.PrefixCommand`: Identifies the command as a prefix command (`.PingCommand` or `.MessageCommand`). +- `execute: (message: Message) => Promise`: The function that will be executed when the Command is triggered. + +#### 🔧 Example Prefix Command: +Here's a practical example of a Prefix Command: + +```typescript +export = { + name: "pong", + aliases: ["poong"], + type: CommandTypes.PrefixCommand, + async execute(message: Message): Promise { + await message.reply("Ping!"); + } +} as PrefixCommandModule; +``` + +### 📑 Context Menus +Context Menus in Discord bots allow users to interact with your bot through right-click context options in the Discord interface. Just like with other commands, standard command arguments can be applied to Context Menus for added flexibility and control. Here is a typical structure of a Context Menu module: + +```typescript +interface ContextMenuCommandModule { + type: CommandTypes.ContextMenu; + register: RegisterTypes; + data: ContextMenuCommandBuilder; + execute: (interaction: ContextMenuCommandInteraction) => Promise; +} +``` +- `register: RegisterTypes`: Determines where the command should be registered. Use `.Guild` for server-specific commands or `.Global` for commands available across all servers where the bot is present. +- `type: CommandTypes.ContextMenu`: Identifies the command as a context menu. +- `data: ContextMenuCommandBuilder`: Defines the command's details, including name, description, and options. You can also set permissions required to use the command here. +- `execute: (interaction: ContextMenuCommandInteraction) => Promise`: The function that will be executed when the Context Menu is triggered. + +#### 📑 Example Context Menu: +Here's a practical example of a Context Menu: + +```typescript +export = { + type: CommandTypes.ContextMenu, + register: RegisterTypes.Guild, + data: new ContextMenuCommandBuilder() + .setName("Get Message ID") + .setType(ApplicationCommandType.Message), + async execute(interaction: ContextMenuCommandInteraction): Promise { + await interaction.reply({ content: `Message ID: ${interaction.targetId}` }); + } +} as ContextMenuCommandModule; +``` + +### 🗃️ Components +On Discord, Components are interactive elements like buttons, select menus, and modals that enhance user interaction. These can be implemented individually or grouped for complex interactions. Below is a typical structure of a Component module: + +```typescript +interface ComponentModule { + id?: string; + group?: string; + type: ComponentTypes; + execute: (interaction: any) => Promise; +} +``` +- `id?`: Specifies a unique identifier (customId) for the component. +- `group?`: Defines the group to which the component belongs, aiding in group handling. +- `type: ComponentTypes`: Identifies the type of the component (Button, SelectMenu, Modal). +- `execute: (interaction: any) => Promise`: The function that will be executed when a user interacts with the component. + +#### 🗃️ Example using ids: + +**Creating and sending a button:** + +This example creates a button with a specific id for distinct handling. + +```typescript +const row: any = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("deleteMessage") + .setLabel("Delete message") + .setStyle(ButtonStyle.Danger) + ); +await interaction.reply({ content: "Example", components: [row] }); +``` + +**Handling the button interaction:** + +The following code handles the interaction for the previously created button. +```typescript +export = { + id: "deleteMessage", + type: ComponentTypes.Button, + async execute(interaction: ButtonInteraction): Promise { + await interaction.message.delete(); + } +} as ComponentModule; +``` + +#### 🗃️ Example using groups: + +**Creating and sending grouped buttons:** + +For buttons that are part of a group, the group name is prepended to the customId for easy identification and handling. +```typescript +const row: any = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("group=subscription;confirm") + .setLabel("Click to confirm") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId("group=subscription;cancel") + .setLabel("Click to cancel") + .setStyle(ButtonStyle.Secondary) + ); +await interaction.reply({ content: "Example", components: [row] }); +``` + +**Handling grouped button interactions::** + +This example shows how to handle interactions for buttons that are part of a group. +```typescript +export = { + group: "subscription", + type: ComponentTypes.Button, + async execute(interaction: ButtonInteraction): Promise { + if (interaction.customId === "confirm") { + await interaction.reply({ content: "Pressed confirm" }); + } else if (interaction.customId === "cancel") { + await interaction.reply({ content: "Pressed cancel" }); + } + } +} as ComponentModule; +``` +This approach allows for more organized and scalable interaction handling, especially when dealing with multiple components grouped under a single category or function. + +The examples provided for handling button interactions are also applicable to select menus and modals, which can be found in the code. + +### 🎉 Events +Below is an example of a typical Event module: + +```typescript +interface EventModule { + name: Events; + once?: boolean; + execute: (...args: any[]) => Promise; +} +``` +- `name: Events`: Specifies the type of event to handle, as defined in the `Events` enum. +- `once?`: If set to true, the event will be executed only once. +- `execute: (...args: any[]) => Promise`: The function that will be executed when the specified event occurs. + +#### 🎉 Example Event: +Here's an example handling the `ClientReady` event: + +```typescript +export = { + name: Events.ClientReady, + once: true, + async execute(client: DiscordClient): Promise { + if (!client.user) return; + + client.user.setStatus(UserStatus.ONLINE); + client.user.setActivity("Development", { type: ActivityType.Watching }); + Logger.log(`Ready! Logged in as ${client.user.tag}`); + } +} as EventModule; +``` + +### 🌈 Colored Message Builder +The `ColoredMessageBuilder` class is designed to enhance the visual appeal of your Discord bot's messages. It allows for various text formatting options, including text color, background color, and styling. This feature can be particularly useful for highlighting important information or making responses more engaging. + +#### 🌈 Example usage: + +```typescript +const msg: string = new ColoredMessageBuilder() + .add("Hello, ", Color.Red) + .add("World!", Color.Blue, BackgroundColor.DarkBlue, Format.Underline) + .addNewLine() + .addRainbow("This is cool!", Format.Bold) + .build(); +``` + +Alternatively, for simpler text formatting needs, you can use the `colored` or `rainbow` functions: + +```typescript +const simpleColoredMsg = colored("Simple Colored Message", Color.GREEN); +const simpleRainbowMsg = rainbow("Simple Rainbow Message"); +``` + +### 🔨 Utility +The `formatTimestamp` function simplifies timestamp formatting for Discord, ensuring +a user-friendly display of time in your bot's messages. All possible format styles are +supported and can be found in the source code. + +#### 🔨 Example usage: +```typescript +const discordTimestamp = formatTimestamp(unixTimestamp, TimestampStyle.ShortDate); +``` +**Output:** `01/23/2024` + +## 📝 License +This project is licensed under the [MIT] License, chosen for its permissive nature, allowing developers to freely use, modify, and distribute the code. +See the [LICENSE.md](LICENSE.md) file for details. + +## 👥 Contributing +Contributions & Issues are welcome! Please follow our [Contribution Guidelines](.github/CONTRIBUTING.md). + +## 📜 Code of Conduct +For a detailed understanding of our community's values and standards, please refer to our [Code of Conduct](.github/CODE_OF_CONDUCT.md). +We are committed to building a welcoming, inclusive, and respectful community. + + +## ✨ Showcase Your Project +Are you using our handler in your open-source bot? We'd love to feature it! +Let's highlight the fantastic work you've achieved with our Discord Bot Handler. + +To share your project details, connect with us on [Discord](https://discord.com/users/lukasbaum). We're excited to showcase your +creation to the community. + +## ❤️ Show Your Support +If you find the Discord Bot Handler useful, please consider giving it a star on GitHub. This not only +helps us understand which projects the community values, but also increases the visibility of our +work. Your support means a lot! + +🌟 Star us on GitHub — it helps! diff --git a/src/commands/chat/message.ts b/src/commands/chat/message.ts new file mode 100644 index 0000000..f4b5eff --- /dev/null +++ b/src/commands/chat/message.ts @@ -0,0 +1,10 @@ +import { Message } from "discord.js"; +import { CommandTypes, MessageCommandModule } from "../../handler/types/Command"; + +export = { + name: "Hello", + type: CommandTypes.MessageCommand, + async execute(message: Message): Promise { + await message.reply(`Hello <@${message.author.id}>`); + } +} as MessageCommandModule; \ No newline at end of file diff --git a/src/commands/chat/prefix.ts b/src/commands/chat/prefix.ts new file mode 100644 index 0000000..05241ed --- /dev/null +++ b/src/commands/chat/prefix.ts @@ -0,0 +1,11 @@ +import { Message } from "discord.js"; +import { CommandTypes, PrefixCommandModule } from "../../handler/types/Command"; + +export = { + name: "pong", + aliases: ["poong"], + type: CommandTypes.PrefixCommand, + async execute(message: Message): Promise { + await message.reply("Ping!"); + } +} as PrefixCommandModule; \ No newline at end of file diff --git a/src/commands/chat/tag.ts b/src/commands/chat/tag.ts new file mode 100644 index 0000000..2c9c0e8 --- /dev/null +++ b/src/commands/chat/tag.ts @@ -0,0 +1,10 @@ +import { Message } from "discord.js"; +import { CommandTypes, PingCommandModule } from "../../handler/types/Command"; + +export = { + name: "help", + type: CommandTypes.PingCommand, + async execute(message: Message): Promise { + await message.reply("How can I help you?"); + } +} as PingCommandModule; \ No newline at end of file diff --git a/src/commands/context/getMessageId.ts b/src/commands/context/getMessageId.ts new file mode 100644 index 0000000..73b464a --- /dev/null +++ b/src/commands/context/getMessageId.ts @@ -0,0 +1,13 @@ +import { CommandTypes, ContextMenuCommandModule, RegisterTypes } from "../../handler/types/Command"; +import { ContextMenuCommandBuilder, ApplicationCommandType, ContextMenuCommandInteraction } from "discord.js"; + +export = { + type: CommandTypes.ContextMenu, + register: RegisterTypes.Guild, + data: new ContextMenuCommandBuilder() + .setName("Get Message ID") + .setType(ApplicationCommandType.Message), + async execute(interaction: ContextMenuCommandInteraction): Promise { + await interaction.reply({ content: `Message ID: ${interaction.targetId}` }); + } +} as ContextMenuCommandModule; \ No newline at end of file diff --git a/src/commands/context/getUsername.ts b/src/commands/context/getUsername.ts new file mode 100644 index 0000000..f77fe24 --- /dev/null +++ b/src/commands/context/getUsername.ts @@ -0,0 +1,14 @@ +import { CommandTypes, ContextMenuCommandModule, RegisterTypes } from "../../handler/types/Command"; +import { ContextMenuCommandBuilder, ApplicationCommandType, ContextMenuCommandInteraction, User } from "discord.js"; + +export = { + type: CommandTypes.ContextMenu, + register: RegisterTypes.Guild, + data: new ContextMenuCommandBuilder() + .setName("Get Username") + .setType(ApplicationCommandType.User), + async execute(interaction: ContextMenuCommandInteraction): Promise { + const user: User = await interaction.client.users.fetch(interaction.targetId); + await interaction.reply({ content: `Username: ${user.username}` }); + } +} as ContextMenuCommandModule; \ No newline at end of file diff --git a/src/commands/other/modal.ts b/src/commands/other/modal.ts new file mode 100644 index 0000000..b0824d9 --- /dev/null +++ b/src/commands/other/modal.ts @@ -0,0 +1,41 @@ +import { CommandTypes, RegisterTypes, SlashCommandModule } from "../../handler/types/Command"; +import { + CommandInteraction, + ModalBuilder, + PermissionFlagsBits, + TextInputStyle, + SlashCommandBuilder, + TextInputBuilder, + ActionRowBuilder +} from "discord.js"; + +export = { + type: CommandTypes.SlashCommand, + register: RegisterTypes.Guild, + data: new SlashCommandBuilder() + .setName("modal") + .setDescription("Replies with a modal!") + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), + async execute(interaction: CommandInteraction): Promise { + const modal: ModalBuilder = new ModalBuilder() + .setCustomId("modalExample") + .setTitle("Modal Example"); + + const favoriteColorInput: TextInputBuilder = new TextInputBuilder() + .setCustomId("favoriteColorInput") + .setLabel("What's your favorite color?") + .setStyle(TextInputStyle.Short) + .setPlaceholder("Purple"); + + const hobbiesInput: TextInputBuilder = new TextInputBuilder() + .setCustomId("hobbiesInput") + .setLabel("What's some of your favorite hobbies?") + .setStyle(TextInputStyle.Paragraph); + + const firstActionRow: any = new ActionRowBuilder().addComponents(favoriteColorInput); + const secondActionRow: any = new ActionRowBuilder().addComponents(hobbiesInput); + + modal.addComponents(firstActionRow, secondActionRow); + await interaction.showModal(modal); + } +} as SlashCommandModule; \ No newline at end of file diff --git a/src/commands/other/selectmenu.ts b/src/commands/other/selectmenu.ts new file mode 100644 index 0000000..ab1eff9 --- /dev/null +++ b/src/commands/other/selectmenu.ts @@ -0,0 +1,46 @@ +import { CommandTypes, RegisterTypes, SlashCommandModule } from "../../handler/types/Command"; +import { + ActionRowBuilder, + CommandInteraction, + PermissionFlagsBits, + SlashCommandBuilder, + StringSelectMenuBuilder +} from "discord.js"; + +export = { + type: CommandTypes.SlashCommand, + register: RegisterTypes.Guild, + data: new SlashCommandBuilder() + .setName("selectmenu") + .setDescription("Replies with a selectmenu!") + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), + async execute(interaction: CommandInteraction): Promise { + const row: any = new ActionRowBuilder() + .addComponents( + new StringSelectMenuBuilder() + .setCustomId("select") + .setPlaceholder("Nothing selected") + .setMinValues(1) + .setMaxValues(2) + .addOptions( + { + label: "Select me", + description: "This is a description", + value: "firstOption", + }, + { + label: "You can select me too", + description: "This is also a description", + value: "secondOption", + emoji: "😱" + }, + { + label: "I am also an option", + description: "This is a description as well", + value: "thirdOption", + }, + ), + ); + await interaction.reply({ content: "Wow!", components: [row] }); + } +} as SlashCommandModule; \ No newline at end of file diff --git a/src/commands/slash/autocomplete.ts b/src/commands/slash/autocomplete.ts new file mode 100644 index 0000000..6f75eb2 --- /dev/null +++ b/src/commands/slash/autocomplete.ts @@ -0,0 +1,35 @@ +import { CommandTypes, RegisterTypes, SlashCommandModule } from "../../handler/types/Command"; +import { AutocompleteInteraction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; + +export = { + type: CommandTypes.SlashCommand, + register: RegisterTypes.Guild, + data: new SlashCommandBuilder() + .setName("autocomplete") + .setDescription("Example of the autocomplete feature!") + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages) + .addStringOption(option => + option.setName("query") + .setDescription("Phrase to search for") + .setAutocomplete(true) + .setRequired(true) + ), + + async autocomplete(interaction: AutocompleteInteraction): Promise { + const focusedValue = interaction.options.getFocused(); + const choices: string[] = [ + "Popular Topics: Threads", "Sharding: Getting started", "Library: Voice Connections", + "Interactions: Replying to slash commands", "Popular Topics: Embed preview" + ]; + + const filtered: string[] = choices.filter(choice => choice.startsWith(focusedValue)); + await interaction.respond( + filtered.map(choice => ({ name: choice, value: choice })) + ); + }, + + async execute(interaction): Promise { + const selectedOption = interaction.options.getString("query"); + await interaction.reply(`You selected ${selectedOption}`); + } +} as SlashCommandModule; \ No newline at end of file diff --git a/src/commands/slash/buttons.ts b/src/commands/slash/buttons.ts new file mode 100644 index 0000000..2a5b8b0 --- /dev/null +++ b/src/commands/slash/buttons.ts @@ -0,0 +1,36 @@ +import { CommandTypes, RegisterTypes, SlashCommandModule } from "../../handler/types/Command"; +import { + ActionRowBuilder, + ButtonBuilder, + CommandInteraction, + PermissionFlagsBits, + SlashCommandBuilder, + ButtonStyle +} from "discord.js"; + +export = { + type: CommandTypes.SlashCommand, + register: RegisterTypes.Guild, + data: new SlashCommandBuilder() + .setName("buttons") + .setDescription("Replies with buttons!") + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), + async execute(interaction: CommandInteraction): Promise { + const row: any = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("group=subscription;confirm") + .setLabel("Click to confirm") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId("group=subscription;cancel") + .setLabel("Click to cancel") + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId("deleteMessage") + .setLabel("Delete message") + .setStyle(ButtonStyle.Danger) + ); + await interaction.reply({ content: "Button examples", components: [row] }); + } +} as SlashCommandModule; \ No newline at end of file diff --git a/src/commands/slash/color.ts b/src/commands/slash/color.ts new file mode 100644 index 0000000..a282266 --- /dev/null +++ b/src/commands/slash/color.ts @@ -0,0 +1,22 @@ +import { BackgroundColor, Color, Format } from "../../handler/types/Formatting"; +import { ColoredMessageBuilder } from "../../handler/util/ColoredMessageBuilder"; +import { CommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { CommandTypes, RegisterTypes, SlashCommandModule } from "../../handler/types/Command"; + +export = { + type: CommandTypes.SlashCommand, + register: RegisterTypes.Guild, + data: new SlashCommandBuilder() + .setName("color") + .setDescription("Replies with a colored message!") + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), + async execute(interaction: CommandInteraction): Promise { + const coloredMessage: string = new ColoredMessageBuilder() + .add("Hello, ", Color.Red) + .add("World!", Color.Blue, BackgroundColor.DarkBlue, Format.Underline) + .addNewLine() + .addRainbow("This is cool!", Format.Bold) + .build(); + await interaction.reply({ content: coloredMessage }); + } +} as SlashCommandModule; \ No newline at end of file diff --git a/src/commands/slash/ping.ts b/src/commands/slash/ping.ts new file mode 100644 index 0000000..f61a8b6 --- /dev/null +++ b/src/commands/slash/ping.ts @@ -0,0 +1,14 @@ +import { CommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import { CommandTypes, RegisterTypes, SlashCommandModule } from "../../handler/types/Command"; + +export = { + type: CommandTypes.SlashCommand, + register: RegisterTypes.Guild, + data: new SlashCommandBuilder() + .setName("ping") + .setDescription("Replies with pong!") + .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), + async execute(interaction: CommandInteraction): Promise { + await interaction.reply({ content: "Pong" }); + } +} as SlashCommandModule; \ No newline at end of file diff --git a/src/components/buttons/deleteMessage.ts b/src/components/buttons/deleteMessage.ts new file mode 100644 index 0000000..4da1823 --- /dev/null +++ b/src/components/buttons/deleteMessage.ts @@ -0,0 +1,10 @@ +import { ButtonInteraction } from "discord.js"; +import { ComponentModule, ComponentTypes } from "../../handler/types/Component"; + +export = { + id: "deleteMessage", + type: ComponentTypes.Button, + async execute(interaction: ButtonInteraction): Promise { + await interaction.message.delete(); + } +} as ComponentModule; \ No newline at end of file diff --git a/src/components/buttons/subscription.ts b/src/components/buttons/subscription.ts new file mode 100644 index 0000000..1dff85b --- /dev/null +++ b/src/components/buttons/subscription.ts @@ -0,0 +1,14 @@ +import { ButtonInteraction } from "discord.js"; +import { ComponentModule, ComponentTypes } from "../../handler/types/Component"; + +export = { + group: "subscription", + type: ComponentTypes.Button, + async execute(interaction: ButtonInteraction): Promise { + if (interaction.customId === "confirm") { + await interaction.reply({ content: "Pressed confirm" }); + } else if (interaction.customId === "cancel") { + await interaction.reply({ content: "Pressed cancel" }); + } + } +} as ComponentModule; \ No newline at end of file diff --git a/src/components/modals/modalExample.ts b/src/components/modals/modalExample.ts new file mode 100644 index 0000000..d64cddd --- /dev/null +++ b/src/components/modals/modalExample.ts @@ -0,0 +1,11 @@ +import { AnySelectMenuInteraction, ModalSubmitInteraction } from "discord.js"; +import { ComponentModule, ComponentTypes } from "../../handler/types/Component"; + +export = { + id: "modalExample", + type: ComponentTypes.Modal, + async execute(interaction: ModalSubmitInteraction): Promise { + const favoriteColor: string = interaction.fields.getTextInputValue("favoriteColorInput"); + await interaction.reply({ content: `Your favorite color: ${favoriteColor}` }); + } +} as ComponentModule; \ No newline at end of file diff --git a/src/components/selectMenus/select.ts b/src/components/selectMenus/select.ts new file mode 100644 index 0000000..a0aa796 --- /dev/null +++ b/src/components/selectMenus/select.ts @@ -0,0 +1,10 @@ +import { AnySelectMenuInteraction } from "discord.js"; +import { ComponentModule, ComponentTypes } from "../../handler/types/Component"; + +export = { + id: "select", + type: ComponentTypes.SelectMenu, + async execute(interaction: AnySelectMenuInteraction): Promise { + await interaction.reply({ content: `You selected ${interaction.values}` }) + } +} as ComponentModule; \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..480930e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,47 @@ +import { Intent } from "./handler/types/Intent"; +import { EmbedBuilder, Interaction } from "discord.js"; +import { ConsoleColor } from "./handler/types/ConsoleColor"; + +// Message command prefix. +export const prefix: string = "!"; + +// Intents which will be enabled by default. +export const defaultIntents: Intent[] = [Intent.Guilds, Intent.MessageContent]; + +// Default folder names. +export const eventsFolderName: string = "events"; +export const commandsFolderName: string = "commands"; +export const componentsFolderName: string = "components"; + +// Your Discord ID (for owner only commands) +export const ownerId: string = "712205125158174751"; + +// Layout for the info logging message. +export function getLoggerLogMessage(message: string): string { + return `${ConsoleColor.Green}[INFO] ${message}${ConsoleColor.Reset}`; +} + +// Layout for the warning logging message. +export function getLoggerWarnMessage(message: string): string { + return `${ConsoleColor.Yellow}[WARNING] ${message}${ConsoleColor.Reset}`; +} + +// Layout for the error logging message. +export function getLoggerErrorMessage(message: string): string { + return `${ConsoleColor.Red}[ERROR] ${message}${ConsoleColor.Reset}`; +} + +// Generates an embed when a user lacks the necessary conditions to execute a command. +export function getCommandNotAllowedEmbed(interaction: Interaction): EmbedBuilder { + return new EmbedBuilder() + .setTitle("You are not allowed to use this command!") + .setColor("#DA373C") +} + +// Generates an embed when a command is on cooldown. +export function getCommandOnCooldownEmbed(timeLeft: number, commandName: string): EmbedBuilder { + return new EmbedBuilder() + .setTitle("Command on cooldown") + .setColor("#DA373C") + .setDescription(`Please wait ${timeLeft} more second(s) before reusing the \`${commandName}\` command.`); +} \ No newline at end of file diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..b2251ef --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,14 @@ +import { Events, Interaction } from "discord.js"; +import { EventModule } from "../handler/types/EventModule"; +import { handleComponents } from "../handler/util/handleComponents"; +import { handleInteractionCommands } from "../handler/util/handleInteractionCommands"; + +export = { + name: Events.InteractionCreate, + async execute(interaction: Interaction): Promise { + // Handles Slash Commands and Context Menus. + await handleInteractionCommands(interaction); + // Handles Buttons, Select Menus and Modals. + await handleComponents(interaction); + } +} as EventModule; \ No newline at end of file diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..8ce7787 --- /dev/null +++ b/src/events/messageCreate.ts @@ -0,0 +1,12 @@ +import { Events, Message } from "discord.js"; +import { EventModule } from "../handler/types/EventModule"; +import { handleMessageCommands } from "../handler/util/handleChatCommands"; + +export = { + name: Events.MessageCreate, + async execute(message: Message): Promise { + if(message.author.bot) return; + // Handles Prefix, Ping and Message commands. + await handleMessageCommands(message); + } +} as EventModule; \ No newline at end of file diff --git a/src/events/ready.ts b/src/events/ready.ts new file mode 100644 index 0000000..6f4eab0 --- /dev/null +++ b/src/events/ready.ts @@ -0,0 +1,17 @@ +import Logger from "../handler/util/Logger"; +import { Events, ActivityType } from "discord.js"; +import { UserStatus } from "../handler/types/UserStatus"; +import { EventModule } from "../handler/types/EventModule"; +import { DiscordClient } from "../handler/util/DiscordClient"; + +export = { + name: Events.ClientReady, + once: true, + async execute(client: DiscordClient): Promise { + if (!client.user) return; + + client.user.setStatus(UserStatus.ONLINE); + client.user.setActivity("Development", { type: ActivityType.Watching }); + Logger.log(`Ready! Logged in as ${client.user.tag}`); + } +} as EventModule; \ No newline at end of file diff --git a/src/handler/types/Command.ts b/src/handler/types/Command.ts new file mode 100644 index 0000000..9f78051 --- /dev/null +++ b/src/handler/types/Command.ts @@ -0,0 +1,100 @@ +import { + AutocompleteInteraction, + Collection, + ContextMenuCommandBuilder, + ContextMenuCommandInteraction, + Message, + SlashCommandBuilder +} from "discord.js"; + +export enum CommandTypes { + "SlashCommand" = "SlashCommand", + "PrefixCommand" = "PrefixCommand", + "MessageCommand" = "MessageCommand", + "PingCommand" = "PingCommand", + "ContextMenu" = "ContextMenu" +} + +export enum RegisterTypes { + "Guild" = "applicationGuildCommands", + "Global" = "applicationCommands" +} + +export interface CommandCollections { + slash: Collection; + prefix: Collection; + message: Collection; + ping: Collection; + context: Collection; + aliases: CommandCollectionsAliases +} + +export interface CommandCollectionsAliases { + slash: Collection; + prefix: Collection; + message: Collection; + ping: Collection; + context: Collection; +} + +export interface CooldownCollections { + user: Collection>; +} + +interface BaseCommandModule { + cooldown?: number; + ownerOnly?: boolean; + userWhitelist?: string[]; + userBlacklist?: string[]; + channelWhitelist?: string[]; + channelBlacklist?: string[]; + guildWhitelist?: string[]; + guildBlacklist?: string[]; + roleWhitelist?: string[]; + roleBlacklist?: string[]; + nsfw?: boolean; + disabled?: boolean; +} + +export interface SlashCommandModule extends BaseCommandModule { + type: CommandTypes.SlashCommand; + register: RegisterTypes; + data: SlashCommandBuilder; + autocomplete?: (interaction: AutocompleteInteraction) => Promise; + execute: (...args: any[]) => Promise; +} + +export interface PrefixCommandModule extends BaseCommandModule { + name: string; + aliases?: string[]; + permissions?: string[]; + type: CommandTypes.PrefixCommand; + execute: (message: Message) => Promise; +} + +export interface MessageCommandModule extends BaseCommandModule { + name: string; + aliases?: string[]; + permissions?: string[]; + type: CommandTypes.MessageCommand; + execute: (message: Message) => Promise; +} + +export interface PingCommandModule extends BaseCommandModule { + name: string; + aliases?: string[]; + permissions?: string[]; + type: CommandTypes.PingCommand; + execute: (message: Message) => Promise; +} + +export interface ContextMenuCommandModule extends BaseCommandModule { + type: CommandTypes.ContextMenu; + register: RegisterTypes; + data: ContextMenuCommandBuilder; + execute: (interaction: ContextMenuCommandInteraction) => Promise; +} + +export interface RegisterCommandOptions { + deploy: boolean; +} \ No newline at end of file diff --git a/src/handler/types/Component.ts b/src/handler/types/Component.ts new file mode 100644 index 0000000..7fc50be --- /dev/null +++ b/src/handler/types/Component.ts @@ -0,0 +1,20 @@ +import { Collection } from "discord.js"; + +export interface ComponentModule { + id?: string; + group?: string; + type: ComponentTypes; + execute: (interaction: any) => Promise; +} + +export interface ComponentCollections { + buttons: Collection; + selectMenus: Collection; + modals: Collection; +} + +export enum ComponentTypes { + Button = "ButtonComponent", + SelectMenu = "SelectMenuComponent", + Modal = "ModalComponent" +} \ No newline at end of file diff --git a/src/handler/types/ConsoleColor.ts b/src/handler/types/ConsoleColor.ts new file mode 100644 index 0000000..d77edcb --- /dev/null +++ b/src/handler/types/ConsoleColor.ts @@ -0,0 +1,11 @@ +export enum ConsoleColor { + Black = "\x1b[30m", + Red = "\x1b[31m", + Green = "\x1b[32m", + Yellow = "\x1b[33m", + Blue = "\x1b[34m", + Magenta = "\x1b[35m", + Cyan = "\x1b[36m", + White = "\x1b[37m", + Reset = "\x1b[0m" +} \ No newline at end of file diff --git a/src/handler/types/EventIntentMapping.ts b/src/handler/types/EventIntentMapping.ts new file mode 100644 index 0000000..e3108d8 --- /dev/null +++ b/src/handler/types/EventIntentMapping.ts @@ -0,0 +1,61 @@ +import { Intent } from "./Intent"; + +/** + * @see https://discord.com/developers/docs/topics/gateway#list-of-intents List of all Intents + */ +export const EventIntentMapping: Record> = { + guildCreate: [Intent.Guilds], + guildUpdate: [Intent.Guilds], + guildDelete: [Intent.Guilds], + guildRoleCreate: [Intent.Guilds], + guildRoleUpdate: [Intent.Guilds], + guildRoleDelete: [Intent.Guilds], + channelCreate: [Intent.Guilds], + channelUpdate: [Intent.Guilds], + channelDelete: [Intent.Guilds], + channelPinsUpdate: [Intent.Guilds], + threadCreate: [Intent.Guilds], + threadUpdate: [Intent.Guilds], + threadDelete: [Intent.Guilds], + threadListSync: [Intent.Guilds], + threadMemberUpdate: [Intent.Guilds], + threadMembersUpdate: [Intent.Guilds, Intent.GuildMembers], + stageInstanceCreate: [Intent.Guilds], + stageInstanceUpdate: [Intent.Guilds], + stageInstanceDelete: [Intent.Guilds], + guildMemberAdd: [Intent.GuildMembers], + guildMemberUpdate: [Intent.GuildMembers], + guildMemberRemove: [Intent.GuildMembers], + guildAuditLogEntryCreate: [Intent.GuildModeration], + guildBanAdd: [Intent.GuildModeration], + guildBanRemove: [Intent.GuildModeration], + guildEmojisUpdate: [Intent.GuildEmojisAndStickers], + guildStickersUpdate: [Intent.GuildEmojisAndStickers], + guildIntegrationsUpdate: [Intent.GuildIntegrations], + integrationCreate: [Intent.GuildIntegrations], + integrationUpdate: [Intent.GuildIntegrations], + integrationDelete: [Intent.GuildIntegrations], + webhooksUpdate: [Intent.GuildWebhooks], + inviteCreate: [Intent.GuildInvites], + inviteDelete: [Intent.GuildInvites], + voiceStateUpdate: [Intent.GuildVoiceStates], + presenceUpdate: [Intent.GuildPresences], + messageCreate: [Intent.GuildMessages], + messageUpdate: [Intent.GuildMessages], + messageDelete: [Intent.GuildMessages], + messageDeleteBulk: [Intent.GuildMessages], + messageReactionAdd: [Intent.GuildMessageReactions], + messageReactionRemove: [Intent.GuildMessageReactions], + messageReactionRemoveAll: [Intent.GuildMessageReactions], + messageReactionRemoveEmoji: [Intent.GuildMessageReactions], + typingStart: [Intent.DirectMessageTyping], + guildScheduledEventCreate: [Intent.GuildScheduledEvents], + guildScheduledEventUpdate: [Intent.GuildScheduledEvents], + guildScheduledEventDelete: [Intent.GuildScheduledEvents], + guildScheduledEventUserAdd: [Intent.GuildScheduledEvents], + guildScheduledEventUserRemove: [Intent.GuildScheduledEvents], + autoModerationRuleCreate: [Intent.AutoModerationConfiguration], + autoModerationRuleUpdate: [Intent.AutoModerationConfiguration], + autoModerationRuleDelete: [Intent.AutoModerationConfiguration], + autoModerationActionExecution: [Intent.AutoModerationExecution] +} \ No newline at end of file diff --git a/src/handler/types/EventModule.d.ts b/src/handler/types/EventModule.d.ts new file mode 100644 index 0000000..24260d9 --- /dev/null +++ b/src/handler/types/EventModule.d.ts @@ -0,0 +1,7 @@ +import { Events } from "discord.js"; + +export interface EventModule { + name: Events; + once?: boolean; + execute: (...args: any[]) => Promise; +} \ No newline at end of file diff --git a/src/handler/types/Formatting.ts b/src/handler/types/Formatting.ts new file mode 100644 index 0000000..9b3a9f6 --- /dev/null +++ b/src/handler/types/Formatting.ts @@ -0,0 +1,73 @@ +export enum Color { + Gray = "30", + Red = "31", + Green = "32", + Yellow = "33", + Blue = "34", + Pink = "35", + Cyan = "36", + White = "37" +} + +export enum BackgroundColor { + DarkBlue = "40", + Orange = "41", + MarbleBlue = "42", + GrayTurquoise = "43", + Gray = "44", + Indigo = "45", + LightGray = "46", + White = "47", + None = "" +} + +export enum Format { + Normal = "0", + Bold = "1", + Underline = "4" +} + +/** + * [Documentation](https://gist.github.com/LeviSnoot/d9147767abeef2f770e9ddcd91eb85aa) + */ +export enum TimestampStyle { + /** + * November 28, 2018 9:01 AM or 28 November 2018 09:01 + */ + Default = "", + + /** + * 9:01 AM or 09:01 + */ + ShortTime = ":t", + + /** + * 9:01:00 AM or 09:01:00 + */ + LongTime = ":T", + + /** + * 11/28/2018 or 28/11/2018 + */ + ShortDate = ":d", + + /** + * November 28, 2018 or 28 November 2018 + */ + LongDate = ":D", + + /** + * November 28, 2018 9:01 AM or 28 November 2018 09:01 + */ + ShortDateTime = ":f", + + /** + * Wednesday, November 28, 2018 9:01 AM or Wednesday, 28 November 2018 09:01 + */ + LongDateTime = ":F", + + /** + * 3 years ago + */ + RelativeTime = ":R" +} \ No newline at end of file diff --git a/src/handler/types/Intent.ts b/src/handler/types/Intent.ts new file mode 100644 index 0000000..b265e91 --- /dev/null +++ b/src/handler/types/Intent.ts @@ -0,0 +1,166 @@ +/** + * @see https://discord.com/developers/docs/topics/gateway#list-of-intents List of all Intents + */ +export enum Intent { + /** + * Required for event: + * - GuildCreate + * - GuildUpdate + * - GuildDelete + * - GuildRoleCreate + * - GuildRoleUpdate + * - GuildRoleDelete + * - ChannelCreate + * - ChannelUpdate + * - ChannelDelete + * - ChannelPinsUpdate + * - ThreadCreate + * - ThreadUpdate + * - ThreadDelete + * - ThreadListSync + * - ThreadMemberUpdate + * - ThreadMembersUpdate (contains different data depending on which intents are used) + * - StageInstanceCreate + * - StageInstanceUpdate + * - StageInstanceDelete + */ + Guilds = 1, + + /** + * Required for event: + * - GuildMemberAdd + * - GuildMemberUpdate + * - GuildMemberRemove + * - ThreadMembersUpdate (contains different data depending on which intents are used) + */ + GuildMembers = 2, + + /** + * Required for event: + * - GuildAuditLogEntryCreate + * - GuildBanAdd + * - GuildBanRemove + */ + GuildModeration = 4, + + /** + * Required for event: + * - GuildEmojisUpdate + * - GuildStickersUpdate + */ + GuildEmojisAndStickers = 8, + + /** + * Required for event: + * - GuildIntegrationsUpdate + * - IntegrationCreate + * - IntegrationUpdate + * - IntegrationDelete + */ + GuildIntegrations = 16, + + /** + * Required for event: + * - WebhooksUpdate + */ + GuildWebhooks = 32, + + /** + * Required for event: + * - InviteCreate + * - InviteDelete + */ + GuildInvites = 64, + + /** + * Required for event: + * - VoiceStateUpdate + */ + GuildVoiceStates = 128, + + /** + * Required for event: + * - PresenceUpdate + */ + GuildPresences = 256, + + /** + * Required for event: + * - MessageCreate + * - MessageUpdate + * - MessageDelete + * - MessageDeleteBulk + */ + GuildMessages = 512, + + /** + * Required for event: + * - MessageReactionAdd + * - MessageReactionRemove + * - MessageReactionRemoveAll + * - MessageReactionRemoveEmoji + */ + GuildMessageReactions = 1024, + + /** + * Required for event: + * - TypingStart + */ + GuildMessageTyping = 2048, + + /** + * Required for event: + * - MessageCreate + * - MessageUpdate + * - MessageDelete + * - ChannelPinsUpdate + */ + DirectMessages = 4096, + + /** + * Required for event: + * - MessageReactionAdd + * - MessageReactionRemove + * - MessageReactionRemoveAll + * - MessageReactionRemoveEmoji + */ + DirectMessageReactions = 8192, + + /** + * Required for event: + * - TypingStart + */ + DirectMessageTyping = 16384, + + /** + * MessageContent does not represent individual events, but rather affects what data is present for events + * that could contain message content fields. More information is in the message content intent section. + */ + MessageContent = 32768, + + /** + * Required for event: + * - GuildScheduledEventCreate + * - GuildScheduledEventUpdate + * - GuildScheduledEventDelete + * - GuildScheduledEventUserAdd + * - GuildScheduledEventUserRemove + */ + GuildScheduledEvents = 65536, + + /** + * Required for event: + * - AutoModerationRuleCreate + * - AutoModerationRuleUpdate + * - AutoModerationRuleDelete + */ + AutoModerationConfiguration = 1048576, + + /** + * Required for event: + * - AutoModerationActionExecution + */ + AutoModerationExecution = 2097152 +} + +export const AutomaticIntents: never[] = []; \ No newline at end of file diff --git a/src/handler/types/ProcessEnv.d.ts b/src/handler/types/ProcessEnv.d.ts new file mode 100644 index 0000000..ba6670c --- /dev/null +++ b/src/handler/types/ProcessEnv.d.ts @@ -0,0 +1,7 @@ +declare namespace NodeJS { + export interface ProcessEnv { + CLIENT_TOKEN: string; + CLIENT_ID: string; + GUILD_ID: string; + } +} \ No newline at end of file diff --git a/src/handler/types/UserStatus.ts b/src/handler/types/UserStatus.ts new file mode 100644 index 0000000..790d593 --- /dev/null +++ b/src/handler/types/UserStatus.ts @@ -0,0 +1,7 @@ +export enum UserStatus { + "INVISIBLE" = "invisible", + "OFFLINE" = "offline", + "IDLE" = "idle", + "ONLINE" = "online", + "DND" = "dnd" +} \ No newline at end of file diff --git a/src/handler/util/ColoredMessageBuilder.ts b/src/handler/util/ColoredMessageBuilder.ts new file mode 100644 index 0000000..f0d78e0 --- /dev/null +++ b/src/handler/util/ColoredMessageBuilder.ts @@ -0,0 +1,112 @@ +import { BackgroundColor, Color, Format } from "../types/Formatting"; + +export class ColoredMessageBuilder { + private message: string = ""; + private readonly start: string = "\u001b["; + private readonly reset: string = "\u001b[0m"; + + public add(text: string, color: Color): this; + public add(text: string, backgroundColor: BackgroundColor): this; + public add(text: string, color: Color, format: Format): this; + public add(text: string, color: Color, backgroundColor: BackgroundColor): this; + public add(text: string, color: Color, backgroundColor: BackgroundColor, format: Format): this; + + public add( + text: string, + param1: Color | BackgroundColor, + param2?: BackgroundColor | Format, + param3: Format = Format.Normal + ): this { + let params: any = [param1, param2, param3]; + + let color, backgroundColor, format; + for (const param of params) { + if (Object.values(Color).includes(param)) color = param; + if (Object.values(BackgroundColor).includes(param)) backgroundColor = param; + if (Object.values(Format).includes(param)) format = param; + } + + if (backgroundColor) backgroundColor = `${backgroundColor};`; + else backgroundColor = BackgroundColor.None; + + this.message += `${this.start}${format};${backgroundColor}${color}m${text}${this.reset}`; + return this; + }; + + public addRainbow(text: string, format: Format = Format.Normal): this { + const rainbowColors: Color[] = [ + Color.Red, + Color.Yellow, + Color.Green, + Color.Cyan, + Color.Blue, + Color.Pink + ]; + + let rainbowText: string = ""; + for (let i = 0; i < text.length; i++) { + const char = text.charAt(i); + const color = rainbowColors[i % rainbowColors.length]; + rainbowText += `${this.start}${format};${color}m${char}${this.reset}`; + } + + this.message += rainbowText; + return this; + }; + + public addNewLine(): this { + this.message += "\n"; + return this; + }; + + public build(): string { + return `\`\`\`ansi\n${this.message}\n\`\`\``; + }; +} + +export function colored(text: string, color: Color): string; +export function colored(text: string, backgroundColor: BackgroundColor): string; +export function colored(text: string, color: Color, format: Format): string; +export function colored(text: string, color: Color, backgroundColor: BackgroundColor): string; +export function colored(text: string, color: Color, backgroundColor: BackgroundColor, format: Format): string; + +export function colored( + text: string, + param1: Color | BackgroundColor, + param2?: BackgroundColor | Format, + param3: Format = Format.Normal +): string { + let params: any = [param1, param2, param3]; + + let color, backgroundColor, format; + for (const param of params) { + if (Object.values(Color).includes(param)) color = param; + if (Object.values(BackgroundColor).includes(param)) backgroundColor = param; + if (Object.values(Format).includes(param)) format = param; + } + + if (backgroundColor) backgroundColor = `${backgroundColor};`; + else backgroundColor = BackgroundColor.None; + + return `\`\`\`ansi\n\u001b[${format};${backgroundColor}${color}m${text}\u001b[0m\n\`\`\``; +} + +export function rainbow(text: string, format = Format.Normal): string { + const rainbowColors: Color[] = [ + Color.Red, + Color.Yellow, + Color.Gray, + Color.Cyan, + Color.Blue, + Color.Pink + ]; + + let rainbowText: string = ""; + for (let i = 0; i < text.length; i++) { + const char = text.charAt(i); + const color = rainbowColors[i % rainbowColors.length]; + rainbowText += `\u001b[${format};${color}m${char}\u001b[0m`; + } + + return `\`\`\`ansi\n${rainbowText}\n\`\`\``; +} \ No newline at end of file diff --git a/src/handler/util/DiscordClient.ts b/src/handler/util/DiscordClient.ts new file mode 100644 index 0000000..1676bbf --- /dev/null +++ b/src/handler/util/DiscordClient.ts @@ -0,0 +1,103 @@ +import Logger from "./Logger"; +import { Intent } from "../types/Intent"; +import { defaultIntents } from "../../config"; +import { registerEvents } from "./handleEvents"; +import { registerComponents } from "./handleComponents"; +import { EventIntentMapping } from "../types/EventIntentMapping"; +import { Client, Collection, IntentsBitField } from "discord.js"; +import { ComponentCollections, ComponentModule } from "../types/Component"; +import { deleteAllCommands, deleteCommands, registerCommands } from "./handleCommands"; +import { + CommandCollections, + ContextMenuCommandModule, + CooldownCollections, + MessageCommandModule, + PingCommandModule, + PrefixCommandModule, + RegisterCommandOptions, + RegisterTypes, + SlashCommandModule +} from "../types/Command"; + +export class DiscordClient extends Client { + public events: string[]; + public commands: CommandCollections; + public components: ComponentCollections; + public cooldowns: CooldownCollections; + + constructor(options: ConstructorParameters[0]) { + super(options); + this.events = []; + this.commands = { + slash: new Collection(), + prefix: new Collection(), + message: new Collection(), + ping: new Collection(), + context: new Collection(), + aliases: { + slash: new Collection(), + prefix: new Collection(), + message: new Collection(), + ping: new Collection(), + context: new Collection() + } + }; + this.components = { + buttons: new Collection(), + selectMenus: new Collection(), + modals: new Collection() + }; + this.cooldowns = { + user: new Collection>() + }; + }; + + public async registerEvents(): Promise { + await registerEvents(this); + }; + + public async registerCommands(options: RegisterCommandOptions): Promise { + await registerCommands(this, options); + }; + + public async deleteCommand(commandId: string, type: RegisterTypes): Promise { + await deleteCommands([commandId], type); + }; + + public async deleteCommands(commandIds: string[], type: RegisterTypes): Promise { + await deleteCommands(commandIds, type); + }; + + public async deleteAllCommands(type: RegisterTypes): Promise { + await deleteAllCommands(type); + }; + + public async registerComponents(): Promise { + await registerComponents(this); + } + + public async connect(token: string | undefined): Promise { + if (token === undefined) return Logger.error("Token is undefined. Please provide a valid token.") + if (!this.options.intents.bitfield) await this.setIntents(); + try { + await this.login(token); + } catch (err) { + Logger.error("Failed to connect to the bot:", err); + } + }; + + public async setIntents(): Promise { + const intentBitField: IntentsBitField = new IntentsBitField(); + + this.events.forEach(event => { + const intents: Intent[] = EventIntentMapping[event]; + if (intents) Array.from(intents).forEach(intent => intentBitField.add(intent)); + }); + + defaultIntents.forEach(intent => { + intentBitField.add(intent) + }); + + this.options.intents = intentBitField; + }; +} \ No newline at end of file diff --git a/src/handler/util/Logger.ts b/src/handler/util/Logger.ts new file mode 100644 index 0000000..67f7f28 --- /dev/null +++ b/src/handler/util/Logger.ts @@ -0,0 +1,15 @@ +import { getLoggerErrorMessage, getLoggerLogMessage, getLoggerWarnMessage } from "../../config"; + +export default class Logger { + public static log(message: string, data?: any): void { + console.info(getLoggerLogMessage(message), data || ""); + }; + + public static warn(message: string, data?: any): void { + console.warn(getLoggerWarnMessage(message), data || ""); + }; + + public static error(message: string, data?: any): void { + console.error(getLoggerErrorMessage(message), data || ""); + }; +} \ No newline at end of file diff --git a/src/handler/util/handleChatCommands.ts b/src/handler/util/handleChatCommands.ts new file mode 100644 index 0000000..93acfd1 --- /dev/null +++ b/src/handler/util/handleChatCommands.ts @@ -0,0 +1,89 @@ +import { client } from "../../index"; +import { Message } from "discord.js"; +import { getCommandOnCooldownEmbed, prefix } from "../../config"; +import { hasCooldown, isAllowedCommand } from "./handleCommands"; +import { MessageCommandModule, PingCommandModule, PrefixCommandModule } from "../types/Command"; + +export async function handleMessageCommands(message: Message): Promise { + if (!client.user) return; + if (message.content.startsWith(prefix)) await handlePrefixCommand(message); + else if (message.content.startsWith(`<@${client.user.id}>`)) await handlePingCommand(message); + else await handleMessageCommand(message); +} + +async function handlePrefixCommand(message: Message): Promise { + const messageCommand: string = message.content.split(" ")[0].replace(prefix, ""); + let command: PrefixCommandModule | string | undefined = + client.commands.prefix.get(messageCommand) + || client.commands.aliases.prefix.get(messageCommand); + + if (typeof command === "string") command = client.commands.prefix.get(command); + + if (command) { + if (command.permissions && !hasPermissions(message.member, command.permissions)) return; + const cooldown: boolean | number = await hasCooldown(message.author.id, command.name, command.cooldown); + if (typeof cooldown === "number") { + await message.reply({ + embeds: [getCommandOnCooldownEmbed(cooldown, command.name)] + }); + return; + } + + message.content.replace(`${prefix}${command.name} `, ""); + if (!await isAllowedCommand(command, message.member?.user, message.guild, message.channel, message.member)) + await command.execute(message); + } +} + +async function handlePingCommand(message: Message): Promise { + if (!client.user) return; + + const messageCommand: string = message.content.split(" ")[1].replace(/ /g, ""); + let command: PingCommandModule | string | undefined = + client.commands.ping.get(messageCommand) + || client.commands.aliases.ping.get(messageCommand); + + if (typeof command === "string") command = client.commands.ping.get(command); + + if (command) { + if (command.permissions && !hasPermissions(message.member, command.permissions)) return; + const cooldown: boolean | number = await hasCooldown(message.author.id, command.name, command.cooldown); + if (typeof cooldown === "number") { + await message.reply({ + embeds: [getCommandOnCooldownEmbed(cooldown, command.name)] + }); + return; + } + + message.content = message.content.replace(`<@${client.user.id}> ${command.name} `, ""); + if (!await isAllowedCommand(command, message.member?.user, message.guild, message.channel, message.member)) + await command.execute(message); + } +} + +async function handleMessageCommand(message: Message): Promise { + const messageCommand: string = message.content.split(" ")[0]; + let command: MessageCommandModule | string | undefined = + client.commands.message.get(messageCommand) + || client.commands.aliases.message.get(messageCommand); + + if (typeof command === "string") command = client.commands.message.get(command); + + if (command) { + if (command.permissions && !hasPermissions(message.member, command.permissions)) return; + const cooldown: boolean | number = await hasCooldown(message.author.id, command.name, command.cooldown); + if (typeof cooldown === "number") { + await message.reply({ + embeds: [getCommandOnCooldownEmbed(cooldown, command.name)] + }); + return; + } + + if (!await isAllowedCommand(command, message.member?.user, message.guild, message.channel, message.member)) + await command.execute(message); + } +} + +function hasPermissions(member: any, permissions: string[]): boolean { + return permissions.every(permission => member.permissions.has(permission)); +} \ No newline at end of file diff --git a/src/handler/util/handleCommands.ts b/src/handler/util/handleCommands.ts new file mode 100644 index 0000000..b71c537 --- /dev/null +++ b/src/handler/util/handleCommands.ts @@ -0,0 +1,205 @@ +import { glob } from "glob"; +import Logger from "./Logger"; +import { client } from "../../index"; +import { DiscordClient } from "./DiscordClient"; +import { commandsFolderName, ownerId } from "../../config"; +import { CommandTypes, RegisterCommandOptions, RegisterTypes } from "../types/Command"; +import { + APIInteractionGuildMember, ApplicationCommandType, + Channel, + Collection, + Guild, + GuildMember, + REST, + Routes, + TextChannel, + User +} from "discord.js"; + +export async function registerCommands(client: DiscordClient, options: RegisterCommandOptions): Promise { + await getCommandModules(client); + if (options.deploy) await deploySlashCommands(client); +} + +async function getCommandModules(client: DiscordClient): Promise { + let commandPaths: string[] = await glob(`**/${commandsFolderName}/**/**/*.js`); + for (const command of commandPaths) { + let importPath: string = `../..${command.replace(/^dist[\\\/]|\\/g, "/")}`; + try { + let module = (await import(importPath)).default; + if (module.type === CommandTypes.SlashCommand && (!module.data.name || !module.data.description)) { + Logger.error(`No name or description for command at ${importPath} set`); + return; + } + if (module.type === CommandTypes.SlashCommand) { + if (module.disabled) continue; + client.commands.slash.set(module.data.name, module); + } else if (module.type === CommandTypes.PrefixCommand) { + if (module.disabled) continue; + client.commands.prefix.set(module.name, module); + if (module.aliases) { + for (const alias of module.aliases) { + client.commands.aliases.prefix.set(alias, module.name); + } + } + } else if (module.type === CommandTypes.MessageCommand) { + if (module.disabled) continue; + client.commands.message.set(module.name, module); + if (module.aliases) { + for (const alias of module.aliases) { + client.commands.aliases.message.set(alias, module.name); + } + } + } else if (module.type === CommandTypes.PingCommand) { + if (module.disabled) continue; + client.commands.ping.set(module.name, module); + if (module.aliases) { + for (const alias of module.aliases) { + client.commands.aliases.ping.set(alias, module.name); + } + } + } else if (module.type === CommandTypes.ContextMenu) { + if (module.disabled) continue; + client.commands.context.set(module.data.name, module); + } + } catch (err) { + Logger.error(`Failed to load command at ${importPath}`); + } + } + if (client.commands.slash.size > 100) { + Logger.error("You can only register 100 Slash Commands."); + process.exit(); + } + if ((client.commands.context.filter( + command => command.data.type === ApplicationCommandType.Message)).size > 5 + ) { + Logger.error("You can only register 5 Message Context Menus."); + process.exit(); + } + if ((client.commands.context.filter( + command => command.data.type === ApplicationCommandType.User)).size > 5 + ) { + Logger.error("You can only register 5 Message User Menus."); + process.exit(); + } +} + +async function deploySlashCommands(client: DiscordClient): Promise { + let guildCommands: any[] = []; + let globalCommands: any[] = []; + + for (const module of client.commands.slash) { + if (module[1].register === RegisterTypes.Guild) guildCommands.push(module[1].data); + if (module[1].register === RegisterTypes.Global) globalCommands.push(module[1].data); + } + + for (const module of client.commands.context) { + if (module[1].register === RegisterTypes.Guild) guildCommands.push(module[1].data); + if (module[1].register === RegisterTypes.Global) globalCommands.push(module[1].data); + } + + if (guildCommands.length > 0) await uploadSlashCommands(RegisterTypes.Guild, guildCommands); + if (globalCommands.length > 0) await uploadSlashCommands(RegisterTypes.Global, globalCommands); +} + +async function uploadSlashCommands(type: RegisterTypes, commands: Array): Promise { + if (!process.env.CLIENT_TOKEN) return Logger.error("No TOKEN set!"); + if (!process.env.CLIENT_ID) return Logger.error("No CLIENT_ID set!"); + if (RegisterTypes.Guild && !process.env.GUILD_ID) return Logger.error("No GUILD_ID set!"); + + const rest: REST = new REST({ version: "10" }).setToken(process.env.CLIENT_TOKEN); + try { + Logger.log(`Started refreshing ${commands.length} application commands`); + await rest.put( + Routes[type](process.env.CLIENT_ID, process.env.GUILD_ID), { body: commands } + ); + Logger.log(`Successfully reloaded ${commands.length} application commands`); + } catch (err) { + Logger.error("Error in uploadCommands", err); + } +} + +export async function deleteCommands(commandIds: string[], type: RegisterTypes): Promise { + if (!process.env.CLIENT_ID) return Logger.error("No CLIENT_ID set!"); + if (RegisterTypes.Guild && !process.env.GUILD_ID) return Logger.error("No GUILD_ID set!"); + + const rest: REST = new REST({ version: "10" }).setToken(process.env.CLIENT_TOKEN); + if (type === RegisterTypes.Guild) { + for (const commandId of commandIds) { + await rest.delete(Routes.applicationGuildCommand(process.env.CLIENT_ID, process.env.GUILD_ID, commandId)) + .then(() => Logger.log(`Successfully deleted guild command: ${commandId}`)) + .catch(console.error); + } + } + + if (type === RegisterTypes.Global) { + for (const commandId of commandIds) { + await rest.delete(Routes.applicationCommand(process.env.CLIENT_ID, commandId)) + .then(() => Logger.log(`Successfully deleted global command: ${commandId}`)) + .catch(console.error); + } + } +} + +export async function deleteAllCommands(type: RegisterTypes): Promise { + if (!process.env.CLIENT_ID) return Logger.error("No CLIENT_ID set!"); + if (RegisterTypes.Guild && !process.env.GUILD_ID) return Logger.error("No GUILD_ID set!"); + + const rest: REST = new REST({ version: "10" }).setToken(process.env.CLIENT_TOKEN); + if (type === RegisterTypes.Guild) { + await rest.put(Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID), { body: [] }) + .then(() => Logger.log("Successfully deleted all guild commands")) + .catch(console.error); + } + + if (type === RegisterTypes.Global) { + await rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: [] }) + .then(() => Logger.log("Successfully deleted all global commands")) + .catch(console.error); + } +} + +export async function isAllowedCommand( + command: any, + user: User | undefined, + guild: Guild | null, + channel: Channel | null, + member: GuildMember | APIInteractionGuildMember | null +): Promise { + if (!user || !member) return false; + const memberRoles: any = member.roles; + + return ((command.ownerOnly && user.id !== ownerId) + || (command.userWhitelist && !command.userWhitelist.includes(user.id)) + || (command.userBlacklist && command.userBlacklist.includes(user.id)) + || (command.channelWhitelist && channel && !command.channelWhitelist.includes(channel.id)) + || (command.channelBlacklist && channel && command.channelBlacklist.includes(channel.id)) + || (command.guildWhitelist && guild && !command.guildWhitelist.includes(guild.id)) + || (command.guildBlacklist && guild && command.guildBlacklist.includes(guild.id)) + || (command.roleWhitelist && !command.roleWhitelist.some((roleId: string) => memberRoles.cache.has(roleId))) + || (command.roleBlacklist && command.roleBlacklist.some((roleId: string) => memberRoles.cache.has(roleId)))) + || (command.nsfw && channel && !(channel as TextChannel).nsfw); +} + +export async function hasCooldown(userId: string, commandName: string, cooldown: number | undefined): Promise { + if (cooldown) { + let currentTimestamp: number = Math.floor(Date.now() / 1000); + let commandCollection: Collection | undefined = client.cooldowns.user.get(commandName); + + if (!commandCollection) { + client.cooldowns.user.set(commandName, new Collection()); + commandCollection = client.cooldowns.user.get(commandName); + } + + let userCooldown: number | undefined = commandCollection?.get(userId); + if (userCooldown) { + if (currentTimestamp < userCooldown) { + return userCooldown - currentTimestamp; + } + } + + commandCollection?.set(userId, currentTimestamp + cooldown); + return true; + } + return true; +} \ No newline at end of file diff --git a/src/handler/util/handleComponents.ts b/src/handler/util/handleComponents.ts new file mode 100644 index 0000000..51b5926 --- /dev/null +++ b/src/handler/util/handleComponents.ts @@ -0,0 +1,82 @@ +import { glob } from "glob"; +import Logger from "./Logger"; +import { client } from "../../index"; +import { Interaction } from "discord.js"; +import { DiscordClient } from "./DiscordClient"; +import { componentsFolderName } from "../../config"; +import { ComponentModule, ComponentTypes } from "../types/Component"; + +export async function registerComponents(client: DiscordClient): Promise { + let componentPaths: string[] = await glob(`**/${componentsFolderName}/**/**/*.js`); + + for (const component of componentPaths) { + let importPath: string = `../..${component.replace(/^dist[\\\/]|\\/g, "/")}`; + try { + let module: ComponentModule = (await import(importPath)).default; + if (module.type === ComponentTypes.Button) { + if (module.id) client.components.buttons.set(module.id, module); + else if (module.group) client.components.buttons.set(module.group, module); + } else if (module.type === ComponentTypes.SelectMenu) { + if (module.id) client.components.selectMenus.set(module.id, module); + else if (module.group) client.components.selectMenus.set(module.group, module); + } else if (module.type === ComponentTypes.Modal) { + if (module.id) client.components.modals.set(module.id, module); + else if (module.group) client.components.modals.set(module.group, module); + } + } catch (err) { + Logger.error(`Failed to load component at ${importPath}`); + } + } +} + +export async function handleComponents(interaction: Interaction): Promise { + if (interaction.isButton()) { + let id; + if (interaction.customId.includes("group=")) { + id = interaction.customId.split(";")[0].replace(/group=|;/g, ""); + interaction.customId = interaction.customId.split(";")[1]; + } else { + id = interaction.customId; + } + + const component: ComponentModule | undefined = client.components.buttons.get(id); + if (!component) return Logger.error(`No component matching ${interaction.customId} was found.`); + + try { + await component.execute(interaction); + } catch (err) { + return Logger.error(`Error executing ${id}`, err); + } + } + + if (interaction.isAnySelectMenu()) { + let id; + if (interaction.customId.includes("group=")) { + id = interaction.customId.split(";")[0].replace(/group=|;/g, ""); + interaction.customId = interaction.customId.split(";")[1]; + } else { + id = interaction.customId; + } + + const component: ComponentModule | undefined = client.components.selectMenus.get(id); + if (!component) return Logger.error(`No component matching ${interaction.customId} was found.`); + + try { + await component.execute(interaction); + } catch (err) { + return Logger.error(`Error executing ${id}`, err); + } + } + + if (interaction.isModalSubmit()) { + const component: ComponentModule | undefined = client.components.modals.get(interaction.customId); + if (!component) return Logger.error(`No component matching ${interaction.customId} was found.`); + if (component.group) return Logger.error(`The parameter group in ${interaction.customId} is not allowed.`); + + try { + await component.execute(interaction); + } catch (err) { + return Logger.error(`Error executing ${interaction.customId}`, err); + } + } +} \ No newline at end of file diff --git a/src/handler/util/handleEvents.ts b/src/handler/util/handleEvents.ts new file mode 100644 index 0000000..d40ed1a --- /dev/null +++ b/src/handler/util/handleEvents.ts @@ -0,0 +1,21 @@ +import { glob } from "glob"; +import Logger from "./Logger"; +import { eventsFolderName } from "../../config"; +import { DiscordClient } from "./DiscordClient"; +import { EventModule } from "../types/EventModule"; + +export async function registerEvents(client: DiscordClient): Promise { + let eventsPaths: string[] = await glob(`**/${eventsFolderName}/**/**/*.js`); + + for (const event of eventsPaths) { + let importPath: string = `../..${event.replace(/^dist[\\\/]|\\/g, "/")}`; + try { + let eventModule: EventModule = (await import(importPath)).default; + client.events.push(eventModule.name); + if (eventModule.once) client.once(String(eventModule.name), (...args: any[]) => eventModule.execute(...args)); + else client.on(String(eventModule.name), (...args: any[]) => eventModule.execute(...args)); + } catch (err) { + Logger.error(`Failed to load event at ${importPath}`); + } + } +} \ No newline at end of file diff --git a/src/handler/util/handleInteractionCommands.ts b/src/handler/util/handleInteractionCommands.ts new file mode 100644 index 0000000..54dbfe5 --- /dev/null +++ b/src/handler/util/handleInteractionCommands.ts @@ -0,0 +1,83 @@ +import Logger from "./Logger"; +import { client } from "../../index"; +import { hasCooldown, isAllowedCommand } from "./handleCommands"; +import { ContextMenuCommandModule, SlashCommandModule } from "../types/Command"; +import { getCommandNotAllowedEmbed, getCommandOnCooldownEmbed } from "../../config"; +import { + AutocompleteInteraction, + CommandInteraction, + ContextMenuCommandInteraction, + Interaction +} from "discord.js"; + +export async function handleInteractionCommands(interaction: Interaction): Promise { + if (interaction.isChatInputCommand()) await handleSlashCommands(interaction); + else if (interaction.isContextMenuCommand()) await handleContextMenu(interaction); + else if (interaction.isAutocomplete()) await handleAutocomplete(interaction); +} + +async function handleSlashCommands(interaction: CommandInteraction): Promise { + const command: SlashCommandModule | undefined = client.commands.slash.get(interaction.commandName); + if (!command) return Logger.error(`No command matching ${interaction.commandName} was found.`); + + const cooldown: boolean | number = await hasCooldown(interaction.user.id, command.data.name, command.cooldown); + if (typeof cooldown === "number") { + await interaction.reply({ + embeds: [getCommandOnCooldownEmbed(cooldown, command.data.name)], + ephemeral: true + }); + return; + } + + if ( + await isAllowedCommand(command, interaction.user, interaction.guild, interaction.channel, interaction.member) + ) { + await interaction.reply({ embeds: [getCommandNotAllowedEmbed(interaction as Interaction)], ephemeral: true }); + return; + } + + try { + await command.execute(interaction); + } catch (err) { + return Logger.error(`Error executing ${interaction.commandName}`, err); + } +} + +async function handleContextMenu(interaction: ContextMenuCommandInteraction): Promise { + const command: ContextMenuCommandModule | undefined = client.commands.context.get(interaction.commandName); + if (!command) return Logger.error(`No command matching ${interaction.commandName} was found.`); + + const cooldown: boolean | number = await hasCooldown(interaction.user.id, command.data.name, command.cooldown); + if (typeof cooldown === "number") { + await interaction.reply({ + embeds: [getCommandOnCooldownEmbed(cooldown, command.data.name)], + ephemeral: true + }); + return; + } + + if ( + await isAllowedCommand(command, interaction.user, interaction.guild, interaction.channel, interaction.member) + ) { + await interaction.reply({ embeds: [getCommandNotAllowedEmbed(interaction as Interaction)], ephemeral: true }); + return; + } + + try { + await command.execute(interaction); + } catch (err) { + return Logger.error(`Error executing ${interaction.commandName}`, err); + } +} + +async function handleAutocomplete(interaction: AutocompleteInteraction): Promise { + const command: SlashCommandModule | undefined = client.commands.slash.get(interaction.commandName); + if (!command) return Logger.error(`No command matching ${interaction.commandName} was found.`); + if (!command.autocomplete) return Logger.error(`No autocomplete in ${interaction.commandName} was found.`) + + try { + await command.autocomplete(interaction); + } catch (err) { + return Logger.error(`Error autocompleting ${interaction.commandName}`, err); + } +} \ No newline at end of file diff --git a/src/handler/util/timestamp.ts b/src/handler/util/timestamp.ts new file mode 100644 index 0000000..8507620 --- /dev/null +++ b/src/handler/util/timestamp.ts @@ -0,0 +1,5 @@ +import { TimestampStyle } from "../types/Formatting"; + +export function formatTimestamp(timestamp: number, style: TimestampStyle): string { + return ``; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1e4d285 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,25 @@ +import 'dotenv/config'; +import { AutomaticIntents } from "./handler/types/Intent"; +import { DiscordClient } from "./handler/util/DiscordClient"; + +export const client: DiscordClient = new DiscordClient({ + // "AutomaticIntents" will provide your client with all necessary Intents. + // By default, two specific Intents are enabled (Guilds, & MessageContent). + // For details or modifications, see the config.ts file. + // Manually adding Intents also works. + intents: AutomaticIntents +}); + +(async (): Promise => { + // You can modify the "events", "components" and "commands" folder name in the config.ts file. + // All directory's can have subfolders, subfolders in subfolders and even no subfolders. + await client.registerEvents(); + await client.registerComponents(); + await client.registerCommands({ + // Whether to deploy your Slash Commands to the Discord API (refreshes command.data) + // Not needed when just updating the execute function. + // Keep in mind that guild commands will be deployed instantly and global commands can take up to one hour. + deploy: true + }); + await client.connect(process.env.CLIENT_TOKEN); +})(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b9a9f89 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "commonjs", + "rootDir": "./src/", + "outDir": "./dist/", + "strict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "sourceMap": true, + "importHelpers": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "removeComments": true, + "typeRoots": ["node_modules/@types", "./handler/types/ProcessEnv.d.ts"], + "baseUrl": "./", + "lib": ["esnext", "dom"] + }, + "files": ["src/index.ts"], + "include": ["./**/*.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file