Made radical changes and setup foundation for SQLite database

This commit is contained in:
WatDuhHekBro 2021-12-08 01:27:56 -06:00
parent 69a8452574
commit 16e42be58d
No known key found for this signature in database
GPG Key ID: E128514902DF8A05
22 changed files with 715 additions and 639 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ test*
!test/
*.bat
desktop.ini
*.db
# Logs
logs

View File

@ -2,6 +2,7 @@
- [Structure](#structure)
- [Version Numbers](#version-numbers)
- [Environment Variables](#environment-variables)
- [Utility Functions](#utility-functions)
- [Testing](#testing)
@ -34,6 +35,17 @@ Because versions are assigned to batches of changes rather than single changes (
*Note: This system doesn't retroactively apply to TravBot-v2, which is why this version naming system won't make sense for v2's changelog.*
# Environment Variables
Certain variables are set via `.env` at the project root. These are for system configuration and should never change dynamically within the program, essentially read-only variables.
- `TOKEN`: Your bot's token
- `PREFIX`: Your bot's prefix
- `OWNER`: The ID of the owner
- `ADMINS`: A comma-separated (with a space) list of bot admin IDs
- `SUPPORT`: A comma-separated (with a space) list of bot support IDs
- `WOLFRAM_API_KEY`: Used for `commands/utility/calc`
- `DEV`: Enables dev mode as long as it isn't a falsy value (`DEV=1` works for example)
# Utility Functions
## [src/lib](../src/lib.ts) - General utility functions

View File

@ -52,7 +52,7 @@ Rather than have an `events` folder which contains dynamically loaded events, yo
```ts
import {client} from "..";
client.on("message", (message) => {
client.on("messageCreate", (message) => {
//...
});
```

1002
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,23 +4,20 @@
"description": "TravBot Discord bot.",
"main": "dist/index.js",
"scripts": {
"start": "node -r dotenv/config .",
"build": "rimraf dist && tsc --project tsconfig.prod.json && npm prune --production",
"start": "node .",
"once": "tsc && npm start",
"dev": "tsc-watch --onSuccess \"npm run dev-instance\"",
"dev-fast": "tsc-watch --onSuccess \"node . dev\"",
"dev-instance": "rimraf dist && tsc && node . dev",
"dev": "tsc-watch --onSuccess \"npm start\"",
"test": "jest",
"format": "prettier --write **/*",
"postinstall": "husky install"
},
"dependencies": {
"better-sqlite3": "^7.4.5",
"canvas": "^2.8.0",
"chalk": "^4.1.2",
"discord.js": "^13.3.0",
"dotenv": "^10.0.0",
"figlet": "^1.5.2",
"glob": "^7.2.0",
"inquirer": "^8.2.0",
"moment": "^2.29.1",
"ms": "^2.1.3",
"node-wolfram-alpha": "^1.2.5",
@ -30,9 +27,8 @@
"weather-js": "^2.0.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.4.1",
"@types/figlet": "^1.5.4",
"@types/glob": "^7.2.0",
"@types/inquirer": "^8.1.3",
"@types/jest": "^27.0.2",
"@types/mathjs": "^9.4.1",
"@types/ms": "^0.7.31",

View File

@ -8,7 +8,7 @@ module.exports = {
jsxSingleQuote: false,
trailingComma: "none",
bracketSpacing: false,
jsxBracketSameLine: false,
bracketSameLine: false,
arrowParens: "always",
endOfLine: "auto" // Apparently, the GitHub repository still uses CRLF. I don't know how to force it to use LF, and until someone figures that out, I'm changing this to auto because I don't want more than one line ending commit.
};

View File

@ -17,7 +17,7 @@ export const BetCommand = new NamedCommand({
// handle invalid target
if (target.id == author.id) return send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev") return send("You can't bet Mons with a bot!");
else if (target.bot && !process.env.DEV) return send("You can't bet Mons with a bot!");
return send("How much are you betting?");
} else return;
@ -34,7 +34,7 @@ export const BetCommand = new NamedCommand({
// handle invalid target
if (target.id == author.id) return send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev") return send("You can't bet Mons with a bot!");
else if (target.bot && !process.env.DEV) return send("You can't bet Mons with a bot!");
// handle invalid amount
if (amount <= 0) return send("You must bet at least one Mon!");
@ -68,7 +68,7 @@ export const BetCommand = new NamedCommand({
// handle invalid target
if (target.id == author.id) return send("You can't bet Mons with yourself!");
else if (target.bot && !IS_DEV_MODE) return send("You can't bet Mons with a bot!");
else if (target.bot && !!process.env.DEV) return send("You can't bet Mons with a bot!");
// handle invalid amount
if (amount <= 0) return send("You must bet at least one Mon!");

View File

@ -143,7 +143,7 @@ export const PayCommand = new NamedCommand({
embeds: [getMoneyEmbed(author, true)]
});
else if (target.id === author.id) return send("You can't send Mons to yourself!");
else if (target.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!");
else if (target.bot && !process.env.DEV) return send("You can't send Mons to a bot!");
sender.money -= amount;
receiver.money += amount;
@ -179,7 +179,7 @@ export const PayCommand = new NamedCommand({
const user = await getUserByNickname(args.join(" "), guild);
if (typeof user === "string") return send(user);
else if (user.id === author.id) return send("You can't send Mons to yourself!");
else if (user.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!");
else if (user.bot && !process.env.DEV) return send("You can't send Mons to a bot!");
const confirmed = await confirm(
await send({

View File

@ -42,7 +42,7 @@ export const AwardCommand = new NamedCommand({
run: "You need to specify a user!",
user: new Command({
async run({send, author, args}) {
if (author.id === "394808963356688394" || IS_DEV_MODE) {
if (author.id === "394808963356688394" || process.env.DEV) {
const target = args[0] as User;
const user = Storage.getUser(target.id);
user.money++;
@ -54,7 +54,7 @@ export const AwardCommand = new NamedCommand({
},
number: new Command({
async run({send, author, args}) {
if (author.id === "394808963356688394" || IS_DEV_MODE) {
if (author.id === "394808963356688394" || process.env.DEV) {
const target = args[0] as User;
const amount = Math.floor(args[1]);

View File

@ -90,7 +90,7 @@ export function getSendEmbed(sender: User, receiver: User, amount: number): obje
}
export function isAuthorized(guild: Guild | null, channel: TextBasedChannels): boolean {
if (IS_DEV_MODE) {
if (process.env.DEV) {
return true;
}

View File

@ -1,7 +1,7 @@
import {Command, NamedCommand, getPermissionLevel, getPermissionName, CHANNEL_TYPE, RestCommand} from "onion-lasers";
import {Config, Storage} from "../../structures";
import {Config, Storage, getPrefix} from "../../structures";
import {Permissions, TextChannel, User, Role, Channel, Util} from "discord.js";
import {logs} from "../../modules/globals";
import {logs} from "../../modules/logger";
function getLogBuffer(type: string) {
return {
@ -38,7 +38,7 @@ export default new NamedCommand({
Storage.getGuild(guild!.id).prefix = null;
Storage.save();
send(
`The custom prefix for this guild has been removed. My prefix is now back to \`${Config.prefix}\`.`
`The custom prefix for this guild has been removed. My prefix is now back to \`${getPrefix()}\`.`
);
},
any: new Command({

View File

@ -1,16 +1,15 @@
import {NamedCommand, RestCommand} from "onion-lasers";
import {WolframClient} from "node-wolfram-alpha";
import {MessageEmbed} from "discord.js";
import {Config} from "../../structures";
export default new NamedCommand({
description: "Calculates a specified math expression.",
run: "Please provide a calculation.",
any: new RestCommand({
async run({send, combined}) {
if (Config.wolfram === null) return send("There's no Wolfram token in the config.");
if (!process.env.WOLFRAM_API_KEY) return send("There's no Wolfram API key in `.env`.");
const wClient = new WolframClient(Config.wolfram);
const wClient = new WolframClient(process.env.WOLFRAM_API_KEY);
let resp;
try {
resp = await wClient.query(combined);

View File

@ -1,4 +1,4 @@
import {MessageEmbed, version as djsversion, Guild, User, GuildMember, TextChannel, VoiceChannel} from "discord.js";
import {MessageEmbed, version, Guild, User, GuildMember, TextChannel, VoiceChannel} from "discord.js";
import ms from "ms";
import os from "os";
import {Command, NamedCommand, getUserByNickname, CHANNEL_TYPE, getGuildByName, RestCommand} from "onion-lasers";
@ -68,8 +68,12 @@ export default new NamedCommand({
"Do MMMM YYYY HH:mm:ss"
)}`,
`** Node.JS:** ${process.version}`,
`** Version:** v${process.env.npm_package_version}`,
`** Discord.JS:** v${djsversion}`,
`** Version:** ${
process.env.npm_package_version
? `v${process.env.npm_package_version}`
: "*Unable to fetch version, make sure to start the project via `npm start`, not `node`!*"
}`,
`** Discord.JS:** v${version}`,
"\u200b"
].join("\n")
)

View File

@ -1,6 +1,5 @@
import "./modules/globals";
import "./modules/logger";
import {Client, Permissions, Intents} from "discord.js";
import path from "path";
// This is here in order to make it much less of a headache to access the client from other files.
// This of course won't actually do anything until the setup process is complete and it logs in.
@ -17,18 +16,16 @@ export const client = new Client({
]
});
import {join} from "path";
import {launch} from "onion-lasers";
import setup from "./modules/setup";
import {Config, getPrefix} from "./structures";
import {getPrefix} from "./structures";
import {toTitleCase} from "./lib";
// Send the login request to Discord's API and then load modules while waiting for it.
setup.init().then(() => {
client.login(Config.token).catch(setup.again);
});
client.login(process.env.TOKEN).catch(console.error);
// Setup the command handler.
launch(client, path.join(__dirname, "commands"), {
launch(client, join(__dirname, "commands"), {
getPrefix,
categoryTransformer: toTitleCase,
permissionLevels: [
@ -60,17 +57,17 @@ launch(client, path.join(__dirname, "commands"), {
{
// BOT_SUPPORT //
name: "Bot Support",
check: (user) => Config.support.includes(user.id)
check: (user) => !!process.env.SUPPORT && process.env.SUPPORT.split(", ").includes(user.id)
},
{
// BOT_ADMIN //
name: "Bot Admin",
check: (user) => Config.admins.includes(user.id)
check: (user) => !!process.env.ADMINS && process.env.ADMINS.split(", ").includes(user.id)
},
{
// BOT_OWNER //
name: "Bot Owner",
check: (user) => Config.owner === user.id
check: (user) => process.env.OWNER === user.id
}
]
});

145
src/modules/database.ts Normal file
View File

@ -0,0 +1,145 @@
import Database from "better-sqlite3";
import {existsSync} from "fs";
import {join} from "path";
// This section will serve as the documentation for the database, because in order to guarantee
// that a database created now will have the same structure as a database that has been migrated
// through different versions, a new database starts at version one and goes through the same
// migration process. Creating separate statements for migrations and creating a new database will
// allow for some dangerous out of sync definitions. For example, version 9 via migration might
// have a column that forgot to be dropped while version 9 via creation won't include that column,
// so when someone tries to use an INSERT statement, it'll throw an error because of discrepancies.
// -=[ Current Schema ]=-
// System: Version (INT UNIQUE)
// Users: ID, Money (INT), LastReceived (TIME), LastMonday (TIME), TimezoneOffset (INT NULLABLE), DaylightSavingsRegion (INT), EcoBetInsurance (INT)
// Guilds: ID, Prefix (TEXT NULLABLE), WelcomeType (INT), WelcomeChannel (TEXT NULLABLE), WelcomeMessage (TEXT NULLABLE), StreamingChannel (TEXT NULLABLE), HasMessageEmbeds (BOOL)
// Members: UserID, GuildID, StreamCategory (TEXT NULLABLE)
// Webhooks: ID, Token (TEXT)
// TodoLists: UserID, Timestamp (TIME), Entry (TEXT)
// StreamingRoles: GuildID, RoleID, Category (TEXT)
// DefaultChannelNames: GuildID, ChannelID, Name (TEXT)
// AutoRoles: GuildID, RoleID
// -=[ Notes ]=-
// - Unless otherwise directed above (NULLABLE), assume the "NOT NULL" constraint.
// - IDs use the "UNIQUE ON CONFLICT REPLACE" constraint to enable implicit UPSERT statements.
// - This way, you don't need to do INSERT INTO ... ON CONFLICT(...) DO UPDATE SET ...
// - For the sake of simplicity, any Discord ID will be stored and retrieved as a string.
// - Any datetime stuff (marked as TIME) will be stored as a UNIX timestamp in milliseconds (INT).
// - Booleans (marked as BOOL) will be stored as an integer, either 0 or 1 (though it just checks for 0).
// Calling migrations[2]() migrates the database from version 2 to version 3.
// NOTE: Once a migration is written, DO NOT change that migration or it'll break all future migrations.
const migrations: (() => void)[] = [
() => {
const hasLegacyData = existsSync(join("data", "config.json")) && existsSync(join("data", "storage.json"));
// Generate initial state
// Stuff like CREATE TABLE IF NOT EXISTS should be handled by the migration system.
generateSQLMigration([
`CREATE TABLE System (
Version INT NOT NULL UNIQUE
)`,
"INSERT INTO System VALUES (1)",
`CREATE TABLE Users (
ID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
Money INT NOT NULL DEFAULT 0,
LastReceived INT NOT NULL DEFAULT -1,
LastMonday INT NOT NULL DEFAULT -1,
TimezoneOffset INT,
DaylightSavingsRegion INT NOT NULL DEFAULT 0,
EcoBetInsurance INT NOT NULL DEFAULT 0
)`,
`CREATE TABLE Guilds (
ID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
Prefix TEXT,
WelcomeType INT NOT NULL DEFAULT 0,
WelcomeChannel TEXT,
WelcomeMessage TEXT,
StreamingChannel TEXT,
HasMessageEmbeds INT NOT NULL CHECK(HasMessageEmbeds BETWEEN 0 AND 1) DEFAULT 1
)`,
`CREATE TABLE Members (
UserID TEXT NOT NULL,
GuildID TEXT NOT NULL,
StreamCategory TEXT,
UNIQUE (UserID, GuildID) ON CONFLICT REPLACE
)`,
`CREATE TABLE Webhooks (
ID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
Token TEXT NOT NULL
)`,
`CREATE TABLE TodoLists (
UserID TEXT NOT NULL,
Timestamp INT NOT NULL,
Entry TEXT NOT NULL
)`,
`CREATE TABLE StreamingRoles (
GuildID TEXT NOT NULL,
RoleID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
Category TEXT NOT NULL
)`,
`CREATE TABLE DefaultChannelNames (
GuildID TEXT NOT NULL,
ChannelID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE,
Name TEXT NOT NULL
)`,
`CREATE TABLE AutoRoles (
GuildID TEXT NOT NULL,
RoleID TEXT NOT NULL UNIQUE ON CONFLICT REPLACE
)`
])();
// Load initial data if present
if (hasLegacyData) {
generateSQLMigration([])();
}
}
// "UPDATE System SET Version=2" when the time comes
];
const isExistingDatabase = existsSync(join("data", "main.db"));
export const db = new Database(join("data", "main.db"));
let version = -1;
// Get existing version if applicable and throw error if corrupt data.
// The data is considered corrupt if it exists and:
// - The System table doesn't exist (throws an error)
// - There isn't exactly one entry in System for Version
if (isExistingDatabase) {
try {
const {Version, Amount} = db.prepare("SELECT Version, Count(Version) AS Amount FROM System").get() as {
Version: number | null;
Amount: number;
};
if (!Version) {
console.error("No version entry in the System table.");
} else if (Amount === 1) {
version = Version;
} else {
console.error("More than one version entry in the System table.");
}
} catch (error) {
console.error(error);
console.error("Invalid database, take a look at it manually.");
}
} else {
version = 0;
}
// Then loop through all the versions
if (version !== -1) {
for (let v = version; v < migrations.length; v++) {
migrations[v]();
}
}
function generateSQLMigration(statements: string[]): () => void {
return () => {
for (const statement of statements) {
db.prepare(statement).run();
}
};
}

View File

@ -5,7 +5,7 @@ import {client} from "../index";
// - "oil" will remain the same though, it's better that way (anything even remotely "oil"-related calls the image)
// - Also uwu and owo penalties
client.on("message", (message) => {
client.on("messageCreate", (message) => {
if (message.content.toLowerCase().includes("remember to drink water")) {
message.react("🚱");
}

View File

@ -1,9 +1,7 @@
import chalk from "chalk";
declare global {
var IS_DEV_MODE: boolean;
var PERMISSIONS: typeof PermissionsEnum;
var BOT_VERSION: string;
interface Console {
ready: (...data: any[]) => void;
@ -20,9 +18,7 @@ enum PermissionsEnum {
BOT_OWNER
}
global.IS_DEV_MODE = process.argv[2] === "dev";
global.PERMISSIONS = PermissionsEnum;
global.BOT_VERSION = "3.2.3";
const oldConsole = console;
@ -84,7 +80,8 @@ console = {
// $.debug(`core/lib::parseArgs("testing \"in progress\"") = ["testing", "in progress"]`) --> <path>/::(<object>.)<function>(<args>) = <value>
// Would probably be more suited for debugging program logic rather than function logic, which can be checked using unit tests.
debug(...args: any[]) {
if (IS_DEV_MODE) oldConsole.debug(chalk.white.bgGray(formatTimestamp()), chalk.white.bgBlue("DEBUG"), ...args);
if (process.env.DEV)
oldConsole.debug(chalk.white.bgGray(formatTimestamp()), chalk.white.bgBlue("DEBUG"), ...args);
const text = `[${formatUTCTimestamp()}] [DEBUG] ${args.join(" ")}\n`;
logs.verbose += text;
},
@ -96,5 +93,3 @@ console = {
logs.verbose += text;
}
};
console.log("Loading globals...");

View File

@ -3,7 +3,7 @@ import {MessageEmbed} from "discord.js";
import {getPrefix} from "../structures";
import {getMessageByID} from "onion-lasers";
client.on("message", (message) => {
client.on("messageCreate", (message) => {
(async () => {
// Only execute if the message is from a user and isn't a command.
if (message.content.startsWith(getPrefix(message.guild)) || message.author.bot) return;

View File

@ -1,5 +1,5 @@
import {client} from "../index";
import {Config, Storage} from "../structures";
import {Storage, getPrefix} from "../structures";
client.once("ready", () => {
if (client.user) {
@ -8,7 +8,7 @@ client.once("ready", () => {
);
client.user.setActivity({
type: "LISTENING",
name: `${Config.prefix}help`
name: `${getPrefix()}help`
});
// Run this setup block once to restore eco bet money in case the bot went down. (And I guess search the client for those users to let them know too.)

View File

@ -1,73 +0,0 @@
import {existsSync as exists} from "fs";
import inquirer from "inquirer";
import Storage from "./storage";
import {Config} from "../structures";
// This file is called (or at least should be called) automatically as long as a config file doesn't exist yet.
// And that file won't be written until the data is successfully initialized.
const prompts = [
{
type: "password",
name: "token",
message: "What's your bot's token?",
mask: true
},
{
type: "input",
name: "prefix",
message: "What do you want your bot's prefix to be?",
default: "$"
},
{
type: "input",
name: "owner",
message: "Enter the owner's user ID here."
},
{
type: "input",
name: "admins",
message: "Enter a list of bot admins (by their IDs) separated by spaces."
},
{
type: "input",
name: "support",
message: "Enter a list of bot troubleshooters (by their IDs) separated by spaces."
}
];
export default {
async init() {
while (!exists("data/config.json")) {
const answers = await inquirer.prompt(prompts);
Storage.open("data");
Config.token = answers.token as string;
Config.prefix = answers.prefix as string;
Config.owner = answers.owner as string;
const admins = answers.admins as string;
Config.admins = admins !== "" ? admins.split(" ") : [];
const support = answers.support as string;
Config.support = support !== "" ? support.split(" ") : [];
Config.save(false);
}
},
/** Prompt the user to set their token again. */
async again() {
console.error("It seems that the token you provided is invalid.");
// Deactivate the console //
const oldConsole = console;
console = {
...oldConsole,
log() {},
warn() {},
error() {},
debug() {},
ready() {}
};
const answers = await inquirer.prompt(prompts.slice(0, 1));
Config.token = answers.token as string;
Config.save(false);
process.exit();
}
};

View File

@ -13,7 +13,9 @@ const Storage = {
try {
data = JSON.parse(file);
} catch (error) {
if (process.argv[2] !== "dev") {
console.error(error, file);
if (!process.env.DEV) {
console.warn("[storage.read]", `Malformed JSON data (header: ${header}), backing it up.`, file);
fs.writeFile(`${path}.backup`, file, (error) => {
if (error) console.error("[storage.read]", error);
@ -30,7 +32,7 @@ const Storage = {
this.open("data");
const path = `data/${header}.json`;
if (IS_DEV_MODE || header === "config") {
if (process.env.DEV || header === "config") {
const result = JSON.stringify(data, null, "\t");
if (asynchronous)

View File

@ -8,25 +8,11 @@ import {Guild as DiscordGuild, Snowflake} from "discord.js";
// And maybe use Collections/Maps instead of objects?
class ConfigStructure extends GenericStructure {
public token: string;
public prefix: string;
public owner: string;
public admins: string[];
public support: string[];
public lavalink: boolean | null;
public wolfram: string | null;
public systemLogsChannel: string | null;
public webhooks: {[id: string]: string}; // id-token pairs
constructor(data: GenericJSON) {
super("config");
this.token = select(data.token, "<ENTER YOUR TOKEN HERE>", String);
this.prefix = select(data.prefix, "$", String);
this.owner = select(data.owner, "", String);
this.admins = select(data.admins, [], String, true);
this.support = select(data.support, [], String, true);
this.lavalink = select(data.lavalink, null, Boolean);
this.wolfram = select(data.wolfram, null, String);
this.systemLogsChannel = select(data.systemLogsChannel, null, String);
this.webhooks = {};
@ -211,7 +197,7 @@ export let Storage = new StorageStructure(FileManager.read("storage"));
// This part will allow the user to manually edit any JSON files they want while the program is running which'll update the program's cache.
// However, fs.watch is a buggy mess that should be avoided in production. While it helps test out stuff for development, it's not a good idea to have it running outside of development as it causes all sorts of issues.
if (IS_DEV_MODE) {
if (process.env.DEV) {
watch("data", (_event, filename) => {
const header = filename.substring(0, filename.indexOf(".json"));
@ -229,19 +215,17 @@ if (IS_DEV_MODE) {
/**
* Get the current prefix of the guild or the bot's prefix if none is found.
*/
export function getPrefix(guild: DiscordGuild | null): string {
let prefix = Config.prefix;
export function getPrefix(guild?: DiscordGuild | null): string {
if (guild) {
const possibleGuildPrefix = Storage.getGuild(guild.id).prefix;
// Here, lossy comparison works in our favor because you wouldn't want an empty string to trigger the prefix.
if (possibleGuildPrefix) {
prefix = possibleGuildPrefix;
return possibleGuildPrefix;
}
}
return prefix;
return process.env.PREFIX || "$";
}
export interface EmoteRegistryDumpEntry {