├── .eslintrc.json ├── .gitattributes ├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ ├── code-ql.yml │ └── node-ci.yml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── PRIVACY.md ├── README.md ├── docs ├── .nojekyll ├── _navbar.md ├── commands │ ├── edit.md │ ├── help.md │ ├── oneHundred.md │ └── view.md ├── img │ ├── edit-confirm.png │ ├── edit.png │ ├── get-id.png │ ├── help.png │ ├── oneHundred.png │ ├── slash-edit.png │ ├── slash.png │ └── view.png ├── index.html └── index.md ├── package-lock.json ├── package.json ├── renovate.json ├── sample.env ├── src ├── commands │ ├── _CommandList.ts │ ├── edit.ts │ ├── help.ts │ ├── oneHundred.ts │ ├── reset.ts │ └── view.ts ├── config │ └── IntentOptions.ts ├── database │ ├── connectDatabase.ts │ └── models │ │ └── CamperModel.ts ├── events │ ├── onInteraction.ts │ └── onReady.ts ├── index.ts ├── interfaces │ └── CommandInt.ts ├── modules │ ├── getCamperData.ts │ └── updateCamperData.ts └── utils │ ├── errorHandler.ts │ ├── logHandler.ts │ └── validateEnv.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2020": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 11, 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["@typescript-eslint"], 17 | "rules": { 18 | "linebreak-style": ["error", "unix"], 19 | "quotes": ["error", "double", { "allowTemplateLiterals": true }], 20 | "semi": ["error", "always"], 21 | "prefer-const": "error", 22 | "eqeqeq": ["error", "always"], 23 | "curly": ["error"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text eol=LF 3 | *.ts text 4 | *.spec.ts text 5 | 6 | # Ignore binary files >:( 7 | *.png binary 8 | *.jpg binary -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @freecodecamp/dev-team 2 | 3 | /package.json 4 | /package-lock.json 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: freecodecamp 2 | -------------------------------------------------------------------------------- /.github/workflows/code-ql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | analyse: 11 | name: Analyse 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | language: ["javascript"] 17 | node-version: [16.x] 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | - name: Use Node.js v${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Build files 28 | run: npm run build 29 | - name: Setup CodeQL 30 | uses: github/codeql-action/init@v1 31 | with: 32 | languages: ${{ matrix.language }} 33 | - name: Perform Analysis 34 | uses: github/codeql-action/analyze@v1 35 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | ci: 12 | name: Lint / Build / Test 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [16.x] 18 | 19 | steps: 20 | - name: Checkout Source Files 21 | uses: actions/checkout@v3 22 | 23 | - name: Use Node.js v${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Install Dependencies 29 | run: npm ci 30 | 31 | - name: Lint Source Files 32 | run: npm run lint 33 | 34 | - name: Verify Build 35 | run: npm run build 36 | 37 | - name: Run Tests 38 | run: npm run test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/.env 3 | **/prod/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "useTabs": false, 4 | "singleQuote": false 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | > Our Code of Conduct is available here: 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Our contributing docs are available here: . 2 | 3 | Looking to edit these docs? Read [this document](https://contribute.freecodecamp.org/#/how-to-work-on-the-docs-theme) first. 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, freeCodeCamp.org 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | The use of this application ("Bot") in a server requires the collection of some specific user data ("Data"). The Data collected includes, but is not limited to Discord user ID values. Use of the Bot is considered an agreement to the terms of this Policy. 4 | 5 | ## Access to Data 6 | 7 | Access to Data is only permitted to Bot's developers, and only in the scope required for the development, testing, and implementation of features for Bot. Data is not sold, provided to, or shared with any third party, except where required by law or a Terms of Service agreement. You can view the data upon request from `@nhcarrigan`. 8 | 9 | ## Storage of Data 10 | 11 | Data is stored in a MongoDB database. The database is secured to prevent external access, however no guarantee is provided and the Bot owners assume no liability for the unintentional or malicious breach of Data. In the event of an unauthorised Data access, users will be notified through the Discord client application. 12 | 13 | ## User Rights 14 | 15 | At any time, you have the right to request to view the Data pertaining to your Discord account. You may submit a request through the [Discord Server](http://chat.nhcarrigan.com). You have the right to request the removal of relevant Data. 16 | 17 | ## Underage Users 18 | 19 | The use of the Bot is not permitted for minors under the age of 13, or under the age of legal consent for their country. This is in compliance with the [Discord Terms of Service](https://discord.com/terms). No information will be knowingly stored from an underage user. If it is found out that a user is underage we will take all necessary action to delete the stored data. 20 | 21 | ## Questions 22 | 23 | If you have any questions or are concerned about what data might be being stored from your account contact `@nhcarrigan`. For more information check the [Discord Terms Of Service](https://discord.com/terms). -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 100 Days of Code Bot 2 | 3 | A Discord bot to help track your progress in the 100 Days of Code challenge. 4 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/.nojekyll -------------------------------------------------------------------------------- /docs/_navbar.md: -------------------------------------------------------------------------------- 1 | Commands 2 | 3 | - [100](/commands/oneHundred.md) 4 | - [view](/commands/view.md) 5 | - [edit](/commands/edit.md) 6 | - [help](/commands/help.md) 7 | -------------------------------------------------------------------------------- /docs/commands/edit.md: -------------------------------------------------------------------------------- 1 | # Edit 2 | 3 | The `/edit` command allows you to edit a previous 100 Days of Code post. The bot will modify the post with your new content. 4 | 5 | This does require a message ID. You can obtain the message ID by right clicking (long press, on mobile) on the message you want to edit, and selecting "Copy ID". 6 | 7 | ![Image depicting the context menu with the Copy ID option](../img/get-id.png) 8 | 9 | If you do not see the "Copy ID" option, you may need to enable Developer Mode in your Discord client. Under your settings, select "Advanced" and you will see the toggle for Developer Mode. 10 | 11 | The bot will not allow you to edit posts which are not your own. 12 | 13 | ## Usage 14 | 15 | `/edit ` will find the message in the channel that matches the `id`. Just like the `/100` command, the `message` parameter can include spaces and line breaks. 16 | 17 | ## Example 18 | 19 | ![Image depicting the usage of the /edit command](../img/slash-edit.png) 20 | 21 | `/edit 855616124788277268 This is an updated demonstration.` will update the existing message with a new embed: 22 | 23 | ![Image depicting an updated message embed](../img/edit.png) 24 | 25 | The bot will also respond with a confirmation that your message has been updated. 26 | 27 | ![Image depicting a confirmation message](../img/edit-confirm.png) 28 | -------------------------------------------------------------------------------- /docs/commands/help.md: -------------------------------------------------------------------------------- 1 | # Help 2 | 3 | The `/help` command provides an embed which explains how to use the bot. This allows you to see the instructions on platform, rather than visiting the documentation. 4 | 5 | ## Usage 6 | 7 | `/help` will generate the embed. This command takes no parameters. 8 | 9 | ## Example 10 | 11 | `/help` will return an embed similar to this: 12 | 13 | ![Image depicting the help embed](../img/help.png) 14 | -------------------------------------------------------------------------------- /docs/commands/oneHundred.md: -------------------------------------------------------------------------------- 1 | # 100 2 | 3 | The `/100` command is used to generate a new 100 Days of Code post. When you use this command, the bot will increase your `day` count by 1. If you have completed a full one hundred days, the bot will set your `day` count to 1 and increase your `round` count by 1. 4 | 5 | ## Usage 6 | 7 | `/100 `, where `message` is the message you would like to appear in your post. 8 | 9 | ## Example 10 | 11 | ![Image depicting the use of the slash command](../img/slash.png) 12 | 13 | `/100 This is a demonstration.` will generate this embed: 14 | 15 | ![Image depicting the embed generated by the !100 command.](../img/oneHundred.png) 16 | -------------------------------------------------------------------------------- /docs/commands/view.md: -------------------------------------------------------------------------------- 1 | # View 2 | 3 | The `/view` command allows you to view your current 100 Days of Code progress. This is helpful if you forgot where you were, or think you might have missed a day. 4 | 5 | The resulting embed will show your current `day` and `round`, as well as the date you last reported an update. 6 | 7 | ## Usage 8 | 9 | `/view` will generate the embed. This command takes no parameters. 10 | 11 | ## Example 12 | 13 | `/view` returns an embed similar to this: 14 | 15 | ![Image depicting the embed generated by the view command](../img/view.png) 16 | -------------------------------------------------------------------------------- /docs/img/edit-confirm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/edit-confirm.png -------------------------------------------------------------------------------- /docs/img/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/edit.png -------------------------------------------------------------------------------- /docs/img/get-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/get-id.png -------------------------------------------------------------------------------- /docs/img/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/help.png -------------------------------------------------------------------------------- /docs/img/oneHundred.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/oneHundred.png -------------------------------------------------------------------------------- /docs/img/slash-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/slash-edit.png -------------------------------------------------------------------------------- /docs/img/slash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/slash.png -------------------------------------------------------------------------------- /docs/img/view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/100DaysOfCode-discord-bot/c5a251130dbfa0251ad2561b373b9b39ca445c41/docs/img/view.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 31 | 100 Days of Code Bot 32 | 33 | 34 |
35 |

