├── assets ├── .gitkeep ├── logo.png ├── images │ └── body.png └── showcase │ ├── kick.gif │ ├── heatmap.png │ ├── thumbnail.gif │ ├── dm-survivor.gif │ ├── player-list.png │ ├── server-info.png │ ├── stats_bloated.png │ ├── stats_normal.png │ ├── admin-player-list.png │ └── flagged-player-list.png ├── Procfile ├── src ├── listeners │ ├── interaction │ │ ├── pingInteraction.js │ │ └── autoCompleteInteraction.js │ ├── guild │ │ ├── guildDelete.js │ │ └── guildCreate.js │ └── client │ │ └── ready.js ├── interactions │ ├── autocomplete │ │ ├── player-to.js │ │ ├── target-player.js │ │ ├── player.js │ │ ├── teleport-location.js │ │ ├── statistic.js │ │ └── command.js │ ├── buttons │ │ ├── .index.js │ │ └── eval │ │ │ └── declineEval.js │ ├── select-menus │ │ └── help.js │ └── modals │ │ └── evalSubmit.js ├── commands │ ├── .sample.js │ ├── developer │ │ ├── test.js │ │ ├── deploy.js │ │ ├── set-name.js │ │ ├── eval.js │ │ ├── set-avatar.js │ │ ├── reload.js │ │ └── exec.js │ ├── system │ │ ├── permlevel.js │ │ ├── invite.js │ │ ├── support.js │ │ ├── help.js │ │ └── stats.js │ ├── admin │ │ ├── broadcast.js │ │ ├── wipe-world-ai.js │ │ ├── wipe-world-vehicles.js │ │ ├── set-weather-sunny.js │ │ ├── dm-survivor.js │ │ ├── set-weather-stormy.js │ │ ├── admin-player-list.js │ │ ├── change-game-time.js │ │ ├── spawn-item-on-player.js │ │ ├── flagged-player-list.js │ │ ├── change-game-weather.js │ │ ├── shutdown.js │ │ └── spawn-item-on-coords.js │ ├── dayz │ │ ├── player-list.js │ │ ├── server-info.js │ │ ├── statistics.js │ │ └── leaderboard.js │ ├── moderator │ │ ├── heal-player.js │ │ ├── kick-player.js │ │ ├── kill-player.js │ │ ├── explode-player.js │ │ └── strip-player.js │ └── teleport │ │ ├── teleport-to-coords.js │ │ ├── teleport-to-location.js │ │ ├── teleport-to-player.js │ │ ├── teleport-all-to-location.js │ │ └── teleport-all-to-player.js ├── modules │ ├── webhooks.js │ ├── delayed-kill-feed.js │ ├── watch-list.js │ ├── db.js │ ├── statistics.js │ ├── server-info.js │ ├── auto-lb.js │ └── heatmap.js ├── server │ └── index.js ├── constants.js ├── context-menus │ ├── message │ │ └── print-embed.js │ └── user │ │ └── info.js └── client.js ├── .gitattributes ├── config ├── emojis.json ├── config.example.js ├── .env.example ├── teleport-locations │ └── chernarus.json ├── servers.example.js └── colors.json ├── .markdownlint.json ├── docker-compose.yml ├── Dockerfile ├── vendor └── @mirasaki │ └── logger │ ├── package.json │ ├── README.md │ ├── package-lock.json │ └── index.js ├── .github ├── workflows │ ├── test.yml │ └── codeql.yml ├── dependabot.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .dockerignore ├── LICENSE ├── tutorials ├── permissions.md ├── api-docs.md └── adding-commands.md ├── development.Dockerfile ├── .gitignore ├── .devcontainer └── arm64.Dockerfile ├── package.json └── README.md /assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: node src/index.js 2 | 3 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/images/body.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/images/body.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /assets/showcase/kick.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/kick.gif -------------------------------------------------------------------------------- /assets/showcase/heatmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/heatmap.png -------------------------------------------------------------------------------- /assets/showcase/thumbnail.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/thumbnail.gif -------------------------------------------------------------------------------- /assets/showcase/dm-survivor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/dm-survivor.gif -------------------------------------------------------------------------------- /assets/showcase/player-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/player-list.png -------------------------------------------------------------------------------- /assets/showcase/server-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/server-info.png -------------------------------------------------------------------------------- /assets/showcase/stats_bloated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/stats_bloated.png -------------------------------------------------------------------------------- /assets/showcase/stats_normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/stats_normal.png -------------------------------------------------------------------------------- /config/emojis.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": "☑️", 3 | "error": "❌", 4 | "wait": "⏳", 5 | "info": "ℹ", 6 | "separator": "•" 7 | } -------------------------------------------------------------------------------- /assets/showcase/admin-player-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/admin-player-list.png -------------------------------------------------------------------------------- /assets/showcase/flagged-player-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ariastarcos/cftools-discord-bot/HEAD/assets/showcase/flagged-player-list.png -------------------------------------------------------------------------------- /src/interactions/autocomplete/player-to.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../classes/Commands'); 2 | const playerAutoCompleteHandler = require('./player'); 3 | 4 | module.exports = new ComponentCommand({ run: playerAutoCompleteHandler.run }); 5 | -------------------------------------------------------------------------------- /src/interactions/autocomplete/target-player.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../classes/Commands'); 2 | const playerAutoCompleteHandler = require('./player'); 3 | 4 | module.exports = new ComponentCommand({ run: playerAutoCompleteHandler.run }); 5 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false, 3 | "no-inline-html": { 4 | "allowed_elements": [ 5 | "div", 6 | "h2", 7 | "h3", 8 | "h4", 9 | "summary", 10 | "details", 11 | "br", 12 | "p", 13 | "a", 14 | "img", 15 | "h1", 16 | "h2" 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /src/commands/.sample.js: -------------------------------------------------------------------------------- 1 | // JavaScript files that start with the "." character 2 | // are ignored by our command file handler 3 | const { ChatInputCommand } = require('../classes/Commands'); 4 | 5 | // Windows (ctrl+space) for auto-complete IntelliSense options 6 | module.exports = new ChatInputCommand({ 7 | run: async (client, interaction) => { 8 | // ... 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /src/interactions/buttons/.index.js: -------------------------------------------------------------------------------- 1 | // JavaScript files that start with the "." character 2 | // are ignored by our command file handler 3 | 4 | const { ComponentCommand } = require('../../classes/Commands'); 5 | 6 | // Windows (ctrl+space) for auto-complete IntelliSense options 7 | module.exports = new ComponentCommand({ 8 | run: async (client, interaction) => { 9 | 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /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 | 8 | // Logging the event to our console 9 | logger.success(`${ chalk.greenBright('[GUILD JOIN]') } ${ guild.name } has added the bot! Members: ${ guild.memberCount }`); 10 | }; 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | client: 5 | container_name: cftools-discord-bot 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | restart: unless-stopped 10 | volumes: 11 | - ./config/config.js:/app/config/config.js:ro 12 | - ./config/servers.js:/app/config/servers.js:ro 13 | - ./cftools-discord-bot.db:/app/cftools-discord-bot.db 14 | env_file: 15 | - config/.env 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-alpine 2 | 3 | # Create app/working/bot directory 4 | RUN mkdir -p /app 5 | WORKDIR /app 6 | 7 | # Install app production dependencies 8 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 9 | # where available (npm@5+) 10 | COPY package*.json ./ 11 | RUN npm ci --omit=dev 12 | 13 | # Bundle app source 14 | COPY . ./ 15 | 16 | # Optional API/Backend port 17 | EXPOSE 3000 18 | 19 | # Run the start command 20 | CMD [ "npm", "run", "start" ] 21 | -------------------------------------------------------------------------------- /vendor/@mirasaki/logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mirasaki/logger", 3 | "version": "1.0.6", 4 | "description": "[deprecated] This (tiny) package is deprecated and will be removed in the future, consider resolving it from a local vendor instead.", 5 | "main": "index.js", 6 | "files": [ 7 | "index.js" 8 | ], 9 | "license": "~~ do whatever you want ~~", 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "dependencies": { 14 | "chalk": "^4.1.2", 15 | "moment": "^2.29.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm ci 20 | - run: npm run build --if-present 21 | - run: npm test -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | target-branch: "dev" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Config 2 | .env 3 | *.env 4 | .env.example 5 | Procfile 6 | 7 | # NPM & dependencies 8 | node_modules 9 | npm-debug.log 10 | .cache 11 | 12 | # Ignoring all markdown files 13 | *.md 14 | tutorials 15 | 16 | # Github 17 | .git 18 | .github 19 | .gitattributes 20 | .gitignore 21 | .gitkeep 22 | 23 | # Docker 24 | Dockerfile 25 | *.Dockerfile 26 | docker-compose.yml 27 | *.docker-compose.yml 28 | .dockerignore 29 | 30 | # Linter files 31 | .markdownlint.json 32 | .eslintrc.json 33 | linter-output.txt 34 | 35 | # Only used to generate types in development 36 | tsconfig.json 37 | # Exported typings file 38 | typings.d.ts 39 | # README static files 40 | assets/showcase -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/commands/developer/test.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | enabled: process.env.NODE_ENV !== 'production', 6 | permLevel: 'Developer', 7 | data: { 8 | description: 'Test command for the developers', 9 | options: [ 10 | { 11 | name: 'value', 12 | description: 'input', 13 | type: ApplicationCommandOptionType.String, 14 | required: true 15 | } 16 | ], 17 | // Unavailable to non-admins in guilds 18 | default_member_permissions: 0 19 | }, 20 | 21 | run: async (client, interaction) => { 22 | } 23 | }); 24 | 25 | -------------------------------------------------------------------------------- /src/interactions/autocomplete/player.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../classes/Commands'); 2 | const { getServerConfigCommandOptionValue, survivorSessionOptionValues } = require('../../modules/cftClient'); 3 | 4 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 5 | const serverCfg = getServerConfigCommandOptionValue(interaction); 6 | const inGameSurvivors = await survivorSessionOptionValues(serverCfg.CFTOOLS_SERVER_API_ID); 7 | 8 | // Getting our search query's results 9 | const queryResult = inGameSurvivors.filter((e) => e.name.toLowerCase().indexOf(query) >= 0); 10 | 11 | // Structuring our result for Discord's API 12 | return queryResult 13 | .sort((a, b) => a.name.localeCompare(b.name)); 14 | } }); 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] - " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /src/interactions/buttons/eval/declineEval.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../../classes/Commands'); 2 | const { DECLINE_EVAL_CODE_EXECUTION } = require('../../../constants'); 3 | 4 | module.exports = new ComponentCommand({ 5 | // Overwriting the default file name with our owm custom component id 6 | data: { name: DECLINE_EVAL_CODE_EXECUTION }, 7 | 8 | run: async (client, interaction) => { 9 | const { member, message } = interaction; 10 | const { emojis } = client.container; 11 | 12 | // Reply to button interaction 13 | interaction.reply({ content: `${ emojis.error } ${ member }, cancelling code execution.` }); 14 | 15 | // Update the original message 16 | await message.edit({ 17 | content: `${ emojis.error } ${ member }, this code block has been discarded, unevaluated.`, 18 | components: [] 19 | }); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/commands/system/permlevel.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { permConfig } = require('../../handlers/permissions'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | global: true, 6 | // Default member type cooldown 7 | cooldown: { 8 | usages: 1, 9 | duration: 10 10 | }, 11 | data: { description: 'Display your bot permission level' }, 12 | 13 | run: async (client, interaction) => { 14 | // Destructure 15 | const { member } = interaction; 16 | const { emojis } = client.container; 17 | 18 | // Definition/Variables 19 | const memberPermLevelName = permConfig 20 | .find(({ level }) => level === member.permLevel).name; 21 | 22 | // User feedback 23 | interaction.reply({ content: `${ emojis.success } ${ member }, your permission level is **${ member.permLevel } | ${ memberPermLevelName }**` }); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /src/listeners/client/ready.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const chalk = require('chalk'); 3 | const { autoLbCycle } = require('../../modules/auto-lb'); 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 | 9 | logger.success(`Client logged in as ${ 10 | chalk.cyanBright(client.user.username) 11 | } after ${ upTimeStr }`); 12 | 13 | // Calculating the membercount 14 | const memberCount = client.guilds.cache.reduce( 15 | (previousValue, currentValue) => previousValue += currentValue.memberCount, 0 16 | ).toLocaleString('en-US'); 17 | 18 | // Getting the server count 19 | const serverCount = (client.guilds.cache.size).toLocaleString('en-US'); 20 | 21 | // Logging counts to developers 22 | logger.info(`Ready to serve ${ memberCount } members across ${ serverCount } servers!`); 23 | 24 | // Initialize our auto-leaderboard module - if applicable 25 | autoLbCycle(client); 26 | }; 27 | -------------------------------------------------------------------------------- /src/commands/system/invite.js: -------------------------------------------------------------------------------- 1 | const { getBotInviteLink, colorResolver } = require('../../util'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | // Not really needed with the release of the button on bot profiles in Discord 5 | // and soon, Bot/App Discovery 6 | 7 | module.exports = new ChatInputCommand({ 8 | enabled: false, 9 | global: true, 10 | cooldown: { 11 | // Use guild/server cooldown instead of default member 12 | type: 'guild', 13 | usages: 3, 14 | duration: 10 15 | }, 16 | clientPerms: [ 'EmbedLinks' ], 17 | data: { description: 'Add the bot to your server!' }, 18 | 19 | run: async (client, interaction) => { 20 | // Replying to the interaction with the bot-invite link 21 | // Not a top-level static variable to take /reload 22 | // changes into consideration 23 | interaction.reply({ embeds: [ 24 | { 25 | color: colorResolver(client.container.colors.invisible), 26 | description: `[Add me to your server](${ getBotInviteLink(client) })` 27 | } 28 | ] }); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /src/commands/developer/deploy.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { refreshSlashCommandData } = require('../../handlers/commands'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | enabled: process.env.NODE_ENV !== 'production', 7 | permLevel: 'Developer', 8 | data: { description: 'Re-deploy ApplicationCommand API data' }, 9 | run: async (client, interaction) => { 10 | const { member } = interaction; 11 | const { emojis } = client.container; 12 | 13 | // Calling our command handler function 14 | refreshSlashCommandData(client); 15 | 16 | // Sending user feedback 17 | interaction.reply({ content: stripIndents` 18 | ${ emojis.success } ${ member }, [ApplicationCommandData](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure "ApplicationCommandData on discord.com/developers") has been refreshed. 19 | ${ emojis.wait } - changes to global commands can take up to an hour to take effect... 20 | ` }); 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /src/commands/developer/set-name.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | permLevel: 'Developer', 6 | // Global rate limit of 2 requests per hour 7 | cooldown: { 8 | usages: 2, 9 | duration: 3600, 10 | type: 'global' 11 | }, 12 | data: { 13 | description: 'Update the bot\'s username', 14 | options: [ 15 | { 16 | type: ApplicationCommandOptionType.String, 17 | name: 'name', 18 | description: 'The bot\'s new username', 19 | required: true 20 | } 21 | ] 22 | }, 23 | 24 | run: async (client, interaction) => { 25 | const { member, options } = interaction; 26 | const { emojis } = client.container; 27 | const name = options.getString('name'); 28 | client.user 29 | .setUsername(name) 30 | .then(() => interaction.reply(`${ emojis.success } ${ member }, name was successfully updated!`)) 31 | .catch((err) => interaction.reply(`${ emojis.error } ${ member }, couldn't update bot's username:\n\n${ err.message }`)); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/interactions/autocomplete/teleport-location.js: -------------------------------------------------------------------------------- 1 | const { ComponentCommand } = require('../../classes/Commands'); 2 | const { getServerConfigCommandOptionValue, getTeleportLocations } = require('../../modules/cftClient'); 3 | 4 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 5 | // Check active/enabled 6 | const serverCfg = getServerConfigCommandOptionValue(interaction); 7 | if (!serverCfg.USE_TELEPORT_LOCATIONS) return [ 8 | { 9 | name: 'Teleport locations aren\'t enabled for this server configuration', 10 | value: '-1' 11 | } 12 | ]; 13 | 14 | // Resolve locations 15 | const teleportLocations = getTeleportLocations(serverCfg); 16 | if (!teleportLocations || !teleportLocations[0]) return null; 17 | 18 | // Getting our search query's results 19 | const queryResult = teleportLocations.filter((e) => e.name.toLowerCase().indexOf(query) >= 0); 20 | 21 | // Structuring our result for Discord's API 22 | return queryResult 23 | .map((e) => ({ 24 | name: e.name, 25 | value: teleportLocations.indexOf(e).toString() 26 | })) 27 | .sort((a, b) => a.name.localeCompare(b.name)); 28 | } }); 29 | -------------------------------------------------------------------------------- /tutorials/permissions.md: -------------------------------------------------------------------------------- 1 | # Managing permissions 2 | 3 | - Our internal permission levels are documented [here](https://djs.mirasaki.dev/global.html#PermLevel) and supports IntelliSense auto-completion 4 | - You can check someone's internal permission level with [#getPermissionLevel](https://djs.mirasaki.dev/module-Handler_Permissions.html#~getPermissionLevel "Documentation") 5 | - You can resolve an internal permission level integer to the relative name with [#getPermLevelName](https://djs.mirasaki.dev/module-Handler_Permissions.html#~getPermLevelName "Documentation") 6 | - You can easily check if someone has Discord permissions with [#hasChannelPerms](https://djs.mirasaki.dev/module-Handler_Permissions.html#~hasChannelPerms "Documentation") 7 | 8 | ```javascript 9 | // Getting someone's internal permission level 10 | const targetPermLevel = getPermissionLevel(clientConfig, interaction.member, interaction.channel); 11 | 12 | // Resolving the permLevel 13 | console.log(targetPermLevel, getPermLevelName(targetPermLevel)); 14 | 15 | // Checking if they have Administrator 16 | const hasAdmin = hasChannelPerms( 17 | interaction.member.id, 18 | interaction.channel, 19 | [ 'Administrator' ] 20 | ) === true; // Returns an Array of missing permissions instead of false 21 | ``` 22 | -------------------------------------------------------------------------------- /src/commands/developer/eval.js: -------------------------------------------------------------------------------- 1 | const { 2 | ModalBuilder, TextInputBuilder, ActionRowBuilder 3 | } = require('@discordjs/builders'); 4 | const { TextInputStyle } = require('discord.js'); 5 | const { ChatInputCommand } = require('../../classes/Commands'); 6 | // Unique identifiers for our components' customIds 7 | const { EVAL_CODE_MODAL, EVAL_CODE_INPUT } = require('../../constants'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | enabled: process.env.NODE_ENV !== 'production', 11 | permLevel: 'Developer', 12 | clientPerms: [ 'EmbedLinks', 'AttachFiles' ], 13 | data: { description: 'Evaluate arbitrary JavaScript code' }, 14 | run: async (client, interaction) => { 15 | // Code Modal 16 | const codeModal = new ModalBuilder() 17 | .setCustomId(EVAL_CODE_MODAL) 18 | .setTitle('JavaScript code'); 19 | 20 | // Code Input 21 | const codeInput = new TextInputBuilder() 22 | .setCustomId(EVAL_CODE_INPUT) 23 | .setLabel('The JavaScript code to evaluate') 24 | .setStyle(TextInputStyle.Paragraph); 25 | 26 | // Modal Rows 27 | const codeInputRow = new ActionRowBuilder().addComponents(codeInput); 28 | 29 | // Adding the components to our modal 30 | codeModal.addComponents(codeInputRow); 31 | 32 | // Showing the modal to the user 33 | await interaction.showModal(codeModal); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /config/config.example.js: -------------------------------------------------------------------------------- 1 | const { PermissionsBitField } = require('discord.js'); 2 | 3 | const config = { 4 | // Bot activity 5 | presence: { 6 | // One of online, idle, invisible, dnd 7 | status: 'online', 8 | activities: [ 9 | { 10 | name: '/help', 11 | // One of Playing, Streaming, Listening, Watching 12 | type: 'Listening' 13 | } 14 | ] 15 | }, 16 | 17 | // Permission config 18 | permissions: { 19 | // Array of Moderator role ids 20 | moderatorRoleIds: [ '968222116682022962', '1112021605267288096' ], 21 | // Array of Administrator role ids 22 | administratorRoleIds: [ '793898367243386940' ], 23 | // Bot Owner, highest permission level (5) 24 | ownerId: '290182686365188096', 25 | 26 | // Bot developers, second to highest permission level (4) 27 | developers: [ '625286565375246366' ] 28 | }, 29 | 30 | // Additional permissions that are considered required when generating 31 | // the bot invite link with /invite 32 | permissionsBase: [ 33 | PermissionsBitField.Flags.ViewChannel, 34 | PermissionsBitField.Flags.SendMessages, 35 | PermissionsBitField.Flags.SendMessagesInThreads 36 | ], 37 | 38 | // The Discord server invite to your Support server 39 | supportServerInviteLink: 'https://discord.mirasaki.dev' 40 | }; 41 | 42 | module.exports = config; 43 | -------------------------------------------------------------------------------- /src/modules/webhooks.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags'); 2 | const { ChannelType } = require('discord.js'); 3 | 4 | const isValidWebhookConfigMessage = ( 5 | msg, 6 | targetChannelId 7 | ) => { 8 | // Destructure from message 9 | const { id: msgId, 10 | guild } = msg; 11 | 12 | // Return if target can't be resolved 13 | const targetChannel = guild.channels.cache.get(targetChannelId); 14 | if (!targetChannel) { 15 | console.error(stripIndents` 16 | [${ msgId }] Can't send watch-list message, targetChannelId channel from config can't be resolved: ${ targetChannelId } 17 | `); 18 | return false; 19 | } 20 | 21 | // Return if target is not text based 22 | else if (!targetChannel.isTextBased()) { 23 | console.error(stripIndents` 24 | [${ msgId }] Can't send watch-list message, targetChannelId channel is NOT a text based channel 25 | `); 26 | return false; 27 | } 28 | 29 | // Don't allow stage channels - which have linked text chat 30 | else if (targetChannel.type === ChannelType.GuildStageVoice) { 31 | console.error(stripIndents` 32 | [${ msgId }] Can't send watch-list message, targetChannelId channel is NOT a valid text based channel 33 | `); 34 | return false; 35 | } 36 | 37 | // Configuration is valid 38 | else return targetChannel; 39 | }; 40 | 41 | module.exports = { isValidWebhookConfigMessage }; 42 | -------------------------------------------------------------------------------- /src/commands/developer/set-avatar.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | 4 | module.exports = new ChatInputCommand({ 5 | permLevel: 'Developer', 6 | // Global rate limit of 5 requests per hour 7 | cooldown: { 8 | usages: 5, 9 | duration: 3600, 10 | type: 'global' 11 | }, 12 | data: { 13 | description: 'Update the bot\'s avatar', 14 | options: [ 15 | { 16 | type: ApplicationCommandOptionType.Attachment, 17 | name: 'avatar', 18 | description: 'The bot\'s new avatar', 19 | required: true 20 | } 21 | ] 22 | }, 23 | 24 | run: async (client, interaction) => { 25 | const { member, options } = interaction; 26 | const { emojis } = client.container; 27 | const attachment = options.getAttachment('avatar'); 28 | 29 | // Check content type 30 | if (!attachment.contentType.startsWith('image/')) { 31 | interaction.reply(`${ emojis.error } ${ member }, expected an image - you provided **\`${ attachment.contentType }\`** instead, this command has been cancelled`); 32 | return; 33 | } 34 | 35 | client.user 36 | .setAvatar(attachment.url) 37 | .then(() => interaction.reply(`${ emojis.success } ${ member }, avatar was successfully updated!`)) 38 | .catch((err) => interaction.reply(`${ emojis.error } ${ member }, couldn't update bot's avatar:\n\n${ err.message }`)); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | // Require our shared environmental file as early as possible 2 | require('dotenv').config({ path: './config/.env' }); 3 | 4 | // Importing from packages 5 | const chalk = require('chalk'); 6 | const logger = require('@mirasaki/logger'); 7 | let express; 8 | 9 | // Try to import express 10 | try { 11 | express = require('express'); 12 | } 13 | catch (err) { 14 | logger.syserr('You have enabled "USE_API" in the .env file, but missing the "express" dependency, to address this - run the "npm install express" command. This is done to minimize dependencies as most users don\'t require the command API'); 15 | process.exit(1); 16 | } 17 | 18 | // Importing our routes 19 | const commandRoutes = require('./commands.routes'); 20 | 21 | // Destructure from our environmental file 22 | // Set our default port to 3000 if it's missing from environmental file 23 | const { NODE_ENV, PORT = 3000 } = process.env; 24 | 25 | /*** 26 | * Initialize our express app 27 | */ 28 | const app = express(); 29 | 30 | // Routes Middleware 31 | app.get('/', (req, res) => res.sendStatus(200)); 32 | app.use('/api/commands', commandRoutes); 33 | 34 | // Serving our generated client documentation as root 35 | app.use( 36 | '/', 37 | express.static('docs', { extensions: [ 'html' ] }) 38 | ); 39 | 40 | // Serving our static public files 41 | app.use(express.static('public')); 42 | 43 | // Actively listen for requests to our API/backend 44 | app.listen( 45 | PORT, 46 | logger.success(chalk.yellow.bold(`API running in ${ NODE_ENV }-mode on port ${ PORT }`)) 47 | ); 48 | -------------------------------------------------------------------------------- /config/.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------- Required variables ------------------------- # 2 | NODE_ENV=production 3 | DISCORD_BOT_TOKEN= 4 | CLIENT_ID= 5 | 6 | # ------------------------- CFTools ------------------------- # 7 | CFTOOLS_API_APPLICATION_ID= 8 | CFTOOLS_API_SECRET= 9 | 10 | 11 | 12 | 13 | 14 | # Everything else is optional 15 | 16 | # Optional - Use if you're getting the following error: 17 | # Error: Failed to launch the browser process! 18 | # Provide a path here to your Chromium binary executable 19 | # More info: https://wiki.mirasaki.dev/docs/cftools-discord-bot/configuration#optional-dotenv 20 | PATH_TO_CHROME_EXECUTABLE= 21 | 22 | # ------------------------- Debug variables ------------------------- # 23 | DEBUG_ENABLED=false 24 | DEBUG_SLASH_COMMAND_API_DATA=true 25 | DEBUG_INTERACTIONS=false 26 | DEBUG_AUTOCOMPLETE_RESPONSE_TIME=true 27 | DEBUG_MODAL_SUBMIT_RESPONSE_TIME=true 28 | DEBUG_COMMAND_THROTTLING=false 29 | 30 | # ------------------------- API Application Command Data ------------------------- # 31 | REFRESH_SLASH_COMMAND_API_DATA=true 32 | CLEAR_SLASH_COMMAND_API_DATA=false 33 | 34 | # ------------------------- API / Server ------------------------- # 35 | USE_API=false 36 | PORT=3000 37 | 38 | # ------------------------- File Paths ------------------------- # 39 | CHAT_INPUT_COMMAND_DIR=src/commands 40 | CONTEXT_MENU_COMMAND_DIR=src/context-menus 41 | AUTO_COMPLETE_INTERACTION_DIR=src/interactions/autocomplete 42 | BUTTON_INTERACTION_DIR=src/interactions/buttons 43 | MODAL_INTERACTION_DIR=src/interactions/modals 44 | SELECT_MENU_INTERACTION_DIR=src/interactions/select-menus 45 | 46 | -------------------------------------------------------------------------------- /src/commands/system/support.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { colorResolver } = require('../../util'); 4 | 5 | module.exports = new ChatInputCommand({ 6 | global: true, 7 | cooldown: { 8 | // Use channel cooldown type instead of default member, 9 | type: 'channel', 10 | usages: 1, 11 | duration: 15 12 | }, 13 | clientPerms: [ 'EmbedLinks' ], 14 | data: { 15 | name: 'support', 16 | description: 'Get a link to this bot\'s support server' 17 | }, 18 | 19 | run: (client, interaction) => { 20 | interaction.reply({ embeds: [ 21 | { 22 | // Not passing an parameter to colorResolver 23 | // will fall-back to client.container.colors.main 24 | color: colorResolver(), 25 | author: { 26 | name: client.user.username, 27 | iconURL: client.user.avatarURL({ dynamic: true }) 28 | }, 29 | // Strip our indentation using common-tags 30 | description: stripIndents` 31 | [${ client.user.username } Support Server](${ client.container.config.supportServerInviteLink } "${ client.user.username } Support Server") 32 | 33 | **__Use this server for:__** 34 | \`\`\`diff 35 | + Any issues you need support with 36 | + Bug reports 37 | + Giving feedback 38 | + Feature requests & suggestions 39 | + Testing beta features & commands 40 | + Be notified of updates 41 | \`\`\` 42 | ` 43 | } 44 | ] }); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /src/interactions/autocomplete/statistic.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ComponentCommand } = require('../../classes/Commands'); 3 | const { getServerConfigCommandOptionValue } = require('../../modules/cftClient'); 4 | const { getStatisticOptions } = require('../../modules/leaderboard'); 5 | 6 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 7 | // Declarations 8 | const serverCfg = getServerConfigCommandOptionValue(interaction); 9 | const activeServerStatOptions = getStatisticOptions(serverCfg.LEADERBOARD_STATS); 10 | 11 | // Getting our search query's results 12 | // Structuring our result for Discord's API 13 | return activeServerStatOptions.filter( 14 | ({ name }) => name.toLowerCase().indexOf(query) >= 0 15 | ); 16 | 17 | // Don't sort 18 | // .sort((a, b) => a.name.localeCompare(b.name)); 19 | } }); 20 | 21 | // Can't spread in required option if directly exported 22 | // because the type will have been resolved 23 | const statisticAutoCompleteOptionIdentifier = 'statistic'; 24 | module.exports.statisticAutoCompleteOptionIdentifier = statisticAutoCompleteOptionIdentifier; 25 | const statisticAutoCompleteOption = { 26 | name: statisticAutoCompleteOptionIdentifier, 27 | description: 'The statistic to rank players by', 28 | type: ApplicationCommandOptionType.String, 29 | autocomplete: true, 30 | required: false 31 | }; 32 | module.exports.statisticAutoCompleteOption = statisticAutoCompleteOption; 33 | module.exports.requiredStatisticAutoCompleteOption = { 34 | ...statisticAutoCompleteOption, 35 | required: true 36 | }; 37 | -------------------------------------------------------------------------------- /vendor/@mirasaki/logger/README.md: -------------------------------------------------------------------------------- 1 | # logger 2 | 3 | ## Require the package 4 | ```js 5 | const logger = require('@mirasaki/logger'); 6 | ``` 7 | 8 | ## Example Usage 9 | ```js 10 | logger.syslog('Start initializing...'); 11 | logger.syserr('Encountered error while trying to connect to database'); 12 | logger.success(`Client initialized after ${logger.getExecutionTime(process.hrtime())}`); 13 | logger.info('Fetching data from API...'); 14 | logger.debug(`Execution time: ${logger.getExecutionTime(process.hrtime())}`); 15 | logger.startLog('Application Command Data'); 16 | console.table( 17 | [ 18 | { 19 | name: 'help', 20 | description: 'Display general information' 21 | }, 22 | { 23 | name: 'start', 24 | description: 'Start task' 25 | } 26 | ] 27 | ); 28 | logger.endLog('Application Command Data'); 29 | ``` 30 | 31 | ### Outputs: 32 | ![](https://i.postimg.cc/BZLdKP0N/Windows-Terminal-5-KQj-Dfpp-KR.png "Preview unavailable") 33 | 34 | ## Example error logging 35 | ```js 36 | // catch (err) {} or .catch((err) => {}) 37 | logger.syserr(`An error has occurred while executing the /zz command`); 38 | logger.printErr(err); 39 | ``` 40 | 41 | ## Outputs: 42 | ![](https://i.postimg.cc/L5X4mf77/Code-8-Qs-Tu-WF23-Z.png "Preview unavailable") 43 | 44 | ## Functions 45 | - `syslog` 46 | - `syserr` 47 | - `success` 48 | - `info` 49 | - `debug` 50 | - `database` 51 | - `startLog` & `endLog` 52 | - `timestamp`: Returns the formatted timestamp for consistency 53 | - `getExecutionTime`: Pass `process.hrtime()` to get precise, formatted `timeSince` output 54 | - `printErr`: print an error object to the console in color -------------------------------------------------------------------------------- /src/interactions/autocomplete/command.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ComponentCommand } = require('../../classes/Commands'); 3 | const { isAppropriateCommandFilter } = require('../../handlers/commands'); 4 | 5 | module.exports = new ComponentCommand({ run: async (client, interaction, query) => { 6 | const { member } = interaction; 7 | // Filtering out unusable commands 8 | const { commands, contextMenus } = client.container; 9 | const workingCmdMap = commands.concat(contextMenus) 10 | .filter((cmd) => isAppropriateCommandFilter(member, cmd)); 11 | 12 | // Getting our search query's results 13 | const queryResult = workingCmdMap.filter( 14 | (cmd) => cmd.data.name.toLowerCase().indexOf(query) >= 0 15 | // Filtering matches by category 16 | || cmd.category.toLowerCase().indexOf(query) >= 0 17 | ); 18 | 19 | // Structuring our result for Discord's API 20 | return queryResult 21 | .map((cmd) => ({ 22 | name: cmd.data.name, value: cmd.data.name 23 | })) 24 | .sort((a, b) => a.name.localeCompare(b.name)); 25 | } }); 26 | 27 | // Can't spread in required option if directly exported 28 | // because the type will have been resolved 29 | const commandAutoCompleteOption = { 30 | type: ApplicationCommandOptionType.String, 31 | name: 'command', 32 | description: 'Command name or category', 33 | autocomplete: true, 34 | required: false 35 | }; 36 | module.exports.commandAutoCompleteOption = commandAutoCompleteOption; 37 | 38 | module.exports.requiredCommandAutoCompleteOption = { 39 | ...commandAutoCompleteOption, 40 | required: true 41 | }; 42 | -------------------------------------------------------------------------------- /development.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:19 2 | 3 | # Docker Puppeteer reference: 4 | # https://pptr.dev/guides/docker 5 | # https://github.com/puppeteer/puppeteer/blob/main/docker/Dockerfile 6 | 7 | # Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others) 8 | # Note: this installs the necessary libs to make the bundled version of Chrome that Puppeteer 9 | # installs, work. 10 | RUN apt-get update --no-install-recommends\ 11 | && apt-get install -y --no-install-recommends wget gnupg \ 12 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg \ 13 | && sh -c 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 14 | && apt-get update \ 15 | && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst fonts-freefont-ttf libxss1 \ 16 | --no-install-recommends \ 17 | && rm -rf /var/lib/apt/lists/* 18 | 19 | # Create app/working/bot directory 20 | RUN mkdir -p /app 21 | WORKDIR /app 22 | 23 | # Install app development dependencies 24 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 25 | # where available (npm@5+) 26 | COPY package*.json ./ 27 | RUN npm install --include=dev 28 | 29 | # Bundle app source 30 | COPY . ./ 31 | 32 | # API port 33 | EXPOSE 3000 34 | 35 | # Show current folder structure in logs 36 | # RUN ls -al -R 37 | 38 | # Run the start command 39 | CMD [ "npx", "nodemon", "--inspect=0.0.0.0:9229", "src/index.js" ] 40 | -------------------------------------------------------------------------------- /src/interactions/select-menus/help.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { generateCommandInfoEmbed, generateCommandOverviewEmbed } = require('../../handlers/commands'); 3 | const { HELP_COMMAND_SELECT_MENU, HELP_SELECT_MENU_SEE_MORE_OPTIONS } = require('../../constants'); 4 | const { ComponentCommand } = require('../../classes/Commands'); 5 | 6 | module.exports = new ComponentCommand({ 7 | data: { name: HELP_COMMAND_SELECT_MENU }, 8 | 9 | run: async (client, interaction) => { 10 | const { 11 | commands, contextMenus, emojis 12 | } = client.container; 13 | const selectTargetValue = interaction.values[0]; 14 | const { member } = interaction; 15 | 16 | // Check max entries notifier - show default page 17 | if (selectTargetValue === HELP_SELECT_MENU_SEE_MORE_OPTIONS) { 18 | // Reply to the interaction with our embed 19 | interaction.update({ embeds: [ generateCommandOverviewEmbed(commands, interaction) ] }); 20 | return; 21 | } 22 | 23 | // Check valid command 24 | const clientCmd = commands.get(selectTargetValue) 25 | || contextMenus.get(selectTargetValue) 26 | || undefined; 27 | 28 | if (!clientCmd) { 29 | interaction.update({ 30 | content: `${ emojis.error } ${ member }, I couldn't find the command **\`/${ selectTargetValue }\`**`, 31 | ephemeral: true 32 | }); 33 | logger.syserr(`Unknown Select Menu Target Value received for Help Command Select Menu: "${ selectTargetValue }"`); 34 | return; 35 | } 36 | 37 | // Update the interaction with the requested command data 38 | const embedData = generateCommandInfoEmbed(clientCmd, interaction); 39 | 40 | interaction.update({ embeds: [ embedData ] }); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /src/interactions/modals/evalSubmit.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags/lib'); 2 | const { ActionRowBuilder, ButtonBuilder } = require('discord.js'); 3 | const { ComponentCommand } = require('../../classes/Commands'); 4 | const { 5 | EVAL_CODE_INPUT, ACCEPT_EVAL_CODE_EXECUTION, DECLINE_EVAL_CODE_EXECUTION, EVAL_CODE_MODAL 6 | } = require('../../constants'); 7 | const { colorResolver } = require('../../util'); 8 | 9 | module.exports = new ComponentCommand({ 10 | // Overwriting the default file name with our owm custom component id 11 | data: { name: EVAL_CODE_MODAL }, 12 | run: async (client, interaction) => { 13 | const { member } = interaction; 14 | const { emojis } = client.container; 15 | 16 | // Defer our reply 17 | await interaction.deferReply(); 18 | 19 | // Code Input 20 | const codeInput = interaction.fields.getTextInputValue(EVAL_CODE_INPUT); 21 | 22 | // Verification prompt 23 | await interaction.editReply({ 24 | content: `${ emojis.wait } ${ member }, are you sure you want to evaluate the following code:`, 25 | embeds: [ 26 | { 27 | color: colorResolver(), 28 | description: stripIndents` 29 | \`\`\`js 30 | ${ codeInput } 31 | \`\`\` 32 | ` 33 | } 34 | ], 35 | components: [ 36 | new ActionRowBuilder().addComponents( 37 | new ButtonBuilder() 38 | .setCustomId(ACCEPT_EVAL_CODE_EXECUTION) 39 | .setLabel('Accept') 40 | .setStyle('Success'), 41 | new ButtonBuilder() 42 | .setCustomId(DECLINE_EVAL_CODE_EXECUTION) 43 | .setLabel('Decline') 44 | .setStyle('Danger') 45 | ) 46 | ] 47 | }); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /src/modules/delayed-kill-feed.js: -------------------------------------------------------------------------------- 1 | const { serverConfig } = require('./cftClient'); 2 | const { isValidWebhookConfigMessage } = require('./webhooks'); 3 | 4 | const checkIsDelayedKillFeedMsg = async (msg) => { 5 | const { 6 | channelId, 7 | webhookId, 8 | author, 9 | cleanContent 10 | } = msg; 11 | for await (const cfg of serverConfig) { 12 | const { 13 | USE_KILL_FEED, 14 | KILL_FEED_DELAY, 15 | KILL_FEED_CHANNEL_ID, 16 | CFTOOLS_WEBHOOK_CHANNEL_ID, 17 | CFTOOLS_WEBHOOK_USER_ID, 18 | KILL_FEED_MESSAGE_IDENTIFIER, 19 | KILL_FEED_REMOVE_IDENTIFIER 20 | } = cfg; 21 | const isKillMsg = cleanContent.indexOf( 22 | KILL_FEED_MESSAGE_IDENTIFIER 23 | ?? ' got killed by ' 24 | ) >= 0; 25 | if (!isKillMsg) continue; 26 | 27 | const isTargetChannel = channelId === CFTOOLS_WEBHOOK_CHANNEL_ID; 28 | const isTargetUser = CFTOOLS_WEBHOOK_USER_ID === ( 29 | process.env.NODE_ENV === 'production' 30 | ? webhookId 31 | : author.id 32 | ); 33 | 34 | // Validate target ids/is webhook message 35 | if (!USE_KILL_FEED || !isTargetChannel || !isTargetUser) continue; 36 | 37 | // Resolve target channel 38 | const webhookTargetChannel = isValidWebhookConfigMessage(msg, KILL_FEED_CHANNEL_ID); 39 | if (!webhookTargetChannel) continue; 40 | 41 | // Send the notification 42 | await new Promise((resolve) => { 43 | setTimeout(async () => { 44 | const feedMsg = await webhookTargetChannel.send( 45 | KILL_FEED_REMOVE_IDENTIFIER 46 | ? cleanContent.replaceAll(KILL_FEED_MESSAGE_IDENTIFIER, '') 47 | : cleanContent 48 | ); 49 | resolve(feedMsg); 50 | }, KILL_FEED_DELAY * 1000); 51 | }); 52 | } 53 | }; 54 | 55 | module.exports = { checkIsDelayedKillFeedMsg }; 56 | -------------------------------------------------------------------------------- /src/modules/watch-list.js: -------------------------------------------------------------------------------- 1 | const { serverConfig } = require('./cftClient'); 2 | const { getGuildSettings } = require('./db'); 3 | const { isValidWebhookConfigMessage } = require('./webhooks'); 4 | 5 | const checkIsWatchListMsg = async (msg) => { 6 | const { 7 | guild, 8 | channelId, 9 | webhookId, 10 | author, 11 | cleanContent 12 | } = msg; 13 | const isJoined = cleanContent.indexOf(' joined from ') >= 0; 14 | if (!isJoined) return false; 15 | 16 | const settings = getGuildSettings(guild.id); 17 | const { watchList } = settings; 18 | 19 | if (!watchList) return false; 20 | 21 | for await (const cfg of serverConfig) { 22 | const { 23 | WATCH_LIST_CHANNEL_ID, 24 | WATCH_LIST_NOTIFICATION_ROLE_ID, 25 | CFTOOLS_WEBHOOK_CHANNEL_ID, 26 | CFTOOLS_WEBHOOK_USER_ID 27 | } = cfg; 28 | const isTargetChannel = channelId === CFTOOLS_WEBHOOK_CHANNEL_ID; 29 | const isTargetUser = CFTOOLS_WEBHOOK_USER_ID === ( 30 | process.env.NODE_ENV === 'production' 31 | ? webhookId 32 | : author.id 33 | ); 34 | 35 | // Validate target ids/is webhook message 36 | if (!isTargetChannel || !isTargetUser) continue; 37 | 38 | // Resolve target channel 39 | const webhookTargetChannel = isValidWebhookConfigMessage(msg, WATCH_LIST_CHANNEL_ID); 40 | if (!webhookTargetChannel) continue; 41 | 42 | // Resolve watch-list 43 | const activePlayerEntry = watchList.watchIds.find((e) => cleanContent.indexOf(e) >= 0); 44 | if (!activePlayerEntry) continue; 45 | 46 | // Send the notification 47 | const text = `<@&${ WATCH_LIST_NOTIFICATION_ROLE_ID }> - A player that's on the watch-list just logged in!\n[**\`${ activePlayerEntry }\`**](https://app.cftools.cloud/profile/${ activePlayerEntry })`; 48 | await webhookTargetChannel.send(text); 49 | } 50 | }; 51 | 52 | module.exports = { checkIsWatchListMsg }; 53 | -------------------------------------------------------------------------------- /src/commands/admin/broadcast.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, getServerConfigCommandOptionValue, broadcastMessage 5 | } = require('../../modules/cftClient'); 6 | 7 | module.exports = new ChatInputCommand({ 8 | permLevel: 'Administrator', 9 | global: true, 10 | data: { 11 | description: 'Broadcast a message to the entire server', 12 | options: [ 13 | requiredServerConfigCommandOption, 14 | { 15 | type: ApplicationCommandOptionType.String, 16 | name: 'message', 17 | description: 'Message to send to specified player', 18 | required: true, 19 | min_length: 3, 20 | max_length: 256 21 | } 22 | ] 23 | }, 24 | 25 | run: async (client, interaction) => { 26 | // Destructuring 27 | const { member, options } = interaction; 28 | const { emojis } = client.container; 29 | 30 | // Deferring our reply 31 | await interaction.deferReply(); 32 | 33 | // Check if a proper server option is provided 34 | const serverCfg = getServerConfigCommandOptionValue(interaction); 35 | 36 | // Assignment 37 | const message = options.getString('message'); 38 | 39 | // Checking message length 40 | if (message.length > 256) { 41 | interaction.editReply({ content: `${ emojis.error } ${ member }, message content can't be over \`256\` characters long - this command has been cancelled` }); 42 | return; 43 | } 44 | 45 | // Sending message to server 46 | const res = await broadcastMessage(serverCfg.CFTOOLS_SERVER_API_ID, message); 47 | if (res !== true) { 48 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - message might not have broadcasted.` }); 49 | return; 50 | } 51 | 52 | // User feedback on success 53 | interaction.editReply({ content: `${ emojis.success } ${ member }, message delivered and displayed to everyone online.\n\n\`\`\`${ message }\`\`\`` }); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/commands/admin/wipe-world-ai.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | getServerConfigCommandOptionValue, 6 | postGameLabsAction, 7 | broadcastMessage 8 | } = require('../../modules/cftClient'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | permLevel: 'Administrator', 12 | global: true, 13 | data: { 14 | description: 'Wipe/clear all AI from the world', 15 | options: [ 16 | requiredServerConfigCommandOption, 17 | { 18 | name: 'notify-players', 19 | description: 'Send a global notification to the players, default true', 20 | type: ApplicationCommandOptionType.Boolean, 21 | required: false 22 | } 23 | ] 24 | }, 25 | 26 | run: async (client, interaction) => { 27 | // Destructuring 28 | const { member, options } = interaction; 29 | const { emojis } = client.container; 30 | const notifyPlayers = options.getBoolean('notify-players') ?? true; 31 | 32 | // Deferring our reply 33 | await interaction.deferReply(); 34 | 35 | // Resolve options 36 | const serverCfg = getServerConfigCommandOptionValue(interaction); 37 | 38 | // Performing request 39 | const res = await postGameLabsAction( 40 | serverCfg.CFTOOLS_SERVER_API_ID, 41 | 'CFCloud_WorldWipeAI', 42 | 'world', 43 | null, 44 | {} 45 | ); 46 | 47 | if (res !== true) { 48 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - AI might not have been wiped` }); 49 | return; 50 | } 51 | 52 | // Notify player 53 | if (notifyPlayers) { 54 | await broadcastMessage( 55 | serverCfg.CFTOOLS_SERVER_API_ID, 56 | 'All AI has been wiped/cleared by an administrator (this might take a while to take effect)' 57 | ); 58 | } 59 | 60 | // User feedback on success 61 | interaction.editReply({ content: `${ emojis.success } ${ member }, all world AI has been wiped/cleared` }); 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /src/commands/admin/wipe-world-vehicles.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | getServerConfigCommandOptionValue, 6 | postGameLabsAction, 7 | broadcastMessage 8 | } = require('../../modules/cftClient'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | permLevel: 'Administrator', 12 | global: true, 13 | data: { 14 | description: 'Wipe/clear all vehicles from the world', 15 | options: [ 16 | requiredServerConfigCommandOption, 17 | { 18 | name: 'notify-players', 19 | description: 'Send a global notification to the players, default true', 20 | type: ApplicationCommandOptionType.Boolean, 21 | required: false 22 | } 23 | ] 24 | }, 25 | 26 | run: async (client, interaction) => { 27 | // Destructuring 28 | const { member, options } = interaction; 29 | const { emojis } = client.container; 30 | const notifyPlayers = options.getBoolean('notify-players') ?? true; 31 | 32 | // Deferring our reply 33 | await interaction.deferReply(); 34 | 35 | // Resolve options 36 | const serverCfg = getServerConfigCommandOptionValue(interaction); 37 | 38 | // Performing request 39 | const res = await postGameLabsAction( 40 | serverCfg.CFTOOLS_SERVER_API_ID, 41 | 'CFCloud_WorldWipeVehicles', 42 | 'world', 43 | null, 44 | {} 45 | ); 46 | 47 | if (res !== true) { 48 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - vehicles might not have been wiped` }); 49 | return; 50 | } 51 | 52 | // Notify player 53 | if (notifyPlayers) { 54 | await broadcastMessage( 55 | serverCfg.CFTOOLS_SERVER_API_ID, 56 | 'All vehicles has been wiped/cleared by an administrator (this might take a while to take effect)' 57 | ); 58 | } 59 | 60 | // User feedback on success 61 | interaction.editReply({ content: `${ emojis.success } ${ member }, all world vehicles have been wiped/cleared` }); 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /src/commands/dayz/player-list.js: -------------------------------------------------------------------------------- 1 | const { ServerApiId } = require('cftools-sdk'); 2 | const { 3 | getServerConfigCommandOptionValue, 4 | handleCFToolsError, 5 | cftClient, 6 | serverConfigCommandOption 7 | } = require('../../modules/cftClient'); 8 | const { ChatInputCommand } = require('../../classes/Commands'); 9 | const { doMaxLengthChunkReply } = require('../../util'); 10 | 11 | module.exports = new ChatInputCommand({ 12 | global: true, 13 | cooldown: { 14 | usages: 1, 15 | duration: 30 16 | }, 17 | data: { 18 | description: 'View the online player list - has sensitive information', 19 | options: [ serverConfigCommandOption ] 20 | }, 21 | 22 | 23 | run: async (client, interaction) => { 24 | // Destructuring 25 | const { guild, member } = interaction; 26 | const { emojis } = client.container; 27 | 28 | // Deferring our reply 29 | await interaction.deferReply(); 30 | 31 | // Check if a proper server option is provided 32 | const serverCfg = getServerConfigCommandOptionValue(interaction); 33 | 34 | // Fetch sessions 35 | let sessions; 36 | try { 37 | sessions = await cftClient 38 | .listGameSessions({ serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID) }); 39 | } 40 | catch (err) { 41 | handleCFToolsError(interaction, err); 42 | return; 43 | } 44 | 45 | // Check availability 46 | if (!sessions || !sessions[0]) { 47 | interaction.editReply(`${ emojis.error } ${ member }, no one is currently online on **\`${ serverCfg.NAME }\`**`); 48 | return; 49 | } 50 | 51 | // Destructure sessions from data and map our player strings 52 | const playerMap = sessions.map((session) => `• ${ session.playerName ?? 'Survivor' }`); 53 | const output = `**Players online:** ${ sessions.length }\n\n${ playerMap.join('\n') ?? '-' }`; 54 | 55 | // Ok, we might have 1 line, or over 15k characters 56 | // Handle that accordingly 57 | doMaxLengthChunkReply(interaction, output, { 58 | title: `Online Player list for ${ serverCfg.NAME }`, 59 | titleIcon: guild.iconURL({ dynamic: true }) 60 | }); 61 | } 62 | }); 63 | -------------------------------------------------------------------------------- /src/commands/admin/set-weather-sunny.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | getServerConfigCommandOptionValue, 6 | postGameLabsAction, 7 | broadcastMessage 8 | } = require('../../modules/cftClient'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | permLevel: 'Administrator', 12 | global: true, 13 | data: { 14 | description: 'Change the current in-game weather to by clear & sunny', 15 | options: [ 16 | requiredServerConfigCommandOption, 17 | { 18 | name: 'notify-players', 19 | description: 'Send a global notification to the players, default true', 20 | type: ApplicationCommandOptionType.Boolean, 21 | required: false 22 | } 23 | ] 24 | }, 25 | 26 | run: async (client, interaction) => { 27 | // Destructuring 28 | const { member, options } = interaction; 29 | const { emojis } = client.container; 30 | const notifyPlayers = options.getBoolean('notify-players') ?? true; 31 | 32 | // Deferring our reply 33 | await interaction.deferReply(); 34 | 35 | // Resolve options 36 | const serverCfg = getServerConfigCommandOptionValue(interaction); 37 | 38 | // Performing request 39 | const res = await postGameLabsAction( 40 | serverCfg.CFTOOLS_SERVER_API_ID, 41 | 'CFCloud_WorldWeatherSunny', 42 | 'world', 43 | null, 44 | {} 45 | ); 46 | 47 | if (res !== true) { 48 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - weather might not have been updated to clear/sunny` }); 49 | return; 50 | } 51 | 52 | // Notify player 53 | if (notifyPlayers) { 54 | await broadcastMessage( 55 | serverCfg.CFTOOLS_SERVER_API_ID, 56 | 'The weather has been changed to clear/sunny by an administrator (this might take a while to take effect)' 57 | ); 58 | } 59 | 60 | // User feedback on success 61 | interaction.editReply({ content: `${ emojis.success } ${ member }, weather has been changed to **\`sunny/clear\`**` }); 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /vendor/@mirasaki/logger/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mirasaki/logger", 3 | "version": "1.0.6", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@mirasaki/logger", 9 | "version": "1.0.6", 10 | "license": "~~ do whatever you want ~~", 11 | "dependencies": { 12 | "chalk": "^4.1.2", 13 | "moment": "^2.29.3" 14 | } 15 | }, 16 | "node_modules/ansi-styles": { 17 | "version": "4.3.0", 18 | "license": "MIT", 19 | "dependencies": { 20 | "color-convert": "^2.0.1" 21 | }, 22 | "engines": { 23 | "node": ">=8" 24 | }, 25 | "funding": { 26 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 27 | } 28 | }, 29 | "node_modules/chalk": { 30 | "version": "4.1.2", 31 | "license": "MIT", 32 | "dependencies": { 33 | "ansi-styles": "^4.1.0", 34 | "supports-color": "^7.1.0" 35 | }, 36 | "engines": { 37 | "node": ">=10" 38 | }, 39 | "funding": { 40 | "url": "https://github.com/chalk/chalk?sponsor=1" 41 | } 42 | }, 43 | "node_modules/color-convert": { 44 | "version": "2.0.1", 45 | "license": "MIT", 46 | "dependencies": { 47 | "color-name": "~1.1.4" 48 | }, 49 | "engines": { 50 | "node": ">=7.0.0" 51 | } 52 | }, 53 | "node_modules/color-name": { 54 | "version": "1.1.4", 55 | "license": "MIT" 56 | }, 57 | "node_modules/has-flag": { 58 | "version": "4.0.0", 59 | "license": "MIT", 60 | "engines": { 61 | "node": ">=8" 62 | } 63 | }, 64 | "node_modules/moment": { 65 | "version": "2.30.1", 66 | "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", 67 | "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", 68 | "license": "MIT", 69 | "engines": { 70 | "node": "*" 71 | } 72 | }, 73 | "node_modules/supports-color": { 74 | "version": "7.2.0", 75 | "license": "MIT", 76 | "dependencies": { 77 | "has-flag": "^4.0.0" 78 | }, 79 | "engines": { 80 | "node": ">=8" 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/system/help.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { 3 | getCommandSelectMenu, 4 | generateCommandOverviewEmbed, 5 | generateCommandInfoEmbed 6 | } = require('../../handlers/commands'); 7 | const { commandAutoCompleteOption } = require('../../interactions/autocomplete/command'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | global: true, 11 | aliases: [ 'commands' ], 12 | cooldown: { 13 | // Use user cooldown type instead of default member 14 | type: 'user', 15 | usages: 2, 16 | duration: 10 17 | }, 18 | clientPerms: [ 'EmbedLinks' ], 19 | data: { 20 | description: 'Receive detailed command information', 21 | options: [ commandAutoCompleteOption ] 22 | }, 23 | 24 | run: (client, interaction) => { 25 | // Destructuring 26 | const { member } = interaction; 27 | const { 28 | commands, contextMenus, emojis 29 | } = client.container; 30 | 31 | // Check for optional autocomplete focus 32 | const commandName = interaction.options.getString('command'); 33 | const hasCommandArg = commandName !== null && typeof commandName !== 'undefined'; 34 | 35 | // Show command overview if no command parameter is supplied 36 | if (!hasCommandArg) { 37 | // Getting our command select menu, re-used 38 | const cmdSelectMenu = getCommandSelectMenu(member); 39 | 40 | // Reply to the interaction with our embed 41 | interaction.reply({ 42 | embeds: [ generateCommandOverviewEmbed(commands, interaction) ], 43 | components: [ cmdSelectMenu ] 44 | }); 45 | return; 46 | } 47 | 48 | // Request HAS optional command argument 49 | // Assigning our data 50 | const clientCmd = commands.get(commandName) 51 | || contextMenus.get(commandName); 52 | 53 | // Checking if the commandName is a valid client command 54 | if (!clientCmd) { 55 | interaction.reply({ 56 | content: `${ emojis.error } ${ member }, I couldn't find the command **\`/${ commandName }\`**`, 57 | ephemeral: true 58 | }); 59 | return; 60 | } 61 | 62 | // Replying with our command information embed 63 | interaction.reply({ embeds: [ 64 | generateCommandInfoEmbed( 65 | clientCmd, 66 | interaction 67 | ) 68 | ] }); 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /src/modules/db.js: -------------------------------------------------------------------------------- 1 | // Use lokijs for database, a super fast in-memory 2 | // javascript document oriented database with fs persistency. 3 | const loki = require('lokijs'); 4 | const fsAdapter = new loki.LokiFsAdapter(); 5 | const pkg = require('../../package.json'); 6 | const logger = require('@mirasaki/logger'); 7 | const chalk = require('chalk'); 8 | 9 | // Initialize our db + collections 10 | const db = new loki(`${ pkg.name }.db`, { 11 | adapter: fsAdapter, 12 | env: 'NODEJS', 13 | autosave: true, 14 | autosaveInterval: 3600, 15 | autoload: true, 16 | autoloadCallback: initializeDatabase 17 | }); 18 | 19 | // Implement the autoLoadCallback referenced in loki constructor 20 | function initializeDatabase (err) { 21 | if (err) { 22 | logger.syserr('Error encountered while loading database from disk persistence:'); 23 | logger.printErr(err); 24 | return; 25 | } 26 | 27 | // Resolve guilds collection 28 | db.getCollection('guilds') 29 | ?? db.addCollection('guilds', { unique: [ 'guildId' ] }); 30 | 31 | // Kick off any program logic or start listening to external events 32 | runProgramLogic(); 33 | } 34 | 35 | // example method with any bootstrap logic to run after database initialized 36 | const runProgramLogic = () => { 37 | const guildCount = db.getCollection('guilds').count(); 38 | logger.success(`Initialized ${ chalk.yellowBright(guildCount) } guild setting document${ guildCount === 1 ? '' : 's' }`); 39 | }; 40 | 41 | // Utility function so save database as a reusable function 42 | const saveDb = (cb) => db 43 | .saveDatabase((err) => { 44 | if (err) { 45 | logger.syserr('Error encountered while saving database to disk:'); 46 | logger.printErr(err); 47 | } 48 | if (typeof cb === 'function') cb(); 49 | }); 50 | // Utility function for resolving guild settings 51 | const getGuildSettings = (guildId) => { 52 | const guilds = db.getCollection('guilds'); 53 | let settings = guilds.by('guildId', guildId); 54 | if (!settings) { 55 | // [DEV] - Add config validation 56 | guilds.insertOne({ 57 | guildId, 58 | watchList: null 59 | }); 60 | settings = guilds.by('guildId', guildId); 61 | } 62 | 63 | if (process.env.NODE_ENV !== 'production') console.dir(settings); 64 | return settings; 65 | }; 66 | 67 | module.exports = { 68 | db, 69 | saveDb, 70 | getGuildSettings 71 | }; 72 | -------------------------------------------------------------------------------- /src/commands/moderator/heal-player.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredPlayerSessionOption, 5 | requiredServerConfigCommandOption, 6 | getPlayerSessionOptionValue, 7 | cftClient, 8 | messageSurvivor 9 | } = require('../../modules/cftClient'); 10 | const { ChatInputCommand } = require('../../classes/Commands'); 11 | const { ServerApiId } = require('cftools-sdk'); 12 | const { ApplicationCommandOptionType } = require('discord.js'); 13 | 14 | module.exports = new ChatInputCommand({ 15 | global: true, 16 | permLevel: 'Moderator', 17 | data: { 18 | description: 'Heal a player that is currently online', 19 | options: [ 20 | requiredServerConfigCommandOption, 21 | requiredPlayerSessionOption, 22 | { 23 | name: 'notify-player', 24 | description: 'Send a DM to the player as a notification, default true', 25 | type: ApplicationCommandOptionType.Boolean, 26 | required: false 27 | } 28 | 29 | ] 30 | }, 31 | run: async (client, interaction) => { 32 | // Destructuring and assignments 33 | const { member } = interaction; 34 | const { emojis } = client.container; 35 | const notifyPlayer = interaction.options.getBoolean('notify-player') ?? true; 36 | const serverCfg = getServerConfigCommandOptionValue(interaction); 37 | 38 | // Deferring our reply 39 | await interaction.deferReply(); 40 | 41 | // Check session, might have logged out 42 | const session = await getPlayerSessionOptionValue(interaction); 43 | if (!session) { 44 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 45 | return; 46 | } 47 | 48 | // Try to perform heal 49 | try { 50 | await cftClient.healPlayer({ 51 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 52 | session 53 | }); 54 | } 55 | catch (err) { 56 | handleCFToolsError(interaction, err); 57 | return; 58 | } 59 | 60 | // Notify player 61 | if (notifyPlayer) { 62 | await messageSurvivor(serverCfg.CFTOOLS_SERVER_API_ID, session.id, 'You have been healed by an administrator'); 63 | } 64 | 65 | // Ok, feedback 66 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been healed` }); 67 | } 68 | }); 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/commands/moderator/kick-player.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredPlayerSessionOption, 5 | requiredServerConfigCommandOption, 6 | getPlayerSessionOptionValue, 7 | kickPlayer 8 | } = require('../../modules/cftClient'); 9 | const { ChatInputCommand } = require('../../classes/Commands'); 10 | const { ApplicationCommandOptionType } = require('discord.js'); 11 | 12 | module.exports = new ChatInputCommand({ 13 | global: true, 14 | permLevel: 'Moderator', 15 | data: { 16 | description: 'Kick a player that is currently online', 17 | options: [ 18 | requiredServerConfigCommandOption, 19 | requiredPlayerSessionOption, 20 | { 21 | name: 'reason', 22 | description: 'The reason for this kick, required', 23 | type: ApplicationCommandOptionType.String, 24 | required: true, 25 | min_length: 1, 26 | max_length: 128 27 | } 28 | ] 29 | }, 30 | run: async (client, interaction) => { 31 | // Destructuring and assignments 32 | const { member, options } = interaction; 33 | const { emojis } = client.container; 34 | const reason = options.getString('reason') ?? 'n/a'; 35 | const serverCfg = getServerConfigCommandOptionValue(interaction); 36 | 37 | // Deferring our reply 38 | await interaction.deferReply(); 39 | 40 | // Check session, might have logged out 41 | const session = await getPlayerSessionOptionValue(interaction); 42 | if (!session) { 43 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 44 | return; 45 | } 46 | 47 | // Try to perform kick 48 | let res; 49 | try { 50 | res = await kickPlayer( 51 | serverCfg.CFTOOLS_SERVER_API_ID, 52 | session.id, 53 | reason 54 | ); 55 | } 56 | catch (err) { 57 | handleCFToolsError(interaction, err); 58 | return; 59 | } 60 | 61 | // Check has failed 62 | if (res !== true) { 63 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - **\`${ session.playerName }\`** might not have been kicked` }); 64 | return; 65 | } 66 | 67 | // Ok, feedback 68 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been kicked` }); 69 | } 70 | }); 71 | 72 | 73 | -------------------------------------------------------------------------------- /tutorials/api-docs.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | ## URL Query Parameters 4 | 5 | The following URL Query parameter apply to all subsequent command routes **without a /:name parameter** 6 | 7 | ```json 8 | { 9 | "category": "The category to filter by", 10 | "limit": "The maximum amount of commands to return" 11 | } 12 | 13 | ``` 14 | 15 | ## Chat Input Commands 16 | 17 | GET all application Chat Input commands 18 | 19 | **URL** : `/api/commands` 20 | 21 | **Find by name** : `/api/commands/:name` 22 | 23 | **Method** : `GET` 24 | 25 | **Response Code** : `200 OK` 26 | 27 | **Response Content** : Returns an array of client Chat Input commands with your specified command structure 28 | 29 | ## Context Menu Commands 30 | 31 | GET all application Context Menu Commands 32 | 33 | **URL** : `/api/commands/context-menus` 34 | 35 | **Find by name** : `/api/commands/context-menus/:name` 36 | 37 | **Method** : `GET` 38 | 39 | **Response Code** : `200 OK` 40 | 41 | **Response Content** : Returns an array of client Context Menu Commands with your specified command structure 42 | 43 | ## Auto-complete options 44 | 45 | GET all application Auto-complete options 46 | 47 | **URL** : `/api/commands/auto-complete` 48 | 49 | **Find by name** : `/api/commands/auto-complete/:name` 50 | 51 | **Method** : `GET` 52 | 53 | **Response Code** : `200 OK` 54 | 55 | **Response Content** : Returns an array of client Auto-complete options with your specified command structure 56 | 57 | ## Button Actions 58 | 59 | GET all application Button Actions 60 | 61 | **URL** : `/api/commands/buttons` 62 | 63 | **Find by name** : `/api/commands/buttons/:name` 64 | 65 | **Method** : `GET` 66 | 67 | **Response Code** : `200 OK` 68 | 69 | **Response Content** : Returns an array of client Button Actions with your specified command structure 70 | 71 | ## Modal Prompts 72 | 73 | GET all application Modal Prompts 74 | 75 | **URL** : `/api/commands/modals` 76 | 77 | **Find by name** : `/api/commands/modals/:name` 78 | 79 | **Method** : `GET` 80 | 81 | **Response Code** : `200 OK` 82 | 83 | **Response Content** : Returns an array of client Modal Prompts with your specified command structure 84 | 85 | ## Select Menu's 86 | 87 | GET all application Select Menu's 88 | 89 | **URL** : `/api/commands/select-menus` 90 | 91 | **Find by name** : `/api/commands/select-menus/:name` 92 | 93 | **Method** : `GET` 94 | 95 | **Response Code** : `200 OK` 96 | 97 | **Response Content** : Returns an array of client Select Menu's with your specified command structure 98 | -------------------------------------------------------------------------------- /src/commands/moderator/kill-player.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | requiredPlayerSessionOption, 6 | getServerConfigCommandOptionValue, 7 | getPlayerSessionOptionValue, 8 | messageSurvivor, 9 | postGameLabsAction 10 | } = require('../../modules/cftClient'); 11 | 12 | module.exports = new ChatInputCommand({ 13 | permLevel: 'Administrator', 14 | global: true, 15 | data: { 16 | description: 'Kill a player that is currently online', 17 | options: [ 18 | requiredServerConfigCommandOption, 19 | requiredPlayerSessionOption, 20 | { 21 | name: 'notify-player', 22 | description: 'Send a DM to the player as a notification, default true', 23 | type: ApplicationCommandOptionType.Boolean, 24 | required: false 25 | } 26 | ] 27 | }, 28 | 29 | run: async (client, interaction) => { 30 | // Destructuring 31 | const { member, options } = interaction; 32 | const { emojis } = client.container; 33 | const notifyPlayer = options.getBoolean('notify-player') ?? true; 34 | 35 | // Deferring our reply 36 | await interaction.deferReply(); 37 | 38 | // Resolve options 39 | const serverCfg = getServerConfigCommandOptionValue(interaction); 40 | const session = await getPlayerSessionOptionValue(interaction); 41 | 42 | // Check session, might have logged out 43 | if (!session) { 44 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 45 | return; 46 | } 47 | 48 | // Performing request 49 | const res = await postGameLabsAction( 50 | serverCfg.CFTOOLS_SERVER_API_ID, 51 | 'CFCloud_KillPlayer', 52 | 'player', 53 | session.steamId.id 54 | ); 55 | 56 | if (res !== true) { 57 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - **\`${ session.playerName }\`** might not have been killed` }); 58 | return; 59 | } 60 | 61 | // Notify player 62 | if (notifyPlayer) { 63 | await messageSurvivor(serverCfg.CFTOOLS_SERVER_API_ID, session.id, 'You have been killed by an administrator'); 64 | } 65 | 66 | // User feedback on success 67 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been killed!` }); 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /src/commands/admin/dm-survivor.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | requiredPlayerSessionOption, 6 | getServerConfigCommandOptionValue, 7 | getPlayerSessionOptionValue, 8 | messageSurvivor 9 | } = require('../../modules/cftClient'); 10 | 11 | module.exports = new ChatInputCommand({ 12 | permLevel: 'Administrator', 13 | global: true, 14 | data: { 15 | description: 'Send a private message to an online survivor', 16 | options: [ 17 | requiredServerConfigCommandOption, 18 | requiredPlayerSessionOption, 19 | { 20 | type: ApplicationCommandOptionType.String, 21 | name: 'message', 22 | description: 'Message to send to specified player', 23 | required: true, 24 | min_length: 3, 25 | max_length: 256 26 | } 27 | ] 28 | }, 29 | 30 | run: async (client, interaction) => { 31 | // Destructuring 32 | const { member, options } = interaction; 33 | const { emojis } = client.container; 34 | 35 | // Deferring our reply 36 | await interaction.deferReply(); 37 | 38 | // Resolve options 39 | const serverCfg = getServerConfigCommandOptionValue(interaction); 40 | const session = await getPlayerSessionOptionValue(interaction); 41 | const message = options.getString('message'); 42 | 43 | // Check session, might have logged out 44 | if (!session) { 45 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 46 | return; 47 | } 48 | 49 | // Checking message length 50 | if (message.length > 256) { 51 | interaction.editReply({ content: `${ emojis.error } ${ member }, message content can't be over \`256\` characters long - this command has been cancelled` }); 52 | return; 53 | } 54 | 55 | // Sending message to survivor 56 | const res = await messageSurvivor(serverCfg.CFTOOLS_SERVER_API_ID, session.id, message); 57 | if (res !== true) { 58 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - message might not have been DM'ed to **\`${ session.playerName }\`**` }); 59 | return; 60 | } 61 | 62 | // User feedback on success 63 | interaction.editReply({ content: `${ emojis.success } ${ member }, message delivered to **\`${ session.playerName }\`**.\n\n\`\`\`${ message }\`\`\`` }); 64 | } 65 | }); 66 | -------------------------------------------------------------------------------- /src/listeners/interaction/autoCompleteInteraction.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { AUTOCOMPLETE_MAX_DATA_OPTIONS } = require('../../constants'); 3 | const { getRuntime } = require('../../util'); 4 | const chalk = require('chalk'); 5 | 6 | // Destructure from env 7 | const { DEBUG_AUTOCOMPLETE_RESPONSE_TIME } = process.env; 8 | 9 | module.exports = async (client, interaction) => { 10 | // guild property is present and available, 11 | // we check in the main interactionCreate.js file 12 | 13 | // Destructure from interaction and client container 14 | const { commandName } = interaction; 15 | const { autoCompletes } = client.container; 16 | 17 | // Start our timer for performance logging 18 | const autoResponseQueryStart = process.hrtime.bigint(); 19 | 20 | // Get our command name query 21 | const query = interaction.options.getFocused()?.toLowerCase() || ''; 22 | const activeOption = interaction.options._hoistedOptions.find(({ focused }) => focused === true)?.name ?? ''; 23 | let autoCompleteQueryHandler = autoCompletes.get(activeOption); 24 | 25 | // Check if a query handler is found 26 | if (!autoCompleteQueryHandler) { 27 | if (activeOption.startsWith('player-')) autoCompleteQueryHandler = autoCompletes.get('player'); 28 | else { 29 | logger.syserr(`Missing AutoComplete query handler for the "${ activeOption }" option in the ${ commandName } command`); 30 | return; 31 | } 32 | } 33 | 34 | // Getting the result 35 | const result = await autoCompleteQueryHandler.run(client, interaction, query); 36 | 37 | // Returning our query result 38 | interaction.respond( 39 | // Slicing of the first 25 results, which is max allowed by Discord 40 | result?.slice(0, AUTOCOMPLETE_MAX_DATA_OPTIONS) || [] 41 | ).catch((err) => { 42 | // Unknown Interaction Error 43 | if (err.code === 10062) { 44 | logger.debug(`Error code 10062 (UNKNOWN_INTERACTION) encountered while responding to autocomplete query in ${ commandName } - this interaction probably expired.`); 45 | } 46 | 47 | // Handle unexpected errors 48 | else { 49 | logger.syserr(`Unknown error encountered while responding to autocomplete query in ${ commandName }`); 50 | console.error(err.stack || err); 51 | } 52 | }); 53 | 54 | // Performance logging if requested depending on environment 55 | if (DEBUG_AUTOCOMPLETE_RESPONSE_TIME === 'true') { 56 | logger.debug(`<${ chalk.cyanBright(commandName) }> | Auto Complete | Queried "${ chalk.green(query) }" in ${ getRuntime(autoResponseQueryStart).ms } ms`); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # LokiJS database 2 | *.db 3 | *.db~ 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # JSON config file 14 | config.js 15 | servers.js 16 | 17 | # Eslint output 18 | linter-output.txt 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | *.lcov 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # TypeScript v1 declaration files 56 | typings/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Microbundle cache 68 | .rpt2_cache/ 69 | .rts2_cache_cjs/ 70 | .rts2_cache_es/ 71 | .rts2_cache_umd/ 72 | 73 | # Optional REPL history 74 | .node_repl_history 75 | 76 | # Output of 'npm pack' 77 | *.tgz 78 | 79 | # Yarn Integrity file 80 | .yarn-integrity 81 | 82 | # dotenv environment variables file 83 | .env 84 | .env.test 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | 89 | # Next.js build output 90 | .next 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # Serverless directories 106 | .serverless/ 107 | 108 | # FuseBox cache 109 | .fusebox/ 110 | 111 | # DynamoDB Local files 112 | .dynamodb/ 113 | 114 | # TernJS port file 115 | .tern-port 116 | 117 | # Ignore our local development test files 118 | src/commands/testing 119 | 120 | # Documentation: JSDoc 121 | /docs 122 | 123 | # Personal TODO file 124 | TODO.md 125 | 126 | # In-development, Intellisense for events/listeners 127 | typings.d.ts 128 | -------------------------------------------------------------------------------- /src/commands/moderator/explode-player.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | requiredPlayerSessionOption, 6 | getServerConfigCommandOptionValue, 7 | getPlayerSessionOptionValue, 8 | messageSurvivor, 9 | postGameLabsAction 10 | } = require('../../modules/cftClient'); 11 | 12 | module.exports = new ChatInputCommand({ 13 | permLevel: 'Administrator', 14 | global: true, 15 | data: { 16 | description: 'Explode a player that is currently online', 17 | options: [ 18 | requiredServerConfigCommandOption, 19 | requiredPlayerSessionOption, 20 | { 21 | name: 'notify-player', 22 | description: 'Send a DM to the player as a notification, default true', 23 | type: ApplicationCommandOptionType.Boolean, 24 | required: false 25 | } 26 | ] 27 | }, 28 | 29 | run: async (client, interaction) => { 30 | // Destructuring 31 | const { member, options } = interaction; 32 | const { emojis } = client.container; 33 | const notifyPlayer = options.getBoolean('notify-player') ?? true; 34 | 35 | // Deferring our reply 36 | await interaction.deferReply(); 37 | 38 | // Resolve options 39 | const serverCfg = getServerConfigCommandOptionValue(interaction); 40 | const session = await getPlayerSessionOptionValue(interaction); 41 | 42 | // Check session, might have logged out 43 | if (!session) { 44 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 45 | return; 46 | } 47 | 48 | // Performing request 49 | const res = await postGameLabsAction( 50 | serverCfg.CFTOOLS_SERVER_API_ID, 51 | 'CFCloud_ExplodePlayer', 52 | 'player', 53 | session.steamId.id 54 | ); 55 | 56 | if (res !== true) { 57 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - **\`${ session.playerName }\`** might not have been killed by explosion` }); 58 | return; 59 | } 60 | 61 | // Notify player 62 | if (notifyPlayer) { 63 | await messageSurvivor(serverCfg.CFTOOLS_SERVER_API_ID, session.id, 'You have been killed by an explosion, as executed by an administrator'); 64 | } 65 | 66 | // User feedback on success 67 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been killed by an explosion!` }); 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /tutorials/adding-commands.md: -------------------------------------------------------------------------------- 1 | # Adding commands 2 | 3 | Create a new file **anywhere** in the [command root folder](/src/commands/) 4 | 5 | - This can be anywhere: Directly inside the root folder, or deep-nested up to 25 levels deep. 6 | 7 | Import/require the [ChatInputCommand](https://djs.mirasaki.dev/ChatInputCommand.html) from `/src/classes/Commands.js` 8 | 9 | ```javascript 10 | // This example assumes you created a file in a new folder inside the command root folder, example: /src/commands/level/rank.js 11 | 12 | const { ChatInputCommand } = require('../../classes/Commands'); 13 | ``` 14 | 15 | Export the defined class after calling the constructor using the `new` keyword, and supply an object as the first, and only, parameter 16 | 17 | ```javascript 18 | module.exports = new ChatInputCommand({ 19 | 20 | }); 21 | ``` 22 | 23 | Since this is a [Discord Application Command](https://discord.com/developers/docs/interactions/application-commands "Source @ discord.dev"), we will have to define a name. The API data is defined in the `data` property. If no name is provided, the filename without extension is the default. 24 | 25 | ```javascript 26 | module.exports = new ChatInputCommand({ 27 | data: { name: 'rank' } 28 | }); 29 | ``` 30 | 31 | The **only** thing we will have to provide, is the `run` parameter, which is the fallback executed when the command is invoked. 32 | 33 | ```javascript 34 | module.exports = new ChatInputCommand({ 35 | run: async (client, interaction) => { 36 | // Code to run when the command is invoked. 37 | } 38 | }); 39 | ``` 40 | 41 | When you're done developing your command, you should make it available to every guild/server instead of just our testing environment 42 | 43 | ```javascript 44 | module.exports = new ChatInputCommand({ 45 | global: true, 46 | run: async (client, interaction) => { 47 | // Code to run when the command is invoked. 48 | } 49 | }); 50 | ``` 51 | 52 | Optionally, throttle the command to avoid abuse 53 | 54 | ```javascript 55 | module.exports = new ChatInputCommand({ 56 | global: true, 57 | cooldown: { 58 | type: 'member' // Default cooldown type 59 | usages: 2, // Command can be used twice 60 | duration: 10 // in 10 seconds 61 | }, 62 | run: async (client, interaction) => { 63 | // Code to run when the command is invoked. 64 | } 65 | }); 66 | ``` 67 | 68 | **Once again:** You only have to define the `run` function. For command configuration defaults, see [BaseConfig](https://djs.mirasaki.dev/global.html#BaseConfig "documentation") and [APICommandConfig](https://djs.mirasaki.dev/global.html#APICommandConfig "documentation") 69 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const EMBED_MAX_FIELDS_LENGTH = 25; 4 | const EMBED_MAX_CHARACTER_LENGTH = 6000; 5 | const EMBED_TITLE_MAX_LENGTH = 256; 6 | const EMBED_DESCRIPTION_MAX_LENGTH = 4096; 7 | const EMBED_FIELD_NAME_MAX_LENGTH = 256; 8 | const EMBED_FIELD_VALUE_MAX_LENGTH = 1024; 9 | const EMBED_FOOTER_TEXT_MAX_LENGTH = 2048; 10 | const EMBED_AUTHOR_NAME_MAX_LENGTH = 256; 11 | 12 | const MESSAGE_CONTENT_MAX_LENGTH = 2000; 13 | const SELECT_MENU_MAX_OPTIONS = 25; 14 | const AUTOCOMPLETE_MAX_DATA_OPTIONS = 25; 15 | 16 | const BYTES_IN_KIB = 1024; 17 | const BYTES_IN_MIB = 1048576; 18 | const BYTES_IN_GIB = BYTES_IN_MIB * 1024; 19 | 20 | const NS_IN_ONE_MS = 1000000; 21 | const NS_IN_ONE_SECOND = 1e9; 22 | 23 | const MS_IN_ONE_SECOND = 1000; 24 | const MS_IN_ONE_MINUTE = 60000; 25 | const MS_IN_ONE_HOUR = 3600000; 26 | const MS_IN_ONE_DAY = 864e5; 27 | 28 | const SECONDS_IN_ONE_MINUTE = 60; 29 | const MINUTES_IN_ONE_HOUR = 60; 30 | const HOURS_IN_ONE_DAY = 24; 31 | 32 | const DEFAULT_DECIMAL_PRECISION = 2; 33 | 34 | /*** 35 | * Commands & Components 36 | */ 37 | 38 | // Help 39 | const HELP_COMMAND_SELECT_MENU = 'help_select_command'; 40 | const HELP_SELECT_MENU_SEE_MORE_OPTIONS = 'help_see_more'; 41 | 42 | // Eval / Evaluate 43 | const EVAL_CODE_MODAL = 'eval_code_modal'; 44 | const EVAL_CODE_INPUT = 'eval_code_input'; 45 | const ACCEPT_EVAL_CODE_EXECUTION = 'accept-eval-code-execution'; 46 | const DECLINE_EVAL_CODE_EXECUTION = 'decline-eval-code-execution'; 47 | 48 | const ZERO_WIDTH_SPACE_CHAR_CODE = 8203; 49 | const CFTOOLS_API_URL = 'https://data.cftools.cloud/v1'; 50 | 51 | 52 | module.exports = { 53 | EMBED_MAX_FIELDS_LENGTH, 54 | EMBED_MAX_CHARACTER_LENGTH, 55 | 56 | EMBED_TITLE_MAX_LENGTH, 57 | EMBED_DESCRIPTION_MAX_LENGTH, 58 | EMBED_FIELD_NAME_MAX_LENGTH, 59 | EMBED_FIELD_VALUE_MAX_LENGTH, 60 | EMBED_FOOTER_TEXT_MAX_LENGTH, 61 | EMBED_AUTHOR_NAME_MAX_LENGTH, 62 | 63 | MESSAGE_CONTENT_MAX_LENGTH, 64 | SELECT_MENU_MAX_OPTIONS, 65 | AUTOCOMPLETE_MAX_DATA_OPTIONS, 66 | 67 | BYTES_IN_KIB, 68 | BYTES_IN_MIB, 69 | BYTES_IN_GIB, 70 | 71 | NS_IN_ONE_MS, 72 | NS_IN_ONE_SECOND, 73 | 74 | MS_IN_ONE_SECOND, 75 | MS_IN_ONE_MINUTE, 76 | MS_IN_ONE_HOUR, 77 | MS_IN_ONE_DAY, 78 | 79 | SECONDS_IN_ONE_MINUTE, 80 | MINUTES_IN_ONE_HOUR, 81 | HOURS_IN_ONE_DAY, 82 | 83 | DEFAULT_DECIMAL_PRECISION, 84 | 85 | HELP_COMMAND_SELECT_MENU, 86 | HELP_SELECT_MENU_SEE_MORE_OPTIONS, 87 | 88 | EVAL_CODE_MODAL, 89 | EVAL_CODE_INPUT, 90 | ACCEPT_EVAL_CODE_EXECUTION, 91 | DECLINE_EVAL_CODE_EXECUTION, 92 | 93 | ZERO_WIDTH_SPACE_CHAR_CODE, 94 | CFTOOLS_API_URL 95 | }; 96 | -------------------------------------------------------------------------------- /src/commands/moderator/strip-player.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | requiredPlayerSessionOption, 6 | getServerConfigCommandOptionValue, 7 | getPlayerSessionOptionValue, 8 | messageSurvivor, 9 | postGameLabsAction 10 | } = require('../../modules/cftClient'); 11 | 12 | module.exports = new ChatInputCommand({ 13 | permLevel: 'Administrator', 14 | global: true, 15 | data: { 16 | description: 'Strip a player that is currently online, removing everything from their inventory', 17 | options: [ 18 | requiredServerConfigCommandOption, 19 | requiredPlayerSessionOption, 20 | { 21 | name: 'notify-player', 22 | description: 'Send a DM to the player as a notification, default true', 23 | type: ApplicationCommandOptionType.Boolean, 24 | required: false 25 | } 26 | ] 27 | }, 28 | 29 | run: async (client, interaction) => { 30 | // Destructuring 31 | const { member, options } = interaction; 32 | const { emojis } = client.container; 33 | const notifyPlayer = options.getBoolean('notify-player') ?? true; 34 | 35 | // Deferring our reply 36 | await interaction.deferReply(); 37 | 38 | // Resolve options 39 | const serverCfg = getServerConfigCommandOptionValue(interaction); 40 | const session = await getPlayerSessionOptionValue(interaction); 41 | 42 | // Check session, might have logged out 43 | if (!session) { 44 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 45 | return; 46 | } 47 | 48 | // Performing request 49 | const res = await postGameLabsAction( 50 | serverCfg.CFTOOLS_SERVER_API_ID, 51 | 'CFCloud_StripPlayer', 52 | 'player', 53 | session.steamId.id 54 | ); 55 | 56 | if (res !== true) { 57 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - **\`${ session.playerName }\`** might not have been killed by stripped` }); 58 | return; 59 | } 60 | 61 | // Notify player 62 | if (notifyPlayer) { 63 | await messageSurvivor(serverCfg.CFTOOLS_SERVER_API_ID, session.id, 'You have been stripped of all your possessions by an administrator'); 64 | } 65 | 66 | // User feedback on success 67 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been stripped of all their possessions!` }); 68 | } 69 | }); 70 | -------------------------------------------------------------------------------- /src/commands/dayz/server-info.js: -------------------------------------------------------------------------------- 1 | const { Game } = require('cftools-sdk'); 2 | const { 3 | getServerConfigCommandOptionValue, 4 | handleCFToolsError, 5 | cftClient, 6 | serverConfigCommandOption 7 | } = require('../../modules/cftClient'); 8 | const { ChatInputCommand } = require('../../classes/Commands'); 9 | const { colorResolver } = require('../../util'); 10 | const { resolveFlags, serverInfoOverviewEmbed } = require('../../modules/server-info'); 11 | 12 | module.exports = new ChatInputCommand({ 13 | global: true, 14 | cooldown: { 15 | usages: 2, 16 | duration: 30 17 | }, 18 | data: { 19 | description: 'Display general server information', 20 | options: [ serverConfigCommandOption ] 21 | }, 22 | run: async (client, interaction) => { 23 | // Destructuring 24 | const { guild } = interaction; 25 | const { colors, emojis } = client.container; 26 | 27 | // Deferring our reply 28 | await interaction.deferReply(); 29 | 30 | // Check if a proper server option is provided 31 | const serverCfg = getServerConfigCommandOptionValue(interaction); 32 | 33 | // Fetch sessions 34 | let data; 35 | try { 36 | data = await cftClient.getGameServerDetails({ 37 | game: Game.DayZ, 38 | ip: serverCfg.SERVER_IPV4, 39 | port: serverCfg.SERVER_PORT 40 | }); 41 | } 42 | catch (err) { 43 | handleCFToolsError(interaction, err); 44 | return; 45 | } 46 | 47 | // Set up context 48 | const ctx = { embeds: [] }; 49 | const { mods, attributes } = data; 50 | 51 | // Resolve flags 52 | const flags = resolveFlags({ attributes }); 53 | 54 | // Not currently a supported scheme in Discord 55 | // [Connect here](steam://connect/${ serverCfg.SERVER_IPV4 }:${ serverCfg.SERVER_PORT } "Connect through direct Steam link") 56 | 57 | // Overview embed 58 | ctx.embeds.push(serverInfoOverviewEmbed(data, flags, guild)); 59 | 60 | // If we have mods, prepare embed for every 25 entries 61 | if (serverCfg.SERVER_INFO_INCLUDE_MOD_LIST && mods && mods[0]) { 62 | for (let i = 0; i < mods.length; i += 25) { 63 | const chunk = mods.slice(i, i + 25); 64 | ctx.embeds.push({ 65 | title: i === 0 ? `Mod List (${ mods.length })` : null, 66 | color: colorResolver(data.online ? colors.success : colors.error), 67 | description: chunk.map(({ name, fileId }) => `${ emojis.separator } [${ name }](https://steamcommunity.com/sharedfiles/filedetails/?id=${ fileId })`).join('\n') 68 | }); 69 | } 70 | } 71 | 72 | // Send the information 73 | interaction.editReply(ctx); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /.devcontainer/arm64.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM arm64v8/node:20-slim 2 | 3 | # Please refer to https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#running-puppeteer-in-docker 4 | # for more information on running puppeteer in docker 5 | 6 | # Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others) 7 | # Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer 8 | # installs, work. 9 | RUN apt-get update \ 10 | && apt-get install -y wget gnupg \ 11 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 12 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 13 | && apt-get update \ 14 | && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \ 15 | --no-install-recommends \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | # If running Docker >= 1.13.0 use docker run's --init arg to reap zombie processes, otherwise 19 | # uncomment the following lines to have `dumb-init` as PID 1 20 | # ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_x86_64 /usr/local/bin/dumb-init 21 | # RUN chmod +x /usr/local/bin/dumb-init 22 | # ENTRYPOINT ["dumb-init", "--"] 23 | 24 | # Uncomment to skip the chromium download when installing puppeteer. If you do, 25 | # you'll need to launch puppeteer with: 26 | # browser.launch({executablePath: 'google-chrome-stable'}) 27 | # ENV PUPPETEER_SKIP_DOWNLOAD true 28 | 29 | # Create app/working/bot directory 30 | RUN mkdir -p /app 31 | WORKDIR /app 32 | 33 | # Install app production dependencies 34 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 35 | # where available (npm@5+) 36 | COPY package*.json ./ 37 | RUN npm ci --omit=dev 38 | 39 | # Install puppeteer so it's available in the container. 40 | # Add user so we don't need --no-sandbox. 41 | # same layer as npm install to keep re-chowned files from using up several hundred MBs more space 42 | RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ 43 | && mkdir -p /home/pptruser/Downloads \ 44 | && chown -R pptruser:pptruser /home/pptruser \ 45 | && chown -R pptruser:pptruser /app/node_modules \ 46 | && chown -R pptruser:pptruser /app/package.json \ 47 | && chown -R pptruser:pptruser /app/package-lock.json 48 | 49 | # Run everything after as non-privileged user. 50 | USER pptruser 51 | 52 | # Bundle app source 53 | COPY . ./ 54 | 55 | # Optional API/Backend port 56 | EXPOSE 3000 57 | 58 | # Run the start command 59 | CMD [ "npm", "run", "start" ] 60 | -------------------------------------------------------------------------------- /src/commands/admin/set-weather-stormy.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | getServerConfigCommandOptionValue, 6 | postGameLabsAction, 7 | broadcastMessage 8 | } = require('../../modules/cftClient'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | permLevel: 'Administrator', 12 | global: true, 13 | data: { 14 | description: 'Change the current in-game weather', 15 | options: [ 16 | requiredServerConfigCommandOption, 17 | { 18 | name: 'notify-players', 19 | description: 'Send a global notification to the players, default true', 20 | type: ApplicationCommandOptionType.Boolean, 21 | required: false 22 | } 23 | ] 24 | }, 25 | 26 | run: async (client, interaction) => { 27 | // Destructuring 28 | const { member, options } = interaction; 29 | const { emojis } = client.container; 30 | const notifyPlayers = options.getBoolean('notify-players') ?? true; 31 | 32 | // Deferring our reply 33 | await interaction.deferReply(); 34 | 35 | // Resolve options 36 | const serverCfg = getServerConfigCommandOptionValue(interaction); 37 | 38 | // Performing request 39 | const overcast = 1.00; 40 | const fog = .35; 41 | const rain = .9; 42 | const wind = .75; 43 | const res = await postGameLabsAction( 44 | serverCfg.CFTOOLS_SERVER_API_ID, 45 | 'CFCloud_WorldWeather', 46 | 'world', 47 | null, 48 | { 49 | overcast: { valueFloat: overcast }, 50 | fog: { valueFloat: fog }, 51 | rain: { valueFloat: rain }, 52 | wind: { valueInt: wind } 53 | } 54 | ); 55 | 56 | if (res !== true) { 57 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - weather might not have been updated to \`stormy\`` }); 58 | return; 59 | } 60 | 61 | // Resolve weather string 62 | const overcastStr = overcast.toFixed(2); 63 | const fogStr = fog.toFixed(2); 64 | const rainStr = rain.toFixed(2); 65 | const windStr = wind.toString().padStart(3, '0'); 66 | const weatherStr = `Overcast: ${ overcastStr }\nFog: ${ fogStr }\nRain: ${ rainStr }\nWind: ${ windStr }`; 67 | 68 | // Notify player 69 | if (notifyPlayers) { 70 | await broadcastMessage( 71 | serverCfg.CFTOOLS_SERVER_API_ID, 72 | 'The weather has been changed to stormy by an administrator (this might take a while to take effect)' 73 | ); 74 | } 75 | 76 | // User feedback on success 77 | interaction.editReply({ content: `${ emojis.success } ${ member }, weather has been changed to:\n\n\`\`\`${ weatherStr }\`\`\`` }); 78 | } 79 | }); 80 | -------------------------------------------------------------------------------- /src/context-menus/message/print-embed.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { MessageContextCommand } = require('../../classes/Commands'); 3 | const { EMBED_DESCRIPTION_MAX_LENGTH } = require('../../constants'); 4 | const { colorResolver } = require('../../util'); 5 | 6 | module.exports = new MessageContextCommand({ 7 | enabled: false, 8 | clientPerms: [ 'EmbedLinks' ], 9 | cooldown: { 10 | // Use guild type cooldown instead of default member 11 | type: 'guild', 12 | usages: 2, 13 | duration: 30 14 | }, 15 | data: { description: 'Display raw JSON for embeds attached to a message' }, 16 | 17 | run: async (client, interaction) => { 18 | // Destructure from interaction and client container 19 | const { 20 | member, targetId, channel 21 | } = interaction; 22 | const { emojis, colors } = client.container; 23 | 24 | // Deferring our reply 25 | await interaction.deferReply(); 26 | 27 | // Fetching the target 28 | let targetMessage; 29 | 30 | try { 31 | targetMessage = await channel.messages.fetch(targetId); 32 | } 33 | catch (err) { 34 | interaction.editReply({ content: `${ emojis.error } ${ member }, can't fetch message **\`${ targetId }\`**, please try again later.` }); 35 | logger.syserr(` Unable to fetch message ${ targetId }`); 36 | console.error(err.stack || err); 37 | return; 38 | } 39 | 40 | // Check Missing content intent 41 | // missing the \`messages.read\` scope in this server or the \`GuildMessages\` gateway intent 42 | const contentHidden = targetMessage.content === '' && !('embeds' in targetMessage); 43 | 44 | if (contentHidden) { 45 | interaction.editReply({ content: `${ emojis.error } ${ member }, I don't have permission to read that message.` }); 46 | return; 47 | } 48 | 49 | // Check has embed 50 | const msgHasEmbed = ('embeds' in targetMessage) && Array.isArray(targetMessage.embeds) && targetMessage.embeds[0]; 51 | 52 | if (!msgHasEmbed) { 53 | interaction.editReply({ content: `${ emojis.error } ${ member }, I can't find any embeds attached to this message. I might not have permission to read the message contents.` }); 54 | return; 55 | } 56 | 57 | 58 | // Print the embed to the member 59 | interaction.editReply({ embeds: targetMessage.embeds.map((embedData) => { 60 | // Getting our JSON string 61 | const jsonStr = `\`\`\`json\n${ JSON.stringify(embedData, null, 4)?.replace(/```/g, '\\`\\`\\`') }\n\`\`\``; 62 | 63 | return { 64 | description: jsonStr.length > EMBED_DESCRIPTION_MAX_LENGTH 65 | ? jsonStr.slice(0, EMBED_DESCRIPTION_MAX_LENGTH) 66 | : jsonStr, 67 | color: colorResolver(colors.main) 68 | }; 69 | }) }); 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /src/commands/dayz/statistics.js: -------------------------------------------------------------------------------- 1 | const { 2 | serverConfigCommandOption, 3 | getServerConfigCommandOptionValue, 4 | cftClient, 5 | handleCFToolsError 6 | } = require('../../modules/cftClient'); 7 | const { ApplicationCommandOptionType } = require('discord.js'); 8 | const { ChatInputCommand } = require('../../classes/Commands'); 9 | const { ServerApiId } = require('cftools-sdk'); 10 | const { playerStatisticsCtx } = require('../../modules/statistics'); 11 | 12 | // Testing - Frank Blank: 13 | // Steam64: 76561199350446127 14 | // CFToolsID: 63dc087c3572609bfffa52af 15 | 16 | module.exports = new ChatInputCommand({ 17 | global: true, 18 | cooldown: { 19 | type: 'member', 20 | usages: 5, 21 | duration: 60 22 | }, 23 | data: { 24 | description: 'Display information for a specific player', 25 | options: [ 26 | { 27 | type: ApplicationCommandOptionType.String, 28 | name: 'identifier', 29 | description: 'The player\'s Steam64, BattlEye GUID, or Bohemia Interactive Id', 30 | required: true 31 | }, 32 | serverConfigCommandOption 33 | ] 34 | }, 35 | 36 | run: async (client, interaction) => { 37 | // Destructuring and assignments 38 | const { options } = interaction; 39 | const identifier = options.getString('identifier'); 40 | const serverCfg = getServerConfigCommandOptionValue(interaction); 41 | 42 | // Deferring our reply 43 | await interaction.deferReply(); 44 | 45 | // Resolve identifier to cftools id 46 | // Doesn't actually work with cftools id 47 | // At least during my testing 48 | // let cftoolsId = identifier; 49 | // try { 50 | // ({ id: cftoolsId } = await cftClient.resolve({ id: identifier })); 51 | // } 52 | // catch { 53 | // // Continue silently 54 | // // Error is expected if identifier === cftoolsId 55 | // } 56 | 57 | // fetching from API 58 | let data; 59 | try { 60 | data = await cftClient.getPlayerDetails({ 61 | playerId: { id: identifier }, 62 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID) 63 | }); 64 | } 65 | catch (err) { 66 | handleCFToolsError(interaction, err); 67 | return; 68 | } 69 | 70 | // Dedicated function for stat calculations 71 | // and sending the result to reduce cognitive complexity 72 | let ctx; 73 | try { 74 | ctx = await playerStatisticsCtx(serverCfg, data); 75 | } 76 | catch (err) { 77 | interaction.editReply('An error occurred while processing the player\'s statistics. This is most likely an issue with the Chromium browser. Please try again later, or disable "STATISTICS_INCLUDE_ZONES_HEATMAP" in the server configuration.'); 78 | } 79 | 80 | // Sending our detailed player information 81 | interaction.editReply(ctx); 82 | } 83 | }); 84 | 85 | 86 | -------------------------------------------------------------------------------- /config/teleport-locations/chernarus.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Altar", 4 | "coordinates": [ 5 | 8170.614746, 6 | 474.052460, 7 | 9110.527344 8 | ] 9 | }, 10 | { 11 | "name": "Kotka", 12 | "coordinates": [ 13 | 5700.507813, 14 | 327.831665, 15 | 6903.870117 16 | ] 17 | }, 18 | { 19 | "name": "Zelenogorsk", 20 | "coordinates": [ 21 | 2599.177002, 22 | 195.030060, 23 | 5101.145996 24 | ] 25 | }, 26 | { 27 | "name": "Devil's Castle", 28 | "coordinates": [ 29 | 896.649414, 30 | 406.026917, 31 | 11418.560547 32 | ] 33 | }, 34 | { 35 | "name": "Severograd", 36 | "coordinates": [ 37 | 7971.697754, 38 | 112.126305, 39 | 12681.623047 40 | ] 41 | }, 42 | { 43 | "name": "Krasonstav", 44 | "coordinates": [ 45 | 11117.527344, 46 | 199.368347, 47 | 12287.526367 48 | ] 49 | }, 50 | { 51 | "name": "NWA - North-West Airfield", 52 | "coordinates": [ 53 | 4452.842285, 54 | 338.922455, 55 | 10138.835938 56 | ] 57 | }, 58 | { 59 | "name": "NEA - North-East Airfield", 60 | "coordinates": [ 61 | 11887.475586, 62 | 140.012497, 63 | 12464.815430 64 | ] 65 | }, 66 | { 67 | "name": "Klen", 68 | "coordinates": [ 69 | 11479.462891, 70 | 342.263763, 71 | 11335.789063 72 | ] 73 | }, 74 | { 75 | "name": "Berezino", 76 | "coordinates": [ 77 | 12313.470703, 78 | 11.296741, 79 | 9700.732422 80 | ] 81 | }, 82 | { 83 | "name": "Solnechny", 84 | "coordinates": [ 85 | 13452.717773, 86 | 6.141191, 87 | 6245.988281 88 | ] 89 | }, 90 | { 91 | "name": "Kamyshovo", 92 | "coordinates": [ 93 | 12056.701172, 94 | 6.090603, 95 | 3494.522461 96 | ] 97 | }, 98 | { 99 | "name": "Elektorzavodsk", 100 | "coordinates": [ 101 | 10269.119141, 102 | 5.765578, 103 | 2156.416504 104 | ] 105 | }, 106 | { 107 | "name": "Chernogorsk", 108 | "coordinates": [ 109 | 6455.633301, 110 | 6.000010, 111 | 2556.477539 112 | ] 113 | }, 114 | { 115 | "name": "Balota Airfield", 116 | "coordinates": [ 117 | 4817.540039, 118 | 8.704959, 119 | 2585.419922 120 | ] 121 | }, 122 | { 123 | "name": "Kamenka", 124 | "coordinates": [ 125 | 1878.224976, 126 | 6.192459, 127 | 2245.751953 128 | ] 129 | }, 130 | { 131 | "name": "Vybor", 132 | "coordinates": [ 133 | 3813.912598, 134 | 310.934540, 135 | 8878.819336 136 | ] 137 | }, 138 | { 139 | "name": "Stary Sobor", 140 | "coordinates": [ 141 | 6148.399902, 142 | 301.041931, 143 | 7716.379883 144 | ] 145 | } 146 | ] -------------------------------------------------------------------------------- /src/commands/admin/admin-player-list.js: -------------------------------------------------------------------------------- 1 | const { ServerApiId } = require('cftools-sdk'); 2 | const { 3 | requiredServerConfigCommandOption, 4 | getServerConfigCommandOptionValue, 5 | handleCFToolsError, 6 | cftClient 7 | } = require('../../modules/cftClient'); 8 | const { ChatInputCommand } = require('../../classes/Commands'); 9 | const { doMaxLengthChunkReply } = require('../../util'); 10 | const { ApplicationCommandOptionType } = require('discord.js'); 11 | 12 | module.exports = new ChatInputCommand({ 13 | permLevel: 'Administrator', 14 | global: true, 15 | data: { 16 | description: 'View the online player list - has sensitive information', 17 | options: [ 18 | requiredServerConfigCommandOption, 19 | { 20 | name: 'public', 21 | description: 'Displays the list to everyone if true, false by default', 22 | type: ApplicationCommandOptionType.Boolean, 23 | required: false 24 | } 25 | ] 26 | }, 27 | 28 | 29 | run: async (client, interaction) => { 30 | // Destructuring 31 | const { 32 | guild, member, options 33 | } = interaction; 34 | const { emojis } = client.container; 35 | 36 | // Deferring our reply 37 | const publicFlag = options.getBoolean('public'); 38 | const isEphemeral = typeof publicFlag === 'boolean' ? !publicFlag : true; 39 | await interaction.deferReply({ ephemeral: isEphemeral }); 40 | 41 | // Check if a proper server option is provided 42 | const serverCfg = getServerConfigCommandOptionValue(interaction); 43 | 44 | // Fetch sessions 45 | let sessions; 46 | try { 47 | sessions = await cftClient 48 | .listGameSessions({ serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID) }); 49 | } 50 | catch (err) { 51 | handleCFToolsError(interaction, err); 52 | return; 53 | } 54 | 55 | // Check availability 56 | if (!sessions || !sessions[0]) { 57 | interaction.editReply(`${ emojis.error } ${ member }, no one is currently online on **\`${ serverCfg.NAME }\`**`); 58 | return; 59 | } 60 | 61 | // Destructure sessions from data and map our player strings 62 | const playerLinkMap = sessions.map((session) => `• \`${ session.steamId.id }\` [${ session.profile?.private === true ? '🕵️' : '' }${ session.playerName ?? 'Survivor' }](https://app.cftools.cloud/profile/${ session.cftoolsId.id } "CFTools Cloud Profile") ([\`${ session.profile?.name ?? 'Unknown' }\`](https://steamcommunity.com/profiles/${ session.steamId.id }/ "Steam Account Profile"))`); 63 | const output = `**Players online:** ${ sessions.length }\n\n${ playerLinkMap.join('\n\n') ?? '-' }`; 64 | 65 | // Ok, we might have 1 line, or over 15k characters 66 | // Handle that accordingly 67 | doMaxLengthChunkReply(interaction, output, { 68 | title: `[ADMIN] Online Player list for ${ serverCfg.NAME }`, 69 | titleIcon: guild.iconURL({ dynamic: true }), 70 | ephemeral: isEphemeral 71 | }); 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /vendor/@mirasaki/logger/index.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const moment = require('moment'); 3 | 4 | const tagList = { 5 | SYSLOG: chalk.grey('[SYSLOG]'), 6 | SYSERR: chalk.red('[SYSERR]'), 7 | SUCCESS: chalk.green('[SUCCESS]'), 8 | INFO: chalk.blue('[INFO]'), 9 | DEBUG: chalk.magenta('[DEBUG]'), 10 | DATA: chalk.yellow('[DATA]'), 11 | COMMAND: chalk.white('[CMD]') 12 | }; 13 | 14 | const longestTagLength = Math.max(...Object.values(tagList).map(t => t.length)); 15 | const getTag = (tag) => `${tagList[tag]}${' '.repeat(longestTagLength - tagList[tag].length)}:`; 16 | const timestamp = () => `${chalk.cyan.bold(`[${moment.utc().format('HH:mm:ss')}]`)}`; 17 | 18 | module.exports = { 19 | syslog: (str) => console.info(`${timestamp()} ${getTag('SYSLOG')} ${str}`), 20 | syserr: (str) => console.error(`${timestamp()} ${getTag('SYSERR')} ${str}`), 21 | success: (str) => console.log(`${timestamp()} ${getTag('SUCCESS')} ${str}`), 22 | info: (str) => console.info(`${timestamp()} ${getTag('INFO')} ${str}`), 23 | debug: (str) => console.log(`${timestamp()} ${getTag('DEBUG')} ${str}`), 24 | data: (str) => console.log(`${timestamp()} ${getTag('DATA')} ${str}`), 25 | 26 | startLog: (identifier) => console.log(`${timestamp()} ${getTag('DEBUG')} ${chalk.greenBright('[START]')} ${identifier}`), 27 | endLog: (identifier) => console.log(`${timestamp()} ${getTag('DEBUG')} ${chalk.redBright('[ END ]')} ${identifier}`), 28 | 29 | timestamp, 30 | getExecutionTime: (hrtime) => { 31 | const timeSinceHrMs = ( 32 | process.hrtime(hrtime)[0] * 1000 33 | + hrtime[1] / 1000000 34 | ).toFixed(2); 35 | return `${chalk.yellowBright( 36 | (timeSinceHrMs / 1000).toFixed(2)) 37 | } seconds (${chalk.yellowBright(timeSinceHrMs)} ms)`; 38 | }, 39 | 40 | printErr: (err) => { 41 | if (!(err instanceof Error)) { 42 | console.error(err) 43 | return; 44 | } 45 | 46 | console.error( 47 | !err.stack 48 | ? chalk.red(err) 49 | : err.stack 50 | .split('\n') 51 | .map((msg, index) => { 52 | if (index === 0) { 53 | return chalk.red(msg); 54 | } 55 | 56 | const isFailedFunctionCall = index === 1; 57 | const traceStartIndex = msg.indexOf('('); 58 | const traceEndIndex = msg.lastIndexOf(')'); 59 | const hasTrace = traceStartIndex !== -1; 60 | const functionCall = msg.slice( 61 | msg.indexOf('at') + 3, 62 | hasTrace ? traceStartIndex - 1 : msg.length 63 | ); 64 | const trace = msg.slice(traceStartIndex, traceEndIndex + 1); 65 | 66 | return ` ${chalk.grey('at')} ${ 67 | isFailedFunctionCall 68 | ? `${chalk.redBright(functionCall)} ${chalk.red.underline(trace)}` 69 | : `${chalk.greenBright(functionCall)} ${chalk.grey(trace)}` 70 | }`; 71 | }) 72 | .join('\n') 73 | ) 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /src/commands/admin/change-game-time.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | getServerConfigCommandOptionValue, 6 | postGameLabsAction, 7 | broadcastMessage 8 | } = require('../../modules/cftClient'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | permLevel: 'Administrator', 12 | global: true, 13 | data: { 14 | description: 'Change the current in-game time', 15 | options: [ 16 | requiredServerConfigCommandOption, 17 | { 18 | name: 'hour', 19 | description: 'The hour to set the time to', 20 | type: ApplicationCommandOptionType.Integer, 21 | required: true, 22 | min_value: 0, 23 | max_value: 23 24 | }, 25 | { 26 | name: 'minute', 27 | description: 'The minute to set the time to', 28 | type: ApplicationCommandOptionType.Integer, 29 | required: true, 30 | min_value: 0, 31 | max_value: 59 32 | }, 33 | { 34 | name: 'notify-players', 35 | description: 'Send a global notification to the players, default true', 36 | type: ApplicationCommandOptionType.Boolean, 37 | required: false 38 | } 39 | ] 40 | }, 41 | 42 | run: async (client, interaction) => { 43 | // Destructuring 44 | const { member, options } = interaction; 45 | const { emojis } = client.container; 46 | const hour = options.getInteger('hour'); 47 | const minute = options.getInteger('minute'); 48 | const notifyPlayers = options.getBoolean('notify-players') ?? true; 49 | 50 | // Deferring our reply 51 | await interaction.deferReply(); 52 | 53 | // Resolve options 54 | const serverCfg = getServerConfigCommandOptionValue(interaction); 55 | 56 | // Performing request 57 | const res = await postGameLabsAction( 58 | serverCfg.CFTOOLS_SERVER_API_ID, 59 | 'CFCloud_WorldTime', 60 | 'world', 61 | null, 62 | { 63 | hour: { valueInt: hour }, 64 | minute: { valueInt: minute } 65 | } 66 | ); 67 | 68 | if (res !== true) { 69 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - time might not have been updated` }); 70 | return; 71 | } 72 | 73 | const hourStr = hour.toString().padStart(2, '0'); 74 | const minuteStr = minute.toString().padStart(2, '0'); 75 | const timeStr = `${ hourStr }:${ minuteStr }`; 76 | 77 | // Notify player 78 | if (notifyPlayers) { 79 | await broadcastMessage( 80 | serverCfg.CFTOOLS_SERVER_API_ID, 81 | `The time has been changed to ${ timeStr } by an administrator (this might take a while to take effect)` 82 | ); 83 | } 84 | 85 | // User feedback on success 86 | interaction.editReply({ content: `${ emojis.success } ${ member }, time has been changed to **\`${ timeStr }\`**!` }); 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /src/commands/developer/reload.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { requiredCommandAutoCompleteOption } = require('../../interactions/autocomplete/command'); 3 | const { colorResolver } = require('../../util'); 4 | 5 | /* 6 | I don't really see the value in having a reload command 7 | when there's options like nodemon available 8 | for active development, but still, it's here 9 | */ 10 | 11 | module.exports = new ChatInputCommand({ 12 | enabled: process.env.NODE_ENV !== 'production', 13 | permLevel: 'Developer', 14 | data: { 15 | description: 'Reload an active, existing command', 16 | options: [ requiredCommandAutoCompleteOption ] 17 | }, 18 | 19 | run: async (client, interaction) => { 20 | // Destructure 21 | const { member, options } = interaction; 22 | const { 23 | emojis, colors, commands 24 | } = client.container; 25 | 26 | // Variables definitions 27 | const commandName = options.getString('command'); 28 | const command = commands.get(commandName); 29 | 30 | // Check is valid command 31 | if (!command) { 32 | interaction.reply({ content: `${ emojis.error } ${ member }, couldn't find any commands named \`${ commandName }\`.` }); 33 | return; 34 | } 35 | 36 | // Deferring our reply 37 | await interaction.deferReply(); 38 | 39 | // Try to reload the command 40 | try { 41 | // Calling class#unload() doesn't refresh the collection 42 | // To avoid code repetition in Commands class file 43 | // We'll re-create the function in our /reload command 44 | 45 | // Removing from our collection 46 | commands.delete(commandName); 47 | 48 | // Getting and deleting our current cmd module cache 49 | const filePath = command.filePath; 50 | const module = require.cache[require.resolve(filePath)]; 51 | 52 | delete require.cache[require.resolve(filePath)]; 53 | for (let i = 0; i < module.children?.length; i++) { 54 | if (!module.children) break; 55 | if (module.children[i] === module) { 56 | module.children.splice(i, 1); 57 | break; 58 | } 59 | } 60 | 61 | const newCommand = require(filePath); 62 | 63 | newCommand.load(filePath, commands); 64 | } 65 | catch (err) { 66 | // Properly handling errors 67 | interaction.editReply({ content: `${ emojis.error } ${ member }, error encountered while reloading the command \`${ commandName }\`, click spoiler-block below to reveal.\n\n||${ err.stack || err }||` }); 68 | return; 69 | } 70 | 71 | // Command successfully reloaded 72 | interaction.editReply({ 73 | content: `${ emojis.success } ${ member }, reloaded the \`/${ commandName }\` command`, 74 | embeds: [ 75 | { 76 | color: colorResolver(colors.invisible), 77 | footer: { text: 'Don\'t forget to use the /deploy command if you made any changes to the command data object' } 78 | } 79 | ] 80 | }); 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /src/commands/teleport/teleport-to-coords.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredPlayerSessionOption, 5 | requiredServerConfigCommandOption, 6 | getPlayerSessionOptionValue, 7 | cftClient 8 | } = require('../../modules/cftClient'); 9 | const { ChatInputCommand } = require('../../classes/Commands'); 10 | const { ServerApiId } = require('cftools-sdk'); 11 | const { ApplicationCommandOptionType } = require('discord.js'); 12 | 13 | module.exports = new ChatInputCommand({ 14 | global: true, 15 | permLevel: 'Administrator', 16 | data: { 17 | description: 'Teleport a player that is currently online', 18 | options: [ 19 | requiredServerConfigCommandOption, 20 | requiredPlayerSessionOption, 21 | { 22 | name: 'x-coordinate', 23 | description: 'The X level coordinate to teleport the player to', 24 | type: ApplicationCommandOptionType.Number, 25 | required: true, 26 | min_value: 0.0000, 27 | max_value: 100000.000 28 | }, 29 | { 30 | name: 'y-coordinate', 31 | description: 'The Y level coordinate to teleport the player to', 32 | type: ApplicationCommandOptionType.Number, 33 | required: true, 34 | min_value: 0.0000, 35 | max_value: 100000.000 36 | }, 37 | { 38 | name: 'z-coordinate', 39 | description: 'The Z level coordinate to teleport the player to', 40 | type: ApplicationCommandOptionType.Number, 41 | required: true, 42 | min_value: 0.0000, 43 | max_value: 100000.000 44 | } 45 | ] 46 | }, 47 | run: async (client, interaction) => { 48 | // Destructuring and assignments 49 | const { member, options } = interaction; 50 | const { emojis } = client.container; 51 | const serverCfg = getServerConfigCommandOptionValue(interaction); 52 | const x = options.getNumber('x-coordinate'); 53 | const z = options.getNumber('y-coordinate'); 54 | const y = options.getNumber('z-coordinate'); 55 | 56 | // Deferring our reply 57 | await interaction.deferReply(); 58 | 59 | // Check session, might have logged out 60 | const session = await getPlayerSessionOptionValue(interaction); 61 | if (!session) { 62 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 63 | return; 64 | } 65 | 66 | // Try to perform teleport 67 | try { 68 | await cftClient.teleport({ 69 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 70 | session, 71 | coordinates: { 72 | // Why does the SDK switch y and z? =) 73 | x, y: z, z: y 74 | } 75 | }); 76 | } 77 | catch (err) { 78 | handleCFToolsError(interaction, err); 79 | return; 80 | } 81 | 82 | // Ok, feedback 83 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been teleported to \`<${ x }, ${ y }, ${ z }>\`` }); 84 | } 85 | }); 86 | 87 | 88 | -------------------------------------------------------------------------------- /src/commands/admin/spawn-item-on-player.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredPlayerSessionOption, 5 | requiredServerConfigCommandOption, 6 | getPlayerSessionOptionValue, 7 | cftClient 8 | } = require('../../modules/cftClient'); 9 | const { ChatInputCommand } = require('../../classes/Commands'); 10 | const { ServerApiId } = require('cftools-sdk'); 11 | const { ApplicationCommandOptionType } = require('discord.js'); 12 | 13 | module.exports = new ChatInputCommand({ 14 | global: true, 15 | permLevel: 'Administrator', 16 | data: { 17 | description: 'Give an item to a player that is currently online', 18 | options: [ 19 | requiredServerConfigCommandOption, 20 | requiredPlayerSessionOption, 21 | { 22 | name: 'item-class', 23 | description: 'The class name of the item to give to the player', 24 | type: ApplicationCommandOptionType.String, 25 | required: true, 26 | min_length: 1, 27 | max_length: 256 28 | }, 29 | { 30 | name: 'quantity', 31 | description: 'The quantity for this item, default is 1', 32 | type: ApplicationCommandOptionType.Number, 33 | required: false, 34 | min_value: 0.0000, 35 | max_value: 1000 36 | }, 37 | { 38 | name: 'stacked', 39 | description: 'Spawn items as a stack (only works if item supports to be stacked), default is false', 40 | type: ApplicationCommandOptionType.Boolean, 41 | required: false 42 | }, 43 | { 44 | name: 'debug', 45 | description: 'Use debug spawn method to automatically populate specific items', 46 | type: ApplicationCommandOptionType.Boolean, 47 | required: false 48 | } 49 | ] 50 | }, 51 | run: async (client, interaction) => { 52 | // Destructuring and assignments 53 | const { member, options } = interaction; 54 | const { emojis } = client.container; 55 | const serverCfg = getServerConfigCommandOptionValue(interaction); 56 | const itemClass = options.getString('item-class'); 57 | const quantity = options.getNumber('quantity') ?? 1; 58 | const stacked = options.getBoolean('stacked') ?? false; 59 | const debug = options.getBoolean('debug') ?? false; 60 | 61 | // Deferring our reply 62 | await interaction.deferReply(); 63 | 64 | // Check session, might have logged out 65 | const session = await getPlayerSessionOptionValue(interaction); 66 | if (!session) { 67 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 68 | return; 69 | } 70 | 71 | // Try to perform spawn 72 | try { 73 | await cftClient.spawnItem({ 74 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 75 | session, 76 | itemClass, 77 | quantity, 78 | stacked, 79 | debug 80 | }); 81 | } 82 | catch (err) { 83 | handleCFToolsError(interaction, err); 84 | return; 85 | } 86 | 87 | // Ok, feedback 88 | interaction.editReply({ content: `${ emojis.success } ${ member }, spawned **${ quantity }x** \`${ itemClass }\` on **\`${ session.playerName }\`**` }); 89 | } 90 | }); 91 | 92 | 93 | -------------------------------------------------------------------------------- /config/servers.example.js: -------------------------------------------------------------------------------- 1 | const { clientConfig } = require('../src/util'); 2 | const colors = require('../config/colors.json'); 3 | 4 | /** 5 | * For more information: 6 | * {@link https://wiki.mirasaki.dev/docs/cftools-discord-bot/server-configuration} 7 | */ 8 | module.exports = [ 9 | { 10 | // Server data 11 | NAME: 'My Server 😎', 12 | CFTOOLS_SERVER_API_ID: 'YOUR_SERVER_API_ID', 13 | SERVER_IPV4: '0.0.0.0', 14 | SERVER_PORT: 2302, 15 | CFTOOLS_WEBHOOK_CHANNEL_ID: '1182012252715499591', 16 | CFTOOLS_WEBHOOK_USER_ID: '290182686365188096', 17 | 18 | // Command config 19 | STATISTICS_INCLUDE_ZONES_HEATMAP: true, 20 | STATISTICS_KEEP_PUPPETEER_BROWSER_OPEN: true, 21 | STATISTICS_HIDE_PLAYER_NAME_HISTORY: true, 22 | SERVER_INFO_INCLUDE_MOD_LIST: true, 23 | 24 | // Live Discord > DayZ chat feed configuration 25 | USE_CHAT_FEED: true, 26 | CHAT_FEED_CHANNEL_IDS: [ '1182012252715499591' ], 27 | CHAT_FEED_REQUIRED_ROLE_IDS: [], 28 | CHAT_FEED_USE_DISCORD_PREFIX: true, 29 | CHAT_FEED_USE_DISPLAY_NAME: true, 30 | CHAT_FEED_MESSAGE_COOLDOWN: 2.5, 31 | CHAT_FEED_MAX_DISPLAY_NAME_LENGTH: 20, 32 | CHAT_FEED_DISCORD_TAGS: [ 33 | { 34 | roleIds: [ clientConfig.permissions.ownerId ], 35 | displayTag: '[OWNER]', 36 | color: colors.red 37 | }, 38 | { 39 | roleIds: clientConfig.permissions.administratorRoleIds, 40 | displayTag: '[ADMIN]', 41 | color: colors.red 42 | }, 43 | { 44 | roleIds: clientConfig.permissions.moderatorRoleIds, 45 | displayTag: '[MOD]', 46 | color: colors.blue 47 | }, 48 | { 49 | // Matches everyone - Doesn't use any color 50 | roleIds: [], 51 | displayTag: '[SURVIVOR]', 52 | enabled: false 53 | } 54 | ], 55 | 56 | // Teleport config 57 | USE_TELEPORT_LOCATIONS: true, 58 | TELEPORT_LOCATIONS_FILE_NAME: 'chernarus', 59 | 60 | // Watch list config 61 | WATCH_LIST_CHANNEL_ID: '1182012252715499591', 62 | WATCH_LIST_NOTIFICATION_ROLE_ID: '1112020551817502860', 63 | 64 | // Kill Feed config 65 | USE_KILL_FEED: true, 66 | KILL_FEED_DELAY: 5, 67 | KILL_FEED_CHANNEL_ID: '1182012252715499591', 68 | KILL_FEED_MESSAGE_IDENTIFIER: ' got killed by ', 69 | KILL_FEED_REMOVE_IDENTIFIER: false, 70 | 71 | // Leaderboard config 72 | OVERALL_RANKING_STAT: 'KILLS', 73 | LEADERBOARD_DEFAULT_SORTING_STAT: 'OVERALL', 74 | LEADERBOARD_PLAYER_LIMIT: 25, 75 | LEADERBOARD_BLACKLIST: [ 76 | '6284d7a30873a63f22e34f34', 77 | 'CFTools IDs to exclude from the blacklist', 78 | 'always use commas (,) at the end of the line EXCEPT THE LAST ONE > like so' 79 | ], 80 | LEADERBOARD_STATS: [ 81 | 'OVERALL', 82 | 'KILLS', 83 | 'KILL_DEATH_RATIO', 84 | 'LONGEST_KILL', 85 | 'PLAYTIME', 86 | 'LONGEST_SHOT', 87 | 'DEATHS', 88 | 'SUICIDES' 89 | ], 90 | 91 | // Automatic Leaderboard 92 | AUTO_LB_ENABLED: true, 93 | AUTO_LB_CHANNEL_ID: '1182012252715499591', 94 | AUTO_LB_INTERVAL_IN_MINUTES: 60, 95 | AUTO_LB_REMOVE_OLD_MESSAGES: true, 96 | AUTO_LB_PLAYER_LIMIT: 100, 97 | AUTO_LB_STAT: 'OVERALL' 98 | } 99 | ]; 100 | -------------------------------------------------------------------------------- /src/commands/teleport/teleport-to-location.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredPlayerSessionOption, 5 | requiredServerConfigCommandOption, 6 | getPlayerSessionOptionValue, 7 | cftClient, 8 | requiredTeleportLocationOption, 9 | getTeleportLocationOptionValue 10 | } = require('../../modules/cftClient'); 11 | const { ChatInputCommand } = require('../../classes/Commands'); 12 | const { ServerApiId } = require('cftools-sdk'); 13 | 14 | module.exports = new ChatInputCommand({ 15 | global: true, 16 | permLevel: 'Administrator', 17 | data: { 18 | description: 'Teleport a player that is currently online to customizable locations', 19 | options: [ 20 | requiredServerConfigCommandOption, 21 | requiredPlayerSessionOption, 22 | requiredTeleportLocationOption 23 | ] 24 | }, 25 | run: async (client, interaction) => { 26 | // Destructuring and assignments 27 | const { member } = interaction; 28 | const { emojis } = client.container; 29 | 30 | // Check active/enabled 31 | const serverCfg = getServerConfigCommandOptionValue(interaction); 32 | if (!serverCfg.USE_TELEPORT_LOCATIONS) { 33 | interaction.reply(`${ emojis.error } ${ member }, teleport locations aren't enabled for this server configuration`); 34 | return; 35 | } 36 | 37 | // Resolve location 38 | const tpLocation = getTeleportLocationOptionValue(interaction); 39 | if (!tpLocation) { 40 | interaction.reply(`${ emojis.error } ${ member }, \`teleport-location\` can't be resolved. This usually happens when you change selected server while having loaded the \`teleport-location\` option, please try again - this command has been cancelled`); 41 | return; 42 | } 43 | 44 | // Deferring our reply 45 | await interaction.deferReply(); 46 | 47 | // Check session, might have logged out 48 | const session = await getPlayerSessionOptionValue(interaction); 49 | if (!session) { 50 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 51 | return; 52 | } 53 | 54 | // Try to perform teleport 55 | try { 56 | // Destructure and verify type 57 | // eslint-disable-next-line array-element-newline, array-bracket-newline 58 | const { name, coordinates: [ x, y, z ] } = tpLocation; 59 | if ( 60 | typeof x !== 'number' 61 | || typeof y !== 'number' 62 | || typeof z !== 'number' 63 | ) { 64 | interaction.editReply(`${ emojis.error } ${ member }, invalid coordinate configuration for teleport location **${ name }**: <${ x }, ${ y }, ${ z }>`); 65 | return; 66 | } 67 | 68 | // Ok, perform teleport 69 | await cftClient.teleport({ 70 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 71 | session, 72 | coordinates: { 73 | // Why does the SDK switch y and z? =) 74 | x, y: z, z: y 75 | } 76 | }); 77 | } 78 | catch (err) { 79 | handleCFToolsError(interaction, err); 80 | return; 81 | } 82 | 83 | // Ok, feedback 84 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been teleported to **\`${ tpLocation.name }\`**` }); 85 | } 86 | }); 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/modules/statistics.js: -------------------------------------------------------------------------------- 1 | const { stripIndents } = require('common-tags/lib'); 2 | const { 3 | msToHumanReadableTime, titleCase, colorResolver 4 | } = require('../util'); 5 | // const { createHitZonesHeatMap } = require('./heatmap'); 6 | 7 | /** 8 | * @param {import('cftools-sdk').Player} data 9 | */ 10 | // eslint-disable-next-line sonarjs/cognitive-complexity 11 | const playerStatisticsCtx = async (cfg, data) => { 12 | // Assigning our stat variables 13 | const { 14 | names, 15 | playtime, 16 | sessions, 17 | statistics: { dayz: { 18 | deaths, 19 | hits, 20 | kdratio, 21 | kills, 22 | longestKill, 23 | longestShot, 24 | weapons 25 | // zones 26 | } } 27 | } = data; 28 | const averagePlaytimePerSession = Math.round(playtime / sessions); 29 | const playtimePerSessionStr = !isNaN(averagePlaytimePerSession) ? msToHumanReadableTime(averagePlaytimePerSession * 1000) : 'n/a'; 30 | const totalDeaths = Object.values(deaths ?? {}).reduce((acc, val) => acc + val, 0); 31 | 32 | // Resolve favorite weapon and it's kill count 33 | let favoriteWeaponName = 'Knife'; 34 | const highestKills = Object.entries(weapons ?? {}).reduce((acc, [ weaponName, weaponStats ]) => { 35 | const weaponKillsIsLower = acc > weaponStats.kills; 36 | if (!weaponKillsIsLower) favoriteWeaponName = weaponName; 37 | return weaponKillsIsLower ? acc : weaponStats.kills; 38 | }, 0); 39 | const cleanedWeaponName = titleCase(favoriteWeaponName.replace(/_/g, ' ')); 40 | 41 | // Reversing the name history array so the latest used name is the first item 42 | names.reverse(); 43 | 44 | // Generate hit zone image - TEMPORARILY DISABLED 45 | const files = []; 46 | // if (cfg.STATISTICS_INCLUDE_ZONES_HEATMAP) { 47 | // const hitZoneHeatMapImg = await createHitZonesHeatMap(cfg, zones); 48 | // const file = new AttachmentBuilder(Buffer.from(hitZoneHeatMapImg.buffer)).setName('heatmap.png'); 49 | // files.push(file); 50 | // } 51 | 52 | return { 53 | files, 54 | embeds: [ 55 | { 56 | color: colorResolver(), 57 | title: `Stats for ${ names[0] ?? 'Survivor' }`, 58 | image: { url: 'attachment://heatmap.png' }, 59 | description: stripIndents` 60 | Survivor has played for ${ msToHumanReadableTime(playtime * 1000) } - over ${ sessions } total sessions. 61 | Bringing them to an average of ${ playtimePerSessionStr } per session. 62 | ${ cfg.STATISTICS_HIDE_PLAYER_NAME_HISTORY !== true && `\n**Name History:** **\`${ names.slice(0, 10).join('`**, **`') || 'None' }\`**` } 63 | 64 | **Favorite Weapon:** ${ cleanedWeaponName ?? 'Knife' } with ${ highestKills ?? 0 } kills 65 | 66 | **Deaths:** ${ totalDeaths ?? 0 } (${ deaths.other } PvP) 67 | **Hits:** ${ hits ?? 0 } 68 | **KDRatio:** ${ kdratio ?? 0 } 69 | **Kills:** ${ kills?.players ?? 0 } 70 | **Longest Kill:** ${ longestKill ?? 0 } m 71 | **Longest Shot:** ${ longestShot ?? 0 } m 72 | **Suicides:** ${ deaths.suicides ?? 0 } 73 | **Environmental Deaths:** ${ deaths.environment ?? 0 } 74 | **Infected Deaths:** ${ deaths.infected ?? 0 }${ cfg.STATISTICS_INCLUDE_ZONES_HEATMAP ? '\n\n**__Hit Zones:__**' : '' } 75 | ` 76 | } 77 | ] 78 | }; 79 | }; 80 | 81 | module.exports = { playerStatisticsCtx }; 82 | -------------------------------------------------------------------------------- /src/modules/server-info.js: -------------------------------------------------------------------------------- 1 | const { colorResolver, msToHumanReadableTime } = require('../util'); 2 | const colors = require('../../config/colors.json'); 3 | const emojis = require('../../config/emojis.json'); 4 | const { stripIndents } = require('common-tags'); 5 | const { MS_IN_ONE_MINUTE } = require('../constants'); 6 | 7 | const resolveFlags = ({ attributes }) => { 8 | const flags = []; 9 | if (attributes?.dlc) flags.push( 10 | ...Object.entries(attributes.dlcs) 11 | .filter(([ k, v ]) => v === true) 12 | .map(([ k, v ]) => `dlc-${ k }`) 13 | ); 14 | if (attributes.official) flags.push('official'); 15 | if (attributes.modded) flags.push('modded'); 16 | if (attributes.hive) flags.push(`hive-${ attributes.hive }`); 17 | if (attributes.experimental) flags.push('experimental'); 18 | if (attributes.whitelist) flags.push('whitelist'); 19 | return flags; 20 | }; 21 | 22 | const calculateDayNightTime = (serverTimeAcceleration, serverNightTimeAcceleration) => { 23 | const minutesPerHour = 60; 24 | // Calculate the duration of a day in minutes 25 | const dayDuration = (12 / serverTimeAcceleration) * minutesPerHour; 26 | // Calculate the duration of a night in minutes 27 | const nightDuration = (dayDuration / serverNightTimeAcceleration); 28 | 29 | return { 30 | day: msToHumanReadableTime(dayDuration * MS_IN_ONE_MINUTE), 31 | night: msToHumanReadableTime(nightDuration * MS_IN_ONE_MINUTE) 32 | }; 33 | }; 34 | 35 | // eslint-disable-next-line sonarjs/cognitive-complexity 36 | const serverInfoOverviewEmbed = (data, flags, guild) => { 37 | const { day, night } = calculateDayNightTime( 38 | (data.environment?.timeAcceleration?.general ?? 1), 39 | (data.environment?.timeAcceleration?.night ?? 1) 40 | ); 41 | return { 42 | color: colorResolver(data.online ? colors.success : colors.error), 43 | author: { 44 | name: `#${ data.rank } | ` + data.name + (data.map ? ` (${ data.map })` : ''), 45 | icon_url: guild.iconURL({ dynamic: true }) 46 | }, 47 | description: stripIndents` 48 | **IP:** **\`${ data.host?.address ?? '0.0.0.0' }:${ data.host?.gamePort ?? '0000' }\`** 49 | **Time:** ${ data.environment?.time ?? 'Unknown' } 50 | **Location:** [${ data.geolocation.country?.code }] ${ data.geolocation?.country?.name ?? 'n/a' } 51 | **Perspective:** ${ data.environment?.perspectives?.thirdPersonPerspective ? 'First + Third' : 'First Online' } 52 | **Time Acceleration:** 53 | 🌞 ${ day } 54 | 🌗 ${ night } 55 | **Flags:** \`${ flags.join('`, `') || 'None' }\` 56 | `, 57 | fields: [ 58 | { 59 | name: 'Players', 60 | value: stripIndents` 61 | Online: ${ data.status?.players?.online ?? 0 } 62 | Slots: ${ data.status?.players?.slots ?? 0 } 63 | Queue: ${ data.status?.players?.queue ?? 0 } 64 | `, 65 | inline: true 66 | }, 67 | { 68 | name: 'Security', 69 | value: stripIndents` 70 | VAC: ${ (data.security?.vac ? emojis.success : emojis.error) ?? 'n/a' } 71 | BatllEye: ${ (data.security?.battleye ? emojis.success : emojis.error) ?? 'n/a' } 72 | Password-Protected: ${ (data.security?.password ? emojis.success : emojis.error) ?? 'n/a' } 73 | `, 74 | inline: true 75 | } 76 | ], 77 | footer: { text: `DayZ - ${ data.version }` } 78 | }; 79 | }; 80 | 81 | module.exports = { 82 | resolveFlags, 83 | calculateDayNightTime, 84 | serverInfoOverviewEmbed 85 | }; 86 | -------------------------------------------------------------------------------- /src/commands/admin/flagged-player-list.js: -------------------------------------------------------------------------------- 1 | const { ServerApiId } = require('cftools-sdk'); 2 | const { 3 | requiredServerConfigCommandOption, 4 | getServerConfigCommandOptionValue, 5 | handleCFToolsError, 6 | cftClient 7 | } = require('../../modules/cftClient'); 8 | const { ChatInputCommand } = require('../../classes/Commands'); 9 | const { doMaxLengthChunkReply, titleCase } = require('../../util'); 10 | const { ApplicationCommandOptionType } = require('discord.js'); 11 | const { stripIndents } = require('common-tags/lib'); 12 | 13 | const banProperties = [ 14 | 'gameBanned', 15 | 'communityBanned', 16 | 'economyBanned', 17 | 'vacBanned' 18 | ]; 19 | 20 | module.exports = new ChatInputCommand({ 21 | permLevel: 'Administrator', 22 | global: true, 23 | data: { 24 | description: 'View the online player list - has sensitive information', 25 | options: [ 26 | requiredServerConfigCommandOption, 27 | { 28 | name: 'public', 29 | description: 'Displays the list to everyone if true, false by default', 30 | type: ApplicationCommandOptionType.Boolean, 31 | required: false 32 | } 33 | ] 34 | }, 35 | 36 | 37 | run: async (client, interaction) => { 38 | // Destructuring 39 | const { 40 | guild, member, options 41 | } = interaction; 42 | const { emojis } = client.container; 43 | 44 | // Deferring our reply 45 | const publicFlag = options.getBoolean('public'); 46 | const isEphemeral = typeof publicFlag === 'boolean' ? !publicFlag : true; 47 | await interaction.deferReply({ ephemeral: isEphemeral }); 48 | 49 | // Check if a proper server option is provided 50 | const serverCfg = getServerConfigCommandOptionValue(interaction); 51 | 52 | // Fetch sessions 53 | let sessions; 54 | try { 55 | sessions = await cftClient 56 | .listGameSessions({ serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID) }); 57 | } 58 | catch (err) { 59 | handleCFToolsError(interaction, err); 60 | return; 61 | } 62 | 63 | // Check availability 64 | if (!sessions || !sessions[0]) { 65 | interaction.editReply(`${ emojis.error } ${ member }, no one is currently online on **\`${ serverCfg.NAME }\`**`); 66 | return; 67 | } 68 | 69 | // Filter bans etc 70 | const flagged = sessions.filter( 71 | (e) => e.bans?.count >= 1 || banProperties.find((e) => e.bans && e.bans[e] === true) 72 | ); 73 | 74 | // Destructure flagged from data and map our player strings 75 | const playerLinkMap = flagged.map((session) => stripIndents` 76 | [• ${ session.profile?.private === true ? '🕵️' : '' }${ session.playerName ?? 'Survivor' }](https://app.cftools.cloud/profile/${ session.cftoolsId.id } "CFTools Cloud Profile") ([\`${ session.profile?.name ?? 'Unknown' }\`](https://steamcommunity.com/profiles/${ session.steamId.id }/ "Steam Account Profile")) 77 | \` - \` ${ session.bans.count } Bans 78 | ${ banProperties.map((e) => `\` - \` ${ session.bans[e] === true ? emojis.success : emojis.error } ${ 79 | titleCase(e.replace('Banned', '')) 80 | } Banned`).join('\n') } 81 | `); 82 | const output = `**Flagged players online:** ${ flagged.length }\n\n${ playerLinkMap.join('\n') ?? '-' }`; 83 | 84 | // Ok, we might have 1 line, or over 15k characters 85 | // Handle that accordingly 86 | doMaxLengthChunkReply(interaction, output, { 87 | title: `[ADMIN - FLAGGED] Online Player list for ${ serverCfg.NAME }`, 88 | titleIcon: guild.iconURL({ dynamic: true }), 89 | ephemeral: isEphemeral 90 | }); 91 | } 92 | }); 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cftools-discord-bot", 3 | "description": "A discord bot template using discord.js", 4 | "version": "1.10.1", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node .", 8 | "dev": "nodemon run node --trace-warnings .", 9 | "test": "node . mode=testing", 10 | "commit": "cz", 11 | "docker:build": "docker build --tag cftools-discord-bot .", 12 | "docker:shell": "docker run -it --rm cftools-discord-bot sh", 13 | "docker:start": "docker run -it -p 3000:3000 --env-file ./.env -d --name cftools-discord-bot cftools-discord-bot", 14 | "docker:restart": "docker restart cftools-discord-bot", 15 | "docker:stop": "docker stop cftools-discord-bot", 16 | "docker:kill": "docker rm -f cftools-discord-bot", 17 | "docker:purge": "docker rm -fv cftools-discord-bot", 18 | "docker:logs": "docker logs cftools-discord-bot -f", 19 | "docker:image": "docker image tag cftools-discord-bot mirasaki/cftools-discord-bot", 20 | "docker:push": "docker push mirasaki/cftools-discord-bot", 21 | "docker:update": "git pull && npm install && npm run docker:stop && npm run docker:kill && npm run docker:build && npm run docker:start", 22 | "docker:dev:build": "docker build --tag cftools-discord-bot-dev -f development.Dockerfile .", 23 | "docker:dev:start": "docker run -it --rm -v $(pwd):/app -v /app/node_modules -p 3000:3000 -p 9229:9229 -w /app cftools-discord-bot-dev", 24 | "pm2:start": "pm2 start --name=cftools-discord-bot npm -- run start", 25 | "pm2:stop": "pm2 stop cftools-discord-bot", 26 | "pm2:purge": "pm2 stop cftools-discord-bot && pm2 delete cftools-discord-bot && pm2 reset cftools-discord-bot", 27 | "pm2:logs": "pm2 logs --lines 300 cftools-discord-bot", 28 | "pm2:logsError": "pm2 logs --err --lines 300 cftools-discord-bot", 29 | "lint": "eslint src", 30 | "linter": "eslint src --fix", 31 | "writeLinter": "eslint src --output-file linter-output.txt", 32 | "docs": "jsdoc -u ./tutorials --readme README.md -c jsdoc.json", 33 | "types": "npx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly --outDir types" 34 | }, 35 | "dependencies": { 36 | "@discordjs/rest": "2.0.0", 37 | "@discordjs/ws": "2.0.3", 38 | "cftools-sdk": "^3.5.0", 39 | "@mirasaki/logger": "file:./vendor/@mirasaki/logger", 40 | "common-tags": "^1.8.2", 41 | "discord.js": "14.12.1", 42 | "dotenv": "^17.2.1", 43 | "express": "^4.18.2", 44 | "lokijs": "^1.5.12" 45 | }, 46 | "devDependencies": { 47 | "commitizen": "^4.3.0", 48 | "cz-conventional-changelog": "^3.3.0", 49 | "eslint": "^8.56.0", 50 | "eslint-plugin-sonarjs": "^3.0.4", 51 | "nodemon": "^3.1.10" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "git+https://github.com/Mirasaki/cftools-discord-bot.git" 56 | }, 57 | "keywords": [ 58 | "nodejs", 59 | "bot-template", 60 | "template", 61 | "boilerplate", 62 | "discord-api", 63 | "typings", 64 | "discord", 65 | "discordjs", 66 | "v14", 67 | "discord-bot", 68 | "cftools-discord-bot", 69 | "slash-commands", 70 | "buttons", 71 | "modals", 72 | "autocomplete", 73 | "context-menus", 74 | "select-menus", 75 | "documented" 76 | ], 77 | "author": "Richard Hillebrand (Mirasaki)", 78 | "license": "MIT", 79 | "bugs": { 80 | "url": "https://github.com/Mirasaki/cftools-discord-bot/issues" 81 | }, 82 | "homepage": "https://github.com/Mirasaki/cftools-discord-bot#readme", 83 | "optionalDependencies": { 84 | "fsevents": "^2.3.2" 85 | }, 86 | "config": { 87 | "commitizen": { 88 | "path": "./node_modules/cz-conventional-changelog" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/commands/teleport/teleport-to-player.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredPlayerSessionOption, 5 | requiredServerConfigCommandOption, 6 | getPlayerSessionOptionValue, 7 | cftClient 8 | } = require('../../modules/cftClient'); 9 | const { ChatInputCommand } = require('../../classes/Commands'); 10 | const { ServerApiId } = require('cftools-sdk'); 11 | const { debugLog } = require('../../util'); 12 | 13 | module.exports = new ChatInputCommand({ 14 | global: true, 15 | permLevel: 'Administrator', 16 | data: { 17 | description: 'Teleport a player that is currently online to another player', 18 | options: [ 19 | requiredServerConfigCommandOption, 20 | requiredPlayerSessionOption, 21 | { 22 | ...requiredPlayerSessionOption, 23 | name: 'player-to', 24 | description: 'The target in-game player to teleport the other to' 25 | } 26 | ] 27 | }, 28 | run: async (client, interaction) => { 29 | // Destructuring and assignments 30 | const { member } = interaction; 31 | const { emojis } = client.container; 32 | const serverCfg = getServerConfigCommandOptionValue(interaction); 33 | 34 | // Deferring our reply 35 | await interaction.deferReply(); 36 | 37 | // Check session, might have logged out 38 | const session = await getPlayerSessionOptionValue(interaction); 39 | if (!session) { 40 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 41 | return; 42 | } 43 | 44 | // Check target session 45 | const targetSession = await getPlayerSessionOptionValue(interaction, 'player-to'); 46 | if (!targetSession) { 47 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided target player/session, player most likely logged out - this command has been cancelled`); 48 | return; 49 | } 50 | 51 | // Check is not same session 52 | if (targetSession.id === session.id) { 53 | interaction.editReply(`${ emojis.error } ${ member }, same session provided for player and target - this command has been cancelled`); 54 | return; 55 | } 56 | 57 | // Resolve data 58 | // Haha optional fields be like 59 | let coords; 60 | const { live } = targetSession; 61 | if (live) { 62 | const { position } = live; 63 | if (position) { 64 | const { latest } = position; 65 | if (latest) coords = latest; 66 | } 67 | } 68 | 69 | // Check data availability 70 | if (!coords) { 71 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve latest coordinates for target **${ targetSession.playerName }**, try again later (they might not have finished connecting/loading yet) - this command has been cancelled`); 72 | return; 73 | } 74 | 75 | // Try to perform teleport 76 | try { 77 | const { 78 | x, y, z 79 | } = coords; 80 | debugLog(`Teleporting ${ session.playerName } to ${ targetSession.playerName }, target session ref:`); 81 | debugLog(targetSession); 82 | debugLog(coords); 83 | await cftClient.teleport({ 84 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 85 | session, 86 | coordinates: { 87 | x, y, z 88 | } 89 | }); 90 | } 91 | catch (err) { 92 | handleCFToolsError(interaction, err); 93 | return; 94 | } 95 | 96 | // Ok, feedback 97 | interaction.editReply({ content: `${ emojis.success } ${ member }, **\`${ session.playerName }\`** has been teleported to **\`${ targetSession.playerName }\`**` }); 98 | } 99 | }); 100 | 101 | 102 | -------------------------------------------------------------------------------- /src/commands/admin/change-game-weather.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | getServerConfigCommandOptionValue, 6 | postGameLabsAction, 7 | broadcastMessage 8 | } = require('../../modules/cftClient'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | permLevel: 'Administrator', 12 | global: true, 13 | data: { 14 | description: 'Change the current in-game weather', 15 | options: [ 16 | requiredServerConfigCommandOption, 17 | { 18 | name: 'overcast', 19 | description: 'The overcast value to set', 20 | type: ApplicationCommandOptionType.Number, 21 | required: true, 22 | min_value: 0.0000, 23 | max_value: 1.0000 24 | }, 25 | { 26 | name: 'fog', 27 | description: 'The fog value to set', 28 | type: ApplicationCommandOptionType.Number, 29 | required: true, 30 | min_value: 0.0000, 31 | max_value: 1.0000 32 | }, 33 | { 34 | name: 'rain', 35 | description: 'The rain value to set', 36 | type: ApplicationCommandOptionType.Number, 37 | required: true, 38 | min_value: 0.0000, 39 | max_value: 1.0000 40 | }, 41 | { 42 | name: 'wind', 43 | description: 'The wind value to set, in km/h', 44 | type: ApplicationCommandOptionType.Integer, 45 | required: true, 46 | min_value: 0, 47 | max_value: 100 48 | }, 49 | { 50 | name: 'notify-players', 51 | description: 'Send a global notification to the players, default true', 52 | type: ApplicationCommandOptionType.Boolean, 53 | required: false 54 | } 55 | ] 56 | }, 57 | 58 | run: async (client, interaction) => { 59 | // Destructuring 60 | const { member, options } = interaction; 61 | const { emojis } = client.container; 62 | const overcast = options.getNumber('overcast'); 63 | const fog = options.getNumber('fog'); 64 | const rain = options.getNumber('rain'); 65 | const wind = options.getInteger('wind'); 66 | const notifyPlayers = options.getBoolean('notify-players') ?? true; 67 | 68 | // Deferring our reply 69 | await interaction.deferReply(); 70 | 71 | // Resolve options 72 | const serverCfg = getServerConfigCommandOptionValue(interaction); 73 | 74 | // Performing request 75 | const res = await postGameLabsAction( 76 | serverCfg.CFTOOLS_SERVER_API_ID, 77 | 'CFCloud_WorldWeather', 78 | 'world', 79 | null, 80 | { 81 | overcast: { valueFloat: overcast }, 82 | fog: { valueFloat: fog }, 83 | rain: { valueFloat: rain }, 84 | wind: { valueInt: wind } 85 | } 86 | ); 87 | 88 | if (res !== true) { 89 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - weather might not have been updated` }); 90 | return; 91 | } 92 | 93 | // Resolve weather string 94 | const overcastStr = overcast.toFixed(2); 95 | const fogStr = fog.toFixed(2); 96 | const rainStr = rain.toFixed(2); 97 | const windStr = wind.toString().padStart(3, '0'); 98 | const weatherStr = `Overcast: ${ overcastStr }\nFog: ${ fogStr }\nRain: ${ rainStr }\nWind: ${ windStr }`; 99 | 100 | // Notify player 101 | if (notifyPlayers) { 102 | await broadcastMessage( 103 | serverCfg.CFTOOLS_SERVER_API_ID, 104 | 'The weather has been changed by an administrator (this might take a while to take effect)' 105 | ); 106 | } 107 | 108 | // User feedback on success 109 | interaction.editReply({ content: `${ emojis.success } ${ member }, weather has been changed to:\n\n\`\`\`${ weatherStr }\`\`\`` }); 110 | } 111 | }); 112 | -------------------------------------------------------------------------------- /src/commands/developer/exec.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('child_process'); 2 | const { EMBED_FIELD_VALUE_MAX_LENGTH } = require('../../constants'); 3 | const { ChatInputCommand } = require('../../classes/Commands'); 4 | const { colorResolver, getRuntime } = require('../../util'); 5 | 6 | module.exports = new ChatInputCommand({ 7 | enabled: process.env.NODE_ENV !== 'production', 8 | permLevel: 'Developer', 9 | clientPerms: [ 'EmbedLinks', 'AttachFiles' ], 10 | data: { 11 | description: 'Execute console commands', 12 | options: [ 13 | { 14 | // STRING 15 | type: 3, 16 | name: 'command', 17 | description: 'The command to execute', 18 | required: true, 19 | min_length: 1, 20 | // API max 21 | max_length: 6000 22 | } 23 | ] 24 | }, 25 | 26 | run: async (client, interaction) => { 27 | // Destructuring 28 | const { member, options } = interaction; 29 | const { emojis, colors } = client.container; 30 | 31 | // Definitions 32 | const commandToExec = options.getString('command'); 33 | const execStartTime = process.hrtime.bigint(); 34 | 35 | // Deferring our reply (3 seconds threshold) 36 | await interaction.deferReply(); 37 | 38 | // Execute the user provided command 39 | exec(commandToExec, (err, stdout) => { 40 | // Get runtime 41 | const timeSinceHr = getRuntime(execStartTime); 42 | const timeSinceStr = `${ timeSinceHr.seconds } seconds (${ timeSinceHr.ms } ms)`; 43 | 44 | // Building our embed object 45 | let outputStr = undefined; 46 | const files = []; 47 | const execEmbed = { 48 | description: `:inbox_tray: **Input:**\n\`\`\`bash\n${ commandToExec }\n\`\`\``, 49 | fields: [ 50 | { 51 | name: 'Time taken', 52 | value: `\`\`\`fix\n${ timeSinceStr }\`\`\``, 53 | inline: false 54 | } 55 | ] 56 | }; 57 | 58 | // Properly handle potential errors 59 | if (err) { 60 | outputStr = `${ emojis.error } ${ member }, error encountered while executing console command.`; 61 | execEmbed.color = colorResolver(colors.error); 62 | 63 | // Add output embed field to the start of the Array 64 | const activeOutput = err.stack || err; 65 | 66 | execEmbed.fields.unshift({ 67 | name: ':outbox_tray: Output:', 68 | value: `\`\`\`js\n${ 69 | activeOutput.length <= EMBED_FIELD_VALUE_MAX_LENGTH 70 | ? activeOutput 71 | : `Error trace over ${ EMBED_FIELD_VALUE_MAX_LENGTH } characters, uploaded as attachment instead` 72 | }\`\`\``, 73 | inline: false 74 | }); 75 | 76 | // Upload as file attachment if output exceeds max length 77 | if (activeOutput.length > EMBED_FIELD_VALUE_MAX_LENGTH) { 78 | files.push({ 79 | attachment: Buffer.from(activeOutput), 80 | name: 'error-trace.txt' 81 | }); 82 | } 83 | } 84 | 85 | // No error encountered 86 | else { 87 | outputStr = `${ emojis.success } ${ member }, console command executed.`; 88 | execEmbed.color = colorResolver(colors.success); 89 | 90 | // Add output embed field to the start of the Array 91 | execEmbed.fields.unshift({ 92 | name: ':outbox_tray: Output:', 93 | value: `\`\`\`js\n${ 94 | stdout.length <= EMBED_FIELD_VALUE_MAX_LENGTH 95 | ? stdout 96 | : `Output over ${ EMBED_FIELD_VALUE_MAX_LENGTH } characters, uploaded as attachment instead` 97 | }\`\`\``, 98 | inline: false 99 | }); 100 | 101 | // Upload as file attachment if output exceeds max length 102 | if (stdout.length > EMBED_FIELD_VALUE_MAX_LENGTH) { 103 | files.push({ 104 | attachment: Buffer.from(stdout), 105 | name: 'stdout.txt' 106 | }); 107 | } 108 | } 109 | 110 | // Final user feedback 111 | interaction.editReply({ 112 | content: outputStr, 113 | embeds: [ execEmbed ], 114 | files 115 | }); 116 | }); 117 | } 118 | }); 119 | -------------------------------------------------------------------------------- /src/context-menus/user/info.js: -------------------------------------------------------------------------------- 1 | const { EmbedBuilder } = require('@discordjs/builders'); 2 | const logger = require('@mirasaki/logger'); 3 | const { stripIndents } = require('common-tags/lib'); 4 | const { UserContextCommand } = require('../../classes/Commands'); 5 | const { MS_IN_ONE_SECOND } = require('../../constants'); 6 | const { colorResolver, getRelativeTime } = require('../../util'); 7 | 8 | const MAX_ROLE_DISPLAY_LENGTH = 25; 9 | 10 | module.exports = new UserContextCommand({ 11 | enabled: false, 12 | clientPerms: [ 'EmbedLinks' ], 13 | global: true, 14 | cooldown: { 15 | usages: 1, 16 | duration: 5 17 | }, 18 | data: { description: 'Display someone\'s account information' }, 19 | 20 | run: async (client, interaction) => { 21 | // Destructure from interaction and client container 22 | const { 23 | member, targetId, guild 24 | } = interaction; 25 | const { emojis, colors } = client.container; 26 | 27 | // Deferring our reply 28 | await interaction.deferReply(); 29 | 30 | // Fetching the target 31 | let targetMember; 32 | 33 | try { 34 | targetMember = await guild.members.fetch(targetId); 35 | } 36 | catch (err) { 37 | interaction.editReply({ content: `${ emojis.error } ${ member }, can't fetch user information for **\`${ targetId }\`**, please try again later.` }); 38 | logger.syserr(` Unable to fetch user information for ${ targetId }`); 39 | console.error(err.stack || err); 40 | return; 41 | } 42 | 43 | // Assign server profile variables 44 | const hasNickname = targetMember.user.username !== targetMember.displayName; 45 | const hasServerAvatar = targetMember.avatarURL() !== null 46 | && targetMember.avatarURL() !== targetMember.user.avatarURL(); 47 | const nicknameString = hasNickname 48 | ? `**Nickname:** ${ targetMember.displayName }` 49 | : 'No active nickname'; 50 | const serverProfileString = hasServerAvatar 51 | ? `${ nicknameString } | [Server Profile Avatar](${ targetMember.avatarURL({ dynamic: true }) } "${ targetMember.displayName }'s Server Avatar")` 52 | : nicknameString; 53 | const finalOutputStr = hasNickname || hasServerAvatar 54 | ? serverProfileString 55 | : `${ emojis.error } This member does **not** have their server profile set-up.`; 56 | 57 | // Assign Time Since variables 58 | const targetUser = await client.users.fetch(targetId); 59 | const relativeTimeSinceCreate = getRelativeTime(targetUser.createdTimestamp); 60 | const relativeTimeSinceJoin = getRelativeTime(targetMember.joinedTimestamp); 61 | const isBoosting = targetMember.premiumSinceTimestamp !== null; 62 | const relativeTimeSinceBoost = isBoosting 63 | ? getRelativeTime(targetMember.premiumSinceTimestamp) 64 | : 'Does **not** have a premium subscription'; 65 | const boostString = isBoosting 66 | ? `Server Boosting since ${ relativeTimeSinceBoost } | ` 67 | : `${ emojis.error } ${ targetMember } is **not** actively boosting the server`; 68 | 69 | // Building our user info embed 70 | const userInfoEmbed = new EmbedBuilder({ 71 | color: colorResolver(colors.main), 72 | author: { 73 | name: targetMember.user.username, 74 | icon_url: targetMember.user.avatarURL({ dynamic: true }) 75 | }, 76 | description: stripIndents` 77 | **Roles:** ${ targetMember._roles.slice(0, MAX_ROLE_DISPLAY_LENGTH).map((id) => `<@&${ id }>`) 78 | .join(` ${ emojis.separator } `) } 79 | ${ targetMember._roles.length > MAX_ROLE_DISPLAY_LENGTH ? `\nAnd ${ targetMember._roles.length - MAX_ROLE_DISPLAY_LENGTH } more...\n` : '' } 80 | **__Server Profile:__** 81 | ${ finalOutputStr } 82 | 83 | **__Timestamps:__** 84 | **Joined Server:** ${ relativeTimeSinceJoin } | 85 | **Account Created:** ${ relativeTimeSinceCreate } | 86 | **Boost Status:**: ${ boostString } 87 | 88 | `, 89 | footer: { text: `ID: ${ targetMember.user.id }` } 90 | }); 91 | 92 | // Updating our reply, displaying the requested user information 93 | interaction.editReply({ embeds: [ userInfoEmbed ] }); 94 | } 95 | }); 96 | -------------------------------------------------------------------------------- /src/commands/system/stats.js: -------------------------------------------------------------------------------- 1 | const { ChatInputCommand } = require('../../classes/Commands'); 2 | const { stripIndents } = require('common-tags'); 3 | const { version } = require('discord.js'); 4 | const { BYTES_IN_KIB } = require('../../constants'); 5 | const { colorResolver, msToHumanReadableTime } = require('../../util'); 6 | 7 | const discordVersion = version.indexOf('dev') < 0 ? version : version.slice(0, version.indexOf('dev') + 3); 8 | const discordVersionDocLink = `https://discord.js.org/#/docs/discord.js/v${ discordVersion.split('.')[0] }/general/welcome`; 9 | const nodeVersionDocLink = `https://nodejs.org/docs/latest-${ process.version.split('.')[0] }.x/api/#`; 10 | 11 | module.exports = new ChatInputCommand({ 12 | global: true, 13 | cooldown: { 14 | // Use channel cooldown type instead of default member 15 | type: 'channel', 16 | usages: 1, 17 | duration: 30 18 | }, 19 | clientPerms: [ 'EmbedLinks' ], 20 | alias: [ 'ping' ], 21 | data: { description: 'Displays bot stats' }, 22 | 23 | run: async (client, interaction) => { 24 | const { emojis } = client.container; 25 | 26 | // Calculating our API latency 27 | const latency = Math.round(client.ws.ping); 28 | const sent = await interaction.reply({ 29 | content: 'Pinging...', 30 | fetchReply: true 31 | }); 32 | const fcLatency = sent.createdTimestamp - interaction.createdTimestamp; 33 | 34 | // Utility function for getting appropriate status emojis 35 | const getMsEmoji = (ms) => { 36 | let emoji = undefined; 37 | 38 | for (const [ key, value ] of Object.entries({ 39 | 250: '🟢', 40 | 500: '🟡', 41 | 1000: '🟠' 42 | })) if (ms <= key) { 43 | emoji = value; 44 | break; 45 | } 46 | return (emoji ??= '🔴'); 47 | }; 48 | 49 | // Memory Variables 50 | const memoryUsage = process.memoryUsage(); 51 | const memoryUsedInMB = memoryUsage.heapUsed / BYTES_IN_KIB / BYTES_IN_KIB; 52 | const memoryAvailableInMB = memoryUsage.heapTotal 53 | / BYTES_IN_KIB / BYTES_IN_KIB; 54 | const objCacheSizeInMB = memoryUsage.external / BYTES_IN_KIB / BYTES_IN_KIB; 55 | 56 | // Replying to the interaction with our embed data 57 | interaction.editReply({ 58 | content: '\u200b', 59 | embeds: [ 60 | { 61 | color: colorResolver(), 62 | author: { 63 | name: `${ client.user.username }`, 64 | iconURL: client.user.displayAvatarURL() 65 | }, 66 | fields: [ 67 | { 68 | name: 'Latency', 69 | value: stripIndents` 70 | ${ getMsEmoji(latency) } **API Latency:** ${ latency } ms 71 | ${ getMsEmoji(fcLatency) } **Full Circle Latency:** ${ fcLatency } ms 72 | `, 73 | inline: true 74 | }, 75 | { 76 | name: 'Memory', 77 | value: stripIndents` 78 | 💾 **Memory Usage:** ${ memoryUsedInMB.toFixed(2) }/${ memoryAvailableInMB.toFixed(2) } MB 79 | ♻️ **Cache Size:** ${ objCacheSizeInMB.toFixed(2) } MB 80 | `, 81 | inline: true 82 | }, 83 | { 84 | name: 'Uptime', 85 | value: stripIndents`**📊 I've been online for ${ msToHumanReadableTime(Date.now() - client.readyTimestamp) }**`, 86 | inline: false 87 | }, 88 | { 89 | name: 'System', 90 | value: stripIndents` 91 | ⚙️ **Discord.js Version:** [v${ discordVersion }](${ discordVersionDocLink }) 92 | ⚙️ **Node Version:** [${ process.version }](${ nodeVersionDocLink }) 93 | `, 94 | inline: true 95 | }, 96 | { 97 | name: 'Stats', 98 | value: stripIndents` 99 | 👪 **Servers:** ${ client.guilds.cache.size.toLocaleString('en-US') } 100 | 🙋 **Users:** ${ client.guilds.cache.reduce((previousValue, currentValue) => previousValue += currentValue.memberCount, 0).toLocaleString('en-US') } 101 | `, 102 | inline: true 103 | } 104 | ], 105 | footer: { text: `Made with ❤️ by Mirasaki#0001 ${ emojis.separator } Open to collaborate ${ emojis.separator } me@mirasaki.dev` } 106 | } 107 | ] 108 | }); 109 | } 110 | }); 111 | -------------------------------------------------------------------------------- /src/commands/dayz/leaderboard.js: -------------------------------------------------------------------------------- 1 | const logger = require('@mirasaki/logger'); 2 | const { Statistic, ServerApiId } = require('cftools-sdk'); 3 | 4 | // Mapping our Interaction Command API options 5 | const { ChatInputCommand } = require('../../classes/Commands'); 6 | const { 7 | getServerConfigCommandOptionValue, requiredServerConfigCommandOption, cftClient 8 | } = require('../../modules/cftClient'); 9 | const { statisticAutoCompleteOption, statisticAutoCompleteOptionIdentifier } = require('../../interactions/autocomplete/statistic'); 10 | const { buildLeaderboardEmbedMessages } = require('../../modules/leaderboard'); 11 | 12 | 13 | module.exports = new ChatInputCommand({ 14 | // This is a global command 15 | global: true, 16 | // Setting a cooldown to avoid abuse 17 | // Allowed 7 times every 60 seconds per user 18 | cooldown: { 19 | type: 'member', 20 | usages: 7, 21 | duration: 60 22 | }, 23 | // Defining our Discord Application Command API data 24 | // Name is generated from the file name if left undefined 25 | data: { 26 | description: 'Display your DayZ Leaderboard', 27 | options: [ requiredServerConfigCommandOption, statisticAutoCompleteOption ] 28 | }, 29 | 30 | run: async (client, interaction) => { 31 | // Destructure from our Discord interaction 32 | const { 33 | member, guild, options 34 | } = interaction; 35 | const { emojis } = client.container; 36 | 37 | // Declarations 38 | const serverCfg = getServerConfigCommandOptionValue(interaction); 39 | const statStr = options.getString(statisticAutoCompleteOptionIdentifier) 40 | ?? serverCfg.LEADERBOARD_DEFAULT_SORTING_STAT 41 | ?? 'OVERALL'; 42 | const statToGet = Statistic[ 43 | statStr === 'OVERALL' 44 | ? serverCfg.OVERALL_RANKING_STAT ?? 'KILL_DEATH_RATIO' 45 | : statStr 46 | ]; 47 | 48 | // Deferring our interaction 49 | // due to possible API latency 50 | await interaction.deferReply(); 51 | 52 | // Default 53 | // No option provided OR 54 | // 'overall' option specified 55 | const isDefaultQuery = statStr === 'OVERALL'; 56 | 57 | // Getting our player data count 58 | let playerLimit = Number(serverCfg.LEADERBOARD_PLAYER_LIMIT); 59 | if ( 60 | isNaN(playerLimit) 61 | || playerLimit < 10 62 | || playerLimit > 100 63 | ) { 64 | // Overwrite the provided player limit back to default if invalid 65 | playerLimit = 15; 66 | } 67 | 68 | let res; 69 | try { 70 | // Fetching our leaderboard data from the CFTools API 71 | res = await cftClient 72 | .getLeaderboard({ 73 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 74 | order: 'ASC', 75 | statistic: statToGet ?? ( 76 | Statistic[serverCfg.LEADERBOARD_DEFAULT_SORTING_STAT ?? 'KILL_DEATH_RATIO'] 77 | ), 78 | // Always use max limit, since we remove blacklist entries 79 | limit: 100 80 | }); 81 | } 82 | catch (err) { 83 | // Properly logging the error if it is encountered 84 | logger.syserr('Encounter an error while fetching leaderboard data'); 85 | logger.printErr(err); 86 | 87 | // Notify the user 88 | // Include debug in non-production environments 89 | interaction.editReply({ content: `${ emojis.error } ${ member }, error encountered while fetching leaderboard data: ${ err.message }` }); 90 | 91 | // Returning the request 92 | return; 93 | } 94 | 95 | // Check if any data is actually present 96 | if (res.length === 0) { 97 | interaction.editReply({ content: `${ emojis.error } ${ member }, we don't have any data for that statistic yet.` }); 98 | return; 99 | } 100 | 101 | // Filter out our blacklisted ids/entries 102 | const whitelistedData = res.filter((e) => !serverCfg.LEADERBOARD_BLACKLIST.includes(e.id.id)); 103 | 104 | // Constructing our embed object 105 | const lbEmbedMessages = buildLeaderboardEmbedMessages( 106 | guild, 107 | whitelistedData, 108 | isDefaultQuery, 109 | statToGet, 110 | playerLimit, 111 | serverCfg 112 | ); 113 | 114 | // Responding to our request 115 | await interaction.editReply({ embeds: lbEmbedMessages[0] }); 116 | for (const msgEmbeds of lbEmbedMessages.slice(1, lbEmbedMessages.length)) { 117 | interaction.followUp({ embeds: msgEmbeds }); 118 | } 119 | } 120 | }); 121 | -------------------------------------------------------------------------------- /config/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "#ffffff", 3 | "invisible": "#36393f", 4 | "success": "#00b105", 5 | "error": "#d91d1d", 6 | "red": "#ed0e0e", 7 | "blue": "#add8e6", 8 | "indianRed": "#cd5c5c", 9 | "lightCoral": "#f08080", 10 | "salmon": "#fa8072", 11 | "darkSalmon": "#e9967a", 12 | "lightSalmon": "#ffa07a", 13 | "crimson": "#dc143c", 14 | "firebrick": "#b22222", 15 | "darkRed": "#8b0000", 16 | "pink": "#ffc0cb", 17 | "lightPink": "#ffb6c1", 18 | "hotPink": "#ff69b4", 19 | "deepPink": "#ff1493", 20 | "mediumVioletRed": "#c71585", 21 | "paleVioletRed": "#db7093", 22 | "coral": "#ff7f50", 23 | "tomato": "#ff6347", 24 | "orangeRed": "#ff4500", 25 | "darkOrange": "#ff8c00", 26 | "orange": "#ffa500", 27 | "gold": "#ffd700", 28 | "yellow": "#ffff00", 29 | "lightYellow": "#ffffe0", 30 | "lemonChiffon": "#fffacd", 31 | "lightGoldenRodYellow": "#fafad2", 32 | "papayaWhip": "#ffefd5", 33 | "moccasin": "#ffe4b5", 34 | "peachPuff": "#ffdab9", 35 | "paleGoldenRod": "#eee8aa", 36 | "khaki": "#f0e68c", 37 | "darkKhaki": "#bdb76b", 38 | "lavender": "#e6e6fa", 39 | "thistle": "#d8bfd8", 40 | "plum": "#dda0dd", 41 | "violet": "#ee82ee", 42 | "orchid": "#da70d6", 43 | "fuchsia": "#ff00ff", 44 | "magenta": "#ff00ff", 45 | "mediumOrchid": "#ba55d3", 46 | "mediumPurple": "#9370db", 47 | "rebeccaPurple": "#663399", 48 | "blueViolet": "#8a2be2", 49 | "darkViolet": "#9400d3", 50 | "darkOrchid": "#9932cc", 51 | "darkMagenta": "#8b008b", 52 | "purple": "#800080", 53 | "indigo": "#4b0082", 54 | "slateBlue": "#6a5acd", 55 | "darkSlateBlue": "#483d8b", 56 | "mediumSlateBlue": "#7b68ee", 57 | "greenYellow": "#adff2f", 58 | "lawnGreen": "#7cfc00", 59 | "lime": "#00ff00", 60 | "limeGreen": "#32cd32", 61 | "paleGreen": "#98fb98", 62 | "lightGreen": "#90ee90", 63 | "mediumSpringGreen": "#00fa9a", 64 | "springGreen": "#00ff7f", 65 | "mediumSeaGreen": "#3cb371", 66 | "seaGreen": "#2e8b57", 67 | "forestGreen": "#228b22", 68 | "green": "#008000", 69 | "darkGreen": "#006400", 70 | "yellowGreen": "#9acd32", 71 | "oliveDrab": "#6b8e23", 72 | "olive": "#808000", 73 | "darkOliveGreen": "#556b2f", 74 | "mediumAquamarine": "#66cdaa", 75 | "darkSeaGreen": "#8fbc8b", 76 | "lightSeaGreen": "#20b2aa", 77 | "darkCyan": "#008b8b", 78 | "teal": "#008080", 79 | "aqua": "#00ffff", 80 | "cyan": "#00ffff", 81 | "lightCyan": "#e0ffff", 82 | "paleTurquoise": "#afeeee", 83 | "aquamarine": "#7fffd4", 84 | "turquoise": "#40e0d0", 85 | "mediumTurquoise": "#48d1cc", 86 | "darkTurquoise": "#00ced1", 87 | "cadetBlue": "#5f9ea0", 88 | "steelBlue": "#4682b4", 89 | "lightSteelBlue": "#b0c4de", 90 | "powderBlue": "#b0e0e6", 91 | "lightblue": "#add8e6", 92 | "skyBlue": "#87ceeb", 93 | "lightSkyBlue": "#87cefa", 94 | "deepSkyBlue": "#00bfff", 95 | "dodgerBlue": "#1e90ff", 96 | "cornFlowerBlue": "#6495ed", 97 | "royalBlue": "#4169e1", 98 | "mediumBlue": "#0000cd", 99 | "darkblue": "#00008b", 100 | "navy": "#000080", 101 | "midnightBlue": "#191970", 102 | "cornSilk": "#fff8dc", 103 | "blanchedAlmond": "#ffebcd", 104 | "bisque": "#ffe4c4", 105 | "navajoWhite": "#ffdead", 106 | "wheat": "#f5deb3", 107 | "burlyWood": "#deb887", 108 | "tan": "#d2b48c", 109 | "rosyBrown": "#bc8f8f", 110 | "sandyBrown": "#f4a460", 111 | "goldenRod": "#daa520", 112 | "darkGoldenRod": "#b8860b", 113 | "peru": "#cd853f", 114 | "chocolate": "#d2691e", 115 | "saddleBrown": "#8b4513", 116 | "sienna": "#a0522d", 117 | "brown": "#a52a2a", 118 | "maroon": "#800000", 119 | "white": "#ffffff", 120 | "snow": "#fffafa", 121 | "honeydew": "#f0fff0", 122 | "mintCream": "#f5fffa", 123 | "azure": "#f0ffff", 124 | "aliceBlue": "#f0f8ff", 125 | "ghostWhite": "#f8f8ff", 126 | "whiteSmoke": "#f5f5f5", 127 | "seashell": "#fff5ee", 128 | "beige": "#f5f5dc", 129 | "oldLace": "#fdf5e6", 130 | "floralWhite": "#fffaf0", 131 | "ivory": "#fffff0", 132 | "antiqueWhite": "#faebd7", 133 | "linen": "#faf0e6", 134 | "lavenderBlush": "#fff0f5", 135 | "mistyRose": "#ffe4e1", 136 | "lightGray": "#d3d3d3", 137 | "silver": "#c0c0c0", 138 | "darkGray": "#a9a9a9", 139 | "gray": "#808080", 140 | "dimGray": "#696969", 141 | "lightSlateGray": "#778899", 142 | "slateGray": "#708090", 143 | "darkSlateGray": "#2f4f4f", 144 | "black": "#000000" 145 | } 146 | -------------------------------------------------------------------------------- /src/commands/admin/shutdown.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, getServerConfigCommandOptionValue, broadcastMessage, rconCommand 5 | } = require('../../modules/cftClient'); 6 | const { MS_IN_ONE_MINUTE } = require('../../constants'); 7 | const { sleep } = require('../../util'); 8 | 9 | module.exports = new ChatInputCommand({ 10 | permLevel: 'Administrator', 11 | global: true, 12 | data: { 13 | description: 'Stop the server, with an optional delay in minutes', 14 | options: [ 15 | requiredServerConfigCommandOption, 16 | { 17 | type: ApplicationCommandOptionType.Integer, 18 | name: 'delay', 19 | description: 'Delay in minutes before stopping the server', 20 | required: true, 21 | min_value: 0, 22 | max_value: 1440 23 | }, 24 | { 25 | type: ApplicationCommandOptionType.Boolean, 26 | name: 'notify-server', 27 | description: 'Whether to notify the server of the shutdown', 28 | required: true 29 | } 30 | ] 31 | }, 32 | 33 | run: async (client, interaction) => { 34 | // Destructuring 35 | const { member, options } = interaction; 36 | const { emojis } = client.container; 37 | 38 | // Deferring our reply 39 | await interaction.deferReply(); 40 | 41 | // Check if a proper server option is provided 42 | const serverCfg = getServerConfigCommandOptionValue(interaction); 43 | const notifyServer = options.getBoolean('notify-server') ?? true; 44 | const delay = options.getInteger('delay') ?? 0; 45 | const delayMs = delay * MS_IN_ONE_MINUTE; 46 | 47 | await interaction.editReply({ content: `${ emojis.success } ${ member }, scheduling shutdown in ${ delay } minutes...` }); 48 | 49 | // Sending message to server 50 | if (notifyServer === true && delay > 0) { 51 | const res = await broadcastMessage(serverCfg.CFTOOLS_SERVER_API_ID, `Server will be shutting down in ${ delay } minutes`); 52 | if (res !== true) { 53 | interaction.followUp({ content: `${ emojis.error } ${ member }, invalid response code - message might not have broadcasted.` }); 54 | } 55 | } 56 | 57 | // Schedule shutdown notifications 58 | if (delay > 60) { 59 | setTimeout(async () => { 60 | await broadcastMessage(serverCfg.CFTOOLS_SERVER_API_ID, 'Server will be shutting down in 1 hour'); 61 | interaction.followUp({ content: `${ emojis.success } ${ member }, server is shutting down in 1 hour!` }); 62 | }, delayMs - (60 * MS_IN_ONE_MINUTE)); 63 | } 64 | 65 | if (delay > 30) { 66 | setTimeout(async () => { 67 | await broadcastMessage(serverCfg.CFTOOLS_SERVER_API_ID, 'Server will be shutting down in 30 minutes'); 68 | interaction.followUp({ content: `${ emojis.success } ${ member }, server is shutting down in 30 minutes!` }); 69 | }, delayMs - (60 * MS_IN_ONE_MINUTE)); 70 | } 71 | 72 | if (delay > 10) { 73 | setTimeout(async () => { 74 | await broadcastMessage(serverCfg.CFTOOLS_SERVER_API_ID, 'Server will be shutting down in 10 minutes'); 75 | interaction.followUp({ content: `${ emojis.success } ${ member }, server is shutting down in 10 minutes!` }); 76 | }, delayMs - (60 * MS_IN_ONE_MINUTE)); 77 | } 78 | 79 | if (delay > 5) { 80 | setTimeout(async () => { 81 | await broadcastMessage(serverCfg.CFTOOLS_SERVER_API_ID, 'Server will be shutting down in 5 minutes'); 82 | interaction.followUp({ content: `${ emojis.success } ${ member }, server is shutting down in 5 minutes!` }); 83 | }, delayMs - (60 * MS_IN_ONE_MINUTE)); 84 | } 85 | 86 | if (delay > 1) { 87 | setTimeout(async () => { 88 | await broadcastMessage(serverCfg.CFTOOLS_SERVER_API_ID, 'Server will be shutting down in 1 minute'); 89 | interaction.followUp({ content: `${ emojis.success } ${ member }, server is shutting down in 1 minutes!` }); 90 | }, delayMs - (60 * MS_IN_ONE_MINUTE)); 91 | } 92 | 93 | // Wait for delay 94 | await sleep(delayMs); 95 | 96 | const res = await rconCommand(serverCfg.CFTOOLS_SERVER_API_ID, '#shutdown'); 97 | if (res !== true) { 98 | interaction.followUp({ content: `${ emojis.error } ${ member }, invalid response code - server might not have shut-down` }); 99 | return; 100 | } 101 | 102 | // User feedback on success 103 | interaction.followUp({ content: `${ emojis.success } ${ member }, server is shutting down!` }); 104 | } 105 | }); 106 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A collection of client-extensions, containerized under `client.container`. Exported from the `/src/client.js` file 3 | * @module Client 4 | */ 5 | 6 | const { Collection } = require('discord.js'); 7 | 8 | // Local imports 9 | const { clientConfig } = require('./util'); 10 | const config = clientConfig; 11 | const emojis = require('../config/emojis.json'); 12 | const colors = require('../config/colors'); 13 | 14 | // Building collections 15 | const Commands = new Collection(); 16 | const ContextMenus = new Collection(); 17 | const Buttons = new Collection(); 18 | const Modals = new Collection(); 19 | const AutoCompletes = new Collection(); 20 | const SelectMenus = new Collection(); 21 | 22 | /** 23 | * The `discord.js` Client class/object 24 | * @external DiscordClient 25 | * @see {@link https://discord.js.org/#/docs/discord.js/main/class/Client} 26 | */ 27 | 28 | /** 29 | * Discord API Gateway Intents Bits 30 | * @external DiscordGatewayIntentBits 31 | * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/GatewayIntentBits} 32 | */ 33 | 34 | /** 35 | * The status of this presence, online, idle or dnd 36 | * @external DiscordClientPresenceStatus 37 | * @see {@link https://discord.js.org/#/docs/discord.js/main/typedef/ClientPresenceStatus} 38 | */ 39 | 40 | /** 41 | * @external DiscordActivityOptions 42 | * @see {@link https://discord.js.org/#/docs/discord.js/main/typedef/ActivityOptions} 43 | */ 44 | 45 | /** 46 | * @typedef {Object} ClientConfigPresence 47 | * @property {external:DiscordClientPresenceStatus} status The client's status (online, busy, dnd, offline) 48 | * @property {Array} activities Array of client activities 49 | */ 50 | 51 | /** 52 | * @typedef {Object} ClientConfigPermissions 53 | * @property {string} ownerId The bot owners's Discord user id 54 | * @property {Array} developers Array of Discord user id's representing active developers 55 | */ 56 | 57 | /** 58 | * @typedef {Object} ClientConfiguration 59 | * @property {Array} intents Required gateway intents 60 | * @property {module:Client~ClientConfigPresence} presence Client presence configuration 61 | * @property {module:Client~ClientConfigPermissions} permissions Internal permission configuration 62 | * @property {string} supportServerInviteLink The link to the Discord server where bot support is offered 63 | */ 64 | 65 | /** 66 | * @typedef {Object} ClientEmojiConfiguration 67 | * @property {string} success Emoji prefix that indicates a successful operation/action 68 | * @property {string} error Emoji prefix that indicates something went wrong 69 | * @property {string} wait Emojis prefix that indicates the client is processing 70 | * @property {string} info Emoji prefix that indicates a general tip 71 | * @property {string} separator Emoji prefix used as a separator 72 | */ 73 | 74 | /** 75 | * @typedef {Object} ClientColorConfiguration 76 | * @property {string} main The main color/primary color. Used in most embeds. 77 | * @property {string} invisible The color that appears invisible in Discord dark mode 78 | * @property {string} success The color used in embeds that display a success message 79 | * @property {string} error The color used in embeds that display an error 80 | */ 81 | 82 | /** 83 | * @typedef {Object} ClientContainer 84 | * @property {module:Client~ClientConfiguration} config The discord client configuration 85 | * @property {module:Client~ClientEmojiConfiguration} emojis An object with defined emoji keys 86 | * @property {module:Client~ClientColorConfiguration} colors An object with defined color keys 87 | * @property {Collection} commands Chat Input commands 88 | * @property {Collection} contextMenus Context Menu commands 89 | * @property {Collection} buttons Button commands 90 | * @property {Collection} modals Modal commands 91 | * @property {Collection} autoCompletes Autocomplete commands 92 | * @property {Collection} selectMenus Select Menu commands 93 | */ 94 | 95 | /** 96 | * @typedef {external:DiscordClient} Client 97 | * @property {module:Client~ClientContainer} container Our containerized client extensions 98 | */ 99 | 100 | /** 101 | * @type {module:Client~ClientContainer} 102 | */ 103 | module.exports = { 104 | // Config 105 | config, 106 | emojis, 107 | colors, 108 | 109 | // Collections 110 | commands: Commands, 111 | contextMenus: ContextMenus, 112 | buttons: Buttons, 113 | modals: Modals, 114 | autoCompletes: AutoCompletes, 115 | selectMenus: SelectMenus 116 | }; 117 | -------------------------------------------------------------------------------- /src/commands/teleport/teleport-all-to-location.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredServerConfigCommandOption, 5 | cftClient, 6 | requiredTeleportLocationOption, 7 | getTeleportLocationOptionValue 8 | } = require('../../modules/cftClient'); 9 | const { ChatInputCommand } = require('../../classes/Commands'); 10 | const { ServerApiId } = require('cftools-sdk'); 11 | const { sleep, msToHumanReadableTime } = require('../../util'); 12 | const { MS_IN_ONE_SECOND } = require('../../constants'); 13 | const TELEPORT_COOLDOWN_IN_SECONDS = 15; 14 | 15 | module.exports = new ChatInputCommand({ 16 | global: true, 17 | permLevel: 'Administrator', 18 | data: { 19 | description: 'Teleport everyone that is currently online to customizable locations', 20 | options: [ requiredServerConfigCommandOption, requiredTeleportLocationOption ] 21 | }, 22 | // eslint-disable-next-line sonarjs/cognitive-complexity 23 | run: async (client, interaction) => { 24 | // Destructuring and assignments 25 | const { member } = interaction; 26 | const { emojis } = client.container; 27 | 28 | // Check active/enabled 29 | const serverCfg = getServerConfigCommandOptionValue(interaction); 30 | if (!serverCfg.USE_TELEPORT_LOCATIONS) { 31 | interaction.reply(`${ emojis.error } ${ member }, teleport locations aren't enabled for this server configuration`); 32 | return; 33 | } 34 | 35 | // Resolve location 36 | const tpLocation = getTeleportLocationOptionValue(interaction); 37 | if (!tpLocation) { 38 | interaction.reply(`${ emojis.error } ${ member }, \`teleport-location\` can't be resolved. This usually happens when you change selected server while having loaded the \`teleport-location\` option, please try again - this command has been cancelled`); 39 | return; 40 | } 41 | 42 | // Try to destructure and verify type 43 | let coords; 44 | try { 45 | // eslint-disable-next-line array-element-newline, array-bracket-newline 46 | const { name, coordinates: [ x, y, z ] } = tpLocation; 47 | if ( 48 | typeof x !== 'number' 49 | || typeof y !== 'number' 50 | || typeof z !== 'number' 51 | ) { 52 | interaction.editReply(`${ emojis.error } ${ member }, invalid coordinate configuration for teleport location **${ name }**: <${ x }, ${ y }, ${ z }>`); 53 | return; 54 | } 55 | coords = { 56 | x, y, z 57 | }; 58 | } 59 | catch (err) { 60 | handleCFToolsError(interaction, err); 61 | return; 62 | } 63 | 64 | // Safe to destructure 65 | const { 66 | x, y, z 67 | } = coords; 68 | 69 | // Deferring our reply 70 | await interaction.deferReply(); 71 | 72 | // Fetch all sessions 73 | const allSessions = await cftClient 74 | .listGameSessions({ serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID) }); 75 | 76 | // Notify start 77 | await interaction.editReply(`${ emojis.wait } ${ member }, teleporting everyone to **\`${ tpLocation.name }\`**, **this will happen in ${ TELEPORT_COOLDOWN_IN_SECONDS } second intervals to avoid getting rate limited**`); 78 | 79 | // Teleport everyone to target 80 | for await (const session of allSessions) { 81 | const sessionIndex = allSessions.indexOf(session); 82 | 83 | // Try to perform teleport, for each session 84 | // On a 15 second interval 85 | try { 86 | await cftClient.teleport({ 87 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 88 | session, 89 | coordinates: { 90 | x, y: z, z: y 91 | } 92 | }); 93 | } 94 | catch (err) { 95 | handleCFToolsError(interaction, err, true); 96 | // Sleep for 15 seconds - even if error is encountered 97 | await sleep(MS_IN_ONE_SECOND * TELEPORT_COOLDOWN_IN_SECONDS); 98 | continue; 99 | } 100 | 101 | // Resolve remaining time str 102 | const remainingSeconds = TELEPORT_COOLDOWN_IN_SECONDS * ( 103 | allSessions.length 104 | - (sessionIndex + 1) 105 | ); 106 | const timeRemainingStr = sessionIndex + 1 !== allSessions.length 107 | ? ` ~${ msToHumanReadableTime(remainingSeconds * 1000) } remaining` 108 | : ''; 109 | 110 | // Explicit await for non-static loop interval 111 | await interaction.followUp(`${ emojis.success } Teleported **\`${ session.playerName }\`** to **\`${ tpLocation.name }\`** (${ sessionIndex + 1 } out of ${ allSessions.length })${ timeRemainingStr }`); 112 | 113 | // Sleep for 15 seconds 114 | await sleep(MS_IN_ONE_SECOND * TELEPORT_COOLDOWN_IN_SECONDS); 115 | } 116 | 117 | // Ok, feedback 118 | interaction.editReply({ content: `${ emojis.success } ${ member }, everyone has been teleported to **\`${ tpLocation.name }\`**` }); 119 | interaction.followUp(`${ emojis.success } ${ member }, finished teleporting`); 120 | } 121 | }); 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/commands/admin/spawn-item-on-coords.js: -------------------------------------------------------------------------------- 1 | const { ApplicationCommandOptionType } = require('discord.js'); 2 | const { ChatInputCommand } = require('../../classes/Commands'); 3 | const { 4 | requiredServerConfigCommandOption, 5 | getServerConfigCommandOptionValue, 6 | postGameLabsAction, 7 | broadcastMessage 8 | } = require('../../modules/cftClient'); 9 | 10 | module.exports = new ChatInputCommand({ 11 | permLevel: 'Administrator', 12 | global: true, 13 | data: { 14 | description: 'Spawn an item at provided coordinates', 15 | options: [ 16 | requiredServerConfigCommandOption, 17 | { 18 | name: 'item-class', 19 | description: 'The class name of the item to give to the player', 20 | type: ApplicationCommandOptionType.String, 21 | required: true, 22 | min_length: 1, 23 | max_length: 256 24 | }, 25 | { 26 | name: 'x-coordinate', 27 | description: 'The X level coordinate to teleport the player to', 28 | type: ApplicationCommandOptionType.Number, 29 | required: true, 30 | min_value: 0.0000, 31 | max_value: 100000.000 32 | }, 33 | { 34 | name: 'y-coordinate', 35 | description: 'The Y level coordinate to teleport the player to', 36 | type: ApplicationCommandOptionType.Number, 37 | required: true, 38 | min_value: 0.0000, 39 | max_value: 100000.000 40 | }, 41 | { 42 | name: 'z-coordinate', 43 | description: 'The Z level coordinate to teleport the player to', 44 | type: ApplicationCommandOptionType.Number, 45 | required: true, 46 | min_value: 0.0000, 47 | max_value: 100000.000 48 | }, 49 | { 50 | name: 'quantity', 51 | description: 'The quantity for this item, default is 1', 52 | type: ApplicationCommandOptionType.Number, 53 | required: false, 54 | min_value: 0.0000, 55 | max_value: 1000 56 | }, 57 | { 58 | name: 'stacked', 59 | description: 'Spawn items as a stack (only works if item supports to be stacked), default is false', 60 | type: ApplicationCommandOptionType.Boolean, 61 | required: false 62 | }, 63 | { 64 | name: 'debug', 65 | description: 'Use debug spawn method to automatically populate specific items', 66 | type: ApplicationCommandOptionType.Boolean, 67 | required: false 68 | }, 69 | { 70 | name: 'notify-players', 71 | description: 'Send a global notification to the players, default FALSE', 72 | type: ApplicationCommandOptionType.Boolean, 73 | required: false 74 | } 75 | ] 76 | }, 77 | 78 | run: async (client, interaction) => { 79 | // Destructuring 80 | const { member, options } = interaction; 81 | const { emojis } = client.container; 82 | const itemClass = options.getString('item-class'); 83 | const quantity = options.getNumber('quantity') ?? 1; 84 | const stacked = options.getBoolean('stacked') ?? false; 85 | const debug = options.getBoolean('debug') ?? false; 86 | const x = options.getNumber('x-coordinate'); 87 | const y = options.getNumber('y-coordinate'); 88 | const z = options.getNumber('z-coordinate'); 89 | const notifyPlayers = options.getBoolean('notify-players') ?? false; 90 | 91 | // Deferring our reply 92 | await interaction.deferReply(); 93 | 94 | // Resolve options 95 | const serverCfg = getServerConfigCommandOptionValue(interaction); 96 | 97 | // Performing request 98 | const res = await postGameLabsAction( 99 | serverCfg.CFTOOLS_SERVER_API_ID, 100 | 'CFCloud_SpawnItemWorld', 101 | 'world', 102 | null, 103 | { 104 | vector: { 105 | dataType: 'vector', 106 | valueVectorX: x, 107 | valueVectorY: y, 108 | valueVectorZ: z 109 | }, 110 | item: { 111 | dataType: 'string', 112 | valueString: itemClass 113 | }, 114 | quantity: { 115 | dataType: 'int', 116 | valueInt: quantity 117 | }, 118 | stacked: { 119 | dataType: 'boolean', 120 | valueBool: stacked 121 | }, 122 | debug: { 123 | dataType: 'boolean', 124 | valueBool: debug 125 | } 126 | } 127 | ); 128 | 129 | if (res !== true) { 130 | interaction.editReply({ content: `${ emojis.error } ${ member }, invalid response code - item might not have been spawned at provided coordinates` }); 131 | return; 132 | } 133 | 134 | // Notify player 135 | if (notifyPlayers) { 136 | await broadcastMessage( 137 | serverCfg.CFTOOLS_SERVER_API_ID, 138 | `${ itemClass } has spawned at ${ x }, ${ y }, ${ z }!` 139 | ); 140 | } 141 | 142 | // User feedback on success 143 | interaction.editReply({ content: `${ emojis.success } ${ member }, ${ itemClass } has been spawned at ${ x }, ${ y }, ${ z }` }); 144 | } 145 | }); 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

CFTools Bot Logo

2 |

CFTools Discord Bot

3 |

A Discord bot that fully utilizes the CFTools Data API.

4 | 5 |
6 | 7 | ![build](https://img.shields.io/github/actions/workflow/status/mirasaki/cftools-discord-bot/test.yml) 8 | [![CodeFactor](https://www.codefactor.io/repository/github/Mirasaki/cftools-discord-bot/badge)](https://www.codefactor.io/repository/github/Mirasaki/cftools-discord-bot) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 10 | ![Docker Pulls](https://img.shields.io/docker/pulls/mirasaki/cftools-discord-bot) 11 | ![version](https://img.shields.io/github/v/release/Mirasaki/cftools-discord-bot) 12 | 13 | 14 |
15 | 16 |

17 | 18 |
19 |
20 |

⭐ It's free, open-source, and self-host - meaning you're in full control

21 |

22 | This project was created and open-sourced by Mirasaki Development. That means it's publicly available for anyone to grab and do whatever with (MIT licensed). This project will never be monetized, every feature will always be free (keep in mind CFTools has premium endpoints). All I need to keep adding new functionality and modules is some GitHub stars. Join the absolute legends below by clicking that Star button in the top-right of your screen, it doesn't cost you anything and means the world to us ❤️ 23 |

24 | 25 |
26 |
27 | 28 |

🎥 Showcase

29 | 30 |
31 | Click to view 32 | 33 | ![dm-survivor](./assets/showcase/dm-survivor.gif) 34 | ![stats-normal](./assets/showcase/stats_normal.png) 35 | ![admin-player-list](./assets/showcase/admin-player-list.png) 36 | ![kick](./assets/showcase/kick.gif) 37 | ![flagged-player-list](./assets/showcase/flagged-player-list.png) 38 | ![server-info](./assets/showcase/server-info.png) 39 | 40 |
41 | 42 |

🤩 Features

43 | 44 | - Discord > DayZ live chat feed - comes with a tag system and is **very** customizable 45 | - Watch List - Receive role-ping notifications when a player in a custom managed list logs in 46 | - Delayed Killfeed - Delay kill webhook messages (configurable) before forwarding them to a different/public channel 47 | - Player Lists 48 | 49 | - Public list 50 | - Admin list with CFTools + Steam links 51 | - Flagged list for potential troublesome accounts/players 52 | 53 | - User-friendly in-game player auto complete 54 | - Broadcast messages to everyone on server 55 | - Direct Message (private) online players 56 | - Heal players 57 | - Kick players 58 | - Kill players 59 | - Manage time & weather 60 | - Strip players, removing all their possessions 61 | - Spawn items on players 62 | - Teleport players 63 | - Comes with support for custom (autocomplete enabled) teleport locations (`/teleport-location`), instead of having to provide coordinates (still supported in `/teleport` command) 64 | - Teleport multiple or all to online player 65 | - Teleport multiple or all to customizable locations 66 | - Currently looking for people to contribute, we'd like a strong default configuration for users to utilize. Check out [the config file example](./config/teleport-locations/chernarus.json) and determine if you'd like to contribute, create a pull request or contact me on Discord: Mirasaki#2287 67 | - Complete leaderboard integration with all available stats 68 | - Display detailed player/individual statistics, supports Steam64, BattlEye GUID, and Bohemia Interactive Id 69 | - Player hit zone % heat maps 70 | - Server info overview 71 | - And best of all, everything is configurable! 72 | 73 |

💡 Planned Features

74 | 75 | - Execute raw RCon commands - I'm looking for someone that is very knowledgeable on available RCon command 76 | - Dedicated Server Status channel, overview with online/offline status 77 | - Manage Priority Queue 78 | - Manage Ban lists 79 | - Manage Whitelists 80 | - Custom GameLab action support 81 | 82 |

🖥️ Hosting

83 | 84 | We have partnered with [VYKIX.com](https://portal.vykix.com/aff.php?aff=17) after observing many of our clients using VYKIX services and products. Check them out for affordable and reliable hosting, they bring the **best DayZ hosting experience possible.** 📈 85 | 86 |

🔨 Installation

87 | 88 | Check out [the wiki for this project](https://wiki.mirasaki.dev/docs/cftools-discord-bot) to learn how to configure and run this bot 89 | 90 | > Open source, self-hosted, and MIT licensed, meaning you're in full control. 91 | 92 |

Back to top

93 | -------------------------------------------------------------------------------- /src/commands/teleport/teleport-all-to-player.js: -------------------------------------------------------------------------------- 1 | const { 2 | getServerConfigCommandOptionValue, 3 | handleCFToolsError, 4 | requiredPlayerSessionOption, 5 | requiredServerConfigCommandOption, 6 | getPlayerSessionOptionValue, 7 | cftClient 8 | } = require('../../modules/cftClient'); 9 | const { ChatInputCommand } = require('../../classes/Commands'); 10 | const { ServerApiId } = require('cftools-sdk'); 11 | const { sleep, msToHumanReadableTime } = require('../../util'); 12 | const { MS_IN_ONE_SECOND } = require('../../constants'); 13 | const TELEPORT_COOLDOWN_IN_SECONDS = 15; 14 | 15 | module.exports = new ChatInputCommand({ 16 | global: true, 17 | permLevel: 'Administrator', 18 | data: { 19 | description: 'Teleport everyone that is currently online to a player', 20 | options: [ requiredServerConfigCommandOption, requiredPlayerSessionOption ] 21 | }, 22 | // eslint-disable-next-line sonarjs/cognitive-complexity 23 | run: async (client, interaction) => { 24 | // Destructuring and assignments 25 | const { member } = interaction; 26 | const { emojis } = client.container; 27 | const serverCfg = getServerConfigCommandOptionValue(interaction); 28 | 29 | // Deferring our reply 30 | await interaction.deferReply(); 31 | 32 | // Check session, might have logged out 33 | const targetSession = await getPlayerSessionOptionValue(interaction); 34 | if (!targetSession) { 35 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve provided player/session, player most likely logged out - this command has been cancelled`); 36 | return; 37 | } 38 | 39 | // Resolve data 40 | // Haha optional fields be like 41 | let coords; 42 | const { live } = targetSession; 43 | if (live) { 44 | const { position } = live; 45 | if (position) { 46 | const { latest } = position; 47 | if (latest) coords = latest; 48 | } 49 | } 50 | 51 | // Check data availability 52 | if (!coords) { 53 | interaction.editReply(`${ emojis.error } ${ member }, can't resolve latest coordinates for target **\`${ targetSession.playerName }\`**, try again later (they might not have finished connecting/loading yet) - this command has been cancelled`); 54 | return; 55 | } 56 | 57 | // Safe to destructure 58 | const { 59 | x, y, z 60 | } = coords; 61 | 62 | // Fetch all sessions 63 | const allSessions = await cftClient 64 | .listGameSessions({ serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID) }); 65 | 66 | // Notify start 67 | await interaction.editReply(`${ emojis.wait } ${ member }, teleporting everyone to **\`${ targetSession.playerName }\`**, **this will happen in ${ TELEPORT_COOLDOWN_IN_SECONDS } second intervals to avoid getting rate limited**`); 68 | 69 | // Teleport everyone to target 70 | for await (const session of allSessions) { 71 | const sessionIndex = allSessions.indexOf(session); 72 | 73 | // Skip target player 74 | if (session.id === targetSession.id) { 75 | interaction.followUp(`${ emojis.success } Skipping **\`${ session.playerName }\`** as they are the target location (${ sessionIndex + 1 } out of ${ allSessions.length })`); 76 | continue; 77 | } 78 | 79 | // Try to perform teleport, for each session 80 | // On a 15 second interval 81 | try { 82 | await cftClient.teleport({ 83 | serverApiId: ServerApiId.of(serverCfg.CFTOOLS_SERVER_API_ID), 84 | session, 85 | coordinates: { 86 | x, y, z 87 | } 88 | }); 89 | } 90 | catch (err) { 91 | handleCFToolsError(interaction, err, true); 92 | // Sleep for 15 seconds - even if error is encountered 93 | await sleep(MS_IN_ONE_SECOND * TELEPORT_COOLDOWN_IN_SECONDS); 94 | continue; 95 | } 96 | 97 | // Resolve remaining time str, account for skipping target session 98 | const remainingSeconds = TELEPORT_COOLDOWN_IN_SECONDS * (( 99 | /* Should account for 0-index, but we add 1 for the target player session **/ 100 | allSessions.length - 2 101 | ) - (sessionIndex - ( 102 | sessionIndex >= allSessions.indexOf(targetSession) 103 | ? 1 104 | : 0 105 | ))); 106 | const timeRemainingStr = sessionIndex + 1 !== allSessions.length 107 | ? ` ~${ msToHumanReadableTime(remainingSeconds * 1000) } remaining` 108 | : ''; 109 | 110 | // Explicit await for non-static loop interval 111 | await interaction.followUp(`${ emojis.success } Teleported **\`${ session.playerName }\`** to **\`${ targetSession.playerName }\`** (${ sessionIndex + 1 } out of ${ allSessions.length })${ timeRemainingStr }`); 112 | 113 | // Sleep for 15 seconds 114 | await sleep(MS_IN_ONE_SECOND * TELEPORT_COOLDOWN_IN_SECONDS); 115 | } 116 | 117 | // Ok, feedback 118 | interaction.editReply({ content: `${ emojis.success } ${ member }, everyone has been teleported to **\`${ targetSession.playerName }\`**` }); 119 | interaction.followUp(`${ emojis.success } ${ member }, finished teleporting`); 120 | } 121 | }); 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/modules/auto-lb.js: -------------------------------------------------------------------------------- 1 | // Imports 2 | const chalk = require('chalk'); 3 | const logger = require('@mirasaki/logger'); 4 | const { MS_IN_ONE_DAY } = require('../constants'); 5 | const { serverConfig, cftClient } = require('./cftClient'); 6 | const { ServerApiId, Statistic } = require('cftools-sdk'); 7 | const { buildLeaderboardEmbedMessages } = require('./leaderboard'); 8 | 9 | // Definitions 10 | const MS_IN_TWO_WEEKS = MS_IN_ONE_DAY * 14; 11 | 12 | // The function that runs on every interval 13 | const autoLbCycle = async (client) => { 14 | for (const serverCfg of serverConfig) { 15 | // Skip if disabled 16 | if (!serverCfg.AUTO_LB_ENABLED) { 17 | logger.info(`Automatic leaderboard posting disabled for "${ serverCfg.NAME }", skipping initialization`); 18 | continue; 19 | } 20 | 21 | // Schedule according to interval 22 | const autoLbInterval = serverCfg.AUTO_LB_INTERVAL_IN_MINUTES * 60 * 1000; 23 | performAutoLb(client, serverCfg) 24 | .then(() => setInterval(() => performAutoLb(client, serverCfg), autoLbInterval)); 25 | } 26 | }; 27 | 28 | const performAutoLb = async (client, serverCfg) => { 29 | const { 30 | NAME, 31 | CFTOOLS_SERVER_API_ID, 32 | LEADERBOARD_BLACKLIST, 33 | AUTO_LB_CHANNEL_ID, 34 | AUTO_LB_PLAYER_LIMIT, 35 | AUTO_LB_REMOVE_OLD_MESSAGES, 36 | AUTO_LB_STAT, 37 | OVERALL_RANKING_STAT 38 | } = serverCfg; 39 | // Resolve the automatic leaderboard channel and stop if it's not available 40 | const autoLbChannel = await getAutoLbChannel(client, AUTO_LB_CHANNEL_ID); 41 | if (!autoLbChannel) { 42 | logger.syserr(`The automatic leaderboard module is enabled, but the channel (${ chalk.green(AUTO_LB_CHANNEL_ID) }) can't be found/resolved.`); 43 | return; 44 | } 45 | 46 | // Clean our old messages (going back 2 weeks) from the channel 47 | // If requested - truthy value 48 | if (AUTO_LB_REMOVE_OLD_MESSAGES) await cleanChannelClientMessages(client, autoLbChannel); 49 | 50 | // Fetch Leaderboard API data 51 | let res; 52 | try { 53 | // Fetching our leaderboard data from the CFTools API 54 | const statToGet = Statistic[ 55 | AUTO_LB_STAT === 'OVERALL' 56 | ? OVERALL_RANKING_STAT ?? Statistic.KILL_DEATH_RATIO 57 | : AUTO_LB_STAT 58 | ]; 59 | res = await cftClient 60 | .getLeaderboard({ 61 | serverApiId: ServerApiId.of(CFTOOLS_SERVER_API_ID), 62 | order: 'ASC', 63 | statistic: statToGet ?? Statistic.KILL_DEATH_RATIO, 64 | limit: 100 65 | }); 66 | } 67 | catch (err) { 68 | // Properly logging the error if it is encountered 69 | logger.syserr(`[AUTO-LB] Encountered an error while fetching leaderboard data for "${ NAME }" automatic-leaderboard posting`); 70 | logger.printErr(err); 71 | return; 72 | } 73 | 74 | // Resolve leaderboard display player limit 75 | // Getting our player data count 76 | let playerLimit = Number(AUTO_LB_PLAYER_LIMIT); 77 | if ( 78 | isNaN(playerLimit) 79 | || playerLimit < 10 80 | || playerLimit > 100 81 | ) { 82 | // Overwrite the provided player limit back to default if invalid 83 | playerLimit = 100; 84 | } 85 | 86 | // Build the leaderboard embed 87 | const whitelistedData = res.filter((e) => !LEADERBOARD_BLACKLIST.includes(e.id.id)); 88 | const lbEmbedMessages = buildLeaderboardEmbedMessages( 89 | autoLbChannel.guild, 90 | whitelistedData, 91 | AUTO_LB_STAT === 'OVERALL', 92 | AUTO_LB_STAT, 93 | playerLimit, 94 | serverCfg 95 | ); 96 | 97 | // Send the data 98 | for await (const lbEmbed of lbEmbedMessages) { 99 | // Send the leaderboard data to configured channel 100 | await autoLbChannel.send({ embeds: lbEmbed }); 101 | } 102 | }; 103 | 104 | // Resolves the configured auto-leaderboard channel from process environment 105 | const getAutoLbChannel = async (client, AUTO_LB_CHANNEL_ID) => { 106 | if ( 107 | typeof AUTO_LB_CHANNEL_ID === 'undefined' 108 | || AUTO_LB_CHANNEL_ID.length < 1 109 | || !AUTO_LB_CHANNEL_ID.match(/^\d+$/) 110 | ) return null; 111 | else { 112 | try { 113 | return await client.channels.fetch(AUTO_LB_CHANNEL_ID); 114 | } 115 | catch (err) { 116 | logger.syserr('Error encountered while fetching AUTO_LB_CHANNEL_ID:'); 117 | logger.printErr(err); 118 | return null; 119 | } 120 | } 121 | }; 122 | 123 | // Cleans all client-user messages from given channel 124 | const cleanChannelClientMessages = async (client, channel) => { 125 | const timestampTwoWeeksAgo = Date.now() - MS_IN_TWO_WEEKS; 126 | try { 127 | await channel.bulkDelete( 128 | (await channel.messages.fetch({ limit: 100 })) 129 | .filter( 130 | (msg) => msg.author.id === client.user.id // Check client messages 131 | && msg.createdTimestamp > timestampTwoWeeksAgo // Check 2 weeks old - API limitation 132 | ) 133 | ); 134 | } 135 | catch (err) { 136 | // Properly log unexpected errors 137 | logger.syserr(`Something went wrong while cleaning channel (${ chalk.green(channel.name) }) from client messages:`); 138 | console.error(err); 139 | } 140 | }; 141 | 142 | module.exports = { 143 | autoLbCycle, 144 | performAutoLb, 145 | getAutoLbChannel, 146 | cleanChannelClientMessages 147 | }; 148 | -------------------------------------------------------------------------------- /src/modules/heatmap.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { readFileSync } = require('fs'); 3 | const h337 = require('heatmap.js'); 4 | const logger = require('@mirasaki/logger'); 5 | 6 | const heatmapWidth = 500; 7 | const heatmapHeight = 900; 8 | const backgroundImage = './assets/images/body.png'; 9 | const backgroundImageDataUrl = `data:image/jpeg;base64,${ readFileSync(backgroundImage).toString('base64') }`; 10 | 11 | const { PATH_TO_CHROME_EXECUTABLE } = process.env; 12 | 13 | // Define the coordinates for each hit zone 14 | const zoneCoordinates = { 15 | brain: { 16 | x: 240, y: 15 17 | }, 18 | head: { 19 | x: 240, y: 45 20 | }, 21 | 22 | torso: { 23 | x: 240, y: 225 24 | }, 25 | 26 | rightLeg: { 27 | x: 295, y: 625 28 | }, 29 | rightFoot: { 30 | x: 280, y: 840 31 | }, 32 | rightHand: { 33 | x: 388, y: 440 34 | }, 35 | rightArm: { 36 | x: 345, y: 311 37 | }, 38 | 39 | leftLeg: { 40 | x: 190, y: 625 41 | }, 42 | leftFoot: { 43 | x: 195, y: 850 44 | }, 45 | leftHand: { 46 | x: 95, y: 450 47 | }, 48 | leftArm: { 49 | x: 135, y: 300 50 | } 51 | }; 52 | 53 | // Headless Puppeteer Chromium browser 54 | // Use reference for increased speed, avoiding start up/#launch 55 | let browser; 56 | const initBrowser = async () => { 57 | const cfg = { 58 | headless: 'new', 59 | args: [ '--no-sandbox', '--disable-setuid-sandbox' ] 60 | }; 61 | if (PATH_TO_CHROME_EXECUTABLE) cfg.executablePath = PATH_TO_CHROME_EXECUTABLE; 62 | try { 63 | browser = await puppeteer.launch(cfg); 64 | } 65 | catch (err) { 66 | logger.syserr('Error encountered while launching headless puppeteer Chromium browser:'); 67 | logger.printErr(err); 68 | } 69 | return browser; 70 | }; 71 | 72 | /** 73 | * @returns {Promise} 74 | */ 75 | const getBrowser = async (cfg) => { 76 | if (cfg.STATISTICS_KEEP_PUPPETEER_BROWSER_OPEN) { 77 | if (!browser) return await initBrowser(); 78 | else return browser; 79 | } 80 | else return await initBrowser(); 81 | }; 82 | 83 | const createHitZonesHeatMap = async (cfg, hitZones = { 84 | brain: 15, 85 | head: 35, 86 | 87 | torso: 50, 88 | 89 | rightLeg: 40, 90 | rightFoot: 15, 91 | rightHand: 25, 92 | rightArm: 20, 93 | 94 | leftLeg: 40, 95 | leftFoot: 15, 96 | leftHand: 25, 97 | leftArm: 20 98 | }) => { 99 | // Resolve our browser 100 | const browser = await getBrowser(cfg); 101 | 102 | if (!browser) throw new Error('Failed to resolve browser instance - Chromium instance couldn\'t be initialized. Refer to the documentation for more information, or turn STATISTICS_INCLUDE_ZONES_HEATMAP off in your server configuration.'); 103 | 104 | // Open a new page 105 | const page = await browser.newPage(); 106 | 107 | // Set the viewport size to match the heatmap dimensions 108 | await page.setViewport({ 109 | width: heatmapWidth, 110 | height: heatmapHeight 111 | }); 112 | 113 | // Include the heatmap.js library from a CDN 114 | await page.evaluate(() => { 115 | const script = document.createElement('script'); 116 | script.src = 'https://cdn.jsdelivr.net/npm/heatmap.js'; 117 | document.head.appendChild(script); 118 | }); 119 | 120 | // Wait for the heatmap.js library to load 121 | await page.waitForFunction(() => typeof h337 !== 'undefined'); 122 | 123 | // Forward console logs 124 | if (process.env.NODE_ENV !== 'production') page.on('console', (msg) => console.info('PAGE LOG:', msg.text())); 125 | 126 | // Create a heatmap instance in the page context 127 | await page.evaluate( 128 | ( 129 | hitZones, 130 | heatmapWidth, 131 | heatmapHeight, 132 | backgroundImage, 133 | zoneCoordinates 134 | ) => { 135 | // Create a canvas element for the heatmap 136 | const canvas = document.createElement('canvas'); 137 | canvas.width = heatmapWidth; 138 | canvas.height = heatmapHeight; 139 | document.body.appendChild(canvas); 140 | canvas.style.zIndex = 9999; 141 | 142 | // Create a heatmap instance 143 | const heatmapInstance = h337.create({ 144 | container: canvas, 145 | radius: 100, 146 | // maxOpacity: 1, 147 | // minOpacity: .5, 148 | // blur: .75 149 | gradient: { 150 | 0: 'yellow', 151 | 1.0: '#FF0000' 152 | } 153 | }); 154 | 155 | // Set the background image for the heatmap 156 | const backgroundImageStyle = `url(${ backgroundImage })`; 157 | document.body.style.backgroundImage = backgroundImageStyle; 158 | 159 | // Add the hit zones data to the heatmap 160 | for (const zone in hitZones) { 161 | const value = hitZones[zone]; 162 | if (!zoneCoordinates[zone]) continue; 163 | const { x, y } = zoneCoordinates[zone]; 164 | heatmapInstance.addData({ 165 | x, y, value: value 166 | }); 167 | } 168 | 169 | canvas.style.backgroundImage = `url(${ heatmapInstance.getDataURL() })`; 170 | }, 171 | hitZones, 172 | heatmapWidth, 173 | heatmapHeight, 174 | backgroundImageDataUrl, 175 | zoneCoordinates 176 | ); 177 | 178 | // Capture a screenshot of the heatmap 179 | const img = await page.screenshot({ omitBackground: true }); 180 | 181 | // Close the browser instance 182 | if (!cfg.STATISTICS_KEEP_PUPPETEER_BROWSER_OPEN) await browser.close(); 183 | else await page.close(); 184 | 185 | // Finally, return the image 186 | return img; 187 | }; 188 | 189 | module.exports = { createHitZonesHeatMap }; 190 | --------------------------------------------------------------------------------