From ddb8f62eca71c34d3ef23bb45ed3e8b2b1f8925b Mon Sep 17 00:00:00 2001 From: "Ean Milligan (Bastion)" Date: Fri, 19 Feb 2021 20:44:38 -0500 Subject: [PATCH] V1.4.2 - Code Rework + minor bugfixes .gitignore - added new logs folder for future feature config.example.ts, README.md, *index.html - version bumped deps.ts - moved all remote dependencies to this file to make version updating easier db/* - changed deps to new format longStrings.ts, README.md - fixed typo mod.ts - changed deps to new format, implemented error catching for editBotStatus, moved api to separate file api.ts - pulled api code to this file for better organization intervals.ts - new utility file for functions that will be called after bot boots (and that continue to be called on a specified interval mod.d.ts - documentation added solver.ts - newline utils.ts - updated deps to new format --- .gitignore | 1 + README.md | 4 +- config.example.ts | 2 +- db/initialize.ts | 5 +- db/populateDefaults.ts | 6 +- deps.ts | 19 ++ longStrings.ts | 2 +- mod.ts | 745 ++--------------------------------------- src/api.ts | 742 ++++++++++++++++++++++++++++++++++++++++ src/intervals.ts | 36 ++ src/mod.d.ts | 1 + src/solver.ts | 2 +- src/utils.ts | 7 +- www/api/index.html | 2 +- www/home/index.html | 2 +- 15 files changed, 840 insertions(+), 736 deletions(-) create mode 100644 deps.ts create mode 100644 src/api.ts create mode 100644 src/intervals.ts diff --git a/.gitignore b/.gitignore index 4afdf84..9eef88c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ config.ts emojis/Thumbs.db +logs diff --git a/README.md b/README.md index d52603a..6b34f52 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # The Artificer - A Dice Rolling Discord Bot -Version 1.4.1 - 2021/02/13 +Version 1.4.2 - 2021/02/14 The Artificer is a Discord bot that specializes in rolling dice. The bot utilizes the compact [Roll20 formatting](https://roll20.zendesk.com/hc/en-us/articles/360037773133-Dice-Reference) for ease of use and will correctly perform any needed math on the roll (limited to basic algebra). @@ -71,7 +71,7 @@ The Artificer comes with a few supplemental commands to the main rolling command | csq or cs=q | Optional | Yes | changes crit score to q | | csq | Optional | Yes | changes crit score to be greater than or equal to q | - | cfq or cs=q | Optional | Yes | changes crit fail to q | + | cfq or cf=q | Optional | Yes | changes crit fail to q | | cfq | Optional | Yes | changes crit fail to be greater than or equal to q | | ! | Optional | No | exploding, rolls another dy for every crit roll | diff --git a/config.example.ts b/config.example.ts index d655e08..d4e1b96 100644 --- a/config.example.ts +++ b/config.example.ts @@ -1,6 +1,6 @@ export const config = { "name": "The Artificer", // Name of the bot - "version": "1.4.1", // Version of the bot + "version": "1.4.2", // Version of the bot "token": "the_bot_token", // Discord API Token for this bot "localtoken": "local_testing_token", // Discord API Token for a secondary OPTIONAL testing bot, THIS MUST BE DIFFERENT FROM "token" "prefix": "[[", // Prefix for all commands diff --git a/db/initialize.ts b/db/initialize.ts index beef87f..eb1fe3f 100644 --- a/db/initialize.ts +++ b/db/initialize.ts @@ -1,7 +1,10 @@ // This file will create all tables for the artificer schema // DATA WILL BE LOST IF DB ALREADY EXISTS, RUN AT OWN RISK -import { Client } from "https://deno.land/x/mysql/mod.ts"; +import { + // MySQL deps + Client +} from "../deps.ts"; import { LOCALMODE } from "../flags.ts"; import config from "../config.ts"; diff --git a/db/populateDefaults.ts b/db/populateDefaults.ts index 420f849..9500d40 100644 --- a/db/populateDefaults.ts +++ b/db/populateDefaults.ts @@ -1,7 +1,9 @@ // This file will populate the tables with default values -import { Client } from "https://deno.land/x/mysql/mod.ts"; - +import { + // MySQL deps + Client +} from "../deps.ts"; import { LOCALMODE } from "../flags.ts"; import config from "../config.ts"; diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..2755d74 --- /dev/null +++ b/deps.ts @@ -0,0 +1,19 @@ +// All external dependancies are to be loaded here to make updating dependancy versions much easier +export { + startBot, editBotsStatus, + Intents, StatusTypes, ActivityType, + sendMessage, sendDirectMessage, + cache, + memberIDHasPermission +} from "https://deno.land/x/discordeno@10.3.0/mod.ts"; + +export type { + CacheData, Message, Guild, MessageContent +} from "https://deno.land/x/discordeno@10.3.0/mod.ts"; + +export { Client } from "https://deno.land/x/mysql@v2.7.0/mod.ts"; + +export { serve } from "https://deno.land/std@0.83.0/http/server.ts"; +export { Status, STATUS_TEXT } from "https://deno.land/std@0.83.0/http/http_status.ts"; + +export { nanoid } from "https://deno.land/x/nanoid@v3.0.0/mod.ts"; diff --git a/longStrings.ts b/longStrings.ts index 45c8744..a8bd18a 100644 --- a/longStrings.ts +++ b/longStrings.ts @@ -34,7 +34,7 @@ export const longStrs = { "* csq or cs=q [OPT] - changes crit score to q", "* csq [OPT] - changes crit score to be greater than or equal to q ", - "* cfq or cs=q [OPT] - changes crit fail to q", + "* cfq or cf=q [OPT] - changes crit fail to q", "* cfq [OPT] - changes crit fail to be greater than or equal to q", "* ! [OPT] - exploding, rolls another dy for every crit roll", diff --git a/mod.ts b/mod.ts index ce50b9a..7f35eae 100644 --- a/mod.ts +++ b/mod.ts @@ -5,21 +5,19 @@ */ import { + // Discordeno deps startBot, editBotsStatus, Intents, StatusTypes, ActivityType, Message, Guild, sendMessage, sendDirectMessage, cache, - MessageContent, - memberIDHasPermission -} from "https://deno.land/x/discordeno@10.3.0/mod.ts"; + memberIDHasPermission, -import { serve } from "https://deno.land/std@0.83.0/http/server.ts"; -import { Status, STATUS_TEXT } from "https://deno.land/std@0.83.0/http/http_status.ts"; - -import { Client } from "https://deno.land/x/mysql@v2.7.0/mod.ts"; - -import { nanoid } from "https://deno.land/x/nanoid@v3.0.0/mod.ts"; + // MySQL Driver deps + Client +} from "./deps.ts"; +import api from "./src/api.ts"; +import intervals from "./src/intervals.ts"; import utils from "./src/utils.ts"; import solver from "./src/solver.ts"; @@ -35,7 +33,7 @@ const dbClient = await new Client().connect({ port: config.db.port, db: config.db.name, username: config.db.username, - password: config.db.password, + password: config.db.password }); // Start up the Discord Bot @@ -45,14 +43,21 @@ startBot({ eventHandlers: { ready: () => { console.log(`${config.name} Logged in!`); - let statusIdx = 0; - const statusRotation =[`${config.prefix}help for commands`, `Running V${config.version}`, `${config.prefix}info to learn more`, `Rolling dice for ${cache.guilds.size} servers`]; + editBotsStatus(StatusTypes.Online, "Booting up . . .", ActivityType.Game); + + // Interval to rotate the status text every 30 seconds to show off more commands setInterval(() => { - editBotsStatus(StatusTypes.Online, statusRotation[statusIdx], ActivityType.Game); - statusIdx >= statusRotation.length ? statusIdx = 0 : statusIdx++; + try { + // Wrapped in try-catch due to hard crash possible + editBotsStatus(StatusTypes.Online, intervals.getRandomStatus(cache), ActivityType.Game); + } catch (err) { + console.error("Failed to update status 00", err); + } }, 30000); + // setTimeout added to make sure the startup message does not error out setTimeout(() => { + editBotsStatus(StatusTypes.Online, `Boot Complete`, ActivityType.Game); sendMessage(config.logChannel, `${config.name} has started, running version ${config.version}.`).catch(() => { console.error("Failed to send message 00"); }); @@ -583,715 +588,5 @@ if (DEBUG) { // Start up the API for rolling from third party apps (like excel macros) if (config.api.enable) { - const server = serve({ hostname: "0.0.0.0", port: config.api.port }); - console.log(`HTTP api running at: http://localhost:${config.api.port}/`); - - // rateLimitTime holds all users with the last time they started a rate limit timer - const rateLimitTime = new Map(); - // rateLimitCnt holds the number of times the user has called the api in the current rate limit timer - const rateLimitCnt = new Map(); - - // Catching every request made to the server - for await (const request of server) { - // Check if user is authenticated to be using this API - let authenticated = false; - let rateLimited = false; - let updateRateLimitTime = false; - let apiUserid = 0n; - let apiUseridStr = ""; - let apiUserEmail = ""; - let apiUserDelCode = ""; - - // Check the requests API key - if (request.headers.has("X-Api-Key")) { - // Get the userid and flags for the specific key - const dbApiQuery = await dbClient.query("SELECT userid, email, deleteCode FROM all_keys WHERE apiKey = ? AND active = 1 AND banned = 0", [request.headers.get("X-Api-Key")]); - - // If only one user returned, is not banned, and is currently active, mark as authenticated - if (dbApiQuery.length === 1) { - apiUserid = BigInt(dbApiQuery[0].userid); - apiUserEmail = dbApiQuery[0].email; - apiUserDelCode = dbApiQuery[0].deleteCode; - authenticated = true; - - // Rate limiting inits - apiUseridStr = apiUserid.toString(); - const apiTimeNow = new Date().getTime(); - - // Check if user has sent a request recently - if (rateLimitTime.has(apiUseridStr) && (((rateLimitTime.get(apiUseridStr) || 0) + config.api.rateLimitTime) > apiTimeNow)) { - // Get current count - const currentCnt = rateLimitCnt.get(apiUseridStr) || 0; - if (currentCnt < config.api.rateLimitCnt) { - // Limit not yet exceeded, update count - rateLimitCnt.set(apiUseridStr, (currentCnt + 1)); - } else { - // Limit exceeded, prevent API use - rateLimited = true; - } - } else { - // Update the maps - updateRateLimitTime = true; - rateLimitCnt.set(apiUseridStr, 1); - } - } - } - - if (authenticated && !rateLimited) { - // Get path and query as a string - const [path, tempQ] = request.url.split("?"); - - // Turn the query into a map (if it exists) - const query = new Map(); - if (tempQ !== undefined) { - tempQ.split("&").forEach(e => { - const [option, params] = e.split("="); - query.set(option.toLowerCase(), params); - }); - } - - // Handle the request - switch (request.method) { - case "GET": - switch (path.toLowerCase()) { - case "/api/key": - case "/api/key/": - if ((query.has("user") && ((query.get("user") || "").length > 0)) && (query.has("a") && ((query.get("a") || "").length > 0))) { - if (apiUserid === config.api.admin && apiUserid === BigInt(query.get("a"))) { - // Generate new secure key - const newKey = await nanoid(25); - - // Flag to see if there is an error inside the catch - let erroredOut = false; - - // Insert new key/user pair into the db - await dbClient.execute("INSERT INTO all_keys(userid,apiKey) values(?,?)", [apiUserid, newKey]).catch(e => { - console.log("Failed to insert into database 20"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - - // Exit this case now if catch errored - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.OK, body: JSON.stringify({ "key": newKey, "userid": query.get("user") }) }); - break; - } - } else { - // Only allow the db admin to use this API - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - case "/api/channel": - case "/api/channel/": - if (query.has("user") && ((query.get("user") || "").length > 0)) { - if (apiUserid === BigInt(query.get("user"))) { - // Flag to see if there is an error inside the catch - let erroredOut = false; - - // Get all channels userid has authorized - const dbAllowedChannelQuery = await dbClient.query("SELECT * FROM allowed_channels WHERE userid = ?", [apiUserid]).catch(e => { - console.log("Failed to query database 22"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - - if (erroredOut) { - break; - } else { - // Customized strinification to handle BigInts correctly - const returnChannels = JSON.stringify(dbAllowedChannelQuery, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)); - // Send API key as response - request.respond({ status: Status.OK, body: returnChannels }); - break; - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - case "/api/roll": - case "/api/roll/": - // Make sure query contains all the needed parts - if ((query.has("rollstr") && ((query.get("rollstr") || "").length > 0)) && (query.has("channel") && ((query.get("channel") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { - if (query.has("n") && query.has("m")) { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - break; - } - - // Check if user is authenticated to use this endpoint - let authorized = false; - - // Check if the db has the requested userid/channelid combo, and that the requested userid matches the userid linked with the api key - const dbChannelQuery = await dbClient.query("SELECT active, banned FROM allowed_channels WHERE userid = ? AND channelid = ?", [apiUserid, BigInt(query.get("channel"))]); - if (dbChannelQuery.length === 1 && (apiUserid === BigInt(query.get("user"))) && dbChannelQuery[0].active && !dbChannelQuery[0].banned) { - - // Get the guild from the channel and make sure user is in said guild - const guild = cache.channels.get(query.get("channel") || "")?.guild; - if (guild && guild.members.get(query.get("user") || "")?.id) { - const dbGuildQuery = await dbClient.query("SELECT active, banned FROM allowed_guilds WHERE guildid = ?", [BigInt(guild.id)]); - - // Make sure guild allows API rolls - if (dbGuildQuery.length === 1 && dbGuildQuery[0].active && !dbGuildQuery[0].banned) { - authorized = true; - } - } - } - - if (authorized) { - // Rest of this command is in a try-catch to protect all sends/edits from erroring out - try { - // Flag to tell if roll was completely successful - let errorOut = false; - // Make sure rollCmd is not undefined - let rollCmd = query.get("rollstr") || ""; - const originalCommand = query.get("rollstr"); - - if (rollCmd.length === 0) { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - - // Always log API rolls for abuse detection - dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, "EmptyInput", null]).catch(e => { - console.log("Failed to insert into database 10", e); - }); - break; - } - - if (query.has("o") && (query.get("o")?.toLowerCase() !== "d" && query.get("o")?.toLowerCase() !== "a")) { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - - // Always log API rolls for abuse detection - dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, "BadOrder", null]).catch(e => { - console.log("Failed to insert into database 10", e); - }); - break; - } - - // Clip off the leading prefix. API calls must be formatted with a prefix at the start to match how commands are sent in Discord - rollCmd = rollCmd.substr(rollCmd.indexOf(config.prefix) + 2).replace(/%20/g, " "); - - // Parse the roll and get the return text - const returnmsg = solver.parseRoll(rollCmd, config.prefix, config.postfix, query.has("m"), query.has("n"), query.has("o") ? (query.get("o")?.toLowerCase() || "") : ""); - - // Alert users why this message just appeared and how they can report abues pf this feature - const apiPrefix = "The following roll was conducted using my built in API. If someone in this channel did not request this roll, please report API abuse here: <" + config.api.supportURL + ">\n\n"; - let m, returnText = ""; - - // Handle sending the error message to whoever called the api - if (returnmsg.error) { - request.respond({ status: Status.InternalServerError, body: returnmsg.errorMsg }); - - // Always log API rolls for abuse detection - dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, returnmsg.errorCode, null]).catch(e => { - console.log("Failed to insert into database 11", e); - }); - break; - } else { - returnText = apiPrefix + "<@" + query.get("user") + ">" + returnmsg.line1 + "\n" + returnmsg.line2; - let spoilerTxt = ""; - - // Determine if spoiler flag was on - if (query.has("s")) { - spoilerTxt = "||"; - } - - // Determine if no details flag was on - if (query.has("nd")) { - returnText += "\nDetails suppressed by nd query."; - } else { - returnText += "\nDetails:\n" + spoilerTxt + returnmsg.line3 + spoilerTxt; - } - } - - // If the roll was a GM roll, send DMs to all the GMs - if (query.has("gms")) { - // Get all the GM user IDs from the query - const gms = (query.get("gms") || "").split(","); - if (gms.length === 0) { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - - // Always log API rolls for abuse detection - dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, "NoGMsSent", null]).catch(e => { - console.log("Failed to insert into database 12", e); - }); - break; - } - - // Make a new return line to be sent to the roller - let normalText = apiPrefix + "<@" + query.get("user") + ">" + returnmsg.line1 + "\nResults have been messaged to the following GMs: "; - gms.forEach(e => { - normalText += "<@" + e + "> "; - }); - - // Send the return message as a DM or normal message depening on if the channel is set - if ((query.get("channel") || "").length > 0) { - m = await sendMessage(query.get("channel") || "", normalText).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 00 failed to send." }); - errorOut = true; - }); - } else { - m = await sendDirectMessage(query.get("user") || "", normalText).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 01 failed to send." }); - errorOut = true; - }); - } - - // And message the full details to each of the GMs, alerting roller of every GM that could not be messaged - gms.forEach(async e => { - // If its too big, collapse it into a .txt file and send that instead. - const b = await new Blob([returnText as BlobPart], { "type": "text" }); - - // Update return text - returnText = apiPrefix + "<@" + query.get("user") + ">" + returnmsg.line1 + "\n" + returnmsg.line2 + "\nFull details have been attached to this messaged as a `.txt` file for verification purposes."; - - // Attempt to DM the GMs and send a warning if it could not DM a GM - await sendDirectMessage(e, { "content": returnText, "file": { "blob": b, "name": "rollDetails.txt" } }).catch(async () => { - const failedSend = "WARNING: <@" + e + "> could not be messaged. If this issue persists, make sure direct messages are allowed from this server." - // Send the return message as a DM or normal message depening on if the channel is set - if ((query.get("channel") || "").length > 0) { - m = await sendMessage(query.get("channel") || "", failedSend).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 10 failed to send." }); - errorOut = true; - }); - } else { - m = await sendDirectMessage(query.get("user") || "", failedSend).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 11 failed to send." }); - errorOut = true; - }); - } - }); - }); - - // Always log API rolls for abuse detection - dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,0)", [originalCommand, returnText, ((typeof m === "object") ? m.id : null)]).catch(e => { - console.log("Failed to insert into database 13", e); - }); - - // Handle closing the request out - if (errorOut) { - break; - } else { - request.respond({ status: Status.OK, body: normalText }); - break; - } - } else { - const newMessage: MessageContent = {}; - newMessage.content = returnText; - - // When not a GM roll, make sure the message is not too big - if (returnText.length > 2000) { - // If its too big, collapse it into a .txt file and send that instead. - const b = await new Blob([returnText as BlobPart], { "type": "text" }); - - // Update return text - returnText = "<@" + query.get("user") + ">" + returnmsg.line1 + "\n" + returnmsg.line2 + "\nDetails have been ommitted from this message for being over 2000 characters. Full details have been attached to this messaged as a `.txt` file for verification purposes."; - - // Set info into the newMessage - newMessage.content = returnText; - newMessage.file = { "blob": b, "name": "rollDetails.txt" }; - } - - // Send the return message as a DM or normal message depening on if the channel is set - if ((query.get("channel") || "").length > 0) { - m = await sendMessage(query.get("channel") || "", newMessage).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 20 failed to send." }); - errorOut = true; - }); - } else { - m = await sendDirectMessage(query.get("user") || "", newMessage).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 21 failed to send." }); - errorOut = true; - }); - } - - // If enabled, log rolls so we can verify the bots math - dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,0)", [originalCommand, returnText, ((typeof m === "object") ? m.id : null)]).catch(e => { - console.log("Failed to insert into database 14", e); - }); - - // Handle closing the request out - if (errorOut) { - break; - } else { - request.respond({ status: Status.OK, body: returnText }); - break; - } - } - } catch (err) { - // Handle any errors we missed - console.log(err) - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - default: - // Alert API user that they messed up - request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); - break; - } - break; - case "POST": - switch (path.toLowerCase()) { - case "/api/channel/add": - case "/api/channel/add/": - if ((query.has("user") && ((query.get("user") || "").length > 0)) && (query.has("channel") && ((query.get("channel") || "").length > 0))) { - if (apiUserid === BigInt(query.get("user"))) { - // Flag to see if there is an error inside the catch - let erroredOut = false; - - // Insert new user/channel pair into the db - await dbClient.execute("INSERT INTO allowed_channels(userid,channelid) values(?,?)", [apiUserid, BigInt(query.get("channel"))]).catch(e => { - console.log("Failed to insert into database 21"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - - // Exit this case now if catch errored - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); - break; - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - default: - // Alert API user that they messed up - request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); - break; - } - break; - case "PUT": - switch (path.toLowerCase()) { - case "/api/key/ban": - case "/api/key/ban/": - case "/api/key/unban": - case "/api/key/unban/": - case "/api/key/activate": - case "/api/key/activate/": - case "/api/key/deactivate": - case "/api/key/deactivate/": - if ((query.has("a") && ((query.get("a") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { - if (apiUserid === config.api.admin && apiUserid === BigInt(query.get("a"))) { - // Flag to see if there is an error inside the catch - let key, value, erroredOut = false; - - // Determine key to edit - if (path.toLowerCase().indexOf("ban") > 0) { - key = "banned"; - } else { - key = "active"; - } - - // Determine value to set - if (path.toLowerCase().indexOf("de") > 0 || path.toLowerCase().indexOf("un") > 0) { - value = 0; - } else { - value = 1; - } - - // Execute the DB modification - await dbClient.execute("UPDATE all_keys SET ?? = ? WHERE userid = ?", [key, value, apiUserid]).catch(e => { - console.log("Failed to update database 28"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - - // Exit this case now if catch errored - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); - break; - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - case "/api/channel/ban": - case "/api/channel/ban/": - case "/api/channel/unban": - case "/api/channel/unban/": - if ((query.has("a") && ((query.get("a") || "").length > 0)) && (query.has("channel") && ((query.get("channel") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { - if (apiUserid === config.api.admin && apiUserid === BigInt(query.get("a"))) { - // Flag to see if there is an error inside the catch - let value, erroredOut = false; - - // Determine value to set - if (path.toLowerCase().indexOf("un") > 0) { - value = 0; - } else { - value = 1; - } - - // Execute the DB modification - await dbClient.execute("UPDATE allowed_channels SET banned = ? WHERE userid = ? AND channelid = ?", [value, apiUserid, BigInt(query.get("channel"))]).catch(e => { - console.log("Failed to update database 24"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - - // Exit this case now if catch errored - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); - break; - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - case "/api/channel/activate": - case "/api/channel/activate/": - case "/api/channel/deactivate": - case "/api/channel/deactivate/": - if ((query.has("channel") && ((query.get("channel") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { - if (apiUserid === BigInt(query.get("user"))) { - // Flag to see if there is an error inside the catch - let value, erroredOut = false; - - // Determine value to set - if (path.toLowerCase().indexOf("de") > 0) { - value = 0; - } else { - value = 1; - } - - // Update the requested entry - await dbClient.execute("UPDATE allowed_channels SET active = ? WHERE userid = ? AND channelid = ?", [value, apiUserid, BigInt(query.get("channel"))]).catch(e => { - console.log("Failed to update database 26"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - - // Exit this case now if catch errored - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); - break; - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - default: - // Alert API user that they messed up - request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); - break; - } - break; - case "DELETE": - switch (path.toLowerCase()) { - case "/api/key/delete": - case "/api/key/delete/": - if (query.has("user") && ((query.get("user") || "").length > 0) && query.has("email") && ((query.get("email") || "").length > 0)) { - if (apiUserid === BigInt(query.get("user")) && apiUserEmail === query.get("email")) { - if (query.has("code") && ((query.get("code") || "").length > 0)) { - if ((query.get("code") || "") === apiUserDelCode) { - // User has recieved their delete code and we need to delete the account now - let erroredOut = false; - - await dbClient.execute("DELETE FROM allowed_channels WHERE userid = ?", [apiUserid]).catch(e => { - console.log("Failed to delete from database 2A"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - if (erroredOut) { - break; - } - - await dbClient.execute("DELETE FROM all_keys WHERE userid = ?", [apiUserid]).catch(e => { - console.log("Failed to delete from database 2B"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); - break; - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // User does not have their delete code yet, so we need to generate one and email it to them - const deleteCode = await nanoid(10); - - let erroredOut = false; - - // Execute the DB modification - await dbClient.execute("UPDATE all_keys SET deleteCode = ? WHERE userid = ?", [deleteCode, apiUserid]).catch(e => { - console.log("Failed to update database 29"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - if (erroredOut) { - break; - } - - // "Send" the email - await sendMessage(config.api.email, `<@${config.api.admin}> A USER HAS REQUESTED A DELETE CODE\n\nEmail Address: ${apiUserEmail}\n\nSubject: \`Artificer API Delete Code\`\n\n\`\`\`Hello Artificer API User,\n\nI am sorry to see you go. If you would like, please respond to this email detailing what I could have done better.\n\nAs requested, here is your delete code: ${deleteCode}\n\nSorry to see you go,\nThe Artificer Developer - Ean Milligan\`\`\``).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 30 failed to send." }); - erroredOut = true; - }); - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.FailedDependency, body: STATUS_TEXT.get(Status.FailedDependency) }); - break; - } - } - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - default: - // Alert API user that they messed up - request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); - break; - } - break; - default: - // Alert API user that they messed up - request.respond({ status: Status.MethodNotAllowed, body: STATUS_TEXT.get(Status.MethodNotAllowed) }); - break; - } - - if (updateRateLimitTime) { - const apiTimeNow = new Date().getTime(); - rateLimitTime.set(apiUseridStr, apiTimeNow); - } - } else if (!authenticated && !rateLimited) { - // Get path and query as a string - const [path, tempQ] = request.url.split("?"); - - // Turn the query into a map (if it exists) - const query = new Map(); - if (tempQ !== undefined) { - tempQ.split("&").forEach(e => { - const [option, params] = e.split("="); - query.set(option.toLowerCase(), params); - }); - } - - // Handle the request - switch (request.method) { - case "GET": - switch (path.toLowerCase()) { - case "/api/key": - case "/api/key/": - if ((query.has("user") && ((query.get("user") || "").length > 0)) && (query.has("email") && ((query.get("email") || "").length > 0))) { - // Generate new secure key - const newKey = await nanoid(25); - - // Flag to see if there is an error inside the catch - let erroredOut = false; - - // Insert new key/user pair into the db - await dbClient.execute("INSERT INTO all_keys(userid,apiKey,email) values(?,?,?)", [BigInt(query.get("user")), newKey, (query.get("email") || "").toLowerCase()]).catch(e => { - console.log("Failed to insert into database 20"); - request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); - erroredOut = true; - }); - - // Exit this case now if catch errored - if (erroredOut) { - break; - } - - // "Send" the email - await sendMessage(config.api.email, `<@${config.api.admin}> A USER HAS REQUESTED AN API KEY\n\nEmail Address: ${query.get("email")}\n\nSubject: \`Artificer API Key\`\n\n\`\`\`Hello Artificer API User,\n\nWelcome aboard The Artificer's API. You can find full details about the API on the GitHub: https://github.com/Burn-E99/TheArtificer\n\nYour API Key is: ${newKey}\n\nGuard this well, as there is zero tolerance for API abuse.\n\nWelcome aboard,\nThe Artificer Developer - Ean Milligan\`\`\``).catch(() => { - request.respond({ status: Status.InternalServerError, body: "Message 31 failed to send." }); - erroredOut = true; - }); - - if (erroredOut) { - break; - } else { - // Send API key as response - request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); - break; - } - } else { - // Alert API user that they messed up - request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); - } - break; - default: - // Alert API user that they messed up - request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); - break; - } - break; - default: - // Alert API user that they messed up - request.respond({ status: Status.MethodNotAllowed, body: STATUS_TEXT.get(Status.MethodNotAllowed) }); - break; - } - } else if (authenticated && rateLimited) { - // Alert API user that they are doing this too often - request.respond({ status: Status.TooManyRequests, body: STATUS_TEXT.get(Status.TooManyRequests) }); - } else { - // Alert API user that they shouldn't be doing this - request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); - } - } + api.start(dbClient, cache, sendMessage, sendDirectMessage); } diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..fce7471 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,742 @@ +/* The Artificer was built in memory of Babka + * With love, Ean + * + * December 21, 2020 + */ + +import { + // Discordeno deps + CacheData, Message, MessageContent, + + // MySQL Driver deps + Client, + + // httpd deps + serve, + Status, STATUS_TEXT, + + // nanoid deps + nanoid +} from "../deps.ts"; + +import solver from "./solver.ts"; + +import config from "../config.ts"; + +// start(databaseClient, botCache, sendMessage, sendDirectMessage) returns nothing +// start initializes and runs the entire API for the bot +const start = async (dbClient: Client, cache: CacheData, sendMessage: (c: string, m: (string | MessageContent)) => Promise, sendDirectMessage: (c: string, m: (string | MessageContent)) => Promise): Promise => { + const server = serve({ hostname: "0.0.0.0", port: config.api.port }); + console.log(`HTTP api running at: http://localhost:${config.api.port}/`); + + // rateLimitTime holds all users with the last time they started a rate limit timer + const rateLimitTime = new Map(); + // rateLimitCnt holds the number of times the user has called the api in the current rate limit timer + const rateLimitCnt = new Map(); + + // Catching every request made to the server + for await (const request of server) { + // Check if user is authenticated to be using this API + let authenticated = false; + let rateLimited = false; + let updateRateLimitTime = false; + let apiUserid = 0n; + let apiUseridStr = ""; + let apiUserEmail = ""; + let apiUserDelCode = ""; + + // Check the requests API key + if (request.headers.has("X-Api-Key")) { + // Get the userid and flags for the specific key + const dbApiQuery = await dbClient.query("SELECT userid, email, deleteCode FROM all_keys WHERE apiKey = ? AND active = 1 AND banned = 0", [request.headers.get("X-Api-Key")]); + + // If only one user returned, is not banned, and is currently active, mark as authenticated + if (dbApiQuery.length === 1) { + apiUserid = BigInt(dbApiQuery[0].userid); + apiUserEmail = dbApiQuery[0].email; + apiUserDelCode = dbApiQuery[0].deleteCode; + authenticated = true; + + // Rate limiting inits + apiUseridStr = apiUserid.toString(); + const apiTimeNow = new Date().getTime(); + + // Check if user has sent a request recently + if (rateLimitTime.has(apiUseridStr) && (((rateLimitTime.get(apiUseridStr) || 0) + config.api.rateLimitTime) > apiTimeNow)) { + // Get current count + const currentCnt = rateLimitCnt.get(apiUseridStr) || 0; + if (currentCnt < config.api.rateLimitCnt) { + // Limit not yet exceeded, update count + rateLimitCnt.set(apiUseridStr, (currentCnt + 1)); + } else { + // Limit exceeded, prevent API use + rateLimited = true; + } + } else { + // Update the maps + updateRateLimitTime = true; + rateLimitCnt.set(apiUseridStr, 1); + } + } + } + + if (authenticated && !rateLimited) { + // Get path and query as a string + const [path, tempQ] = request.url.split("?"); + + // Turn the query into a map (if it exists) + const query = new Map(); + if (tempQ !== undefined) { + tempQ.split("&").forEach(e => { + const [option, params] = e.split("="); + query.set(option.toLowerCase(), params); + }); + } + + // Handle the request + switch (request.method) { + case "GET": + switch (path.toLowerCase()) { + case "/api/key": + case "/api/key/": + if ((query.has("user") && ((query.get("user") || "").length > 0)) && (query.has("a") && ((query.get("a") || "").length > 0))) { + if (apiUserid === config.api.admin && apiUserid === BigInt(query.get("a"))) { + // Generate new secure key + const newKey = await nanoid(25); + + // Flag to see if there is an error inside the catch + let erroredOut = false; + + // Insert new key/user pair into the db + await dbClient.execute("INSERT INTO all_keys(userid,apiKey) values(?,?)", [apiUserid, newKey]).catch(() => { + console.log("Failed to insert into database 20"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + + // Exit this case now if catch errored + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.OK, body: JSON.stringify({ "key": newKey, "userid": query.get("user") }) }); + break; + } + } else { + // Only allow the db admin to use this API + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + case "/api/channel": + case "/api/channel/": + if (query.has("user") && ((query.get("user") || "").length > 0)) { + if (apiUserid === BigInt(query.get("user"))) { + // Flag to see if there is an error inside the catch + let erroredOut = false; + + // Get all channels userid has authorized + const dbAllowedChannelQuery = await dbClient.query("SELECT * FROM allowed_channels WHERE userid = ?", [apiUserid]).catch(() => { + console.log("Failed to query database 22"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + + if (erroredOut) { + break; + } else { + // Customized strinification to handle BigInts correctly + const returnChannels = JSON.stringify(dbAllowedChannelQuery, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)); + // Send API key as response + request.respond({ status: Status.OK, body: returnChannels }); + break; + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + case "/api/roll": + case "/api/roll/": + // Make sure query contains all the needed parts + if ((query.has("rollstr") && ((query.get("rollstr") || "").length > 0)) && (query.has("channel") && ((query.get("channel") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { + if (query.has("n") && query.has("m")) { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + break; + } + + // Check if user is authenticated to use this endpoint + let authorized = false; + + // Check if the db has the requested userid/channelid combo, and that the requested userid matches the userid linked with the api key + const dbChannelQuery = await dbClient.query("SELECT active, banned FROM allowed_channels WHERE userid = ? AND channelid = ?", [apiUserid, BigInt(query.get("channel"))]); + if (dbChannelQuery.length === 1 && (apiUserid === BigInt(query.get("user"))) && dbChannelQuery[0].active && !dbChannelQuery[0].banned) { + + // Get the guild from the channel and make sure user is in said guild + const guild = cache.channels.get(query.get("channel") || "")?.guild; + if (guild && guild.members.get(query.get("user") || "")?.id) { + const dbGuildQuery = await dbClient.query("SELECT active, banned FROM allowed_guilds WHERE guildid = ?", [BigInt(guild.id)]); + + // Make sure guild allows API rolls + if (dbGuildQuery.length === 1 && dbGuildQuery[0].active && !dbGuildQuery[0].banned) { + authorized = true; + } + } + } + + if (authorized) { + // Rest of this command is in a try-catch to protect all sends/edits from erroring out + try { + // Flag to tell if roll was completely successful + let errorOut = false; + // Make sure rollCmd is not undefined + let rollCmd = query.get("rollstr") || ""; + const originalCommand = query.get("rollstr"); + + if (rollCmd.length === 0) { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, "EmptyInput", null]).catch(() => { + console.log("Failed to insert into database 10"); + }); + break; + } + + if (query.has("o") && (query.get("o")?.toLowerCase() !== "d" && query.get("o")?.toLowerCase() !== "a")) { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, "BadOrder", null]).catch(() => { + console.log("Failed to insert into database 10"); + }); + break; + } + + // Clip off the leading prefix. API calls must be formatted with a prefix at the start to match how commands are sent in Discord + rollCmd = rollCmd.substr(rollCmd.indexOf(config.prefix) + 2).replace(/%20/g, " "); + + // Parse the roll and get the return text + const returnmsg = solver.parseRoll(rollCmd, config.prefix, config.postfix, query.has("m"), query.has("n"), query.has("o") ? (query.get("o")?.toLowerCase() || "") : ""); + + // Alert users why this message just appeared and how they can report abues pf this feature + const apiPrefix = "The following roll was conducted using my built in API. If someone in this channel did not request this roll, please report API abuse here: <" + config.api.supportURL + ">\n\n"; + let m, returnText = ""; + + // Handle sending the error message to whoever called the api + if (returnmsg.error) { + request.respond({ status: Status.InternalServerError, body: returnmsg.errorMsg }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, returnmsg.errorCode, null]).catch(() => { + console.log("Failed to insert into database 11"); + }); + break; + } else { + returnText = apiPrefix + "<@" + query.get("user") + ">" + returnmsg.line1 + "\n" + returnmsg.line2; + let spoilerTxt = ""; + + // Determine if spoiler flag was on + if (query.has("s")) { + spoilerTxt = "||"; + } + + // Determine if no details flag was on + if (query.has("nd")) { + returnText += "\nDetails suppressed by nd query."; + } else { + returnText += "\nDetails:\n" + spoilerTxt + returnmsg.line3 + spoilerTxt; + } + } + + // If the roll was a GM roll, send DMs to all the GMs + if (query.has("gms")) { + // Get all the GM user IDs from the query + const gms = (query.get("gms") || "").split(","); + if (gms.length === 0) { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,1)", [originalCommand, "NoGMsSent", null]).catch(() => { + console.log("Failed to insert into database 12"); + }); + break; + } + + // Make a new return line to be sent to the roller + let normalText = apiPrefix + "<@" + query.get("user") + ">" + returnmsg.line1 + "\nResults have been messaged to the following GMs: "; + gms.forEach(e => { + normalText += "<@" + e + "> "; + }); + + // Send the return message as a DM or normal message depening on if the channel is set + if ((query.get("channel") || "").length > 0) { + m = await sendMessage(query.get("channel") || "", normalText).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 00 failed to send." }); + errorOut = true; + }); + } else { + m = await sendDirectMessage(query.get("user") || "", normalText).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 01 failed to send." }); + errorOut = true; + }); + } + + // And message the full details to each of the GMs, alerting roller of every GM that could not be messaged + gms.forEach(async e => { + // If its too big, collapse it into a .txt file and send that instead. + const b = await new Blob([returnText as BlobPart], { "type": "text" }); + + // Update return text + returnText = apiPrefix + "<@" + query.get("user") + ">" + returnmsg.line1 + "\n" + returnmsg.line2 + "\nFull details have been attached to this messaged as a `.txt` file for verification purposes."; + + // Attempt to DM the GMs and send a warning if it could not DM a GM + await sendDirectMessage(e, { "content": returnText, "file": { "blob": b, "name": "rollDetails.txt" } }).catch(async () => { + const failedSend = "WARNING: <@" + e + "> could not be messaged. If this issue persists, make sure direct messages are allowed from this server." + // Send the return message as a DM or normal message depening on if the channel is set + if ((query.get("channel") || "").length > 0) { + m = await sendMessage(query.get("channel") || "", failedSend).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 10 failed to send." }); + errorOut = true; + }); + } else { + m = await sendDirectMessage(query.get("user") || "", failedSend).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 11 failed to send." }); + errorOut = true; + }); + } + }); + }); + + // Always log API rolls for abuse detection + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,0)", [originalCommand, returnText, ((typeof m === "object") ? m.id : null)]).catch(() => { + console.log("Failed to insert into database 13"); + }); + + // Handle closing the request out + if (errorOut) { + break; + } else { + request.respond({ status: Status.OK, body: normalText }); + break; + } + } else { + const newMessage: MessageContent = {}; + newMessage.content = returnText; + + // When not a GM roll, make sure the message is not too big + if (returnText.length > 2000) { + // If its too big, collapse it into a .txt file and send that instead. + const b = await new Blob([returnText as BlobPart], { "type": "text" }); + + // Update return text + returnText = "<@" + query.get("user") + ">" + returnmsg.line1 + "\n" + returnmsg.line2 + "\nDetails have been ommitted from this message for being over 2000 characters. Full details have been attached to this messaged as a `.txt` file for verification purposes."; + + // Set info into the newMessage + newMessage.content = returnText; + newMessage.file = { "blob": b, "name": "rollDetails.txt" }; + } + + // Send the return message as a DM or normal message depening on if the channel is set + if ((query.get("channel") || "").length > 0) { + m = await sendMessage(query.get("channel") || "", newMessage).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 20 failed to send." }); + errorOut = true; + }); + } else { + m = await sendDirectMessage(query.get("user") || "", newMessage).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 21 failed to send." }); + errorOut = true; + }); + } + + // If enabled, log rolls so we can verify the bots math + dbClient.execute("INSERT INTO roll_log(input,result,resultid,api,error) values(?,?,?,1,0)", [originalCommand, returnText, ((typeof m === "object") ? m.id : null)]).catch(() => { + console.log("Failed to insert into database 14"); + }); + + // Handle closing the request out + if (errorOut) { + break; + } else { + request.respond({ status: Status.OK, body: returnText }); + break; + } + } + } catch (err) { + // Handle any errors we missed + console.log(err) + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + default: + // Alert API user that they messed up + request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); + break; + } + break; + case "POST": + switch (path.toLowerCase()) { + case "/api/channel/add": + case "/api/channel/add/": + if ((query.has("user") && ((query.get("user") || "").length > 0)) && (query.has("channel") && ((query.get("channel") || "").length > 0))) { + if (apiUserid === BigInt(query.get("user"))) { + // Flag to see if there is an error inside the catch + let erroredOut = false; + + // Insert new user/channel pair into the db + await dbClient.execute("INSERT INTO allowed_channels(userid,channelid) values(?,?)", [apiUserid, BigInt(query.get("channel"))]).catch(() => { + console.log("Failed to insert into database 21"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + + // Exit this case now if catch errored + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); + break; + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + default: + // Alert API user that they messed up + request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); + break; + } + break; + case "PUT": + switch (path.toLowerCase()) { + case "/api/key/ban": + case "/api/key/ban/": + case "/api/key/unban": + case "/api/key/unban/": + case "/api/key/activate": + case "/api/key/activate/": + case "/api/key/deactivate": + case "/api/key/deactivate/": + if ((query.has("a") && ((query.get("a") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { + if (apiUserid === config.api.admin && apiUserid === BigInt(query.get("a"))) { + // Flag to see if there is an error inside the catch + let key, value, erroredOut = false; + + // Determine key to edit + if (path.toLowerCase().indexOf("ban") > 0) { + key = "banned"; + } else { + key = "active"; + } + + // Determine value to set + if (path.toLowerCase().indexOf("de") > 0 || path.toLowerCase().indexOf("un") > 0) { + value = 0; + } else { + value = 1; + } + + // Execute the DB modification + await dbClient.execute("UPDATE all_keys SET ?? = ? WHERE userid = ?", [key, value, apiUserid]).catch(() => { + console.log("Failed to update database 28"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + + // Exit this case now if catch errored + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); + break; + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + case "/api/channel/ban": + case "/api/channel/ban/": + case "/api/channel/unban": + case "/api/channel/unban/": + if ((query.has("a") && ((query.get("a") || "").length > 0)) && (query.has("channel") && ((query.get("channel") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { + if (apiUserid === config.api.admin && apiUserid === BigInt(query.get("a"))) { + // Flag to see if there is an error inside the catch + let value, erroredOut = false; + + // Determine value to set + if (path.toLowerCase().indexOf("un") > 0) { + value = 0; + } else { + value = 1; + } + + // Execute the DB modification + await dbClient.execute("UPDATE allowed_channels SET banned = ? WHERE userid = ? AND channelid = ?", [value, apiUserid, BigInt(query.get("channel"))]).catch(() => { + console.log("Failed to update database 24"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + + // Exit this case now if catch errored + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); + break; + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + case "/api/channel/activate": + case "/api/channel/activate/": + case "/api/channel/deactivate": + case "/api/channel/deactivate/": + if ((query.has("channel") && ((query.get("channel") || "").length > 0)) && (query.has("user") && ((query.get("user") || "").length > 0))) { + if (apiUserid === BigInt(query.get("user"))) { + // Flag to see if there is an error inside the catch + let value, erroredOut = false; + + // Determine value to set + if (path.toLowerCase().indexOf("de") > 0) { + value = 0; + } else { + value = 1; + } + + // Update the requested entry + await dbClient.execute("UPDATE allowed_channels SET active = ? WHERE userid = ? AND channelid = ?", [value, apiUserid, BigInt(query.get("channel"))]).catch(() => { + console.log("Failed to update database 26"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + + // Exit this case now if catch errored + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); + break; + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + default: + // Alert API user that they messed up + request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); + break; + } + break; + case "DELETE": + switch (path.toLowerCase()) { + case "/api/key/delete": + case "/api/key/delete/": + if (query.has("user") && ((query.get("user") || "").length > 0) && query.has("email") && ((query.get("email") || "").length > 0)) { + if (apiUserid === BigInt(query.get("user")) && apiUserEmail === query.get("email")) { + if (query.has("code") && ((query.get("code") || "").length > 0)) { + if ((query.get("code") || "") === apiUserDelCode) { + // User has recieved their delete code and we need to delete the account now + let erroredOut = false; + + await dbClient.execute("DELETE FROM allowed_channels WHERE userid = ?", [apiUserid]).catch(() => { + console.log("Failed to delete from database 2A"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + if (erroredOut) { + break; + } + + await dbClient.execute("DELETE FROM all_keys WHERE userid = ?", [apiUserid]).catch(() => { + console.log("Failed to delete from database 2B"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); + break; + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // User does not have their delete code yet, so we need to generate one and email it to them + const deleteCode = await nanoid(10); + + let erroredOut = false; + + // Execute the DB modification + await dbClient.execute("UPDATE all_keys SET deleteCode = ? WHERE userid = ?", [deleteCode, apiUserid]).catch(() => { + console.log("Failed to update database 29"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + if (erroredOut) { + break; + } + + // "Send" the email + await sendMessage(config.api.email, `<@${config.api.admin}> A USER HAS REQUESTED A DELETE CODE\n\nEmail Address: ${apiUserEmail}\n\nSubject: \`Artificer API Delete Code\`\n\n\`\`\`Hello Artificer API User,\n\nI am sorry to see you go. If you would like, please respond to this email detailing what I could have done better.\n\nAs requested, here is your delete code: ${deleteCode}\n\nSorry to see you go,\nThe Artificer Developer - Ean Milligan\`\`\``).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 30 failed to send." }); + erroredOut = true; + }); + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.FailedDependency, body: STATUS_TEXT.get(Status.FailedDependency) }); + break; + } + } + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + default: + // Alert API user that they messed up + request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); + break; + } + break; + default: + // Alert API user that they messed up + request.respond({ status: Status.MethodNotAllowed, body: STATUS_TEXT.get(Status.MethodNotAllowed) }); + break; + } + + if (updateRateLimitTime) { + const apiTimeNow = new Date().getTime(); + rateLimitTime.set(apiUseridStr, apiTimeNow); + } + } else if (!authenticated && !rateLimited) { + // Get path and query as a string + const [path, tempQ] = request.url.split("?"); + + // Turn the query into a map (if it exists) + const query = new Map(); + if (tempQ !== undefined) { + tempQ.split("&").forEach(e => { + const [option, params] = e.split("="); + query.set(option.toLowerCase(), params); + }); + } + + // Handle the request + switch (request.method) { + case "GET": + switch (path.toLowerCase()) { + case "/api/key": + case "/api/key/": + if ((query.has("user") && ((query.get("user") || "").length > 0)) && (query.has("email") && ((query.get("email") || "").length > 0))) { + // Generate new secure key + const newKey = await nanoid(25); + + // Flag to see if there is an error inside the catch + let erroredOut = false; + + // Insert new key/user pair into the db + await dbClient.execute("INSERT INTO all_keys(userid,apiKey,email) values(?,?,?)", [BigInt(query.get("user")), newKey, (query.get("email") || "").toLowerCase()]).catch(() => { + console.log("Failed to insert into database 20"); + request.respond({ status: Status.InternalServerError, body: STATUS_TEXT.get(Status.InternalServerError) }); + erroredOut = true; + }); + + // Exit this case now if catch errored + if (erroredOut) { + break; + } + + // "Send" the email + await sendMessage(config.api.email, `<@${config.api.admin}> A USER HAS REQUESTED AN API KEY\n\nEmail Address: ${query.get("email")}\n\nSubject: \`Artificer API Key\`\n\n\`\`\`Hello Artificer API User,\n\nWelcome aboard The Artificer's API. You can find full details about the API on the GitHub: https://github.com/Burn-E99/TheArtificer\n\nYour API Key is: ${newKey}\n\nGuard this well, as there is zero tolerance for API abuse.\n\nWelcome aboard,\nThe Artificer Developer - Ean Milligan\`\`\``).catch(() => { + request.respond({ status: Status.InternalServerError, body: "Message 31 failed to send." }); + erroredOut = true; + }); + + if (erroredOut) { + break; + } else { + // Send API key as response + request.respond({ status: Status.OK, body: STATUS_TEXT.get(Status.OK) }); + break; + } + } else { + // Alert API user that they messed up + request.respond({ status: Status.BadRequest, body: STATUS_TEXT.get(Status.BadRequest) }); + } + break; + default: + // Alert API user that they messed up + request.respond({ status: Status.NotFound, body: STATUS_TEXT.get(Status.NotFound) }); + break; + } + break; + default: + // Alert API user that they messed up + request.respond({ status: Status.MethodNotAllowed, body: STATUS_TEXT.get(Status.MethodNotAllowed) }); + break; + } + } else if (authenticated && rateLimited) { + // Alert API user that they are doing this too often + request.respond({ status: Status.TooManyRequests, body: STATUS_TEXT.get(Status.TooManyRequests) }); + } else { + // Alert API user that they shouldn't be doing this + request.respond({ status: Status.Forbidden, body: STATUS_TEXT.get(Status.Forbidden) }); + } + } +}; + +export default { start }; diff --git a/src/intervals.ts b/src/intervals.ts new file mode 100644 index 0000000..334ba18 --- /dev/null +++ b/src/intervals.ts @@ -0,0 +1,36 @@ +/* The Artificer was built in memory of Babka + * With love, Ean + * + * December 21, 2020 + */ + +import { + // Discordeno deps + CacheData +} from "../deps.ts"; + +import config from "../config.ts"; + +// getRandomStatus(bot cache) returns status as string +// Gets a new random status for the bot +const getRandomStatus = (cache: CacheData): string => { + let status = ""; + switch (Math.floor((Math.random() * 4) + 1)) { + case 1: + status = `${config.prefix}help for commands`; + break; + case 2: + status = `Running V${config.version}`; + break; + case 3: + status = `${config.prefix}info to learn more`; + break; + default: + status = `Rolling dice for ${cache.guilds.size} servers`; + break; + } + + return status; +}; + +export default { getRandomStatus }; diff --git a/src/mod.d.ts b/src/mod.d.ts index 5ebce5b..e1f3cfb 100644 --- a/src/mod.d.ts +++ b/src/mod.d.ts @@ -1,5 +1,6 @@ // mod.d.ts custom types +// EmojiConf is used as a structure for the emojis stored in config.ts export type EmojiConf = { "name": string, "aliases": Array, diff --git a/src/solver.ts b/src/solver.ts index 26b54a8..4f1be5e 100644 --- a/src/solver.ts +++ b/src/solver.ts @@ -1001,4 +1001,4 @@ const parseRoll = (fullCmd: string, localPrefix: string, localPostfix: string, m return returnmsg; }; -export default { parseRoll }; \ No newline at end of file +export default { parseRoll }; diff --git a/src/utils.ts b/src/utils.ts index c2bd159..5a3bd3d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,7 +4,10 @@ * December 21, 2020 */ -import { Message, MessageContent } from "https://deno.land/x/discordeno@10.3.0/mod.ts"; +import { + // Discordeno deps + Message, MessageContent +} from "../deps.ts"; // split2k(longMessage) returns shortMessage[] // split2k takes a long string in and cuts it into shorter strings to be sent in Discord @@ -140,4 +143,6 @@ const sendIndirectMessage = async (originalMessage: Message, messageContent: (st } }; +// Write logging function with trace and whatnot for errors and necessary messages to log, log bot state in server to determine if user is at fault or if I am at fault (maybe message user if its their fault?) + export default { split2k, cmdPrompt, sendIndirectMessage }; diff --git a/www/api/index.html b/www/api/index.html index 0e61a96..73b16f4 100644 --- a/www/api/index.html +++ b/www/api/index.html @@ -122,7 +122,7 @@ Built by Ean Milligan diff --git a/www/home/index.html b/www/home/index.html index fe071e1..1c69bb9 100644 --- a/www/home/index.html +++ b/www/home/index.html @@ -94,7 +94,7 @@ Built by Ean Milligan