From 8eaf8dd2cbe25a1834277876ef047f9b92201612 Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Tue, 2 Aug 2016 01:10:39 -0400 Subject: [PATCH] Loads of changes Moved character database to SQLite Made command run async Refactored existing database stuff Changed the way command errors are handled Added double interruption handling Other minor changes --- .babelrc | 3 +- .gitignore | 1 + .npmignore | 1 + README.md | 2 +- migrations/001-initial-schema.sql | 15 ++++ package.json | 4 +- src/commands/characters/add.js | 12 +-- src/commands/characters/delete.js | 13 +-- src/commands/characters/list.js | 11 +-- src/commands/characters/view.js | 9 ++- src/commands/dice/max.js | 5 +- src/commands/dice/min.js | 5 +- src/commands/dice/roll.js | 5 +- src/commands/general/about.js | 2 +- src/commands/general/eval.js | 9 +-- src/commands/general/help.js | 2 +- src/commands/general/list-roles.js | 2 +- src/commands/general/prefix.js | 11 ++- src/commands/mod-roles/add.js | 9 ++- src/commands/mod-roles/delete.js | 11 +-- src/commands/mod-roles/list.js | 6 +- src/config.js | 30 +++++-- src/database/character.js | 93 ++++++++++++++++++++++ src/database/characters.js | 86 -------------------- src/database/index.js | 26 ++++++ src/database/{mod-roles.js => mod-role.js} | 22 ++--- src/database/setting.js | 64 ++++++++++++++- src/database/settings.js | 62 --------------- src/rpbot.js | 46 ++++++++--- src/util/command-pattern.js | 4 +- src/util/command-usage.js | 4 +- src/util/errors/command-format.js | 12 +++ src/util/errors/friendly.js | 9 +++ src/util/permissions.js | 6 +- src/util/search.js | 18 ++--- src/util/update-check.js | 6 +- 36 files changed, 369 insertions(+), 257 deletions(-) create mode 100644 migrations/001-initial-schema.sql delete mode 100644 src/database/characters.js create mode 100644 src/database/index.js rename src/database/{mod-roles.js => mod-role.js} (81%) delete mode 100644 src/database/settings.js create mode 100644 src/util/errors/command-format.js create mode 100644 src/util/errors/friendly.js diff --git a/.babelrc b/.babelrc index 9f08f2f..8edaf80 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["es2015-node6"] + "presets": ["es2015-node6"], + "plugins": ["transform-async-to-generator"] } diff --git a/.gitignore b/.gitignore index 61ddb42..6d5ecd6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ npm-debug.log /lib /rpbot-storage +rpbot.sqlite3 settings.yml rpbot.log diff --git a/.npmignore b/.npmignore index b3bc83d..7d6c827 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,4 @@ /rpbot-storage +rpbot.sqlite3 settings.yml rpbot.log diff --git a/README.md b/README.md index af93901..37e2d28 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Run `sudo npm install -g discord-rpbot --no-optional`. There is no identifiable or potentially private/unsafe information sent whatsoever. The only things that are being shared is the name of commands being run (no message contents), and an event for the bot starting up. This is so that I know how many people are using the bot, and what commands are being used the most. -If you don't want anything being sent at all, use the `analytics` configuration option. +If you don't want anything being sent at all, run RPBot with the `--no-analytics` option, or set `analytics` in your config file. ## Chat commands | Command | Description | diff --git a/migrations/001-initial-schema.sql b/migrations/001-initial-schema.sql new file mode 100644 index 0000000..9760643 --- /dev/null +++ b/migrations/001-initial-schema.sql @@ -0,0 +1,15 @@ +-- Up +CREATE TABLE characters (server_id INTEGER NOT NULL, name TEXT NOT NULL COLLATE NOCASE, info TEXT, user_id INTEGER NOT NULL); +CREATE INDEX characters_index ON characters (server_id, name); +/*CREATE TABLE mod_roles (server_id INTEGER NOT NULL, role_id INTEGER NOT NULL); +CREATE INDEX mod_roles_index ON mod_roles (server_id, role_id); +CREATE TABLE settings (server_id INTEGER, key TEXT NOT NULL, value TEXT); +CREATE INDEX settings_index ON settings (server_id, key);*/ + +-- Down +DROP TABLE characters; +DROP INDEX characters_index; +/*DROP TABLE mod_roles; +DROP INDEX mod_roles_index; +DROP TABLE settings; +DROP INDEX settings_index;*/ diff --git a/package.json b/package.json index 9be6d7e..9bdbb9a 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "string-argv": "^0.0.2", "common-tags": "^1.3.0", "node-localstorage": "^1.3.0", + "sqlite": "^2.2.0", "escape-string-regexp": "^1.0.0", "dice-expression-evaluator": "^0.1.0", "universal-analytics": "^0.4.0" @@ -42,7 +43,8 @@ "eslint": "^3.1.0", "babel-cli": "^6.11.0", "babel-eslint": "^6.1.0", - "babel-preset-es2015-node6": "^0.2.0" + "babel-preset-es2015-node6": "^0.2.0", + "babel-plugin-transform-async-to-generator": "^6.8.0" }, "engines": { "node": ">=6.0.0" diff --git a/src/commands/characters/add.js b/src/commands/characters/add.js index 369f1a0..d57babd 100644 --- a/src/commands/characters/add.js +++ b/src/commands/characters/add.js @@ -3,7 +3,7 @@ import stringArgv from 'string-argv'; import Character from '../../database/character'; -import database from '../../database/characters'; +import CommandFormatError from '../../util/errors/command-format'; const newlinesPattern = /\n/g; const newlinesReplacement = '{!~NL~!}'; @@ -26,8 +26,8 @@ export default { return !!message.server; }, - run(message, args) { - if(!args[0]) return false; + async run(message, args) { + if(!args[0]) throw new CommandFormatError(this); if(mentionsPattern.test(args[0])) { message.reply('Please do not use mentions in your character name or information.'); return; @@ -51,11 +51,11 @@ export default { } // Add or update the character - const result = database.saveCharacter(new Character(message.server, message.author, name, info)); + const result = await Character.save(new Character(message.server, message.author, name, info)); if(result) { - message.reply(`${result === 1 ? 'Added' : 'Updated'} character "${name}".`); + message.reply(`${result.new ? 'Added' : 'Updated'} character "${name}".`); } else { - message.reply(`Unable to update character "${info}". You are not the owner.`); + message.reply(`Unable to update character "${name}". You are not the owner.`); } } }; diff --git a/src/commands/characters/delete.js b/src/commands/characters/delete.js index 70a8858..0e803c6 100644 --- a/src/commands/characters/delete.js +++ b/src/commands/characters/delete.js @@ -1,9 +1,10 @@ 'use babel'; 'use strict'; -import database from '../../database/characters'; +import Character from '../../database/character'; import disambiguation from '../../util/disambiguation'; import * as usage from '../../util/command-usage'; +import CommandFormatError from '../../util/errors/command-format'; export default { name: 'deletecharacter', @@ -20,12 +21,12 @@ export default { return !!message.server; }, - run(message, args) { - if(!args[0]) return false; - const characters = database.findCharactersInServer(message.server, args[0]); + async run(message, args) { + if(!args[0]) throw new CommandFormatError(this); + const characters = await Character.findInServer(message.server, args[0]); if(characters.length === 1) { - if(database.deleteCharacter(characters[0])) { - message.reply(`Deleted character "${characters[0].name}."`); + if(await Character.delete(characters[0])) { + message.reply(`Deleted character "${characters[0].name}".`); } else { message.reply(`Unable to delete character "${characters[0].name}". You are not the owner.`); } diff --git a/src/commands/characters/list.js b/src/commands/characters/list.js index ea1ffbb..69520a0 100644 --- a/src/commands/characters/list.js +++ b/src/commands/characters/list.js @@ -1,7 +1,7 @@ 'use babel'; 'use strict'; -import database from '../../database/characters'; +import Character from '../../database/character'; import config from '../../config'; import paginate from '../../util/pagination'; import * as usage from '../../util/command-usage'; @@ -20,10 +20,11 @@ export default { return !!message.server; }, - run(message, args) { - const search = args.length >= 2 || isNaN(args[0]) ? args[0] : ''; - const page = args.length >= 2 ? parseInt(args[1]) : (!isNaN(args[0]) ? parseInt(args[0]) : 1); - let characters = database.findCharactersInServer(message.server, search, false); + async run(message, args) { + const last = args.length >= 1 ? args.length - 1 : 0; + const page = !isNaN(args[last]) ? parseInt(args.pop()) : 1; + const search = args.join(' '); + let characters = await Character.findInServer(message.server, search, false); if(characters.length > 0) { characters.sort((a, b) => a.name < b.name ? -1 : (a.name > b.name ? 1 : 0)); const paginated = paginate(characters, page, Math.floor(config.paginationItems)); diff --git a/src/commands/characters/view.js b/src/commands/characters/view.js index c32c0c9..93348ba 100644 --- a/src/commands/characters/view.js +++ b/src/commands/characters/view.js @@ -1,9 +1,10 @@ 'use babel'; 'use strict'; -import database from '../../database/characters'; +import Character from '../../database/character'; import disambiguation from '../../util/disambiguation'; import * as usage from '../../util/command-usage'; +import CommandFormatError from '../../util/errors/command-format'; export default { name: 'character', @@ -20,9 +21,9 @@ export default { return !!message.server; }, - run(message, args) { - if(!args[0]) return false; - const characters = database.findCharactersInServer(message.server, args[0]); + async run(message, args) { + if(!args[0]) throw new CommandFormatError(this); + const characters = await Character.findInServer(message.server, args[0]); if(characters.length === 1) { const owner = message.client.users.get('id', characters[0].owner); const ownerName = owner ? owner.name + '#' + owner.discriminator : 'Unknown'; diff --git a/src/commands/dice/max.js b/src/commands/dice/max.js index e742a72..b6f5422 100644 --- a/src/commands/dice/max.js +++ b/src/commands/dice/max.js @@ -3,6 +3,7 @@ import DiceExpression from 'dice-expression-evaluator'; import logger from '../../util/logger'; +import CommandFormatError from '../../util/errors/command-format'; export default { name: 'maxroll', @@ -18,8 +19,8 @@ export default { return true; }, - run(message, args) { - if(!args[0]) return false; + async run(message, args) { + if(!args[0]) throw new CommandFormatError(this); try { const maxRoll = new DiceExpression(args[0]).max(); message.reply(`The maximum possible roll is **${maxRoll}**.`); diff --git a/src/commands/dice/min.js b/src/commands/dice/min.js index d924f92..169aed2 100644 --- a/src/commands/dice/min.js +++ b/src/commands/dice/min.js @@ -3,6 +3,7 @@ import DiceExpression from 'dice-expression-evaluator'; import logger from '../../util/logger'; +import CommandFormatError from '../../util/errors/command-format'; export default { name: 'minroll', @@ -18,8 +19,8 @@ export default { return true; }, - run(message, args) { - if(!args[0]) return false; + async run(message, args) { + if(!args[0]) throw new CommandFormatError(this); try { const minRoll = new DiceExpression(args[0]).min(); message.reply(`The minimum possible roll is **${minRoll}**.`); diff --git a/src/commands/dice/roll.js b/src/commands/dice/roll.js index aeeddcb..ec21c13 100644 --- a/src/commands/dice/roll.js +++ b/src/commands/dice/roll.js @@ -4,6 +4,7 @@ import DiceExpression from 'dice-expression-evaluator'; import nbsp from '../../util/nbsp'; import logger from '../../util/logger'; +import CommandFormatError from '../../util/errors/command-format'; const pattern = /^(.+?)(?:(>|<)\s*([0-9]+?))?\s*$/; @@ -25,8 +26,8 @@ export default { return true; }, - run(message, args, fromPattern) { - if(!args[0]) return false; + async run(message, args, fromPattern) { + if(!args[0]) throw new CommandFormatError(this); try { const matches = fromPattern ? args : pattern.exec(args[0]); const dice = new DiceExpression(matches[1]); diff --git a/src/commands/general/about.js b/src/commands/general/about.js index a46b27a..0d38d31 100644 --- a/src/commands/general/about.js +++ b/src/commands/general/about.js @@ -15,7 +15,7 @@ export default { return true; }, - run(message) { + async run(message) { const owner = message.client.users.get('id', config.owner); const servers = message.client.servers.length.toLocaleString(), users = message.client.users.length.toLocaleString(); const serversLabel = servers != 1 ? 'servers' : 'server', usersLabel = users != 1 ? 'users' : 'user'; diff --git a/src/commands/general/eval.js b/src/commands/general/eval.js index 9f68331..35bd06d 100644 --- a/src/commands/general/eval.js +++ b/src/commands/general/eval.js @@ -14,9 +14,6 @@ import * as commands from '..'; import storage from '../../database/local-storage'; import Character from '../../database/character'; import Setting from '../../database/setting'; -import CharDB from '../../database/characters'; -import SettingDB from '../../database/settings'; -import ModRolesDB from '../../database/mod-roles'; import search from '../../util/search'; import disambiguation from '../../util/disambiguation'; import pagination from '../../util/pagination'; @@ -26,6 +23,8 @@ import checkForUpdate from '../../util/update-check'; import * as permissions from '../../util/permissions'; import * as usage from '../../util/command-usage'; import * as nbsp from '../../util/nbsp'; +import FriendlyError from '../../util/errors/friendly'; +import CommandFormatError from '../../util/errors/command-format'; /* eslint-enable no-unused-vars */ export default { @@ -41,8 +40,8 @@ export default { return message.author.id === config.owner; }, - run(msg, args) { - if(!args[0]) return false; + async run(msg, args) { + if(!args[0]) throw new CommandFormatError(this); try { msg.reply(`Result: \`${util.inspect(eval(args[0]), {depth: 0})}\``); } catch(e) { diff --git a/src/commands/general/help.js b/src/commands/general/help.js index 9e55a58..d95aad4 100644 --- a/src/commands/general/help.js +++ b/src/commands/general/help.js @@ -19,7 +19,7 @@ export default { return true; }, - run(message, args) { + async run(message, args) { const commands = findCommands(args[0], message); if(args[0]) { if(commands.length === 1) { diff --git a/src/commands/general/list-roles.js b/src/commands/general/list-roles.js index 3e91692..4523fa3 100644 --- a/src/commands/general/list-roles.js +++ b/src/commands/general/list-roles.js @@ -15,7 +15,7 @@ export default { return message.server && permissions.isAdministrator(message.server, message.author); }, - run(message) { + async run(message) { const roleList = message.server.roles.map(element => `${element.name} (ID: ${element.id})`).join('\n'); message.reply(`Server roles:\n${roleList}`); } diff --git a/src/commands/general/prefix.js b/src/commands/general/prefix.js index 4d6d8a8..3c270d9 100644 --- a/src/commands/general/prefix.js +++ b/src/commands/general/prefix.js @@ -6,7 +6,6 @@ import config from '../../config'; import buildCommandPattern from '../../util/command-pattern'; import * as permissions from '../../util/permissions'; import * as usage from '../../util/command-usage'; -import SettingsDatabase from '../../database/settings'; import Setting from '../../database/setting'; export default { @@ -23,7 +22,7 @@ export default { return true; }, - run(message, args) { + async run(message, args) { if(args[0] && message.server) { // Only allow administrators if(!permissions.isAdministrator(message.server, message.author)) { @@ -36,10 +35,10 @@ export default { const prefix = lowercase === 'none' ? '' : args[0]; let response; if(lowercase === 'default') { - SettingsDatabase.deleteSetting('command-prefix', message.server); + Setting.delete('command-prefix', message.server); response = `Reset the command prefix to default (currently "${config.commandPrefix}").`; } else { - SettingsDatabase.saveSetting(new Setting(message.server, 'command-prefix', prefix)); + Setting.save(new Setting(message.server, 'command-prefix', prefix)); response = prefix ? `Set the command prefix to "${args[0]}".` : 'Removed the command prefix entirely.'; } @@ -49,8 +48,8 @@ export default { message.reply(`${response} To run commands, use ${usage.long('command', message.server)}.`); } else { - const prefix = message.server ? SettingsDatabase.getSettingValue('command-prefix', config.commandPrefix, message.server) : config.commandPrefix; - message.reply(`${prefix ? `The command prefix is "${prefix}".` : 'There is no command prefix.'} To run commands, use ${usage.long('command', message.server)}`); + const prefix = message.server ? Setting.getValue('command-prefix', config.commandPrefix, message.server) : config.commandPrefix; + message.reply(`${prefix ? `The command prefix is "${prefix}".` : 'There is no command prefix.'} To run commands, use ${usage.long('command', message.server)}.`); } } }; diff --git a/src/commands/mod-roles/add.js b/src/commands/mod-roles/add.js index d271bda..0b5b6ba 100644 --- a/src/commands/mod-roles/add.js +++ b/src/commands/mod-roles/add.js @@ -1,11 +1,12 @@ 'use babel'; 'use strict'; -import database from '../../database/mod-roles'; +import ModRole from '../../database/mod-role'; import search from '../../util/search'; import disambiguation from '../../util/disambiguation'; import * as usage from '../../util/command-usage'; import * as permissions from '../../util/permissions'; +import CommandFormatError from '../../util/errors/command-format'; const pattern = /^(?:<@&)?(.+?)>?$/; @@ -24,15 +25,15 @@ export default { return message.server && permissions.isAdministrator(message.server, message.author); }, - run(message, args) { - if(!args[0]) return false; + async run(message, args) { + if(!args[0]) throw new CommandFormatError(this); const matches = pattern.exec(args[0]); let roles; const idRole = message.server.roles.get('id', matches[1]); if(idRole) roles = [idRole]; else roles = search(message.server.roles, matches[1]); if(roles.length === 1) { - if(database.saveRole(roles[0])) { + if(ModRole.save(roles[0])) { message.reply(`Added "${roles[0].name}" to the moderator roles.`); } else { message.reply(`Unable to add "${roles[0].name}" to the moderator roles. It already is one.`); diff --git a/src/commands/mod-roles/delete.js b/src/commands/mod-roles/delete.js index d6352eb..9b427f0 100644 --- a/src/commands/mod-roles/delete.js +++ b/src/commands/mod-roles/delete.js @@ -1,10 +1,11 @@ 'use babel'; 'use strict'; -import database from '../../database/mod-roles'; +import ModRole from '../../database/mod-role'; import disambiguation from '../../util/disambiguation'; import * as usage from '../../util/command-usage'; import * as permissions from '../../util/permissions'; +import CommandFormatError from '../../util/errors/command-format'; const pattern = /^(?:<@&)?(.+?)>?$/; @@ -23,15 +24,15 @@ export default { return message.server && permissions.isAdministrator(message.server, message.author); }, - run(message, args) { - if(!args[0]) return false; + async run(message, args) { + if(!args[0]) throw new CommandFormatError(this); const matches = pattern.exec(args[0]); let roles; const idRole = message.server.roles.get('id', matches[1]); - if(idRole) roles = [idRole]; else roles = database.findRolesInServer(message.server, matches[1]); + if(idRole) roles = [idRole]; else roles = ModRole.findInServer(message.server, matches[1]); if(roles.length === 1) { - if(database.deleteRole(roles[0])) { + if(ModRole.delete(roles[0])) { message.reply(`Removed "${roles[0].name}" from the moderator roles.`); } else { message.reply(`Unable to remove "${roles[0].name}" from the moderator roles. It isn\'t one.`); diff --git a/src/commands/mod-roles/list.js b/src/commands/mod-roles/list.js index cef99d4..4a862d8 100644 --- a/src/commands/mod-roles/list.js +++ b/src/commands/mod-roles/list.js @@ -1,7 +1,7 @@ 'use babel'; 'use strict'; -import database from '../../database/mod-roles'; +import ModRole from '../../database/mod-role'; import * as permissions from '../../util/permissions'; export default { @@ -16,8 +16,8 @@ export default { return message.server && permissions.isAdministrator(message.server, message.author); }, - run(message) { - const roles = database.findRolesInServer(message.server); + async run(message) { + const roles = ModRole.findInServer(message.server); if(roles.length > 0) { message.reply('Moderator roles:\n' + roles.map(role => `${role.name} (ID: ${role.id})`).join('\n')); } else { diff --git a/src/config.js b/src/config.js index 9ebe451..1fa7e34 100644 --- a/src/config.js +++ b/src/config.js @@ -36,21 +36,37 @@ const config = yargs }) .implies({ email: 'password', password: 'email' }) - // General - .option('owner', { + // Database + .option('database', { type: 'string', - alias: 'o', - describe: 'Discord user ID of the bot owner', - group: 'General:' + default: 'rpbot.sqlite3', + alias: 'd', + describe: 'Path to SQLite3 database file', + group: 'Database:', + normalize: true + }) + .option('database-verbose', { + type: 'boolean', + alias: 'V', + describe: 'Whether or not SQLite3 should be put into verbose mode', + group: 'Database:' }) .option('storage', { type: 'string', default: 'rpbot-storage', alias: 's', describe: 'Path to storage directory', - group: 'General:', + group: 'Database:', normalize: true }) + + // General + .option('owner', { + type: 'string', + alias: 'o', + describe: 'Discord user ID of the bot owner', + group: 'General:' + }) .option('command-prefix', { type: 'string', default: '!', @@ -144,7 +160,7 @@ const config = yargs const extension = path.extname(configFile).toLowerCase(); if(extension === '.json') return JSON.parse(fs.readFileSync(configFile)); - else if(extension === '.yml' || extension == '.yaml') + else if(extension === '.yml' || extension === '.yaml') return YAML.safeLoad(fs.readFileSync(configFile)); throw new Error('Unknown config file type.'); } diff --git a/src/database/character.js b/src/database/character.js index 9dfcbf6..6ede2dc 100644 --- a/src/database/character.js +++ b/src/database/character.js @@ -1,6 +1,19 @@ 'use babel'; 'use strict'; +import db from './'; +import storage from './local-storage'; +import search from '../util/search'; +import logger from '../util/logger'; +import * as permissions from '../util/permissions'; + +const sqlFindByServer = 'SELECT * FROM characters WHERE server_id = ?'; +const sqlFindByServerAndName = 'SELECT * FROM characters WHERE server_id = ? AND name = ?'; +const sqlFindByServerAndNameLike = 'SELECT * FROM characters WHERE server_id = ? AND name LIKE ?'; +const sqlInsert = 'INSERT INTO characters VALUES(?, ?, ?, ?)'; +const sqlUpdate = 'UPDATE characters SET name = ?, info = ? WHERE server_id = ? AND name = ?'; +const sqlDelete = 'DELETE FROM characters WHERE server_id = ? AND name = ?'; + export default class Character { constructor(server, owner, name, info) { if(!server || !owner || !name) throw new Error('Character name, owner, and server must be specified.'); @@ -9,4 +22,84 @@ export default class Character { this.name = name; this.info = info; } + + static async save(character) { + if(!character) throw new Error('A character must be specified.'); + const findStmt = await db.prepare(sqlFindByServerAndName); + const existingCharacters = await findStmt.all(character.server, character.name); + findStmt.finalize(); + if(existingCharacters.length > 1) throw new Error('Multiple existing characters found.'); + if(existingCharacters.length === 1) { + if(existingCharacters[0].user_id === character.owner || permissions.isModerator(character.server, character.owner)) { + const updateStmt = await db.prepare(sqlUpdate); + await updateStmt.run(character.name, character.info, character.server, existingCharacters[0].name); + updateStmt.finalize(); + logger.info('Updated existing character.', character); + return { character: new Character(character.server, existingCharacters[0].user_id, character.name, character.info), new: false }; + } else { + throw new Error('Character already exists, and the owners don\'t match.'); + } + } else { + const insertStmt = await db.prepare(sqlInsert); + await insertStmt.run(character.server, character.name, character.info, character.owner); + insertStmt.finalize(); + logger.info('Added new character.', character); + return { character: character, new: true }; + } + } + + static async delete(character) { + if(!character) throw new Error('A character must be specified.'); + const findStmt = await db.prepare(sqlFindByServerAndName); + const existingCharacters = await findStmt.all(character.server, character.name); + findStmt.finalize(); + if(existingCharacters.length > 1) throw new Error('Multiple existing characters found.'); + if(existingCharacters.length === 1) { + if(existingCharacters[0].user_id === character.owner || permissions.isModerator(character.server, character.owner)) { + const deleteStmt = await db.prepare(sqlDelete); + await deleteStmt.run(character.server, character.name); + deleteStmt.finalize(); + logger.info('Deleted character.', character); + return true; + } else { + throw new Error('Existing character is not owned by the specified character owner.'); + } + } else { + throw new Error('Character doesn\'t exist.'); + } + } + + static async findInServer(server, searchString = null, searchExact = true) { + if(!server) throw new Error('A server must be specified.'); + server = server.id ? server.id : server; + const findStmt = await db.prepare(searchString ? sqlFindByServerAndNameLike : sqlFindByServer); + const characters = await findStmt.all(server, searchString ? (searchString.length > 1 ? `%${searchString}%` : `${searchString}%`) : undefined); + findStmt.finalize(); + for(const [index, character] of characters.entries()) characters[index] = new Character(character.server_id, character.user_id, character.name, character.info); + return searchExact ? search(characters, searchString, { searchInexact: false }) : characters; + } + + static async convertStorage() { + const storageEntry = storage.getItem('characters'); + if(!storageEntry) return; + const baseMap = JSON.parse(storageEntry); + if(!baseMap) return; + const keys = Object.keys(baseMap); + if(keys.length === 0) return; + const characters = []; + for(const key of keys) { + const serverCharacters = baseMap[key]; + if(!serverCharacters || serverCharacters.length === 0) continue; + characters.push(...serverCharacters); + } + if(characters.length > 0) { + const stmt = await db.prepare('INSERT INTO characters VALUES(?, ?, ?, ?)'); + for(const character of characters) { + stmt.run(character.server, character.name, character.info, character.owner); + } + stmt.finalize(); + } + storage.removeItem('characters'); + logger.info('Converted characters from local storage to database.', { count: characters.length }); + } } diff --git a/src/database/characters.js b/src/database/characters.js deleted file mode 100644 index e8f4363..0000000 --- a/src/database/characters.js +++ /dev/null @@ -1,86 +0,0 @@ -'use babel'; -'use strict'; - -import Character from './character'; -import storage from './local-storage'; -import search from '../util/search'; -import logger from '../util/logger'; -import * as permissions from '../util/permissions'; - -export default class CharacterDatabase { - static loadDatabase() { - this.serversMap = JSON.parse(storage.getItem('characters')); - if(!this.serversMap) this.serversMap = {}; - } - - static saveDatabase() { - logger.debug('Saving characters database...', this.serversMap); - storage.setItem('characters', JSON.stringify(this.serversMap)); - } - - static saveCharacter(character) { - if(!character) throw new Error('A character must be specified.'); - if(!this.serversMap) this.loadDatabase(); - if(!this.serversMap[character.server]) this.serversMap[character.server] = []; - const serverCharacters = this.serversMap[character.server]; - - const normalizedName = character.name.normalize('NFKD').toLowerCase(); - const characterIndex = serverCharacters.findIndex(element => element.name.normalize('NFKD').toLowerCase() === normalizedName); - if(characterIndex >= 0) { - if(character.owner === serverCharacters[characterIndex].owner || permissions.isModerator(character.server, character.owner)) { - character.owner = serverCharacters[characterIndex].owner; - serverCharacters[characterIndex] = character; - logger.info('Updated existing character.', character); - this.saveDatabase(); - return 2; - } else { - logger.info('Not updating existing character, because the owner isn\'t the original owner.', { character: character, original: serverCharacters[characterIndex] }); - return 0; - } - } else { - serverCharacters.push(character); - logger.info('Added new character.', character); - this.saveDatabase(); - return 1; - } - } - - static deleteCharacter(character) { - if(!character) throw new Error('A character must be specified.'); - if(!this.serversMap) this.loadDatabase(); - if(!this.serversMap[character.server]) return false; - const serverCharacters = this.serversMap[character.server]; - - const characterIndex = serverCharacters.findIndex(element => element.name === character.name); - if(characterIndex >= 0) { - if(character.owner === serverCharacters[characterIndex].owner || permissions.isModerator(character.server, character.owner)) { - serverCharacters.splice(characterIndex, 1); - logger.info('Removed character.', character); - } else { - logger.info('Not removing character, because the owner isn\'t the original owner.', { character: character, original: serverCharacters[characterIndex] }); - return false; - } - } else { - logger.info('Not removing character, because it doesn\'t exist.', character); - return false; - } - - this.saveDatabase(); - return true; - } - - static findCharactersInServer(server, searchString = null, searchExact = true) { - if(!server) throw new Error('A server must be specified.'); - if(!this.serversMap) this.loadDatabase(); - if(!this.serversMap[server.id]) return []; - - const characters = search(this.serversMap[server.id], searchString, { useStartsWith: true, searchExact: searchExact }); - - // Make sure they're all Character instances - for(const [index, character] of characters.entries()) { - if(!(character instanceof Character)) characters[index] = new Character(character.server, character.owner, character.name, character.info); - } - - return characters; - } -} diff --git a/src/database/index.js b/src/database/index.js new file mode 100644 index 0000000..841cadb --- /dev/null +++ b/src/database/index.js @@ -0,0 +1,26 @@ +'use babel'; +'use strict'; + +import sqlite from 'sqlite'; +import Character from './character'; +import config from '../config'; +import logger from '../util/logger'; + +export const db = sqlite; +export default db; + +export async function init() { + logger.info('Initializing database...', { file: config.database, verbose: config.databaseVerbose }); + await db.open(config.database, { verbose: config.databaseVerbose }); + await db.migrate({ migrationsPath: __dirname + '/../../migrations' }); + await Promise.all([ + Character.convertStorage() + ]); + logger.info('Database initialized.'); +} + +export async function close() { + logger.info('Closing database...'); + await db.close(); + logger.info('Database closed.'); +} diff --git a/src/database/mod-roles.js b/src/database/mod-role.js similarity index 81% rename from src/database/mod-roles.js rename to src/database/mod-role.js index 95a9af7..71fa16a 100644 --- a/src/database/mod-roles.js +++ b/src/database/mod-role.js @@ -5,18 +5,18 @@ import storage from './local-storage'; import search from '../util/search'; import logger from '../util/logger'; -export default class ModRolesDatabase { +export default class ModRole { static loadDatabase() { this.serversMap = JSON.parse(storage.getItem('mod-roles')); if(!this.serversMap) this.serversMap = {}; } static saveDatabase() { - logger.debug('Saving mod roles database...', this.serversMap); + logger.debug('Saving mod roles storage...', this.serversMap); storage.setItem('mod-roles', JSON.stringify(this.serversMap)); } - static saveRole(role) { + static save(role) { if(!role) throw new Error('A role must be specified.'); if(!this.serversMap) this.loadDatabase(); if(!this.serversMap[role.server.id]) this.serversMap[role.server.id] = []; @@ -24,16 +24,16 @@ export default class ModRolesDatabase { if(!serverRoles.includes(role.id)) { serverRoles.push(role.id); - logger.info('Added new mod role.', this.basicRoleInfo(role)); + logger.info('Added new mod role.', this.basicInfo(role)); this.saveDatabase(); return true; } else { - logger.info('Not adding mod role, because it already exists.', this.basicRoleInfo(role)); + logger.info('Not adding mod role, because it already exists.', this.basicInfo(role)); return false; } } - static deleteRole(role) { + static delete(role) { if(!role) throw new Error('A role must be specified.'); if(!this.serversMap) this.loadDatabase(); if(!this.serversMap[role.server.id]) return false; @@ -42,16 +42,16 @@ export default class ModRolesDatabase { const roleIndex = serverRoles.findIndex(element => element === role.id); if(roleIndex >= 0) { serverRoles.splice(roleIndex, 1); - logger.info('Removed mod role.', this.basicRoleInfo(role)); + logger.info('Removed mod role.', this.basicInfo(role)); this.saveDatabase(); return true; } else { - logger.info('Not removing mod role, because it doesn\'t exist.', this.basicRoleInfo(role)); + logger.info('Not removing mod role, because it doesn\'t exist.', this.basicInfo(role)); return false; } } - static findRolesInServer(server, searchString = null) { + static findInServer(server, searchString = null) { if(!server) throw new Error('A server must be specified.'); if(!this.serversMap) this.loadDatabase(); if(!this.serversMap[server.id]) return []; @@ -61,13 +61,13 @@ export default class ModRolesDatabase { return search(roles, searchString, { searchInexact: false }); } - static serverHasRoles(server) { + static serverHasAny(server) { if(!server) throw new Error('A server must be specified.'); if(!this.serversMap) this.loadDatabase(); return this.serversMap[server.id] && this.serversMap[server.id].length > 0; } - static basicRoleInfo(role) { + static basicInfo(role) { return { id: role.id, name: role.name, server: role.server.name, serverID: role.server.id }; } } diff --git a/src/database/setting.js b/src/database/setting.js index a168c5c..878494c 100644 --- a/src/database/setting.js +++ b/src/database/setting.js @@ -1,8 +1,68 @@ +'use babel'; +'use strict'; + +import storage from './local-storage'; +import logger from '../util/logger'; + export default class Setting { constructor(server, key, value) { - if(!key || !server) throw new Error('Setting key and server must be specified.'); + if(!key) throw new Error('Setting key must be specified.'); this.key = key; this.value = value; - this.server = server.id ? server.id : server; + this.server = server ? (server.id ? server.id : server) : 'global'; + } + + static loadDatabase() { + this.serversMap = JSON.parse(storage.getItem('settings')); + if(!this.serversMap) this.serversMap = {}; + } + + static saveDatabase() { + logger.debug('Saving settings storage...', this.serversMap); + storage.setItem('settings', JSON.stringify(this.serversMap)); + } + + static save(setting) { + if(!setting) throw new Error('A setting must be specified.'); + if(!this.serversMap) this.loadDatabase(); + if(!this.serversMap[setting.server]) this.serversMap[setting.server] = {}; + this.serversMap[setting.server][setting.key] = setting.value; + logger.info('Saved setting.', setting); + this.saveDatabase(); + return true; + } + + static get(setting, server = null) { + [setting, server] = this.getSettingKeyAndServer(setting, server); + if(!this.serversMap) this.loadDatabase(); + if(!this.serversMap[server]) return null; + return new Setting(server, setting, this.serversMap[server][setting]); + } + + static getValue(setting, defaultValue = null, server = null) { + [setting, server] = this.getSettingKeyAndServer(setting, server); + if(!this.serversMap) this.loadDatabase(); + if(!this.serversMap[server]) return defaultValue; + return setting in this.serversMap[server] ? this.serversMap[server][setting] : defaultValue; + } + + static delete(setting, server = null) { + [setting, server] = this.getSettingKeyAndServer(setting, server); + if(!this.serversMap) this.loadDatabase(); + if(!this.serversMap[server]) return false; + if(typeof this.serversMap[server][setting] === 'undefined') return false; + delete this.serversMap[server][setting]; + logger.info('Removed setting.', { key: setting, server: server }); + this.saveDatabase(); + return true; + } + + static getSettingKeyAndServer(setting, server) { + if(setting instanceof Setting) { + return [setting.key, !server ? setting.server : (server.id ? server.id : server)]; + } else { + if(!setting) throw new Error('A setting or a key must be specified.'); + return [setting, server ? (server.id ? server.id : server) : 'global']; + } } } diff --git a/src/database/settings.js b/src/database/settings.js deleted file mode 100644 index 8fe8ccb..0000000 --- a/src/database/settings.js +++ /dev/null @@ -1,62 +0,0 @@ -'use babel'; -'use strict'; - -import Setting from './setting'; -import storage from './local-storage'; -import logger from '../util/logger'; - -export default class SettingsDatabase { - static loadDatabase() { - this.serversMap = JSON.parse(storage.getItem('settings')); - if(!this.serversMap) this.serversMap = {}; - } - - static saveDatabase() { - logger.debug('Saving settings database...', this.serversMap); - storage.setItem('settings', JSON.stringify(this.serversMap)); - } - - static saveSetting(setting) { - if(!setting) throw new Error('A setting must be specified.'); - if(!this.serversMap) this.loadDatabase(); - if(!this.serversMap[setting.server]) this.serversMap[setting.server] = {}; - this.serversMap[setting.server][setting.key] = setting.value; - logger.info('Saved setting.', setting); - this.saveDatabase(); - return true; - } - - static getSetting(setting, server = null) { - if(!(setting instanceof Setting) && (!setting || !server)) throw new Error('A setting or a key and server must be specified.'); - if(!this.serversMap) this.loadDatabase(); - if(!server) server = setting.server; - if(server.id) server = server.id; - if(!this.serversMap[server]) return null; - const key = setting instanceof Setting ? setting.key : setting; - return new Setting(server, key, this.serversMap[server][key]); - } - - static getSettingValue(setting, defaultValue = null, server = null) { - if(!(setting instanceof Setting) && (!setting || !server)) throw new Error('A setting or a key and server must be specified.'); - if(!this.serversMap) this.loadDatabase(); - if(!server) server = setting.server; - if(server.id) server = server.id; - if(!this.serversMap[server]) return defaultValue; - const key = setting instanceof Setting ? setting.key : setting; - return key in this.serversMap[server] ? this.serversMap[server][key] : defaultValue; - } - - static deleteSetting(setting, server = null) { - if(!(setting instanceof Setting) && (!setting || !server)) throw new Error('A setting or a key and server must be specified.'); - if(!this.serversMap) this.loadDatabase(); - if(!server) server = setting.server; - if(server.id) server = server.id; - if(!this.serversMap[server]) return false; - const key = setting instanceof Setting ? setting.key : setting; - if(typeof this.serversMap[server][key] === 'undefined') return false; - delete this.serversMap[server][key]; - logger.info('Removed setting.', { key: key, server: server }); - this.saveDatabase(); - return true; - } -} diff --git a/src/rpbot.js b/src/rpbot.js index c72bf92..4a9bb10 100644 --- a/src/rpbot.js +++ b/src/rpbot.js @@ -2,16 +2,18 @@ 'use babel'; 'use strict'; +import config from './config'; import Discord from 'discord.js'; import stringArgv from 'string-argv'; -import config from './config'; import version from './version'; import commands from './commands'; +import { init as initDatabase, close as closeDatabase } from './database'; import logger from './util/logger'; import buildCommandPattern from './util/command-pattern'; import checkForUpdate from './util/update-check'; import * as usage from './util/command-usage'; import * as analytics from './util/analytics'; +import FriendlyError from './util/errors/friendly'; logger.info('RPBot v' + version + ' is starting...'); analytics.sendEvent('Bot', 'started'); @@ -30,6 +32,12 @@ if(!config.token && (!config.email || !config.password)) { process.exit(1); } +// Set up database +initDatabase().catch(err => { + logger.error(err); + process.exit(1); +}); + // Create client const clientOptions = { autoReconnect: config.autoReconnect, forceFetchUsers: true, disableEveryone: true }; export const client = new Discord.Client(clientOptions); @@ -109,14 +117,15 @@ client.on('message', message => { if(runCommand.isRunnable(message)) { logger.info(`Running ${runCommand.group}:${runCommand.groupName}.`, logInfo); analytics.sendEvent('Command', 'run', runCommand.group + ':' + runCommand.groupName); - try { - const result = runCommand.run(message, runArgs, runFromPattern); - if(typeof result !== 'undefined' && !result) message.reply(`Invalid command format. Use \`!help ${runCommand.name}\` for information.`); - } catch(e) { - message.reply(`An error occurred while running the command. (${e.name}: ${e.message})`); - logger.error(e); - analytics.sendException(e); - } + runCommand.run(message, runArgs, runFromPattern).catch(err => { + if(err instanceof FriendlyError) { + message.reply(err.message); + } else { + message.reply(`An error occurred while running the command. (${err.name}: ${err.message})`); + logger.error(err); + analytics.sendException(err); + } + }); } else { message.reply(`The \`${runCommand.name}\` command is not currently usable in your context.`); logger.info(`Not running ${runCommand.group}:${runCommand.groupName}; not runnable.`, logInfo); @@ -137,7 +146,20 @@ if(config.token) { } // Exit on interrupt -process.on('SIGINT', () => { - logger.info('Received interrupt signal; destroying client and exiting...'); - client.destroy(() => { process.exit(0); }); +let interruptCount = 0; +process.on('SIGINT', async () => { + interruptCount++; + if(interruptCount === 1) { + logger.info('Received interrupt signal; closing database, destroying client, and exiting...'); + await Promise.all([ + closeDatabase(), + client.destroy() + ]).catch((err) => { + logger.error(err); + }); + process.exit(0); + } else { + logger.info('Received another interrupt signal; immediately exiting.'); + process.exit(0); + } }); diff --git a/src/util/command-pattern.js b/src/util/command-pattern.js index f172ddf..39666de 100644 --- a/src/util/command-pattern.js +++ b/src/util/command-pattern.js @@ -3,11 +3,11 @@ import escapeRegex from 'escape-string-regexp'; import config from '../config'; -import SettingsDatabase from '../database/settings'; +import Setting from '../database/setting'; import logger from './logger'; export function buildCommandPattern(server, user) { - let prefix = server ? SettingsDatabase.getSettingValue('command-prefix', config.commandPrefix, server) : config.commandPrefix; + let prefix = server ? Setting.getValue('command-prefix', config.commandPrefix, server) : config.commandPrefix; if(prefix === 'none') prefix = ''; const escapedPrefix = escapeRegex(prefix); const prefixPatternPiece = prefix ? escapedPrefix + '\\s*|' : ''; diff --git a/src/util/command-usage.js b/src/util/command-usage.js index ac3eb94..db32ada 100644 --- a/src/util/command-usage.js +++ b/src/util/command-usage.js @@ -3,12 +3,12 @@ import { client } from '../rpbot'; import config from '../config'; -import SettingsDatabase from '../database/settings'; +import Setting from '../database/setting'; import nbsp from './nbsp'; export function long(command, server = null) { const nbcmd = nbsp(command); - let prefix = nbsp(server ? SettingsDatabase.getSettingValue('command-prefix', config.commandPrefix, server) : config.commandPrefix); + let prefix = nbsp(server ? Setting.getValue('command-prefix', config.commandPrefix, server) : config.commandPrefix); if(prefix.length > 1) prefix += '\xa0'; const prefixAddon = prefix ? `\`${prefix}${nbcmd}\` or ` : ''; return `${prefixAddon}\`@${nbsp(client.user.name)}#${client.user.discriminator}\xa0${nbcmd}\``; diff --git a/src/util/errors/command-format.js b/src/util/errors/command-format.js new file mode 100644 index 0000000..95862f3 --- /dev/null +++ b/src/util/errors/command-format.js @@ -0,0 +1,12 @@ +'use babel'; +'use strict'; + +import FriendlyError from './friendly'; +import * as usage from '../command-usage'; + +export default class CommandFormatError extends FriendlyError { + constructor(command) { + super(`Invalid command format. Use \`${usage.short(`help ${command.name}`)}\` for information.`); + this.name = 'CommandFormatError'; + } +} diff --git a/src/util/errors/friendly.js b/src/util/errors/friendly.js new file mode 100644 index 0000000..3eeeeee --- /dev/null +++ b/src/util/errors/friendly.js @@ -0,0 +1,9 @@ +'use babel'; +'use strict'; + +export default class FriendlyError extends Error { + constructor(message) { + super(message); + this.name = 'FriendlyError'; + } +} diff --git a/src/util/permissions.js b/src/util/permissions.js index 1ef7fe6..190f679 100644 --- a/src/util/permissions.js +++ b/src/util/permissions.js @@ -3,15 +3,15 @@ import { client } from '../rpbot'; import config from '../config'; -import ModRolesDatabase from '../database/mod-roles'; +import ModRole from '../database/mod-role'; export function isModerator(server, user) { [server, user] = resolve(server, user); if(user.id === config.owner) return true; const userRoles = server.rolesOfUser(user); if(userRoles.some(role => role.hasPermission('administrator'))) return true; - if(!ModRolesDatabase.serverHasRoles(server)) return userRoles.some(role => role.hasPermission('manageMessages')); - return ModRolesDatabase.findRolesInServer(server).some(element => userRoles.some(element2 => element.id === element2.id)); + if(!ModRole.serverHasAny(server)) return userRoles.some(role => role.hasPermission('manageMessages')); + return ModRole.findInServer(server).some(element => userRoles.some(element2 => element.id === element2.id)); } export function isAdministrator(server, user) { diff --git a/src/util/search.js b/src/util/search.js index 09b6448..cf6ad75 100644 --- a/src/util/search.js +++ b/src/util/search.js @@ -1,31 +1,27 @@ 'use babel'; 'use strict'; -export default function search(items, searchString, options) { +export default function search(items, searchString, { property = 'name', searchInexact = true, searchExact = true, useStartsWith = false } = {}) { if(!items || items.length === 0) return []; if(!searchString) return items; - if(!options) options = {}; - if(!('property' in options)) options.property = 'name'; - if(!('searchInexact' in options)) options.searchInexact = true; - if(!('searchExact' in options)) options.searchExact = true; const lowercaseSearch = searchString.toLowerCase(); let matchedItems; // Find all items that start with or include the search string - if(options.searchInexact) { - if(options.useStartsWith && searchString.length === 1) { - matchedItems = items.filter(element => (options.property ? element[options.property] : element.toString()).normalize('NFKD').toLowerCase().startsWith(lowercaseSearch)); + if(searchInexact) { + if(useStartsWith && searchString.length === 1) { + matchedItems = items.filter(element => (property ? element[property] : element.toString()).normalize('NFKD').toLowerCase().startsWith(lowercaseSearch)); } else { - matchedItems = items.filter(element => (options.property ? element[options.property] : element.toString()).normalize('NFKD').toLowerCase().includes(lowercaseSearch)); + matchedItems = items.filter(element => (property ? element[property] : element.toString()).normalize('NFKD').toLowerCase().includes(lowercaseSearch)); } } else { matchedItems = items; } // See if any are an exact match - if(options.searchExact && matchedItems.length > 1) { - const exactItems = matchedItems.filter(element => (options.property ? element[options.property] : element.toString()).normalize('NFKD').toLowerCase() === lowercaseSearch); + if(searchExact && matchedItems.length > 1) { + const exactItems = matchedItems.filter(element => (property ? element[property] : element.toString()).normalize('NFKD').toLowerCase() === lowercaseSearch); if(exactItems.length > 0) return exactItems; } diff --git a/src/util/update-check.js b/src/util/update-check.js index f8e13de..05db3ca 100644 --- a/src/util/update-check.js +++ b/src/util/update-check.js @@ -7,7 +7,7 @@ import logger from './logger'; import { client } from '../rpbot'; import config from '../config'; import version from '../version'; -import localStorage from '../database/local-storage'; +import Setting from '../database/setting'; const packageURL = 'https://raw.githubusercontent.com/Gawdl3y/discord-rpbot/master/package.json'; @@ -18,10 +18,10 @@ export default function checkForUpdate() { if(semver.gt(masterVersion, version)) { const message = `An RPBot update is available! Current version is ${version}, latest available is ${masterVersion}.`; logger.warn(message); - const savedVersion = localStorage.getItem('notified-version'); + const savedVersion = Setting.getValue('notified-version'); if(savedVersion != masterVersion && client && config.owner) { client.sendMessage(config.owner, message); - localStorage.setItem('notified-version', masterVersion); + Setting.save(new Setting(null, 'notified-version', masterVersion)); } } }