diff --git a/.eslintrc.json b/.eslintrc.json index 3096922..667a13e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,29 +1,17 @@ { - "env": { - "commonjs": true, - "es2021": true, - "node": true - }, - "extends": "eslint:recommended", - "parserOptions": { - "ecmaVersion": "latest" - }, - "rules": { - "indent": [ - "error", - 2 - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "double" - ], - "semi": [ - "error", - "always" - ] - } + "env": { + "es2021": true, + "node": true + }, + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], + "overrides": [], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "prettier"], + "rules": { + "prettier/prettier": "error" + } } diff --git a/.gitignore b/.gitignore index 9669650..1a0de81 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ # NPM /node_modules +# TypeScript +/dist + # Custom Ignores -/.vscode latest-error.log config.json *.sh \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b14cf56 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "trailingComma": "none", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "endOfLine": "lf", + "printWidth": 120 +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..def7e72 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Build & Run IRF:A", + "skipFiles": [ + "/**" + ], + "preLaunchTask": "npm: prestart", + "cwd": "${workspaceFolder}\\dist", + "program": "index.js", + "env": { + "environment": "development" + } + }, + { + "type": "node", + "request": "launch", + "name": "Run IRF:A", + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}\\dist", + "program": "index.js" + } + ] +} \ No newline at end of file diff --git a/.vscode/pets.json b/.vscode/pets.json new file mode 100644 index 0000000..39f3220 --- /dev/null +++ b/.vscode/pets.json @@ -0,0 +1,8 @@ +[ + { + "color": "red", + "type": "fox", + "size": "nano", + "name": "Tavi (TaviShadows)" + } +] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1ba1f30 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1 @@ +This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License. To view a copy of this license, visit [http://creativecommons.org/licenses/by-sa/4.0/](http://creativecommons.org/licenses/by-sa/4.0/). \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a12e3a1 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# IRF Administration +This document is pending further review and will be updated soon. + +## Table of Contents +1. [Table of Contents](#table-of-contents) +2. [Contributors](#contributors) +3. [Installation](#installation) + - [Pre-requisites](#pre-requisites) + - [Steps](#steps) +4. [Contributing](#contributing) + - [Bug Report](#bug-report) + - [Feature Request](#feature-request) + - [Security Concern](#security-concern) +5. [License](#license) + +## Contributors +IRF Administration would not be possible without contributions from the following people +| Position | — | Github | Discord | +| --- | --- | --- | --- | +| Primary Developer | — | [@totallytavi](https://github.com/totallytavi) | totallytavi | + +## Installation +### Pre-requisites +* Node.js Version 19.7.0 or higher +* NPM Version 9.5.0 or higher +* MySQL server +* Discord application (With bot token) +* Webserver with specific endpoints + +### Steps +1. Clone this repository by forking it. This allows you to make changes and submit pull requests if you make changes. In addition, it keeps your repository up to date with the latest changes. +2. Install the required dependencies by running `npm install` in the root directory of the repository. +3. Create a `config.json` file in the `src` directory. A template has been provided below. +```json +{ + "bot": { + "applicationId": "", + "guildId": "", + "token": "", + "rowifiApiKey": "" + }, + "discord": { + "logChannel": "", + "mainServer": "", + "defaultProofURL": "" // Must be Discord message URL + }, + "mysql": { + "host": "localhost", + "user": "", + "password": "", + "database": "", + "port": 3306 + }, + "roblox": { + "commissariatGroup": 0, // Group that gets bypassed from Rowifi checks + "validationToken": "", // Roblox .ROBLOSECURITY token + "mainOCtoken": "", // OpenCloud token + "altOCtoken": "", // Alternate OpenCloud token + "developerGroup": 0, // Group allowed to remove bans with "Fairplay" in them + "developerRank": 0 // Minimum rank in developer group to remove bans + }, + "channels": { + "ban": "", + "image_host": "", + "mp_report": "", + "nsc_report": "", + "request": "", + "unban": "" + }, + "urls": { + "servers": "" + } +} +``` +4. Run `npm start` at the root directory to build and start the project. If you wish to only build files, run `npm run prebuild` +5. If in VSCode, recommend using the provided `launch.json` file. Otherwise, change to the `dist` directory and run `node index.js` to start the bot. + +**Note**: For the servers URL, you should create an endpoint that returns a JSON payload like the following: +```json +{ + "gameId": { + // List of job IDs + // Each job ID contains an array + // The first element of the array is an array of player IDs + // The second element is the date that the list was updated + "jobId1": [[1, 2, 3, 4], "new Date().toUTCString()"] + } +} +``` + +## Contributing +### Bug Report +Any feature that is not working as intended or you believe to have a bug should be reported as an issue. Please be as descriptive as possible, and include any screenshots or other information that may be helpful. Open an issue on the [Issues](https://github.com/FederationStudios/IRF_Administration/issues) page. If you are able to fix the bug, please submit a pull request and link the issue in the pull request. + +### Feature Request +If you have an idea for this bot and it involves a division/ministry, please reach out to the corresponding High Command. They may have a divisional bot that serves this need already. However, if your idea is completely new, you are more than welcome to submit it as an issue. Please be as descriptive as possible, and include any mockups or other information that may be helpful. + +### Security Concern +All security concerns will be handled properly, given proper information such as the severity of the concern and its potential for exploit. Please reach out to any member (With as many details as possible) ranked `Engineer` in the [Discord server](https://discord.gg/irf) and we will handle it immediately. By default, we will credit you here. If you do not wish to be credited, please let us know. + +## License +[![Creative Commons License](https://i.creativecommons.org/l/by-sa/4.0/88x31.png)](https://creativecommons.org/licenses/by-sa/4.0/) +**IRF Administration** by [@FederationStudios](https://github.com/FederationStudios) is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0/). \ No newline at end of file diff --git a/commands/ban.js b/commands/ban.js deleted file mode 100644 index b1e147b..0000000 --- a/commands/ban.js +++ /dev/null @@ -1,225 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder, Collection } = require("discord.js"); -const { default: fetch } = require("node-fetch"); -const { interactionEmbed, toConsole, ids, getRowifi } = require("../functions.js"); -const { channels, discord } = require("../config.json"); - -module.exports = { - name: "ban", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("ban") - .setDescription("Bans a user from an IRF game") - .setDMPermission(false) - .addStringOption(option => { - return option - .setName("user_id") - .setDescription("Roblox username or ID") - .setRequired(true); - }) - .addStringOption(option => { - return option - .setName("game_id") - .setDescription("Roblox game ID") - .setRequired(true) - .addChoices( - { - name: "Papers, Please!", - value: "583507031" - }, - { - name: "Sevastopol Military Academy", - value: "603943201" - }, - { - name: "Prada Offensive", - value: "4683162920" - }, - { - name: "Triumphal Arch of Moscow", - value: "2506054725" - }, - { - name: "Moscow Parade Grounds", - value: "6887031333" - }, - { - name: "Ryazan Airbase", - value: "4424975098" - }, - { - name: "Tank Training Grounds", - value: "2451182763" - }, - { - name: "Global", - value: "0" - } - ); - }) - .addStringOption(option => { - return option - .setName("reason") - .setDescription("Reason for banning the user") - .setAutocomplete(true) - .setRequired(true); - }) - .addAttachmentOption(option => { - return option - .setName("evidence") - .setDescription("Evidence of the user's ban (Add when possible, please!)") - .setRequired(false); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - if(!interaction.member.roles.cache.find(r => r.name === "Administration Access")) return interactionEmbed(3, "[ERR-UPRM]", "You are not authorized to use this command", interaction, client, [true, 10]); - let id = options.getString("user_id"); - if(isNaN(options.getString("user_id"))) { - id = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ usernames: [options.getString("user_id")] }) - }) - .then(res => res.json()) - .then(r => r.data[0]); - - if(!id) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("user_id")}\` as username but found no user`, interaction, client, [true, 15]); - } else { - id = await fetch(`https://users.roblox.com/v1/users/${options.getString("user_id")}`) - .then(async res => JSON.parse((await res.text()).trim())); - - if(id.errors) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("user_id")}\` as ID but Roblox API returned: \`${id.errors[0].message}\``, interaction, client, [true, 15]); - } - if(isNaN(options.getString("game_id"))) return interactionEmbed(3, "[ERR-ARGS]", "Arg `game_id` must be a number", interaction, client, [true, 15]); - if(!ids.some(pair => pair[1] == options.getString("game_id"))) return interactionEmbed(3, "[ERR-ARGS]", "Arg `game_id` must be a Military game ID. Use `/ids` to see all recognised games", interaction, client, [true, 15]); - - const bans = await client.models.Ban.findAll({ - where: { - userID: id.id, - gameID: options.getString("game_id") - } - }); - if(bans.length > 0) { - interactionEmbed(2, "", `A ban already exists for ${id.name} (${id.id}) on ${ids.filter(pair => pair[1] == options.getString("game_id"))[0][0]}. This will overwrite the ban!\n(Adding ban in 5 seconds)`, interaction, client, [false, 0]); - await require("node:util").promisify(setTimeout)(5000); // Show warning - } - const rowifi = await getRowifi(interaction.user.id, client); - if(!rowifi.success) return interactionEmbed(3, "[ERR-UPRM]", rowifi.error ?? "Unknown error (Report this to a developer)", interaction, client, [true, 10]); - - const toFetch = [channels.image_host, channels.nsc_report, channels.banLogs]; - const p = []; - for(const channel of toFetch) { - p.push(client.channels.fetch(channel, { cache: true })); - } - await Promise.allSettled(p); - let error = false; - let evidence = options.getAttachment("evidence") || { proxyURL: "https://media.discordapp.net/attachments/1059784888603127898/1059808550840451123/unknown.png", contentType: "image/png" }; - if(evidence.contentType.split("/")[0] !== "image" && evidence.contentType.split("/")[1] === "gif" && evidence.contentType.split("/")[0] === "video") { - return interactionEmbed(3, "[ERR-ARGS]", "Evidence must be an image (PNG, JPG, JPEG, or MP4)", interaction, client, [true, 15]); - } - console.info(evidence); - // If no attachment, do not send to image_host - if(options.getAttachment("evidence")) { - evidence = await client.channels.cache.get(channels.image_host).send({ - content: `Evidence from ${interaction.user.toString()} (${interaction.user.tag} - ${interaction.user.id})`, - files = [ - { - attachment: (evidence.proxyURL || evidence.url).split('?')[0], // Remove query parameters - name: `EvidenceFrom_${rowifi.username}+${rowifi.roblox}.${evidence.name.split(".").splice(-1)[0]}` - } - ]; - }) - .catch(err => { - error = true; - if(String(err).includes("Request entity too large")) return interactionEmbed(3, "[ERR-UPRM]", "Discord rejected the evidence (File too large). Try compressing the file first!", interaction, client, [true, 10]); - return interactionEmbed(3, "[ERR-UPRM]", "Failed to upload evidence to image host", interaction, client, [true, 10]); - }); - if(error) return; - } else { - const coll = new Collection(); - coll.set("0", { - proxyURL: "https://media.discordapp.net/attachments/1059784888603127898/1059808550840451123/unknown.png" - }); - evidence = { - attachments: coll, - url: "https://discord.com/channels/989558770801737778/1059784888603127898/1063318255265120396" - }; - } - try { - if(bans.length > 0) { - await client.models.Ban.update({ - userID: id.id, - gameID: options.getString("game_id"), - reason: `${options.getString("reason")} - Banned by ${interaction.user.toString()} (${rowifi.roblox})`, - proof: evidence.url, - unixtime: Math.floor(Date.now()/1000) - }, { - where: { - userID: id.id, - gameID: options.getString("game_id") - } - }); - } else { - await client.models.Ban.create({ - userID: id.id, - gameID: options.getString("game_id"), - reason: `${options.getString("reason")} - Banned by ${interaction.user.toString()} (${rowifi.roblox})`, - proof: evidence.url, - unixtime: Math.floor(Date.now()/1000) - }); - } - } catch (e) { - toConsole(`An error occurred while adding a ban for ${id.name} (${id.id})\n> ${String(e)}`, new Error().stack, client); - error = true; - } - if(error) return interactionEmbed(3, "[SQL-ERR]", "An error occurred while adding the ban. This has been reported to the bot developers", interaction, client, [true, 15]); - - await client.channels.cache.get(discord.banLogs).send({ embeds: [{ - title: `${interaction.member.nickname ?? interaction.user.username} banned => ${id.name}`, - description: `**${interaction.user.id}** has added a ban for ${id.name} (${id.id}) on ${ids.filter(pair => pair[1] == options.getString("game_id"))[0][0]}`, - color: 0x00FF00, - fields: [ - { - name: "Game", - value: ids.filter(pair => pair[1] == options.getString("game_id"))[0][0], - inline: true - }, - { - name: "User", - value: `${id.name} (${id.id})`, - inline: true - }, - { - name: "Reason", - value: `${options.getString("reason")} - Banned by ${interaction.user.toString()} (${rowifi.roblox})\n\n**Evidence:** ${evidence.attachments.first().proxyURL}`, - inline: true - } - ], - timestamp: new Date() - }] }); - await interaction.editReply({ content: "Ban added successfully!", embeds: [{ - title: "Ban Details", - color: 0xDE2821, - fields: [ - { - name: "User", - value: `${id.name} (${id.id})`, - inline: true, - }, { - name: "Game", - value: ids.filter(pair => pair[1] == options.getString("game_id")).map(pair => `${pair[0]} (${pair[1]})`)[0], - inline: true, - }, { - name: "Reason", - value: `${options.getString("reason")} - Banned by ${interaction.user.toString()} (${rowifi.roblox})\n\n**Evidence:** ${evidence.attachments.first().proxyURL}`, - inline: false - } - ] - }] }); - return interaction; - } -}; diff --git a/commands/check.js b/commands/check.js deleted file mode 100644 index bbb5531..0000000 --- a/commands/check.js +++ /dev/null @@ -1,123 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder, EmbedBuilder, ButtonBuilder, ActionRowBuilder, ButtonStyle, ComponentType } = require("discord.js"); -const { default: fetch } = require("node-fetch"); -const { interactionEmbed } = require("../functions.js"); - -module.exports = { - name: "check", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("check") - .setDescription("Checks for bans associated with a user") - .setDMPermission(false) - .addStringOption(option => { - return option - .setName("user_id") - .setDescription("User's Roblox ID") - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - const filter = (i) => i.user.id === interaction.user.id; - let id = options.getString("user_id"); - if(isNaN(id)) { - id = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ usernames: [options.getString("user_id")] }) - }) - .then(res => res.json()) - .then(r => r.data[0]); - - if(!id) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("user_id")}\` as a username but found no user`, interaction, client, [true, 15]); - } else { - if(Math.floor(id) != id) return interactionEmbed(3, "[ERR-ARGS]", "Invalid user ID", interaction, client, [true, 15]); - id = await fetch(`https://users.roblox.com/v1/users/${Math.floor(id)}`) - .then(r => r.json()); - - if(id.errors) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("user_id")}\` as ID but Roblox API returned: \`${id.errors[0].message}\``, interaction, client, [true, 15]); - } - if(!id.id) return interactionEmbed(3, "[ERR-ARGS]", "Invalid user ID provided", interaction, client, [true, 15]); - - let bans = await client.models.Ban.findAll({ where: { userID: id.id } }); - const avatar = await fetch(`https://thumbnails.roblox.com/v1/users/avatar?userIds=${id.id}&size=720x720&format=Png&isCircular=false`) - .then(r => r.json()) - .then(r => r.data[0].imageUrl); - const embeds = []; - for(const ban of bans) { - if(!/.+\/([0-9]{0,20})\/([0-9]{0,20})$/.exec(ban.proof || "https://discord.com/channels/989558770801737778/1059784888603127898/1063318255265120396")) { - embeds.push(new EmbedBuilder({ title: "Error Parsing Proof", description: `Proof given was invalid and could not be parsed. Report this to a developer.\n\nRegEx failed on \`${ban.proof}\` (ID: ${ban.banId})` })); - continue; - } - const evid = await client.channels.fetch(/.+\/([0-9]{0,20})\/([0-9]{0,20})$/.exec(ban.proof || "https://discord.com/channels/989558770801737778/1059784888603127898/1063318255265120396")[1]) - .then(c => c.messages.fetch(/.+\/([0-9]{0,20})\/([0-9]{0,20})$/g.exec(ban.proof || "https://discord.com/channels/989558770801737778/1059784888603127898/1063318255265120396")[2])); - const image = evid.attachments.first().contentType.startsWith("video") ? null : { url: evid.attachments.first().url, proxyURL: evid.attachments.first().proxyURL }; - embeds.push(new EmbedBuilder({ - title: `__**Bans for ${id.name}**__`, - thumbnail: { - url: avatar - }, - fields: [ - { name: "Game ID", value: String(ban.gameID), inline: true }, - { name: "Reason", value: evid.attachments.first().contentType.startsWith("video") ? `${ban.reason}\n\n**Evidence**: ${evid.attachments.first().proxyURL}` : ban.reason, inline: true }, - { name: "Date", value: ``, inline: true }, - ], - image: image, - footer: { - text: `Ban ${bans.indexOf(ban) + 1} of ${bans.length}` - }, - timestamp: new Date() - })); - } - if(bans.length === 0) embeds.push(new EmbedBuilder({ - title: `__**Bans for ${id.name}**__`, - thumbnail: { - url: avatar - }, - fields: [ - { name: "Game ID", value: "-", inline: true }, - { name: "Reason", value: "No bans found!", inline: true }, - { name: "Date", value: "-", inline: true } - ], - footer: { - text: "Ban 0 of 0" - }, - timestamp: new Date() - })); - - let page = 0; - const paginationRow = new ActionRowBuilder().setComponents( - new ButtonBuilder({ customId: "previous", label: "◀️", style: ButtonStyle.Primary }), - new ButtonBuilder({ customId: "cancel", label: "🟥", style: ButtonStyle.Danger }), - new ButtonBuilder({ customId: "next", label: "▶️", style: ButtonStyle.Primary }), - ); - const data = { embeds: [embeds[page]], components: [paginationRow] }; - if(embeds.length < 2) delete data.components; - const coll = await interaction.editReply(data) - .then(r => r.createMessageComponentCollector({ filter, componentType: ComponentType.Button, time: 120_000 })); - - coll.on("collect", (i) => { - if(i.customId === "next") { - page = page + 1; - if(page > embeds.length - 1) page = 0; - i.update({ embeds: [embeds[page]], components: [paginationRow] }); - } else if(i.customId === "previous") { - page = page - 1; - if(page < 0) page = embeds.length - 1; - i.update({ embeds: [embeds[page]], components: [paginationRow] }); - } else { - coll.stop(); - } - }); - - coll.once("end", () => { - interaction.deleteReply(); - }); - - return; - } -}; \ No newline at end of file diff --git a/commands/ids.js b/commands/ids.js deleted file mode 100644 index e59585d..0000000 --- a/commands/ids.js +++ /dev/null @@ -1,24 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, EmbedBuilder, SlashCommandBuilder } = require("discord.js"); -const { ids } = require("../functions"); - -module.exports = { - name: "ids", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("ids") - .setDescription("Returns all IRF Game IDs") - .setDMPermission(false), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - */ - run: async (client, interaction) => { - return interaction.editReply({ embeds: [new EmbedBuilder({ - color: 0xDE2821, - title: "IRF Game IDs", - description: `\`\`\`\n${ids.map(pair => `${pair[1]} -> ${pair[0]}`).join("\n")}\n\`\`\``, - timestamp: new Date() - })] }); - } -}; \ No newline at end of file diff --git a/commands/kick.js b/commands/kick.js deleted file mode 100644 index 683b343..0000000 --- a/commands/kick.js +++ /dev/null @@ -1,69 +0,0 @@ -const { interactionEmbed, getGroup, getRowifi, toConsole } = require("../functions.js"); -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } = require("discord.js"); -const { default: fetch } = require("node-fetch"); -const { bot } = require("../config.json"); - -module.exports = { - name: "kick", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("kick") - .setDescription("Kicks a player from a server") - .setDMPermission(false) - .addStringOption(option => { - return option - .setName("target") - .setDescription("The user you wish to kick (Roblox ID)") - .setRequired(true); - }) - .addStringOption(option => { - return option - .setName("reason") - .setDescription("Reason for kicking the player") - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - const rowifi = await getRowifi(interaction.user.id, client); - if(rowifi.error) return interactionEmbed(3, "[ERR-ARGS]", rowifi.error, interaction, client, [true, 10]); - const roblox = await getGroup(rowifi.username, 4899462); - if(!roblox.success) return interactionEmbed(3, "[ERR-ARGS]", roblox.error, interaction, client, [true, 10]); - if(roblox.data.role.rank < 200) return interactionEmbed(3, "[ERR-UPRM]", "You do not have permission to use this command (Engineer+)", interaction, client, [true, 10]); - const servers = await fetch("https://tavis.page/test_servers").then(r => r.json()); - if(!servers.success) return interactionEmbed(3, "[ERR-UNK]", "The remote access system is having issues. Please try again later (Status code: 503)", interaction, client, [true, 10]); - let target = options.getString("target"); - if(isNaN(target)) return interactionEmbed(3, "[ERR-ARGS]", "Invalid target (Must be a user ID)", interaction, client, [true, 10]); - target = parseInt(target); - const reason = options.getString("reason"); - // For each server in each game, check if the target is in the server - let playerFound = false; - for(const gameId in servers.servers) { - for(const server in servers.servers[gameId]) { - const players = servers.servers[gameId][server][0]; - if(players.findIndex(p => p === target) === -1) continue; - const universeId = await fetch(`https://apis.roblox.com/universes/v1/places/${gameId}/universe`).then(r => r.json()).then(r => r.universeId); - const req = await fetch(`https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, { - headers: { - "Content-Type": "application/json", - "x-api-key": bot.mainOCtoken - }, - body: `{"message": '{"type": "kick", "target": "${server}", "userId": "${target}", "reason": "${reason}"}'}`, - method: "POST" - }); - playerFound = true; - console.info((await req.text())); - if(!req.ok) return interactionEmbed(3, "[ERR-UNK]", "The remote access system is having issues. Please try again later (Status code: 400)", interaction, client, [true, 10]); - } - } - // Send the response body to the user - if(!playerFound) return interactionEmbed(3, "[ERR-ARGS]", "Invalid target (User is not in any servers)", interaction, client, [true, 10]); - await interactionEmbed(1, "", `Kicked ${target} from the server`, interaction, client, [false, 0]); - toConsole(`[REMOTE ADMIN] ${interaction.user.username} (${interaction.user.id}) kicked ${target} from the server they were in`, new Error().stack, client); - return interaction.followUp({ files: [{ attachment: Buffer.from(JSON.stringify(servers, null, 2)), name: "servers.json", description: "List of IRF servers (DEBUGGING PURPOSES IF THIS COMMAND BACKFIRES)" }], ephemeral: true }); - } -}; \ No newline at end of file diff --git a/commands/nsc_report.js b/commands/nsc_report.js deleted file mode 100644 index 05db170..0000000 --- a/commands/nsc_report.js +++ /dev/null @@ -1,32 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, SlashCommandBuilder, ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } = require("discord.js"); - -module.exports = { - name: "nsc_report", - modal: true, - data: new SlashCommandBuilder() - .setName("nsc_report") - .setDescription("Report a user for severe conduct (i.e. personal safety concerns)"), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - */ - run: async (client, interaction) => { - const nsc_report = new ModalBuilder({ title: "NSC Report", custom_id: "nsc_report" }); - const components = [ - new TextInputBuilder({ label: "Your Roblox username?", custom_id: "reporter_roblox", min_length: 3, max_length: 16, style: TextInputStyle.Short }), - new TextInputBuilder({ label: "Roblox username of the offender(s)?", custom_id: "offender_roblox", min_length: 3, max_length: 128, style: TextInputStyle.Paragraph }), - new TextInputBuilder({ label: "Location of the incident?", placeholder: "Example: 2 minutes ago on Sevastopol", custom_id: "incident_place", min_length: 3, max_length: 32, style: TextInputStyle.Short }), - new TextInputBuilder({ label: "What occurred during this incident?", placeholder: "Please provide as much detail as possible", custom_id: "incident_description", min_length: 3, max_length: 2000, style: TextInputStyle.Paragraph }), - new TextInputBuilder({ label: "Relevant proof", placeholder: "Post links ONLY. This bot cannot see attachments!", custom_id: "incident_proof", min_length: 3, max_length: 4000, style: TextInputStyle.Paragraph }), - ]; - - for(let component of components) { - const row = new ActionRowBuilder(); - row.addComponents(component); - nsc_report.addComponents(row); - } - - await interaction.showModal(nsc_report); - } -}; \ No newline at end of file diff --git a/commands/profile.js b/commands/profile.js deleted file mode 100644 index b138211..0000000 --- a/commands/profile.js +++ /dev/null @@ -1,352 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); -const { interactionEmbed } = require("../functions.js"); -const { default: fetch } = require("node-fetch"); -const { bot } = require("../config.json"); - -module.exports = { - name: "profile", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("profile") - .setDescription("Returns a user's profile") - .addStringOption(option => { - return option - .setName("roblox") - .setDescription("Roblox username") - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - let user = options.getString("roblox"); - if(!isNaN(options.getString("roblox_username"))) { - user = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ usernames: [options.getString("roblox")] }) - }) - .then(res => res.json()) - .then(r => r.data[0]); - - if(!user) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("roblox")}\` as username but found no user`, interaction, client, [true, 15]); - } else { - user = await fetch(`https://users.roblox.com/v1/users/${options.getString("roblox")}`) - .then(r => r.json()); - - if(user.errors) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("roblox")}\` as ID but Roblox API returned: \`${user.errors[0].message}\``, interaction, client, [true, 15]); - } - - const avatar = await fetch(`https://thumbnails.roblox.com/v1/users/avatar?userIds=${user.id}&size=720x720&format=Png&isCircular=false`) - .then(r => r.json()) - .then(r => r.data[0].imageUrl); - - const bans = await client.models.Ban.findAll({ where: { userID: user.id } }); - - //#region Fetching data - const data = {}; - const promises = []; - promises.push(fetch(`https://users.roblox.com/v1/users/${user.id}`).then(r => r.json()).then(r => data.user = r)); - promises.push(fetch(`https://friends.roblox.com/v1/users/${user.id}/friends`).then(r => r.json()).then(r => data.friends = r.data)); - promises.push(fetch(`https://groups.roblox.com/v1/users/${user.id}/groups/roles`).then(r => r.json()).then(r => data.groups = r.data)); - promises.push(fetch(`https://users.roblox.com/v1/users/${user.id}/username-history?limit=50`).then(r => r.json()).then(r => data.history = r.data.map(u => u.name))); - promises.push(fetch("https://presence.roblox.com/v1/presence/users", { - method: "POST", - body: JSON.stringify({userIds: [user.id]}), - headers: { "Content-Type": "application/json", "Cookie": `.ROBLOSECURITY=${bot.validationToken || "abcdef123456"}`} - }).then(r => r.json()).then(r => data.presence = r.userPresences[0])); - await Promise.allSettled(promises); - data.user.createdAt = new Date(data.user.created).toUTCString(); - //#endregion - - //#region Data Parsing - const categories = {"overview": [false, []], "friends": [true, []], "groups": [true, []], "activity": [false, []]}; - let embeds = []; - let page = 0; - // OVERVIEW // - categories.overview[1] = [ - new EmbedBuilder({ - title: "Overview", - color: 0xDE2821, - thumbnail: { - url: client.user.avatarURL() - }, - description: data.user.description+"\n\n[Visit Profile](https://www.roblox.com/users/"+user.id+"/profile)", - image: { - url: avatar - }, - fields: [ - { - name: "Username", - value: user.name, - inline: true - }, - { - name: "ID", - value: user.id, - inline: true - }, - { - name: "Created", - value: new Date(data.user.createdAt).getTime() > 0 ? `` : "Unknown", - inline: true - }, - { - name: "IRF Game Bans", - value: bans.length, - inline: true - }, - { - name: "Friends", - value: data.friends.length, - inline: true - }, - { - name: "Groups", - value: data.groups.length, - inline: true - }, - { - name: "Previous Usernames", - value: data.history ? data.history.join("\n") : "None", - inline: false - } - ], - timestamp: new Date() - }) - ]; - // FRIENDS // - const friendFields = data.friends.map(friend => { - return { - name: friend.displayName, - value: `Username: ${friend.name}\nID: ${friend.id}\nOnline: ${friend.isOnline ? "Yes" : "No"}`, - inline: true - }; - }); - if(friendFields.length === 0) - categories.friends[1].push(new EmbedBuilder({ - title: `${user.name}'s Friends`, - color: 0xDE2821, - thumbnail: { - url: client.user.avatarURL() - }, - description: `https://roblox.com/users/${user.id}/profile`, - image: { - url: avatar - }, - fields: [{ name: "No friends", value: "This user has no friends!" }], - footer: { - text: "Page 1 of 1" - }, - timestamp: new Date() - })); - for(let i = 0; i < friendFields.length; i += 9) { - categories.friends[1].push(new EmbedBuilder({ - title: `${user.name}'s Friends`, - color: 0xDE2821, - thumbnail: { - url: client.user.avatarURL() - }, - description: `https://roblox.com/users/${user.id}/profile`, - image: { - url: avatar - }, - fields: friendFields.slice(i, i + 9), - footer: { - text: `Page ${Math.floor(i/9) + 1} of ${Math.ceil(friendFields.length/9)}` - }, - timestamp: new Date() - })); - } - // GROUPS // - data.groups.forEach((group, index) => { - categories.groups[1].push( - new EmbedBuilder({ - title: `${group.group.name} (${group.group.id})`, - color: 0xDE2821, - thumbnail: { - url: client.user.avatarURL() - }, - description: group.group.description.length > 2048 ? `${group.group.description.slice(0, 2045)}...` : group.group.description, - fields: [ - { - name: "Owner", - value: `${group.group.owner.username} "${group.group.owner.displayName}" (${group.group.owner.userId})`, - inline: true - }, - { - name: "Members", - value: group.group.memberCount, - inline: true - }, - { - name: "User's Rank", - value: group.role.name, - inline: true - } - ], - footer: { - text: `Group ${index + 1} of ${data.groups.length}` - }, - timestamp: new Date() - }) - ); - }); - if(data.groups.length === 0) - categories.groups[1].push(new EmbedBuilder({ - title: `${user.name}'s Groups`, - color: 0xDE2821, - thumbnail: { - url: client.user.avatarURL() - }, - description: `https://roblox.com/users/${user.id}/profile`, - image: { - url: avatar - }, - fields: [{ name: "No groups", value: "This user is not in any groups!" }], - footer: { - text: "Page 1 of 1" - }, - timestamp: new Date() - })); - // ACTIVITY // - categories.activity[1] = [new EmbedBuilder({ - title: `${user.name}'s Activity`, - color: 0xDE2821, - thumbnail: { - url: client.user.avatarURL() - }, - description: `https://roblox.com/users/${user.id}/profile`, - image: { - url: avatar - }, - fields: [ - { - name: "Status", - value: data.presence.userPresenceType === 0 ? "💤 Offline" : data.presence.userPresenceType === 1 ? "🌐 Online" : data.presence.userPresenceType === 2 ? "🟢 In Game" : "❔ Unknown", - inline: true - }, - { - name: "Last Online", - value: new Date(data.presence.lastOnline).getTime() > 0 ? `` : "Unknown", - inline: true - }, - { - name: "Last Location", - value: data.presence.lastLocation ?? "Unknown", - inline: true - } - ], - timestamp: new Date() - })]; - if(data.presence.userPresenceType === 2 && data.presence.placeId) { - categories.activity[1][0].addFields([{ - name: "Game", - value: `[${data.presence.lastLocation}](https://roblox.com/games/${data.presence.placeId}) ([https://roblox.com/games/${data.presence.placeId}](https://roblox.com/games/${data.presence.placeId}))`, - inline: true - }]); - } else if(data.presence.userPresenceType === 2 && !data.presence.placeId) { - categories.activity[1][0].addFields([{ - name: "Game", - value: "❗ Profile is private, unable to fetch current game", - inline: true - }]); - } - //#endregion - - const selectorRow = new ActionRowBuilder().setComponents( - new StringSelectMenuBuilder({ - customId: "profile-category", - placeholder: "Select a category to review", - options: [ - { - label: "Overview", - value: "overview", - description: `View general information on ${user.name}`, - emoji: "🔍" - }, - { - label: "Friends", - value: "friends", - description: `${user.name}'s friends`, - emoji: "👥" - }, - { - label: "Groups", - value: "groups", - description: `${user.name}'s groups`, - emoji: "🎖️" - }, - { - label: "Activity", - value: "activity", - description: `${user.name}'s status`, - emoji: "📊" - }, - { - label: "Cancel", - value: "cancel", - description: "Cancel the command", - emoji: "❌" - } - ], - min_values: 1, - max_values: 1 - }) - ); - const paginationRow = new ActionRowBuilder().setComponents( - new ButtonBuilder({ customId: "previous", label: "◀️", style: ButtonStyle.Primary }), - new ButtonBuilder({ customId: "cancel", label: "🟥", style: ButtonStyle.Danger }), - new ButtonBuilder({ customId: "next", label: "▶️", style: ButtonStyle.Primary }), - ); - - //#region Pagination - const coll = await interaction.editReply({ embeds: [categories.overview[1][0]], components: [selectorRow] }) - .then(m => m.createMessageComponentCollector({ filter: i => i.user.id === interaction.user.id, time: 180_000 })); - - coll.on("collect", i => { - const selectors = [selectorRow]; - switch(i.customId) { - case "cancel": { - coll.stop(); - break; - } - case "previous": { - page = page - 1; - if(page > embeds[1].length - 1) page = 0; - if(embeds[0]) selectors.unshift(paginationRow); - i.update({ embeds: [embeds[1][page]], components: selectors }); - break; - } - case "next": { - page = page + 1; - if(page > embeds[1].length - 1) page = 0; - if(embeds[0]) selectors.unshift(paginationRow); - i.update({ embeds: [embeds[1][page]], components: selectors }); - break; - } - case "profile-category": { - if(i.values[0] === "cancel") { - coll.stop(); - break; - } - embeds = categories[i.values[0]]; - page = 0; - if(embeds[0]) selectors.unshift(paginationRow); - i.update({ embeds: [embeds[1][page]], components: selectors }); - break; - } - default: { - i.update({ content: "You shouldn't be seeing this! Report this to a developer\n\n**CUSTOMID**: "+i.customId, embeds: [], components: [] }); - } - } - }); - coll.on("end", () => { - interaction.editReply({ content: `This embed has timed out. Please run the command again: `, components: [] }); - }); - //#endregion - } -}; \ No newline at end of file diff --git a/commands/report.js b/commands/report.js deleted file mode 100644 index a63995b..0000000 --- a/commands/report.js +++ /dev/null @@ -1,32 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, SlashCommandBuilder, ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle } = require("discord.js"); - -module.exports = { - name: "report", - modal: true, - data: new SlashCommandBuilder() - .setName("report") - .setDescription("Report an MP for breaking their rules or a member of the Military for violating Military Law"), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - */ - run: async (client, interaction) => { - const nsc_report = new ModalBuilder({ title: "Military Police Report", custom_id: "report" }); - const components = [ - new TextInputBuilder({ label: "Your Roblox username?", custom_id: "reporter_roblox", min_length: 3, max_length: 16, style: TextInputStyle.Short }), - new TextInputBuilder({ label: "Roblox username of the offender(s)?", custom_id: "offender_roblox", min_length: 3, max_length: 128, style: TextInputStyle.Paragraph }), - new TextInputBuilder({ label: "Location of the incident?", placeholder: "Example: 2 minutes ago on Sevastopol", custom_id: "incident_place", min_length: 3, max_length: 32, style: TextInputStyle.Short }), - new TextInputBuilder({ label: "What occurred during this incident?", placeholder: "Please provide as much detail as possible", custom_id: "incident_description", min_length: 3, max_length: 2000, style: TextInputStyle.Paragraph }), - new TextInputBuilder({ label: "Relevant proof", placeholder: "Post links ONLY. This bot cannot see attachments!", custom_id: "incident_proof", min_length: 3, max_length: 4000, style: TextInputStyle.Paragraph }), - ]; - - for(let component of components) { - const row = new ActionRowBuilder(); - row.addComponents(component); - nsc_report.addComponents(row); - } - - await interaction.showModal(nsc_report); - } -}; \ No newline at end of file diff --git a/commands/request.js b/commands/request.js deleted file mode 100644 index 512e011..0000000 --- a/commands/request.js +++ /dev/null @@ -1,85 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } = require("discord.js"); -const { interactionEmbed, getRowifi, toConsole } = require("../functions.js"); -const { default: fetch } = require("node-fetch"); -const { bot, channels, discord } = require("../config.json"); -const cooldown = new Map(); - -module.exports = { - name: "request", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("request") - .setDescription("Requests a division to assist you (Cooldown: 15 minutes)") - .setDMPermission(false) - .addStringOption(option => { - return option - .setName("division") - .setDescription("Division you are requesting") - .addChoices( - { name: "Admissions", value: "Admissions" }, - { name: "Game Administration", value: "Game Administrator" }, - { name: "National Defense", value: "National Defense" }, - { name: "Military Police", value: "Military Police" }, - { name: "State Security (NKVD)", value: "State Security" } - ) - .setRequired(true); - }) - .addStringOption(option => { - return option - .setName("reason") - .setDescription("Reason for request") - .setAutocomplete(true) - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - if(cooldown.has(interaction.user.id)) return interactionEmbed(3, "[ERR-CLD]", `You can request `, interaction, client, [false, 0]); - if(interaction.guild.id != discord.mainServer) return interactionEmbed(3, "[ERR-ARGS]", "This command can only be used in the main server", interaction, client, [true, 15]); - const division = options.getString("division"); - await interaction.guild.roles.fetch({ cache: true }); - const role = interaction.guild.roles.cache.find(r => r.name === options.getString("division")).toString(); - const reason = options.getString("reason"); - const rowifi = await getRowifi(interaction.user.id, client); - if(!rowifi.success) return interactionEmbed(3, "[ERR-UPRM]", "You must verify with RoWifi before using this command", interaction, client, [true, 15]); - - const presenceCheck = await fetch("https://presence.roblox.com/v1/presence/users", { - method: "POST", - body: JSON.stringify({ - userIds: [rowifi.roblox] - }), - headers: { - "Content-Type": "application/json", - "Cookie": `.ROBLOSECURITY=${bot.validationToken || "abcdef123456"}` - } - }) - .then(r => r.json()) - .then(r => r.errors || r.userPresences[0]); - if(!Array.isArray(presenceCheck) && presenceCheck.userPresenceType !== 2) return interactionEmbed(3, "[ERR-UPRM]", "You must be in-game in order to use this command. Try again later when you're in-game", interaction, client, [false, 0]); - if(bot.validationToken && presenceCheck.gameId === null) return interactionEmbed(3, "[ERR-UPRM]", "You must have your profile set to public in order to use this command. Try again later when your profile is public", interaction, client, [false, 0]); - if(Array.isArray(presenceCheck)) toConsole(`Presence check failed for ${interaction.user.tag} (${interaction.user.id})\n\`\`\`json\n${JSON.stringify(presenceCheck, null, 2)}\n\`\`\``, new Error().stack, client); - - await client.channels.fetch(channels.request, { cache: true }); - await client.channels.cache.get(channels.request).send({ content: role, embeds: [{ - title: `${rowifi.username} is requesting ${division}`, - color: 0xDE2821, - description: `${interaction.member.toString()} is requesting ${role} due to: __${reason}__\n\n**Profile Link:** https://www.roblox.com/users/${rowifi.roblox}/profile\n\n**React if you are handling this request**` - }] }) - .then(m => m.react("✅")); - - interaction.editReply({ embeds: [{ - title: "Request Sent", - color: 0xDE2821, - description: `Your request has been sent and ${division} has been called` - }] }); - - cooldown.set(interaction.member.id, Date.now()); - setTimeout(() => { - cooldown.delete(interaction.member.id); - }, 900000); // 15 minutes - } -}; diff --git a/commands/shutdown.js b/commands/shutdown.js deleted file mode 100644 index d765ed3..0000000 --- a/commands/shutdown.js +++ /dev/null @@ -1,101 +0,0 @@ -const { interactionEmbed, getGroup, getRowifi, ids, toConsole } = require("../functions.js"); -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } = require("discord.js"); -const { default: fetch } = require("node-fetch"); -const { bot } = require("../config.json"); - -module.exports = { - name: "shutdown", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("shutdown") - .setDescription("Shuts down a server") - .setDMPermission(false) - .addStringOption(option => { - return option - .setName("target") - .setDescription("Target server's JobId to shut down") - .setRequired(true) - .setAutocomplete(true); - }) - .addStringOption(option => { - return option - .setName("reason") - .setDescription("Reason for shutting down") - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - const rowifi = await getRowifi(interaction.user.id, client); - if(rowifi.error) return interactionEmbed(3, "", rowifi.error, interaction, client, [true, 10]); - const roblox = await getGroup(rowifi.username, 4899462); - if(!roblox.success) return interactionEmbed(3, "", roblox.error, interaction, client, [true, 10]); - if(roblox.data.role.rank < 200) return interactionEmbed(3, "[ERR-UPRM]", "You do not have permission to use this command (Engineer+)", interaction, client, [true, 10]); - const servers = await fetch("https://tavis.page/test_servers").then(r => r.json()); - if(!servers.success) return interactionEmbed(3, "", "The remote access system is having issues. Please try again later (Status code: 503)", interaction, client, [true, 10]); - const target = options.getString("target"); - const reason = options.getString("reason"); - // If target does not equal *, find the gameId which matches the target - let server = false; - for(const [PlaceId, game] of Object.entries(servers.servers)) { - if(target === "*") break; // Not handled here, but later on - for(const [JobId, Data] of Object.entries(game)) { - if(JobId === target) { - const universeId = await fetch(`https://apis.roblox.com/universes/v1/places/${PlaceId}/universe`).then(r => r.json()).then(r => r.universeId); - const resp = await fetch(`https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, { - headers: { - "Content-Type": "application/json", - "x-api-key": bot.mainOCtoken - }, - body: `{"message": '{"type": "shutdown", "target": "${target}", "reason": "${reason}"}'}`, - method: "POST" - }); - if(!resp.ok) { - const att2 = await fetch(`https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, { - headers: { - "Content-Type": "application/json", - "x-api-key": bot.altOCtoken - }, - body: `{"message": '{"type": "shutdown", "target": "${target}", "reason": "${reason}"}'}`, - method: "POST" - }); - if(!att2.ok) return interactionEmbed(3, "", "The remote access system is having issues. Please try again later (Status code: 400)", interaction, client, [true, 10]); - } - server = {JobId, Players: Data[0]}; - } - } - if(server) break; - } - if(!server && target !== "*") return interactionEmbed(3, "", "The server you are trying to shut down does not exist. Try using the autocomplete menu", interaction, client, [true, 10]); - if(target === "*") { - for(const [, id] of ids) { - if(id === 0) continue; - const universeId = await fetch(`https://apis.roblox.com/universes/v1/places/${id}/universe`).then(r => r.json()).then(r => r.universeId); - const resp = await fetch(`https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, { - headers: { - "Content-Type": "application/json", - "x-api-key": bot.mainOCtoken - }, - body: `{"message": '{"type": "shutdown", "target": "${target}", "reason": "${reason}"}'}`, - method: "POST" - }); - if(!resp.ok) { - await fetch(`https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, { - headers: { - "Content-Type": "application/json", - "x-api-key": bot.altOCtoken - }, - body: `{"message": '{"type": "shutdown", "target": "${target}", "reason": "${reason}"}'}`, - method: "POST" - }); - } - } - } - toConsole(`[REMOTE ADMIN] ${interaction.user.username} (${interaction.user.id}) shut down server ${target === "*" ? "{*}" : server.JobId} with ${server.Players.length || "{?}"} players`, new Error().stack, client); - return interactionEmbed(1, "", `Shut down server ${target === "*" ? "{*}" : server.JobId} with ${server.Players.length || "{?}"} players`, interaction, client, [false, 0]); - } -}; \ No newline at end of file diff --git a/commands/unban.js b/commands/unban.js deleted file mode 100644 index 157408e..0000000 --- a/commands/unban.js +++ /dev/null @@ -1,152 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } = require("discord.js"); -const { default: fetch } = require("node-fetch"); -const { interactionEmbed, toConsole, getGroup, ids, getRowifi } = require("../functions.js"); -const { discord } = require("../config.json"); - -module.exports = { - name: "unban", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("unban") - .setDescription("Unbans a user from an IRF game") - .setDMPermission(false) - .addStringOption(option => { - return option - .setName("user_id") - .setDescription("Roblox username or ID") - .setRequired(true); - }) - .addStringOption(option => { - return option - .setName("game_id") - .setDescription("Roblox game ID") - .setRequired(true) - .addChoices( - { - name: "Papers, Please!", - value: "583507031" - }, - { - name: "Sevastopol Military Academy", - value: "603943201" - }, - { - name: "Prada Offensive", - value: "4683162920" - }, - { - name: "Triumphal Arch of Moscow", - value: "2506054725" - }, - { - name: "Moscow Parade Grounds", - value: "6887031333" - }, - { - name: "Ryazan Airbase", - value: "4424975098" - }, - { - name: "Tank Training Grounds", - value: "2451182763" - }, - { - name: "Global", - value: "0" - } - ); - }) - .addStringOption(option => { - return option - .setName("reason") - .setDescription("Reason for unban") - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - if(!interaction.member.roles.cache.find(r => r.name === "Administration Access")) return interactionEmbed(3, "[ERR-UPRM]", "You are not authorized to use this command", interaction, client, [true, 10]); - let id = options.getString("user_id"); - if(isNaN(options.getString("user_id"))) { - id = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ usernames: [options.getString("user_id")] }) - }) - .then(res => res.json()) - .then(r => r.data[0]); - - if(!id) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("user_id")}\` as username but found no user`, interaction, client, [true, 15]); - } else { - id = await fetch(`https://users.roblox.com/v1/users/${options.getString("user_id")}`) - .then(r => r.json()); - - if(id.errors) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("user_id")}\` as ID but Roblox API returned: \`${id.errors[0].message}\``, interaction, client, [true, 15]); - } - if(isNaN(options.getString("game_id"))) return interactionEmbed(3, "[ERR-ARGS]", "Arg `game_id` must be a number", interaction, client, [true, 15]); - if(!ids.some(pair => pair[1] == options.getString("game_id"))) return interactionEmbed(3, "[ERR-ARGS]", "Arg `game_id` must be a Military game ID. Use `/ids` to see all recognised games", interaction, client, [true, 15]); - - // Rowifi - const rowifi = await getRowifi(interaction.user.id, client); - if(!rowifi.success) return interactionEmbed(3, "[ERR-UPRM]", rowifi.error ?? "Unknown error (Report this to a developer)", interaction, client, [true, 10]); - - // Find bans - const bans = await client.models.Ban.findAll({ - where: { - userID: id.id, - gameID: options.getString("game_id") - } - }); - if (bans.length === 0) { - return interactionEmbed(3, "[ERR-ARGS]", `No bans exist for \`${id.name}\` (${id.id}) on ${ids.filter(pair => pair[1] == options.getString("game_id"))[0][0]}`, interaction, client, [false, 0]); - } - if (bans[0].reason.includes("FairPlay")) { - const data = await getGroup(rowifi.username, 4899462); - if(data.success && data.data.role.rank < 200) return interactionEmbed(3, "[ERR-UPRM]", "You are not authorized to unban a FairPlay ban. Contact a developer to arrange the unban", interaction, client, [true, 10]); - } - - let error = false; - try { - await client.models.Ban.destroy({ - where: { - userID: id.id, - gameID: options.getString("game_id") - } - }); - } catch (e) { - toConsole(`An error occurred while removing a ban for ${id.name} (${id.id})\n> ${String(e)}`, new Error().stack, client); - error = true; - } - if(error) return interactionEmbed(3, "[ERR-SQL]", "An error occurred while removing the ban. This has been reported to the bot developers", interaction, client, [true, 15]); - await client.channels.fetch(discord.unbanLogs, { cache: true }); - await client.channels.cache.get(discord.unbanLogs).send({ embeds: [{ - title: `${interaction.member.nickname ?? interaction.user.username} unbanned => ${id.name}`, - description: `**${interaction.user.id}** has removed a ban for ${id.name} (${id.id}) on ${ids.filter(pair => pair[1] == options.getString("game_id"))[0][0]}`, - color: 0x00FF00, - fields: [ - { - name: "Game", - value: ids.filter(pair => pair[1] == options.getString("game_id"))[0][0], - inline: true - }, - { - name: "User", - value: `${id.name} (${id.id})`, - inline: true - }, - { - name: "Reason", - value: `${options.getString("reason")} - Unbanned by ${interaction.member.toString()}`, - inline: true - } - ], - timestamp: new Date() - }] }); - - return interactionEmbed(1, "", `Removed ban for ${id.name} (${id.id}) on ${ids.filter(pair => pair[1] == options.getString("game_id"))[0][0]}\n> Reason: ${options.getString("reason")} - Unbanned by ${interaction.member.toString()}`, interaction, client, [false, 0]); - } -}; \ No newline at end of file diff --git a/commands/userid.js b/commands/userid.js deleted file mode 100644 index a1f183f..0000000 --- a/commands/userid.js +++ /dev/null @@ -1,47 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } = require("discord.js"); -const { interactionEmbed } = require("../functions.js"); -const { default: fetch } = require("node-fetch"); - -module.exports = { - name: "userid", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("userid") - .setDescription("Provides a Roblox ID when given a username") - .addStringOption(option => { - return option - .setName("username") - .setDescription("Roblox username") - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) =>{ - let username = options.getString("username"); - username = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ usernames: [username] }) - }) - .then(res => res.json()) - .then(r => r.data[0]); - - if(!username) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getString("username")}\` as username but found no user`, interaction, client, [true, 15]); - - const avatar = await fetch(`https://thumbnails.roblox.com/v1/users/avatar?userIds=${username.id}&size=720x720&format=Png&isCircular=false`) - .then(r => r.json()) - .then(r => r.data[0].imageUrl); - return interaction.editReply({ embeds: [{ - title: `Roblox ID for ${username.name}`, - color: 0xDE2821, - description: `${username.id}`, - thumbnail: { - url: avatar - } - }] }); - } -}; \ No newline at end of file diff --git a/commands/username.js b/commands/username.js deleted file mode 100644 index ac57982..0000000 --- a/commands/username.js +++ /dev/null @@ -1,41 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, CommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } = require("discord.js"); -const { interactionEmbed } = require("../functions.js"); -const { default: fetch } = require("node-fetch"); - -module.exports = { - name: "username", - ephemeral: false, - data: new SlashCommandBuilder() - .setName("username") - .setDescription("Provides a Roblox username when given a Roblox ID") - .addIntegerOption(option => { - return option - .setName("id") - .setDescription("Roblox ID") - .setRequired(true); - }), - /** - * @param {Client} client - * @param {CommandInteraction} interaction - * @param {CommandInteractionOptionResolver} options - */ - run: async (client, interaction, options) => { - let id = options.getInteger("id"); - id = await fetch(`https://users.roblox.com/v1/users/${id}`) - .then(r => r.json()); - - if(id.errors) return interactionEmbed(3, "[ERR-ARGS]", `Interpreted \`${options.getInteger("id")}\` as a user ID but Roblox API returned: \`${id.errors[0].message}\``, interaction, client, [true, 15]); - const avatar = await fetch(`https://thumbnails.roblox.com/v1/users/avatar?userIds=${id.id}&size=720x720&format=Png&isCircular=false`) - .then(r => r.json()) - .then(r => r.data[0].imageUrl); - return interaction.editReply({ embeds: [{ - title: `Roblox Username for ${id.id}`, - color: 0xDE2821, - description: `${id.name} (${id.displayName})`, - thumbnail: { - url: avatar - } - }] }); - } -}; \ No newline at end of file diff --git a/functions.js b/functions.js deleted file mode 100644 index 3fef67c..0000000 --- a/functions.js +++ /dev/null @@ -1,244 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, EmbedBuilder, Interaction } = require("discord.js"); -const { default: fetch } = require("node-fetch"); -const config = require("./config.json"); - -const errors = { - "[SQL-ERR]": "An error has occurred while communicating with the database", - "[ERR-CLD]": "You are on cooldown!", - "[ERR-UPRM]": "You do not have the proper permissions to execute this command", - "[ERR-BPRM]": "This bot does not have proper permissions to execute this command", - "[ERR-ARGS]": "You have not supplied the correct parameters. Please check again", - "[ERR-UNK]": "An unknwon error occurred. Please report this to a developer", - "[ERR-MISS]": "The requested information wasn't found", - "[WARN-NODM]": "This command isn't available in Direct Messages. Please run this in a server", - "[WARN-CMD]": "The requested slash command was not found. Please refresh your Discord client and try again", - "[INFO-DEV]": "This command is in development. This should not be expected to work" -}; - -module.exports = { - /** - * @typedef RobloxGroupUserData - * @prop {RobloxGroupGroupData} group - * @prop {RobloxGroupRoleData} role - */ - /** - * @typedef {Object} RobloxGroupGroupData - * @prop {string} id Group ID - * @prop {string} name Name of the group - * @prop {number} memberCount Member count of the group - */ - /** - * @typedef {Object} RobloxGroupRoleData - * @prop {number} id Numeric identifier of the role - * @prop {string} name Name of the role - * @prop {number} rank Rank of the role (0-255) - */ - - /** - * @description Sends a message to the console - * @param {String} message [REQUIRED] The message to send to the console - * @param {String} source [REQUIRED] Source of the message (Error.stack) - * @param {Client} client [REQUIRED] A logged-in Client to send the message - * @returns {null} null - * @example toConsole(`Hello, World!`, `functions.js 12:15`, client); - * @example toConsole(`Published a ban`, `ban.js 14:35`, client); - */ - toConsole: async (message, source, client) => { - if(!message || !source || !client) return console.error(`One or more of the required parameters are missing.\n\n> message: ${message}\n> source: ${source}\n> client: ${client}`); - const channel = await client.channels.cache.get(config.discord.devChannel) || await client.channels.fetch(config.discord.devChannel).catch(() => null); - if(source.split("\n").length < 2) return console.error("[ERR] toConsole called but Error.stack was not used\n> Source: " + source); - source = source.split("\n")[1].trim().substring(3).split("/").pop().replace(")", ""); - if(!channel) return console.warn("[WARN] toConsole called but bot cannot find config.discord.devChannel\n", message, "\n", source); - - await channel.send(`Incoming message from \`${source}\` at `); - const check = await channel.send({ embeds: [ - new EmbedBuilder({ - title: "Message to Console", - color: 0xDE2821, - description: `${message}`, - footer: { - text: `Source: ${source} @ ${new Date().toLocaleTimeString()} ${new Date().toString().match(/GMT([+-]\d{2})(\d{2})/)[0]}` - }, - timestamp: new Date() - }) - ]}) - .then(false) - .catch(true); // Supress errors - if(check) return console.error(`[ERR] At ${new Date()}, toConsole called but message failed to send`); - - return null; - }, - /** - * @description Replies with a Embed to the Interaction - * @param {Number} type 1- Sucessful, 2- Warning, 3- Error, 4- Information - * @param {String} content The information to state - * @param {String} expected The expected argument (If applicable) - * @param {Interaction} interaction The Interaction object for responding - * @param {Client} client Client object for logging - * @param {Array} remove Whether to delete the message and the specified timeout in seconds - * @example interactionEmbed(1, "", `Removed ${removed} roles`, interaction, client, [false, 0]) - * @example interactionEmbed(3, `[ERR-UPRM]`, `Missing: \`Manage Messages\``, interaction, client, [true, 15]) - * @returns {null} - */ - interactionEmbed: async function(type, content, expected, interaction, client, remove) { - if(!type || typeof content != "string" || expected === undefined || !interaction || !client || !remove || remove.length != 2) throw new SyntaxError(`One or more of the required parameters are missing in [interactionEmbed]\n\n> ${type}\n> ${content}\n> ${expected}\n> ${interaction}\n> ${client}`); - if(!interaction.deferred) await interaction.deferReply(); - const embed = new EmbedBuilder(); - - switch(type) { - case 1: - embed - .setTitle("Success") - .setAuthor({ name: interaction.user.username, iconURL: interaction.user.avatarURL({ dynamic: true, size: 4096 }) }) - .setColor(0x7289DA) - .setDescription(!errors[content] ? expected : `${errors[content]}\n> ${expected}`) - .setFooter({ text: "The operation was completed successfully with no errors" }) - .setTimestamp(); - - break; - case 2: - embed - .setTitle("Warning") - .setAuthor({ name: interaction.user.username, iconURL: interaction.user.avatarURL({ dynamic: true, size: 4096 }) }) - .setColor(0xFFA500) - .setDescription(!errors[content] ? expected : `${errors[content]}\n> ${expected}`) - .setFooter({ text: "The operation was completed successfully with a minor error" }) - .setTimestamp(); - - break; - case 3: - embed - .setTitle("Error") - .setAuthor({ name: interaction.user.username, iconURL: interaction.user.avatarURL({ dynamic: true, size: 4096 }) }) - .setColor(0xFF0000) - .setDescription(!errors[content] ? `I don't understand the error "${content}" but was expecting ${expected}. Please report this to the support server!` : `${errors[content]}\n> ${expected}`) - .setFooter({ text: "The operation failed to complete due to an error" }) - .setTimestamp(); - - break; - case 4: - embed - .setTitle("Information") - .setAuthor({ name: interaction.user.username, iconURL: interaction.user.avatarURL({ dynamic: true, size: 4096 }) }) - .setColor(0x7289DA) - .setDescription(!errors[content] ? expected : `${errors[content]}\n> ${expected}`) - .setFooter({ text: "The operation is pending completion" }) - .setTimestamp(); - - break; - } - await interaction.editReply({ content: "​", embeds: [embed] }); - if(remove[0]) setTimeout(() => { interaction.deleteReply(); }, remove[1]*1000); - return null; - }, - /** - * @param {String} time - * @returns {Number|"NaN"} - */ - parseTime: function (time) { - let duration = 0; - if(!time.match(/[1-9]{1,3}[dhms]/g)) return "NaN"; - - for(const period of time.match(/[1-9]{1,3}[dhms]/g)) { - const [amount, unit] = period.match(/^(\d+)([dhms])$/).slice(1); - duration += unit === "d" ? amount * 24 * 60 * 60 : unit === "h" ? amount * 60 * 60 : unit === "m" ? amount * 60 : amount; - } - - return duration; - }, - /** - * @async - * @param {string} username Roblox username - * @param {number} groupId Group ID to fetch - * @returns {Promise<{success: false, error: string}|{success: true, data: RobloxGroupUserData}>} - */ - getGroup: async (username, groupId) => { - if(!groupId) return {success: false, error: "No group ID provided"}; - if(isNaN(username)) { - const user = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ usernames: [username] }) - }) - .then(res => res.json()) - .then(r => r.data[0]); - - if(!user) return {success: false, error: `Interpreted \`${username}\` as Username but no user was found`}; - username = user.id; - } else { - const user = await fetch(`https://users.roblox.com/v1/users/${username}`) - .then(res => res.json()); - - if(user.errors) return {success: false, error: `Interpreted \`${username}\` as ID but Roblox API returned: \`${user.errors[0].message}\``}; - } - const group = await fetch(`https://groups.roblox.com/v2/users/${username}/groups/roles`) - .then(res => res.json()); - if(group.errorMessage) return {success: false, error: `No group found with ID \`${groupId}\``}; - const role = group.data.find(g => g.group.id === groupId); - if(!role) return {success: false, error: "User is not in the group specified"}; - return {success: true, data: role}; - }, - - /** - * @async - * @param {number} user Discord user ID - * @param {Client} client Discord client - * @returns {Promise<{success: boolean, error: string}|{success: undefined, roblox: number, username: string}>} - */ - getRowifi: async (user, client) => { - const discord = await client.users.fetch(user) - .catch(() => { - return client.users.fetch("456226577798135808"); - }); - await client.guilds.fetch({ limit: 100, cache: true }); - if(client.guilds.cache.some((g) => { - g.fetch({ cache: true }); - if(g.roles.cache.find(r => r.name.includes("Commissariat"))) - return g.roles.cache.find(r => r.name.includes("Commissariat")).members.has(discord.id); - else - return false; - })) { - const roblox = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ usernames: [discord.username] }) - }) - .then(res => res.json()) - .then(r => r.data[0]); - module.exports.toConsole(`[ROWIFI] ${discord.tag} (${discord.id}) is a member of the Commissariat and has been bypassed`, new Error().stack, client); - return {success: true, roblox: roblox.id, username: roblox.name}; // Commissariat bypass - } - if(!user) return {success: false, error: "No username provided"}; - const userData = await fetch(`https://api.rowifi.xyz/v2/guilds/${config.discord.mainServer}/members/${user}`, { headers: { "Authorization": `Bot ${config.bot.rowifiApiKey}` } }) - .then(res => { - if(!res.ok) { - if(res.status !== 404) module.exports.toConsole(`Rowifi API returned ${res.status} ${res.statusText}`, new Error().stack, client); - return {success: false, error: "Rowifi API returned an error"}; - } else - return res.json(); - }); - if(userData.success !== undefined) return {success: false, error: "Rowifi failed to return any data! (If you are signed in with Rowifi, report this to a developer)"}; - - const roblox = await fetch(`https://users.roblox.com/v1/users/${userData.roblox_id}`) - .then(res => res.json()); - - if(roblox.errors) return {success: false, error: `\`${roblox.errors[0].message}\``}; - - return {success: true, roblox: userData.roblox_id, username: roblox.name}; - }, - - // -- // - - ids: [ - ["Global", 0], - ["Papers, Please!", 583507031], - ["Sevastopol Military Academy", 603943201], - ["Prada Offensive", 4683162920], - ["Triumphal Arch of Moscow", 2506054725], - ["Moscow Parade Grounds", 6887031333], - ["Ryazan Airbase", 4424975098], - ["Tank Training Grounds", 2451182763], - ["Papers, Please! Testing", 13883173412] - ] -}; \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index f1a7c41..0000000 --- a/index.js +++ /dev/null @@ -1,410 +0,0 @@ -const { Client, Collection, InteractionType, IntentsBitField } = require("discord.js"); -const { default: fetch } = require("node-fetch"); -const { interactionEmbed, toConsole, ids } = require("./functions.js"); -const config = require("./config.json"); -const fs = require("node:fs"); -const Sequelize = require("sequelize"); -const wait = require("node:util").promisify(setTimeout); -let ready = false; - -//#region Setup -// Database -const sequelize = new Sequelize(config.mysql.database, config.mysql.user, config.mysql.password, { - host: config.mysql.host, - dialect: "mysql", - logging: process.env.environment === "development" ? console.log : false, - port: config.mysql.port -}); -if(!fs.existsSync("./models")) { - console.warn("[DB] No models detected"); -} else { - console.info("[DB] Models detected"); - const models = fs.readdirSync("models").filter(file => file.endsWith(".js")); - console.info(`[DB] Expecting ${models.length} models`); - for(const model of models) { - try { - const file = require(`./models/${model}`); - file.import(sequelize); - console.info(`[DB] Loaded ${model}`); - } catch(e) { - console.error(`[DB] Unloaded ${model}`); - console.error(`[DB] ${e}`); - } - } - console.info("[DB] Loaded models"); - try { - sequelize.authenticate(); - console.info("[DB] Authenticated connection successfully"); - } catch(e) { - console.error("[DB] Failed to authenticate connection"); - console.error(`[DB] ${e}`); - ready = "fail"; - } - try { - if(ready === "fail") throw new Error("Connection authentication failed"); - sequelize.sync({ alter: process.env.environment === "development" }); - console.info("[DB] Synced models"); - } catch(e) { - console.error("[DB] Failed to sync models"); - console.error(`[DB] ${e}`); - } - ready = false; // Reset ready state -} - -// Discord bot -const client = new Client({ - intents: [IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildMessages, IntentsBitField.Flags.MessageContent] -}); -const slashCommands = []; -client.sequelize = sequelize; -client.models = sequelize.models; -client.commands = new Collection(); -client.modals = new Collection(); -//#endregion - -//#region Events -client.on("ready", async () => { - console.info("[READY] Client is ready"); - console.info(`[READY] Logged in as ${client.user.tag} (${client.user.id}) at ${new Date()}`); - toConsole(`[READY] Logged in as ${client.user.tag} (${client.user.id}) at and **${ready ? "can" : "cannot"}** receive commands`, new Error().stack, client); - client.user.setActivity("users of the IRF", { type: "LISTENING" }); - - if(!fs.existsSync("./commands")) await fs.mkdirSync("./commands"); - const commands = fs.readdirSync("./commands").filter(file => file.endsWith(".js")); - console.info(`[CMD-LOAD] Loading commands, expecting ${commands.length} commands`); - for(const file of commands) { - try { - const command = require(`./commands/${file}`); - client.commands.set(command.name, command); - slashCommands.push(command.data.toJSON()); - console.info(`[CMD-LOAD] Loaded command ${command.name}`); - } catch(err) { - console.error(`[CMD-LOAD] Failed to load command ${file}: ${err}`); - } - } - const modals = fs.readdirSync("./modals").filter(file => file.endsWith(".js")); - console.info(`[CMD-LOAD] Loading modals, expecting ${modals.length} modals`); - for(const file of modals) { - try { - const modal = require(`./modals/${file}`); - client.modals.set(modal.name, modal); - console.info(`[CMD-LOAD] Loaded modal ${modal.name}`); - } catch(err) { - console.error(`[CMD-LOAD] Failed to load modal ${file}: ${err}`); - } - } - try { - client.application.commands.set(slashCommands); - } catch(err) { - console.error(`[CMD-LOAD] Failed to load commands: ${err}`); - } - - ready = true; - - setInterval(async () => { - if(!ready) return; - client.channels.cache.get(config.discord.banLogs) || await client.channels.fetch(config.discord.banLogs); - client.guilds.cache.get(config.discord.mainServer) || await client.guilds.fetch(config.discord.mainServer); - const parseBans = await client.models.Ban.findAll({ where: { reason: { [Sequelize.Op.like]: "%___irf" } } }); - for(const ban of parseBans) { - /** - * @type {[string, string]} Reason added by user and moderator - */ - const reason = ban.reason.replace("___irf", "").split(" - Banned by "); - - /** - * @type {{id:number,name:string,displayName:string}} - */ - const victim = await fetch(`https://users.roblox.com/v1/users/${ban.userID}`).then(r => r.json()); - - /** - * @type {{id:number,name:string,displayName:string}} - */ - let moderator = {}; - if(reason[1].includes("FairPlay")) - reason[1] = "FairPlay_AntiCheat"; // Rewrite name - // Fetch from Roblox - moderator = await fetch("https://users.roblox.com/v1/usernames/users", { - method: "POST", body: JSON.stringify({ usernames: [reason[1]] }), headers: { "Content-Type": "application/json" } - }).then(r => r.json()).then(r => r.data[0]); - /** - * @type {{user:{username:string,id:string},nickname:string}} - */ - let discord; - - // Find them in the server - if(moderator.id !== 0) { - // Attempt #1: Query via Discord - discord = (await client.guilds.cache.get(config.discord.mainServer).members.search({ query: moderator.name, limit: 1 })).first(); - if(!discord) { - // Attempt #2: Query via RoWifi - const rowifiData = await fetch(`https://api.rowifi.xyz/v2/guilds/${config.discord.mainServer}/members/roblox/${moderator.id}`, { - headers: { "Authorization": `Bot ${config.bot.rowifiApiKey}` } - }); - if(rowifiData.ok) { - const json = await rowifiData.json(); - discord = await client.guilds.cache.get(config.discord.mainServer).members.fetch(json[0].discord_id); - } - } - } - // Can't find them, use Roblox data - if(!discord) - discord = { - user: { - id: 0, - username: moderator.name - }, - nickname: moderator.displayName - }; - - const gameName = ids.find(pair => pair[1]) ? ids.find(pair => pair[1])[0] : ban.gameId; - if(gameName === ban.gameId) toConsole(`[BAN] Failed to find game name for \`${ban.gameId}\``, new Error().stack, client); - client.channels.cache.get(config.discord.banLogs).send({ - embeds: [{ - title: `${moderator.name} banned => ${victim.name} (In Game)`, - description: `**${discord.user.id}** has added a ban for ${victim.name} (${victim.id}) on ${gameName}`, - color: 0x00FF00, - fields: [ - { - name: "Game", - value: gameName, - inline: true - }, - { - name: "User", - value: `${victim.name} (${victim.id})`, - inline: true - }, - { - name: "Reason", - value: `${reason[0]} - Banned by <@${discord.user.id}> (${moderator.id})`, - inline: true - } - ], - timestamp: ban.createdAt - }] - }); - // Post new ban data - await ban.update({ reason: `${reason[0]} - Banned by ${discord.id === 0 ? moderator.name : `<@${discord.id}>`} (${moderator.id})` }); - } - }, 20000); -}); - -client.on("interactionCreate", async (interaction) => { - if(!ready) return interactionEmbed(4, "", "The bot is starting up, please wait", interaction, client, [true, 10]); - - if(interaction.type === InteractionType.ApplicationCommand) { - let command = client.commands.get(interaction.commandName); - if(command) { - if(!command.modal) { - await interaction.deferReply({ ephemeral: command.ephemeral }); - // Can't Promise.all(...), deferReply must be first - await interaction.user.fetch(false); - } - const ack = command.run(client, interaction, interaction.options) - .catch((e) => { - interaction.editReply({ content: "Something went wrong while executing the command. Please report this to <@409740404636909578> (Tavi#0001)", embeds: [] }); - return toConsole(e.stack, new Error().stack, client); - }); - - await wait(1e4); - if(ack != null) return; // Already executed - interaction.fetchReply() - .then(m => { - if(m.content === "" && m.embeds.length === 0) interactionEmbed(3, "[ERR-UNK]", "The command timed out and failed to reply in 10 seconds", interaction, client, [true, 15]); - }); - } - } if(interaction.type === InteractionType.ModalSubmit) { - let modal = client.modals.get(interaction.customId); - if(modal) { - await interaction.deferReply({ ephemeral: true }); - const ack = modal.run(client, interaction, interaction.fields) - .catch((e) => { - interaction.editReply({ content: "Something went wrong while executing the modal. Please report this to <@409740404636909578> (Tavi#0001)", embeds: [] }); - return toConsole(e.stack, new Error().stack, client); - }); - - await wait(1e4); - if(ack != null) return; // Already executed - interaction.fetchReply() - .then(m => { - if(m.content === "" && m.embeds.length === 0) interactionEmbed(3, "[ERR-UNK]", "The modal timed out and failed to reply in 10 seconds", interaction, client, [true, 15]); - }); - } - } else if(interaction.type === InteractionType.ApplicationCommandAutocomplete) { - switch(interaction.commandName) { - case "ban": { - const value = interaction.options.getString("reason"); - const commonReasons = [ - // ROBLOX TOS // - { name: "TOS - Chat bypass", value: "Roblox TOS - Bypassing chat filter" }, - { name: "TOS - Clothes bypass", value: "Roblox TOS - Bypassed clothing" }, - { name: "TOS - Username bypass", value: "Roblox TOS - Bypassed username" }, - { name: "TOS - Nudity", value: "Roblox TOS - Nudity" }, - { name: "TOS - Exploit", value: "Roblox TOS - Exploiting" }, - { name: "TOS - Impersonation", value: "Roblox TOS - Impersonation" }, - { name: "TOS - Racism", value: "Roblox TOS - Racism" }, - { name: "TOS - Nazism", value: "Roblox TOS - Nazism" }, - { name: "TOS - NSFW", value: "Roblox TOS - NSFW content or actions (PDA included)" }, - // TBAN // - { name: "TBan - Evasion", value: "Temp Ban - Evasion of moderation action" }, - { name: "TBan - Nudity", value: "Temp Ban - Nudity" }, - { name: "TBan - NSFW", value: "Temp Ban - NSFW content or actions (PDA included)" }, - { name: "TBan - Spamming", value: "Temp Ban - Spamming" }, - { name: "TBan - SS Insignia", value: "Temp Ban - SS Insignia" }, - { name: "TBan - Chat bypass", value: "Temp Ban - Bypassing chat filter" }, - // GAME RULES // - { name: "Rules - Glitching", value: "Game Rules - Glitching" }, - { name: "Rules - RK", value: "Game Rules - Mass random killing (RK)" }, - // RULES // - { name: "Rules - Ban Bypass (Alt)", value: "Rules - Bypassing ban using alternative account" }, - { name: "Rules - DDoS Attack", value: "Rules - Attempting or causing a Distributed Denial of Service attack" }, - ]; - if(!value) return interaction.respond(commonReasons); - const matches = commonReasons.filter(r => r.value.toLowerCase().includes(value.toLowerCase())); - if(matches.length === 0 && value.length <= 100) return interaction.respond([{ name: value.length > 25 ? value.slice(0, 22) + "..." : value, value: value }]); - if(value.length > 100) return; // Timeout, too long value - return interaction.respond(matches); - } - case "request": { - const value = interaction.options.getString("reason"); - const reasons = [ - // DIVISIONS // - { name: "GA - Random killing", value: "User is mass random killing" }, - { name: "MP - Military Law", value: "User is violating Military Law" }, - { name: "FSS - Bolshevik Law", value: "User is violating Bolshevik Law" }, - { name: "MoA - No admissions", value: "There is no Admissions in the server" }, - { name: "MoA - Gamepass Admissions abuse", value: "Admissions is abusing their powers (Gamepass)" }, - // RAIDS // - { name: "Immigrant Raid", value: "Immigrant(s) are raiding against Military personnel" }, - { name: "Small Raid (1-7 raiders)", value: "There is chaos at the border and we are struggling to maintain control (1-7 raiders)" }, - { name: "Big Raid (8+ raiders)", value: "There is chaos at the border and we are struggling to maintain control (8+ raiders)" }, - { name: "Exploiter", value: "A user is exploiting" }, - // AUTHORITY // - { name: "Higher authority needed (Kick)", value: "Need someone to kick a user" }, - { name: "Higher authority needed (Server Ban)", value: "Need someone to server ban a user" }, - { name: "Higher authority needed (Temp/Perm Ban)", value: "Need someone to temp/perm ban a user" }, - // BACKUP // - { name: "General backup", value: "Control has been lost, general backup is needed" }, - { name: "DDoS Attack", value: "There is a DDoS attack on the server" } - ]; - if(!value) return interaction.respond(reasons); - const matches = reasons.filter(r => r.value.toLowerCase().includes(value.toLowerCase())); - if(matches.length === 0 && value.length <= 100) return interaction.respond([{ name: value.length > 25 ? value.slice(0, 22) + "..." : value, value: value }]); - if(value.length > 100) return; // Timeout, too long value - return interaction.respond(matches); - } - case "shutdown": { - let { name, value = "Papers" } = interaction.options.getFocused(true); - if(name !== "target") return; - if(value === "") value = "Papers"; - const servers = await fetch("https://tavis.page/test_servers").then(r => r.json()); - const matches = []; - const idMap = new Map(); - let matchedGame = 0; - for(const [name, id] of ids) { - idMap.set(String(id), name); - if(name.toLowerCase().includes(value.toLowerCase())) matchedGame = id; - } - // Push all servers with the game ID in matchedGame to matches - for(const [placeId, jobs] of Object.entries(servers.servers)) { - if(placeId == matchedGame) { - // eslint-disable-next-line no-unused-vars - for(const [jobId, [players, date]] of Object.entries(jobs)) { - matches.push({ name: `${jobId} - ${idMap.get(placeId) || "RTT"} (${players.length})`, value: jobId }); - } - } - } - matches.unshift({ name: "All servers - DANGEROUS (*)", value: "*" }); - return interaction.respond(matches); - } - default: { - return interaction.respond([]); // Invalid commandName - } - } - } -}); - -client.on("messageCreate", async (message) => { - if(message.guild.id != config.discord.mainServer) return; - if(message.author.bot) return; - if(!message.channel.name.includes("reports")) return; - // Message handler - let refMessage; - if(message.reference && message.content === "CHECK_IA_VIOLATIONS") { - setTimeout(() => message.delete(), 5000); - refMessage = await message.channel.messages.fetch(message.reference.messageId); - if(refMessage.author.bot) return; - } else { - refMessage = message; - } - // Check attachments for direct files - if(refMessage.attachments.size > 0) { - for(const attachment of refMessage.attachments) { - if(!/png|jpg|jpeg|webm|mov|mp4/i.test(attachment[1].name.split(".").pop())) { - refMessage.react("1095481555431997460"); - message.react("1095484268219732028"); - return refMessage.reply({ content: "<:denied:1095481555431997460> | Direct recordings are **not allowed** for security reasons. Upload your files to YouTube, Medal.TV, or Streamable.com and send the link instead.\n\n> *This was an automated action. If you think this was a mistake, DM <@409740404636909578> (Tavi#0001).*" }); - } - } - } - // RK check - if(!/(?:Mass (?:RK|(?:kill.*)))|(?:([^\w\d]RK)|Random(ly)?(?: )?kill.*)/i.test(refMessage.content)) - if(message.content === "CHECK_IA_VIOLATIONS") - return message.react("1095481555431997460"); - else - return; - refMessage.react("1095481555431997460"); - if(message.content === "CHECK_IA_VIOLATIONS") - message.react("1095484268219732028"); // Successful RK detection - refMessage.reply({ content: "<:denied:1095481555431997460> | Random killing reports are **not allowed**. Read the pinned messages and request Game Administrators for help if you find a random killer.\n\n> *This was an automated action. If you think this was a mistake, DM <@409740404636909578> (Tavi#0001).*" }); -}); -//#endregion - -client.login(config.bot.token); - -//#region Error handling -const recentErrors = []; -process.on("uncaughtException", (err, origin) => { - fs.writeSync( - process.stderr.fd, - `Caught exception: ${err}\n`+`Exception origin: ${origin}` - ); -}); -process.on("unhandledRejection", async (reason, promise) => { - if(!ready) { - console.warn("Exiting due to a [unhandledRejection] during start up"); - console.error(reason, promise); - return process.exit(15); - } - // Anti-spam System - if(recentErrors.length > 2) { - recentErrors.push({ promise: String(reason), time: new Date() }); - recentErrors.shift(); - } else { - recentErrors.push({ promise: String(reason), time: new Date() }); - } - if(recentErrors.length === 3 - && (recentErrors[0].reason === recentErrors[1].reason && recentErrors[1].reason === recentErrors[2].reason) - && recentErrors[0].time.getTime() - recentErrors[2].time.getTime() < 1e4) { - fs.writeFileSync("./latest-error.log", JSON.stringify({code: 15, info: {source: "Anti spam triggered! Three errors with the same content have occurred recently", r: String(reason)+" <------------> "+reason.stack}, time: new Date().toString()}, null, 2)); - return process.exit(17); - } - // Regular error handling - const suppressChannel = await client.channels.fetch(config.discord.suppressChannel).catch(() => { return undefined; }); - if(!suppressChannel) return console.error(`An [unhandledRejection] has occurred.\n\n> ${reason}`); - if(String(reason).includes("Interaction has already been acknowledged.") || String(reason).includes("Unknown interaction") || String(reason).includes("Unknown Message") || String(reason).includes("Cannot read properties of undefined (reading 'ephemeral')")) return suppressChannel.send(`A suppressed error has occured at process.on(unhandledRejection):\n>>> ${reason}`); - toConsole(`An [unhandledRejection] has occurred.\n\n> ${String(reason).replaceAll(/:/g, "\\:")}`, reason.stack || new Error().stack, client); -}); -process.on("warning", async (warning) => { - if(!ready) { - console.warn("[warning] has occurred during start up"); - console.warn(warning); - } - toConsole(`A [warning] has occurred.\n\n> ${warning}`, new Error().stack, client); -}); -process.on("exit", (code) => { - console.error("[EXIT] The process is exiting!"); - console.error(`[EXIT] Code: ${code}`); -}); -//#endregion diff --git a/modals/nscreport.js b/modals/nscreport.js deleted file mode 100644 index d0f0c3f..0000000 --- a/modals/nscreport.js +++ /dev/null @@ -1,67 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, ModalSubmitInteraction, ModalSubmitFields, EmbedBuilder } = require("discord.js"); -const { getRowifi } = require("../functions.js"); -const { channels } = require("../config.json"); - -module.exports = { - name: "nsc_report", - /** - * @param {Client} client - * @param {ModalSubmitInteraction} interaction - * @param {ModalSubmitFields} fields - */ - run: async (client, interaction, fields) => { - // Filing - const rowifi = await getRowifi(interaction.user.id, client); - const embed = new EmbedBuilder({ - title: "NSC Report", - color: 0xDE2821, - fields: [ - { - name: "Reporter Username", - value: fields.getTextInputValue("reporter_roblox"), - inline: false - }, - { - name: "Reporter ID", - value: interaction.user.id, - inline: false - }, - { - name: "RoWifi Link", - value: !rowifi.success ? `\`❌\` No, ${rowifi.error}` : `\`✅\` Yes, ${rowifi.roblox}`, - inline: false - }, - { - name: "Offender", - value: fields.getTextInputValue("offender_roblox"), - inline: false - }, - { - name: "Place & Time of Incident", - value: fields.getTextInputValue("incident_place"), - inline: false - }, - { - name: "Description of Incident", - value: fields.getTextInputValue("incident_description"), - inline: false - }, - { - name: "Proof of Incident", - value: fields.getTextInputValue("incident_proof"), - inline: false - } - ], - footer: { - text: `NSC Report - Secure Transmission | Filed at ${new Date().toLocaleTimeString()} ${new Date().toString().match(/GMT([+-]\d{2})(\d{2})/)[0]}`, - iconURL: client.user.displayAvatarURL() - } - }); - await client.channels.fetch(channels.nsc_report, { cache: true }); - await client.channels.cache.get(channels.nsc_report).send({ content: `Incoming NSC report from ${interaction.user.tag} (ID: ${interaction.user.id})`, embeds: [embed] }); // NSC - embed.fields.splice(2, 1); // Remove RoWifi - interaction.user.send({ content: `Here is a copy of a **NSC report** you filed at `, embeds: [embed] }); // DM copy - return interaction.followUp({ content: "`✅` Report filed! A copy has been sent to your DMs if I can DM you", embeds: [embed] }); // User's copy - } -}; \ No newline at end of file diff --git a/modals/report.js b/modals/report.js deleted file mode 100644 index eb7073e..0000000 --- a/modals/report.js +++ /dev/null @@ -1,67 +0,0 @@ -// eslint-disable-next-line no-unused-vars -const { Client, ModalSubmitInteraction, ModalSubmitFields, EmbedBuilder } = require("discord.js"); -const { getRowifi } = require("../functions.js"); -const { channels } = require("../config.json"); - -module.exports = { - name: "report", - /** - * @param {Client} client - * @param {ModalSubmitInteraction} interaction - * @param {ModalSubmitFields} fields - */ - run: async (client, interaction, fields) => { - // Filing - const rowifi = await getRowifi(interaction.user.id, client); - const embed = new EmbedBuilder({ - title: "MP Report", - color: 0x400080, - fields: [ - { - name: "Reporter Username", - value: fields.getTextInputValue("reporter_roblox"), - inline: false - }, - { - name: "Reporter ID", - value: interaction.user.id, - inline: false - }, - { - name: "RoWifi Link", - value: !rowifi.success ? `\`❌\` No, ${rowifi.error}` : `\`✅\` Yes, ${rowifi.roblox}`, - inline: false - }, - { - name: "Offender", - value: fields.getTextInputValue("offender_roblox"), - inline: false - }, - { - name: "Place & Time of Incident", - value: fields.getTextInputValue("incident_place"), - inline: false - }, - { - name: "Description of Incident", - value: fields.getTextInputValue("incident_description"), - inline: false - }, - { - name: "Proof of Incident", - value: fields.getTextInputValue("incident_proof"), - inline: false - } - ], - footer: { - text: `MP Report - Secure Transmission | Filed at ${new Date().toLocaleTimeString()} ${new Date().toString().match(/GMT([+-]\d{2})(\d{2})/)[0]}`, - iconURL: client.user.displayAvatarURL() - } - }); - await client.channels.fetch(channels.mp_report, { cache: true }); - await client.channels.cache.get(channels.mp_report).send({ content: `Incoming MP report from ${interaction.user.tag} (ID: ${interaction.user.id})`, embeds: [embed] }); // NSC - embed.fields.splice(2, 1); // Remove RoWifi - interaction.user.send({ content: `Here is a copy of a **MP report** you filed at `, embeds: [embed] }); // DM copy - return interaction.followUp({ content: "`✅` Report filed! A copy has been sent to your DMs if I can DM you", embeds: [embed] }); // User's copy - } -}; \ No newline at end of file diff --git a/models/Ban.js b/models/Ban.js deleted file mode 100644 index af34788..0000000 --- a/models/Ban.js +++ /dev/null @@ -1,30 +0,0 @@ -const { DataTypes } = require("sequelize"); - -module.exports.import = (sequelize) => sequelize.define("Ban", { - banId: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true - }, - userID: { - type: DataTypes.BIGINT(11), - allowNull: false - }, - gameID: { - type: DataTypes.BIGINT(11), - allowNull: false, - }, - reason: { - type: DataTypes.TEXT, - allowNull: false, - }, - proof: { - type: DataTypes.CHAR(100), - allowNull: false, - defaultValue: "https://discord.com/channels/989558770801737778/1059784888603127898/1063318255265120396" - }, - unixtime: { - type: DataTypes.BIGINT(12), - allowNull: false, - } -}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 80f104a..247d6ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,25 @@ { "name": "irf_administration", - "version": "1.6.2", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "irf_administration", - "version": "1.6.2", + "version": "2.2.0", "license": "ISC", "dependencies": { - "discord.js": "14.13.0", - "mysql2": "3.6.1", - "node-fetch": "2.7.0", - "sequelize": "6.33.0" + "discord.js": "14.11.0", + "mysql2": "3.6.2", + "sequelize": "6.33.0", + "typescript": "5.2.2" }, "devDependencies": { - "eslint": "8.51.0" + "@typescript-eslint/eslint-plugin": "6.9.0", + "@typescript-eslint/parser": "6.9.0", + "eslint": "8.52.0", + "eslint-plugin-prettier": "5.0.1", + "prettier": "3.0.3" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -82,6 +86,14 @@ "node": ">=16.11.0" } }, + "node_modules/@discordjs/rest/node_modules/@discordjs/util": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/@discordjs/util": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.0.1.tgz", @@ -109,10 +121,26 @@ "node": ">=16.11.0" } }, + "node_modules/@discordjs/ws/node_modules/@discordjs/util": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/util": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.2.0.tgz", - "integrity": "sha512-gB8T4H4DEfX2IV9zGDJPOBgP1e/DbfCPDTtEqUMckpvzS1OYtva8JdFYBqMwYk7xAQ429WGF/UPqn8uQ//h2vQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", "dev": true, "dependencies": { "eslint-visitor-keys": "^3.3.0" @@ -125,15 +153,18 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.2.tgz", - "integrity": "sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz", + "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==", "version": "2.1.2", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz", "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==", @@ -157,21 +188,29 @@ } }, "node_modules/@eslint/js": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.51.0.tgz", - "integrity": "sha512-HxjQ8Qn+4SI3/AFv6sOrDB+g6PpUTDwSJiQqOrnneEk8L71161srI9gjzzZvYVbzHiVg/BvcH95+cK/zfIt4pg==", + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.52.0.tgz", + "integrity": "sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", + "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==", + "engines": { + "node": ">=14" + } + }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", - "integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", + "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", + "@humanwhocodes/object-schema": "^2.0.1", "debug": "^4.1.1", "minimatch": "^3.0.5" }, @@ -193,9 +232,9 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", + "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, "node_modules/@nodelib/fs.scandir": { @@ -233,6 +272,26 @@ "node": ">= 8" } }, + "node_modules/@pkgr/utils": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", + "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "fast-glob": "^3.3.0", + "is-glob": "^4.0.3", + "open": "^9.1.0", + "picocolors": "^1.0.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.0.tgz", @@ -243,9 +302,9 @@ } }, "node_modules/@sapphire/shapeshift": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.2.tgz", - "integrity": "sha512-YRbCXWy969oGIdqR/wha62eX8GNHsvyYi0Rfd4rNW6tSVVa8p0ELiMEuOH/k8rgtvRoM+EMV7Csqz77YdwiDpA==", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.3.tgz", + "integrity": "sha512-WzKJSwDYloSkHoBbE8rkRW8UNKJiSRJ/P8NqJ5iVq7U2Yr/kriIBx2hW+wj2Z5e5EnXL1hgYomgaFsdK6b+zqQ==", "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" @@ -256,6 +315,9 @@ } }, "node_modules/@sapphire/snowflake": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", + "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", "version": "3.5.1", "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", @@ -265,36 +327,246 @@ } }, "node_modules/@types/debug": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", - "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.10.tgz", + "integrity": "sha512-tOSCru6s732pofZ+sMv9o4o3Zc+Sa8l3bxd/tweTQudFn06vAzb13ZX46Zi6m6EJ+RUbRTHvgQJ1gBtSgkaUYA==", "dependencies": { "@types/ms": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", + "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", + "dev": true + }, "node_modules/@types/ms": { - "version": "0.7.31", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", - "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" + "version": "0.7.33", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.33.tgz", + "integrity": "sha512-AuHIyzR5Hea7ij0P9q7vx7xu4z0C28ucwjAZC0ja7JhINyCnOw8/DnvAPQQ9TfOlCtZAmCERKQX9+o1mgQhuOQ==" }, "node_modules/@types/node": { - "version": "18.11.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", - "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", + "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", + "dev": true }, "node_modules/@types/validator": { - "version": "13.7.17", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", - "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" + "version": "13.11.5", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.5.tgz", + "integrity": "sha512-xW4qsT4UIYILu+7ZrBnfQdBYniZrMLYYK3wN9M/NdeIHgBN5pZI2/8Q7UfdWIcr5RLJv/OGENsx91JIpUUoC7Q==" }, "node_modules/@types/ws": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", - "integrity": "sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.8.tgz", + "integrity": "sha512-flUksGIQCnJd6sZ1l5dqCEG/ksaoAg/eUwiLAGTJQcfgvZJKF++Ta4bJA6A5aPSJmsr+xlseHn4KLgVlNnvPTg==", "dependencies": { "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.9.0.tgz", + "integrity": "sha512-lgX7F0azQwRPB7t7WAyeHWVfW1YJ9NIgd9mvGhfQpRY56X6AVf8mwM8Wol+0z4liE7XX3QOt8MN1rUKCfSjRIA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.9.0", + "@typescript-eslint/type-utils": "6.9.0", + "@typescript-eslint/utils": "6.9.0", + "@typescript-eslint/visitor-keys": "6.9.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.9.0.tgz", + "integrity": "sha512-GZmjMh4AJ/5gaH4XF2eXA8tMnHWP+Pm1mjQR2QN4Iz+j/zO04b9TOvJYOX2sCNIQHtRStKTxRY1FX7LhpJT4Gw==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.9.0", + "@typescript-eslint/types": "6.9.0", + "@typescript-eslint/typescript-estree": "6.9.0", + "@typescript-eslint/visitor-keys": "6.9.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.9.0.tgz", + "integrity": "sha512-1R8A9Mc39n4pCCz9o79qRO31HGNDvC7UhPhv26TovDsWPBDx+Sg3rOZdCELIA3ZmNoWAuxaMOT7aWtGRSYkQxw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.9.0", + "@typescript-eslint/visitor-keys": "6.9.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.9.0.tgz", + "integrity": "sha512-XXeahmfbpuhVbhSOROIzJ+b13krFmgtc4GlEuu1WBT+RpyGPIA4Y/eGnXzjbDj5gZLzpAXO/sj+IF/x2GtTMjQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.9.0", + "@typescript-eslint/utils": "6.9.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.9.0.tgz", + "integrity": "sha512-+KB0lbkpxBkBSiVCuQvduqMJy+I1FyDbdwSpM3IoBS7APl4Bu15lStPjgBIdykdRqQNYqYNMa8Kuidax6phaEw==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.9.0.tgz", + "integrity": "sha512-NJM2BnJFZBEAbCfBP00zONKXvMqihZCrmwCaik0UhLr0vAgb6oguXxLX1k00oQyD+vZZ+CJn3kocvv2yxm4awQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.9.0", + "@typescript-eslint/visitor-keys": "6.9.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.9.0.tgz", + "integrity": "sha512-5Wf+Jsqya7WcCO8me504FBigeQKVLAMPmUzYgDbWchINNh1KJbxCgVya3EQ2MjvJMVeXl3pofRmprqX6mfQkjQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.9.0", + "@typescript-eslint/types": "6.9.0", + "@typescript-eslint/typescript-estree": "6.9.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.9.0.tgz", + "integrity": "sha512-dGtAfqjV6RFOtIP8I0B4ZTBRrlTT8NHHlZZSchQx3qReaoDeXhYM++M4So2AgFK9ZB0emRPA6JI1HkafzA2Ibg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.9.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, "node_modules/@vladfrangu/async_event_emitter": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.2.2.tgz", @@ -305,9 +577,9 @@ } }, "node_modules/acorn": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", - "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -371,12 +643,42 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/big-integer": { + "version": "1.6.51", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", + "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", + "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", + "dev": true, + "dependencies": { + "big-integer": "^1.6.44" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -387,15 +689,31 @@ "concat-map": "0.0.1" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bundle-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", + "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", + "dev": true, "dependencies": { - "streamsearch": "^1.1.0" + "run-applescript": "^5.0.0" }, "engines": { - "node": ">=10.16.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/callsites": { @@ -483,6 +801,52 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/default-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", + "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", + "dev": true, + "dependencies": { + "bundle-name": "^3.0.0", + "default-browser-id": "^3.0.0", + "execa": "^7.1.1", + "titleize": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", + "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", + "dev": true, + "dependencies": { + "bplist-parser": "^0.2.0", + "untildify": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -491,6 +855,18 @@ "node": ">=0.10" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/discord-api-types": { "version": "0.37.50", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.50.tgz", @@ -520,6 +896,22 @@ "node": ">=16.11.0" } }, + "node_modules/discord.js/node_modules/@discordjs/util": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/discord.js/node_modules/@discordjs/util": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz", + "integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -550,18 +942,19 @@ } }, "node_modules/eslint": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.51.0.tgz", - "integrity": "sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==", + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.52.0.tgz", + "integrity": "sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.2", - "@eslint/js": "8.51.0", - "@humanwhocodes/config-array": "^0.11.11", + "@eslint/js": "8.52.0", + "@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -571,6 +964,9 @@ "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -603,6 +999,35 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-plugin-prettier": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", + "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.5" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -690,11 +1115,68 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -728,10 +1210,38 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/file-type": { + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz", + "integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==", + "dependencies": { + "readable-web-to-node-stream": "^3.0.2", + "strtok3": "^7.0.0", + "token-types": "^5.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "dependencies": { "locate-path": "^6.0.0", @@ -745,22 +1255,23 @@ } }, "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", + "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", "dev": true, "dependencies": { - "flatted": "^3.1.0", + "flatted": "^3.2.9", + "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=12.0.0" } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, "node_modules/fs.realpath": { @@ -777,6 +1288,18 @@ "is-property": "^1.0.2" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -810,9 +1333,9 @@ } }, "node_modules/globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "version": "13.23.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", + "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -824,6 +1347,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -839,6 +1382,15 @@ "node": ">=8" } }, + "node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -905,8 +1457,22 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/is-extglob": { "version": "2.1.1", @@ -929,6 +1495,33 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -943,6 +1536,45 @@ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -961,6 +1593,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -973,6 +1611,15 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1018,16 +1665,56 @@ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" }, "node_modules/long": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.1.tgz", - "integrity": "sha512-GKSNGeNAtw8IryjjkhZxuKB3JzlcLTwjtiQCHKvqQet81I93kXslhDQruGI/QsddO83mcDToBVy7GqGS/zYf/A==" + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "node_modules/lru-cache": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz", - "integrity": "sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/magic-bytes.js": { @@ -1072,9 +1759,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mysql2": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.6.1.tgz", - "integrity": "sha512-O7FXjLtNkjcMBpLURwkXIhyVbX9i4lq4nNRCykPNOXfceq94kJ0miagmTEGCZieuO8JtwtXaZ41U6KT4eF9y3g==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.6.2.tgz", + "integrity": "sha512-m5erE6bMoWfPXW1D5UrVwlT8PowAoSX69KcZzPuARQ3wY1RJ52NW9PdvdPo076XiSIkQ5IBTis7hxdlrQTlyug==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -1089,14 +1776,6 @@ "node": ">= 8.0" } }, - "node_modules/mysql2/node_modules/lru-cache": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", - "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", - "engines": { - "node": ">=16.14" - } - }, "node_modules/named-placeholders": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", @@ -1108,29 +1787,45 @@ "node": ">=12.0.0" } }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/npm-run-path": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", + "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "dev": true, "dependencies": { - "whatwg-url": "^5.0.0" + "path-key": "^4.0.0" }, "engines": { - "node": "4.x || >=6.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependencies": { - "encoding": "^0.1.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/once": { @@ -1142,6 +1837,39 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", + "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", + "dev": true, + "dependencies": { + "default-browser": "^4.0.0", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -1228,11 +1956,50 @@ "node": ">=8" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/peek-readable": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", + "integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1242,6 +2009,33 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", + "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -1310,6 +2104,110 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-applescript": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", + "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/run-applescript/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/run-applescript/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/run-applescript/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-applescript/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -1458,6 +2356,21 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -1466,12 +2379,12 @@ "node": ">= 0.6" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" } }, "node_modules/strip-ansi": { @@ -1486,6 +2399,18 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -1510,21 +2435,84 @@ "node": ">=8" } }, + "node_modules/synckit": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz", + "integrity": "sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==", + "dev": true, + "dependencies": { + "@pkgr/utils": "^2.3.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/titleize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", + "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/token-types": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", + "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/toposort-class": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==" }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + "node_modules/ts-api-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", + "integrity": "sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==", + "dev": true, + "engines": { + "node": ">=16.13.0" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } }, "node_modules/ts-mixer": { "version": "6.0.3", @@ -1532,9 +2520,9 @@ "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" }, "node_modules/tslib": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", - "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, "node_modules/type-check": { "version": "0.4.0", @@ -1560,17 +2548,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", - "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.27.0.tgz", + "integrity": "sha512-l3ydWhlhOJzMVOYkymLykcRRXqbUaQriERtR70B9LzNkZ4bX52Fc8wbTDneMiwo8T+AemZXvXaTx+9o5ROxrXg==", "dependencies": { - "busboy": "^1.6.0" + "@fastify/busboy": "^2.0.0" }, "engines": { "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -1589,27 +2603,13 @@ } }, "node_modules/validator": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", - "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==", + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", "engines": { "node": ">= 0.10" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1640,9 +2640,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 869ca93..01d2b1b 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,43 @@ { "name": "irf_administration", - "version": "1.6.2", + "version": "2.2.0", "description": "Administration bot for the Immortal Robloxian Federation", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "lint": "npx prettier --write ./src", + "prestart": "npx tsc -b", + "start": "cd dist && node index.js" }, - "keywords": ["discord.js", "discord", "bot", "automation"], + "keywords": [ + "discord.js", + "discord", + "bot", + "automation" + ], "author": { - "name": "TaviShadows (Tavi)", - "email": "github@sl.tavis.page", - "url": "https://tavis.page" + "name": "Federation Studios", + "url": "https://github.com/FederationStudios" }, + "contributors": [ + { + "name": "TaviShadows (Tavi)", + "email": "github@sl.tavis.page", + "url": "https://tavis.page" + } + ], "license": "ISC", "dependencies": { - "discord.js": "14.13.0", - "mysql2": "3.6.1", - "node-fetch": "2.7.0", - "sequelize": "6.33.0" + "discord.js": "14.11.0", + "mysql2": "3.6.2", + "sequelize": "6.33.0", + "typescript": "5.2.2" }, "devDependencies": { - "eslint": "8.51.0" - } + "@typescript-eslint/eslint-plugin": "6.9.0", + "@typescript-eslint/parser": "6.9.0", + "eslint": "8.52.0", + "eslint-plugin-prettier": "5.0.1", + "prettier": "3.0.3" + }, + "type": "module" } diff --git a/src/commands/ban.ts b/src/commands/ban.ts new file mode 100644 index 0000000..c6cc2e5 --- /dev/null +++ b/src/commands/ban.ts @@ -0,0 +1,291 @@ +import { + Attachment, + ChatInputCommandInteraction, + CommandInteractionOptionResolver, + GuildMember, + GuildMemberRoleManager, + Message, + SlashCommandBuilder, + TextChannel +} from 'discord.js'; +import { promisify } from 'node:util'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { + IRFGameId, + ResultMessage, + ResultType, + getEnumKey, + getRoblox, + getRowifi, + interactionEmbed, + toConsole +} from '../functions.js'; +import { CustomClient } from '../typings/Extensions.js'; +const { channels, discord } = config; +const wait = promisify(setTimeout); + +export const name = 'ban'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Bans a user from an IRF game') + .setDMPermission(false) + .addStringOption((option) => { + return option.setName('user_id').setDescription('Roblox username or ID').setRequired(true); + }) + .addStringOption((option) => { + return option + .setName('game_id') + .setDescription('Roblox game ID') + .setRequired(true) + .addChoices( + { name: 'Global', value: '0' }, + { name: 'Papers, Please!', value: '583507031' }, + { name: 'Sevastopol Military Academy', value: '603943201' }, + { name: 'Triumphal Arch of Moscow', value: '2506054725' }, + { name: 'Tank Training Grounds', value: '2451182763' }, + { name: 'Ryazan Airbase', value: '4424975098' }, + { name: 'Prada Offensive', value: '4683162920' } + ); + }) + .addStringOption((option) => { + return option + .setName('reason') + .setDescription('Reason for banning the user') + .setAutocomplete(true) + .setRequired(true); + }) + .addAttachmentOption((option) => { + return option + .setName('evidence') + .setDescription("Evidence of the user's ban (Add when possible, please!)") + .setRequired(false); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +): Promise { + if (!(interaction.member!.roles as GuildMemberRoleManager).cache.find((r) => r.name === 'Administration Access')) + return interactionEmbed(ResultType.Error, ResultMessage.UserPermission, interaction); + let user_id = options.getString('user_id', true); + // Check if the user ID is a valid ID + let roblox = await getRoblox(user_id); + if (roblox.success === false) { + interactionEmbed(3, roblox.error, interaction); + return; + } + const id = roblox.user; + // Ensure the game ID is valid + if (!IRFGameId[options.getString('game_id', true)]) + return interactionEmbed( + 3, + 'Arg `game_id` must be a registered game ID. Use `/ids` to see all recognised games', + interaction + ); + + // Check if the user is already banned + const bans = await client.models.bans.findAll({ + where: { + user: id.id, + game: options.getString('game_id', true) + } + }); + // If the user is already banned, show a warning + if (bans.length > 0) { + interactionEmbed( + 2, + `A ban already exists for ${id.name} (${id.id}) on ${getEnumKey( + IRFGameId, + Number(options.getString('game_id', true)) + )}. This will overwrite the ban!\n(Adding ban in 5 seconds)`, + interaction + ); + await wait(5000); // Show warning + } + // Get the user's rowifi data + const rowifi = await getRowifi(interaction.user.id, client); + if (rowifi.success === false) { + interactionEmbed(3, rowifi.error ?? 'Unknown error (Report this to a developer)', interaction); + return; + } + + // Fetch the channels that will be used + const image_host = (await client.channels.fetch(channels.image_host, { cache: true })) as TextChannel; + const nsc_report = (await client.channels.fetch(channels.nsc_report, { cache: true })) as TextChannel; + const ban = (await client.channels.fetch(channels.ban, { cache: true })) as TextChannel; + if (!image_host || !nsc_report || !ban) + return interactionEmbed(3, 'One or more channels could not be fetched. Please try again later', interaction); + // Validate the evidence + let rawEvidence: Attachment = options.getAttachment('evidence'); + // If no evidence was provided, fetch the default proof + if (!rawEvidence) { + rawEvidence = await image_host.messages + .fetch(discord.defaultProofURL.split('/')[6]) + .then((m) => m.attachments.first()); + } + if ( + rawEvidence.contentType.split('/')[0] !== 'image' && + rawEvidence.contentType.split('/')[1] === 'gif' && + rawEvidence.contentType.split('/')[0] === 'video' + ) { + interactionEmbed(3, 'Evidence must be an image (PNG, JPG, JPEG, or MP4)', interaction); + return; + } + let evidence: Message | undefined | void; + // Add variable for checking for errors + let error = false; + // If attachments are present, send to image_host + if (options.getAttachment('evidence')) { + const image_host = client.channels.cache.get(channels.image_host) as TextChannel; + evidence = await image_host + .send({ + content: `Evidence from ${interaction.user.toString()} (${interaction.user.tag} - ${interaction.user.id})`, + files: [ + { + attachment: rawEvidence.proxyURL.split('?')[0], + name: `EvidenceFrom_${rowifi.username}+${rowifi.roblox}.${ + rawEvidence.proxyURL.split('.').splice(-1)[0].split('?')[0] + }` + } + ] + }) + .catch((err) => { + // Throw error and safely exit + error = true; + // Detect too large files + if (String(err).includes('Request entity too large')) { + return interactionEmbed( + 3, + 'Discord rejected the evidence (File too large). Try compressing the file first!', + interaction + ); + } + return interactionEmbed(3, 'Failed to upload evidence to image host', interaction); + }); + // Drop further handling - we've already responded + if (error) return; + } else { + // Extract the message ID and channel ID from the URL + const pChnlId = discord.defaultProofURL.split('/')[5]; + const pMsgId = discord.defaultProofURL.split('/')[6]; + // Fetch evidence + evidence = await (client.channels.cache.get(pChnlId) as TextChannel).messages.fetch(pMsgId); + } + // If the evidence failed to upload, return an error + if (!evidence || !evidence.attachments.first()) { + interactionEmbed(3, 'Failed to upload evidence to image host', interaction); + return; + } + try { + // If the user is already banned, update the ban + if (bans.length > 0) { + await client.models.bans.update( + { + user: id.id, + game: Number(options.getString('game_id', true)), + reason: options.getString('reason', true), + data: { + proof: evidence.url, + privacy: 'Public' + }, + mod: { + discord: interaction.user.id, + roblox: rowifi.roblox + } + }, + { + where: { + user: id.id, + game: Number(options.getString('game_id', true)) + } + } + ); + } else { + await client.models.bans.create({ + user: id.id, + game: Number(options.getString('game_id')), + reason: options.getString('reason'), + data: { + privacy: 'Public', + proof: evidence.url + }, + mod: { + discord: interaction.user.id, + roblox: rowifi.roblox + } + }); + } + } catch (e) { + toConsole( + `An error occurred while adding a ban for ${id.name} (${id.id})\n> ${String(e)}`, + new Error().stack!, + client + ); + error = true; + } + if (error) return interactionEmbed(3, ResultMessage.DatabaseError, interaction); + + await ban.send({ + embeds: [ + { + title: `${(interaction.member as GuildMember).nickname || interaction.user.username} banned => ${id.name}`, + description: `**${interaction.user.id}** has added a ban for ${id.name} (${id.id}) on ${ + IRFGameId[options.getString('game_id', true)] + } (${options.getString('game_id')})`, + color: 0x00ff00, + fields: [ + { + name: 'Game', + value: `${IRFGameId[options.getString('game_id', true)]} (${options.getString('game_id')})`, + inline: true + }, + { + name: 'User', + value: `${id.name} (${id.id})`, + inline: true + }, + { + name: 'Reason', + // Attachment will always be present, checks are above + value: `${options.getString('reason')}\n\n**Evidence:** ${ + evidence.attachments.first()!.proxyURL.split('?')[0] + }`, + inline: true + } + ], + timestamp: new Date().toISOString() + } + ] + }); + await interaction.editReply({ + content: 'Ban added successfully!', + embeds: [ + { + title: 'Ban Details', + color: 0xde2821, + fields: [ + { + name: 'User', + value: `${id.name} (${id.id})`, + inline: true + }, + { + name: 'Game', + value: getEnumKey(IRFGameId, Number(options.getString('game_id', true)))!, + inline: true + }, + { + name: 'Reason', + // Attachment will always be present, checks are above + value: `${options.getString('reason')}\n\n**Evidence:** ${ + evidence.attachments.first()!.proxyURL.split('?')[0] + }`, + inline: false + } + ] + } + ] + }); + return; +} diff --git a/src/commands/check.ts b/src/commands/check.ts new file mode 100644 index 0000000..204568c --- /dev/null +++ b/src/commands/check.ts @@ -0,0 +1,159 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + CommandInteractionOptionResolver, + ComponentType, + EmbedBuilder, + MessageComponentInteraction, + SlashCommandBuilder, + TextChannel +} from 'discord.js'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { IRFGameId, getRoblox, interactionEmbed } from '../functions.js'; +import { CustomClient } from '../typings/Extensions.js'; +const { discord } = config; + +export const name = 'check'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Checks for bans associated with a user') + .setDMPermission(false) + .addStringOption((option) => { + return option.setName('user_id').setDescription("User's Roblox ID").setRequired(true); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +): Promise { + const filter = (i: MessageComponentInteraction) => i.user.id === interaction.user.id; + const user_id = options.getString('user_id', true); + const roblox = await getRoblox(user_id); + if (roblox.success === false) return interactionEmbed(3, roblox.error, interaction); + + let bans = (await client.models.bans.findAll({ where: { user: roblox.user.id }, paranoid: false })).sort( + (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() + ); + // Get the user's avatar + const avatar = await fetch( + `https://thumbnails.roblox.com/v1/users/avatar?userIds=${roblox.user.id}&size=720x720&format=Png&isCircular=false` + ) + .then((r: Response) => r.json()) + .then((r) => r.data[0].imageUrl); + // Create array holder for bans + const embeds: EmbedBuilder[] = []; + for (const ban of bans) { + // Check if proof is valid + if (!/.+\/([0-9]{0,20})\/([0-9]{0,20})$/.exec(ban.data.proof || discord.defaultProofURL)) { + // If not, add an embed with an error message + embeds.push( + new EmbedBuilder({ + title: 'Error Parsing Proof', + description: `Proof given was invalid and could not be parsed. Report this to a developer.\n\nRegEx failed on \`${ban.data.proof}\` (ID: ${ban.banId})` + }) + ); + continue; + } + // Get the evidence message + const evid = await client.channels + .fetch(/.+\/([0-9]{0,20})\/([0-9]{0,20})$/.exec(ban.data.proof || discord.defaultProofURL)![1]) + .then((c) => + (c as TextChannel).messages.fetch( + /.+\/([0-9]{0,20})\/([0-9]{0,20})$/g.exec(ban.data.proof || discord.defaultProofURL)![2] + ) + ); + // First attachment + const fa = evid.attachments.first(); + // If the evidence message is not found, add an embed with an error message + if (!fa || !fa.contentType) { + embeds.push( + new EmbedBuilder({ + title: 'Error Parsing Proof', + description: `Proof given was invalid and could not be parsed. Report this to a developer.\n\nAttachment failed on \`${evid.url}\` (ID: ${ban.banId})` + }) + ); + continue; + } + // Try to get the image, else set to undefined + const image = fa.contentType.startsWith('video') ? undefined : { url: fa.url, proxyURL: fa.proxyURL }; + // Create the fields so we can modify later + const f = [ + { name: 'Game', value: IRFGameId[ban.game], inline: true }, + { name: 'Status', value: ban.isSoftDeleted() ? `Revoked` : 'Active', inline: true }, + { name: 'Moderator', value: `Roblox ID: ${ban.mod.roblox}\nDiscord: <@${ban.mod.discord}>`, inline: false } + ]; + // If the proof isn't an image, it's a video, so add a field for it + if (typeof image === 'undefined') { + f.push({ name: 'Evidence', value: `[Video provided by moderator](${evid.url})`, inline: true }); + } + // Add the embed to the array + embeds.push( + new EmbedBuilder({ + title: `__**Bans for ${roblox.user.name} (${roblox.user.displayName})**__`, + description: `**Reason**: ${ban.reason}${ban.isSoftDeleted() ? `\n**Unban Reason**: ${ban.unbanReason}` : ''}`, + color: ban.isSoftDeleted() ? 0x00ff00 : 0xff0000, + thumbnail: { + url: avatar + }, + fields: f, + image: image, + footer: { + text: `Ban ${bans.indexOf(ban) + 1} of ${bans.length} - Ban created:` + }, + timestamp: ban.createdAt + }) + ); + } + if (bans.length === 0) + embeds.push( + new EmbedBuilder({ + title: `__**Bans for ${roblox.user.name} (${roblox.user.displayName})**__`, + description: `No bans found for this user`, + color: 0xaaaaaa, + thumbnail: { + url: avatar + }, + footer: { + text: 'Ban 0 of 0' + }, + timestamp: new Date() + }) + ); + + let page = 0; + const paginationRow: ActionRowBuilder = new ActionRowBuilder({ + components: [ + new ButtonBuilder({ customId: 'previous', label: '◀️', style: ButtonStyle.Primary }), + new ButtonBuilder({ customId: 'cancel', label: '🟥', style: ButtonStyle.Danger }), + new ButtonBuilder({ customId: 'next', label: '▶️', style: ButtonStyle.Primary }) + ] + }); + const data = { embeds: [embeds[page]], components: [paginationRow] }; + if (embeds.length < 2) data.components = []; + const coll = await interaction + .editReply(data) + .then((r) => r.createMessageComponentCollector({ filter, componentType: ComponentType.Button, time: 120_000 })); + + coll.on('collect', (i) => { + if (i.customId === 'next') { + page = page + 1; + if (page > embeds.length - 1) page = 0; + i.update({ embeds: [embeds[page]], components: [paginationRow] }); + } else if (i.customId === 'previous') { + page = page - 1; + if (page < 0) page = embeds.length - 1; + i.update({ embeds: [embeds[page]], components: [paginationRow] }); + } else { + coll.stop(); + } + }); + + coll.once('end', () => { + interaction.deleteReply(); + }); + + return; +} diff --git a/src/commands/ids.ts b/src/commands/ids.ts new file mode 100644 index 0000000..0d4943e --- /dev/null +++ b/src/commands/ids.ts @@ -0,0 +1,27 @@ +import { ChatInputCommandInteraction, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { IRFGameId } from '../functions.js'; +import { CustomClient } from '../typings/Extensions.js'; + +export const name = 'ids'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Returns all IRF Game IDs') + .setDMPermission(false); +export async function run(client: CustomClient, interaction: ChatInputCommandInteraction): Promise { + interaction.editReply({ + embeds: [ + new EmbedBuilder({ + color: 0xde2821, + title: 'IRF Game IDs', + // Convert the map to an array of strings, then join them with newlines + description: Object.entries(IRFGameId) + .filter(([_k, v]) => typeof v === 'number') + .map(([k, v]) => `**${k}**: ${v}`) + .join('\n'), + timestamp: new Date() + }) + ] + }); + return; +} diff --git a/src/commands/kick.ts b/src/commands/kick.ts new file mode 100644 index 0000000..7fedf0b --- /dev/null +++ b/src/commands/kick.ts @@ -0,0 +1,98 @@ +import { ChatInputCommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } from 'discord.js'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { getGroup, getRowifi, interactionEmbed, toConsole } from '../functions.js'; +import { CustomClient, ServerList } from '../typings/Extensions.js'; +const { roblox, urls } = config; + +export const name = 'kick'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Kicks a player from a server') + .setDMPermission(false) + .addStringOption((option) => { + return option.setName('target').setDescription('The user you wish to kick (Roblox ID)').setRequired(true); + }) + .addStringOption((option) => { + return option.setName('reason').setDescription('Reason for kicking the player').setRequired(true); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +): Promise { + const rowifi = await getRowifi(interaction.user.id, client); + if (rowifi.success === false) { + interactionEmbed(3, rowifi.error, interaction); + return; + } + const robloxData = await getGroup(rowifi.username, 4899462); + if (robloxData.success === false) return interactionEmbed(3, robloxData.error, interaction); + if (robloxData.data.role.rank <= 200) + return interactionEmbed(3, 'You do not have permission to use this command (Engineer+)', interaction); + const servers: { success: boolean; servers: ServerList } = await fetch(urls.servers).then((r: Response) => r.json()); + if (!servers.success) + return interactionEmbed( + 3, + 'The remote access system is having issues. Please try again later (Status code: 503)', + interaction + ); + let target = Number(options.getString('target', true)); + if (isNaN(target)) return interactionEmbed(3, 'Invalid target (Must be a user ID)', interaction); + const reason = options.getString('reason'); + // For each server in each game, check if the target is in the server + let playerFound = false; + for (const gameId in servers.servers) { + for (const server in servers.servers[gameId]) { + // Convert the players array to an array if it isn't already + const players = servers.servers[gameId][server][0]; + // Check if the target is in the server + if (players.findIndex((p) => p === target) === -1) continue; + // Get the universe ID of the game + const universeId = await fetch(`https://apis.roblox.com/universes/v1/places/${gameId}/universe`) + .then((r) => r.json()) + .then((r) => r.universeId); + // Send the kick request + const req = await fetch( + `https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, + { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': roblox.mainOCtoken + }, + // JSON.stringify doesn't work with Roblox OpenCloud so using manual escaping + body: `{"message": '{"type": "kick", "target": "${server}", "userId": "${target}", "reason": "${reason}"}'}`, + method: 'POST' + } + ); + // Mark the player as found + playerFound = true; + // Check if the request was successful + if (!req.ok) + return interactionEmbed( + 3, + 'The remote access system is having issues. Please try again later (Status code: 400)', + interaction + ); + } + } + // Send the response body to the user + if (!playerFound) return interactionEmbed(3, 'Invalid target (User is not in any servers)', interaction); + await interactionEmbed(1, `Kicked ${target} from the server`, interaction); + toConsole( + `[REMOTE ADMIN] ${interaction.user.username} (${interaction.user.id}) kicked ${target} from the server they were in`, + new Error().stack!, + client + ); + interaction.followUp({ + files: [ + { + attachment: Buffer.from(JSON.stringify(servers, null, 2)), + name: 'servers.json', + description: 'List of IRF servers (DEBUGGING PURPOSES IF THIS COMMAND BACKFIRES)' + } + ], + ephemeral: true + }); + return; +} diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..2aa14fe --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,440 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChatInputCommandInteraction, + CommandInteractionOptionResolver, + EmbedBuilder, + SlashCommandBuilder, + StringSelectMenuBuilder, + StringSelectMenuInteraction +} from 'discord.js'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { getRoblox, interactionEmbed } from '../functions.js'; +import { CustomClient } from '../typings/Extensions.js'; +const { roblox } = config; + +export const name = 'profile'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription("Returns a user's profile") + .addStringOption((option) => { + return option.setName('roblox').setDescription('Roblox username').setRequired(true); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +): Promise { + const robloxData = await getRoblox(options.getString('roblox', true)); + if (robloxData.success === false) return interactionEmbed(3, robloxData.error, interaction); + + const avatar = await fetch( + `https://thumbnails.roblox.com/v1/users/avatar?userIds=${robloxData.user.id}&size=720x720&format=Png&isCircular=false` + ) + .then((r) => r.json()) + .then((r) => r.data[0].imageUrl); + const bans = await client.models.bans.findAll({ where: { user: robloxData.user.id } }); + + //#region Fetching data + const data: { [key: string]: any } = {}; + const promises: Promise[] = []; + // We fetch the relevant data about the user + promises.push( + fetch(`https://users.roblox.com/v1/users/${robloxData.user.id}`) + .then((r) => r.json()) + .then((r: unknown) => (data.user = r)) + ); + promises.push( + fetch(`https://friends.roblox.com/v1/users/${robloxData.user.id}/friends`) + .then((r) => r.json()) + .then((r) => (data.friends = r.data)) + ); + promises.push( + fetch(`https://groups.roblox.com/v1/users/${robloxData.user.id}/groups/roles`) + .then((r) => r.json()) + .then((r) => (data.groups = r.data)) + ); + promises.push( + fetch(`https://users.roblox.com/v1/users/${robloxData.user.id}/username-history?limit=50`) + .then((r) => r.json()) + .then((r) => (data.history = r.data.map((u) => u.name))) + ); + promises.push( + fetch('https://presence.roblox.com/v1/presence/users', { + method: 'POST', + body: JSON.stringify({ userIds: [robloxData.user.id] }), + headers: { + 'Content-Type': 'application/json', + Cookie: `.ROBLOSECURITY=${roblox.validationToken || 'abcdef123456'}` + } + }) + .then((r) => r.json()) + .then((r) => (data.presence = r.userPresences[0])) + ); + await Promise.allSettled(promises); + data.user.createdAt = new Date(data.user.created).toUTCString(); + //#endregion + + //#region Data Parsing + const categories: { [key: string]: EmbedBuilder[] } = { overview: [], friends: [], groups: [], activity: [] }; + let embeds: EmbedBuilder[] = []; + let page = 0; + //#region Overview + categories.overview = [ + new EmbedBuilder({ + title: 'Overview', + color: 0xde2821, + thumbnail: { + url: client.user!.avatarURL()! + }, + description: + data.user.description + '\n\n[Visit Profile](https://www.roblox.com/users/' + robloxData.user.id + '/profile)', + image: { + url: avatar + }, + fields: [ + { + name: 'Username', + value: robloxData.user.name, + inline: true + }, + { + name: 'ID', + value: robloxData.user.id, + inline: true + }, + { + name: 'Created', + value: + new Date(data.user.createdAt).getTime() > 0 + ? `` + : 'Unknown', + inline: true + }, + { + name: 'IRF Game Bans', + value: bans.length, + inline: true + }, + { + name: 'Friends', + value: data.friends.length, + inline: true + }, + { + name: 'Groups', + value: data.groups.length, + inline: true + }, + { + name: 'Previous Usernames', + value: data.history ? data.history.join('\n') : 'None', + inline: false + } + ], + timestamp: new Date() + }) + ]; + //#endregion + //#region Friends + const friendFields = data.friends.map((friend) => { + return { + name: friend.displayName, + value: `Username: ${friend.name}\nID: ${friend.id}\nOnline: ${friend.isOnline ? 'Yes' : 'No'}`, + inline: true + }; + }); + if (friendFields.length === 0) + categories.friends.push( + new EmbedBuilder({ + title: `${robloxData.user.name}'s Friends`, + color: 0xde2821, + thumbnail: { + url: client.user!.avatarURL()! + }, + description: `https://roblox.com/users/${robloxData.user.id}/profile`, + image: { + url: avatar + }, + fields: [{ name: 'No friends', value: 'This user has no friends!' }], + footer: { + text: 'Page 1 of 1' + }, + timestamp: new Date() + }) + ); + for (let i = 0; i < friendFields.length; i += 9) { + categories.friends.push( + new EmbedBuilder({ + title: `${robloxData.user.name}'s Friends`, + color: 0xde2821, + thumbnail: { + url: client.user!.avatarURL()! + }, + description: `https://roblox.com/users/${robloxData.user.id}/profile`, + image: { + url: avatar + }, + fields: friendFields.slice(i, i + 9), + footer: { + text: `Page ${Math.floor(i / 9) + 1} of ${Math.ceil(friendFields.length / 9)}` + }, + timestamp: new Date() + }) + ); + } + //#endregion + //#region Groups + data.groups.forEach((group, index) => { + categories.groups.push( + new EmbedBuilder({ + title: `${group.group.name} (${group.group.id})`, + color: 0xde2821, + thumbnail: { + url: client.user!.avatarURL()! + }, + description: + group.group.description.length > 2048 + ? `${group.group.description.slice(0, 2045)}...` + : group.group.description, + fields: [ + { + name: 'Owner', + value: `${group.group.owner.username || 'NO_USERNAME_WAS_RETURNED'} "${ + group.group.owner.displayName || 'NO_DISPLAY_NAME_WAS_RETURNED' + }" (${group.group.owner.userId || 'NO_USERID_WAS_RETURNED'})`, + inline: true + }, + { + name: 'Members', + value: group.group.memberCount, + inline: true + }, + { + name: "User's Rank", + value: group.role.name, + inline: true + } + ], + footer: { + text: `Group ${index + 1} of ${data.groups.length}` + }, + timestamp: new Date() + }) + ); + }); + if (data.groups.length === 0) + categories.groups.push( + new EmbedBuilder({ + title: `${robloxData.user.name}'s Groups`, + color: 0xde2821, + thumbnail: { + url: client.user!.avatarURL()! + }, + description: `https://roblox.com/users/${robloxData.user.id}/profile`, + image: { + url: avatar + }, + fields: [{ name: 'No groups', value: 'This user is not in any groups!' }], + footer: { + text: 'Page 1 of 1' + }, + timestamp: new Date() + }) + ); + //#endregion + //#region Activity + categories.activity = [ + new EmbedBuilder({ + title: `${robloxData.user.name}'s Activity`, + color: 0xde2821, + thumbnail: { + url: client.user!.avatarURL()! + }, + description: `https://roblox.com/users/${robloxData.user.id}/profile`, + image: { + url: avatar + }, + fields: [ + { + name: 'Status', + value: + data.presence.userPresenceType === 0 + ? '💤 Offline' + : data.presence.userPresenceType === 1 + ? '🌐 Online' + : data.presence.userPresenceType === 2 + ? '🟢 In Game' + : '❔ Unknown', + inline: true + }, + { + name: 'Last Online', + value: + new Date(data.presence.lastOnline).getTime() > 0 + ? `` + : 'Unknown', + inline: true + }, + { + name: 'Last Location', + value: data.presence.lastLocation ?? 'Unknown', + inline: true + } + ], + timestamp: new Date() + }) + ]; + if (data.presence.userPresenceType === 2 && data.presence.placeId) { + categories.activity[1][0].addFields([ + { + name: 'Game', + value: `[${data.presence.lastLocation}](https://roblox.com/games/${data.presence.placeId}) ([https://roblox.com/games/${data.presence.placeId}](https://roblox.com/games/${data.presence.placeId}))`, + inline: true + } + ]); + } else if (data.presence.userPresenceType === 2 && !data.presence.placeId) { + categories.activity[1][0].addFields([ + { + name: 'Game', + value: '❗ Profile is private, unable to fetch current game', + inline: true + } + ]); + } + //#endregion + //#endregion + + const selectorRow: ActionRowBuilder = new ActionRowBuilder({ + components: [ + new StringSelectMenuBuilder({ + customId: 'profile-category', + placeholder: 'Select a category to review', + options: [ + { + label: 'Overview', + value: 'overview', + description: `View general information on ${robloxData.user.name}`, + emoji: '🔍' + }, + { + label: 'Friends', + value: 'friends', + description: `${robloxData.user.name}'s friends`, + emoji: '👥' + }, + { + label: 'Groups', + value: 'groups', + description: `${robloxData.user.name}'s groups`, + emoji: '🎖️' + }, + { + label: 'Activity', + value: 'activity', + description: `${robloxData.user.name}'s status`, + emoji: '📊' + }, + { + label: 'Cancel', + value: 'cancel', + description: 'Cancel the command', + emoji: '❌' + } + ], + min_values: 1, + max_values: 1 + }) + ] + }); + const paginationRow: ActionRowBuilder = new ActionRowBuilder({ + components: [ + new ButtonBuilder({ customId: 'previous', label: '◀️', style: ButtonStyle.Primary }), + new ButtonBuilder({ customId: 'cancel', label: '🟥', style: ButtonStyle.Danger }), + new ButtonBuilder({ customId: 'next', label: '▶️', style: ButtonStyle.Primary }) + ] + }); + + //#region Pagination + const coll = await interaction + .editReply({ embeds: [categories.overview[0]], components: [selectorRow] }) + .then((m) => + m.createMessageComponentCollector({ filter: (i) => i.user.id === interaction.user.id, time: 180_000 }) + ); + + coll.on('collect', (i: ButtonInteraction | StringSelectMenuInteraction) => { + const selectors: ActionRowBuilder[] = [selectorRow]; + // If they select the menu, switch category + if (i.isStringSelectMenu()) { + // If they cancel, stop the collector + if (i.values[0] === 'cancel') return coll.stop(); + // Get the category + embeds = categories[i.values[0]]; + // New category, so page #0 + page = 0; + // If the length is greater than 2, add pagination row + if (embeds.length > 1) selectors.unshift(paginationRow); + // Update the message + i.update({ embeds: [embeds[page]], components: selectors }); + return; + } + // Pagination row + switch (i.customId) { + // Cancel + case 'cancel': { + coll.stop(); + break; + } + // Previous + case 'previous': { + page = page - 1; + // If the page is less than 0, set it to the last page + if (page < 0) page = embeds.length - 1; + // If the length is greater than the length, add pagination row + if (embeds.length > 1) selectors.unshift(paginationRow); + // Update the message + i.update({ embeds: [embeds[page]], components: selectors }); + break; + } + // Next + case 'next': { + page = page + 1; + // If the page is greater than the length, set it to the first page + if (page > embeds.length - 1) page = 0; + // If the length is greater than 2, add pagination row + if (embeds.length > 1) selectors.unshift(paginationRow); + // Update the message + i.update({ embeds: [embeds[page]], components: selectors }); + break; + } + // Handled above + case 'profile-category': { + // Handled above + break; + } + // Theoretically impossible to reach + default: { + i.update({ + content: "You shouldn't be seeing this! Report this to an Engineer\n\n**CUSTOMID**: " + i.customId, + embeds: [], + components: [] + }); + } + } + }); + coll.on('end', () => { + interaction + .fetchReply() + .then((m) => + m.edit({ + content: `This embed has timed out. Please run the command again: `, + components: [] + }) + ) + .catch(() => null); + }); + //#endregion +} diff --git a/src/commands/report.ts b/src/commands/report.ts new file mode 100644 index 0000000..e6e1f25 --- /dev/null +++ b/src/commands/report.ts @@ -0,0 +1,203 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + ComponentType, + ModalBuilder, + SlashCommandBuilder, + TextInputBuilder, + TextInputStyle +} from 'discord.js'; +import { getRowifi } from '../functions.js'; +import { divisions, msgs, ticketsCreationAttributes } from '../models/init-models.js'; +import { getDivision } from '../functions/tickets.js'; +import { CustomClient } from '../typings/Extensions.js'; + +export const name = 'report'; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Report a member to a division or sub-division'); +export const ephemeral = true; +export async function run(client: CustomClient, interaction: ChatInputCommandInteraction): Promise { + // Verify Rowifi + const rowifi = await getRowifi(interaction.user.id, client); + if (rowifi.success === false) { + interaction.editReply({ content: rowifi.error }); + return; + } + // Create collector filter for use later + const filter = (i) => i.user.id === interaction.user.id; + // Get division + const division: string | divisions = await getDivision({ interaction, client: null, ticket: null }).catch((e) => e); + if (division === 'Cancelled') return; + else if (typeof division !== 'object' || division instanceof Error) throw division; + // Return data + const gCheck = client.guilds.cache.get(division.guildId); + if (!gCheck || !gCheck.available) { + interaction.editReply({ content: 'The guild is not available' }); + return; + } + // Get contact channel + const contactChannel = gCheck.channels.cache.get(division.contacts); + if (!contactChannel || !contactChannel.isTextBased()) { + interaction.editReply({ content: 'The contact channel is not available' }); + return; + } + // Request confirmation since modals need a raw interaction + const confirm = await interaction + .editReply({ + content: `You have selected ${division.division} and its sub-division ${division.name}. Is this correct?`, + components: [ + new ActionRowBuilder({ + components: [ + new ButtonBuilder({ customId: 'yes', label: 'Yes', style: ButtonStyle.Success }), + new ButtonBuilder({ customId: 'no', label: 'No', style: ButtonStyle.Danger }) + ] + }) as ActionRowBuilder + ] + }) + .then((i) => i.awaitMessageComponent({ componentType: ComponentType.Button, time: 10_000, filter })) + .catch(() => null); + // If no confirmation, cancel + if (!confirm || confirm.customId === 'no') { + confirm.update({ content: 'Report cancelled', components: [] }); + return; + } + // Create user inputs + const inputs = [ + new TextInputBuilder({ + custom_id: 'offender', + label: 'Offender(s) ID?', + placeholder: 'Enter the ID(s) of the offender(s)', + style: TextInputStyle.Paragraph, + min_length: 4, + max_length: 100, + required: true + }), + new TextInputBuilder({ + custom_id: 'location', + label: 'Where did this occur? [Discord/Roblox]', + placeholder: 'Discord/Roblox', + style: TextInputStyle.Short, + min_length: 6, + max_length: 8, + required: true + }), + new TextInputBuilder({ + custom_id: 'reason', + label: 'What happened?', + placeholder: 'Enter the reason for the report', + style: TextInputStyle.Paragraph, + min_length: 10, + max_length: 500, + required: true + }), + new TextInputBuilder({ + custom_id: 'evidence', + label: 'Evidence?', + placeholder: 'Enter evidence for the report (Use URLs, the bot cannot see any images on your computer!)', + style: TextInputStyle.Paragraph, + min_length: 10, + max_length: 1000, + required: true + }), + new TextInputBuilder({ + custom_id: 'acknowledgement', + label: 'I swear this report is:', + placeholder: 'Complete and correct [yes]', + style: TextInputStyle.Short, + min_length: 3, + max_length: 3, + required: true + }) + ]; + // Create the modal and add the inputs + const modal = new ModalBuilder().setTitle(`${division.name} Report Form`).setCustomId(`${division.divId}-report`); + for (const i of inputs) { + modal.addComponents(new ActionRowBuilder({ components: [i] })); + } + // Create ticket creation attributes (for later) + const tAttr: ticketsCreationAttributes = { + division: division.divId, + status: 'Open', + author: interaction.user.id + }; + // Submit modal + await confirm.showModal(modal); + await confirm.editReply({ + content: 'Please submit the report within 120 seconds. Open a new report if needed', + components: [] + }); + const mi = await confirm.awaitModalSubmit({ + filter, + time: 120_000 + }) + .catch(() => null); + if (!mi) { + interaction.editReply({ content: 'You did not submit a report in time. Please re-run the command' }); + return; + } + // Defer update + await mi.deferUpdate(); + if (mi.fields.getTextInputValue('acknowledgement') !== 'yes') { + mi.editReply({ content: 'You must agree to the acknowledgement. Please resubmit the report' }); + return; + } + // Create a ticket + const ticket = await division.createTicket(tAttr); + // Send the report + const reportEmbed = { + title: 'New Report', + description: `${interaction.user.toString()} (Rowifi: ${rowifi.username}) has submitted a report to ${ + division.name + }'s ${division.name}. Details are listed below. Click the button to reply to the report`, + fields: [ + { name: 'Offender(s)', value: mi.fields.getTextInputValue('offender'), inline: false }, + { name: 'Location', value: mi.fields.getTextInputValue('location'), inline: false }, + { name: 'Reason', value: mi.fields.getTextInputValue('reason'), inline: false }, + { name: 'Evidence', value: mi.fields.getTextInputValue('evidence'), inline: false }, + { name: 'Acknowledgement', value: mi.fields.getTextInputValue('acknowledgement'), inline: false } + ] + }; + const m = await contactChannel.send({ + embeds: [reportEmbed], + components: [ + new ActionRowBuilder({ + components: [ + new ButtonBuilder({ customId: `claim-${ticket.ticketId}`, label: 'Claim', style: ButtonStyle.Primary }) + ] + }) as ActionRowBuilder + ] + }); + // Suppress errors (DMs restricted) + await interaction.user + .send({ + content: `[\`#${ticket.ticketId}\`] This is a copy of the report you submitted at . Someone will assist you soon!`, + embeds: [reportEmbed] + }) + .catch(() => {}); + // Create DB message + await msgs + .create({ + content: `**Offenders**: ${mi.fields.getTextInputValue('offender')}\n**Location**: ${mi.fields.getTextInputValue( + 'location' + )}\n**Reason**: ${mi.fields.getTextInputValue('reason')}\n**Evidence**: ${mi.fields.getTextInputValue( + 'evidence' + )}\n**Acknowledgement**: ${mi.fields.getTextInputValue('acknowledgement')}`, + author: interaction.user.id, + tick: ticket.ticketId, + link: m.url + }) + // Then add the message to the ticket + .then((m) => ticket.addMsg(m)); + // Edit the original interaction + mi.editReply({ + content: 'Report submitted. Here is a copy. A copy as also been sent to your DMs if I can DM you.', + embeds: [reportEmbed] + }); + // Return + return; +} diff --git a/src/commands/request.ts b/src/commands/request.ts new file mode 100644 index 0000000..f1fd69f --- /dev/null +++ b/src/commands/request.ts @@ -0,0 +1,142 @@ +import { + ChatInputCommandInteraction, + CommandInteractionOptionResolver, + Message, + SlashCommandBuilder +} from 'discord.js'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { ResultMessage, getRowifi, interactionEmbed, toConsole } from '../functions.js'; +import { CustomClient, RobloxUserPresenceData } from '../typings/Extensions.js'; +const { channels, discord, roblox } = config; +const cooldown = new Map(); + +export const name = 'request'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Requests a division to assist you (Cooldown: 15 minutes)') + .setDMPermission(false) + .addStringOption((option) => { + return option + .setName('division') + .setDescription('Division you are requesting') + .addChoices( + { name: 'Admissions', value: 'Admissions' }, + { name: 'Game Administration', value: 'Game Administrator' }, + { name: 'National Defense', value: 'National Defense' }, + { name: 'Military Police', value: 'Military Police' }, + { name: 'State Security (NKVD)', value: 'State Security' } + ) + .setRequired(true); + }) + .addStringOption((option) => { + return option.setName('reason').setDescription('Reason for request').setAutocomplete(true).setRequired(true); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +): Promise { + if (cooldown.has(interaction.user.id)) { + interactionEmbed( + 3, + `You can request `, + interaction + ); + return; + } + if (interaction.guild!.id != discord.mainServer) { + interactionEmbed(3, 'This command can only be used in the main server', interaction); + return; + } + const division = options.getString('division'); + await interaction.guild!.roles.fetch(); + const role = interaction.guild!.roles.cache!.find((r) => r.name === options.getString('division', true))!.toString(); + const reason = options.getString('reason'); + const rowifi = await getRowifi(interaction.user.id, client); + if (!rowifi.success) { + interactionEmbed(3, 'You must verify with RoWifi before using this command', interaction); + return; + } + + const presenceCheck: RobloxUserPresenceData | {}[] = await fetch('https://presence.roblox.com/v1/presence/users', { + method: 'POST', + body: JSON.stringify({ + userIds: [rowifi.roblox] + }), + headers: { + 'Content-Type': 'application/json', + Cookie: `.ROBLOSECURITY=${roblox.validationToken || 'abcdef123456'}` + } + }) + .then((r) => r.json()) + .then((r) => r.errors || r.userPresences[0]); + if (Array.isArray(presenceCheck)) { + toConsole( + `Presence check failed for ${interaction.user.tag} (${interaction.user.id})\n\`\`\`json\n${JSON.stringify( + presenceCheck, + null, + 2 + )}\n\`\`\``, + new Error().stack!, + client + ); + interactionEmbed(3, 'An error occurred while checking your presence. Try again later', interaction); + return; + } + if (presenceCheck.userPresenceType !== 2) { + interactionEmbed( + 3, + "You must be in-game in order to use this command. Try again later when you're in-game", + interaction + ); + return; + } + if (presenceCheck.gameId === null) { + interactionEmbed( + 3, + 'You must have your profile set to public in order to use this command. Try again later when your profile is public', + interaction + ); + return; + } + + const request = await client.channels.fetch(channels.request, { cache: true }); + if (!request || !request.isTextBased()) { + interactionEmbed(3, ResultMessage.Unknown, interaction); + return; + } + request + .send({ + content: role, + embeds: [ + { + title: `${rowifi.username} is requesting ${division}`, + color: 0xde2821, + description: `${interaction.member!.toString()} is requesting ${role} due to: __${reason}__\n\n**Profile Link:** https://www.roblox.com/users/${ + rowifi.roblox + }/profile\n\n**React if you are handling this request**` + } + ] + }) + .then((m: Message) => m.react('✅')) + .then(() => { + interaction.editReply({ + embeds: [ + { + title: 'Request Sent', + color: 0xde2821, + description: `Your request has been sent and ${division} has been called` + } + ] + }); + }) + .catch(() => { + interactionEmbed(3, 'An error occurred while sending the request. Try again later', interaction); + }); + + cooldown.set(interaction.user.id, Date.now()); + setTimeout(() => { + cooldown.delete(interaction.user.id); + }, 900000); // 15 minutes +} diff --git a/src/commands/shutdown.ts b/src/commands/shutdown.ts new file mode 100644 index 0000000..78ded25 --- /dev/null +++ b/src/commands/shutdown.ts @@ -0,0 +1,149 @@ +import { ChatInputCommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } from 'discord.js'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { IRFGameId, getGroup, getRowifi, interactionEmbed, toConsole } from '../functions.js'; +import { CustomClient, ServerList } from '../typings/Extensions.js'; +const { roblox, urls } = config; + +export const name = 'shutdown'; +export const ephemeral = true; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Shuts down a server') + .setDMPermission(false) + .addStringOption((option) => { + return option + .setName('target') + .setDescription("Target server's JobId to shut down") + .setRequired(true) + .setAutocomplete(true); + }) + .addStringOption((option) => { + return option.setName('reason').setDescription('Reason for shutting down').setRequired(true); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +): Promise { + const rowifi = await getRowifi(interaction.user.id, client); + if (rowifi.success === false) { + interactionEmbed(3, rowifi.error, interaction); + return; + } + // Check Federation Studios rank + const robloxData = await getGroup(rowifi.username, 4899462); + if (robloxData.success === false) { + interactionEmbed(3, robloxData.error, interaction); + return; + } + if (robloxData.data.role.rank < 200) { + interactionEmbed(3, 'You do not have permission to use this command (Engineer+)', interaction); + return; + } + // Fetch servers + const servers: { success: boolean; servers: ServerList } = await fetch(urls.servers).then((r: Response) => r.json()); + if (!servers.success) { + interactionEmbed( + 3, + 'The remote access system is having issues. Please try again later (Status code: 503)', + interaction + ); + return; + } + const target = options.getString('target'); + const reason = options.getString('reason'); + // If target does not equal *, find the gameId which matches the target + let server = false; + // Set up request options + const params = { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': roblox.mainOCtoken + }, + body: `{"message": '{"type": "shutdown", "target": "${target}", "reason": "${reason}"}'}`, + method: 'POST' + }; + // Loop through servers + for (const [PlaceId, game] of Object.entries(servers.servers)) { + if (target === '*') break; // Not handled here, but later on + for (const [JobId, _Data] of Object.entries(game)) { + // If JobId matches + if (JobId === target) { + // Fetch universeId + const universeId = await fetch(`https://apis.roblox.com/universes/v1/places/${PlaceId}/universe`) + .then((r) => r.json()) + .then((r) => r.universeId); + // Send primary request to shutdown server + const resp = await fetch( + `https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, + params + ); + if (!resp.ok) { + // Try using secondary OpenCloud token + params.headers['x-api-key'] = roblox.altOCtoken; + // Send secondary request to shutdown server + const att2 = await fetch( + `https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, + params + ); + // If secondary request fails, return error + if (!att2.ok) { + interactionEmbed( + 3, + 'The remote access system is having issues. Please try again later (Status code: 400)', + interaction + ); + return; + } + } + // Set server to true + server = true; + // Break out of loop + break; + } + } + } + // If server is false and target is not *, return error + if (!server && target !== '*') { + interactionEmbed( + 3, + 'The server you are trying to shut down does not exist. Try using the autocomplete menu', + interaction + ); + return; + } + // If target is *, shut down all servers + if (target === '*') { + for (const id of Object.values(IRFGameId)) { + // ID 0 is Global + if (id === 0) continue; + // Fetch universeId + const universeId = await fetch(`https://apis.roblox.com/universes/v1/places/${id}/universe`) + .then((r) => r.json()) + .then((r) => r.universeId); + // Restore primary OpenCloud token + params.headers['x-api-key'] = roblox.mainOCtoken; + // Send primary request to shutdown server + const resp = await fetch( + `https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, + params + ); + if (!resp.ok) { + // Try using secondary OpenCloud token + params.headers['x-api-key'] = roblox.altOCtoken; + // Send secondary request to shutdown server + await fetch( + `https://apis.roblox.com/messaging-service/v1/universes/${universeId}/topics/remoteAdminCommands`, + params + ); + } + } + } + toConsole( + `[REMOTE ADMIN] ${interaction.user.username} (${interaction.user.id}) shut down server ${target}`, + new Error().stack!, + client + ); + interactionEmbed(1, `Executed shutdown on server ${target} successfully`, interaction); + return; +} diff --git a/src/commands/unban.ts b/src/commands/unban.ts new file mode 100644 index 0000000..91110ea --- /dev/null +++ b/src/commands/unban.ts @@ -0,0 +1,153 @@ +import { + ChatInputCommandInteraction, + CommandInteractionOptionResolver, + GuildMember, + GuildMemberRoleManager, + SlashCommandBuilder +} from 'discord.js'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { IRFGameId, ResultMessage, getGroup, getRoblox, getRowifi, interactionEmbed, toConsole } from '../functions.js'; +import { CustomClient } from '../typings/Extensions.js'; +const { channels, roblox } = config; + +export const name = 'unban'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Unbans a user from an IRF game') + .setDMPermission(false) + .addStringOption((option) => { + return option.setName('user_id').setDescription('Roblox username or ID').setRequired(true); + }) + .addStringOption((option) => { + return option + .setName('game_id') + .setDescription('Roblox game ID') + .setRequired(true) + .addChoices( + { name: 'Global', value: '0' }, + { name: 'Papers, Please!', value: '583507031' }, + { name: 'Sevastopol Military Academy', value: '603943201' }, + { name: 'Triumphal Arch of Moscow', value: '2506054725' }, + { name: 'Tank Training Grounds', value: '2451182763' }, + { name: 'Ryazan Airbase', value: '4424975098' }, + { name: 'Prada Offensive', value: '4683162920' } + ); + }) + .addStringOption((option) => { + return option.setName('reason').setDescription('Reason for unban').setRequired(true); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +) { + // Check roles + if (!(interaction.member.roles as GuildMemberRoleManager).cache.find((r) => r.name === 'Administration Access')) + return interactionEmbed(3, ResultMessage.UserPermission, interaction); + const id = await getRoblox(options.getString('user_id', true)); + if (id.success === false) return interactionEmbed(3, id.error, interaction); + + // Rowifi link + const rowifi = await getRowifi(interaction.user.id, client); + if (rowifi.success === false) return interactionEmbed(3, rowifi.error, interaction); + + // Find bans + const bans = await client.models.bans.findAll({ + where: { + user: id.user.id, + game: options.getString('game_id') + } + }); + // If no bans exists, return + if (bans.length === 0) { + return interactionEmbed( + 3, + `No bans exist for \`${id.user.name}\` (${id.user.id}) on ${IRFGameId[options.getString('game_id', true)]}`, + interaction + ); + } + // If FairPlay ban... + if (bans[0].reason.includes('FairPlay')) { + const data = await getGroup(rowifi.username, roblox.developerGroup); + // Check if they are lower than the developer rank in the developer group + if (data.success === true && data.data.role.rank < roblox.developerRank) + return interactionEmbed( + 3, + 'You are not authorized to unban a FairPlay ban. Contact a developer to arrange the unban', + interaction + ); + } + + // Destroy the ban + let error = false; + try { + bans + .filter((b) => !b.isSoftDeleted()) + .forEach((b) => { + // Add unban reason and destroy + b.update({ + unbanReason: options.getString('reason') + }); + b.destroy(); + }); + } catch (e) { + // Error handling + toConsole( + `An error occurred while removing a ban for ${id.user.name} (${id.user.id})\n> ${String(e)}`, + new Error().stack, + client + ); + error = true; + } + // Return an error to the user + if (error) return interactionEmbed(3, ResultMessage.DatabaseError, interaction); + const unban = await client.channels.fetch(channels.unban); + if (!unban || !unban.isTextBased()) return interactionEmbed(3, ResultMessage.Unknown, interaction); + // Send a message to the unban channel + unban.send({ + embeds: [ + { + title: `${(interaction.member as GuildMember).nickname || interaction.user.username} unbanned => ${ + id.user.name + }`, + description: `**${interaction.user.id}** has removed a ban for ${id.user.name} (${id.user.id}) on ${ + IRFGameId[options.getString('game_id', true)] + } (${options.getString('game_id')})`, + color: 0x00ff00, + fields: [ + { + name: 'Game', + value: `${IRFGameId[options.getString('game_id', true)]} (${options.getString('game_id')})`, + inline: true + }, + { + name: 'User', + value: `${id.user.name} (${id.user.id})`, + inline: true + }, + { + name: 'Reason', + value: options.getString('reason'), + inline: true + }, + { + name: 'Original Ban Reason', + value: bans[0].reason, + inline: false + } + ], + timestamp: new Date().toISOString() + } + ] + }); + + // Return a success message to the user + return interactionEmbed( + 1, + `Removed ban for ${id.user.name} (${id.user.id}) on ${ + IRFGameId[options.getString('game_id', true)] + } (${options.getString('game_id', true)})\n> Reason: ${options.getString('reason')}`, + interaction + ); +} diff --git a/src/commands/userid.ts b/src/commands/userid.ts new file mode 100644 index 0000000..9a7fd13 --- /dev/null +++ b/src/commands/userid.ts @@ -0,0 +1,44 @@ +import { ChatInputCommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } from 'discord.js'; +import { getRoblox, interactionEmbed } from '../functions.js'; +import { CustomClient } from '../typings/Extensions.js'; + +export const name = 'userid'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Provides a Roblox ID when given a username') + .addStringOption((option) => { + return option.setName('username').setDescription('Roblox username').setRequired(true); + }); +export async function run( + client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +) { + // Fetch Roblox ID + const roblox = await getRoblox(options.getString('username', true)); + if (roblox.success === false) return interactionEmbed(3, roblox.error, interaction); + + // Fetch avatar + const avatar = await fetch( + `https://thumbnails.roblox.com/v1/users/avatar?userIds=${roblox.user.id}&size=720x720&format=Png&isCircular=false` + ) + .then((r) => r.json()) + .then((r) => r.data[0].imageUrl); + // Reply to interaction + return interaction.editReply({ + embeds: [ + { + title: `Roblox ID for ${roblox.user.name}`, + color: 0xde2821, + description: `${roblox.user.id}`, + thumbnail: { + url: avatar + }, + footer: { + text: `This user also goes by "${roblox.user.displayName}" as their display name` + } + } + ] + }); +} diff --git a/src/commands/username.ts b/src/commands/username.ts new file mode 100644 index 0000000..a1365c6 --- /dev/null +++ b/src/commands/username.ts @@ -0,0 +1,43 @@ +import { ChatInputCommandInteraction, CommandInteractionOptionResolver, SlashCommandBuilder } from 'discord.js'; +import { getRoblox, interactionEmbed } from '../functions.js'; +import { CustomClient } from '../typings/Extensions.js'; + +export const name = 'username'; +export const ephemeral = false; +export const data = new SlashCommandBuilder() + .setName(name) + .setDescription('Provides a Roblox username when given a Roblox ID') + .addIntegerOption((option) => { + return option.setName('id').setDescription('Roblox ID').setRequired(true); + }); +export async function run( + _client: CustomClient, + interaction: ChatInputCommandInteraction, + options: CommandInteractionOptionResolver +) { + // Fetch Roblox ID + const roblox = await getRoblox(options.getInteger('id', true)); + if (roblox.success === false) return interactionEmbed(3, roblox.error, interaction); + + // Fetch avatar + const avatar = await fetch( + `https://thumbnails.roblox.com/v1/users/avatar?userIds=${roblox.user.id}&size=720x720&format=Png&isCircular=false` + ) + .then((r) => r.json()) + .then((r) => r.data[0].imageUrl); + return interaction.editReply({ + embeds: [ + { + title: `Roblox Username for ${roblox.user.id}`, + color: 0xde2821, + description: `${roblox.user.name}`, + thumbnail: { + url: avatar + }, + footer: { + text: `This user also goes by "${roblox.user.displayName}" as their display name` + } + } + ] + }); +} diff --git a/src/functions.ts b/src/functions.ts new file mode 100644 index 0000000..aaf4a81 --- /dev/null +++ b/src/functions.ts @@ -0,0 +1,391 @@ +import { + ActionRowBuilder, + AutocompleteInteraction, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ComponentType, + EmbedBuilder, + Interaction, + InteractionEditReplyOptions, + InteractionType +} from 'discord.js'; +import { default as config } from './config.json' assert { type: 'json' }; +import { CustomClient } from './typings/Extensions.js'; + +//#region Enums +enum IRFGameId { + 'Global' = 0, + 'Papers, Please!' = 583507031, + 'Sevastopol Military Academy' = 603943201, + 'Triumphal Arch of Moscow' = 2506054725, + 'Tank Training Grounds' = 2451182763, + 'Ryazan Airbase' = 4424975098, + 'Prada Offensive' = 4683162920 +} +enum ResultMessage { + DatabaseError = 'An error has occurred while communicating with the database', + Cooldown = 'You are on cooldown!', + UserPermission = 'You do not have the proper permissions to execute this command', + BotPermission = 'This bot does not have proper permissions to execute this command', + BadArgument = 'You have not supplied the correct parameters. Please check again', + Unknown = 'An unknwon error occurred. Please report this to a developer', + NotFound = "The requested information wasn't found", + NoDM = "This command isn't available in Direct Messages. Please run this in a server", + NonexistentCommand = 'The requested slash command was not found. Please refresh your Discord client and try again', + Development = 'This command is in development. This should not be expected to work' +} +enum ResultType { + Success, + Warning, + Error, + Information +} +//#endregion +//#region Types +type RobloxGroupUserData = { + group: RobloxGroupGroupData; + role: RobloxGroupRoleData; +}; +/** + * @prop {string} id Group ID + * @prop {string} name Name of the group + * @prop {number} memberCount Member count of the group + */ +type RobloxGroupGroupData = { + id: string; + name: string; + memberCount: number; +}; +/** + * @prop {number} id Numeric identifier of the role + * @prop {string} name Name of the role + * @prop {number} rank Rank of the role (0-255) + */ +type RobloxGroupRoleData = { + id: number; + name: string; + rank: number; +}; +/** + * @prop {string} requestedUsername Username that was requested + * @prop {boolean} hasVerifiedBadge Whether or not the user has a verified badge + * @prop {number} id User ID + * @prop {string} name Username of the user + * @prop {string} displayName Display name of the user + */ +type RobloxUserData = { + requestedUsername: string; + hasVerifiedBadge: boolean; + id: number; + name: string; + displayName: string; +}; +//#endregion + +//#region Functions +/** + * @async + * @description Sends a message to the console + * @example toConsole(`Hello, World!`, new Error().stack, client); + */ +async function toConsole(message: string, source: string, client: CustomClient): Promise { + const channel = await client.channels.fetch(config.discord.logChannel).catch(() => null); + if (source.split('\n').length < 2) + return console.error('[ERR] toConsole called but Error.stack was not used\n> Source: ' + source); + source = /(?:[A-Za-z0-9._]+:[0-9]+:[0-9]+)/.exec(source)![0]; + if (!channel || !channel.isTextBased()) + return console.warn('[WARN] toConsole called but bot cannot find logging channel\n', message, '\n', source); + + await channel.send(`Incoming message from \`${source}\` at `); + const check = await channel + .send({ + embeds: [ + new EmbedBuilder({ + title: 'Message to Console', + color: 0xde2821, + description: `${message}`, + timestamp: new Date() + }) + ] + }) + .then(() => false) + .catch(() => true); // Supress errors + if (check) return console.error(`[ERR] At ${new Date().toString()}, toConsole called but message failed to send`); + + return; +} + +/** + * @async + * @description Replies with a Embed to the Interaction + * @example interactionEmbed(1, "", `Removed ${removed} roles`, interaction) + * @example interactionEmbed(3, `[ERR-UPRM]`, `Missing: \`Manage Messages\``, interaction) + * @returns {Promise} + */ +async function interactionEmbed( + type: ResultType, + content: ResultMessage | string, + interaction: Exclude +): Promise { + if (!interaction.deferred) await interaction.deferReply(); + const embed = new EmbedBuilder() + .setAuthor({ name: interaction.user.username, iconURL: interaction.user.avatarURL({ size: 4096 })! }) + .setDescription(content) + .setTimestamp(); + + switch (type) { + case ResultType.Success: + embed.setTitle('Success').setColor(0x7289da); + + break; + case ResultType.Warning: + embed.setTitle('Warning').setColor(0xffa500); + + break; + case ResultType.Error: + embed.setTitle('Error').setColor(0xff0000); + + break; + case ResultType.Information: + embed.setTitle('Information').setColor(0x7289da); + + break; + } + // Utilise invisible character to remove message content + await interaction.editReply({ content: '​', embeds: [embed] }); + return; +} + +function parseTime(time: string): number { + let duration = 0; + if (!time.match(/[1-9]{1,3}[dhms]/g)) return NaN; + + for (const period of time.match(/[1-9]{1,3}[dhms]/g)!) { + const [amount, unit] = period.match(/^(\d+)([dhms])$/)!.slice(1); + duration += + unit === 'd' + ? Number(amount) * 24 * 60 * 60 + : unit === 'h' + ? Number(amount) * 60 * 60 + : unit === 'm' + ? Number(amount) * 60 + : Number(amount); + } + + return duration; +} + +/** + * @async + */ +async function getGroup( + username: string | number, + groupId: number +): Promise<{ success: false; error: string } | { success: true; data: RobloxGroupUserData }> { + const roblox = await getRoblox(username); + if (roblox.success === false) return { success: false, error: roblox.error }; + username = roblox.user.id; // Set username to the ID of the user + // Fetch the group data from Roblox API + const group = await fetch(`https://groups.roblox.com/v2/users/${username}/groups/roles`).then((r: Response) => + r.json() + ); + // If the group is not found, return an error + if (group.errorMessage) return { success: false, error: `No group found with ID \`${groupId}\`` }; + // Find the group specified + const role = group.data.find((g) => g.group.id === groupId); + // If the user is not in the group, return an error + if (!role) return { success: false, error: 'User is not in the group specified' }; + // Return the role + return { success: true, data: role }; +} + +/** + * @async + */ +async function getRowifi( + user: string, + client: CustomClient +): Promise<{ success: false; error: string } | { success: true; roblox: number; username: string }> { + const discord = await client.users.fetch(user).catch(() => false); + if (typeof discord === 'boolean') return { success: false, error: 'Invalid Discord user ID' }; + // Check if user is in the Commissariat group + const commGroup = await getGroup(discord.username, config.roblox.commissariatGroup); + if (commGroup.success === true) { + // Fetch their roblox ID + return await fetch(`https://users.roblox.com/v1/usernames/users`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ usernames: [discord.username] }) + }) + .then((r: Response) => r.json()) + // Typings are slightly incorrect, but the property we want is there + .then((r: { data: { id: number }[] }) => r.data[0]) + .then((r: { id: number }) => { + // Return their roblox ID and username + return { success: true, roblox: r.id, username: discord.username }; + }); + } + // Fetch their Roblox ID from Rowifi + const userData = await fetch(`https://api.rowifi.xyz/v2/guilds/${config.discord.mainServer}/members/${user}`, { + headers: { Authorization: `Bot ${config.bot.rowifiApiKey}` } + }).then((r: Response) => { + // If response is not OK, return the error + if (!r.ok) return { success: false, error: `Rowifi returned status code \`${r.status}\`` }; + // Return the JSON + return r.json(); + }); + // If success is present, return an error + if (userData.success !== undefined) + return { + success: false, + error: 'Rowifi failed to return any data! Please check you are signed in with Rowifi' + }; + + // Fetch their Roblox username from the Roblox API + const roblox = await fetch(`https://users.roblox.com/v1/users/${userData.roblox_id}`).then((r: Response) => r.json()); + + // If the Roblox API returns an error, return the error + if (roblox.errors) return { success: false, error: `\`${roblox.errors[0].message}\`` }; + // Return their roblox ID and username + return { success: true, roblox: userData.roblox_id, username: roblox.name }; +} + +/** + * @async + * @example getRoblox(1) => { success: true, id: 1, username: 'Roblox' } + * @example getRoblox('Roblox') => { success: true, id: 1, username: 'Roblox' } + * @returns {Promise<{success: false; error: string}|{success: true; user: {requestedUsername: string; hasVerifiedBadge: boolean; id: number; name: string; displayName: string;}}>} + */ +async function getRoblox( + input: string | number +): Promise<{ success: false; error: string } | { success: true; user: RobloxUserData }> { + if (!isNaN(input as number)) { + // If input is a number, fetch the user from Roblox API + const user = await fetch(`https://users.roblox.com/v1/users/${input}`).then((r: Response) => r.json()); + + // If the user is not found, return an error + if (user.errors) return { success: false, error: `Interpreted ${input} as user ID but found no user` }; + // Return the user + return { success: true, user }; + } else { + const user = await fetch('https://users.roblox.com/v1/usernames/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ usernames: [input] }) + }) + .then((r: Response) => r.json()) + .then((r: { data: RobloxUserData[] }) => r.data[0]); + + // If the user is not found, return an error + if (!user) return { success: false, error: `Interpreted ${input} as username but found no user` }; + // Return the user + return { success: true, user }; + } +} + +function getEnumKey(enumObj: any, value: number): string | undefined { + for (const key in enumObj) { + if (enumObj.hasOwnProperty(key) && enumObj[key] === (value as number)) { + return key; + } + } + return undefined; +} + +async function paginationRow( + interaction: Exclude, + buttonRows: ButtonBuilder[][], + args: InteractionEditReplyOptions, + embeds?: EmbedBuilder[] +): Promise { + // Create the row + const paginationRow: ActionRowBuilder = new ActionRowBuilder({ + components: [ + new ButtonBuilder({ customId: 'prev', style: ButtonStyle.Primary, emoji: '⬅️' }), + new ButtonBuilder({ customId: 'cancel', style: ButtonStyle.Danger, emoji: '🟥' }), + new ButtonBuilder({ customId: 'next', style: ButtonStyle.Primary, emoji: '➡️' }) + ] + }); + // Pair the embed with the buttons + const rows: [ActionRowBuilder, EmbedBuilder?][] = buttonRows.map((r, i) => { + // Create the row + const row: ActionRowBuilder = new ActionRowBuilder({ components: r }); + // If no embeds exist, just return the row + if (!embeds) return [row]; + // Else, return the row and the embed + else return [row, embeds[i]]; + }); + // Configure message + if (rows.length === 0 || (embeds && embeds.length !== rows.length)) return Promise.reject('No rows were provided'); + let index = 0, + returnedInteraction; + if (embeds && embeds.length > 0) args.embeds = [rows[index][1]]; + while (typeof returnedInteraction === 'undefined') { + // Create message + const coll = await interaction + // Edit the reply + .editReply({ + content: args.content || 'Please select an option below', + embeds: args.embeds || undefined, + components: [rows[index][0], paginationRow] + }) + // Add listener + .then((m) => + m.awaitMessageComponent({ + time: 15_000, + filter: (i) => i.user.id === interaction.user.id, + componentType: ComponentType.Button + }) + ) + // Handle no response + .catch((e) => e); + // Check the custom id + if (coll instanceof Error && coll.name === 'Error [InteractionCollectorError]') { + returnedInteraction = null; // Timeout + break; + } else if (coll instanceof Error) { + throw coll; // Not an error we can handle + } + // Drop the update + await coll.update({}); + // If it's anything other than + // next or prev, return it + if (!/next|prev/.test(coll.customId)) { + // Return the interaction + returnedInteraction = coll; + break; + } + // Configure index + if (coll.customId === 'next') { + if (index === rows.length - 1) index = 0; + else index++; + } else { + if (index === 0) index = rows.length - 1; + else index--; + } + // Configure message + if (embeds && embeds.length > 0) args.embeds = [rows[index][1]]; + else args.embeds = []; + args.components = [rows[index][0], paginationRow]; + // And the loop continues... + } + // Remove embeds and components + await interaction.editReply({ content: args.content || 'Please select an option below', embeds: [], components: [] }); + return Promise.resolve(returnedInteraction); +} +//#endregion + +export { + IRFGameId, + ResultMessage, + ResultType, + getEnumKey, + getGroup, + getRoblox, + getRowifi, + interactionEmbed, + paginationRow, + parseTime, + toConsole +}; diff --git a/src/functions/ready.ts b/src/functions/ready.ts new file mode 100644 index 0000000..d2458c3 --- /dev/null +++ b/src/functions/ready.ts @@ -0,0 +1,151 @@ +import { ActivityType, GuildMember, RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js'; +import fs from 'node:fs'; +import { Op } from 'sequelize'; +import { default as config } from '../config.json' assert { type: 'json' }; +import { IRFGameId, toConsole } from '../functions.js'; +import { bans } from '../models/bans.js'; +import { CommandFile, CustomClient } from '../typings/Extensions.js'; + +export default async function (client: CustomClient, ready: boolean): Promise { + console.info('[READY] Client is ready'); + console.info(`[READY] Logged in as ${client.user!.tag} (${client.user!.id}) at ${new Date()}`); + toConsole( + `[READY] Logged in as ${client.user?.tag} (${client.user!.id}) at and **${ + ready ? 'can' : 'cannot' + }** receive commands`, + new Error().stack!, + client + ); + client.user!.setActivity('users of the IRF', { type: ActivityType.Listening }); + + // Create directory if doesn't exist, then read all files + if (!fs.existsSync('./commands')) { + console.error('[CMD] No command file detected'); + return Promise.reject(); + } + const commands = fs.readdirSync('./commands').filter((file) => file.endsWith('.js')); + const slashCommands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + for (const file of commands) { + try { + // Load the command + const command: CommandFile = await import(`../commands/${file}`); + // Set command & push data + client.commands!.set(command.name, command); + slashCommands.push(command.data!.toJSON()); + console.info(`[CMD-LOAD] Loaded command ${command.name}`); + } catch (err) { + // Log error + console.error(`[CMD-LOAD] Failed to load command ${file}: ${err}`); + } + } + try { + client.application!.commands.set(slashCommands); + } catch (err) { + console.error(`[CMD-LOAD] Failed to load commands: ${err}`); + } + + setInterval(async () => { + if (!ready) return; + client.channels.cache.get(config.channels.ban) || (await client.channels.fetch(config.channels.ban)); + client.guilds.cache.get(config.discord.mainServer) || (await client.guilds.fetch(config.discord.mainServer)); + const parseBans: bans[] = await bans.findAll({ where: { reason: { [Op.like]: '%___irf' } } }); + for (const ban of parseBans) { + // Extract data from ban + let rblxUsername: string = ban.mod.discord; + // Fetch the victim's data from Roblox + const victim: { id: number; name: string; displayName: string } = await fetch( + `https://users.roblox.com/v1/users/${ban.user}` + ).then((r) => r.json()); + // Add moderator placeholder data + let moderator: { id: number; name: string; displayName: string } = { + id: 0, + name: 'Unknown', + displayName: 'Unknown' + }; + // Rewrite if FairPlay + if (ban.mod.discord.includes('FairPlay')) rblxUsername = 'FairPlay_AntiCheat'; + // Fetch from Roblox + moderator = await fetch('https://users.roblox.com/v1/usernames/users', { + method: 'POST', + body: JSON.stringify({ usernames: [rblxUsername] }), + headers: { 'Content-Type': 'application/json' } + }) + .then((r: Response) => r.json()) + .then((r: { data: (typeof moderator)[] }) => r.data[0]); + // Fetch Discord information + let discord: GuildMember | { user: { username: string; id: string }; nickname: string } | undefined; + if (moderator.id !== 0) { + // Attempt #1: Query via Discord + discord = ( + await client.guilds.cache.get(config.discord.mainServer)!.members.search({ query: moderator.name, limit: 1 }) + ).first(); + if (!discord) { + // Attempt #2: Query via RoWifi + const rowifiData = await fetch( + `https://api.rowifi.xyz/v2/guilds/${config.discord.mainServer}/members/roblox/${moderator.id}`, + { + headers: { Authorization: `Bot ${config.bot.rowifiApiKey}` } + } + ); + if (rowifiData.ok) { + const json = await rowifiData.json(); + discord = await client.guilds.cache.get(config.discord.mainServer)!.members.fetch(json[0].discord_id); + } + } + } + // Can't find them, use Roblox data + if (!discord) + discord = { + user: { + id: '0', + username: moderator.name + }, + nickname: moderator.displayName + }; + + // Get the game name + const gameName = IRFGameId[ban.game] || ban.game; + if (gameName === ban.game) + toConsole(`[BAN] Failed to find game name for \`${ban.game}\``, new Error().stack!, client); + // Check logging channel exists + const banLog = client.channels.cache.get(config.channels.ban); + if (!banLog || !banLog.isTextBased()) break; + // Send embed + banLog.send({ + embeds: [ + { + title: `${moderator.name} banned => ${victim.name} (In Game)`, + description: `**${discord.user.id}** has added a ban for ${victim.name} (${victim.id}) on ${gameName}`, + color: 0x00ff00, + fields: [ + { + name: 'Game', + value: String(gameName), + inline: true + }, + { + name: 'User', + value: `${victim.name} (${victim.id})`, + inline: true + }, + { + name: 'Reason', + value: ban.reason, + inline: true + } + ], + timestamp: ban.createdAt.toString() + } + ] + }); + // Update moderator data + await ban.update({ + mod: { + discord: discord.user.id, + roblox: moderator.id + } + }); + } + }, 20000); + return true; +} diff --git a/src/functions/tickets.ts b/src/functions/tickets.ts new file mode 100644 index 0000000..97d23a3 --- /dev/null +++ b/src/functions/tickets.ts @@ -0,0 +1,418 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChatInputCommandInteraction, + GuildMember, + Message, + ModalBuilder, + TextChannel, + TextInputBuilder, + TextInputStyle +} from 'discord.js'; +import { paginationRow } from '../functions.js'; +import { departments } from '../models/departments.js'; +import { divisions } from '../models/divisions.js'; +import { msgs } from '../models/msgs.js'; +import { tickets } from '../models/tickets.js'; +import { CustomClient } from '../typings/Extensions.js'; + +type ticketFunctionArgs = { + interaction: ButtonInteraction | ChatInputCommandInteraction; + ticket: tickets; + client: CustomClient; +}; +async function transferTicket({ interaction, ticket, client }: ticketFunctionArgs): Promise { + // Check ticket claimer + if (ticket.claimer !== interaction.user.id) { + await interaction.editReply({ content: 'You are not the ticket owner' }); + return Promise.reject('Not owner'); + } + if (ticket.status === 'Closed') { + await interaction.editReply({ content: 'This ticket is closed' }); + return Promise.reject('Closed'); + } + // Check if guild ID is the same as ticket's division + const div = await ticket.getDivision_division(); + if (div.contacts !== interaction.channel.id) { + await interaction.editReply({ content: 'This ticket is not with this department' }); + return Promise.reject('Not in guild'); + } + // Update ticket + ticket.status = 'Transferring'; + ticket.claimer = null; + // Find the new department it's being transferred to + const division = await getDivision({ interaction, ticket, client }).catch((e) => e); + if (division === 'Cancelled') division === null; + else if (typeof division !== 'object' || division instanceof Error) throw division; + if (!division) { + await interaction.editReply({ + content: "You didn't select a division to transfer the ticket to. This is required!" + }); + } else { + // Set division + ticket.division = division.divId; + await ticket.save(); + // Get the last message sent + const m = await msgs.findOne({ where: { tick: ticket.ticketId }, order: [['createdAt', 'DESC']] }); + // Get the division guild + const gCheck = client.guilds.cache.get(division.guildId); + if (!gCheck || !gCheck.available) { + await interaction.editReply({ content: 'The guild is not available' }); + return; + } + // Get the division channel + const cCheck = gCheck.channels.cache.get(division.contacts); + if (!cCheck || !cCheck.isTextBased()) { + await interaction.editReply({ content: 'The channel is not available' }); + return; + } + // Send message in the channel + const firstMsg = await client.models.msgs.findOne({ + where: { tick: ticket.ticketId }, + order: [['createdAt', 'ASC']] + }); + await cCheck.send({ + content: `[\`#${ticket.ticketId}\`] This ticket has been transferred to this division by its previous one. The last message sent is attached below. Click the button to claim the ticket!`, + embeds: [ + { title: 'Initial Report', description: firstMsg.content, color: 0xaae66e }, + { title: 'Last Message', description: m.content + `\n> ${interaction.user.toString()}`, color: 0xaae66e } + ], + components: [ + new ActionRowBuilder({ + components: [ + new ButtonBuilder({ + customId: `claim-${ticket.ticketId}`, + label: 'Claim Ticket', + style: ButtonStyle.Success + }) + ] + }) as ActionRowBuilder + ] + }); + client.users.fetch(ticket.author).then((u) => + u.send({ + content: `[\`#${ticket.ticketId}\`] Your ticket has been transferred to ${division.name} division. You can expect a response soon` + }) + ); + } + // Return (un)modified ticket + return ticket; +} +async function closeTicket({ interaction, ticket, client }: ticketFunctionArgs): Promise { + // Check ticket claimer + if (ticket.claimer !== interaction.user.id) { + await interaction.editReply({ content: 'You are not the ticket owner' }); + return Promise.reject('Not owner'); + } + if (ticket.status === 'Closed') { + await interaction.editReply({ content: 'This ticket is already closed' }); + (interaction as ButtonInteraction).message.edit({ components: [] }); + return Promise.reject('Closed'); + } + if (ticket.status !== 'Open') { + await interaction.editReply({ content: 'This ticket is not open. Please wait for transfers to finish' }); + (interaction as ButtonInteraction).message.edit({ components: [] }); + return Promise.reject('Not open'); + } + // Check if guild ID is the same as ticket's division + const div = await ticket.getDivision_division(); + if (div.contacts !== interaction.channel.id) { + await interaction.editReply({ content: 'This ticket is not with this department' }); + return Promise.reject('Not in guild'); + } + // Update the ticket + ticket.status = 'Closed'; + ticket.claimer = null; + await ticket.save(); + // Fetch all buttons + const msgs = await ticket.getMsgs(); + const mp = []; + for (const m of msgs) { + // Extract the channel ID and message ID + const [, , , , , cId, mId] = m.link.split('/'); + mp.push((client.channels.cache.get(cId) as TextChannel).messages.fetch(mId)); + } + const messages = (await Promise.all(mp)).filter((m) => m !== undefined); + // Edit the messages + const ep = []; + for (const m of messages) { + ep.push(m.edit({ components: [] })); + } + await Promise.all(ep); + // Inform the user + await interaction.editReply({ content: 'This ticket has been closed' }); + await client.users.fetch(ticket.author).then((u) => + u.send({ + content: `[\`#${ticket.ticketId}\`] Your ticket has been closed. If you have any further issues, please open a new ticket.` + }) + ); +} +async function replyTicket({ interaction, ticket, client }: ticketFunctionArgs): Promise { + const tId = (interaction as ButtonInteraction).customId.split('-').slice(1).join('-'); + // Build the modal + const modal = new ModalBuilder().setTitle('Reply to Ticket').setCustomId(`reply-${tId}`); + modal.setComponents( + new ActionRowBuilder({ + components: [ + new TextInputBuilder({ + customId: 'content', + label: 'Enter your message to the user', + minLength: 10, + maxLength: 2000, + style: TextInputStyle.Paragraph + }) + ] + }) + ); + // Send the modal + await interaction.showModal(modal); + const i = await interaction.awaitModalSubmit({ filter: (mi) => mi.user.id === interaction.user.id, time: 60_000 }); + if (!i) { + await interaction.editReply({ content: 'You did not reply in time' }); + return Promise.reject('Cancelled'); + } + await i.deferReply({ ephemeral: true }); + // Fetch the content + const content = i.fields.getTextInputValue('content'); + if (!content) { + await interaction.editReply({ content: 'You did not reply with a message to the user' }); + return Promise.reject('Cancelled'); + } + // Get ticket + ticket = await client.models.tickets.findOne({ where: { ticketId: tId } }); + if (!ticket) { + await i.editReply({ content: 'This ticket does not exist' }); + (interaction as ButtonInteraction).message.edit({ components: [] }); + return Promise.reject('Does not exist'); + } + // Check ticket + if (ticket.claimer !== interaction.user.id && ticket.author !== interaction.user.id) { + await i.editReply({ content: 'You are not the ticket owner' }); + return Promise.reject('Not owner'); + } + if (ticket.status !== 'Open') { + await i.editReply({ + content: `This ticket cannot be interacted with. Please ${ + ticket.status === 'Closed' ? 'open a new ticket' : 'wait for transfers to complete' + }` + }); + (interaction as ButtonInteraction).message.edit({ components: [] }); + return Promise.reject('Not open'); + } + // Check if guild ID is the same as ticket's division + const div = await ticket.getDivision_division(); + if (interaction.inGuild() && div.contacts !== interaction.channel.id) { + await interaction.editReply({ content: 'This ticket is not with this department' }); + return Promise.reject('Not in guild'); + } + // Get the contact channel + const contactChannel = client.channels.cache.get((await ticket.getDivision_division()).contacts); + if (!contactChannel || !contactChannel.isTextBased()) { + await i.editReply({ content: 'The contact channel is not available' }); + return Promise.reject('Unavailable'); + } + // Send the message + let m: Message; + if (!interaction.inGuild()) { + m = await contactChannel.send({ + content: `A reply has been received! Ticket is claimed by <@${ticket.claimer}>`, + embeds: [{ description: content + `\n> ${(interaction as ButtonInteraction).user.toString()}`, color: 0xaae66e }], + components: [ + new ActionRowBuilder({ + components: [ + new ButtonBuilder({ customId: `reply-${ticket.ticketId}`, label: 'Reply', style: ButtonStyle.Primary }), + new ButtonBuilder({ + customId: `transfer-${ticket.ticketId}`, + label: 'Transfer', + style: ButtonStyle.Secondary + }), + new ButtonBuilder({ customId: `close-${ticket.ticketId}`, label: 'Close', style: ButtonStyle.Danger }), + new ButtonBuilder({ customId: `unclaim-${ticket.ticketId}`, label: 'Unclaim', style: ButtonStyle.Danger }) + ] + }) as ActionRowBuilder + ], + target: (interaction as ButtonInteraction).message + }); + } else { + m = await client.users.fetch(ticket.author).then((u) => + u.send({ + content: `[\`#${ticket.ticketId}\`] Your ticket has been replied to. You can view the reply by clicking below`, + embeds: [{ description: content + `\n> ${interaction.user.toString()}`, color: 0xaae66e }], + components: [ + new ActionRowBuilder({ + components: [ + new ButtonBuilder({ + customId: `reply-${ticket.ticketId}`, + label: 'Reply', + style: ButtonStyle.Success + }) + ] + }) as ActionRowBuilder + ], + target: (interaction as ButtonInteraction).message + }) + ); + } + // Insert message into the database + await msgs + .create({ tick: ticket.ticketId, content, author: interaction.user.id, link: m.url }) + .then((m) => ticket.addMsg(m)); + await i.editReply({ content: 'Your reply has been sent' }); + // Return the ticket + return ticket; +} +async function claimTicket({ interaction, ticket, client }: ticketFunctionArgs): Promise { + // Check ticket + if (ticket.claimer) { + await interaction.editReply({ content: 'This ticket is already claimed' }); + return Promise.reject('Already claimed'); + } + if (ticket.status === 'Closed') { + await interaction.editReply({ content: 'This ticket is closed' }); + return Promise.reject('Closed'); + } + // Check if channel ID is the same as ticket's division + const div = await ticket.getDivision_division(); + if (div.contacts !== interaction.channel.id) { + await interaction.editReply({ content: 'This ticket is not with this department' }); + return Promise.reject('Not in guild'); + } + // Mark the ticket as claimed + ticket.claimer = interaction.user.id; + ticket.status = 'Open'; + await ticket.save(); + // Inform the user + await interaction.editReply({ content: `Ticket claimed successfully!` }); + await client.users.fetch(ticket.author).then((u) => + u.send({ + content: `[\`#${ticket.ticketId}\`] Your ticket has been claimed by ${ + (interaction.member as GuildMember).nickname || interaction.user.username + } (${interaction.user.toString()}). You can expect a response shortly!` + }) + ); + await (interaction as ButtonInteraction).message.edit({ + content: `Ticket claimed by ${interaction.user.toString()}`, + components: [ + new ActionRowBuilder({ + components: [ + new ButtonBuilder({ customId: `reply-${ticket.ticketId}`, label: 'Reply', style: ButtonStyle.Primary }), + new ButtonBuilder({ + customId: `transfer-${ticket.ticketId}`, + label: 'Transfer', + style: ButtonStyle.Secondary + }), + new ButtonBuilder({ customId: `close-${ticket.ticketId}`, label: 'Close', style: ButtonStyle.Danger }), + new ButtonBuilder({ customId: `unclaim-${ticket.ticketId}`, label: 'Unclaim', style: ButtonStyle.Danger }) + ] + }) as ActionRowBuilder + ] + }); + // Return the ticket + return ticket; +} +async function unclaimTicket({ interaction, ticket, client }: ticketFunctionArgs): Promise { + // Check ticket + if (!ticket.claimer) { + await interaction.editReply({ content: 'This ticket is not claimed' }); + return Promise.reject('Not claimed'); + } + if (ticket.claimer !== interaction.user.id) { + await interaction.editReply({ content: 'You are not the ticket owner' }); + (interaction as ButtonInteraction).message.edit({ components: [] }); + return Promise.reject('Not owner'); + } + if (ticket.status === 'Closed') { + await interaction.editReply({ content: 'This ticket is closed' }); + (interaction as ButtonInteraction).message.edit({ components: [] }); + return Promise.reject('Closed'); + } + // Check if guild ID is the same as ticket's division + const div = await ticket.getDivision_division(); + if (div.contacts !== interaction.channel.id) { + await interaction.editReply({ content: 'This ticket is not with this department' }); + (interaction as ButtonInteraction).message.edit({ components: [] }); + return Promise.reject('Not in guild'); + } + // Mark the ticket as unclaimed + ticket.claimer = null; + await ticket.save(); + // Inform the user + await interaction.editReply({ content: 'You have unclaimed this ticket' }); + await client.users.fetch(ticket.author).then((u) => + u.send({ + content: `[\`#${ticket.ticketId}\`] Your ticket has been released. A new member will be assisting you soon!` + }) + ); + // Return the ticket + return ticket; +} +async function getDivision({ interaction, ticket, client }: ticketFunctionArgs): Promise { + // Fetch all divisions and departments + // We use Promise.all for shorthanding and running the requests asynchronously + const [dep, div] = await Promise.all([ + departments.findAll({ paranoid: false }), + divisions.findAll({ paranoid: false }) + ]); + // Split the divisions and departments + const deps = dep.filter((d) => d.department === null); + // Create variables for use later + const depEmbeds = [], + divEmbeds = []; + const depButtons = [], + divButtons = []; + // Create buttons for each department + for (const d of deps) { + depEmbeds.push({ + title: d.name, + description: 'Click the relevant division for your report' + }); + depButtons.push( + dep + .filter((dp) => dp.department === d.name) + .map((d) => + new ButtonBuilder() + .setCustomId(d.name) + .setLabel(d.name) + .setStyle(ButtonStyle.Primary) + .setEmoji(d.emoji) + .setDisabled(d.isSoftDeleted()) + ) + ); + } + // Get the requested division + const divInteraction = await paginationRow(interaction, depButtons, {}, depEmbeds); + if (!divInteraction || divInteraction.customId === 'cancel') { + interaction.editReply({ content: 'Cancelled' }); + return Promise.reject('Cancelled'); + } + /** @desc Division from departments */ + const division = dep.find((d) => d.name === divInteraction.customId); + // Get the requested sub division, if any + for (const s of div.filter((d) => d.division === division.name)) { + // Push the embed + divEmbeds.push({ + title: s.name, + description: 'Select the relevant sub-division using the arrows' + }); + // Push the button + // We use an embed since this isn't being mapped + divButtons.push([ + new ButtonBuilder() + .setCustomId(String(s.divId)) + .setLabel(s.name) + .setStyle(ButtonStyle.Primary) + .setEmoji(s.emoji) + .setDisabled(s.isSoftDeleted()) + ]); + } + const sdInteraction = await paginationRow(interaction, divButtons, {}, divEmbeds); + if (!sdInteraction || sdInteraction.customId === 'cancel') { + interaction.editReply({ content: 'Cancelled' }); + return Promise.reject('Cancelled'); + } + return div.filter((d) => String(d.divId) === sdInteraction.customId)[0]; +} + +export { claimTicket, closeTicket, getDivision, replyTicket, transferTicket, unclaimTicket }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b122e1e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,378 @@ +import { ButtonInteraction, Client, Collection, IntentsBitField, InteractionType, Message } from 'discord.js'; +import * as fs from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { Sequelize } from 'sequelize'; +import { default as config } from './config.json' assert { type: 'json' }; +import { IRFGameId, interactionEmbed, toConsole } from './functions.js'; +import { default as readyHandler } from './functions/ready.js'; +import { claimTicket, closeTicket, replyTicket, transferTicket, unclaimTicket } from './functions/tickets.js'; +import { initModels, tickets } from './models/init-models.js'; +import { CustomClient, ServerList } from './typings/Extensions.js'; +const wait = promisify(setTimeout); +let ready = false; + +//#region Setup +//#region Database +const sequelize = new Sequelize(config.mysql.database, config.mysql.user, config.mysql.password, { + host: config.mysql.host, + dialect: 'mysql', + logging: process.env.environment === 'development' ? console.log : false, + port: config.mysql.port +}); +// Check for existing models folder +if (!fs.existsSync(join(dirname(fileURLToPath(import.meta.url)), 'models'))) console.warn('[SQL] No models detected'); +// Load database models +const file = await import('./models/init-models.js'); +try { + file.initModels(sequelize); + sequelize.authenticate(); + sequelize.sync({ alter: process.env.environment === 'development' }); +} catch (e) { + console.error(`[SQL] ${e}`); +} +//#endregion +//#region Discord bot +const client: CustomClient = new Client({ + intents: [ + IntentsBitField.Flags.Guilds, + IntentsBitField.Flags.GuildMessages, + IntentsBitField.Flags.GuildMembers, + IntentsBitField.Flags.MessageContent + ] +}); +client.sequelize = sequelize; +client.models = initModels(sequelize); +client.commands = new Collection(); +//#endregion +//#endregion + +//#region Events +client.on('ready', async () => { + ready = await readyHandler(client, ready).catch((e) => { + console.error(e); + return false; + }); +}); + +client.on('interactionCreate', async (interaction): Promise => { + if (!ready && interaction.isRepliable()) + return interaction + .reply({ content: 'Please wait for the bot to finish loading', ephemeral: true }) + .then(() => void Promise); // Hacky method to return void promise + + if (interaction.type === InteractionType.ApplicationCommand) { + let command = client.commands!.get(interaction.commandName); + if (command) { + // If the command is not a modal, defer reply and fetch user + if (!command.modal) { + await interaction.deferReply({ ephemeral: command.ephemeral }); + // Can't Promise.all(...), deferReply must be first + await interaction.user.fetch(); + } + const ack = command.run(client, interaction, interaction.options).catch((e) => { + interaction.editReply({ + content: 'Something went wrong. Please contact an Engineer', + embeds: [] + }); + return toConsole(e.stack || e, new Error().stack!, client); + }); + + // Wait for 10 seconds, if the command hasn't been executed, send a timeout message + await wait(10_000); + if (ack != null) return; // Already executed + interaction.fetchReply().then((m) => { + // If the message is empty and there are no embeds, it's a timeout + if (m.content === '' && m.embeds.length === 0) + interactionEmbed(3, 'The command timed out. Please contact an Engineer', interaction); + }); + } + } else if (interaction.type === InteractionType.ApplicationCommandAutocomplete) { + switch (interaction.commandName) { + case 'ban': { + // If command is ban, offer a list of common reasons + const value = interaction.options.getString('reason'); + const commonReasons = [ + // ROBLOX TOS // + { name: 'TOS - Chat bypass', value: 'Roblox TOS - Bypassing chat filter' }, + { name: 'TOS - Clothes bypass', value: 'Roblox TOS - Bypassed clothing' }, + { name: 'TOS - Username bypass', value: 'Roblox TOS - Bypassed username' }, + { name: 'TOS - Nudity', value: 'Roblox TOS - Nudity' }, + { name: 'TOS - Exploit', value: 'Roblox TOS - Exploiting' }, + { name: 'TOS - Impersonation', value: 'Roblox TOS - Impersonation' }, + { name: 'TOS - Racism', value: 'Roblox TOS - Racism' }, + { name: 'TOS - Nazism', value: 'Roblox TOS - Nazism' }, + { name: 'TOS - NSFW', value: 'Roblox TOS - NSFW content or actions (PDA included)' }, + // TBAN // + { name: 'TBan - Evasion', value: 'Temp Ban - Evasion of moderation action' }, + { name: 'TBan - Nudity', value: 'Temp Ban - Nudity' }, + { name: 'TBan - NSFW', value: 'Temp Ban - NSFW content or actions (PDA included)' }, + { name: 'TBan - Spamming', value: 'Temp Ban - Spamming' }, + { name: 'TBan - SS Insignia', value: 'Temp Ban - SS Insignia' }, + { name: 'TBan - Chat bypass', value: 'Temp Ban - Bypassing chat filter' }, + // GAME RULES // + { name: 'Rules - Glitching', value: 'Game Rules - Glitching' }, + { name: 'Rules - RK', value: 'Game Rules - Mass random killing (RK)' }, + // RULES // + { name: 'Rules - Ban Bypass (Alt)', value: 'Rules - Bypassing ban using alternative account' }, + { name: 'Rules - DDoS Attack', value: 'Rules - Attempting or causing a Distributed Denial of Service attack' } + ]; + // If no value, return the list of common reasons + if (!value) return interaction.respond(commonReasons); + // If value, filter the list of common reasons + const matches = commonReasons.filter((r) => r.value.toLowerCase().includes(value.toLowerCase())); + // If no matches, return the value the user entered + if (matches.length === 0 && value.length <= 100) + return interaction.respond([{ name: value.length > 25 ? value.slice(0, 22) + '...' : value, value: value }]); + if (value.length > 100) return; // Timeout, too long value + return interaction.respond(matches); + } + case 'request': { + // If command is request, offer a list of common reasons + const value = interaction.options.getString('reason'); + const reasons = [ + // DIVISIONS // + { name: 'GA - Random killing', value: 'User is mass random killing' }, + { name: 'MP - Military Law', value: 'User is violating Military Law' }, + { name: 'FSS - Bolshevik Law', value: 'User is violating Bolshevik Law' }, + { name: 'MoA - No admissions', value: 'There is no Admissions in the server' }, + { name: 'MoA - Gamepass Admissions abuse', value: 'Admissions is abusing their powers (Gamepass)' }, + // RAIDS // + { name: 'Immigrant Raid', value: 'Immigrant(s) are raiding against Military personnel' }, + { + name: 'Small Raid (1-7 raiders)', + value: 'There is chaos at the border and we are struggling to maintain control (1-7 raiders)' + }, + { + name: 'Big Raid (8+ raiders)', + value: 'There is chaos at the border and we are struggling to maintain control (8+ raiders)' + }, + { name: 'Exploiter', value: 'A user is exploiting' }, + // AUTHORITY // + { name: 'Higher authority needed (Kick)', value: 'Need someone to kick a user' }, + { name: 'Higher authority needed (Server Ban)', value: 'Need someone to server ban a user' }, + { name: 'Higher authority needed (Temp/Perm Ban)', value: 'Need someone to temp/perm ban a user' }, + // BACKUP // + { name: 'General backup', value: 'Control has been lost, general backup is needed' }, + { name: 'DDoS Attack', value: 'There is a DDoS attack on the server' } + ]; + // If no value, return the list of common reasons + if (!value) return interaction.respond(reasons); + // If value, filter the list of common reasons + const matches = reasons.filter((r) => r.value.toLowerCase().includes(value.toLowerCase())); + // If no matches, return the value the user entered + if (matches.length === 0 && value.length <= 100) + return interaction.respond([{ name: value.length > 25 ? value.slice(0, 22) + '...' : value, value: value }]); + if (value.length > 100) return; // Timeout, too long value + return interaction.respond(matches); + } + case 'shutdown': { + // If the command is shutdown, offer the list of active servers + let { name, value = 'Papers' } = interaction.options.getFocused(true); + if (name !== 'target') return; // Not focused on the server list + const servers: { success: boolean; servers: ServerList } = await fetch(config.urls.servers).then( + (r: Response) => r.json() + ); + if (!servers.success) return interaction.respond([]); + const matches: { name: string; value: string }[] = []; + const idMap = new Map(); + let matchedGame = 0; + // Loop through game IDs and find the ID from the name + for (const name of Object.keys(IRFGameId)) { + // Push to map + idMap.set(IRFGameId[name], name); + // If we have a partial match, set matchedGame to the ID + if (name.toLowerCase().includes(value.toLowerCase())) matchedGame = Number(IRFGameId[name]); + } + // Push all servers with the game ID in matchedGame to matches + for (const [placeId, jobs] of Object.entries(servers.servers)) { + if (Number(placeId) == matchedGame) { + for (const [jobId, [players, _date]] of Object.entries(jobs)) { + // RTT if we don't know the name + matches.push({ name: `${jobId} - ${idMap.get(placeId) || 'RTT'} (${players.length})`, value: jobId }); + } + } + } + matches.unshift({ name: 'All servers - DANGEROUS (*)', value: '*' }); + return interaction.respond(matches); + } + default: { + return interaction.respond([]); // Invalid commandName + } + } + } else if (interaction.type === InteractionType.MessageComponent) { + // If not a button or a ticket handler, return + if (!interaction.isButton()) return; + if (!/(?:reply|transfer|close|claim)\-[\w\-]{36}/.test(interaction.customId)) return; + // Get the ticket + let ticket: tickets = null; + if (interaction.customId.split('-')[0] !== 'reply') { + // If not a reply, defer reply and fetch ticket + await interaction.deferReply({ ephemeral: true }); + ticket = await client.models!.tickets.findByPk(interaction.customId.split('-').slice(1).join('-')); + if (!ticket) { + // Run both edits at the same time + await Promise.all([ + interaction.editReply({ content: 'This ticket does not exist' }), + (interaction as ButtonInteraction).message.edit({ + content: 'This ticket does not exist. It may have been manually removed by a developer!', + components: [] + }) + ]); + return; + } + } + // Send to handler + switch (interaction.customId.split('-')[0]) { + case 'close': { + await closeTicket({ interaction, client, ticket }); + break; + } + case 'claim': { + await claimTicket({ interaction, client, ticket }); + break; + } + case 'unclaim': { + await unclaimTicket({ interaction, client, ticket }); + break; + } + case 'transfer': { + await transferTicket({ interaction, client, ticket }); + break; + } + case 'reply': { + await replyTicket({ interaction, client, ticket }); + break; + } + default: { + if (!interaction.replied) + interaction.reply({ + content: `You shouldn't see this! Contact an Engineer with this: ${interaction.customId.split('-')[0]}` + }); + else + interaction.editReply({ + content: `You shouldn't see this! Contact an Engineer with this: ${interaction.customId.split('-')[0]}` + }); + } + } + return; + } +}); + +client.on('messageCreate', async (message): Promise => { + if (message.guild!.id != config.discord.mainServer || message.channel.isDMBased()) return; + if (message.author.bot) return; + if (!message.channel.name.includes('reports')) return; + // Message handler + const denied = '<:denied:1095481555431997460>'; + let refMessage: Message; + // If the message is a reply and the content matches a key string, check the referenced message + if (message.reference && message.content === 'CHECK_IA_VIOLATIONS') { + // Set the message to delete after 5 seconds + setTimeout(() => message.delete(), 5000); + refMessage = await message.channel.messages.fetch(message.reference.messageId!); + // If the refMessage is from a bot, ignore it + if (refMessage.author.bot) return; + } else { + refMessage = message; + } + // Test the message against the regex + if (refMessage.content.startsWith('<:')) return; // Ignore GA accept/denial messages + const msgContentRegex = + /^Suspect: (?[\w\-]+)\nSuspect Roblox ID: (?[\d]+)\nReason: (?[ -~]+)(?:\nProof:\n(?[\S\n]*))?/; + const result = msgContentRegex.exec(refMessage.content); + async function deny(msg) { + await refMessage.reactions.removeAll(); + refMessage.react(denied); + refMessage.reply({ embeds: [{ color: 0xff0000, description: `${denied} | **Denied**. ${msg}` }] }); + } + if (!result || !result.groups) { + await deny('Your report does not follow the format. Please check the pinned messages for the correct format'); + return; + } + const matches = result.groups; + // Check if proof is present + if (!matches.proof && refMessage.attachments.size === 0) { + await deny( + 'Your report does not contain proof. In order to properly process bans, we must have clear evidence of the crime' + ); + return; + } + // Test links + const links = matches.proof ? matches.proof.split('\n') : []; + if ( + !links.every((l) => + /^https:\/\/(?:medal\.tv\/games\/roblox\/clips\/[\w]+\/[\w]+|youtube\.com\/watch\?v=[\w\-]+|youtu\.be\/[\w\-]+|gyazo\.com\/[\w]+|cdn\.discordapp\.com\/attachments\/[\d]{17,20}\/[\d]{17,20}\/[\w\-]+\.[a-z4]+)/.test( + l + ) + ) + ) { + await deny('Your proof does not contain valid links. Please check the pinned messages for the valid sources'); + return; + } + // Add reactions + refMessage.react('⚙️'); + return; +}); +//#endregion + +client.login(config.bot.token); + +//#region Error handling +const recentErrors: { promise: Promise; reason: string; time: Date }[] = []; +process.on('uncaughtException', (err, origin) => { + toConsole(`Uncaught exception: ${err}\n` + `Exception origin: ${origin}`, new Error().stack, client); +}); +process.on('unhandledRejection', async (reason, promise) => { + if (!ready) { + console.warn('Exiting due to a [unhandledRejection] during start up'); + console.error(reason, promise); + return process.exit(15); + } + // Anti-spam System + if (recentErrors.length > 2) { + recentErrors.push({ promise, reason: String(reason), time: new Date() }); + recentErrors.shift(); + } else { + recentErrors.push({ promise, reason: String(reason), time: new Date() }); + } + // If all three errors are the same, exit + if ( + recentErrors.length === 3 && + recentErrors[0].reason === recentErrors[1].reason && + recentErrors[1].reason === recentErrors[2].reason + ) { + // Write the error to a file + fs.writeFileSync( + './latest-error.log', + JSON.stringify( + { + code: 15, + info: { + source: 'Anti spam triggered! Three errors with the same content have occurred recently', + r: String(promise) + ' <------------> ' + reason + }, + time: new Date().toString() + }, + null, + 2 + ) + ); + return process.exit(17); + } + + toConsole('An [unhandledRejection] has occurred.\n\n> ' + reason, new Error().stack!, client); +}); +process.on('warning', async (warning) => { + if (!ready) { + console.warn('[warning] has occurred during start up'); + console.warn(warning); + } + toConsole(`A [warning] has occurred.\n\n> ${warning}`, new Error().stack!, client); +}); +process.on('exit', (code) => { + console.error('[EXIT] The process is exiting!'); + console.error(`[EXIT] Code: ${code}`); +}); +//#endregion diff --git a/src/models/bans.ts b/src/models/bans.ts new file mode 100644 index 0000000..a13e1e7 --- /dev/null +++ b/src/models/bans.ts @@ -0,0 +1,92 @@ +import * as Sequelize from 'sequelize'; +import { DataTypes, Model, Optional } from 'sequelize'; + +export interface bansAttributes { + banId: string; + user: number; + game: number; + mod: bansModData; + data: bansData; + reason: string; + unbanReason?: string; +} + +export type bansPk = 'banId'; +export type bansId = bans[bansPk]; +export type bansOptionalAttributes = bansPk | 'unbanReason'; +export type bansCreationAttributes = Optional; +export type bansModData = { + roblox: number; + discord: string; +}; +export type bansData = { + privacy: 'Public' | 'Restricted' | 'Private' | 'Database'; + proof: string; +}; + +export class bans extends Model implements bansAttributes { + declare banId: string; + declare user: number; + declare game: number; + declare mod: bansModData; + declare data: bansData; + declare reason: string; + declare unbanReason?: string; + declare createdAt: Date; + declare updatedAt: Date; + declare deletedAt?: Date; + + static initModel(sequelize: Sequelize.Sequelize): typeof bans { + return bans.init( + { + banId: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + comment: 'UUID V4', + defaultValue: DataTypes.UUIDV4 + }, + user: { + type: DataTypes.BIGINT, + allowNull: false + }, + game: { + type: DataTypes.BIGINT, + allowNull: false + }, + mod: { + type: DataTypes.JSON, + allowNull: false, + comment: '{ roblox: number, discord: string }' + }, + data: { + type: DataTypes.JSON, + allowNull: false, + comment: '{ privacy: string, proof: string }' + }, + reason: { + type: DataTypes.CHAR(255), + allowNull: false + }, + unbanReason: { + type: DataTypes.CHAR(255), + allowNull: true + } + }, + { + sequelize, + tableName: 'bans', + timestamps: true, + paranoid: true, + indexes: [ + { + name: 'PRIMARY', + unique: true, + using: 'BTREE', + fields: [{ name: 'banId' }] + } + ] + } + ); + } +} diff --git a/src/models/departments.ts b/src/models/departments.ts new file mode 100644 index 0000000..55b4c8c --- /dev/null +++ b/src/models/departments.ts @@ -0,0 +1,99 @@ +import * as Sequelize from 'sequelize'; +import { DataTypes, Model, Optional } from 'sequelize'; +import type { divisions, divisionsId } from './divisions.js'; + +export interface departmentsAttributes { + name: string; + department?: string; + guildId: string; + emoji: string; + contacts: string; +} + +export type departmentsPk = 'name'; +export type departmentsId = departments[departmentsPk]; +export type departmentsOptionalAttributes = 'department'; +export type departmentsCreationAttributes = Optional; + +export class departments + extends Model + implements departmentsAttributes +{ + declare name: string; + declare department?: string; + declare guildId: string; + declare emoji: string; + declare contacts: string; + declare createdAt: Date; + declare updatedAt: Date; + declare deletedAt?: Date; + + // departments belongsTo departments via department + declare department_department: departments; + declare getDepartment_department: Sequelize.BelongsToGetAssociationMixin; + declare setDepartment_department: Sequelize.BelongsToSetAssociationMixin; + declare createDepartment_department: Sequelize.BelongsToCreateAssociationMixin; + // departments hasMany divisions via division + declare divisions: divisions[]; + declare getDivisions: Sequelize.HasManyGetAssociationsMixin; + declare setDivisions: Sequelize.HasManySetAssociationsMixin; + declare addDivision: Sequelize.HasManyAddAssociationMixin; + declare addDivisions: Sequelize.HasManyAddAssociationsMixin; + declare createDivision: Sequelize.HasManyCreateAssociationMixin; + declare removeDivision: Sequelize.HasManyRemoveAssociationMixin; + declare removeDivisions: Sequelize.HasManyRemoveAssociationsMixin; + declare hasDivision: Sequelize.HasManyHasAssociationMixin; + declare hasDivisions: Sequelize.HasManyHasAssociationsMixin; + declare countDivisions: Sequelize.HasManyCountAssociationsMixin; + + static initModel(sequelize: Sequelize.Sequelize): typeof departments { + return departments.init( + { + name: { + type: DataTypes.STRING(50), + allowNull: false, + primaryKey: true + }, + department: { + type: DataTypes.STRING(50), + allowNull: true, + references: { + model: 'departments', + key: 'name' + } + }, + guildId: { + type: DataTypes.STRING(25), + allowNull: false + }, + emoji: { + type: DataTypes.CHAR(36), + allowNull: false + }, + contacts: { + type: DataTypes.CHAR(32), + allowNull: false + } + }, + { + sequelize, + tableName: 'departments', + timestamps: true, + paranoid: true, + indexes: [ + { + name: 'PRIMARY', + unique: true, + using: 'BTREE', + fields: [{ name: 'name' }] + }, + { + name: 'departments_fk_1', + using: 'BTREE', + fields: [{ name: 'department' }] + } + ] + } + ); + } +} diff --git a/src/models/divisions.ts b/src/models/divisions.ts new file mode 100644 index 0000000..1d56423 --- /dev/null +++ b/src/models/divisions.ts @@ -0,0 +1,117 @@ +import * as Sequelize from 'sequelize'; +import { DataTypes, Model, Optional } from 'sequelize'; +import type { departments, departmentsId } from './departments.js'; +import type { tickets, ticketsId } from '../models/tickets.js'; + +export interface divisionsAttributes { + divId: number; + name: string; + guildId: string; + division: string; + emoji: string; + contacts: string; +} + +export type divisionsPk = 'divId'; +export type divisionsId = divisions[divisionsPk]; +export type divisionsOptionalAttributes = 'divId'; +export type divisionsCreationAttributes = Optional; + +export class divisions extends Model implements divisionsAttributes { + declare divId: number; + declare name: string; + declare guildId: string; + declare division: string; + declare emoji: string; + declare contacts: string; + declare createdAt: Date; + declare updatedAt: Date; + declare deletedAt?: Date; + + // divisions belongsTo departments via division + declare division_department: departments; + declare getDivision_department: Sequelize.BelongsToGetAssociationMixin; + declare setDivision_department: Sequelize.BelongsToSetAssociationMixin; + declare createDivision_department: Sequelize.BelongsToCreateAssociationMixin; + // divisions hasMany tickets via division + declare tickets: tickets[]; + declare getTickets: Sequelize.HasManyGetAssociationsMixin; + declare setTickets: Sequelize.HasManySetAssociationsMixin; + declare addTicket: Sequelize.HasManyAddAssociationMixin; + declare addTickets: Sequelize.HasManyAddAssociationsMixin; + declare createTicket: Sequelize.HasManyCreateAssociationMixin; + declare removeTicket: Sequelize.HasManyRemoveAssociationMixin; + declare removeTickets: Sequelize.HasManyRemoveAssociationsMixin; + declare hasTicket: Sequelize.HasManyHasAssociationMixin; + declare hasTickets: Sequelize.HasManyHasAssociationsMixin; + declare countTickets: Sequelize.HasManyCountAssociationsMixin; + + static initModel(sequelize: Sequelize.Sequelize): typeof divisions { + return divisions.init( + { + divId: { + autoIncrement: true, + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true + }, + name: { + type: DataTypes.STRING(50), + allowNull: false + }, + guildId: { + type: DataTypes.STRING(25), + allowNull: false + }, + division: { + type: DataTypes.STRING(50), + allowNull: false, + references: { + model: 'departments', + key: 'name' + } + }, + emoji: { + type: DataTypes.CHAR(36), + allowNull: false + }, + contacts: { + type: DataTypes.CHAR(20), + allowNull: false, + unique: 'contacts_2' + } + }, + { + sequelize, + tableName: 'divisions', + timestamps: true, + paranoid: true, + indexes: [ + { + name: 'PRIMARY', + unique: true, + using: 'BTREE', + fields: [{ name: 'divId' }] + }, + { + name: 'contacts', + unique: true, + using: 'BTREE', + fields: [{ name: 'contacts' }] + }, + { + name: 'contacts_2', + unique: true, + using: 'BTREE', + fields: [{ name: 'contacts' }] + }, + { + name: 'divisions_fk_1', + using: 'BTREE', + fields: [{ name: 'division' }] + } + ] + } + ); + } +} diff --git a/src/models/init-models.ts b/src/models/init-models.ts new file mode 100644 index 0000000..558dc83 --- /dev/null +++ b/src/models/init-models.ts @@ -0,0 +1,51 @@ +import type { Sequelize } from 'sequelize'; +import { bans as _bans } from './bans.js'; +import type { bansAttributes, bansCreationAttributes } from './bans.js'; +import { departments as _departments } from './departments.js'; +import type { departmentsAttributes, departmentsCreationAttributes } from './departments.js'; +import { divisions as _divisions } from './divisions.js'; +import type { divisionsAttributes, divisionsCreationAttributes } from './divisions.js'; +import { msgs as _msgs } from './msgs.js'; +import type { msgsAttributes, msgsCreationAttributes } from './msgs.js'; +import { tickets as _tickets } from '../models/tickets.js'; +import type { ticketsAttributes, ticketsCreationAttributes } from '../models/tickets.js'; + +export { _bans as bans, _departments as departments, _divisions as divisions, _msgs as msgs, _tickets as tickets }; + +export type { + bansAttributes, + bansCreationAttributes, + departmentsAttributes, + departmentsCreationAttributes, + divisionsAttributes, + divisionsCreationAttributes, + msgsAttributes, + msgsCreationAttributes, + ticketsAttributes, + ticketsCreationAttributes +}; + +export function initModels(sequelize: Sequelize) { + const bans = _bans.initModel(sequelize); + const departments = _departments.initModel(sequelize); + const divisions = _divisions.initModel(sequelize); + const msgs = _msgs.initModel(sequelize); + const tickets = _tickets.initModel(sequelize); + + departments.belongsTo(departments, { as: 'department_department', foreignKey: 'department' }); + departments.hasMany(departments, { as: 'departments', foreignKey: 'department' }); + divisions.belongsTo(departments, { as: 'division_department', foreignKey: 'division' }); + departments.hasMany(divisions, { as: 'divisions', foreignKey: 'division' }); + tickets.belongsTo(divisions, { as: 'division_division', foreignKey: 'division' }); + divisions.hasMany(tickets, { as: 'tickets', foreignKey: 'division' }); + msgs.belongsTo(tickets, { as: 'tick_ticket', foreignKey: 'tick' }); + tickets.hasMany(msgs, { as: 'msgs', foreignKey: 'tick' }); + + return { + bans: bans, + departments: departments, + divisions: divisions, + msgs: msgs, + tickets: tickets + }; +} diff --git a/src/models/msgs.ts b/src/models/msgs.ts new file mode 100644 index 0000000..267dc09 --- /dev/null +++ b/src/models/msgs.ts @@ -0,0 +1,87 @@ +import * as Sequelize from 'sequelize'; +import { DataTypes, Model, Optional } from 'sequelize'; +import type { tickets, ticketsId } from './tickets.js'; + +export interface msgsAttributes { + mId: string; + author: string; + tick: string; + content: string; + link?: string; +} + +export type msgsPk = 'mId'; +export type msgsId = msgs[msgsPk]; +export type msgsOptionalAttributes = 'link'; +export type msgsCreationAttributes = Optional; + +export class msgs extends Model implements msgsAttributes { + declare mId: string; + declare author: string; + declare tick: string; + declare content: string; + declare createdAt: Date; + declare updatedAt: Date; + declare link?: string; + + // msgs belongsTo tickets via tick + declare tick_ticket: tickets; + declare getTick_ticket: Sequelize.BelongsToGetAssociationMixin; + declare setTick_ticket: Sequelize.BelongsToSetAssociationMixin; + declare createTick_ticket: Sequelize.BelongsToCreateAssociationMixin; + + static initModel(sequelize: Sequelize.Sequelize): typeof msgs { + return msgs.init( + { + mId: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + comment: 'UUID V4', + defaultValue: Sequelize.UUIDV4 + }, + author: { + type: DataTypes.CHAR(20), + allowNull: false, + comment: 'Snowflake' + }, + tick: { + type: DataTypes.UUID, + allowNull: false, + comment: 'Associated ticket', + references: { + model: 'tickets', + key: 'ticketId' + } + }, + content: { + type: DataTypes.STRING(2048), + allowNull: false + }, + link: { + type: DataTypes.CHAR(100), + allowNull: true, + comment: 'Snowflake' + } + }, + { + sequelize, + tableName: 'msgs', + timestamps: true, + indexes: [ + { + name: 'PRIMARY', + unique: true, + using: 'BTREE', + fields: [{ name: 'mId' }] + }, + { + name: 'messages_fk_1', + using: 'BTREE', + fields: [{ name: 'tick' }] + } + ] + } + ); + } +} diff --git a/src/models/tickets.ts b/src/models/tickets.ts new file mode 100644 index 0000000..afa6322 --- /dev/null +++ b/src/models/tickets.ts @@ -0,0 +1,100 @@ +import * as Sequelize from 'sequelize'; +import { DataTypes, Model, Optional } from 'sequelize'; +import type { divisions, divisionsId } from './divisions.js'; +import type { msgs, msgsId } from './msgs.js'; + +export interface ticketsAttributes { + ticketId: string; + status: 'Open' | 'Stale' | 'Closed' | 'Transferring'; + author: string; + division: number; + claimer?: string; +} + +export type ticketsPk = 'ticketId'; +export type ticketsId = tickets[ticketsPk]; +export type ticketsOptionalAttributes = 'ticketId' | 'status' | 'claimer'; +export type ticketsCreationAttributes = Optional; + +export class tickets extends Model implements ticketsAttributes { + declare ticketId: string; + declare status: 'Open' | 'Stale' | 'Closed' | 'Transferring'; + declare author: string; + declare division: number; + declare claimer?: string; + declare createdAt: Date; + declare updatedAt: Date; + + // tickets belongsTo divisions via division + declare division_division: divisions; + declare getDivision_division: Sequelize.BelongsToGetAssociationMixin; + declare setDivision_division: Sequelize.BelongsToSetAssociationMixin; + declare createDivision_division: Sequelize.BelongsToCreateAssociationMixin; + // tickets hasMany msgs via tick + declare msgs: msgs[]; + declare getMsgs: Sequelize.HasManyGetAssociationsMixin; + declare setMsgs: Sequelize.HasManySetAssociationsMixin; + declare addMsg: Sequelize.HasManyAddAssociationMixin; + declare addMsgs: Sequelize.HasManyAddAssociationsMixin; + declare createMsg: Sequelize.HasManyCreateAssociationMixin; + declare removeMsg: Sequelize.HasManyRemoveAssociationMixin; + declare removeMsgs: Sequelize.HasManyRemoveAssociationsMixin; + declare hasMsg: Sequelize.HasManyHasAssociationMixin; + declare hasMsgs: Sequelize.HasManyHasAssociationsMixin; + declare countMsgs: Sequelize.HasManyCountAssociationsMixin; + + static initModel(sequelize: Sequelize.Sequelize): typeof tickets { + return tickets.init( + { + ticketId: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + comment: 'UUID V4', + defaultValue: Sequelize.UUIDV4 + }, + status: { + type: DataTypes.ENUM('Open', 'Stale', 'Closed', 'Transferring'), + allowNull: false, + defaultValue: 'Open' + }, + author: { + type: DataTypes.CHAR(20), + allowNull: false, + comment: 'Snowflake' + }, + division: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'divisions', + key: 'divId' + } + }, + claimer: { + type: DataTypes.CHAR(20), + allowNull: true, + comment: 'Snowflake' + } + }, + { + sequelize, + tableName: 'tickets', + timestamps: true, + indexes: [ + { + name: 'PRIMARY', + unique: true, + using: 'BTREE', + fields: [{ name: 'ticketId' }] + }, + { + name: 'tickets_fk_1', + using: 'BTREE', + fields: [{ name: 'division' }] + } + ] + } + ); + } +} diff --git a/src/typings/Extensions.ts b/src/typings/Extensions.ts new file mode 100644 index 0000000..a324de3 --- /dev/null +++ b/src/typings/Extensions.ts @@ -0,0 +1,49 @@ +import { Client, CommandInteraction, ModalSubmitFields, ModalSubmitInteraction, SlashCommandBuilder } from 'discord.js'; +import { initModels } from '../models/init-models.js'; +import { Sequelize } from 'sequelize'; + +interface CustomClient extends Client { + commands?: Map; + modals?: Map; + sequelize?: Sequelize; + models?: ReturnType; +} +interface CommandFile { + name: string; + ephemeral: boolean; + modal?: boolean; + data: SlashCommandBuilder; + run: (client: CustomClient, interaction: CommandInteraction, options: CommandInteraction['options']) => Promise; +} +interface ModalFile { + name: string; + run: (client: CustomClient, interaction: ModalSubmitInteraction, fields: ModalSubmitFields) => Promise; +} +/** + * Expected structure: + * => servers[GameId][JobId] = [Players, new Date().toUTCString()]; + */ +type ServerList = { + /** + * @param key ID of the game + */ + [key: string]: { + /** + * @param key JobId of the server + */ + [key: string]: [players: number[], date: string]; + }; +}; +type RobloxUserPresenceData = { + userPresenceType: number; + lastLocation: string; + placeId: number; + rootPlaceId: number; + gameId: string; + universeId: number; + userId: number; + lastOnline: string; + invisibleModeExpiry: string; +}; + +export { CustomClient, CommandFile, ModalFile, ServerList, RobloxUserPresenceData }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2538661 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "newLine": "LF", + "resolveJsonModule": true, + "skipLibCheck": true + } +}