From 3a167cf686cd9e66a8e42964308189986967c88e Mon Sep 17 00:00:00 2001 From: Carter Penterman Date: Wed, 24 Apr 2024 06:28:07 +0000 Subject: [PATCH] Parse map models and validate them (#13) The enhancements: - Added map validation that checks for some common errors that maps have - /take: Does map validation but will still take the map even if there are problems - /take: Improved the help text and added links to the maptest places - /submit: Does map validation and will not let you submit if there are problems - /submissions: Now shows the map's display name and creator - Added an ESLint config and tidied some things up - Updated some of the packages Reviewed-on: https://git.itzana.me/StrafesNET/maptest-bot/pulls/13 Co-authored-by: Carter Penterman Co-committed-by: Carter Penterman --- bot.js | 6 +- commands/submissions.js | 16 ++-- commands/submit.js | 38 ++++++--- commands/take.js | 34 +++++--- common.js | 175 ++++++++++++++++++++++++++++++++++++++-- config/config.js | 12 ++- eslint.config.mjs | 15 ++++ package.json | 16 ++-- 8 files changed, 267 insertions(+), 45 deletions(-) create mode 100644 eslint.config.mjs diff --git a/bot.js b/bot.js index 84d2974..ecdabf0 100644 --- a/bot.js +++ b/bot.js @@ -1,8 +1,8 @@ const fs = require('node:fs'); -const { Client, Collection, Intents } = require("discord.js"); +const { Client, Collection } = require("discord.js"); const {token} = require("./config/config.json"); -const client = new Client({intents: [Intents.FLAGS.GUILDS]}); +const client = new Client({intents: ["Guilds"]}); client.commands = new Collection(); const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js')); @@ -29,7 +29,7 @@ client.on('interactionCreate', async interaction => { } }); -client.on("error", async error => { +client.on("error", async _error => { }); diff --git a/commands/submissions.js b/commands/submissions.js index 0fd996c..122c504 100644 --- a/commands/submissions.js +++ b/commands/submissions.js @@ -1,13 +1,17 @@ const { SlashCommandBuilder } = require('@discordjs/builders'); -const { MessageAttachment } = require("discord.js"); +const { AttachmentBuilder } = require("discord.js"); const fs = require('node:fs'); const { submissions, commands } = require("../config/config.js"); const { parse } = require("csv-parse/sync"); -const { getSubmissionLine } = require("../common.js"); +const { getSubmissionLine, safeCsvFormat } = require("../common.js"); const Sugar = require("sugar-date"); +const { Buffer } = require("buffer"); +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ async function execute(interaction) { - const game = interaction.options.getString("game"); + const game = interaction.options.getString("game", true); const fname = submissions[game]; if (fname === undefined) { @@ -53,7 +57,7 @@ async function execute(interaction) { const lines = parse(csvFile, {delimiter: ',', fromLine: 2}); - let csvString = "map_id,unix_timestamp,date_string,user_id,username\n"; + let csvString = "map_id,unix_timestamp,date_string,user_id,username,display_name,creator\n"; let found = false; for (let lineStr of lines) { const line = getSubmissionLine(lineStr); @@ -66,7 +70,7 @@ async function execute(interaction) { found = true; const dateStr = new Date(line.timestamp * 1000).toLocaleString("en-US", {dateStyle: "short"}); - csvString += `${line.modelId},${line.timestamp},${dateStr},${line.userId},${line.username}\n`; + csvString += `${line.modelId},${line.timestamp},${dateStr},${line.userId},${safeCsvFormat(line.username)},${safeCsvFormat(line.displayName)},${safeCsvFormat(line.creator)}\n`; } if (!found) { @@ -82,7 +86,7 @@ async function execute(interaction) { return; } - const file = new MessageAttachment(Buffer.from(csvString), fname); + const file = new AttachmentBuilder(Buffer.from(csvString), { name: fname }); const dateRangeStr = getDateRangeString(afterTimestamp, beforeTimestamp); if (dateRangeStr) { await interaction.reply({content: `Using date range ${dateRangeStr}:`, files: [file]}); diff --git a/commands/submit.js b/commands/submit.js index a11e86d..8a1674e 100644 --- a/commands/submit.js +++ b/commands/submit.js @@ -4,14 +4,14 @@ const fs = require('node:fs'); const noblox = require("noblox.js"); const axios = require("axios").default; const { submissions, commands, cookies } = require("../config/config.js"); -const { AssetType, getAssetInfo, SubmissionColumnsString, createSubmissionLine, getSubmissionLine } = require("../common.js"); +const { AssetType, getAssetInfo, SubmissionColumnsString, createSubmissionLine, getSubmissionLine, validateMapAsset, getValidationMessage } = require("../common.js"); async function robloxUserFromDiscord(id) { if (isNaN(id)) return undefined; try { const res = await axios.get(`https://api.fiveman1.net/v1/users/${id}`); return res.data.result.robloxId; - } catch (error) { + } catch { return undefined; } } @@ -21,11 +21,14 @@ async function robloxUsernameFromId(id) { try { const res = await axios.get(`https://users.roblox.com/v1/users/${id}`); return res.data.name; - } catch (error) { + } catch { return undefined; } } +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ async function execute(interaction) { const userId = await robloxUserFromDiscord(interaction.user.id); if (!userId) { @@ -33,7 +36,7 @@ async function execute(interaction) { await interaction.reply({content: msg, ephemeral: true}); return; } - const game = interaction.options.getString("game"); + const game = interaction.options.getString("game", true); const fname = submissions[game]; if (fname === undefined) { @@ -45,7 +48,7 @@ async function execute(interaction) { fs.writeFileSync(fname, SubmissionColumnsString); } - const id = interaction.options.getInteger("asset_id"); + const id = interaction.options.getInteger("asset_id", true); await noblox.setCookie(cookies[game]); // Check that the bot owns this model @@ -74,23 +77,38 @@ async function execute(interaction) { const csvFile = fs.readFileSync(fname); const lines = parse(csvFile, {delimiter: ',', fromLine: 2}); - let csvString = SubmissionColumnsString; for (let lineStr of lines) { const line = getSubmissionLine(lineStr); - if (id === line.modelId) { await interaction.reply({content: `This map (id: ${id}) was already submitted on .`, ephemeral: true}); return; } + } - csvString += createSubmissionLine(line.modelId, line.timestamp, line.userId, line.username); + const mapValidatePromise = validateMapAsset(id, game); + + const message = await interaction.reply("⌛ Validating map..."); + + const validation = await mapValidatePromise; + const msg = getValidationMessage(validation, game, true); + await message.edit(msg); + + if (!validation.valid) { + await interaction.followUp("Due to having problems, your map was **NOT submitted**."); + return; + } + + let csvString = SubmissionColumnsString; + for (let lineStr of lines) { + const line = getSubmissionLine(lineStr); + csvString += createSubmissionLine(line.modelId, line.timestamp, line.userId, line.username, line.displayName, line.creator); } const unixTimestamp = Math.round(+new Date()/1000); - csvString += createSubmissionLine(id, unixTimestamp, userId, await robloxUsernameFromId(userId)); + csvString += createSubmissionLine(id, unixTimestamp, userId, await robloxUsernameFromId(userId), validation.displayName, validation.creator); fs.writeFileSync(fname, csvString); - await interaction.reply(`Map (id: ${id}) successfully submitted.`); + await interaction.followUp(`Map (id: ${id}) successfully submitted.`); } module.exports = { diff --git a/commands/take.js b/commands/take.js index cb89604..b67ba16 100644 --- a/commands/take.js +++ b/commands/take.js @@ -1,17 +1,20 @@ const { SlashCommandBuilder } = require('@discordjs/builders'); const noblox = require("noblox.js"); -const { cookies, commands } = require("../config/config.js"); -const { AssetType, getAssetInfo } = require("../common.js"); +const { cookies, commands, gamePlaces } = require("../config/config.js"); +const { AssetType, getAssetInfo, validateMapAsset, getValidationMessage } = require("../common.js"); +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ async function execute(interaction) { - const game = interaction.options.getString("game"); + const game = interaction.options.getString("game", true); const cookie = cookies[game]; if (cookie === undefined) { await interaction.reply({content: "Invalid game specified!", ephemeral: true}); return; } - const id = interaction.options.getInteger("asset_id"); + const id = interaction.options.getInteger("asset_id", true); await noblox.setCookie(cookie); // Check that the bot doesn't already own this asset if (await noblox.getOwnership(await noblox.getCurrentUser("UserID"), id, "Asset")) { @@ -46,16 +49,23 @@ async function execute(interaction) { Creator: { Id: assetInfo.creatorId } - } + }; - await noblox.buy({product: productInfo, price: 0}); - await interaction.reply( + const buyPromise = noblox.buy({product: productInfo, price: 0}); + const mapValidatePromise = validateMapAsset(id, game); + + const message = await interaction.reply("⌛ Validating map..."); + + const validation = await mapValidatePromise; + const msg = getValidationMessage(validation, game, false); + await message.edit(msg); + + await buyPromise; + await interaction.followUp( ` -Now that your map (id: ${id}) has been taken by the ${game} maptest bot you can load it into the ${game} maptest place. To load your map, join the game and say -\`\`\` -!map ${id} -\`\`\`Read what it says. If your map successfully loaded type !rtv and then choose your map. -If it did not load successfully, you can expand the chat to view the full error message by clicking and dragging on the edge of the chat. +Now that your [map (id: ${id})]() has been taken by the bot you can load it into the [${game} maptest place](<${gamePlaces[game]}>). +To load your map, join the game and do \`!map ${id}\`. If your map successfully loaded, do \`!rtv\` and then choose your map. +Otherwise, you can expand the chat to view the full error message by clicking and dragging on the edge of the chat. ` ); } diff --git a/common.js b/common.js index f23ccd9..933d7d6 100644 --- a/common.js +++ b/common.js @@ -1,4 +1,5 @@ const axios = require("axios").default; +const { RobloxModel } = require("rbxm-parser"); // https://create.roblox.com/docs/reference/engine/enums/AssetType const AssetType = { @@ -34,7 +35,7 @@ async function getAssetInfo(assetId) { if (res.status < 200 || res.status > 300) { return { status: res.status - } + }; } const data = res.data.data; @@ -49,16 +50,18 @@ async function getAssetInfo(assetId) { price: assetInfo.product.price, productId: assetInfo.product.productId, forSale: assetInfo.product.isForSaleOrIsPublicDomain - } + }; } -const SubmissionColumnsString = "map_id,unix_timestamp,user_id,username\n"; +const SubmissionColumnsString = "map_id,unix_timestamp,user_id,username,display_name,creator\n"; const SubmissionColumn = { ModelID: 0, UnixTimestamp: 1, UserID: 2, - Username: 3 + Username: 3, + DisplayName: 4, + Creator: 5 }; function getSubmissionLine(line) { @@ -66,6 +69,8 @@ function getSubmissionLine(line) { const timestamp = Number(line[SubmissionColumn.UnixTimestamp]); let userId = ""; let username = ""; + let displayName = ""; + let creator = ""; // Backwards compatibility if (line.length > 2) { @@ -73,16 +78,170 @@ function getSubmissionLine(line) { username = line[SubmissionColumn.Username]; } + if (line.length > 4) { + displayName = line[SubmissionColumn.DisplayName]; + creator = line[SubmissionColumn.Creator]; + } + return { modelId: modelId, timestamp: timestamp, userId: userId, - username: username + username: username, + displayName: displayName, + creator: creator }; } -function createSubmissionLine(modelId, timestamp, userId, username) { - return `${modelId},${timestamp},${userId},${username}\n`; +function safeCsvFormat(cell) { + // https://stackoverflow.com/questions/46637955/write-a-string-containing-commas-and-double-quotes-to-csv + if (cell.replace(/ /g, '').match(/[\s,"]/)) { + return '"' + cell.replace(/"/g, '""') + '"'; + } + return cell; } -module.exports = { AssetType, getAssetInfo, SubmissionColumn, SubmissionColumnsString, getSubmissionLine, createSubmissionLine }; \ No newline at end of file +function createSubmissionLine(modelId, timestamp, userId, username, displayName, creator) { + return `${modelId},${timestamp},${userId},${safeCsvFormat(username)},${safeCsvFormat(displayName)},${safeCsvFormat(creator)}\n`; +} + +/** + * @param {RobloxModel} model + */ +function getMapStringValues(model) { + if (model.Roots.length < 1) { + return undefined; + } + + const root = model.Roots[0]; + const values = root.FindChildrenOfClass("StringValue", (child) => child.Name === "DisplayName" || child.Name === "Creator"); + if (values.length !== 2) { + return undefined; + } + + let name, creator; + if (values[0].Name === "DisplayName") { + name = values[0].Value; + creator = values[1].Value; + } + else { + name = values[1].Value; + creator = values[0].Value; + } + + return { + displayName: name, + creator: creator + }; +} + +function capitalize(str) { + if (!str) return ""; + return str[0].toUpperCase() + str.slice(1); +} + +async function validateMapAsset(assetId, game) { + const model = await RobloxModel.ReadFromAssetId(assetId); + if (!model) { + // For whatever reason we couldn't parse the model, so we'll just skip doing validation + return { + valid: true, + displayName: "", + creator: "" + }; + } + + const errors = []; + + if (model.Roots.length > 1) { + errors.push("Your map has more than one root part, it must have a single `Model` as the root."); + return { + valid: false, + errors: errors + }; + } + + const root = model.Roots[0]; + if (!root.IsA("Model")) { + errors.push(`The root part of your model is a \`${root.ClassName}\`, it needs to be a \`Model\` instead.`); + } + else { + const prefix = (game === "deathrun") ? "dr" : game; + if (!root.Name.startsWith(prefix + "_")) { + errors.push(`Your root model's name is \`${root.Name}\`, its name must start with \`${prefix}_\`.`); + } + if (!/^[a-z0-9_]*$/.test(root.Name)) { + errors.push(`Your root model's name is \`${root.Name}\` which contains invalid characters. It must only contain lowercase alphanumeric characters separated by underscores.`); + } + } + + const values = getMapStringValues(model); + if (!values) { + errors.push("Your map is missing a `StringValue` named `Creator` and/or a `StringValue` named `DisplayName`. You must add both to your map and they must be parented directly to the root model. These are used to set the map name and creator in-game."); + } + else { + if (!values.creator) { + errors.push("Your map's `Creator` `StringValue` does not have a `Value`."); + } + if (!values.displayName) { + errors.push("Your map's `DisplayName` `StringValue` does not have a `Value`."); + } + else { + const checkName = capitalize(values.displayName); + if (values.displayName !== checkName) { + errors.push(`Your map's \`DisplayName\` must be capitalized. You may change it from \`${values.displayName}\` to \`${checkName}\`.`); + } + } + } + + const mapParts = root.FindDescendantsOfClass("BasePart", (part) => part.Name === "MapStart" || part.Name === "MapFinish"); + if (mapParts.length !== 2 || mapParts[0].Name === mapParts[1].Name) { + errors.push("Your map must have exactly one part named `MapStart` and one part named `MapFinish`."); + } + else if (mapParts[0].CanCollide || mapParts[1].CanCollide) { + errors.push("The `MapStart` and `MapFinish` parts in your map must have the `CanCollide` property disabled."); + } + + const spawnOnes = root.FindDescendantsOfClass("BasePart", (part) => part.Name === "Spawn1"); + if (spawnOnes.length !== 1) { + errors.push("Your map must have exactly one part named `Spawn1`."); + } + + // Why does ModuleScript not inherit from Script, and/or why does BaseScript not have a Source property? + const illegalScript = root.FindFirstDescendantOfClass("Script", (script) => sourceHasIllegalKeywords(script.Source)); + const illegalModuleScript = root.FindFirstDescendantOfClass("ModuleScript", (script) => sourceHasIllegalKeywords(script.Source)); + if (illegalScript || illegalModuleScript) { + errors.push("Your map has a `Script` that contains the keyword `getfenv` or `require`. You must remove these."); + } + + if (errors.length > 0) { + return { + valid: false, + errors: errors + }; + } + + return { + valid: true, + displayName: values.displayName, + creator: values.creator + }; +} + +function sourceHasIllegalKeywords(source) { + return source && (source.includes("getfenv") || source.includes("require")); +} + +function getValidationMessage(validation, game, errorOnFail) { + if (validation.valid) { + return `✅ Your map is valid! (game: ${game})`; + } + + let msg = `${errorOnFail ? "🚫" : "⚠️"} **Your map has problems.** (game: ${game})`; + for (const error of validation.errors) { + msg += `\n* ${error}`; + } + return msg; +} + +module.exports = { AssetType, getAssetInfo, SubmissionColumn, SubmissionColumnsString, getSubmissionLine, createSubmissionLine, validateMapAsset, getValidationMessage, safeCsvFormat }; \ No newline at end of file diff --git a/config/config.js b/config/config.js index e994159..84634ab 100644 --- a/config/config.js +++ b/config/config.js @@ -1,14 +1,24 @@ const { bhopCookie, surfCookie, deathrunCookie, flytrialsCookie } = require("./config.json"); + const cookies = { bhop: bhopCookie, surf: surfCookie, deathrun: deathrunCookie, flytrials: flytrialsCookie, }; + const submissions = {}; const commands = []; for (const game in cookies) { submissions[game] = "files/" + game + "_submissions.csv"; commands.push({name: game, value: game}); } -module.exports = { cookies, submissions, commands }; + +const gamePlaces = { + bhop: "https://www.roblox.com/games/517201717/", + surf: "https://www.roblox.com/games/517206177/", + deathrun: "https://www.roblox.com/games/6870563649/", + flytrials: "https://www.roblox.com/games/12724901535/" +}; + +module.exports = { cookies, submissions, commands, gamePlaces }; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..cad99b3 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,15 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; + + +export default [ + {files: ["**/*.js"], languageOptions: {sourceType: "commonjs"}}, + {languageOptions: { globals: globals.browser }}, + pluginJs.configs.recommended, + { + rules: { + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "semi": "error" + } + } +]; \ No newline at end of file diff --git a/package.json b/package.json index 578eb5d..161c789 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,19 @@ { "dependencies": { - "@discordjs/builders": "^0.13.0", - "@discordjs/rest": "^0.4.1", - "axios": "^0.27.2", + "@discordjs/builders": "^1.7.0", + "@discordjs/rest": "^2.2.0", + "axios": "^1.6.8", "csv-parse": "^5.0.4", - "discord-api-types": "^0.32.1", - "discord.js": "^13.6.0", + "discord-api-types": "^0.37.81", + "discord.js": "^14.14.1", "noblox.js": "^4.15.1", "node-csv": "^0.1.2", + "rbxm-parser": "^1.0.4", "sugar-date": "^2.0.6" + }, + "devDependencies": { + "@eslint/js": "^9.1.1", + "eslint": "^9.1.1", + "globals": "^15.0.0" } }