diff --git a/.prettierignore b/.prettierignore index 28e6c5e..b7d9b96 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,7 @@ LICENSE # Specific to this repository dist/ data/ +public/ docs/ *.md tmp/ diff --git a/docs/Documentation.md b/docs/Documentation.md index 27935b3..c165115 100644 --- a/docs/Documentation.md +++ b/docs/Documentation.md @@ -44,7 +44,8 @@ Certain variables are set via `.env` at the project root. These are for system c - `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) +- `DEV`: Enables dev mode as long as it isn't a falsy value (`DEV=1` works for example), specific values can be checked to test certain features +- `DEV_DATABASE`: Specifies the file to use for trying out changes on a database (`DEV_DATABASE=test` writes to `data/test.db`) # Utility Functions diff --git a/src/commands/fun/modules/eco-core.ts b/src/commands/fun/modules/eco-core.ts index 13d1b60..c8bcc4e 100644 --- a/src/commands/fun/modules/eco-core.ts +++ b/src/commands/fun/modules/eco-core.ts @@ -88,17 +88,16 @@ export const LeaderboardCommand = new NamedCommand({ async run({send, guild, channel, client}) { if (isAuthorized(guild, channel)) { const users = User.all(); - const ids = Object.keys(users); - ids.sort((a, b) => users[b].money - users[a].money); + users.sort((a, b) => b.money - a.money); const fields = []; - for (let i = 0, limit = Math.min(10, ids.length); i < limit; i++) { - const id = ids[i]; + for (let i = 0, limit = Math.min(10, users.length); i < limit; i++) { + const id = users[i].id; const user = await client.users.fetch(id); fields.push({ name: `#${i + 1}. ${user.tag}`, - value: pluralise(users[id].money, "Mon", "s") + value: pluralise(users[i].money, "Mon", "s") }); } diff --git a/src/commands/system/admin.ts b/src/commands/system/admin.ts index 6587d49..29b5d65 100644 --- a/src/commands/system/admin.ts +++ b/src/commands/system/admin.ts @@ -214,7 +214,7 @@ export default new NamedCommand({ any: new RestCommand({ async run({send, guild, args, combined}) { const role = args[0] as Role; - new Guild(guild!.id).streamingRoles.set(role.id, combined); + new Guild(guild!.id).setStreamingRole(role.id, combined); send( `Successfully set the category \`${combined}\` to notify \`${role.name}\`.` ); @@ -229,11 +229,14 @@ export default new NamedCommand({ async run({send, guild, args}) { const role = args[0] as Role; const guildStorage = new Guild(guild!.id); - const category = guildStorage.streamingRoles.get(role.id); - delete guildStorage.streamingRoles[role.id]; - send( - `Successfully removed the category \`${category}\` to notify \`${role.name}\`.` - ); + const category = guildStorage.getStreamingRole(role.id); + if (guildStorage.removeStreamingRole(role.id)) { + send( + `Successfully removed the category \`${category}\` to notify \`${role.name}\`.` + ); + } else { + send(`Failed to remove streaming role \`${role.id}\` (\`${category}\`).`); + } } }) }) @@ -248,8 +251,12 @@ export default new NamedCommand({ const voiceChannel = message.member?.voice.channel; if (!voiceChannel) return send("You are not in a voice channel."); const guildStorage = new Guild(guild!.id); - delete guildStorage.defaultChannelNames[voiceChannel.id]; - return send(`Successfully removed the default channel name for ${voiceChannel}.`); + + if (guildStorage.removeDefaultChannelName(voiceChannel.id)) { + send(`Successfully removed the default channel name for ${voiceChannel}.`); + } else { + send(`Failed to remove the default channel name for ${voiceChannel}`); + } }, any: new RestCommand({ async run({send, guild, message, combined}) { @@ -262,7 +269,7 @@ export default new NamedCommand({ if (!guild!.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS)) return send("I can't change channel names without the `Manage Channels` permission."); - guildStorage.defaultChannelNames.set(voiceChannel.id, newName); + guildStorage.setDefaultChannelName(voiceChannel.id, newName); return await send(`Set default channel name to "${newName}".`); } }) diff --git a/src/commands/utility/streaminfo.ts b/src/commands/utility/streaminfo.ts index 81fc36f..df6c131 100644 --- a/src/commands/utility/streaminfo.ts +++ b/src/commands/utility/streaminfo.ts @@ -115,7 +115,7 @@ export default new NamedCommand({ let found = false; // Check if it's a valid category - for (const [roleID, categoryName] of Object.entries(guildStorage.streamingRoles)) { + for (const [roleID, categoryName] of guildStorage.getStreamingRoleEntries()) { if (combined === categoryName) { found = true; memberStorage.streamCategory = roleID; @@ -133,10 +133,16 @@ export default new NamedCommand({ } if (!found) { + const categories = []; + + for (const [_, category] of guildStorage.getStreamingRoleEntries()) { + categories.push(category); + } + send( - `No valid category found by \`${combined}\`! The available categories are: \`${Object.values( - guildStorage.streamingRoles - ).join(", ")}\`` + `No valid category found by \`${combined}\`! The available categories are: \`${categories.join( + ", " + )}\`` ); } } diff --git a/src/commands/utility/todo.ts b/src/commands/utility/todo.ts index c629320..d3d4367 100644 --- a/src/commands/utility/todo.ts +++ b/src/commands/utility/todo.ts @@ -1,4 +1,4 @@ -import {NamedCommand, RestCommand} from "onion-lasers"; +import {Command, NamedCommand, RestCommand} from "onion-lasers"; import moment from "moment"; import {User} from "../../lib"; import {MessageEmbed} from "discord.js"; @@ -9,11 +9,12 @@ export default new NamedCommand({ const user = new User(author.id); const embed = new MessageEmbed().setTitle(`Todo list for ${author.tag}`).setColor("BLUE"); - for (const timestamp in user.todoList) { - const date = new Date(Number(timestamp)); + for (const [id, {entry, lastModified}] of user.getTodoEntries()) { embed.addField( - `${moment(date).format("LT")} ${moment(date).format("LL")} (${moment(date).fromNow()})`, - user.todoList[timestamp] + `\`${id}\`: ${moment(lastModified).format("LT")} ${moment(lastModified).format("LL")} (${moment( + lastModified + ).fromNow()})`, + entry ); } @@ -24,40 +25,29 @@ export default new NamedCommand({ run: "You need to specify a note to add.", any: new RestCommand({ async run({send, author, combined}) { - const user = new User(author.id); - user.todoList[Date.now().toString()] = combined; - Storage.save(); + new User(author.id).addTodoEntry(combined); send(`Successfully added \`${combined}\` to your todo list.`); } }) }), remove: new NamedCommand({ run: "You need to specify a note to remove.", - any: new RestCommand({ - async run({send, author, combined}) { + number: new Command({ + async run({send, author, args}) { const user = new User(author.id); - let isFound = false; + const success = user.removeTodoEntry(args[0]); - for (const timestamp in user.todoList) { - const selectedNote = user.todoList[timestamp]; - - if (selectedNote === combined) { - delete user.todoList[timestamp]; - Storage.save(); - isFound = true; - send(`Removed \`${combined}\` from your todo list.`); - } + if (success) { + send(`Removed Note \`${args[0]}\` from your todo list.`); + } else { + send("That item couldn't be found."); } - - if (!isFound) send("That item couldn't be found."); } }) }), clear: new NamedCommand({ async run({send, author}) { - const user = new User(author.id); - user.todoList = {}; - Storage.save(); + new User(author.id).clearTodoEntries(); send("Cleared todo list."); } }) diff --git a/src/defs/config.ts b/src/defs/config.ts index 1d8035a..2f342f6 100644 --- a/src/defs/config.ts +++ b/src/defs/config.ts @@ -23,15 +23,23 @@ class Config { this._systemLogsChannel = systemLogsChannel; db.prepare("UPDATE Settings SET SystemLogsChannel = ? WHERE Tag = 'Main'").run(systemLogsChannel); } - get webhooks() { - return this._webhooks; + + getWebhook(id: string) { + return this._webhooks.get(id); + } + getWebhookEntries() { + return this._webhooks.entries(); + } + hasWebhook(id: string) { + return this._webhooks.has(id); } - // getWebhook, setWebhook, removeWebhook, hasWebhook, getWebhookEntries setWebhook(id: string, token: string) { + db.prepare("INSERT INTO Webhooks VALUES (?, ?)").run(id, token); this._webhooks.set(id, token); - db.prepare( - "INSERT INTO Webhooks VALUES (:id, :token) ON CONFLICT (ID) DO UPDATE SET Token = :token WHERE ID = :id" - ).run({id, token}); + } + removeWebhook(id: string) { + db.prepare("DELETE FROM Webhooks WHERE ID = ?").run(id); + return this._webhooks.delete(id); } } diff --git a/src/defs/guild.ts b/src/defs/guild.ts index dcd0af8..bb8b984 100644 --- a/src/defs/guild.ts +++ b/src/defs/guild.ts @@ -109,7 +109,7 @@ export class Guild { break; } - db.prepare(upsert("WelcomeType", "welcomeType")).run({ + db.prepare(upsert("WelcomeType", "welcomeTypeInt")).run({ id: this.id, welcomeTypeInt }); @@ -154,13 +154,53 @@ export class Guild { hasMessageEmbeds: +hasMessageEmbeds }); } - get streamingRoles() { - return this._streamingRoles; // Role ID: Category Name + + getStreamingRole(id: string) { + return this._streamingRoles.get(id); } - get defaultChannelNames() { - return this._defaultChannelNames; // Channel ID: Channel Name + getStreamingRoleEntries() { + return this._streamingRoles.entries(); } + hasStreamingRole(id: string) { + return this._streamingRoles.has(id); + } + setStreamingRole(id: string, category: string) { + db.prepare("INSERT INTO StreamingRoles VALUES (?, ?, ?)").run(this.id, id, category); + this._streamingRoles.set(id, category); + } + removeStreamingRole(id: string) { + db.prepare("DELETE FROM StreamingRoles WHERE GuildID = ? AND RoleID = ?").run(this.id, id); + return this._streamingRoles.delete(id); + } + + getDefaultChannelName(id: string) { + return this._defaultChannelNames.get(id); + } + getDefaultChannelNameEntries() { + return this._defaultChannelNames.entries(); + } + hasDefaultChannelName(id: string) { + return this._defaultChannelNames.has(id); + } + setDefaultChannelName(id: string, name: string) { + db.prepare("INSERT INTO DefaultChannelNames VALUES (?, ?, ?)").run(this.id, id, name); + this._defaultChannelNames.set(id, name); + } + removeDefaultChannelName(id: string) { + db.prepare("DELETE FROM DefaultChannelNames WHERE GuildID = ? AND ChannelID = ?").run(this.id, id); + return this._defaultChannelNames.delete(id); + } + get autoRoles() { - return this._autoRoles; // string array of role IDs + return this._autoRoles; + } + set autoRoles(autoRoles) { + this._autoRoles = autoRoles; + db.prepare("DELETE FROM AutoRoles WHERE GuildID = ?").run(this.id); + const addAutoRoles = db.prepare("INSERT INTO AutoRoles VALUES (?, ?)"); + + for (const roleID of autoRoles) { + addAutoRoles.run(this.id, roleID); + } } } diff --git a/src/defs/user.ts b/src/defs/user.ts index ecfdd3f..ae9ebcf 100644 --- a/src/defs/user.ts +++ b/src/defs/user.ts @@ -12,12 +12,12 @@ export class User { private _timezoneOffset: number | null; // This is for the standard timezone only, not the daylight savings timezone private _daylightSavingsRegion: "na" | "eu" | "sh" | "none"; private _ecoBetInsurance: number; - private _todoList: Collection; + private _todoList: Collection; constructor(id: string) { this.id = id; const data = db.prepare("SELECT * FROM Users WHERE ID = ?").get(id); - const todoList = db.prepare("SELECT Timestamp, Entry FROM TodoLists WHERE UserID = ?").all(id) ?? []; + const todoList = db.prepare("SELECT ID, LastModified, Entry FROM TodoLists WHERE UserID = ?").all(id) ?? []; if (data) { const {Money, LastReceived, LastMonday, TimezoneOffset, DaylightSavingsRegion, EcoBetInsurance} = data; @@ -51,8 +51,11 @@ export class User { this._todoList = new Collection(); - for (const {Timestamp, Entry} of todoList) { - this._todoList.set(Timestamp, Entry); + for (const {ID, LastModified, Entry} of todoList) { + this._todoList.set(ID, { + entry: Entry, + lastModified: new Date(LastModified) + }); } } @@ -147,8 +150,52 @@ export class User { get todoList() { return this._todoList; } - // NOTE: Need to figure out an actual ID system - setTodoEntry(timestamp: number, entry: string) { - db.prepare("INSERT INTO TodoLists VALUES (?, ?, ?)").run(this.id, timestamp, entry); + + getTodoEntry(id: number) { + return this._todoList.get(id); + } + getTodoEntries() { + return this._todoList.entries(); + } + hasTodoEntry(id: number) { + return this._todoList.has(id); + } + addTodoEntry(entry: string) { + const lastModified = Date.now(); + db.prepare("INSERT INTO TodoLists (UserID, Entry, LastModified) VALUES (?, ?, ?)").run( + this.id, + entry, + lastModified + ); + const {ID} = db + .prepare("SELECT ID FROM TodoLists WHERE UserID = ? AND Entry = ? AND LastModified = ?") + .get(this.id, entry, lastModified); + this._todoList.set(ID, { + entry, + lastModified: new Date(lastModified) + }); + } + setTodoEntry(id: number, entry: string): boolean { + const lastModified = Date.now(); + const exists = !!db.prepare("SELECT * FROM TodoLists WHERE UserID = ? AND ID = ?").get(this.id, id); + + if (exists) { + db.prepare("INSERT INTO TodoLists VALUES (?, ?, ?, ?)").run(id, this.id, entry, lastModified); + this._todoList.set(id, { + entry, + lastModified: new Date(lastModified) + }); + return true; + } else { + return false; + } + } + removeTodoEntry(id: number) { + db.prepare("DELETE FROM TodoLists WHERE UserID = ? AND ID = ?").run(this.id, id); + return this._todoList.delete(id); + } + clearTodoEntries() { + db.prepare("DELETE FROM TodoLists WHERE UserID = ?").run(this.id); + this._todoList.clear(); } } diff --git a/src/modules/channelDefaults.ts b/src/modules/channelDefaults.ts index b715052..5caa052 100644 --- a/src/modules/channelDefaults.ts +++ b/src/modules/channelDefaults.ts @@ -4,14 +4,14 @@ import {Permissions} from "discord.js"; client.on("voiceStateUpdate", async (before, after) => { const channel = before.channel; - const {defaultChannelNames} = new Guild(after.guild.id); + const guild = new Guild(after.guild.id); if ( channel && channel.members.size === 0 && - defaultChannelNames.has(channel.id) && + guild.hasDefaultChannelName(channel.id) && before.guild.me?.permissions.has(Permissions.FLAGS.MANAGE_CHANNELS) ) { - channel.setName(defaultChannelNames.get(channel.id)!); + channel.setName(guild.getDefaultChannelName(channel.id)!); } }); diff --git a/src/modules/database.ts b/src/modules/database.ts index 7b40983..b90dbb9 100644 --- a/src/modules/database.ts +++ b/src/modules/database.ts @@ -17,7 +17,7 @@ import {join} from "path"; // 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) +// TodoLists: ID (INT PRIMARY KEY), UserID, Entry (TEXT), LastModified (TIME) // StreamingRoles: GuildID, RoleID, Category (TEXT) // DefaultChannelNames: GuildID, ChannelID, Name (TEXT) // AutoRoles: GuildID, RoleID @@ -33,7 +33,7 @@ import {join} from "path"; // - Booleans (marked as BOOL) will be stored as an integer, either 0 or 1 (though it just checks for 0). const DATA_FOLDER = "data"; -const DATABASE_FILE = join(DATA_FOLDER, "main.db"); +const DATABASE_FILE = join(DATA_FOLDER, `${process.env.DEV_DATABASE ?? "main"}.db`); // 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. @@ -84,9 +84,10 @@ const migrations: (() => void)[] = [ Token TEXT NOT NULL )`, `CREATE TABLE TodoLists ( + ID INTEGER NOT NULL PRIMARY KEY ON CONFLICT REPLACE AUTOINCREMENT, UserID TEXT NOT NULL, - Timestamp INT NOT NULL, - Entry TEXT NOT NULL + Entry TEXT NOT NULL, + LastModified INT NOT NULL )`, `CREATE TABLE StreamingRoles ( GuildID TEXT NOT NULL, @@ -108,11 +109,18 @@ const migrations: (() => void)[] = [ if (hasLegacyData) { const config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); const {users, guilds} = JSON.parse(readFileSync(STORAGE_FILE, "utf-8")); - db.prepare("INSERT INTO Settings VALUES ('Main', ?)").run(config.systemLogsChannel); + const addWebhooks = db.prepare("INSERT INTO Webhooks VALUES (?, ?)"); + const addUsers = db.prepare("INSERT INTO Users VALUES (?, ?, ?, ?, ?, ?, ?)"); + const addTodoLists = db.prepare("INSERT INTO TodoLists (UserID, Entry, LastModified) VALUES (?, ?, ?)"); + const addGuilds = db.prepare("INSERT INTO Guilds VALUES (?, ?, ?, ?, ?, ?, ?)"); + const addMembers = db.prepare("INSERT INTO Members VALUES (?, ?, ?)"); + const addStreamingRoles = db.prepare("INSERT INTO StreamingRoles VALUES (?, ?, ?)"); + const addDefaultChannelNames = db.prepare("INSERT INTO DefaultChannelNames VALUES (?, ?, ?)"); + const addAutoRoles = db.prepare("INSERT INTO AutoRoles VALUES (?, ?)"); for (const [id, token] of Object.entries(config.webhooks)) { - db.prepare("INSERT INTO Webhooks VALUES (?, ?)").run(id, token); + addWebhooks.run(id, token); } for (const id in users) { @@ -134,19 +142,11 @@ const migrations: (() => void)[] = [ } } - db.prepare("INSERT INTO Users VALUES (?, ?, ?, ?, ?, ?, ?)").run( - id, - money, - lastReceived, - lastMonday, - timezone, - dstInfo, - ecoBetInsurance - ); + addUsers.run(id, money, lastReceived, lastMonday, timezone, dstInfo, ecoBetInsurance); for (const timestamp in todoList) { const entry = todoList[timestamp]; - db.prepare("INSERT INTO TodoLists VALUES (?, ?, ?)").run(id, Number(timestamp), entry); + addTodoLists.run(id, entry, Number(timestamp)); } } @@ -174,7 +174,7 @@ const migrations: (() => void)[] = [ break; } - db.prepare("INSERT INTO Guilds VALUES (?, ?, ?, ?, ?, ?, ?)").run( + addGuilds.run( id, prefix, welcomeTypeInt, @@ -186,28 +186,28 @@ const migrations: (() => void)[] = [ for (const userID in members) { const {streamCategory} = members[userID]; - db.prepare("INSERT INTO Members VALUES (?, ?, ?)").run(userID, id, streamCategory); + addMembers.run(userID, id, streamCategory); } for (const roleID in streamingRoles) { const category = streamingRoles[roleID]; - db.prepare("INSERT INTO StreamingRoles VALUES (?, ?, ?)").run(id, roleID, category); + addStreamingRoles.run(id, roleID, category); } for (const channelID in channelNames) { const channelName = channelNames[channelID]; - db.prepare("INSERT INTO DefaultChannelNames VALUES (?, ?, ?)").run(id, channelID, channelName); + addDefaultChannelNames.run(id, channelID, channelName); } if (autoRoles) { for (const roleID of autoRoles) { - db.prepare("INSERT INTO AutoRoles VALUES (?, ?)").run(id, roleID); + addAutoRoles.run(id, roleID); } } } } } - // generateSQLMigration(["UPDATE System SET Version = 2"]) + // generateSQLMigration([]) ]; const isExistingDatabase = existsSync(DATABASE_FILE); @@ -244,6 +244,10 @@ if (isExistingDatabase) { if (version !== -1) { for (let v = version; v < migrations.length; v++) { migrations[v](); + + if (v >= 1) { + db.prepare("UPDATE System SET Version = ?").run(v + 1); + } } } diff --git a/src/modules/streamNotifications.ts b/src/modules/streamNotifications.ts index 878d0e7..743a9d0 100644 --- a/src/modules/streamNotifications.ts +++ b/src/modules/streamNotifications.ts @@ -61,7 +61,8 @@ client.on("voiceStateUpdate", async (before, after) => { // Note: isStopStreamEvent can be called twice in a row - If Discord crashes/quits while you're streaming, it'll call once with a null channel and a second time with a channel. if (isStartStreamEvent || isStopStreamEvent) { - const {streamingChannel, streamingRoles} = new Guild(after.guild.id); + const guild = new Guild(after.guild.id); + const {streamingChannel} = guild; if (streamingChannel) { const member = after.member!; @@ -79,9 +80,9 @@ client.on("voiceStateUpdate", async (before, after) => { const roleID = new Member(member.id, after.guild.id).streamCategory; // Only continue if they set a valid category. - if (roleID && streamingRoles.has(roleID)) { + if (roleID && guild.hasStreamingRole(roleID)) { streamNotificationPing = `<@&${roleID}>`; - category = streamingRoles.get(roleID)!; + category = guild.getStreamingRole(roleID)!; } streamList.set(member.id, { diff --git a/src/modules/webhookStorageManager.ts b/src/modules/webhookStorageManager.ts index 55510cc..d2e21f6 100644 --- a/src/modules/webhookStorageManager.ts +++ b/src/modules/webhookStorageManager.ts @@ -38,7 +38,7 @@ export function deleteWebhook(urlOrID: string): boolean { else if (ID_PATTERN.test(urlOrID)) id = ID_PATTERN.exec(urlOrID)![1]; if (id) { - delete config.webhooks[id]; + config.removeWebhook(id); refreshWebhookCache(); } @@ -55,14 +55,13 @@ client.on("ready", refreshWebhookCache); export async function refreshWebhookCache(): Promise { webhookStorage.clear(); - for (const [id, token] of Object.entries(Config.webhooks)) { + for (const [id, token] of config.getWebhookEntries()) { // If there are stored webhook IDs/tokens that don't work, delete those webhooks from storage. try { const webhook = await client.fetchWebhook(id, token); webhookStorage.set(webhook.channelId, webhook); } catch { - delete Config.webhooks[id]; - Config.save(); + config.removeWebhook(id); } } }