├── logs.bat ├── stop.bat ├── restart.bat ├── src ├── listeners │ ├── interaction │ │ ├── pingInteraction.js │ │ ├── modalSubmitInteraction.js │ │ ├── messageComponentInteraction.js │ │ ├── interactionCreate.js │ │ ├── autoCompleteInteraction.js │ │ └── applicationCommandInteraction.js │ ├── guild │ │ ├── guildDelete.js │ │ └── guildCreate.js │ └── client │ │ └── ready.js ├── handlers │ ├── permissions.js │ └── commands.js ├── modules │ ├── cftClients.js │ └── autoLeaderboard.js ├── index.js ├── commands │ ├── system │ │ └── ping.js │ └── dayz │ │ ├── stats.js │ │ └── leaderboard.js └── util.js ├── .gitattributes ├── config ├── emojis.json ├── colors.json ├── blacklist.json ├── config.json ├── servers.example.json └── .env.example ├── start.bat ├── LICENSE ├── package.json ├── .gitignore ├── .github └── workflows │ └── codeql.yml ├── README.md └── .eslintrc.json /logs.bat: -------------------------------------------------------------------------------- 1 | START npm run logs -------------------------------------------------------------------------------- /stop.bat: -------------------------------------------------------------------------------- 1 | START npm run stop -------------------------------------------------------------------------------- /restart.bat: -------------------------------------------------------------------------------- 1 | START npm run stop 2 | START npm run start -------------------------------------------------------------------------------- /src/listeners/interaction/pingInteraction.js: -------------------------------------------------------------------------------- 1 | module.exports = (client, interaction) => { 2 | // ... 3 | }; 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf 4 | -------------------------------------------------------------------------------- /src/listeners/interaction/modalSubmitInteraction.js: -------------------------------------------------------------------------------- 1 | module.exports = (client, interaction) => { 2 | // ... 3 | }; 4 | -------------------------------------------------------------------------------- /src/listeners/interaction/messageComponentInteraction.js: -------------------------------------------------------------------------------- 1 | module.exports = (client, interaction) => { 2 | // ... 3 | }; 4 | -------------------------------------------------------------------------------- /config/emojis.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": "☑️", 3 | "error": "❌", 4 | "wait": "⏳", 5 | "info": "ℹ", 6 | "bulletPoint": "•" 7 | } -------------------------------------------------------------------------------- /config/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "#ffffff", 3 | "invisible": "#36393f", 4 | "success": "#00b105", 5 | "error": "#d91d1d" 6 | } 7 | -------------------------------------------------------------------------------- /config/blacklist.json: -------------------------------------------------------------------------------- 1 | [ 2 | "6284d7a30873a63f22e34f34", 3 | "CFTools IDs to exclude from the blacklist", 4 | "always use commas (,) at the end of the line EXCEPT THE LAST ONE > like so" 5 | ] -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "statOptions": [ 3 | "OVERALL", 4 | "KILLS", 5 | "KILL_DEATH_RATIO", 6 | "LONGEST_KILL", 7 | "PLAYTIME", 8 | "LONGEST_SHOT", 9 | "DEATHS", 10 | "SUICIDES" 11 | ], 12 | "intents": [ "GUILDS" ], 13 | "permissions": { 14 | "ownerId": "290182686365188096", 15 | "developers": [] 16 | } 17 | } -------------------------------------------------------------------------------- /src/listeners/guild/guildDelete.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | 4 | module.exports = (client, guild) => { 5 | // Always check to make sure the guild is available 6 | if (!guild?.available) return; 7 | // Logging the event to our console 8 | logger.success(`${chalk.redBright('[GUILD REMOVE]')} ${guild.name} has removed the bot!`); 9 | }; 10 | -------------------------------------------------------------------------------- /src/listeners/guild/guildCreate.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | 4 | module.exports = (client, guild) => { 5 | // Always check to make sure the guild is available 6 | if (!guild?.available) return; 7 | // Logging the event to our console 8 | logger.success(`${chalk.greenBright('[GUILD JOIN]')} ${guild.name} has added the bot! Members: ${guild.memberCount}`); 9 | }; 10 | -------------------------------------------------------------------------------- /start.bat: -------------------------------------------------------------------------------- 1 | rem Get script dir, with '\' on the end 2 | set SCRIPT_DIR=%~dp0 3 | rem Remove trailing slash 4 | set SCRIPT_DIR=%SCRIPT_DIR:~0,-1% 5 | 6 | rem Navigate to our directory 7 | cd %SCRIPT_DIR% 8 | 9 | rem Installing our dependencies 10 | call npm install 11 | call npm audit fix 12 | 13 | rem Installing Process Manager 2 globally 14 | call npm install pm2 -g 15 | 16 | rem Calling our start script 17 | START npm run start 18 | 19 | rem Tail the log file 20 | call npm run logs 21 | 22 | -------------------------------------------------------------------------------- /config/servers.example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Name to display - server without automatic leaderboard", 4 | "CFTOOLS_SERVER_API_ID": "Your secret server API id" 5 | }, 6 | { 7 | "name": "Name to display - server WITH automatic leaderboard", 8 | "CFTOOLS_SERVER_API_ID": "Your secret server API id", 9 | "AUTO_LB_ENABLED": true, 10 | "AUTO_LB_CHANNEL_ID": "806479539110674472", 11 | "AUTO_LB_INTERVAL_IN_MINUTES": 60, 12 | "AUTO_LB_REMOVE_OLD_MESSAGES": true, 13 | "AUTO_LB_PLAYER_LIMIT": 25 14 | } 15 | ] -------------------------------------------------------------------------------- /config/.env.example: -------------------------------------------------------------------------------- 1 | # Required variables 2 | NODE_ENV=production 3 | DISCORD_BOT_TOKEN= 4 | CLIENT_ID= 5 | TEST_SERVER_GUILD_ID= 6 | 7 | # CFTools Cloud API & GameLabs 8 | CFTOOLS_API_APPLICATION_ID= 9 | CFTOOLS_API_SECRET= 10 | # Amount of players to display on the leaderboard. 10 min, 25 max 11 | CFTOOLS_API_PLAYER_DATA_COUNT=15 12 | 13 | # ------------------------- OPTIONAL ------------------------- # 14 | 15 | # (OPTIONAL) API port to check if process is alive 16 | # See the express code in src/index.js for more information 17 | PORT= 18 | 19 | # Debug variables 20 | DEBUG_ENABLED=true 21 | DEBUG_SLASH_COMMAND_API_DATA=true 22 | DEBUG_INTERACTIONS=false 23 | DEBUG_AUTOCOMPLETE_RESPONSE_TIME=true 24 | DEBUG_LEADERBOARD_API_DATA=false 25 | DEBUG_STAT_COMMAND_DATA=false 26 | 27 | # Interaction command API data 28 | REFRESH_SLASH_COMMAND_API_DATA=true 29 | CLEAR_SLASH_COMMAND_API_DATA=false -------------------------------------------------------------------------------- /src/listeners/client/ready.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | const { autoLbCycle } = require('../../modules/autoLeaderboard'); 4 | 5 | module.exports = (client) => { 6 | // Logging our process uptime to the developer 7 | const upTimeStr = chalk.yellow(`${Math.floor(process.uptime()) || 1} second(s)`); 8 | logger.success(`Client logged in as ${ 9 | chalk.cyanBright(client.user.username) 10 | }${ 11 | chalk.grey(`#${client.user.discriminator}`) 12 | } after ${upTimeStr}`); 13 | 14 | // Initialing our automatic leaderboard poster module 15 | autoLbCycle(client); 16 | 17 | // Calculating the membercount 18 | const memberCount = client.guilds.cache.reduce( 19 | (previousValue, currentValue) => 20 | previousValue += currentValue.memberCount, 0 21 | ).toLocaleString('en-US'); 22 | 23 | // Getting the server count 24 | const serverCount = (client.guilds.cache.size).toLocaleString('en-US'); 25 | 26 | // Logging counts to developers 27 | logger.info(`Ready to serve ${memberCount} members across ${serverCount} servers!`); 28 | }; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Richard Hillebrand 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dayz-leaderboard-bot", 3 | "description": "A DayZ bot written in Javascript to display your leaderboard using the CFTools Cloud API.", 4 | "version": "1.1.0", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "nodemon -w . -w config/.env run node --trace-warnings .", 9 | "start": "pm2 start src/index.js --name=dayz-leaderboard-bot", 10 | "stop": "pm2 stop dayz-leaderboard-bot", 11 | "remove": "pm2 stop dayz-leaderboard-bot && pm2 delete dayz-leaderboard-bot && pm2 reset dayz-leaderboard-bot", 12 | "logs": "pm2 logs --lines 300 dayz-leaderboard-bot", 13 | "logsError": "pm2 logs --err --lines 300 dayz-leaderboard-bot", 14 | "lint": "eslint src", 15 | "linter": "eslint src --fix", 16 | "writeLinter": "eslint src --output-file linter-output.txt" 17 | }, 18 | "engines": { 19 | "node": ">=16.10.0" 20 | }, 21 | "dependencies": { 22 | "@discordjs/rest": "^0.4.1", 23 | "@mirasaki/logger": "^1.0.5", 24 | "cftools-sdk": "^1.8.0", 25 | "common-tags": "^1.8.2", 26 | "cross-fetch": "^3.1.5", 27 | "discord.js": "^14.1.2", 28 | "dotenv": "^16.0.0", 29 | "express": "^4.18.1" 30 | }, 31 | "devDependencies": { 32 | "eslint": "^8.15.0", 33 | "nodemon": "^2.0.16" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/Mirasaki/dayz-leaderboard-bot.git" 38 | }, 39 | "keywords": [ 40 | "bot-template", 41 | "template", 42 | "discord", 43 | "discord-bot", 44 | "discord-bot-template" 45 | ], 46 | "author": "Richard Hillebrand (Mirasaki)", 47 | "license": "MIT", 48 | "bugs": { 49 | "url": "https://github.com/Mirasaki/dayz-leaderboard-bot/issues" 50 | }, 51 | "homepage": "https://github.com/Mirasaki/dayz-leaderboard-bot#readme" 52 | } 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Untrack developer commands 10 | src/commands/developer/deploy.js 11 | src/commands/developer/test.js 12 | 13 | # Config/Credentials 14 | config/servers.json 15 | 16 | # Eslint output 17 | linter-output.txt 18 | 19 | # Diagnostic reports (https://nodejs.org/api/report.html) 20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | *.lcov 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | bower_components 43 | 44 | # node-waf configuration 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directories 51 | node_modules/ 52 | jspm_packages/ 53 | 54 | # TypeScript v1 declaration files 55 | typings/ 56 | 57 | # TypeScript cache 58 | *.tsbuildinfo 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variables file 82 | .env 83 | .env.test 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | 88 | # Next.js build output 89 | .next 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # Serverless directories 105 | .serverless/ 106 | 107 | # FuseBox cache 108 | .fusebox/ 109 | 110 | # DynamoDB Local files 111 | .dynamodb/ 112 | 113 | # TernJS port file 114 | .tern-port 115 | -------------------------------------------------------------------------------- /src/listeners/interaction/interactionCreate.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { InteractionType } = require('discord.js'); 3 | const { getPermissionLevel } = require('../../handlers/permissions'); 4 | 5 | module.exports = (client, interaction) => { 6 | // Definitions 7 | const { emojis } = client.container; 8 | const { member, channel, user } = interaction; 9 | 10 | // Check for DM interactions 11 | // Planning on adding support later down the road 12 | if (!interaction.inGuild()) { 13 | if (interaction.isRepliable()) { 14 | interaction.reply({ 15 | content: `${emojis.error} ${member || user}, I don't currently support DM interactions. Please try again in a server.` 16 | }); 17 | } 18 | return; 19 | } 20 | 21 | // Check for outages 22 | if (interaction.guild?.available !== true) { 23 | const { guild } = interaction; 24 | logger.debug(`Interaction returned, server unavailable.\nServer: ${guild.name} (${guild.id})`); 25 | return; 26 | } 27 | 28 | // Check for missing 'bot' scope 29 | if (!interaction.guild) { 30 | logger.debug('Interaction returned, missing \'bot\' scope / missing guild object in interaction.'); 31 | return; 32 | } 33 | 34 | // Setting the permLevel on the member object 35 | const permLevel = getPermissionLevel(member, channel); 36 | interaction.member.permLevel = permLevel; 37 | 38 | // Switch case for our interaction.type 39 | switch (interaction.type) { 40 | // Ping Interaction 41 | case InteractionType.Ping: { 42 | client.emit('pingInteraction', (interaction)); 43 | break; 44 | } 45 | // ApplicationCommand Interaction 46 | case InteractionType.ApplicationCommand: { 47 | client.emit('applicationCommandInteraction', (interaction)); 48 | break; 49 | } 50 | // MessageComponent Interaction 51 | case InteractionType.MessageComponent: { 52 | client.emit('messageComponentInteraction', (interaction)); 53 | break; 54 | } 55 | // ApplicationCommandAutocomplete Interaction 56 | case InteractionType.ApplicationCommandAutocomplete: { 57 | client.emit('autoCompleteInteraction', (interaction)); 58 | break; 59 | } 60 | // ModalSubmit Interaction 61 | case InteractionType.ModalSubmit: { 62 | client.emit('modalSubmitInteraction', (interaction)); 63 | break; 64 | } 65 | // Unknown interaction type - log to the console 66 | default: { 67 | logger.syserr(`Unknown interaction received: Type ${interaction.type}`); 68 | break; 69 | } 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /src/handlers/permissions.js: -------------------------------------------------------------------------------- 1 | const { PermissionsBitField } = require('discord.js'); 2 | const config = require('../../config/config.json'); 3 | 4 | // Our ordered permission level configuration 5 | const permConfig = [ 6 | { 7 | name: 'User', 8 | level: 0, 9 | hasLevel: () => true 10 | }, 11 | 12 | { 13 | name: 'Moderator', 14 | level: 1, 15 | hasLevel: (member, channel) => hasChannelPerms( 16 | member.id, channel, ['KickMembers', 'BanMembers'] 17 | ) === true 18 | }, 19 | 20 | { 21 | name: 'Administrator', 22 | level: 2, 23 | hasLevel: (member, channel) => hasChannelPerms(member.id, channel, ['Administrator']) === true 24 | }, 25 | 26 | { 27 | name: 'Server Owner', 28 | level: 3, 29 | hasLevel: (member, channel) => { 30 | // Shorthand 31 | // hasLevel: (member, channel) => (channel.guild?.ownerId === member.user?.id) 32 | // COULD result in (undefined === undefined) 33 | if (channel.guild && channel.guild.ownerId) { 34 | return (channel.guild.ownerId === member.user?.id); 35 | } 36 | return false; 37 | } 38 | }, 39 | 40 | { 41 | name: 'Developer', 42 | level: 4, 43 | hasLevel: (member) => config.permissions.developers.includes(member.user.id) 44 | }, 45 | 46 | { 47 | name: 'Bot Owner', 48 | level: 5, 49 | hasLevel: (member) => config.permissions.ownerId === member.user.id 50 | } 51 | ].reverse(); 52 | 53 | // Creating a permission level map/list 54 | const permLevelMap = { ...permConfig.map(({ name }) => name) }; 55 | 56 | // Get someone's permLvl, returns Integer 57 | const getPermissionLevel = (member, channel) => { 58 | for (const currLvl of permConfig) { 59 | if (currLvl.hasLevel(member, channel)) { 60 | return currLvl.level; 61 | } 62 | } 63 | }; 64 | 65 | // Utility function for checking if provided permissions are actually valid 66 | const getInvalidPerms = (permArr) => 67 | permArr.filter((perm) => typeof PermissionsBitField.Flags[perm] === 'undefined'); 68 | 69 | const hasChannelPerms = (userId, channel, permArr) => { 70 | // Convert string to array 71 | if (typeof permArr === 'string') permArr = [permArr]; 72 | 73 | // Making sure all our perms are valid 74 | const invalidPerms = getInvalidPerms(permArr); 75 | if (invalidPerms.length >= 1) { 76 | throw new Error(`Invalid Discord permissions were provided: ${invalidPerms.join(', ')}`); 77 | } 78 | 79 | // Return the entire array if no permissions are found 80 | if (!channel.permissionsFor(userId)) return permArr; 81 | 82 | // Filter missing permissions 83 | const missingPerms = permArr.filter((perm) => !channel.permissionsFor(userId).has(PermissionsBitField.Flags[perm])); 84 | return missingPerms.length >= 1 ? missingPerms : true; 85 | }; 86 | 87 | module.exports = { 88 | permConfig, 89 | permLevelMap, 90 | getPermissionLevel, 91 | getInvalidPerms, 92 | hasChannelPerms 93 | }; 94 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '41 4 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /src/modules/cftClients.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const cftSDK = require('cftools-sdk'); 3 | const fetch = require('cross-fetch'); 4 | 5 | // Initializing our clients object 6 | const clients = {}; 7 | 8 | // Destructure our environmental variables 9 | const { 10 | CFTOOLS_API_SECRET, 11 | CFTOOLS_API_APPLICATION_ID 12 | } = process.env; 13 | 14 | // Getting our servers config 15 | const serverConfig = require('../../config/servers.json') 16 | .filter( 17 | ({ CFTOOLS_SERVER_API_ID, name }) => 18 | name !== '' 19 | && CFTOOLS_SERVER_API_ID !== '' 20 | ); 21 | 22 | // Creating a unique client for every entry 23 | for (const { CFTOOLS_SERVER_API_ID, name } of serverConfig) { 24 | clients[name] = new cftSDK.CFToolsClientBuilder() 25 | .withCache() 26 | .withServerApiId(CFTOOLS_SERVER_API_ID) 27 | .withCredentials(CFTOOLS_API_APPLICATION_ID, CFTOOLS_API_SECRET) 28 | .build(); 29 | } 30 | 31 | // export our CFTools clients as unnamed default 32 | module.exports = clients; 33 | 34 | // Get API token, valid for 24 hours, don't export function 35 | const getAPIToken = async () => { 36 | // Getting our token 37 | let token = await fetch( 38 | 'https://data.cftools.cloud/v1/auth/register', 39 | { 40 | method: 'POST', 41 | body: JSON.stringify({ 42 | 'application_id': CFTOOLS_API_APPLICATION_ID, 43 | secret: CFTOOLS_API_SECRET 44 | }), 45 | headers: { 'Content-Type': 'application/json' } 46 | } 47 | ); 48 | token = (await token.json()).token; 49 | return token; 50 | }; 51 | 52 | let CFTOOLS_API_TOKEN; 53 | const tokenExpirationMS = 1000 * 60 * 60 * 23; 54 | module.exports.getAPIToken = async () => { 55 | if (!CFTOOLS_API_TOKEN) { 56 | CFTOOLS_API_TOKEN = await getAPIToken(); 57 | // Update our token every 23 hours 58 | setInterval(async () => { 59 | CFTOOLS_API_TOKEN = await getAPIToken(); 60 | }, tokenExpirationMS); 61 | } 62 | return CFTOOLS_API_TOKEN; 63 | }; 64 | 65 | const fetchPlayerDetails = async (cftoolsId, CFTOOLS_SERVER_API_ID = null) => { 66 | let data; 67 | try { 68 | data = await fetch( 69 | `https://data.cftools.cloud/v1/server/${CFTOOLS_SERVER_API_ID}/player?cftools_id=${cftoolsId}`, 70 | { 71 | method: 'GET', 72 | headers: { Authorization: `Bearer ${await getAPIToken()}` } 73 | } 74 | ); 75 | data = (await data.json()); 76 | return data; 77 | } catch (err) { 78 | logger.syserr('Error encounter fetching player information'); 79 | logger.printErr(err); 80 | return err; 81 | } 82 | }; 83 | module.exports.fetchPlayerDetails = fetchPlayerDetails; 84 | 85 | const getCftoolsId = async (id) => { 86 | let data; 87 | try { 88 | data = await fetch( 89 | `https://data.cftools.cloud/v1/users/lookup?identifier=${id}`, 90 | { headers: { Authorization: `Bearer ${await getAPIToken()}` } } 91 | ); 92 | data = (await data.json()); 93 | return 'cftools_id' in data ? data.cftools_id : undefined; 94 | } catch (err) { 95 | logger.syserr('Error encounter fetching cftools id'); 96 | logger.printErr(err); 97 | return err; 98 | } 99 | }; 100 | module.exports.getCftoolsId = getCftoolsId; 101 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Importing from packages 2 | require('dotenv').config({ path: 'config/.env' }); 3 | const logger = require('@mirasaki/logger'); 4 | const chalk = require('chalk'); 5 | const { Client, GatewayIntentBits, Collection, ActivityType } = require('discord.js'); 6 | const express = require('express'); 7 | 8 | // Local imports 9 | const pkg = require('../package'); 10 | const config = require('../config/config'); 11 | const emojis = require('../config/emojis'); 12 | const colors = require('../config/colors'); 13 | const { clearSlashCommandData, refreshSlashCommandData, bindCommandsToClient } = require('./handlers/commands'); 14 | const { titleCase, getFiles } = require('./util'); 15 | const path = require('path'); 16 | 17 | // Ping server, check if bot is online and responsive 18 | const { PORT } = process.env; 19 | if (PORT) { 20 | const app = express(); 21 | app.get('/', (req, res) => res.sendStatus(200)); 22 | app.listen(PORT, () => logger.info(`Listening on port ${PORT}...`)); 23 | } 24 | 25 | // Clear the console in non-production modes & printing vanity 26 | process.env.NODE_ENV !== 'production' && console.clear(); 27 | const packageIdentifierStr = `${pkg.name}@${pkg.version}`; 28 | logger.info(`${chalk.greenBright.underline(packageIdentifierStr)} by ${chalk.cyanBright.bold(pkg.author)}`); 29 | 30 | // Initializing/declaring our variables 31 | const initTimerStart = process.hrtime(); 32 | const intents = config.intents.map((intent) => GatewayIntentBits[titleCase(intent)]); 33 | const client = new Client({ 34 | intents: intents, 35 | presence: { 36 | status: 'online', 37 | activities: [ 38 | { 39 | name: '/leaderboard', 40 | type: ActivityType.Listening 41 | } 42 | ] 43 | } 44 | }); 45 | 46 | // Destructuring from env 47 | const { 48 | DISCORD_BOT_TOKEN, 49 | DEBUG_ENABLED 50 | } = process.env; 51 | 52 | (async () => { 53 | // Containering?=) all our client extensions 54 | client.container = { 55 | commands: new Collection(), 56 | config, 57 | emojis, 58 | colors 59 | }; 60 | 61 | // Calling required functions 62 | bindCommandsToClient(client); 63 | 64 | // Clear only executes if enabled in .env 65 | clearSlashCommandData(); 66 | 67 | // Refresh InteractionCommand data if requested 68 | refreshSlashCommandData(client); 69 | 70 | // Registering our listeners 71 | const eventFiles = getFiles('src/listeners', '.js'); 72 | const eventNames = eventFiles.map((filePath) => filePath.slice( 73 | filePath.lastIndexOf(path.sep) + 1, 74 | filePath.lastIndexOf('.') 75 | )); 76 | 77 | // Debug logging 78 | if (DEBUG_ENABLED === 'true') { 79 | logger.debug(`Registering ${eventFiles.length} listeners: ${eventNames.map((name) => chalk.whiteBright(name)).join(', ')}`); 80 | } 81 | 82 | for (const filePath of eventFiles) { 83 | const eventName = filePath.slice( 84 | filePath.lastIndexOf(path.sep) + 1, 85 | filePath.lastIndexOf('.') 86 | ); 87 | eventNames.push(eventName); 88 | 89 | // Binding our event to the client 90 | const eventFile = require(filePath); 91 | client.on(eventName, (...received) => eventFile(client, ...received)); 92 | } 93 | 94 | if (DEBUG_ENABLED === 'true') { 95 | logger.debug('Finished registering listeners.'); 96 | } 97 | 98 | // Execution time logging 99 | logger.success(`Finished initializing after ${logger.getExecutionTime(initTimerStart)}`); 100 | 101 | // Logging in to our client 102 | client.login(DISCORD_BOT_TOKEN); 103 | })(); 104 | -------------------------------------------------------------------------------- /src/commands/system/ping.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags'); 2 | const { version } = require('discord.js'); 3 | const { colorResolver } = require('../../util'); 4 | 5 | const discordVersion = version.indexOf('dev') < 0 ? version : version.slice(0, version.indexOf('dev') + 3); 6 | const discordVersionDocLink = `https://discord.js.org/#/docs/discord.js/v${discordVersion.split('.')[0]}/general/welcome`; 7 | const nodeVersionDocLink = `https://nodejs.org/docs/latest-${process.version.split('.')[0]}.x/api/#`; 8 | 9 | module.exports = { 10 | data: { 11 | name: 'ping', 12 | description: 'Display latency & bot stats' 13 | }, 14 | 15 | config: { 16 | globalCmd: true 17 | }, 18 | 19 | run: async ({ client, interaction }) => { 20 | // Calculating our API latency 21 | const latency = Math.round(client.ws.ping); 22 | const sent = await interaction.reply({ 23 | content: 'Pinging...', 24 | fetchReply: true 25 | }); 26 | const fcLatency = sent.createdTimestamp - interaction.createdTimestamp; 27 | 28 | // Utility function for getting appropriate status emojis 29 | const getMsEmoji = (ms) => { 30 | let emoji = undefined; 31 | for (const [key, value] of Object.entries({ 32 | 250: '🟢', 33 | 500: '🟡', 34 | 1000: '🟠' 35 | })) if (ms <= key) { 36 | emoji = value; 37 | break; 38 | } 39 | return (emoji ??= '🔴'); 40 | }; 41 | 42 | // Replying to the interaction with our embed data 43 | interaction.editReply({ 44 | content: '\u200b', 45 | embeds: [ 46 | { 47 | color: colorResolver(), 48 | author: { 49 | name: `${client.user.tag}`, 50 | iconURL: client.user.displayAvatarURL() 51 | }, 52 | fields: [ 53 | { 54 | name: 'Latency', 55 | value: stripIndents` 56 | ${getMsEmoji(latency)} **API Latency:** ${latency} ms 57 | ${getMsEmoji(fcLatency)} **Full Circle Latency:** ${fcLatency} ms 58 | `, 59 | inline: true 60 | }, 61 | { 62 | name: 'Memory', 63 | value: stripIndents` 64 | 💾 **Memory Usage:** ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB 65 | ♻️ **Cache Size:** ${(process.memoryUsage().external / 1024 / 1024).toFixed(2)} MB 66 | `, 67 | inline: true 68 | }, 69 | { 70 | name: 'Uptime', 71 | value: stripIndents`**📊 I've been online for ${parseInt((client.uptime / (1000 * 60 * 60 * 24)) % 60, 10)} days, ${parseInt((client.uptime / (1000 * 60 * 60)) % 24, 10)} hours, ${parseInt((client.uptime / (1000 * 60)) % 60, 10)} minutes and ${parseInt((client.uptime / 1000) % 60, 10)}.${parseInt((client.uptime % 1000) / 100, 10)} seconds!**`, 72 | inline: false 73 | }, 74 | { 75 | name: 'System', 76 | value: stripIndents` 77 | ⚙️ **Discord.js Version:** [v${discordVersion}](${discordVersionDocLink}) 78 | ⚙️ **Node Version:** [${process.version}](${nodeVersionDocLink}) 79 | `, 80 | inline: true 81 | }, 82 | { 83 | name: 'Stats', 84 | value: stripIndents` 85 | 👪 **Servers:** ${client.guilds.cache.size.toLocaleString('en-US')} 86 | 🙋 **Users:** ${client.guilds.cache.reduce((previousValue, currentValue) => previousValue += currentValue.memberCount, 0).toLocaleString('en-US')} 87 | `, 88 | inline: true 89 | } 90 | ], 91 | footer: { 92 | text: 'Made with ❤️ by Mirasaki#0001 • Open to collaborate • me@mirasaki.dev' 93 | } 94 | } 95 | ] 96 | }); 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/listeners/interaction/autoCompleteInteraction.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | 3 | // Mapping our command names 4 | const HELP = 'help'; 5 | 6 | // Mapping our commands 7 | const commandMap = []; 8 | 9 | // Defining our function to populate our commandMap 10 | // We cant use the "[ ...commands.map() ]" because this is located 11 | // in our top level scope 12 | const populateCommandMap = (commands) => { 13 | // Looping over all our commands 14 | for (const cmd of commands) { 15 | const { config, data } = cmd[1]; 16 | // Checking for command availability 17 | if (!config.enabled) continue; 18 | 19 | // Pushing the entry to our map if it's available 20 | commandMap.push({ 21 | name: data.name, 22 | permLevel: config.permLevel, 23 | category: data.category, 24 | globalCmd: config.globalCmd 25 | }); 26 | } 27 | }; 28 | 29 | // Destructure from env 30 | const { 31 | DEBUG_AUTOCOMPLETE_RESPONSE_TIME, 32 | TEST_SERVER_GUILD_ID 33 | } = process.env; 34 | 35 | module.exports = (client, interaction) => { 36 | // guild property is present and available, 37 | // we check in the main interactionCreate.js file 38 | 39 | // Check if our 1 time map has been generated 40 | const { commands } = client.container; 41 | if (commandMap.length === 0) { 42 | populateCommandMap(commands); 43 | } 44 | 45 | // Destructure from interaction 46 | const { 47 | guild, 48 | commandName, 49 | member 50 | } = interaction; 51 | 52 | // Get our command name query 53 | const query = interaction.options.getFocused()?.toLowerCase() || ''; 54 | 55 | 56 | // Start our timer for performance logging 57 | const autoResponseQueryStart = process.hrtime(); 58 | 59 | // Initialize an empty result 60 | let result = []; 61 | 62 | // This switch case is setting up for any future commands 63 | switch (commandName) { 64 | // Handle our Help command auto complete 65 | case HELP: { 66 | // Filtering out unusable commands 67 | const workingCmdMap = commandMap.filter( 68 | (cmd) => member.permLevel >= cmd.permLevel 69 | // Filtering out test commands 70 | && ( 71 | cmd.globalCmd === true 72 | ? true 73 | : guild.id === TEST_SERVER_GUILD_ID 74 | ) 75 | ); 76 | 77 | // Getting our search query's results 78 | const queryResult = workingCmdMap.filter( 79 | (cmd) => 80 | // Filtering matches by name 81 | cmd.name.toLowerCase().indexOf(query) >= 0 82 | // Filtering matches by category 83 | || cmd.category.toLowerCase().indexOf(query) >= 0 84 | ); 85 | 86 | // Structuring our result for Discord's API 87 | result = queryResult 88 | .map(cmd => { 89 | return { 90 | name: cmd.name, 91 | value: cmd.name 92 | }; 93 | }) 94 | .sort((a, b) => a.name.localeCompare(b.name)); 95 | 96 | // Finish our switch case entry 97 | break; 98 | } 99 | 100 | // Unknown auto complete interaction 101 | // Default result is an empty array 102 | default: { 103 | logger.debug(`Unknown AutoCompleteInteraction received. Command: ${commandName} - returning empty array as response`); 104 | break; 105 | } 106 | } 107 | 108 | // Returning our query result 109 | interaction.respond( 110 | // Slicing of the first 25 results, which is max allowed by Discord 111 | result?.slice(0, 25) || [] 112 | ).catch((err) => { 113 | // Unknown Interaction Error 114 | if (err.code === 10062) { 115 | logger.debug(`Error code 10062 (UNKNOWN_INTERACTION) encountered while responding to autocomplete query in ${commandName} - this interaction probably expired.`); 116 | } 117 | 118 | // Handle unexpected errors 119 | else { 120 | logger.syserr(`Unknown error encountered while responding to autocomplete query in ${commandName}`); 121 | logger.printErr(err); 122 | } 123 | }); 124 | 125 | // Performance logging if requested depending on environment 126 | if (DEBUG_AUTOCOMPLETE_RESPONSE_TIME === 'true') { 127 | logger.debug(`Responded to "${query}" auto-complete query in ${logger.getExecutionTime(autoResponseQueryStart)}`); 128 | } 129 | }; 130 | -------------------------------------------------------------------------------- /src/modules/autoLeaderboard.js: -------------------------------------------------------------------------------- 1 | // Imports 2 | const chalk = require('chalk'); 3 | const logger = require('@mirasaki/logger'); 4 | const { buildLeaderboardEmbedMessages } = require('../commands/dayz/leaderboard'); 5 | const leaderboardBlacklist = require('../../config/blacklist.json'); 6 | const cftClients = require('./cftClients'); 7 | 8 | // Getting our servers config 9 | const serverConfig = require('../../config/servers.json') 10 | .filter( 11 | ({ CFTOOLS_SERVER_API_ID, name }) => 12 | name !== '' 13 | && CFTOOLS_SERVER_API_ID !== '' 14 | ); 15 | 16 | // Definitions 17 | const MS_IN_TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; 18 | 19 | // The function that runs on every interval 20 | const autoLbCycle = async (client) => { 21 | for (const serverCfg of serverConfig) { 22 | // Skip if disabled 23 | if (!serverCfg.AUTO_LB_ENABLED) { 24 | logger.info(`Automatic leaderboard posting disabled for "${serverCfg.name}", skipping initialization`); 25 | continue; 26 | } 27 | 28 | // Schedule according to interval 29 | const autoLbInterval = serverCfg.AUTO_LB_INTERVAL_IN_MINUTES * 60 * 1000; 30 | performAutoLb(client, serverCfg) 31 | .then(() => setInterval(() => performAutoLb(client, serverCfg), autoLbInterval)); 32 | } 33 | }; 34 | module.exports.autoLbCycle = autoLbCycle; 35 | 36 | const performAutoLb = async (client, { 37 | name, 38 | AUTO_LB_CHANNEL_ID, 39 | AUTO_LB_REMOVE_OLD_MESSAGES, 40 | AUTO_LB_PLAYER_LIMIT 41 | }) => { 42 | // Resolve the automatic leaderboard channel and stop if it's not available 43 | const autoLbChannel = await getAutoLbChannel(client, AUTO_LB_CHANNEL_ID); 44 | if (!autoLbChannel) { 45 | logger.syserr(`The automatic leaderboard module is enabled, but the channel (${chalk.green(AUTO_LB_CHANNEL_ID)}) can't be found/resolved.`); 46 | return; 47 | } 48 | 49 | // Clean our old messages (going back 2 weeks) from the channel 50 | // If requested - truthy value 51 | if (AUTO_LB_REMOVE_OLD_MESSAGES) await cleanChannelClientMessages(client, autoLbChannel); 52 | 53 | // Fetch Leaderboard API data 54 | let res; 55 | try { 56 | // Fetching our leaderboard data from the CFTools API 57 | res = await cftClients[name] 58 | .getLeaderboard({ 59 | order: 'ASC', 60 | statistic: 'kills', 61 | limit: 100 62 | }); 63 | } catch (err) { 64 | // Properly logging the error if it is encountered 65 | logger.syserr(`Encounter an error while fetching leaderboard data for "${name}" automatic-leaderboard posting`); 66 | logger.printErr(err); 67 | return; 68 | } 69 | 70 | // Resolve leaderboard display player limit 71 | // Getting our player data count 72 | let playerLimit = Number(AUTO_LB_PLAYER_LIMIT); 73 | if ( 74 | isNaN(playerLimit) 75 | || playerLimit < 10 76 | || playerLimit > 100 77 | ) { 78 | // Overwrite the provided player limit back to default if invalid 79 | playerLimit = 100; 80 | } 81 | 82 | // Build the leaderboard embed 83 | const whitelistedData = res.filter((e) => !leaderboardBlacklist.includes(e.id.id)); 84 | const lbEmbedMessages = buildLeaderboardEmbedMessages(autoLbChannel.guild, whitelistedData, true, 'OVERALL', 'overall', playerLimit); 85 | 86 | for await (const lbEmbed of lbEmbedMessages) { 87 | // Send the leaderboard data to configured channel 88 | await autoLbChannel.send({ 89 | embeds: lbEmbed 90 | }); 91 | } 92 | 93 | }; 94 | module.exports.performAutoLb = performAutoLb; 95 | 96 | // Resolves the configured auto-leaderboard channel from process environment 97 | const getAutoLbChannel = async (client, AUTO_LB_CHANNEL_ID) => { 98 | if ( 99 | typeof AUTO_LB_CHANNEL_ID === 'undefined' 100 | || AUTO_LB_CHANNEL_ID.length < 1 101 | || !AUTO_LB_CHANNEL_ID.match(/^\d+$/) 102 | ) return null; 103 | else return await client.channels.fetch(AUTO_LB_CHANNEL_ID); 104 | }; 105 | module.exports.getAutoLbChannel = getAutoLbChannel; 106 | 107 | // Cleans all client-user messages from given channel 108 | const cleanChannelClientMessages = async (client, channel) => { 109 | const timestampTwoWeeksAgo = Date.now() - MS_IN_TWO_WEEKS; 110 | try { 111 | await channel.bulkDelete( 112 | (await channel.messages.fetch({ limit: 100 })) 113 | .filter( 114 | (msg) => msg.author.id === client.user.id // Check client messages 115 | && msg.createdTimestamp > timestampTwoWeeksAgo // Check 2 weeks old - API limitation 116 | ) 117 | ); 118 | } catch (err) { 119 | // Properly log unexpected errors 120 | logger.syserr(`Something went wrong while cleaning channel (${chalk.green(channel.name)}) from client messages:`); 121 | console.error(err); 122 | } 123 | }; 124 | module.exports.cleanChannelClientMessages = cleanChannelClientMessages; 125 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const { readdirSync, statSync } = require('fs'); 2 | const moment = require('moment'); 3 | const path = require('path'); 4 | const colors = require('../config/colors.json'); 5 | 6 | // Split a camel case array at uppercase 7 | module.exports.splitCamelCaseStr = (str, joinCharacter) => { 8 | const arr = str.split(/ |\B(?=[A-Z])/); 9 | if (typeof joinCharacter === 'string') { 10 | return arr.join(joinCharacter); 11 | } 12 | return arr; 13 | }; 14 | 15 | // Return integer color code 16 | module.exports.colorResolver = (input) => { 17 | // Return main bot color if no input is provided 18 | if (!input) return parseInt(colors.main.slice(1), 16); 19 | 20 | // Hex values 21 | if (typeof input === 'string') { 22 | input = parseInt(input.slice(1), 16); 23 | } 24 | 25 | else if (Array.isArray(input)) { 26 | // HSL values 27 | if (input[0] === 'hsl') { 28 | const h = input[1]; 29 | const s = input[2]; 30 | let l = input[3]; 31 | l /= 100; 32 | const a = s * Math.min(l, 1 - l) / 100; 33 | const f = (n) => { 34 | const k = (n + h / 30) % 12; 35 | const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); 36 | return Math.round(255 * color).toString(16).padStart(2, '0'); // convert to Hex and prefix "0" if needed 37 | }; 38 | return parseInt(`${f(0)}${f(8)}${f(4)}`, 16); 39 | } 40 | 41 | // RGB values 42 | else { 43 | input = (input[0] << 16) + (input[1] << 8) + input[2]; 44 | } 45 | } 46 | 47 | return input; 48 | }; 49 | 50 | // getFiles() ignores files that start with "." 51 | module.exports.getFiles = (requestedPath, allowedExtensions) => { 52 | if (typeof allowedExtensions === 'string') allowedExtensions = [allowedExtensions]; 53 | requestedPath ??= path.resolve(requestedPath); 54 | let res = []; 55 | for (let itemInDir of readdirSync(requestedPath)) { 56 | itemInDir = path.resolve(requestedPath, itemInDir); 57 | const stat = statSync(itemInDir); 58 | if (stat.isDirectory()) res = res.concat(this.getFiles(itemInDir, allowedExtensions)); 59 | if ( 60 | stat.isFile() 61 | && allowedExtensions.find((ext) => itemInDir.endsWith(ext)) 62 | && !itemInDir.slice( 63 | itemInDir.lastIndexOf(path.sep) + 1, itemInDir.length 64 | ).startsWith('.') 65 | ) res.push(itemInDir); 66 | } 67 | return res; 68 | }; 69 | 70 | // String converter: Mary Had A Little Lamb 71 | module.exports.titleCase = (str) => { 72 | if (typeof str !== 'string') throw new TypeError('Expected type: String'); 73 | str = str.toLowerCase().split(' '); 74 | for (let i = 0; i < str.length; i++) str[i] = str[i].charAt(0).toUpperCase() + str[i].slice(1); 75 | return str.join(' '); 76 | }; 77 | 78 | // Utility function for getting the relative time string using moment 79 | module.exports.getRelativeTime = (date) => moment(date).fromNow(); 80 | 81 | // Parses a SNAKE_CASE_ARRAY to title-cased strings 82 | module.exports.parseSnakeCaseArray = (arr) => { 83 | return arr.map((perm) => { 84 | perm = perm.toLowerCase().split(/[ _]+/); 85 | for (let i = 0; i < perm.length; i++) perm[i] = perm[i].charAt(0).toUpperCase() + perm[i].slice(1); 86 | return perm.join(' '); 87 | }).join('\n'); 88 | }; 89 | 90 | // String converter: Mary had a little lamb 91 | module.exports.capitalizeString = (str) => `${str.charAt(0).toUpperCase()}${str.slice(1)}`; 92 | 93 | module.exports.getApproximateObjectSizeBytes = (obj, bytes = 0) => { 94 | // Separate function to avoid code complexity 95 | const loopObj = (obj) => { 96 | for (const key in obj) { 97 | if (typeof obj[key] === 'undefined') continue; 98 | sizeOf(obj[key]); 99 | } 100 | }; 101 | 102 | // Determine the size of the object 103 | const sizeOf = (obj) => { 104 | if (obj !== null && obj !== undefined) { 105 | switch (typeof obj) { 106 | case 'number': bytes += 8; break; 107 | case 'string': bytes += obj.length * 2; break; 108 | case 'boolean': bytes += 4; break; 109 | case 'object': { 110 | const objClass = Object.prototype.toString.call(obj).slice(8, -1); 111 | if (objClass === 'Object' || objClass === 'Array') { 112 | loopObj(obj); 113 | } else bytes += obj.toString().length * 2; 114 | break; 115 | } 116 | default: break; 117 | } 118 | } 119 | return bytes; 120 | }; 121 | 122 | // Return human readable string for displaying the bytes 123 | const formatByteSize = (bytes) => { 124 | if (bytes < 1024) return `${bytes} bytes`; 125 | else if (bytes < 1048576) return `${(bytes / 1024).toFixed(3)} KiB`; 126 | else if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(3)} MiB`; 127 | else return `${(bytes / 1073741824).toFixed(3)} GiB`; 128 | }; 129 | 130 | return formatByteSize(sizeOf(obj)); 131 | }; 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DayZ Leaderboard Bot 2 | 3 | [![GitHub license](https://img.shields.io/github/license/Mirasaki/dayz-leaderboard-bot?style=flat-square)](https://github.com/Mirasaki/dayz-leaderboard-bot/blob/main/LICENSE) 4 | [![GitHub issues](https://img.shields.io/github/issues/Mirasaki/dayz-leaderboard-bot?style=flat-square)](https://github.com/Mirasaki/dayz-leaderboard-bot/issues) 5 | [![GitHub forks](https://img.shields.io/github/forks/Mirasaki/dayz-leaderboard-bot?style=flat-square)](https://github.com/Mirasaki/dayz-leaderboard-bot/network) 6 | [![GitHub stars](https://img.shields.io/github/stars/Mirasaki/dayz-leaderboard-bot?style=flat-square)](https://github.com/Mirasaki/dayz-leaderboard-bot/stargazers) 7 | 8 | A DayZ bot written in Javascript to display your leaderboard using the CFTools Cloud API. 9 | 10 | ## Archived 11 | 12 | This project has been archived and has since been replaced with [cftools-discord-bot](https://github.com/Mirasaki/cftools-discord-bot), a bot that that fully utilizes the CFTools Data API and offers way more than just a leaderboard 13 | 14 | ## Demo 15 | 16 | Come try the bot yourself in our official [support server](https://discord.gg/jKja5FBnYf)! 17 | ![Demo](https://i.imgur.com/vzoS6cq.gif) 18 | 19 | ## Technologies Used 20 | 21 | - [discord.js-bot-template](https://github.com/Mirasaki/discord.js-bot-template) 22 | - [CFTools Cloud API](https://wiki.cftools.de/display/CFAPI/CFTools+Cloud+API) 23 | 24 | ## Prerequisites 25 | 26 | - [Node.js](https://nodejs.org/en/download/) 27 | 1) Head over to the download page 28 | 2) Download the latest LTS available for your OS 29 | 3) Be sure to check the box that says "Automatically install the necessary tools" when you're running the installation wizard 30 | - A [Discord Bot account](https://discord.com/developers/applications) 31 | 1) Head over to the page linked above 32 | 2) Click "New Application" in the top right 33 | 3) Give it a cool name and click "Create" 34 | 4) Click "Bot" in the left hand panel 35 | 5) Click "Add Bot" -> "Yes, do it!" 36 | 6) Click "Rest Token" and copy it to your clipboard, you will need it later 37 | 38 | ## Installation 39 | 40 | 1. Download the latest release [here](https://github.com/Mirasaki/dayz-leaderboard-bot/releases) 41 | 2. Extract/unzip the downloaded compressed file into a new folder 42 | 3. Open a command prompt in the project root folder/directory 43 | - On Windows you can type `cmd.exe` in the File Explorer path 44 | - Root folder structure: 45 | - commands/ 46 | - local_modules/ 47 | - .env.example 48 | - index.js 49 | - etc... 50 | 4. Run the command `npm install` 51 | 5. Copy-paste the `.env.example` file in the same directory and re-name the created file to `.env` 52 | 6. Open the `.env` file and fill in your values 53 | - `CLIENT_ID`: Can be grabbed by creating a new application in [your Discord Developer Portal](https://discord.com/developers/applications) 54 | - `DISCORD_BOT_TOKEN`: After creating your bot on the link above, navigate to `Bot` in the left-side menu to reveal your bot-token 55 | - `CFTOOLS_API_APPLICATION_ID`: Application ID from your [CFTools Developer Apps](https://developer.cftools.cloud/applications) - Authorization has to be granted by navigating to the `Grant URL` that's displayed in your app overview 56 | - `CFTOOLS_API_SECRET`: Same as above, click `Reveal Secret` 57 | 7. Open the `config/servers.example.json` file and rename it to `servers.json`. Fill in your values. 58 | - `CFTOOLS_SERVER_API_ID`: Click `Manage Server` in your [CF Cloud Panel](https://app.cftools.cloud/dashboard) > `Settings` > `API Key` > `Server ID` 59 | 8. Add the bot to your server by using the following link: (Replace CLIENT-ID with your CLIENT_ID from before) 60 | 9. Run the command `node .` in the project root folder/directory or `npm run start` if you have [PM2](https://pm2.keymetrics.io/) installed to keep the process alive. 61 | 62 | ## Server configuration 63 | 64 | Server configuration is managed through the `/config/servers.json` file, here is a quick reference of what the values mean. 65 | 66 | This is **NOT** valid JSON, as you should **NOT** use this file, use the [example](/config/servers.example.json) instead 67 | 68 | ```json 69 | [ 70 | { 71 | // The name to display when selecting servers with /leaderboard and /stats 72 | "name": "Name to display - server WITH automatic leaderboard", 73 | // Click `Manage Server` in your CFTools dashboard (https://app.cftools.cloud/dashboard) > `Settings` > `API Key` > `Server ID` 74 | "CFTOOLS_SERVER_API_ID": "Your secret server API id", 75 | // Should the automatic leaderboard be enabled 76 | "AUTO_LB_ENABLED": true, 77 | // ID of the channel to post the automatic leaderboard to 78 | "AUTO_LB_CHANNEL_ID": "806479539110674472", 79 | // How often should the leaderboard be updated 80 | "AUTO_LB_INTERVAL_IN_MINUTES": 60, 81 | // Should old leaderboard data/messages be deleted 82 | "AUTO_LB_REMOVE_OLD_MESSAGES": true, 83 | // The amount of players to show on the leaderboard 84 | "AUTO_LB_PLAYER_LIMIT": 25 85 | } 86 | ] 87 | ``` 88 | 89 | ### FAQ 90 | 91 | #### How do I create the Discord bot account? 92 | 93 | Check out [this video](https://www.youtube.com/watch?v=ibtXXoMxaho) by [The Coding Train](https://www.youtube.com/channel/UCvjgXvBlbQiydffZU7m1_aw) 94 | 95 | #### Is any specific set-up required to use this? 96 | 97 | Yes. Your DayZ server has to be connected to the [CFTools Cloud API](https://wiki.cftools.de/display/CFAPI/CFTools+Cloud+API) and needs the [GameLabs integration](https://steamcommunity.com/sharedfiles/filedetails/?id=2464526692) mod. 98 | 99 | ## License 100 | 101 | [MIT](https://choosealicense.com/licenses/mit/) 102 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended" 10 | ], 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | "no-trailing-spaces": "warn", 17 | "array-callback-return": "warn", 18 | "default-case": [ 19 | "warn", 20 | { 21 | "commentPattern": "^no default$" 22 | } 23 | ], 24 | "dot-location": [ 25 | "warn", 26 | "property" 27 | ], 28 | "eqeqeq": [ 29 | "warn", 30 | "smart" 31 | ], 32 | "new-parens": "warn", 33 | "no-array-constructor": "warn", 34 | "no-caller": "warn", 35 | "no-cond-assign": [ 36 | "warn", 37 | "except-parens" 38 | ], 39 | "no-const-assign": "warn", 40 | "no-control-regex": "warn", 41 | "no-delete-var": "warn", 42 | "no-dupe-args": "warn", 43 | "no-dupe-class-members": "warn", 44 | "no-dupe-keys": "warn", 45 | "no-duplicate-case": "warn", 46 | "no-empty-character-class": "warn", 47 | "no-empty-pattern": "warn", 48 | "no-eval": "warn", 49 | "no-ex-assign": "warn", 50 | "no-extend-native": "warn", 51 | "no-extra-bind": "warn", 52 | "no-extra-label": "warn", 53 | "no-fallthrough": "warn", 54 | "no-func-assign": "warn", 55 | "no-implied-eval": "warn", 56 | "no-invalid-regexp": "warn", 57 | "no-iterator": "warn", 58 | "no-label-var": "warn", 59 | "no-labels": [ 60 | "warn", 61 | { 62 | "allowLoop": true, 63 | "allowSwitch": false 64 | } 65 | ], 66 | "no-lone-blocks": "warn", 67 | "no-loop-func": "warn", 68 | "no-mixed-operators": [ 69 | "warn", 70 | { 71 | "groups": [ 72 | [ 73 | "&", 74 | "|", 75 | "^", 76 | "~", 77 | "<<", 78 | ">>", 79 | ">>>" 80 | ], 81 | [ 82 | "==", 83 | "!=", 84 | "===", 85 | "!==", 86 | ">", 87 | ">=", 88 | "<", 89 | "<=" 90 | ], 91 | [ 92 | "&&", 93 | "||" 94 | ], 95 | [ 96 | "in", 97 | "instanceof" 98 | ] 99 | ], 100 | "allowSamePrecedence": false 101 | } 102 | ], 103 | "no-multi-str": "warn", 104 | "no-native-reassign": "warn", 105 | "no-negated-in-lhs": "warn", 106 | "no-new-func": "warn", 107 | "no-new-object": "warn", 108 | "no-new-symbol": "warn", 109 | "no-new-wrappers": "warn", 110 | "no-obj-calls": "warn", 111 | "no-octal": "warn", 112 | "no-octal-escape": "warn", 113 | "no-redeclare": [ 114 | "warn", 115 | { 116 | "builtinGlobals": false 117 | } 118 | ], 119 | "no-regex-spaces": "warn", 120 | "no-restricted-syntax": [ 121 | "warn", 122 | "WithStatement" 123 | ], 124 | "no-script-url": "warn", 125 | "no-self-assign": "warn", 126 | "no-self-compare": "warn", 127 | "no-sequences": "warn", 128 | "no-shadow-restricted-names": "warn", 129 | "no-sparse-arrays": "warn", 130 | "no-template-curly-in-string": "error", 131 | "no-this-before-super": "warn", 132 | "no-throw-literal": "warn", 133 | "no-undef": "error", 134 | "no-unexpected-multiline": "warn", 135 | "no-unreachable": "warn", 136 | "no-unused-expressions": [ 137 | "error", 138 | { 139 | "allowShortCircuit": true, 140 | "allowTernary": true, 141 | "allowTaggedTemplates": true 142 | } 143 | ], 144 | "no-unused-labels": "warn", 145 | "no-unused-vars": [ 146 | "warn", 147 | { 148 | "args": "none", 149 | "ignoreRestSiblings": true 150 | } 151 | ], 152 | "no-use-before-define": [ 153 | "warn", 154 | { 155 | "functions": false, 156 | "classes": false, 157 | "variables": false 158 | } 159 | ], 160 | "no-useless-computed-key": "warn", 161 | "no-useless-concat": "warn", 162 | "no-useless-constructor": "warn", 163 | "no-useless-escape": "warn", 164 | "no-useless-rename": [ 165 | "warn", 166 | { 167 | "ignoreDestructuring": false, 168 | "ignoreImport": false, 169 | "ignoreExport": false 170 | } 171 | ], 172 | "no-with": "warn", 173 | "require-yield": "warn", 174 | "rest-spread-spacing": [ 175 | "warn", 176 | "never" 177 | ], 178 | "strict": [ 179 | "warn", 180 | "never" 181 | ], 182 | "unicode-bom": [ 183 | "warn", 184 | "never" 185 | ], 186 | "use-isnan": "warn", 187 | "valid-typeof": "warn", 188 | "getter-return": "warn", 189 | "object-curly-spacing": [ 190 | "error", 191 | "always" 192 | ], 193 | "no-unneeded-ternary": "warn", 194 | "no-whitespace-before-property": "error", 195 | "object-property-newline": [ 196 | "error", 197 | { 198 | "allowAllPropertiesOnSameLine": true 199 | } 200 | ], 201 | "nonblock-statement-body-position": [ 202 | "error", 203 | "beside", 204 | { 205 | "overrides": { 206 | "while": "below", 207 | "do": "any" 208 | } 209 | } 210 | ], 211 | "no-console": "off", 212 | "indent": [ 213 | "error", 214 | 2, 215 | { 216 | "SwitchCase": 1 217 | } 218 | ], 219 | "quotes": [ 220 | "warn", 221 | "single" 222 | ], 223 | "semi": [ 224 | "warn", 225 | "always" 226 | ], 227 | "keyword-spacing": [ 228 | "error", 229 | { 230 | "before": true, 231 | "after": true 232 | } 233 | ], 234 | "space-before-blocks": [ 235 | "error", 236 | { 237 | "functions": "always", 238 | "keywords": "always", 239 | "classes": "always" 240 | } 241 | ], 242 | "space-before-function-paren": [ 243 | "error", 244 | "always" 245 | ], 246 | "prefer-const": [ 247 | "error", 248 | { 249 | "destructuring": "any", 250 | "ignoreReadBeforeAssign": true 251 | } 252 | ], 253 | "comma-dangle": [ 254 | "error", 255 | "never" 256 | ], 257 | "eol-last": ["error", "always"] 258 | } 259 | } -------------------------------------------------------------------------------- /src/listeners/interaction/applicationCommandInteraction.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | const { stripIndents } = require('common-tags'); 4 | const { throttleCommand } = require('../../handlers/commands'); 5 | const { hasChannelPerms } = require('../../handlers/permissions'); 6 | const { titleCase } = require('../../util'); 7 | 8 | // Destructuring from env 9 | const { 10 | DEBUG_ENABLED, 11 | DEBUG_INTERACTIONS 12 | } = process.env; 13 | 14 | module.exports = (client, interaction) => { 15 | // Destructure from interaction 16 | const { 17 | member, 18 | channel, 19 | guild, 20 | commandName 21 | } = interaction; 22 | 23 | // Initial performace measuring timer 24 | const cmdRunTimeStart = process.hrtime(); 25 | 26 | // Logging the Command to our console 27 | console.log([ 28 | `${logger.timestamp()} ${chalk.white('[CMD]')} : ${chalk.bold(titleCase(commandName))}`, 29 | guild.name, 30 | `#${channel.name}`, 31 | member.user.username 32 | ].join(chalk.magentaBright(' • '))); 33 | 34 | // Conditional Debug logging 35 | if (DEBUG_INTERACTIONS === 'true') { 36 | logger.startLog('Application Command Interaction'); 37 | console.dir(interaction, { showHidden: false, depth: 0, colors: true }); 38 | logger.endLog('Application Command Interaction'); 39 | } 40 | 41 | // Get the client.container.commands command 42 | const { commands, emojis } = client.container; 43 | const clientCmd = commands.get(commandName); 44 | 45 | // Check for late API changes 46 | if (!clientCmd) { 47 | interaction.reply({ 48 | content: `${emojis.error} ${member}, this command currently isn't available.`, 49 | ephemeral: true 50 | }); 51 | return; 52 | } 53 | 54 | // Grab our data object from the client command 55 | const { data } = clientCmd; 56 | 57 | // Return if we can't reply to the interacion 58 | const clientCanReply = interaction.isRepliable(); 59 | if (!clientCanReply) { 60 | logger.debug(`Interaction returned - Interaction not repliable\nCommand: ${data.name}\nServer: ${guild.name}\nChannel: #${channel.name}\nMember: ${member}`); 61 | return; 62 | } 63 | 64 | // Check if the command exists 65 | if (!clientCmd) { 66 | interaction.reply({ 67 | content: stripIndents` 68 | ${emojis} ${member}, this command doesn't exist anymore. 69 | If you're seeing this message, please wait a moment for Slash Commands to sync in this server. 70 | ` 71 | }); 72 | return; 73 | } 74 | 75 | // Perform our additional checks 76 | // Like permissions, NSFW, status, availability 77 | if (checkCommandCanExecute(client, interaction, clientCmd) === false) { 78 | // If individual checks fail 79 | // the function returns false and provides user feedback 80 | return; 81 | } 82 | 83 | // Throttle the command 84 | // permLevel 4 = Developer 85 | if (member.permLevel < 4) { 86 | const onCooldown = throttleCommand(clientCmd, `${guild.id}`); 87 | if (onCooldown !== false) { 88 | interaction.reply({ 89 | content: onCooldown.replace('{{user}}', `${member}`) 90 | }); 91 | return; 92 | } 93 | } 94 | 95 | /* 96 | All checks have passed 97 | Run the command 98 | While catching possible errors 99 | */ 100 | (async () => { 101 | try { 102 | await clientCmd.run({ client, interaction }); 103 | } catch (err) { 104 | logger.syserr(`An error has occurred while executing the /${chalk.whiteBright(commandName)} command`); 105 | logger.printErr(err); 106 | } 107 | })(); 108 | 109 | // Log command execution time 110 | if (DEBUG_ENABLED === 'true') { 111 | logger.debug(`${chalk.white(commandName)} executed in ${logger.getExecutionTime(cmdRunTimeStart)}`); 112 | } 113 | }; 114 | 115 | // Utility function for running all the checks that have to pass 116 | // In order to execute the command 117 | const checkCommandCanExecute = (client, interaction, clientCmd) => { 118 | // Required destructuring 119 | const { member, channel } = interaction; 120 | const { emojis } = client.container; 121 | const { config, data } = clientCmd; 122 | 123 | // Get permission levels 124 | const commandPermLvl = config.permLevel; 125 | 126 | // Check if the command is currently disabled 127 | // Needed 'cuz it takes a while for CommandInteractions to sync across server 128 | if (config.enabled === false) { 129 | interaction.reply({ 130 | content: `${emojis} ${member}, this command is currently disabled. Please try again later.` 131 | }); 132 | return false; 133 | } 134 | 135 | // Fallback for unexpected results 136 | if (isNaN(commandPermLvl)) { 137 | interaction.reply({ 138 | content: `${emojis.error} ${member}, something went wrong while using this command.\n${emojis.info} This issue has been logged to the developer.\n${emojis.wait} Please try again later`, 139 | ephemeral: true 140 | }); 141 | logger.syserr(`Interaction returned: Calculated permission level for command ${data.name} is NaN.`); 142 | return false; 143 | } 144 | 145 | // Check if they have the required permission level 146 | if (member.permLevel < commandPermLvl) { 147 | interaction.reply({ 148 | content: `${emojis.error} ${member}, you do not have the required permission level to use this command.` 149 | }); 150 | return false; 151 | } 152 | 153 | // Check for missing client Discord App permissions 154 | if (config.clientPerms.length !== 0) { 155 | const missingPerms = hasChannelPerms(client.user.id, channel, config.clientPerms); 156 | if (missingPerms !== true) { 157 | interaction.reply({ 158 | content: `${emojis.error} ${member}, this command can't be executed because I lack the following permissions in ${channel}\n${emojis.bulletPoint} ${missingPerms.join(', ')}` 159 | }); 160 | return false; 161 | } 162 | } 163 | 164 | // Check for missing user Discord App permissions 165 | if (config.userPerms.length !== 0) { 166 | const missingPerms = hasChannelPerms(member.user.id, channel, config.userPerms); 167 | if (missingPerms !== true) { 168 | interaction.reply({ 169 | content: `${emojis.error} ${member}, this command can't be executed because you lack the following permissions in ${channel}:\n${emojis.bulletPoint} ${missingPerms.join(', ')}` 170 | }); 171 | return false; 172 | } 173 | } 174 | 175 | // Check for NSFW commands and channels 176 | if (config.nsfw === true && channel.nsfw !== true) { 177 | interaction.reply({ 178 | content: `${emojis.error} ${member}, that command is marked as **NSFW**, you can't use it in a **SFW** channel!`, 179 | ephemeral: true 180 | }); 181 | return false; 182 | } 183 | 184 | // All checks have passed 185 | return true; 186 | }; 187 | -------------------------------------------------------------------------------- /src/commands/dayz/stats.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { stripIndents } = require('common-tags/lib'); 3 | const { fetchPlayerDetails, getCftoolsId } = require('../../modules/cftClients'); 4 | const { colorResolver, titleCase } = require('../../util'); 5 | 6 | // Getting our servers config 7 | const serverConfig = require('../../../config/servers.json') 8 | .filter( 9 | ({ CFTOOLS_SERVER_API_ID, name }) => 10 | name !== '' 11 | && CFTOOLS_SERVER_API_ID !== '' 12 | ); 13 | 14 | // Mapping our API choices data 15 | const serverConfigChoices = serverConfig 16 | .map(({ CFTOOLS_SERVER_API_ID, name }) => ({ name, value: name })); 17 | const { DEBUG_STAT_COMMAND_DATA } = process.env; 18 | 19 | module.exports = { 20 | data: { 21 | description: 'Display information for a specific player', 22 | options: [ 23 | { 24 | type: 3, // STRING, 25 | name: 'identifier', 26 | description: 'The player\'s Steam64, CFTools Cloud, BattlEye, or Bohemia Interactive ID', 27 | required: true 28 | }, 29 | { 30 | name: 'server', 31 | description: 'Which leaderboard to display', 32 | type: 3, // String 33 | required: false, 34 | choices: serverConfigChoices 35 | } 36 | ] 37 | }, 38 | 39 | config: { 40 | globalCmd: true, 41 | cooldown: { 42 | usages: 10, 43 | duration: 60 44 | } 45 | }, 46 | 47 | run: async ({ client, interaction }) => { 48 | // Destructuring and assignments 49 | const { options, member } = interaction; 50 | const { emojis } = client.container; 51 | const identifier = options.getString('identifier'); 52 | 53 | // Deferring our reply 54 | await interaction.deferReply(); 55 | 56 | // Resolving server input 57 | let serverName = options.getString('server'); 58 | if (!serverName) serverName = serverConfig[0].name; 59 | 60 | // Getting the server api ID 61 | const apiServerId = serverConfig.find(({ name }) => name === serverName)?.CFTOOLS_SERVER_API_ID; 62 | 63 | // Invalid config fallback 64 | if (!apiServerId) { 65 | interaction.reply({ 66 | content: `${emojis.error} ${member}, invalid config in /config/servers.json - missing apiServerId for ${serverName}` 67 | }); 68 | return; 69 | } 70 | 71 | // Reduce cognitive complexity 72 | // tryPlayerData replies to interaction if anything fails 73 | const data = await tryPlayerData(client, interaction, identifier, apiServerId); 74 | if (!data) return; 75 | 76 | // Data is delivered as on object with ID key parameters 77 | const stats = data[data.identifier]; 78 | if (!stats) { 79 | interaction.editReply({ 80 | content: `${client.container.emojis.error} ${interaction.member}, no data belonging to **\`${identifier}\`** was found.` 81 | }); 82 | return; 83 | } 84 | 85 | // Detailed, conditional debug logging 86 | if (DEBUG_STAT_COMMAND_DATA === 'true') { 87 | logger.startLog('STAT COMMAND DATA'); 88 | console.dir(stats, { depth: Infinity }); 89 | logger.endLog('STAT COMMAND DATA'); 90 | } 91 | 92 | // Dedicated function for stat calculations 93 | // and sending the result to reduce 94 | // cognitive complexity 95 | sendPlayerData(stats, interaction); 96 | } 97 | }; 98 | 99 | const sendPlayerData = (stats, interaction) => { 100 | // Assigning our stat variables 101 | const { omega, game } = stats; 102 | const { general } = game; 103 | const daysPlayed = Math.floor(omega.playtime / 86400); 104 | const hoursPlayed = Math.floor(omega.playtime / 3600) % 24; 105 | const minutesPlayed = Math.floor(omega.playtime / 60) % 60; 106 | const secondsPlayed = omega.playtime % 60; 107 | const playSessions = omega.sessions; 108 | const averagePlaytimePerSession = Math.round( 109 | ((hoursPlayed * 60) 110 | + minutesPlayed) 111 | / playSessions 112 | ); 113 | const [ 114 | day, month, date, year, time, timezone 115 | ] = `${new Date(stats.updated_at)}`.split(' '); 116 | let favoriteWeaponName = 'Knife'; 117 | const highestKills = Object.entries(general?.weapons || {}).reduce((acc, [weaponName, weaponStats]) => { 118 | const weaponKillsIsLower = acc > weaponStats.kills; 119 | if (!weaponKillsIsLower) favoriteWeaponName = weaponName; 120 | return weaponKillsIsLower ? acc : weaponStats.kills; 121 | }, 0); 122 | const cleanedWeaponName = titleCase(favoriteWeaponName.replace(/_/g, ' ')); 123 | 124 | // Reversing the name history array so the latest used name is the first item 125 | omega.name_history.reverse(); 126 | 127 | // Returning our detailed player information 128 | interaction.editReply({ 129 | embeds: [ 130 | { 131 | color: colorResolver(), 132 | title: `Stats for ${omega.name_history[0] || 'Survivor'}`, 133 | description: stripIndents` 134 | Survivor has played for ${daysPlayed} days, ${hoursPlayed} hours, ${minutesPlayed} minutes, and ${secondsPlayed} seconds - over ${playSessions} total sessions. 135 | Bringing them to an average of ${!isNaN(averagePlaytimePerSession) ? averagePlaytimePerSession : 'n/a'} minutes per session. 136 | 137 | **Name History:** **\`${omega.name_history.slice(0, 10).join('`**, **`') || 'None'}\`** 138 | 139 | **Deaths:** ${general?.deaths || 0} 140 | **Hits:** ${general?.hits || 0} 141 | **KDRatio:** ${general?.kdratio || 0} 142 | **Kills:** ${general?.kills || 0} 143 | **Longest Kill:** ${general?.longest_kill || 0} m 144 | **Longest Shot:** ${general?.longest_shot || 0} m 145 | **Suicides:** ${general?.suicides || 0} 146 | **Favorite Weapon:** ${cleanedWeaponName || 'Knife'} with ${highestKills || 0} kills 147 | `, 148 | footer: { 149 | text: `Last action: ${time} | ${day} ${month} ${date} ${year} ${time} (${timezone})` 150 | } 151 | } 152 | ] 153 | }); 154 | }; 155 | 156 | const tryPlayerData = async (client, interaction, identifier, CFTOOLS_SERVER_API_ID) => { 157 | const { emojis } = client.container; 158 | const { member } = interaction; 159 | 160 | // Resolve identifier to cftools id 161 | const cftoolsId = await getCftoolsId(identifier); 162 | identifier = cftoolsId || identifier; 163 | 164 | // fetching from API 165 | let data; 166 | try { 167 | data = await fetchPlayerDetails(identifier, CFTOOLS_SERVER_API_ID); 168 | } catch (err) { 169 | interaction.editReply({ 170 | content: `${emojis.error} ${member}, encountered an error while fetching data, please try again later.` 171 | }); 172 | return undefined; 173 | } 174 | 175 | // Invalid ID or no access granted 176 | if (data.status === false) { 177 | interaction.editReply({ 178 | content: `${emojis.error} ${member}, either the ID you provided is invalid or that player isn't currently known to the client. This command has been cancelled.` 179 | }); 180 | return undefined; 181 | } 182 | 183 | return { ...data, identifier }; 184 | }; 185 | 186 | 187 | -------------------------------------------------------------------------------- /src/handlers/commands.js: -------------------------------------------------------------------------------- 1 | // Require dependencies 2 | const { REST } = require('@discordjs/rest'); 3 | const { Routes } = require('discord-api-types/v10'); 4 | const logger = require('@mirasaki/logger'); 5 | const chalk = require('chalk'); 6 | const path = require('path'); 7 | const { getFiles, titleCase } = require('../util'); 8 | const { permConfig, getInvalidPerms, permLevelMap } = require('./permissions'); 9 | const emojis = require('../../config/emojis.json'); 10 | 11 | // Destructure from process.env 12 | const { 13 | CLEAR_SLASH_COMMAND_API_DATA, 14 | DISCORD_BOT_TOKEN, 15 | CLIENT_ID, 16 | TEST_SERVER_GUILD_ID, 17 | DEBUG_ENABLED, 18 | REFRESH_SLASH_COMMAND_API_DATA, 19 | DEBUG_SLASH_COMMAND_API_DATA 20 | } = process.env; 21 | 22 | // Defining our command class for value defaults 23 | class Command { 24 | constructor ({ config, data, filePath, run }) { 25 | this.config = { 26 | // Permissions 27 | permLevel: permConfig[0].name, 28 | clientPerms: [], 29 | userPerms: [], 30 | 31 | // Status 32 | enabled: true, 33 | globalCmd: false, 34 | testServerCmd: true, 35 | nsfw: false, 36 | 37 | // Command Cooldown 38 | cooldown: { 39 | usages: 1, 40 | duration: 2 41 | }, 42 | 43 | ...config 44 | }; 45 | 46 | this.data = { 47 | // Default = file name without extension 48 | name: filePath.slice( 49 | filePath.lastIndexOf(path.sep) + 1, 50 | filePath.lastIndexOf('.') 51 | ), 52 | // Default = file parent folder name 53 | category: filePath.slice( 54 | filePath.lastIndexOf(path.sep, filePath.lastIndexOf(path.sep) - 1) + 1, 55 | filePath.lastIndexOf(path.sep) 56 | ), 57 | ...data 58 | }; 59 | 60 | this.run = run; 61 | 62 | // Transforms the permLevel into an integer 63 | this.setPermLevel = () => { 64 | this.config.permLevel = Number( 65 | Object.entries(permLevelMap) 66 | .find(([lvl, name]) => name === this.config.permLevel)[0] 67 | ); 68 | }; 69 | } 70 | } 71 | 72 | 73 | // Initializing our REST client 74 | const rest = new REST({ version: '10' }) 75 | .setToken(DISCORD_BOT_TOKEN); 76 | 77 | // Utility function for clearing our Slash Command Data 78 | const clearSlashCommandData = () => { 79 | if (CLEAR_SLASH_COMMAND_API_DATA === 'true') { 80 | logger.info('Clearing ApplicationCommand API data'); 81 | rest.put(Routes.applicationCommands(CLIENT_ID), { body: [] }); 82 | if (TEST_SERVER_GUILD_ID) rest.put(Routes.applicationGuildCommands(CLIENT_ID, TEST_SERVER_GUILD_ID), { body: [] }) 83 | .catch((err) => { 84 | // Catching Missing Access error 85 | logger.syserr('Error encountered while trying to clear GuildCommands in the test server, this probably means your TEST_SERVER_GUILD_ID in the config/.env file is invalid or the client isn\'t currently in that server'); 86 | logger.syserr(err); 87 | }); 88 | logger.success( 89 | 'Successfully reset all Slash Commands. It may take up to an hour for global changes to take effect.' 90 | ); 91 | logger.syslog(chalk.redBright('Shutting down...')); 92 | process.exit(1); 93 | } 94 | }; 95 | 96 | const bindCommandsToClient = (client) => { 97 | // Destructuring commands from our client container 98 | const { commands } = client.container; 99 | const topLevelCommandFolder = path.resolve('src', 'commands'); 100 | 101 | // Looping over every src/commands/ file 102 | for (const commandPath of getFiles(topLevelCommandFolder, '.js', '.mjs', '.cjs')) { 103 | // Require our command module 104 | let cmdModule = require(commandPath); 105 | // Adding the filepath/origin onto the module 106 | cmdModule.filePath = commandPath; 107 | // Validating the command 108 | cmdModule = validateCmdConfig(cmdModule); 109 | 110 | // Transforming the string permission into an integer 111 | cmdModule.setPermLevel(); 112 | 113 | // Debug Logging 114 | if (DEBUG_ENABLED === 'true') { 115 | logger.debug(`Loading the <${chalk.cyanBright(cmdModule.data.name)}> command`); 116 | } 117 | 118 | // Set the command in client.container.commands[x] 119 | commands.set(cmdModule.data.name, cmdModule); 120 | } 121 | }; 122 | 123 | // Utility function for sorting the commands 124 | const sortCommandsByCategory = (commands) => { 125 | let currentCategory = ''; 126 | const sorted = []; 127 | commands.forEach(cmd => { 128 | const workingCategory = titleCase(cmd.data.category); 129 | if (currentCategory !== workingCategory) { 130 | sorted.push({ 131 | category: workingCategory, 132 | commands: [cmd] 133 | }); 134 | currentCategory = workingCategory; 135 | } else sorted.find(e => e.category === currentCategory).commands.unshift(cmd); 136 | }); 137 | return sorted; 138 | }; 139 | 140 | // Utility function for refreshing our InteractionCommand API data 141 | const refreshSlashCommandData = (client) => { 142 | // Environmental skip 143 | if (REFRESH_SLASH_COMMAND_API_DATA !== 'true') { 144 | logger.syslog(`Skipping application ${chalk.white('(/)')} commands refresh.`); 145 | return; 146 | } 147 | 148 | try { 149 | logger.startLog(`Refreshing Application ${chalk.white('(/)')} Commands.`); 150 | 151 | // Handle our different cmd config setups 152 | registerGlobalCommands(client); // Global Slash Command 153 | registerTestServerCommands(client); // Test Server Commands 154 | 155 | logger.endLog(`Refreshing Application ${chalk.white('(/)')} Commands.`); 156 | } catch (error) { 157 | logger.syserr(`Error while refreshing application ${chalk.white('(/)')} commands`); 158 | console.error(error); 159 | } 160 | }; 161 | 162 | // Registering our global commands 163 | const registerGlobalCommands = async (client) => { 164 | // Logging 165 | logger.info('Registering Global Application Commands'); 166 | 167 | // Defining our variables 168 | const { commands } = client.container; 169 | const globalCommandData = commands 170 | .filter((cmd) => 171 | cmd.config.globalCmd === true 172 | && cmd.config.enabled === true 173 | ) 174 | .map((cmd) => cmd.data); 175 | 176 | // Extensive debug logging 177 | if (DEBUG_SLASH_COMMAND_API_DATA === 'true') { 178 | logger.startLog('Global Command Data'); 179 | console.table(globalCommandData); 180 | logger.endLog('Global Command Data'); 181 | } 182 | 183 | // Sending the global command data 184 | rest.put( 185 | Routes.applicationCommands(CLIENT_ID), 186 | { body: globalCommandData } 187 | ); 188 | }; 189 | 190 | // Registering our Test Server commands 191 | const registerTestServerCommands = (client) => { 192 | // Defining our variables 193 | const { commands } = client.container; 194 | const testServerCommandData = commands 195 | .filter((cmd) => 196 | cmd.config.globalCmd === false // Filter out global commands 197 | && cmd.config.testServerCmd === true 198 | && cmd.config.enabled === true 199 | ) 200 | .map((cmd) => cmd.data); 201 | 202 | // Return if there's no test command data 203 | if (testServerCommandData.length === 0) { 204 | return true; 205 | } 206 | 207 | // Logging 208 | logger.info('Registering Test Server Commands'); 209 | 210 | // Extensive debug logging 211 | if (DEBUG_SLASH_COMMAND_API_DATA === 'true') { 212 | logger.startLog('Test Server Command Data'); 213 | console.table(testServerCommandData); 214 | logger.endLog('Test Server Command Data'); 215 | } 216 | 217 | // Sending the test server command data 218 | if (TEST_SERVER_GUILD_ID) rest.put( 219 | Routes.applicationGuildCommands( 220 | CLIENT_ID, 221 | TEST_SERVER_GUILD_ID // Providing our test server id 222 | ), 223 | { body: testServerCommandData } 224 | ).catch((err) => { 225 | // Catching Missing Access error 226 | logger.syserr('Error encountered while trying to register GuildCommands in the test server, this probably means your TEST_SERVER_GUILD_ID in the config/.env file is invalid or the client isn\'t currently in that server'); 227 | logger.syserr(err); 228 | }); 229 | }; 230 | 231 | // Disable our eslint rule 232 | // The function isn't complex, just long 233 | const validateCmdConfig = (cmd) => { 234 | // Default values 235 | cmd = new Command(cmd); 236 | 237 | // Destructure 238 | const { config, data, run } = cmd; 239 | 240 | // Check if valid permission level is supplied 241 | const { permLevel } = config; 242 | if (!permConfig.find((e) => e.name === permLevel)) { 243 | throw new Error(`The permission level "${permLevel}" is not currently configured.\nCommand: ${data.name}`); 244 | } 245 | 246 | // Check that optional client permissions are valid 247 | if (config.permissions?.client) { 248 | const { client } = config.permissions; 249 | if (!Array.isArray(client)) { 250 | throw new Error (`Invalid permissions provided in ${data.name} command client permissions\nCommand: ${data.name}`); 251 | } 252 | } 253 | 254 | // Check that optional user permissions are valid 255 | if (config.permissions?.user) { 256 | const { user } = config.permissions; 257 | if (!Array.isArray(user)) { 258 | throw new Error (`Invalid permissions provided in ${data.name} command user permissions\nCommand: ${data.name}`); 259 | } 260 | } 261 | 262 | // Util for code repetition 263 | const throwBoolErr = (key) => { 264 | throw new Error(`Expected boolean at config.${key}\nCommand: ${data.name}`); 265 | }; 266 | 267 | // Check our required boolean values 268 | if (typeof config.globalCmd !== 'boolean') throwBoolErr('globalCmd'); 269 | if (typeof config.testServerCmd !== 'boolean') throwBoolErr('testServerCmd'); 270 | if (typeof config.nsfw !== 'boolean') throwBoolErr('nsfw'); 271 | 272 | // Description is required 273 | if (!data?.description) { 274 | throw new Error(`An InteractionCommand description is required by Discord's API\nCommand: ${data.name}`); 275 | } 276 | 277 | // Check our run function 278 | if (typeof run !== 'function') { 279 | throw new Error(`Expected run to be a function, but received ${typeof run}\nCommand: ${data.name}`); 280 | } 281 | 282 | // Check optional required client permissions 283 | if (config.clientPerms.length >= 1) { 284 | const invalidPerms = getInvalidPerms(config.clientPerms).map(e => chalk.red(e)); 285 | if (invalidPerms.length >= 1) { 286 | throw new Error(`Invalid permissions provided in config.clientPerms: ${invalidPerms.join(', ')}\nCommand: ${data.name}`); 287 | } 288 | } 289 | 290 | // Check optional required user permissions 291 | if (config.userPerms.length >= 1) { 292 | const invalidPerms = getInvalidPerms(config.userPerms).map(e => chalk.red(e)); 293 | if (invalidPerms.length >= 1) { 294 | throw new Error(`Invalid permissions provided in config.userPerms: ${invalidPerms.join(', ')}\nCommand: ${data.name}`); 295 | } 296 | } 297 | 298 | // Return the valid command module 299 | return cmd; 300 | }; 301 | 302 | // Handling command cooldowns 303 | const ThrottleMap = new Map(); 304 | const throttleCommand = (cmd, id) => { 305 | const { config, data: cmdData } = cmd; 306 | const { cooldown } = config; 307 | if (cooldown === false) return false; 308 | const cmdCd = parseInt(cooldown.duration * 1000); 309 | if (!cmdCd || cmdCd < 0) return false; 310 | 311 | const identifierString = `${id}-${cmd}`; 312 | 313 | // No data 314 | if (!ThrottleMap.has(identifierString)) { 315 | ThrottleMap.set(identifierString, [Date.now()]); 316 | setTimeout(() => ThrottleMap.delete(identifierString), cmdCd); 317 | return false; 318 | } 319 | 320 | // Data was found 321 | else { 322 | const data = ThrottleMap.get(identifierString); 323 | const nonExpired = data.filter((timestamp) => Date.now() < (timestamp + cmdCd)); 324 | 325 | // Currently on cooldown 326 | if (nonExpired.length >= cooldown.usages) { 327 | return `${emojis.error} {{user}}, you can use **\`/${cmdData.name}\`** again in ${ 328 | Number.parseFloat(((nonExpired[0] + cmdCd) - Date.now()) / 1000).toFixed(2) 329 | } seconds`; 330 | } 331 | 332 | // Not on max-usages yet, increment 333 | else { 334 | data.push(Date.now()); 335 | return false; 336 | } 337 | } 338 | }; 339 | 340 | module.exports = { 341 | clearSlashCommandData, 342 | refreshSlashCommandData, 343 | bindCommandsToClient, 344 | validateCmdConfig, 345 | sortCommandsByCategory, 346 | throttleCommand 347 | }; 348 | -------------------------------------------------------------------------------- /src/commands/dayz/leaderboard.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { Statistic } = require('cftools-sdk'); 3 | const { stripIndents } = require('common-tags/lib'); 4 | const cftClients = require('../../modules/cftClients'); 5 | const { parseSnakeCaseArray, colorResolver } = require('../../util'); 6 | 7 | // Getting our servers config 8 | const serverConfig = require('../../../config/servers.json') 9 | .filter( 10 | ({ CFTOOLS_SERVER_API_ID, name }) => 11 | name !== '' 12 | && CFTOOLS_SERVER_API_ID !== '' 13 | ); 14 | 15 | // Mapping our API choices data 16 | const serverConfigChoices = serverConfig 17 | .map(({ CFTOOLS_SERVER_API_ID, name }) => ({ name, value: name })); 18 | 19 | // Include our blacklist file 20 | const leaderboardBlacklist = require('../../../config/blacklist.json'); 21 | 22 | // Destructure from our process env 23 | const { 24 | DEBUG_LEADERBOARD_API_DATA, 25 | NODE_ENV, 26 | CFTOOLS_API_PLAYER_DATA_COUNT 27 | } = process.env; 28 | 29 | // Mapping our leaderboard stat options 30 | const statMap = { 31 | DEATHS: Statistic.DEATHS, 32 | KILLS: Statistic.KILLS, 33 | KILL_DEATH_RATIO: Statistic.KILL_DEATH_RATIO, 34 | LONGEST_KILL: Statistic.LONGEST_KILL, 35 | LONGEST_SHOT: Statistic.LONGEST_SHOT, 36 | PLAYTIME: Statistic.PLAYTIME, 37 | SUICIDES: Statistic.SUICIDES 38 | }; 39 | 40 | // Mapping our emojis 41 | const emojiMap = { 42 | 1: '👑', 43 | 2: ':two:', 44 | 3: ':three:', 45 | 4: ':four:', 46 | 5: ':five:', 47 | 6: ':six:', 48 | 7: ':seven:', 49 | 8: ':eight:', 50 | 9: ':nine:' 51 | }; 52 | 53 | // Mapping our Interaction Command API options 54 | const { statOptions } = require('../../../config/config.json'); 55 | const activeStatisticOptions = [ 56 | { name: 'Overall', value: 'OVERALL' }, 57 | { name: 'Kills', value: 'KILLS' }, 58 | { name: 'Kill Death Ratio', value: 'KILL_DEATH_RATIO' }, 59 | { name: 'Longest Kill', value: 'LONGEST_KILL' }, 60 | { name: 'Playtime', value: 'PLAYTIME' }, 61 | { name: 'Longest Shot', value: 'LONGEST_SHOT' }, 62 | { name: 'Deaths', value: 'DEATHS' }, 63 | { name: 'Suicides', value: 'SUICIDES' } 64 | ].filter((e) => statOptions.includes(e.value)); 65 | 66 | module.exports = { 67 | // Defining our Discord Application Command API data 68 | // Name is generated from the file name if left undefined 69 | data: { 70 | description: 'Display your DayZ Leaderboard', 71 | options: [ 72 | { 73 | name: 'server', 74 | description: 'Which leaderboard to display', 75 | type: 3, // String 76 | required: false, 77 | choices: serverConfigChoices 78 | }, 79 | { 80 | name: 'type', 81 | description: 'The type of leaderboard to display', 82 | type: 3, // STRING 83 | required: false, 84 | choices: activeStatisticOptions 85 | } 86 | ] 87 | }, 88 | 89 | config: { 90 | globalCmd: true, 91 | // Setting a cooldown to avoid abuse 92 | // Allowed 2 times every 10 seconds per user 93 | cooldown: { 94 | usages: 7, 95 | duration: 60 96 | } 97 | }, 98 | 99 | run: async ({ client, interaction }) => { 100 | // Destructure from our Discord interaction 101 | const { member, guild, options } = interaction; 102 | const { emojis } = client.container; 103 | 104 | // Assigning our stat variable 105 | const statToGet = options.getString('type') || 'OVERALL'; 106 | let mappedStat = statMap[statToGet]; 107 | 108 | // Resolving server input 109 | let serverName = options.getString('server'); 110 | if (!serverName) serverName = serverConfig[0].name; 111 | 112 | // Getting the server api ID 113 | const apiServerId = serverConfig.find(({ name }) => name === serverName)?.CFTOOLS_SERVER_API_ID; 114 | 115 | // Invalid config fallback 116 | if (!apiServerId) { 117 | interaction.reply({ 118 | content: `${emojis.error} ${member}, invalid config in /config/servers.json - missing apiServerId for ${serverName}` 119 | }); 120 | return; 121 | } 122 | 123 | // Deferring our interaction 124 | // due to possible API latency 125 | await interaction.deferReply(); 126 | 127 | // Default 128 | // No option provided OR 129 | // 'overall' option specified 130 | const isDefaultQuery = !statToGet || statToGet === 'OVERALL'; 131 | if (isDefaultQuery) { 132 | mappedStat = Statistic.KILLS; 133 | } 134 | 135 | // Getting our player data count 136 | let playerLimit = Number(CFTOOLS_API_PLAYER_DATA_COUNT); 137 | if ( 138 | isNaN(playerLimit) 139 | || playerLimit < 10 140 | || playerLimit > 25 141 | ) { 142 | // Overwrite the provided player limit back to default if invalid 143 | playerLimit = 15; 144 | } 145 | 146 | let res; 147 | try { 148 | // Fetching our leaderboard data from the CFTools API 149 | res = await cftClients[serverName] 150 | .getLeaderboard({ 151 | order: 'ASC', 152 | statistic: mappedStat, 153 | limit: 100 154 | // serverApiId: apiServerId 155 | // overwriting literally doesn't work 156 | // Which is why we use the cftClients[serverName] approach 157 | // Error: ResourceNotFound: https://data.cftools.cloud/v1/server/undefined/leaderboard?stat=kills&order=-1&limit=15 158 | // c'mon bro =( 159 | }); 160 | } catch (err) { 161 | // Properly logging the error if it is encountered 162 | logger.syserr('Encounter an error while fetching leaderboard data'); 163 | logger.printErr(err); 164 | 165 | // Notify the user 166 | // Include debug in non-production environments 167 | interaction.editReply({ 168 | content: `${emojis.error} ${member}, something went wrong. Please try again later.${ 169 | NODE_ENV === 'production' 170 | ? '' 171 | : `\n\n||${err.stack || err}||` 172 | }` 173 | }); 174 | 175 | // Returning the request 176 | return; 177 | } 178 | 179 | // Additional debug logging if requested 180 | if (DEBUG_LEADERBOARD_API_DATA === 'true') { 181 | logger.startLog('LEADERBOARD API DATA'); 182 | console.table(res.map((entry) => { 183 | return { 184 | name: entry.name, 185 | rank: entry.rank, 186 | kills: entry.kills, 187 | deaths: entry.deaths, 188 | playtime: entry.playtime, 189 | hits: entry.hits, 190 | envDeaths: entry.environmentDeaths, 191 | suicides: entry.suicides, 192 | lk: entry.longestKill, 193 | ls: entry.longestShot 194 | }; 195 | })); 196 | logger.endLog('LEADERBOARD API DATA'); 197 | } 198 | 199 | // Check if any data is actually present 200 | if (res.length === 0) { 201 | interaction.editReply({ 202 | content: `${emojis.error} ${member}, we don't have any data for that statistic yet.` 203 | }); 204 | return; 205 | } 206 | 207 | // Filter out our blacklisted ids/entries 208 | const whitelistedData = res.filter((e) => !leaderboardBlacklist.includes(e.id.id)); 209 | 210 | // Constructing our embed object 211 | const lbEmbedMessages = buildLeaderboardEmbedMessages(guild, whitelistedData, isDefaultQuery, statToGet, mappedStat, playerLimit); 212 | 213 | // Responding to our request 214 | // The /leaderboard command only displays a max of 25 players 215 | interaction.editReply({ 216 | embeds: lbEmbedMessages[0] 217 | }); 218 | } 219 | }; 220 | 221 | // Dedicated function for building our embed data 222 | const buildLeaderboardEmbedMessages = (guild, res, isDefaultQuery, statToGet, mappedStat, playerLimit) => { 223 | // Initializing our embed vars 224 | let description = ''; 225 | let fields = []; 226 | 227 | // Apply player limit variable 228 | if (playerLimit) res = res.slice(0, playerLimit); 229 | 230 | // Resolve fields for OVERALL leaderboard 231 | if (isDefaultQuery) { 232 | description = `Overall Leaderboard for ${guild.name}`; 233 | fields = res.map((e, index) => { 234 | const noEmojiFallback = `${(index + 1).toString()}.`; 235 | return { 236 | name: `${emojiMap[index + 1] || noEmojiFallback} ${e.name}`, 237 | value: stripIndents` 238 | Kills: **${e.kills}** 239 | Deaths: **${e.deaths}** 240 | KD: **${e.killDeathRation}** 241 | LK: **${e.longestKill}m** 242 | `, 243 | inline: true 244 | }; 245 | }); 246 | } 247 | 248 | // Resolve fields for Statistic leaderboard 249 | else { 250 | const parameterMap = { 251 | 'kdratio': 'killDeathRation', 252 | 'longest_kill': 'longestKill', 253 | 'longest_shot': 'longestShot' 254 | }; 255 | const appendMap = { 256 | 'kdratio': ' k/d', 257 | 'longest_kill': 'm', 258 | 'longest_shot': 'm', 259 | 'kills': ' kills', 260 | 'deaths': ' deaths', 261 | 'suicides': ' suicides' 262 | }; 263 | description = `${parseSnakeCaseArray([statToGet]).toUpperCase()} Leaderboard for ${guild.name}`; 264 | fields = res.map((e, index) => { 265 | return { 266 | name: `${(index + 1).toString()}. ${e.name}`, 267 | value: `\`\`\`${ 268 | mappedStat === 'playtime' 269 | ? stripIndents` 270 | ${Math.floor(e.playtime / 60 / 60)} hours 271 | ${Math.floor((e.playtime / 60 ) % 60)} minutes 272 | ` 273 | : e[parameterMap[mappedStat] || statToGet.toLowerCase()] 274 | }${appendMap[mappedStat] || ''}\`\`\``, 275 | inline: true 276 | }; 277 | }); 278 | } 279 | 280 | 281 | // Defining limits and sizes 282 | const messageEmbedCollection = []; 283 | let embeds = []; 284 | const embedFieldLimit = 25; 285 | const maxPageCharSeize = 6000; 286 | const LAST_EMBED_FOOTER_TEXT = 'Did you know, you can use /stats to display detailed information on a player?\nYou can find someone\'s CFTools id on their CFTools Cloud account page'; 287 | 288 | // Variables that reset in next loop 289 | let currentPage = []; 290 | let charCount = description.length + LAST_EMBED_FOOTER_TEXT.length; // Account for all scenarios 291 | let messageCharCount = charCount; 292 | 293 | // Iteration loop that will check if current data can be added 294 | // to existing page, or create a new one if we would exceed 295 | // and API limits 296 | for (let i = 0; i < fields.length; i++) { 297 | // Definitions 298 | const currentEntry = fields[i]; 299 | const entryLength = currentEntry.name.length + currentEntry.value.length; 300 | const newCharCount = charCount + entryLength; 301 | const newMessageCharCount = messageCharCount + entryLength; 302 | 303 | // Create a new page/embed if adding this entry would cause 304 | // us to go over allowed maximums 305 | if ( 306 | currentPage.length === embedFieldLimit 307 | || newCharCount >= maxPageCharSeize 308 | ) { 309 | embeds.push({ 310 | fields: currentPage, 311 | color: colorResolver(), 312 | author: { 313 | name: description, 314 | iconURL: guild.iconURL({ dynamic: true }) 315 | } 316 | }); 317 | 318 | // Create a new page 319 | charCount = description.length + LAST_EMBED_FOOTER_TEXT.length; 320 | messageCharCount += charCount; 321 | currentPage = []; 322 | 323 | // Create a new message if total char count across 324 | // all embeds exceeds 6000 325 | if (newMessageCharCount >= embedFieldLimit) { 326 | messageEmbedCollection.push(embeds); 327 | embeds = []; 328 | } 329 | } 330 | 331 | // Fits in the character limit 332 | charCount += entryLength; 333 | messageCharCount += entryLength; 334 | currentPage.push(currentEntry); 335 | } 336 | 337 | // Push the left-over page data into the embeds array 338 | if (currentPage[0]) { 339 | embeds.push({ 340 | fields: currentPage, 341 | color: colorResolver(), 342 | author: { 343 | name: description, 344 | iconURL: guild.iconURL({ dynamic: true }) 345 | } 346 | }); 347 | } 348 | 349 | // Push remaining embeds into message collection 350 | if (embeds[0]) messageEmbedCollection.push(embeds); 351 | 352 | // Appending the footer to the last embed 353 | const lastEmbed = embeds[embeds.length - 1]; 354 | if (lastEmbed) lastEmbed.footer = Math.random() < 0.7 355 | ? ({ text: LAST_EMBED_FOOTER_TEXT }) 356 | : null; 357 | 358 | return messageEmbedCollection; 359 | }; 360 | module.exports.buildLeaderboardEmbedMessages = buildLeaderboardEmbedMessages; 361 | --------------------------------------------------------------------------------