100 Days of Code Bot

36 |

37 | Documentation for 100 Days of Code bot in the freeCodeCamp Discord server. 38 |

39 |
40 | 41 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # 100 Days of Code Bot 2 | 3 | This is the documentation for the 100 Days of Code bot in the freeCodeCamp Discord server. 4 | 5 | Use the sidebar to navigate the available commands. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "100-days-of-code-bot", 3 | "version": "2.0.0", 4 | "description": "A bot for tracking 100 Days of Code progress in Discord.", 5 | "main": "./prod/index.js", 6 | "scripts": { 7 | "prebuild": "rm -rf ./prod", 8 | "build": "tsc", 9 | "lint": "eslint ./src", 10 | "start": "node -r dotenv/config ./prod/index.js", 11 | "test": "echo 'No tests yet'.", 12 | "docs": "docsify serve ./docs -o --port 3200" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/nhcarrigan/100-days-of-code-bot.git" 17 | }, 18 | "author": "Nicholas Carrigan", 19 | "license": "AGPL-3.0-or-later", 20 | "bugs": { 21 | "url": "https://github.com/nhcarrigan/100-days-of-code-bot/issues" 22 | }, 23 | "homepage": "https://github.com/nhcarrigan/100-days-of-code-bot#readme", 24 | "engines": { 25 | "node": "16.15.1", 26 | "npm": "8.12.1" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "16.11.41", 30 | "@typescript-eslint/eslint-plugin": "5.20.0", 31 | "@typescript-eslint/parser": "5.20.0", 32 | "eslint": "8.17.0", 33 | "eslint-config-prettier": "8.5.0", 34 | "eslint-plugin-prettier": "4.0.0", 35 | "prettier": "2.6.2", 36 | "typescript": "4.7.4" 37 | }, 38 | "dependencies": { 39 | "@discordjs/builders": "0.13.0", 40 | "@discordjs/rest": "0.4.1", 41 | "@sentry/integrations": "6.19.7", 42 | "@sentry/node": "6.19.7", 43 | "discord-api-types": "0.33.5", 44 | "discord.js": "13.7.0", 45 | "docsify-cli": "4.4.4", 46 | "dotenv": "16.0.1", 47 | "mongoose": "6.2.10", 48 | "winston": "3.7.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>freecodecamp/renovate-config"], 3 | "prCreation": "not-pending", 4 | "rangeStrategy": "pin" 5 | } 6 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # Discord 2 | BOT_TOKEN="" 3 | GUILD_ID="" 4 | 5 | # Database 6 | MONGO_URI="" 7 | 8 | # Logging 9 | SENTRY_DSN="" -------------------------------------------------------------------------------- /src/commands/_CommandList.ts: -------------------------------------------------------------------------------- 1 | import { CommandInt } from "../interfaces/CommandInt"; 2 | import { edit } from "./edit"; 3 | import { help } from "./help"; 4 | import { oneHundred } from "./oneHundred"; 5 | import { reset } from "./reset"; 6 | import { view } from "./view"; 7 | 8 | export const CommandList: CommandInt[] = [oneHundred, view, help, edit, reset]; 9 | -------------------------------------------------------------------------------- /src/commands/edit.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { CommandInt } from "../interfaces/CommandInt"; 3 | import { errorHandler } from "../utils/errorHandler"; 4 | 5 | export const edit: CommandInt = { 6 | data: new SlashCommandBuilder() 7 | .setName("edit") 8 | .setDescription("Edit a previous 100 days of code post.") 9 | .addStringOption((option) => 10 | option 11 | .setName("embed-id") 12 | .setDescription("ID of the message to edit.") 13 | .setRequired(true) 14 | ) 15 | .addStringOption((option) => 16 | option 17 | .setName("message") 18 | .setDescription("The message to go in your 100 Days of Code update.") 19 | .setRequired(true) 20 | ) as SlashCommandBuilder, 21 | run: async (interaction) => { 22 | try { 23 | await interaction.deferReply(); 24 | const { channel, user } = interaction; 25 | const targetId = interaction.options.getString("embed-id"); 26 | const text = interaction.options.getString("message"); 27 | 28 | if (!text || !targetId || !channel) { 29 | await interaction.editReply({ 30 | content: "Missing required parameters...", 31 | }); 32 | return; 33 | } 34 | 35 | const targetMessage = await channel.messages.fetch(targetId); 36 | 37 | if (!targetMessage) { 38 | await interaction.editReply({ 39 | content: 40 | "That does not appear to be a valid message ID. Be sure that the message is in the same channel you are using this command.", 41 | }); 42 | return; 43 | } 44 | 45 | const targetEmbed = targetMessage.embeds[0]; 46 | 47 | if ( 48 | targetEmbed.author?.name !== 49 | user.username + "#" + user.discriminator 50 | ) { 51 | await interaction.editReply( 52 | "This does not appear to be your 100 Days of Code post. You cannot edit it." 53 | ); 54 | return; 55 | } 56 | 57 | targetEmbed.setDescription(text); 58 | 59 | await targetMessage.edit({ embeds: [targetEmbed] }); 60 | await interaction.editReply({ content: "Updated!" }); 61 | } catch (err) { 62 | errorHandler("edit command", err); 63 | } 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/commands/help.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { MessageEmbed } from "discord.js"; 3 | import { CommandInt } from "../interfaces/CommandInt"; 4 | import { errorHandler } from "../utils/errorHandler"; 5 | 6 | export const help: CommandInt = { 7 | data: new SlashCommandBuilder() 8 | .setName("help") 9 | .setDescription("Provides information on using this bot."), 10 | run: async (interaction) => { 11 | try { 12 | await interaction.deferReply(); 13 | const helpEmbed = new MessageEmbed(); 14 | helpEmbed.setTitle("100 Days of Code Bot!"); 15 | helpEmbed.setDescription( 16 | "This discord bot is designed to help you track and share your 100 Days of Code progress." 17 | ); 18 | helpEmbed.addField( 19 | "Create today's update", 20 | "Use the `/100` command to create your update for today. The `message` will be displayed in your embed." 21 | ); 22 | helpEmbed.addField( 23 | "Edit today's update", 24 | "Do you see a typo in your embed? Right click it and copy the ID (you may need developer mode on for this), and use the `/edit` command to update that embed with a new message." 25 | ); 26 | helpEmbed.addField( 27 | "Show your progress", 28 | "To see your current progress in the challenge, and the day you last checked in, use `/view`." 29 | ); 30 | helpEmbed.setFooter(`Version ${process.env.npm_package_version}`); 31 | await interaction.editReply({ embeds: [helpEmbed] }); 32 | return; 33 | } catch (err) { 34 | errorHandler("help command", err); 35 | } 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/commands/oneHundred.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { MessageEmbed } from "discord.js"; 3 | import { CommandInt } from "../interfaces/CommandInt"; 4 | import { getCamperData } from "../modules/getCamperData"; 5 | import { updateCamperData } from "../modules/updateCamperData"; 6 | import { errorHandler } from "../utils/errorHandler"; 7 | 8 | export const oneHundred: CommandInt = { 9 | data: new SlashCommandBuilder() 10 | .setName("100") 11 | .setDescription("Check in for the 100 Days of Code challenge.") 12 | .addStringOption((option) => 13 | option 14 | .setName("message") 15 | .setDescription("The message to go in your 100 Days of Code update.") 16 | .setRequired(true) 17 | ) as SlashCommandBuilder, 18 | run: async (interaction) => { 19 | try { 20 | await interaction.deferReply(); 21 | const { user } = interaction; 22 | const text = interaction.options.getString("message"); 23 | 24 | if (!text) { 25 | await interaction.editReply({ 26 | content: "The message argument is required.", 27 | }); 28 | return; 29 | } 30 | const targetCamper = await getCamperData(user.id); 31 | 32 | if (!targetCamper) { 33 | await interaction.editReply({ 34 | content: 35 | "There is an error with the database lookup. Please try again later.", 36 | }); 37 | return; 38 | } 39 | 40 | const updatedCamper = await updateCamperData(targetCamper); 41 | 42 | if (!updatedCamper) { 43 | await interaction.editReply({ 44 | content: 45 | "There is an error with the database update. Please try again later.", 46 | }); 47 | return; 48 | } 49 | 50 | const oneHundredEmbed = new MessageEmbed(); 51 | oneHundredEmbed.setTitle("100 Days of Code"); 52 | oneHundredEmbed.setDescription(text); 53 | oneHundredEmbed.setAuthor( 54 | user.username + "#" + user.discriminator, 55 | user.displayAvatarURL() 56 | ); 57 | oneHundredEmbed.addField("Round", updatedCamper.round.toString(), true); 58 | oneHundredEmbed.addField("Day", updatedCamper.day.toString(), true); 59 | oneHundredEmbed.setFooter( 60 | "Day completed: " + 61 | new Date(updatedCamper.timestamp).toLocaleDateString() 62 | ); 63 | 64 | await interaction.editReply({ embeds: [oneHundredEmbed] }); 65 | } catch (err) { 66 | errorHandler("100 command", err); 67 | } 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /src/commands/reset.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { Message, MessageActionRow, MessageButton } from "discord.js"; 3 | import { CommandInt } from "../interfaces/CommandInt"; 4 | import { getCamperData } from "../modules/getCamperData"; 5 | import { errorHandler } from "../utils/errorHandler"; 6 | 7 | export const reset: CommandInt = { 8 | data: new SlashCommandBuilder() 9 | .setName("reset") 10 | .setDescription("Reset your 100 Days of Code progress."), 11 | run: async (interaction) => { 12 | try { 13 | await interaction.deferReply(); 14 | const { user } = interaction; 15 | const confirmButton = new MessageButton() 16 | .setCustomId("confirm") 17 | .setLabel("Confirm") 18 | .setEmoji("✅") 19 | .setStyle("PRIMARY"); 20 | const row = new MessageActionRow().addComponents([confirmButton]); 21 | const response = (await interaction.editReply({ 22 | content: 23 | "This will delete all of your stored 100 Days of Code progress. You will start at Round 1 Day 1. If you are sure this is what you want to do, click the button below.", 24 | components: [row], 25 | })) as Message; 26 | 27 | const collector = response.createMessageComponentCollector({ 28 | time: 30000, 29 | filter: (click) => click.user.id === user.id, 30 | max: 1, 31 | }); 32 | 33 | collector.on("collect", async (click) => { 34 | await click.deferUpdate(); 35 | const camperData = await getCamperData(user.id); 36 | 37 | if (!camperData) { 38 | await interaction.editReply({ 39 | content: "There was an error fetching your data.", 40 | }); 41 | return; 42 | } 43 | 44 | await camperData.delete(); 45 | 46 | await interaction.editReply({ 47 | content: "Your 100 Days of Code progress has been reset.", 48 | }); 49 | return; 50 | }); 51 | 52 | collector.on("end", async () => { 53 | const disabledButton = confirmButton.setDisabled(true); 54 | const row = new MessageActionRow().addComponents([disabledButton]); 55 | await interaction.editReply({ 56 | components: [row], 57 | }); 58 | }); 59 | } catch (err) { 60 | errorHandler("reset command", err); 61 | } 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/commands/view.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder } from "@discordjs/builders"; 2 | import { MessageEmbed } from "discord.js"; 3 | import { CommandInt } from "../interfaces/CommandInt"; 4 | import { getCamperData } from "../modules/getCamperData"; 5 | import { errorHandler } from "../utils/errorHandler"; 6 | 7 | export const view: CommandInt = { 8 | data: new SlashCommandBuilder() 9 | .setName("view") 10 | .setDescription("Shows your latest 100 days of code check in."), 11 | run: async (interaction) => { 12 | try { 13 | await interaction.deferReply(); 14 | const { user } = interaction; 15 | const targetCamper = await getCamperData(user.id); 16 | 17 | if (!targetCamper) { 18 | await interaction.editReply({ 19 | content: 20 | "There was an error with the database lookup. Please try again later.", 21 | }); 22 | return; 23 | } 24 | 25 | if (!targetCamper.day) { 26 | await interaction.editReply({ 27 | content: 28 | "It looks like you have not started the 100 Days of Code challenge yet. Use `/100` and add your message to report your first day!", 29 | }); 30 | return; 31 | } 32 | 33 | const camperEmbed = new MessageEmbed(); 34 | camperEmbed.setTitle("My 100DoC Progress"); 35 | camperEmbed.setDescription( 36 | `Here is my 100 Days of Code progress. I last reported an update on ${new Date( 37 | targetCamper.timestamp 38 | ).toLocaleDateString()}.` 39 | ); 40 | camperEmbed.addField("Round", targetCamper.round.toString(), true); 41 | camperEmbed.addField("Day", targetCamper.day.toString(), true); 42 | camperEmbed.setAuthor( 43 | user.username + "#" + user.discriminator, 44 | user.displayAvatarURL() 45 | ); 46 | 47 | await interaction.editReply({ embeds: [camperEmbed] }); 48 | } catch (err) { 49 | errorHandler("view command", err); 50 | } 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /src/config/IntentOptions.ts: -------------------------------------------------------------------------------- 1 | import { IntentsString } from "discord.js"; 2 | 3 | export const IntentOptions: IntentsString[] = ["GUILDS", "GUILD_MESSAGES"]; 4 | -------------------------------------------------------------------------------- /src/database/connectDatabase.ts: -------------------------------------------------------------------------------- 1 | import { connect } from "mongoose"; 2 | import { errorHandler } from "../utils/errorHandler"; 3 | import { logHandler } from "../utils/logHandler"; 4 | 5 | export const connectDatabase = async (): Promise => { 6 | try { 7 | await connect(process.env.MONGO_URI as string); 8 | 9 | logHandler.log("info", "Database connection successful."); 10 | } catch (error) { 11 | errorHandler("database connection", error); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/database/models/CamperModel.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | 3 | export interface CamperInt extends Document { 4 | discordId: string; 5 | round: number; 6 | day: number; 7 | timestamp: number; 8 | } 9 | 10 | export const Camper = new Schema({ 11 | discordId: String, 12 | round: { 13 | type: Number, 14 | default: 1, 15 | }, 16 | day: { 17 | type: Number, 18 | default: 0, 19 | }, 20 | timestamp: { 21 | type: Number, 22 | default: Date.now(), 23 | }, 24 | }); 25 | 26 | export default model("Camper", Camper); 27 | -------------------------------------------------------------------------------- /src/events/onInteraction.ts: -------------------------------------------------------------------------------- 1 | import { Interaction } from "discord.js"; 2 | import { CommandList } from "../commands/_CommandList"; 3 | import { errorHandler } from "../utils/errorHandler"; 4 | 5 | export const onInteraction = async ( 6 | interaction: Interaction 7 | ): Promise => { 8 | try { 9 | if (interaction.isCommand()) { 10 | for (const Command of CommandList) { 11 | if (interaction.commandName === Command.data.name) { 12 | await Command.run(interaction); 13 | break; 14 | } 15 | } 16 | } 17 | } catch (err) { 18 | errorHandler("onInteraction event", err); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/events/onReady.ts: -------------------------------------------------------------------------------- 1 | import { errorHandler } from "../utils/errorHandler"; 2 | import { logHandler } from "../utils/logHandler"; 3 | import { REST } from "@discordjs/rest"; 4 | import { APIApplicationCommandOption, Routes } from "discord-api-types/v9"; 5 | import { CommandList } from "../commands/_CommandList"; 6 | import { Client } from "discord.js"; 7 | 8 | export const onReady = async (BOT: Client): Promise => { 9 | try { 10 | const rest = new REST({ version: "9" }).setToken( 11 | process.env.BOT_TOKEN as string 12 | ); 13 | 14 | const commandData: { 15 | name: string; 16 | description?: string; 17 | type?: number; 18 | options?: APIApplicationCommandOption[]; 19 | }[] = []; 20 | 21 | CommandList.forEach((command) => 22 | commandData.push( 23 | command.data.toJSON() as { 24 | name: string; 25 | description?: string; 26 | type?: number; 27 | options?: APIApplicationCommandOption[]; 28 | } 29 | ) 30 | ); 31 | await rest.put( 32 | Routes.applicationGuildCommands( 33 | BOT.user?.id || "missing token", 34 | process.env.GUILD_ID as string 35 | ), 36 | { body: commandData } 37 | ); 38 | logHandler.log("info", "Bot has connected to Discord!"); 39 | } catch (err) { 40 | errorHandler("onReady event", err); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import { RewriteFrames } from "@sentry/integrations"; 3 | import { validateEnv } from "./utils/validateEnv"; 4 | import { Client } from "discord.js"; 5 | import { connectDatabase } from "./database/connectDatabase"; 6 | import { onReady } from "./events/onReady"; 7 | import { onInteraction } from "./events/onInteraction"; 8 | import { IntentOptions } from "./config/IntentOptions"; 9 | 10 | (async () => { 11 | validateEnv(); 12 | 13 | Sentry.init({ 14 | dsn: process.env.SENTRY_DSN, 15 | tracesSampleRate: 1.0, 16 | integrations: [ 17 | new RewriteFrames({ 18 | root: global.__dirname, 19 | }), 20 | ], 21 | }); 22 | 23 | const BOT = new Client({ intents: IntentOptions }); 24 | 25 | BOT.on("ready", async () => await onReady(BOT)); 26 | 27 | BOT.on( 28 | "interactionCreate", 29 | async (interaction) => await onInteraction(interaction) 30 | ); 31 | 32 | await connectDatabase(); 33 | 34 | await BOT.login(process.env.BOT_TOKEN as string); 35 | })(); 36 | -------------------------------------------------------------------------------- /src/interfaces/CommandInt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SlashCommandBuilder, 3 | SlashCommandSubcommandsOnlyBuilder, 4 | } from "@discordjs/builders"; 5 | import { CommandInteraction } from "discord.js"; 6 | 7 | export interface CommandInt { 8 | data: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder; 9 | run: (interaction: CommandInteraction) => Promise; 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/getCamperData.ts: -------------------------------------------------------------------------------- 1 | import { errorHandler } from "../utils/errorHandler"; 2 | import CamperModel, { CamperInt } from "../database/models/CamperModel"; 3 | 4 | export const getCamperData = async ( 5 | id: string 6 | ): Promise => { 7 | try { 8 | const targetCamperData = await CamperModel.findOne({ discordId: id }); 9 | 10 | if (targetCamperData) { 11 | return targetCamperData; 12 | } 13 | 14 | const newCamperData = await CamperModel.create({ 15 | discordId: id, 16 | round: 1, 17 | day: 0, 18 | date: Date.now(), 19 | }); 20 | 21 | return newCamperData; 22 | } catch (error) { 23 | errorHandler("getCamperData module", error); 24 | return; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/modules/updateCamperData.ts: -------------------------------------------------------------------------------- 1 | import { CamperInt } from "../database/models/CamperModel"; 2 | import { errorHandler } from "../utils/errorHandler"; 3 | 4 | export const updateCamperData = async ( 5 | Camper: CamperInt 6 | ): Promise => { 7 | try { 8 | Camper.day++; 9 | if (Camper.day > 100) { 10 | Camper.day = 1; 11 | Camper.round++; 12 | } 13 | Camper.timestamp = Date.now(); 14 | await Camper.save(); 15 | return Camper; 16 | } catch (err) { 17 | errorHandler("updateCamperData module", err); 18 | return; 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import { logHandler } from "./logHandler"; 3 | export const errorHandler = (context: string, err: unknown): void => { 4 | const error = err as Error; 5 | logHandler.log("error", `There was an error in the ${context}:`); 6 | logHandler.log( 7 | "error", 8 | JSON.stringify({ errorMessage: error.message, errorStack: error.stack }) 9 | ); 10 | Sentry.captureException(error); 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/logHandler.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports, config } from "winston"; 2 | const { combine, timestamp, colorize, printf } = format; 3 | 4 | export const logHandler = createLogger({ 5 | levels: config.npm.levels, 6 | level: "silly", 7 | transports: [new transports.Console()], 8 | format: combine( 9 | timestamp({ 10 | format: "YYYY-MM-DD HH:mm:ss", 11 | }), 12 | colorize(), 13 | printf((info) => `${info.level}: ${[info.timestamp]}: ${info.message}`) 14 | ), 15 | exitOnError: false, 16 | }); 17 | -------------------------------------------------------------------------------- /src/utils/validateEnv.ts: -------------------------------------------------------------------------------- 1 | import { logHandler } from "./logHandler"; 2 | 3 | export const validateEnv = (): void => { 4 | if (!process.env.BOT_TOKEN) { 5 | logHandler.log("warn", "Missing Discord bot token."); 6 | process.exit(1); 7 | } 8 | 9 | if (!process.env.MONGO_URI) { 10 | logHandler.log("warn", "Missing MongoDB connection."); 11 | process.exit(1); 12 | } 13 | 14 | if (!process.env.SENTRY_DSN) { 15 | logHandler.log("warn", "Missing Sentry DSN."); 16 | process.exit(1); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "CommonJS", 5 | "rootDir": "./src", 6 | "outDir": "./prod", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true 12 | } 13 | } 14 | --------------------------------------------------------------------------------