aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSin-MacBook <[email protected]>2020-08-10 23:44:20 +0200
committerSin-MacBook <[email protected]>2020-08-10 23:44:20 +0200
commit2a53887abba882bf7b63aeda8dfa55fdb3ab8792 (patch)
treead7a95eb41faa6ff13c3142285cdc0eb3ca92183
downloadmodmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.tar.xz
modmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.zip
clean this up when home
-rw-r--r--.DS_Storebin0 -> 6148 bytes
-rw-r--r--.editorconfig13
-rw-r--r--.eslintrc24
-rw-r--r--.gitattributes2
-rw-r--r--.gitignore7
-rw-r--r--.npmrc1
-rw-r--r--.nvmrc1
-rw-r--r--CHANGELOG.md284
-rw-r--r--LICENSE.md21
-rw-r--r--README.md20
-rw-r--r--attachments/.gitignore2
-rw-r--r--db/.gitignore.example3
-rw-r--r--db/data.sqlitebin0 -> 86016 bytes
-rw-r--r--db/migrations/20171223203915_create_tables.js45
-rw-r--r--db/migrations/20180224235946_add_close_at_to_threads.js15
-rw-r--r--db/migrations/20180421161550_add_alert_id_to_threads.js11
-rw-r--r--db/migrations/20180920224224_remove_is_anonymous_from_snippets.js11
-rw-r--r--db/migrations/20190306204728_add_scheduled_close_silent_to_threads.js11
-rw-r--r--db/migrations/20190306211534_add_scheduled_suspend_to_threads.js15
-rw-r--r--db/migrations/20190609161116_create_updates_table.js14
-rw-r--r--db/migrations/20190609193213_add_expires_at_to_blocked_users.js11
-rw-r--r--docs/commands.md31
-rw-r--r--docs/configuration.md342
-rw-r--r--docs/faq.md21
-rw-r--r--docs/plugins.md47
-rw-r--r--docs/setup.md50
-rw-r--r--docs/starting-the-bot.md16
-rw-r--r--knexfile.js2
-rw-r--r--logs/.gitignore2
-rw-r--r--modmailbot-pm2.json8
-rw-r--r--package-lock.json4180
-rw-r--r--package.json37
-rw-r--r--src/bot.js9
-rw-r--r--src/commands.js130
-rw-r--r--src/config.js289
-rw-r--r--src/data/Snippet.js15
-rw-r--r--src/data/Thread.js468
-rw-r--r--src/data/ThreadMessage.js20
-rw-r--r--src/data/attachments.js202
-rw-r--r--src/data/blocked.js94
-rw-r--r--src/data/constants.js55
-rw-r--r--src/data/snippets.js58
-rw-r--r--src/data/threads.js381
-rw-r--r--src/data/updates.js115
-rw-r--r--src/index.js93
-rw-r--r--src/knex.js2
-rw-r--r--src/legacy/jsonDb.js71
-rw-r--r--src/legacy/legacyMigrator.js222
-rw-r--r--src/main.js281
-rw-r--r--src/modules/alert.js11
-rw-r--r--src/modules/block.js99
-rw-r--r--src/modules/close.js153
-rw-r--r--src/modules/greeting.js37
-rw-r--r--src/modules/id.js5
-rw-r--r--src/modules/logs.js69
-rw-r--r--src/modules/move.js82
-rw-r--r--src/modules/newthread.js23
-rw-r--r--src/modules/reply.js32
-rw-r--r--src/modules/snippets.js138
-rw-r--r--src/modules/suspend.js77
-rw-r--r--src/modules/typingProxy.js33
-rw-r--r--src/modules/version.js53
-rw-r--r--src/modules/webserver.js92
-rw-r--r--src/plugins.js26
-rw-r--r--src/queue.js40
-rw-r--r--src/utils.js357
-rw-r--r--start.bat17
67 files changed, 9096 insertions, 0 deletions
diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..f0c1918
--- /dev/null
+++ b/.DS_Store
Binary files differ
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..5d12634
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,13 @@
+# editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..d33e1cf
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,24 @@
+{
+ "root": true,
+
+ "parserOptions": {
+ "ecmaVersion": 10,
+ "sourceType": "module"
+ },
+
+ "env": {
+ "node": true
+ },
+
+ "rules": {
+ "space-infix-ops": "error",
+ "space-unary-ops": ["error", {
+ "words": true,
+ "nonwords": false,
+ "overrides": {
+ "!": true,
+ "!!": true
+ }
+ }]
+ }
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dfe0770
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6757ecf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/.vscode
+/.idea
+/node_modules
+/config.*
+!/config.example.ini
+/welcome.png
+/update.sh
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..2ae2317
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+loglevel=silent
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..f599e28
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+10
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f4f990b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,284 @@
+# Changelog
+
+## v2.30.1
+* Fix crash with `responseMessage` and `closeMessage` introduced in v2.30.0
+ ([#369](https://github.com/Dragory/modmailbot/pull/369))
+
+## v2.30.0
+* The following config options now also support multi-line values:
+ * `responseMessage`
+ * `closeMessage`
+ * `botMentionResponse`
+ * `greetingMessage`
+ * `accountAgeDeniedMessage`
+ * `timeOnServerDeniedMessage`
+* When the bot is mentioned on the main server, the log message about this now
+ also includes a link to the message ([#319](https://github.com/Dragory/modmailbot/pull/319))
+* Fix error when supplying all config values from env variables without a config file
+* Fix crash in update checker if the repository value in package.json is set to
+ a GitHub repository without releases (this only applies to forks)
+
+## v2.29.1
+* Fix boolean values in `config.ini` not being handled properly
+
+## v2.29.0
+* **Default configuration format is now .ini**
+ * Existing `config.json` files will continue to work and will not be deprecated
+ * This makes the default configuration format for the bot much more approachable than JSON
+* Config values can now also be loaded from environment variables
+ (see [Configuration](docs/configuration.md#environment-variables) for more details)
+* New rewritten instructions for setting up and using the bot
+* New easy-to-use `start.bat` file for Windows
+* Update several package dependencies
+* Fixed incompatibility with Node.js 10 versions prior to 10.9.0
+
+## v2.28.0
+* Fix error when saving attachments locally with `attachmentStorage` set to `"local"` (default) when the bot's folder is
+ on a different storage device than the system's temp folder
+* Add `attachments` object to the plugin API
+ * This allows plugins to add new storage types via `attachments.addStorageType()`
+ * See the [Plugins section in the README](README.md#plugins) for more details
+
+## v2.27.0
+* The `syncPermissionsOnMove` option now defaults to `true`, which should be more intuitive
+* **Plugins:** Plugin functions are no longer called with 4 arguments. Instead, the function is called with 1 argument,
+which is an object that contains the previous 4 values as properties: `bot`, `knex`, `config`, `commands`.
+This will make it easier to scale the plugin system in the future with new features.
+You can see an [updated example in the README](https://github.com/Dragory/modmailbot/blob/master/README.md#example-plugin-file).
+
+## v2.26.0
+* The bot now waits for the main server(s) and inbox server to become available before initializing.
+This is a potential fix to [#335](https://github.com/Dragory/modmailbot/issues/335).
+This should have little to no effect on smaller servers.
+* The bot status ("Playing") is now reapplied hourly since the status can sometimes disappear
+
+## v2.25.0
+* Fix regression introduced in v2.24.0 where line breaks would get turned to spaces in replies and snippets ([#304](https://github.com/Dragory/modmailbot/issues/304))
+* Replace the internal command handler with a new one. This should be fairly thoroughly tested, but please report any issues you encounter!
+* Plugins are now called with a fourth parameter that allows you to easily add specific types of commands
+ * Due to the command handler change, any calls to `bot.registerCommand` should be replaced with the new system
+
+## v2.24.0
+* Switch to the new stable version of Eris (0.10.0) instead of the dev version
+
+## v2.23.2
+* Update Node.js version check at startup to require Node.js 10
+
+## v2.23.1
+* Updated required Node.js version in .nvmrc and README (v10 is now the minimum)
+
+## v2.23.0
+* Add update notifications. The bot will check for new versions every 12 hours and notify moderators at the top of new
+modmail threads when there are new versions available. Can be disabled by setting the `updateNotifications` option to `false`.
+New available versions are also shown in `!version`.
+ * If you have forked the repository and want to check for updates in your own repository instead,
+ change the `repository` value in `package.json`
+* Add basic support for plugins. See the **Plugins** section in README for more information.
+* Add support for snippet arguments. To use these, put {1}, {2}, etc. in the snippet text and they will be replaced by the given arguments when using the snippet.
+* Add support for multiple `mentionRole` config option values in an array
+* Add `commandAliases` config option to set custom command aliases
+* Add support for timed blocks. Simply specify the duration as the last argument in `!block` or `!unblock`.
+* Add pagination to `!logs`
+
+## v2.22.0
+* Add `guildGreetings` option to allow configuring greeting messages on a per-server basis
+* Add `rolesInThreadHeader` option to show the user's roles in the modmail thread's header
+
+## v2.21.3
+* Fix crash caused by Nitro Boosting notifications
+
+## v2.21.2
+* Update Eris to fix crashes with news channels and nitro boosting
+
+## v2.21.1
+* "Account age" and "time on server" requirements are now ignored when using `!newthread`
+
+## v2.21.0
+* Add `requiredTimeOnServer` and `timeOnServerDeniedMessage` config options to restrict modmail from users who have just joined the server. Thanks [@reboxer](https://github.com/reboxer) ([#270](https://github.com/Dragory/modmailbot/pull/270))!
+
+## v2.20.0
+* Add `categoryAutomation` option to automate thread categories. Currently supported sub-options:
+ * `newThread` - same as `newThreadCategoryId`, the default category for new threads
+ * `newThreadFromGuild` - default category on a per-guild basis, value is an object with guild IDs as keys and category IDs as values
+* Threads should now include member information (nickname, joined at, etc.) more reliably
+* Thread header now also includes the member's current voice channel, if any
+
+## v2.19.0
+* Add `attachmentStorage` option to control where attachments are saved. Currently supported:
+ * `"local"` (default) - Same as before: attachments are saved locally on the machine running the bot and served through the bot's web server
+ * `"discord"` - Attachments are saved on a special Discord channel specified by the `attachmentStorageChannelId` option
+* Add `syncPermissionsOnMove` option. When enabled, thread channel permissions are synced with the category when the thread is moved with `!move`.
+* Add support for scheduling `!suspend`. Works the same way as with `!close`, just specify the time after the command. Can be cancelled with `!suspend cancel`.
+* Scheduled `!close` can now be silent - just add `silent` as an argument to the command before or after the schedule time
+* The schedule time format for `!close` is now stricter and times with whitespace (e.g. `2 h 30 m`) no longer work. Use e.g. `2h30m` instead.
+* `!loglink` can now be used in suspended threads
+* User can now be mentioned in `botMentionResponse` by adding `{userMention}` to the response text. Thanks @reboxer (#225)!
+* Fixed a small mistake in README, thanks @GabrielLewis2 (#226)!
+
+## v2.18.0
+* Add `silent` option to `!close` (e.g. `!close silent`) to close threads without sending the specified `closeMessage`
+* Update some package versions (may help with sqlite3 install issues)
+
+## v2.17.0
+* Add `mentionUserInThreadHeader` option. When set to `true`, mentions the thread's user in the thread header. Fixes #152.
+* Add `botMentionResponse` option. If set, the bot auto-responds to bot mentions with this message. Fixes #143.
+* Fix member info sometimes missing in thread header. Thanks @Akhawais (#136)!
+* Add support for role and user IDs in inboxServerPermission instead of just permission names
+* Allow specifying multiple values (an array) for inboxServerPermission. Members will be considered "staff" if they pass any of the values.
+* Update Eris to 0.9.0, Knex to 0.15.2
+* Add support for sending anonymous snippets. By default, you can do this by using `!!!` instead of `!!`. Fixes #82.
+* Add `snippetPrefixAnon` option
+* Add `allowUserClose` option. When set to `true`, users can use the close command to close threads by themselves.
+* Fix `allowMove` missing from README. Thanks @AndreasGassmann (#126)!
+
+## v2.16.0
+* Add support for a .js config file (export config with `module.exports`)
+
+## v2.15.2
+* Update several other packages as well
+
+## v2.15.1
+* Update `node-sqlite3` dependency to hopefully fix installation issues on some setups
+
+## v2.15.0
+* Add `smallAttachmentLimit` config option to control the max size of attachments forwarded by `relaySmallAttachmentsAsAttachments`
+* Fix crash when `closeMessage` failed to send
+* Handle webserver errors gracefully
+
+## v2.14.1
+* Don't alert for main server pings if the pinger is a bot
+
+## v2.14.0
+* Changed `requiredAccountAge` to be in hours instead of days
+
+## v2.13.0
+* Added `requiredAccountAge` and `accountAgeDeniedMessage` options for restricting how new accounts can message modmail
+
+## v2.12.0
+* Added `closeMessage` option. This option can be used to send a message to the user when their modmail thread is closed.
+* Documented `pingOnBotMention` option
+
+## v2.11.1
+* Fixed greetings not being sent since multi-server support was added in 2.9.0
+
+## v2.11.0
+* Config files are now parsed using [JSON5](https://json5.org/), allowing you to use comments, trailing commas, and other neat things in your config.json
+* When using multiple main guilds, the originating guild name is now always included at the top of the thread (if possible).
+Previously, if the user that messaged modmail was on only one of the guilds, the guild name would not be shown at the top.
+* Fixed crash when a user edited a message in their DMs with modmail without an open thread
+* Small fixes to category name matching when using `!move`
+* Fixed crash when the bot was unable to send an auto-response to a user
+* Added option `pingOnBotMention` (defaults to `true`) that allows you to control whether staff are pinged when the bot is mentioned
+* Long messages are now chunked so they don't fail to send due to added length from e.g. user name
+
+## v2.10.1
+* Changed timed close default unit from seconds to minutes.
+This means that doing e.g. `!close 30` now closes the thread in 30 *minutes*, not seconds.
+
+## v2.10.0
+* Added `!alert`
+Using `!alert` in a modmail thread will ping you the next time the thread gets a new reply.
+Use `!alert cancel` to cancel.
+
+## v2.9.1
+* If using multiple main guilds, the originating server is now specified in bot mention notifications
+
+## v2.9.0
+* Added multi-server support.
+Multi-server support allows you to set an array of ids in mainGuildId.
+Nickname and join date will be displayed for each main guild the user is in.
+* Information posted at the top of modmail threads now also includes time since the user joined the guild(s)
+* Added `!id`
+`!id` posts the user ID of the current thread. Useful on mobile when you need to get the user ID.
+* Added `!newthread`
+`!newthread <userid>` opens a new thread with the specified user
+* Fixed a crash when the bot was unable to send a greeting message due to the user's privacy options
+
+## v2.8.0
+* Added a `!version` command for checking the version of the bot you're running
+
+## v2.7.0
+* Split more code from main.js to individual module files
+
+## v2.6.0
+* Warn the user if new dependencies haven't been installed
+* `!close` now supports `d` for days in the delay
+* `!close` is now stricter about the time format
+
+## v2.5.0
+* Commands used in inbox threads are now saved in logs again
+* Moved more of the code to individual plugin files
+
+## v2.4.1-v2.4.4
+* Fix errors on first run after upgrading to v2.2.0
+* Various other fixes
+
+## v2.4.0
+* Add thread suspending. A modmail thread can now be suspended with `!suspend`. Suspended threads will function as closed until unsuspended with `!unsuspend`.
+
+## v2.3.2
+* Auto-close threads if their modmail channel is deleted
+
+## v2.3.1
+* Fixed incorrect default value for `mentionRole` (was `null`, should've been `"here"`)
+
+## v2.3.0
+* Added `mentionRole` configuration option ([#59](https://github.com/Dragory/modmailbot/pull/59)). This option can be used to set the role that is pinged when new threads are created or the bot is mentioned. See README for more details.
+
+## v2.2.0
+* Added the ability to schedule a thread to close by specifying a time after `!close`, e.g. `!close 1h`. The scheduling is cancelled if a new message is sent to or received from the user.
+
+## v2.1.0
+* Added typing proxy ([#48](https://github.com/Dragory/modmailbot/pull/48)):
+ * If the `typingProxy` config option is enabled, any time a user is typing to modmail in their DMs, the modmail thread will show the bot as "typing"
+ * If the `typingProxyReverse` config option is enabled, any time a moderator is typing in a modmail thread, the user will see the bot "typing" in their DMs
+
+## v2.0.1
+* The link to the current thread's log is no longer posted to the top of the thread. Use `!loglink` instead.
+
+## v2.0.0
+* Rewrote large parts of the code to be more modular and maintainable. There may be some new bugs because of this - please report them through GitHub issues if you encounter any!
+* Threads, logs, and snippets are now stored in an SQLite database. The bot will migrate old data on the first run.
+* Small attachments (<2MB) from users can now be relayed as Discord attachments in the modmail thread with the `relaySmallAttachmentsAsAttachments` config option. Logs will have the link as usual.
+* Fixed system messages like pins in DMs being relayed to the thread
+* Fixed channels sometimes being created without a category even when `newThreadCategoryId` was set
+* Removed timestamps from threads by default. Logs will still have accurate timestamps. Can be re-enabled with the `threadTimestamps` config option.
+* Added `!move` command to move threads between categories. Can be enabled with the `allowMove` config option, disabled by default.
+
+## Sep 22, 2017
+* Added `newThreadCategoryId` option. This option can be set to a category ID to place all new threads in that category.
+
+## Sep 20, 2017
+* Fixed crash when the bot was unable to find or create a modmail thread
+* Reduced error log spam in case of network errors from Eris
+* Fixed unintended error when a message was ignored due to an "accidental thread" word
+
+## Sep 19, 2017
+* Added `logChannelId` option
+* Some code clean-up. Please open an issue if you encounter any bugs!
+* The bot now throws an error for unknown options in `config.json` (assuming they're typos) and tells you if you haven't configured the token or mail guild id.
+
+## Aug 3, 2017
+* Fixed user nicknames not showing in new threads
+* The "manageRoles" permission is no longer required to use commands on the inbox server.
+This can be configured with the `inboxServerPermission` config option.
+
+## July 24, 2017
+* Commands are now case-insensitive (so !close, !Close, and !CLOSE all work)
+* The before/after prefixes in edit notifications are now the same length, making it easier to spot the edited part
+* Non-ASCII names should now result in better channel names (not just "unknown")
+
+## May 18, 2017
+* Identical edits are now ignored
+* New thread notification (with @ here ping) is now posted in the thread instead of the inbox server default channel
+* Thread close notifications no longer ping the user who closed the thread
+* Received attachments are now only linked once the bot has saved them (should fix embeds)
+* Replies now use your nickname, if any
+* Several messages are now ignored for thread creation ("ok", "thanks", and similar)
+* Logs from !logs are now sorted in descending order (newest first)
+
+## Feb 15, 2017
+More info is now available at the beginning of modmail threads.
+
+## Feb 10, 2017
+Major rewrite. Includes anonymous replies (!ar), stability improvements, and server greeting feature.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..f682d0f
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Miikka Virtanen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4863c7f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,20 @@
+# Modmail for Discord
+Modmail Bot is a bot for [Discord](https://discordapp.com/) that allows users to DM the bot to contact the server's moderators/staff
+without messaging them individually or pinging them publically on the server.
+These DMs get relayed to modmail *threads*, channels where staff members can reply to and talk with the user.
+To the user, the entire process happens in DMs with the bot.
+
+Inspired by Reddit's modmail system.
+
+## Getting started
+* **[🛠️ Setting up the bot](docs/setup.md)**
+* [📝 Configuration](docs/configuration.md)
+* [🤖 Commands](docs/commands.md)
+* [🧩 Plugins](docs/plugins.md)
+* [🙋 Frequently Asked Questions](docs/faq.md)
+* [Release notes](CHANGELOG.md)
+
+## Support server
+If you need help with setting up the bot or would like to discuss other things related to it, join the support server on Discord here:
+
+👉 **[Join support server](https://discord.gg/vRuhG9R)**
diff --git a/attachments/.gitignore b/attachments/.gitignore
new file mode 100644
index 0000000..120f485
--- /dev/null
+++ b/attachments/.gitignore
@@ -0,0 +1,2 @@
+*
+!/.gitignore
diff --git a/db/.gitignore.example b/db/.gitignore.example
new file mode 100644
index 0000000..08f41a1
--- /dev/null
+++ b/db/.gitignore.example
@@ -0,0 +1,3 @@
+/*
+!/.gitignore
+!/migrations/
diff --git a/db/data.sqlite b/db/data.sqlite
new file mode 100644
index 0000000..967e6c5
--- /dev/null
+++ b/db/data.sqlite
Binary files differ
diff --git a/db/migrations/20171223203915_create_tables.js b/db/migrations/20171223203915_create_tables.js
new file mode 100644
index 0000000..b5880a3
--- /dev/null
+++ b/db/migrations/20171223203915_create_tables.js
@@ -0,0 +1,45 @@
+exports.up = async function(knex, Promise) {
+ await knex.schema.createTableIfNotExists('threads', table => {
+ table.string('id', 36).notNullable().primary();
+ table.integer('status').unsigned().notNullable().index();
+ table.integer('is_legacy').unsigned().notNullable();
+ table.string('user_id', 20).notNullable().index();
+ table.string('user_name', 128).notNullable();
+ table.string('channel_id', 20).nullable().unique();
+ table.dateTime('created_at').notNullable().index();
+ });
+
+ await knex.schema.createTableIfNotExists('thread_messages', table => {
+ table.increments('id');
+ table.string('thread_id', 36).notNullable().index().references('id').inTable('threads').onDelete('CASCADE');
+ table.integer('message_type').unsigned().notNullable();
+ table.string('user_id', 20).nullable();
+ table.string('user_name', 128).notNullable();
+ table.mediumtext('body').notNullable();
+ table.integer('is_anonymous').unsigned().notNullable();
+ table.string('dm_message_id', 20).nullable().unique();
+ table.dateTime('created_at').notNullable().index();
+ });
+
+ await knex.schema.createTableIfNotExists('blocked_users', table => {
+ table.string('user_id', 20).primary().notNullable();
+ table.string('user_name', 128).notNullable();
+ table.string('blocked_by', 20).nullable();
+ table.dateTime('blocked_at').notNullable();
+ });
+
+ await knex.schema.createTableIfNotExists('snippets', table => {
+ table.string('trigger', 32).primary().notNullable();
+ table.text('body').notNullable();
+ table.integer('is_anonymous').unsigned().notNullable();
+ table.string('created_by', 20).nullable();
+ table.dateTime('created_at').notNullable();
+ });
+};
+
+exports.down = async function(knex, Promise) {
+ await knex.schema.dropTableIfExists('thread_messages');
+ await knex.schema.dropTableIfExists('threads');
+ await knex.schema.dropTableIfExists('blocked_users');
+ await knex.schema.dropTableIfExists('snippets');
+};
diff --git a/db/migrations/20180224235946_add_close_at_to_threads.js b/db/migrations/20180224235946_add_close_at_to_threads.js
new file mode 100644
index 0000000..dbbb04d
--- /dev/null
+++ b/db/migrations/20180224235946_add_close_at_to_threads.js
@@ -0,0 +1,15 @@
+exports.up = async function (knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.dateTime('scheduled_close_at').index().nullable().defaultTo(null).after('channel_id');
+ table.string('scheduled_close_id', 20).nullable().defaultTo(null).after('channel_id');
+ table.string('scheduled_close_name', 128).nullable().defaultTo(null).after('channel_id');
+ });
+};
+
+exports.down = async function(knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.dropColumn('scheduled_close_at');
+ table.dropColumn('scheduled_close_id');
+ table.dropColumn('scheduled_close_name');
+ });
+};
diff --git a/db/migrations/20180421161550_add_alert_id_to_threads.js b/db/migrations/20180421161550_add_alert_id_to_threads.js
new file mode 100644
index 0000000..5defc38
--- /dev/null
+++ b/db/migrations/20180421161550_add_alert_id_to_threads.js
@@ -0,0 +1,11 @@
+exports.up = async function (knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.string('alert_id', 20).nullable().defaultTo(null).after('scheduled_close_name');
+ });
+};
+
+exports.down = async function(knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.dropColumn('alert_id');
+ });
+};
diff --git a/db/migrations/20180920224224_remove_is_anonymous_from_snippets.js b/db/migrations/20180920224224_remove_is_anonymous_from_snippets.js
new file mode 100644
index 0000000..ac33267
--- /dev/null
+++ b/db/migrations/20180920224224_remove_is_anonymous_from_snippets.js
@@ -0,0 +1,11 @@
+exports.up = async function (knex, Promise) {
+ await knex.schema.table('snippets', table => {
+ table.dropColumn('is_anonymous');
+ });
+};
+
+exports.down = async function(knex, Promise) {
+ await knex.schema.table('snippets', table => {
+ table.integer('is_anonymous').unsigned().notNullable();
+ });
+};
diff --git a/db/migrations/20190306204728_add_scheduled_close_silent_to_threads.js b/db/migrations/20190306204728_add_scheduled_close_silent_to_threads.js
new file mode 100644
index 0000000..e61490d
--- /dev/null
+++ b/db/migrations/20190306204728_add_scheduled_close_silent_to_threads.js
@@ -0,0 +1,11 @@
+exports.up = async function(knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.integer('scheduled_close_silent').nullable().after('scheduled_close_name');
+ });
+};
+
+exports.down = async function(knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.dropColumn('scheduled_close_silent');
+ });
+};
diff --git a/db/migrations/20190306211534_add_scheduled_suspend_to_threads.js b/db/migrations/20190306211534_add_scheduled_suspend_to_threads.js
new file mode 100644
index 0000000..a22b5f3
--- /dev/null
+++ b/db/migrations/20190306211534_add_scheduled_suspend_to_threads.js
@@ -0,0 +1,15 @@
+exports.up = async function(knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.dateTime('scheduled_suspend_at').index().nullable().defaultTo(null).after('channel_id');
+ table.string('scheduled_suspend_id', 20).nullable().defaultTo(null).after('channel_id');
+ table.string('scheduled_suspend_name', 128).nullable().defaultTo(null).after('channel_id');
+ });
+};
+
+exports.down = async function(knex, Promise) {
+ await knex.schema.table('threads', table => {
+ table.dropColumn('scheduled_suspend_at');
+ table.dropColumn('scheduled_suspend_id');
+ table.dropColumn('scheduled_suspend_name');
+ });
+};
diff --git a/db/migrations/20190609161116_create_updates_table.js b/db/migrations/20190609161116_create_updates_table.js
new file mode 100644
index 0000000..c90e9bd
--- /dev/null
+++ b/db/migrations/20190609161116_create_updates_table.js
@@ -0,0 +1,14 @@
+exports.up = async function(knex, Promise) {
+ if (! await knex.schema.hasTable('updates')) {
+ await knex.schema.createTable('updates', table => {
+ table.string('available_version', 16).nullable();
+ table.dateTime('last_checked').nullable();
+ });
+ }
+};
+
+exports.down = async function(knex, Promise) {
+ if (await knex.schema.hasTable('updates')) {
+ await knex.schema.dropTable('updates');
+ }
+};
diff --git a/db/migrations/20190609193213_add_expires_at_to_blocked_users.js b/db/migrations/20190609193213_add_expires_at_to_blocked_users.js
new file mode 100644
index 0000000..ea456a6
--- /dev/null
+++ b/db/migrations/20190609193213_add_expires_at_to_blocked_users.js
@@ -0,0 +1,11 @@
+exports.up = async function(knex, Promise) {
+ await knex.schema.table('blocked_users', table => {
+ table.dateTime('expires_at').nullable();
+ });
+};
+
+exports.down = async function(knex, Promise) {
+ await knex.schema.table('blocked_users', table => {
+ table.dropColumn('expires_at');
+ });
+};
diff --git a/docs/commands.md b/docs/commands.md
new file mode 100644
index 0000000..397cf16
--- /dev/null
+++ b/docs/commands.md
@@ -0,0 +1,31 @@
+# 🤖 Commands
+
+## Anywhere on the inbox server
+`!logs <user> <page>` Lists previous modmail logs with the specified user. If there are a lot of logs, they will be paginated. In this case, you can specify the page number to view as the second argument.
+`!block <user> <time>` Blocks the specified user from using modmail. If a time is specified, the block is temporary.
+`!unblock <user> <time>` Unblocks the specified user from using modmail. If a time is specified, the user will be scheduled to be unblocked after that time.
+`!is_blocked <user>` Checks whether the user is blocked and for how long
+`!s <shortcut> <text>` Adds a snippet (a canned response). Supports {1}, {2}, etc. for arguments. See below for how to use it.
+`!edit_snippet <shortcut> <text>` Edits an existing snippet (alias `!es`)
+`!delete_snippet <shortcut>` Deletes the specified snippet (alias `!ds`)
+`!snippets` Lists all available snippets
+`!version` Print the version of the bot you're running
+`!newthread <user>` Opens a new thread with the specified user
+
+## Inside a modmail thread
+`!reply <text>` Sends a reply to the user in the format "(Role) User: text" (alias `!r`)
+`!anonreply <text>` Sends an anonymous reply to the user in the format "Role: text" (alias `!ar`)
+`!close <time>` Closes the modmail thread. If a time is specified, the thread is scheduled to be closed later. Scheduled closing is cancelled if a message is sent to or received from the user.
+`!logs <page>` Lists previous modmail logs with this user. If there are a lot of logs, they will be paginated. In this case, you can specify the page number to view as an argument.
+`!block <time>` Blocks the user from using modmail. If a time is specified, the block is temporary.
+`!unblock <time>` Unblocks the user from using modmail. If a time is specified, the user will be scheduled to be unblocked after that time.
+`!!shortcut` Reply with a snippet. Replace `shortcut` with the snippet's actual shortcut.
+`!!!shortcut` Reply with a snippet anonymously. Replace `shortcut` with the snippet's actual shortcut.
+`!move <category>` If `allowMove` is enabled, moves the thread channel to the specified category
+`!loglink` Shows the link to the current thread's log
+`!suspend` Suspend a thread. The thread will act as closed and not receive any messages until unsuspended.
+`!unsuspend` Unsuspend a thread
+`!id` Prints the user's ID
+`!alert` Pings you when the thread gets a new reply. Use `!alert cancel` to cancel.
+
+To automatically reply without using !reply or !r, [enable `alwaysReply` in the config](configuration.md).
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..cb4f2bb
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,342 @@
+# 📝 Configuration
+Haven't set up the bot yet? Check out [Setting up the bot](setup.md) first!
+
+## Table of contents
+- [Configuration file](#configuration-file) (start here)
+- [Adding new options](#adding-new-options)
+- [Required options](#required-options)
+- [Other options](#other-options)
+- [config.ini vs config.json](#configini-vs-configjson)
+- [Other formats](#other-formats)
+- [Environment variables](#environment-variables)
+
+## Configuration file
+All bot options are saved in a configuration file in the bot's folder.
+This is created during the [setup](setup.md) and is generally either `config.ini` or, if you've been using the bot for
+longer, `config.json`.
+
+The instructions on this page are for `config.ini` but can be adapted to `config.json` as well.
+See [config.ini vs config.json](#configini-vs-configjson) for more details.
+Note that the format of `.ini` and `.json` are different -- you can't simply rename a `.json` to `.ini` or
+vice versa.
+
+## Adding new options
+To add a new option to your `config.ini`, open the file in a text editor such as notepad.
+Each option is put on a new line, and follows the format `option = value`. For example, `mainGuildId = 1234`.
+
+**You need to restart the bot for configuration changes to take effect!**
+
+You can add comments in the config file by prefixing the line with `#`. Example:
+```ini
+# This is a comment
+option = value
+```
+
+### Toggle options
+Some options like `allowMove` are "**Toggle options**": they control whether certain features are enabled (on) or not (off).
+* To enable a toggle option, set its value to `on`, `true`, or `1`
+* To disable a toggle option, set its value to `off`, `false`, or `0`
+* E.g. `allowMove = on` or `allowMove = off`
+
+### "Accepts multiple values"
+Some options are marked as "**Accepts multiple values**". To give these options multiple values,
+write the option as `option[] = value` and repeat for every value. For example:
+
+```ini
+inboxServerPermission[] = kickMembers
+inboxServerPermission[] = manageMessages
+```
+
+You can also give these options a single value in the usual way, i.e. `inboxServerPermission = kickMembers`
+
+### Multiple lines of text
+For some options, such as `greetingMessage`, you might want to add text that spans multiple lines.
+To do that, use the same format as with "Accepts multiple values" above:
+
+```ini
+greetingMessage[] = Welcome to the server!
+greetingMessage[] = This is the second line of the greeting.
+greetingMessage[] =
+greetingMessage[] = Fourth line! With an empty line in the middle.
+```
+
+## Required options
+
+#### token
+The bot user's token from [Discord Developer Portal](https://discordapp.com/developers/).
+
+#### mainGuildId
+Your server's ID, wrapped in quotes.
+
+#### mailGuildId
+For a two-server setup, the inbox server's ID.
+For a single-server setup, same as [mainGuildId](#mainguildid).
+
+#### logChannelId
+ID of a channel on the inbox server where logs are posted after a modmail thread is closed
+
+## Other options
+
+#### accountAgeDeniedMessage
+**Default:** `Your Discord account is not old enough to contact modmail.`
+See `requiredAccountAge` below
+
+#### allowMove
+**Default:** `off`
+If enabled, allows you to move threads between categories using `!move <category>`
+
+#### allowUserClose
+**Default:** `off`
+If enabled, users can use the close command to close threads by themselves from their DMs with the bot
+
+#### alwaysReply
+**Default:** `off`
+If enabled, all messages in modmail threads will be sent to the user without having to use `!r`.
+To send internal messages in the thread when this option is enabled, prefix them with `!note` (using your `prefix`),
+e.g. `!note This is an internal message`.
+
+#### alwaysReplyAnon
+**Default:** `off`
+If `alwaysReply` is enabled, this option controls whether the auto-reply is anonymous
+
+#### attachmentStorage
+**Default:** `local`
+Controls how attachments in modmail threads are stored. Possible values:
+* **local** - Files are saved locally on the machine running the bot
+* **discord** - Files are saved as attachments on a special channel on the inbox server. Requires `attachmentStorageChannelId` to be set.
+
+#### attachmentStorageChannelId
+**Default:** *None*
+When using attachmentStorage is set to "discord", the id of the channel on the inbox server where attachments are saved
+
+#### botMentionResponse
+**Default:** *None*
+If set, the bot auto-replies to bot mentions (pings) with this message. Use `{userMention}` in the text to ping the user back.
+
+#### categoryAutomation.newThread
+**Default:** *None*
+ID of the category where new threads are opened. Also functions as a fallback for `categoryAutomation.newThreadFromGuild`.
+
+#### categoryAutomation.newThreadFromGuild.GUILDID
+**Default:** *None*
+When running the bot on multiple main servers, this allows you to specify new thread categories for users from each guild. Example:
+```ini
+# When the user is from the server ID 94882524378968064, their modmail thread will be placed in the category ID 360863035130249235
+categoryAutomation.newThreadFromGuild.94882524378968064 = 360863035130249235
+# When the user is from the server ID 541484311354933258, their modmail thread will be placed in the category ID 542780020972716042
+categoryAutomation.newThreadFromGuild.541484311354933258 = 542780020972716042
+```
+
+#### closeMessage
+**Default:** *None*
+If set, the bot sends this message to the user when the modmail thread is closed.
+
+#### commandAliases
+**Default:** *None*
+Custom aliases/shortcuts for commands. Example:
+```ini
+# !mv is an alias/shortcut for !move
+commandAliases.mv = move
+# !x is an alias/shortcut for !close
+commandAliases.x = close
+```
+
+#### enableGreeting
+**Default:** `off`
+When enabled, the bot will send a greeting DM to users that join the main server.
+
+#### greetingAttachment
+**Default:** *None*
+Path to an image or other attachment to send as a greeting. Requires `enableGreeting` to be enabled.
+
+#### greetingMessage
+**Default:** *None*
+Message to send as a greeting. Requires `enableGreeting` to be enabled. Example:
+```ini
+greetingMessage[] = Welcome to the server!
+greetingMessage[] = Remember to read the rules.
+```
+
+#### guildGreetings
+**Default:** *None*
+When running the bot on multiple main servers, this allows you to set different greetings for each server. Example:
+```ini
+guildGreetings.94882524378968064.message = Welcome to server ID 94882524378968064!
+guildGreetings.94882524378968064.attachment = greeting.png
+
+guildGreetings.541484311354933258.message[] = Welcome to server ID 541484311354933258!
+guildGreetings.541484311354933258.message[] = Second line of the greeting.
+```
+
+#### ignoreAccidentalThreads
+**Default:** `off`
+If enabled, the bot attempts to ignore common "accidental" messages that would start a new thread, such as "ok", "thanks", etc.
+
+#### inboxServerPermission
+**Default:** *None*
+**Accepts multiple values.** Permission name, user id, or role id required to use bot commands on the inbox server.
+See ["Permissions" on this page](https://abal.moe/Eris/docs/reference) for supported permission names (e.g. `kickMembers`).
+
+#### timeOnServerDeniedMessage
+**Default:** `You haven't been a member of the server for long enough to contact modmail.`
+If `requiredTimeOnServer` is set, users that are too new will be sent this message if they try to message modmail.
+
+#### mentionRole
+**Default:** `here`
+**Accepts multiple values.** Role that is mentioned when new threads are created or the bot is mentioned.
+Accepted values are "here", "everyone", or a role id.
+Requires `pingOnBotMention` to be enabled.
+Set to an empty value (`mentionRole=`) to disable these pings entirely.
+
+#### mentionUserInThreadHeader
+**Default:** `off`
+If enabled, mentions the user messaging modmail in the modmail thread's header.
+
+#### newThreadCategoryId
+**Default:** *None*
+**Deprecated.** Same as `categoryAutomation.newThread`.
+
+#### pingOnBotMention
+**Default:** `on`
+If enabled, the bot will mention staff (see `mentionRole` option) on the inbox server when the bot is mentioned on the main server.
+
+#### plugins
+**Default:** *None*
+**Accepts multiple values.** External plugins to load on startup. See [Plugins](plugins.md) for more information.
+
+#### port
+**Default:** `8890`
+Port to use for attachments (when `attachmentStorage` is set to `local`) and logs.
+Make sure to do the necessary [port forwarding](https://portforward.com/) and add any needed firewall exceptions so the port is accessible from the internet.
+
+#### prefix
+**Default:** `!`
+Prefix for bot commands
+
+#### relaySmallAttachmentsAsAttachments
+**Default:** `off`
+If enabled, small attachments from users are sent as real attachments rather than links in modmail threads.
+The limit for "small" is 2MB by default; you can change this with the `smallAttachmentLimit` option.
+
+#### requiredAccountAge
+**Default:** *None*
+Required account age for contacting modmail (in hours). If the account is not old enough, a new thread will not be created and the bot will reply with `accountAgeDeniedMessage` (if set) instead.
+
+#### requiredTimeOnServer
+**Default:** *None*
+Required amount of time (in minutes) the user must be a member of the server before being able to contact modmail. If the user hasn't been a member of the server for the specified time, a new thread will not be created and the bot will reply with `timeOnServerDeniedMessage` (if set) instead.
+
+#### responseMessage
+**Default:** `Thank you for your message! Our mod team will reply to you here as soon as possible.`
+The bot's response to the user when they message the bot and open a new modmail thread
+
+#### rolesInThreadHeader
+**Default:** `off`
+If enabled, the user's roles will be shown in the modmail thread header
+
+#### smallAttachmentLimit
+**Default:** `2097152`
+Size limit of `relaySmallAttachmentsAsAttachments` in bytes (default is 2MB)
+
+#### snippetPrefix
+**Default:** `!!`
+Prefix for snippets
+
+#### snippetPrefixAnon
+**Default:** `!!!`
+Prefix to use snippets anonymously
+
+#### status
+**Default:** `Message me for help`
+The bot's "Playing" text
+
+#### syncPermissionsOnMove
+**Default:** `on`
+If enabled, channel permissions for the thread are synchronized with the category when using `!move`. Requires `allowMove` to be enabled.
+
+#### threadTimestamps
+**Default:** `off`
+If enabled, modmail threads will show accurate UTC timestamps for each message, in addition to Discord's own timestamps.
+Logs show these always, regardless of this setting.
+
+#### typingProxy
+**Default:** `off`
+If enabled, any time a user is typing to modmail in their DMs, the modmail thread will show the bot as "typing"
+
+#### typingProxyReverse
+**Default:** `off`
+If enabled, any time a moderator is typing in a modmail thread, the user will see the bot "typing" in their DMs
+
+#### updateNotifications
+**Default:** `on`
+If enabled, the bot will automatically check for new bot updates periodically and notify about them at the top of new modmail threads
+
+#### url
+**Default:** *None*
+URL to use for attachment and log links. Defaults to `http://IP:PORT`.
+
+#### useNicknames
+**Default:** `off`
+If enabled, mod replies will use their nicknames (on the inbox server) instead of their usernames
+
+## config.ini vs config.json
+Earlier versions of the bot instructed you to create a `config.json` instead of a `config.ini`.
+**This is still fully supported, and will be in the future as well.**
+However, there are some differences between `config.ini` and `config.json`.
+
+### Formatting
+*See [the example on the Wikipedia page for JSON](https://en.wikipedia.org/wiki/JSON#Example)
+for a general overview of the JSON format.*
+
+* In `config.json`, all text values and IDs need to be wrapped in quotes, e.g. `"mainGuildId": "94882524378968064"`
+* In `config.json`, all numbers (other than IDs) are written without quotes, e.g. `"port": 3000`
+
+### Toggle options
+In `config.json`, valid values for toggle options are `true` and `false` (not quoted),
+which correspond to `on` and `off` in `config.ini`.
+
+### "Accepts multiple values"
+Multiple values are specified in `config.json` using arrays:
+```json
+{
+ "inboxPermission": [
+ "kickMembers",
+ "manageMessages"
+ ]
+}
+```
+
+### Multiple lines of text
+Since `config.json` is parsed using [JSON5](https://json5.org/), multiple lines of text are supported
+by escaping the newline with a backslash (`\ `):
+```json5
+{
+ "greetingMessage": "Welcome to the server!\
+This is the second line of the greeting."
+}
+```
+
+## Other formats
+Loading config values programmatically is also supported.
+Create a `config.js` in the bot's folder and export the config object with `module.exports`.
+All other configuration files take precedence, so make sure you don't have both.
+
+## Environment variables
+Config options can be passed via environment variables.
+
+To get the name of the corresponding environment variable for an option, convert the option to SNAKE_CASE with periods
+being replaced by two underscores and add `MM_` as a prefix. If adding multiple values for the same option, separate the
+values with two pipe characters: `||`.
+
+Examples:
+* `mainGuildId` -> `MM_MAIN_GUILD_ID`
+* `commandAliases.mv` -> `MM_COMMAND_ALIASES__MV`
+* From:
+ ```ini
+ inboxServerPermission[] = kickMembers
+ inboxServerPermission[] = manageMessages
+ ```
+ To:
+ `MM_INBOX_SERVER_PERMISSION=kickMembers||manageMessages`
+
+The `port` option also accepts the environment variable `PORT` without a prefix, but `MM_PORT` takes precedence.
diff --git a/docs/faq.md b/docs/faq.md
new file mode 100644
index 0000000..29b0370
--- /dev/null
+++ b/docs/faq.md
@@ -0,0 +1,21 @@
+# 🙋 Frequently Asked Questions
+
+## In a [single-server setup](setup.md#single-server-setup), how do I hide modmails from regular users?
+1. Create a private category for modmail threads that only your server staff and the bot can see and set the option
+`categoryAutomation.newThread = 1234` (replace `1234` with the ID of the category)
+2. Set the `inboxServerPermission` option to limit who can use bot commands.
+ [Click here for more information.](configuration.md#inboxserverpermission)
+
+## My logs and/or attachments aren't loading!
+Since logs and attachments are both stored and sent directly from the machine running the bot, you'll need to make sure
+that the machine doesn't have a firewall blocking the bot and has the appropriate port forwardings set up.
+[You can find more information and instructions for port forwarding here.](https://portforward.com/)
+By default, the bot uses the port **8890**.
+
+## I don't want attachments saved on my computer
+As an alternative to storing modmail attachments on the machine running the bot, they can be stored in a special Discord
+channel instead. Create a new text channel and then set the options `attachmentStorage = discord` and
+`attachmentStorageChannelId = 1234` (replace `1234` with the ID of the new channel).
+
+## I want to categorize my modmail threads in multiple categories
+Set `allowMove = on` to allow your staff to move threads to other categories with `!move`
diff --git a/docs/plugins.md b/docs/plugins.md
new file mode 100644
index 0000000..2a08558
--- /dev/null
+++ b/docs/plugins.md
@@ -0,0 +1,47 @@
+# 🧩 Plugins
+The bot supports loading external plugins.
+
+## Specifying plugins to load
+For each plugin file you'd like to load, add the file path to the [`plugins` option](configuration.md#plugins).
+The path is relative to the bot's folder.
+Plugins are automatically loaded on bot startup.
+
+## Creating a plugin
+Create a `.js` file that exports a function.
+This function will be called when the plugin is loaded, with 1 argument: an object that has the following properties:
+* `bot` - the [Eris Client object](https://abal.moe/Eris/docs/Client)
+* `knex` - the [Knex database object](https://knexjs.org/#Builder)
+* `config` - the loaded config
+* `commands` - an object with functions to add and manage commands
+* `attachments` - an object with functions to save attachments and manage attachment storage types
+
+See [src/plugins.js#L4](../src/plugins.js#L4) for more details
+
+### Example plugin file
+This example adds a command `!mycommand` that replies with `"Reply from my custom plugin!"` when the command is used inside a modmail inbox thread channel.
+```js
+module.exports = function({ bot, knex, config, commands }) {
+ commands.addInboxThreadCommand('mycommand', [], (msg, args, thread) => {
+ thread.replyToUser(msg.author, 'Reply from my custom plugin!');
+ });
+}
+```
+
+(Note the use of [object destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Unpacking_fields_from_objects_passed_as_function_parameter) in the function parameters)
+
+### Example of a custom attachment storage type
+This example adds a custom type for the `attachmentStorage` option called `"original"` that simply returns the original attachment URL without rehosting it in any way.
+```js
+module.exports = function({ attachments }) {
+ attachments.addStorageType('original', attachment => {
+ return { url: attachment.url };
+ });
+};
+```
+To use this custom attachment storage type, you would set the `attachmentStorage` config option to `"original"`.
+
+## Work in progress
+The current plugin API is fairly rudimentary and will be expanded on in the future.
+The API can change in non-major releases during this early stage. Keep an eye on [CHANGELOG.md](../CHANGELOG.md) for any changes.
+
+Please send any feature suggestions to the [issue tracker](https://github.com/Dragory/modmailbot/issues)!
diff --git a/docs/setup.md b/docs/setup.md
new file mode 100644
index 0000000..ab724f0
--- /dev/null
+++ b/docs/setup.md
@@ -0,0 +1,50 @@
+# 🛠️ Setting up the bot
+**Note:** This bot is run on your own machine or a server.
+To keep it online, you need to keep the bot process running.
+
+## Terminology
+* **Main server** (or main guild) is the server where users will be contacting modmail from
+* **Inbox server** (or inbox guild, or mail guild) is the server where modmail threads will be created.
+ In a "single-server setup" this is the same server as the main server.
+* A **modmail thread** is a channel on the **inbox server** that contains the current exchange with the **user**.
+ These threads can be closed to archive them. One **user** can only have 1 modmail thread open at once.
+* A **moderator**, in modmail's context, is a server staff member who responds to and manages modmail threads
+* A **user**, in modmail's context, is a Discord user who is contacting modmail by DMing the bot
+
+## Prerequisites
+1. Create a bot account through the [Discord Developer Portal](https://discordapp.com/developers/)
+2. Invite the created bot to your server
+3. Install Node.js 10, 11, or 12
+ - Node.js 13 is currently not supported
+4. Download the latest bot version from [the releases page](https://github.com/Dragory/modmailbot/releases) and extract it to a folder
+5. In the bot's folder, make a copy of the file `config.example.ini` and rename the copy to `config.ini`
+
+## Single-server setup
+In this setup, modmail threads are opened on the main server in a special category.
+This is the recommended setup for small and medium sized servers.
+
+1. Open `config.ini` in a text editor and fill in the required values. `mainGuildId` and `mailGuildId` should both be set to your server's id.
+2. On a new line at the end of `config.ini`, add `categoryAutomation.newThread = CATEGORY_ID_HERE`
+ - Replace `CATEGORY_ID_HERE` with the ID of the category where new modmail threads should go
+3. Make sure the bot has `Manage Channels`, `Manage Messages`, and `Attach Files` permissions in the category
+4. **[🏃 Start the bot!](starting-the-bot.md)**
+5. Want to change other bot options? See **[📝 Configuration](configuration.md)**
+6. Have any other questions? Check out the **[🙋 Frequently Asked Questions](faq.md)** or
+ **[join the support server!](../README.md#support-server)**
+
+## Two-server setup
+In this setup, modmail threads are opened on a separate inbox server.
+This is the recommended setup for large servers that get a lot of modmails, where a single-server setup could get messy.
+You might also want this setup for privacy concerns*.
+
+1. Create an inbox server on Discord
+2. Invite the bot to the inbox server.
+3. Open `config.ini` in a text editor and fill in the values
+4. Make sure the bot has the `Manage Channels`, `Manage Messages`, and `Attach Files` permissions on the **inbox** server
+5. **[🏃 Start the bot!](starting-the-bot.md)**
+5. Want to change other bot options? See **[📝 Configuration](configuration.md)**
+6. Have any other questions? Check out the **[🙋 Frequently Asked Questions](faq.md)** or
+ **[join the support server!](../README.md#support-server)**
+
+*\* Since all channel names, even for channels you can't see, are public information through the API, a user with a
+modified client could see the names of all users contacting modmail through the modmail channel names.*
diff --git a/docs/starting-the-bot.md b/docs/starting-the-bot.md
new file mode 100644
index 0000000..624c8f2
--- /dev/null
+++ b/docs/starting-the-bot.md
@@ -0,0 +1,16 @@
+# 🏃 Starting the bot
+Haven't set up the bot yet? Check out [Setting up the bot](setup.md) first!
+
+## Windows
+* To start the bot, double-click on `start.bat` in the bot's folder
+* To shut down the bot, close the console window
+* To restart the bot, close the console window and then double-click on `start.bat` again
+
+## Linux / macOS / Advanced on Windows
+The following assumes basic knowledge about using command line tools.
+1. Before first start-up and after every update, run `npm ci` in the bot's folder
+2. Run `npm start` in the bot's folder to start the bot
+
+## Process managers
+If you're using a process manager like PM2, the command to run is `npm start`.
+A PM2 process file, `modmailbot-pm2.json`, is included in the repository.
diff --git a/knexfile.js b/knexfile.js
new file mode 100644
index 0000000..cf568f4
--- /dev/null
+++ b/knexfile.js
@@ -0,0 +1,2 @@
+const config = require('./src/config');
+module.exports = config.knex;
diff --git a/logs/.gitignore b/logs/.gitignore
new file mode 100644
index 0000000..120f485
--- /dev/null
+++ b/logs/.gitignore
@@ -0,0 +1,2 @@
+*
+!/.gitignore
diff --git a/modmailbot-pm2.json b/modmailbot-pm2.json
new file mode 100644
index 0000000..6322645
--- /dev/null
+++ b/modmailbot-pm2.json
@@ -0,0 +1,8 @@
+{
+ "apps": [{
+ "name": "ModMailBot",
+ "cwd": "./",
+ "script": "npm",
+ "args": "start"
+ }]
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..baca952
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,4180 @@
+{
+ "name": "modmailbot",
+ "version": "2.29.1",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "@babel/code-frame": {
+ "version": "7.5.5",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz",
+ "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==",
+ "dev": true,
+ "requires": {
+ "@babel/highlight": "^7.0.0"
+ }
+ },
+ "@babel/highlight": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz",
+ "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==",
+ "dev": true,
+ "requires": {
+ "chalk": "^2.0.0",
+ "esutils": "^2.0.2",
+ "js-tokens": "^4.0.0"
+ }
+ },
+ "@sindresorhus/is": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
+ "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ=="
+ },
+ "@szmarczak/http-timer": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
+ "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==",
+ "requires": {
+ "defer-to-connect": "^1.0.1"
+ }
+ },
+ "abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
+ },
+ "acorn": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz",
+ "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==",
+ "dev": true
+ },
+ "acorn-jsx": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.1.0.tgz",
+ "integrity": "sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==",
+ "dev": true
+ },
+ "ajv": {
+ "version": "6.10.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
+ "integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==",
+ "requires": {
+ "fast-deep-equal": "^2.0.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ansi-align": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz",
+ "integrity": "sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=",
+ "dev": true,
+ "requires": {
+ "string-width": "^2.0.0"
+ }
+ },
+ "ansi-escapes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.0.tgz",
+ "integrity": "sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==",
+ "dev": true,
+ "requires": {
+ "type-fest": "^0.8.1"
+ }
+ },
+ "ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+ },
+ "ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "requires": {
+ "color-convert": "^1.9.0"
+ }
+ },
+ "anymatch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
+ "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
+ "dev": true,
+ "requires": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ }
+ },
+ "aproba": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+ },
+ "are-we-there-yet": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
+ "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
+ "requires": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^2.0.6"
+ }
+ },
+ "argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "requires": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "arr-diff": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz",
+ "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA="
+ },
+ "arr-flatten": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
+ "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg=="
+ },
+ "arr-union": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
+ "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ="
+ },
+ "array-each": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
+ "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8="
+ },
+ "array-slice": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz",
+ "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w=="
+ },
+ "array-unique": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
+ "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg="
+ },
+ "asn1": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+ "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+ "requires": {
+ "safer-buffer": "~2.1.0"
+ }
+ },
+ "assert-plus": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+ "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
+ },
+ "assign-symbols": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
+ "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c="
+ },
+ "astral-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz",
+ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==",
+ "dev": true
+ },
+ "async-limiter": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
+ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
+ },
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
+ },
+ "atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
+ },
+ "aws-sign2": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+ "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg="
+ },
+ "aws4": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.0.tgz",
+ "integrity": "sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A=="
+ },
+ "balanced-match": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+ },
+ "base": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz",
+ "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==",
+ "requires": {
+ "cache-base": "^1.0.1",
+ "class-utils": "^0.3.5",
+ "component-emitter": "^1.2.1",
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.1",
+ "mixin-deep": "^1.2.0",
+ "pascalcase": "^0.1.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=",
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "requires": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ }
+ }
+ }
+ },
+ "bcrypt-pbkdf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+ "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
+ "requires": {
+ "tweetnacl": "^0.14.3"
+ },
+ "dependencies": {
+ "tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
+ }
+ }
+ },
+ "binary-extensions": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
+ "integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==",
+ "dev": true
+ },
+ "bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
+ },
+ "boxen": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz",
+ "integrity": "sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==",
+ "dev": true,
+ "requires": {
+ "ansi-align": "^2.0.0",
+ "camelcase": "^4.0.0",
+ "chalk": "^2.0.1",
+ "cli-boxes": "^1.0.0",
+ "string-width": "^2.0.0",
+ "term-size": "^1.2.0",
+ "widest-line": "^2.0.0"
+ },
+ "dependencies": {
+ "camelcase": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+ "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+ "dev": true
+ }
+ }
+ },
+ "brace-expansion": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz",
+ "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=",
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "braces": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
+ "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
+ "requires": {
+ "arr-flatten": "^1.1.0",
+ "array-unique": "^0.3.2",
+ "extend-shallow": "^2.0.1",
+ "fill-range": "^4.0.0",
+ "isobject": "^3.0.1",
+ "repeat-element": "^1.1.2",
+ "snapdragon": "^0.8.1",
+ "snapdragon-node": "^2.0.1",
+ "split-string": "^3.0.2",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cache-base": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz",
+ "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==",
+ "requires": {
+ "collection-visit": "^1.0.0",
+ "component-emitter": "^1.2.1",
+ "get-value": "^2.0.6",
+ "has-value": "^1.0.0",
+ "isobject": "^3.0.1",
+ "set-value": "^2.0.0",
+ "to-object-path": "^0.3.0",
+ "union-value": "^1.0.0",
+ "unset-value": "^1.0.0"
+ }
+ },
+ "cacheable-request": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
+ "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==",
+ "requires": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^3.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^4.1.0",
+ "responselike": "^1.0.2"
+ },
+ "dependencies": {
+ "get-stream": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz",
+ "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==",
+ "requires": {
+ "pump": "^3.0.0"
+ }
+ },
+ "lowercase-keys": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="
+ }
+ }
+ },
+ "callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true
+ },
+ "camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
+ },
+ "capture-stack-trace": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz",
+ "integrity": "sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==",
+ "dev": true
+ },
+ "caseless": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+ "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
+ },
+ "chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ }
+ },
+ "chardet": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
+ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
+ "dev": true
+ },
+ "chokidar": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz",
+ "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==",
+ "dev": true,
+ "requires": {
+ "anymatch": "~3.1.1",
+ "braces": "~3.0.2",
+ "fsevents": "~2.1.1",
+ "glob-parent": "~5.1.0",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.2.0"
+ },
+ "dependencies": {
+ "braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.0.1"
+ }
+ },
+ "fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ }
+ }
+ },
+ "chownr": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz",
+ "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw=="
+ },
+ "ci-info": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz",
+ "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==",
+ "dev": true
+ },
+ "class-utils": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
+ "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==",
+ "requires": {
+ "arr-union": "^3.1.0",
+ "define-property": "^0.2.5",
+ "isobject": "^3.0.0",
+ "static-extend": "^0.1.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ }
+ }
+ },
+ "cli-boxes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz",
+ "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM=",
+ "dev": true
+ },
+ "cli-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
+ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
+ "dev": true,
+ "requires": {
+ "restore-cursor": "^3.1.0"
+ }
+ },
+ "cli-width": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz",
+ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=",
+ "dev": true
+ },
+ "cliui": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz",
+ "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==",
+ "requires": {
+ "string-width": "^3.1.0",
+ "strip-ansi": "^5.2.0",
+ "wrap-ansi": "^5.1.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
+ },
+ "emoji-regex": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
+ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="
+ },
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+ },
+ "string-width": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
+ "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+ "requires": {
+ "emoji-regex": "^7.0.1",
+ "is-fullwidth-code-point": "^2.0.0",
+ "strip-ansi": "^5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ }
+ }
+ }
+ },
+ "clone-response": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
+ "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=",
+ "requires": {
+ "mimic-response": "^1.0.0"
+ }
+ },
+ "code-point-at": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
+ },
+ "collection-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
+ "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=",
+ "requires": {
+ "map-visit": "^1.0.0",
+ "object-visit": "^1.0.0"
+ }
+ },
+ "color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "requires": {
+ "color-name": "1.1.3"
+ }
+ },
+ "color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+ },
+ "colorette": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.1.0.tgz",
+ "integrity": "sha512-6S062WDQUXi6hOfkO/sBPVwE5ASXY4G2+b4atvhJfSsuUUhIaUKlkjLe9692Ipyt5/a+IPF5aVTu3V5gvXq5cg=="
+ },
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
+ "commander": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.0.1.tgz",
+ "integrity": "sha512-IPF4ouhCP+qdlcmCedhxX4xiGBPyigb8v5NeUp+0LyhwLgxMqyp3S0vl7TAPfS/hiP7FC3caI/PB9lTmP8r1NA=="
+ },
+ "component-emitter": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
+ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+ },
+ "configstore": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz",
+ "integrity": "sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==",
+ "dev": true,
+ "requires": {
+ "dot-prop": "^4.1.0",
+ "graceful-fs": "^4.1.2",
+ "make-dir": "^1.0.0",
+ "unique-string": "^1.0.0",
+ "write-file-atomic": "^2.0.0",
+ "xdg-basedir": "^3.0.0"
+ }
+ },
+ "console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
+ },
+ "copy-descriptor": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz",
+ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+ },
+ "create-error-class": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz",
+ "integrity": "sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=",
+ "dev": true,
+ "requires": {
+ "capture-stack-trace": "^1.0.0"
+ }
+ },
+ "cross-spawn": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
+ "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^4.0.1",
+ "shebang-command": "^1.2.0",
+ "which": "^1.2.9"
+ }
+ },
+ "crypto-random-string": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
+ "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=",
+ "dev": true
+ },
+ "dashdash": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+ "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+ "requires": {
+ "assert-plus": "^1.0.0"
+ }
+ },
+ "debug": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+ "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
+ },
+ "decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
+ },
+ "decompress-response": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
+ "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=",
+ "requires": {
+ "mimic-response": "^1.0.0"
+ }
+ },
+ "deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="
+ },
+ "deep-is": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
+ "dev": true
+ },
+ "defer-to-connect": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.0.tgz",
+ "integrity": "sha512-WE2sZoctWm/v4smfCAdjYbrfS55JiMRdlY9ZubFhsYbteCK9+BvAx4YV7nPjYM6ZnX5BcoVKwfmyx9sIFTgQMQ=="
+ },
+ "define-property": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz",
+ "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==",
+ "requires": {
+ "is-descriptor": "^1.0.2",
+ "isobject": "^3.0.1"
+ },
+ "dependencies": {
+ "is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "requires": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ }
+ }
+ }
+ },
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
+ },
+ "delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+ },
+ "detect-file": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
+ "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc="
+ },
+ "detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
+ },
+ "dns-packet": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.2.1.tgz",
+ "integrity": "sha512-JHj2yJeKOqlxzeuYpN1d56GfhzivAxavNwHj9co3qptECel27B1rLY5PifJAvubsInX5pGLDjAHuCfCUc2Zv/w==",
+ "requires": {
+ "ip": "^1.1.5"
+ }
+ },
+ "dns-socket": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/dns-socket/-/dns-socket-4.2.0.tgz",
+ "integrity": "sha512-4XuD3z28jht3jvHbiom6fAipgG5LkjYeDLrX5OH8cbl0AtzTyUUAxGckcW8T7z0pLfBBV5qOiuC4wUEohk6FrQ==",
+ "requires": {
+ "dns-packet": "^5.1.2"
+ }
+ },
+ "doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "dot-prop": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-4.2.0.tgz",
+ "integrity": "sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==",
+ "dev": true,
+ "requires": {
+ "is-obj": "^1.0.0"
+ }
+ },
+ "duplexer3": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
+ "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI="
+ },
+ "ecc-jsbn": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+ "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+ "requires": {
+ "jsbn": "~0.1.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
+ "emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "requires": {
+ "once": "^1.4.0"
+ }
+ },
+ "eris": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/eris/-/eris-0.11.1.tgz",
+ "integrity": "sha512-Ct32iXjESOnmklxZCEA281BQsTlAsS9xzQkbGlnvzXshCjBptWJ5h8Oxbu67ui1DirsYs0WipB8EBC9ITQ5ZQA==",
+ "requires": {
+ "opusscript": "^0.0.4",
+ "tweetnacl": "^1.0.0",
+ "ws": "^7.1.2"
+ }
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+ "dev": true
+ },
+ "eslint": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.7.2.tgz",
+ "integrity": "sha512-qMlSWJaCSxDFr8fBPvJM9kJwbazrhNcBU3+DszDW1OlEwKBBRWsJc7NJFelvwQpanHCR14cOLD41x8Eqvo3Nng==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.0.0",
+ "ajv": "^6.10.0",
+ "chalk": "^2.1.0",
+ "cross-spawn": "^6.0.5",
+ "debug": "^4.0.1",
+ "doctrine": "^3.0.0",
+ "eslint-scope": "^5.0.0",
+ "eslint-utils": "^1.4.3",
+ "eslint-visitor-keys": "^1.1.0",
+ "espree": "^6.1.2",
+ "esquery": "^1.0.1",
+ "esutils": "^2.0.2",
+ "file-entry-cache": "^5.0.1",
+ "functional-red-black-tree": "^1.0.1",
+ "glob-parent": "^5.0.0",
+ "globals": "^12.1.0",
+ "ignore": "^4.0.6",
+ "import-fresh": "^3.0.0",
+ "imurmurhash": "^0.1.4",
+ "inquirer": "^7.0.0",
+ "is-glob": "^4.0.0",
+ "js-yaml": "^3.13.1",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.3.0",
+ "lodash": "^4.17.14",
+ "minimatch": "^3.0.4",
+ "mkdirp": "^0.5.1",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.8.3",
+ "progress": "^2.0.0",
+ "regexpp": "^2.0.1",
+ "semver": "^6.1.2",
+ "strip-ansi": "^5.2.0",
+ "strip-json-comments": "^3.0.1",
+ "table": "^5.2.3",
+ "text-table": "^0.2.0",
+ "v8-compile-cache": "^2.0.3"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+ "dev": true
+ },
+ "cross-spawn": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+ "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+ "dev": true,
+ "requires": {
+ "nice-try": "^1.0.4",
+ "path-key": "^2.0.1",
+ "semver": "^5.5.0",
+ "shebang-command": "^1.2.0",
+ "which": "^1.2.9"
+ },
+ "dependencies": {
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "semver": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "dev": true
+ },
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ }
+ },
+ "strip-json-comments": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz",
+ "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-scope": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz",
+ "integrity": "sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.1.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "eslint-utils": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz",
+ "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==",
+ "dev": true,
+ "requires": {
+ "eslint-visitor-keys": "^1.1.0"
+ }
+ },
+ "eslint-visitor-keys": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz",
+ "integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==",
+ "dev": true
+ },
+ "espree": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-6.1.2.tgz",
+ "integrity": "sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA==",
+ "dev": true,
+ "requires": {
+ "acorn": "^7.1.0",
+ "acorn-jsx": "^5.1.0",
+ "eslint-visitor-keys": "^1.1.0"
+ }
+ },
+ "esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true
+ },
+ "esquery": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz",
+ "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^4.0.0"
+ }
+ },
+ "esrecurse": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz",
+ "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^4.1.0"
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true
+ },
+ "execa": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz",
+ "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=",
+ "dev": true,
+ "requires": {
+ "cross-spawn": "^5.0.1",
+ "get-stream": "^3.0.0",
+ "is-stream": "^1.1.0",
+ "npm-run-path": "^2.0.0",
+ "p-finally": "^1.0.0",
+ "signal-exit": "^3.0.0",
+ "strip-eof": "^1.0.0"
+ }
+ },
+ "expand-brackets": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
+ "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=",
+ "requires": {
+ "debug": "^2.3.3",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "posix-character-classes": "^0.1.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ }
+ }
+ },
+ "expand-tilde": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
+ "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=",
+ "requires": {
+ "homedir-polyfill": "^1.0.1"
+ }
+ },
+ "extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
+ },
+ "extend-shallow": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
+ "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=",
+ "requires": {
+ "assign-symbols": "^1.0.0",
+ "is-extendable": "^1.0.1"
+ },
+ "dependencies": {
+ "is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "requires": {
+ "is-plain-object": "^2.0.4"
+ }
+ }
+ }
+ },
+ "external-editor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
+ "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
+ "dev": true,
+ "requires": {
+ "chardet": "^0.7.0",
+ "iconv-lite": "^0.4.24",
+ "tmp": "^0.0.33"
+ },
+ "dependencies": {
+ "tmp": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
+ "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
+ "dev": true,
+ "requires": {
+ "os-tmpdir": "~1.0.2"
+ }
+ }
+ }
+ },
+ "extglob": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
+ "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==",
+ "requires": {
+ "array-unique": "^0.3.2",
+ "define-property": "^1.0.0",
+ "expand-brackets": "^2.1.4",
+ "extend-shallow": "^2.0.1",
+ "fragment-cache": "^0.2.1",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=",
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "requires": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ }
+ }
+ }
+ },
+ "extsprintf": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
+ },
+ "fast-deep-equal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
+ "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz",
+ "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I="
+ },
+ "fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+ "dev": true
+ },
+ "figures": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-3.1.0.tgz",
+ "integrity": "sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==",
+ "dev": true,
+ "requires": {
+ "escape-string-regexp": "^1.0.5"
+ }
+ },
+ "file-entry-cache": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz",
+ "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==",
+ "dev": true,
+ "requires": {
+ "flat-cache": "^2.0.1"
+ }
+ },
+ "fill-range": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
+ "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=",
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1",
+ "to-regex-range": "^2.1.0"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "findup-sync": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-3.0.0.tgz",
+ "integrity": "sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==",
+ "requires": {
+ "detect-file": "^1.0.0",
+ "is-glob": "^4.0.0",
+ "micromatch": "^3.0.4",
+ "resolve-dir": "^1.0.1"
+ }
+ },
+ "fined": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz",
+ "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==",
+ "requires": {
+ "expand-tilde": "^2.0.2",
+ "is-plain-object": "^2.0.3",
+ "object.defaults": "^1.1.0",
+ "object.pick": "^1.2.0",
+ "parse-filepath": "^1.0.1"
+ }
+ },
+ "flagged-respawn": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
+ "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q=="
+ },
+ "flat-cache": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz",
+ "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==",
+ "dev": true,
+ "requires": {
+ "flatted": "^2.0.0",
+ "rimraf": "2.6.3",
+ "write": "1.0.3"
+ }
+ },
+ "flatted": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.1.tgz",
+ "integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==",
+ "dev": true
+ },
+ "for-in": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
+ "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA="
+ },
+ "for-own": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
+ "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=",
+ "requires": {
+ "for-in": "^1.0.1"
+ }
+ },
+ "forever-agent": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
+ },
+ "form-data": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+ "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.6",
+ "mime-types": "^2.1.12"
+ }
+ },
+ "fragment-cache": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
+ "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=",
+ "requires": {
+ "map-cache": "^0.2.2"
+ }
+ },
+ "fs-minipass": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
+ "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
+ "requires": {
+ "minipass": "^2.6.0"
+ }
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+ },
+ "fsevents": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz",
+ "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==",
+ "dev": true,
+ "optional": true
+ },
+ "functional-red-black-tree": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+ "dev": true
+ },
+ "gauge": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+ "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+ "requires": {
+ "aproba": "^1.0.3",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.0",
+ "object-assign": "^4.1.0",
+ "signal-exit": "^3.0.0",
+ "string-width": "^1.0.1",
+ "strip-ansi": "^3.0.1",
+ "wide-align": "^1.1.0"
+ },
+ "dependencies": {
+ "string-width": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+ "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+ "requires": {
+ "code-point-at": "^1.0.0",
+ "is-fullwidth-code-point": "^1.0.0",
+ "strip-ansi": "^3.0.0"
+ }
+ }
+ }
+ },
+ "get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
+ },
+ "get-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
+ "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=",
+ "dev": true
+ },
+ "get-value": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
+ "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg="
+ },
+ "getopts": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.2.5.tgz",
+ "integrity": "sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA=="
+ },
+ "getpass": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+ "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+ "requires": {
+ "assert-plus": "^1.0.0"
+ }
+ },
+ "glob-parent": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz",
+ "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "global-dirs": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
+ "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=",
+ "dev": true,
+ "requires": {
+ "ini": "^1.3.4"
+ }
+ },
+ "global-modules": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
+ "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
+ "requires": {
+ "global-prefix": "^1.0.1",
+ "is-windows": "^1.0.1",
+ "resolve-dir": "^1.0.0"
+ }
+ },
+ "global-prefix": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
+ "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=",
+ "requires": {
+ "expand-tilde": "^2.0.2",
+ "homedir-polyfill": "^1.0.1",
+ "ini": "^1.3.4",
+ "is-windows": "^1.0.1",
+ "which": "^1.2.14"
+ }
+ },
+ "globals": {
+ "version": "12.3.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-12.3.0.tgz",
+ "integrity": "sha512-wAfjdLgFsPZsklLJvOBUBmzYE8/CwhEqSBEMRXA3qxIiNtyqvjYurAtIfDh6chlEPUfmTY3MnZh5Hfh4q0UlIw==",
+ "dev": true,
+ "requires": {
+ "type-fest": "^0.8.1"
+ }
+ },
+ "got": {
+ "version": "9.6.0",
+ "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
+ "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
+ "requires": {
+ "@sindresorhus/is": "^0.14.0",
+ "@szmarczak/http-timer": "^1.1.2",
+ "cacheable-request": "^6.0.0",
+ "decompress-response": "^3.3.0",
+ "duplexer3": "^0.1.4",
+ "get-stream": "^4.1.0",
+ "lowercase-keys": "^1.0.1",
+ "mimic-response": "^1.0.1",
+ "p-cancelable": "^1.0.0",
+ "to-readable-stream": "^1.0.0",
+ "url-parse-lax": "^3.0.0"
+ },
+ "dependencies": {
+ "get-stream": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+ "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+ "requires": {
+ "pump": "^3.0.0"
+ }
+ }
+ }
+ },
+ "graceful-fs": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
+ "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==",
+ "dev": true
+ },
+ "har-schema": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+ "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI="
+ },
+ "har-validator": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
+ "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
+ "requires": {
+ "ajv": "^6.5.5",
+ "har-schema": "^2.0.0"
+ }
+ },
+ "has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+ "dev": true
+ },
+ "has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+ },
+ "has-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz",
+ "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=",
+ "requires": {
+ "get-value": "^2.0.6",
+ "has-values": "^1.0.0",
+ "isobject": "^3.0.0"
+ }
+ },
+ "has-values": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz",
+ "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=",
+ "requires": {
+ "is-number": "^3.0.0",
+ "kind-of": "^4.0.0"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
+ "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "homedir-polyfill": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
+ "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
+ "requires": {
+ "parse-passwd": "^1.0.0"
+ }
+ },
+ "http-cache-semantics": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.0.3.tgz",
+ "integrity": "sha512-TcIMG3qeVLgDr1TEd2XvHaTnMPwYQUQMIBLy+5pLSDKYFc7UIqj39w8EGzZkaxoLv/l2K8HaI0t5AVA+YYgUew=="
+ },
+ "http-signature": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+ "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
+ "requires": {
+ "assert-plus": "^1.0.0",
+ "jsprim": "^1.2.2",
+ "sshpk": "^1.7.0"
+ }
+ },
+ "humanize-duration": {
+ "version": "3.12.1",
+ "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.12.1.tgz",
+ "integrity": "sha512-Eu68Xnq5C38391em1zfVy8tiapQrOvTNTlWpax9smHMlEEUcudXrdMfXMoMRyZx4uODowYgi1AYiMzUXEbG+sA=="
+ },
+ "iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ }
+ },
+ "ignore": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "dev": true
+ },
+ "ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=",
+ "dev": true
+ },
+ "ignore-walk": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz",
+ "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==",
+ "requires": {
+ "minimatch": "^3.0.4"
+ }
+ },
+ "import-fresh": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz",
+ "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==",
+ "dev": true,
+ "requires": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ }
+ },
+ "import-lazy": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz",
+ "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=",
+ "dev": true
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+ "dev": true
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+ },
+ "ini": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
+ "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
+ },
+ "inquirer": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.0.0.tgz",
+ "integrity": "sha512-rSdC7zelHdRQFkWnhsMu2+2SO41mpv2oF2zy4tMhmiLWkcKbOAs87fWAJhVXttKVwhdZvymvnuM95EyEXg2/tQ==",
+ "dev": true,
+ "requires": {
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^2.4.2",
+ "cli-cursor": "^3.1.0",
+ "cli-width": "^2.0.0",
+ "external-editor": "^3.0.3",
+ "figures": "^3.0.0",
+ "lodash": "^4.17.15",
+ "mute-stream": "0.0.8",
+ "run-async": "^2.2.0",
+ "rxjs": "^6.4.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^5.1.0",
+ "through": "^2.3.6"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
+ "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+ "dev": true
+ },
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true
+ },
+ "string-width": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
+ "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
+ "dev": true,
+ "requires": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "dependencies": {
+ "strip-ansi": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+ "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.0"
+ }
+ }
+ }
+ },
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+ "dev": true
+ }
+ }
+ }
+ }
+ },
+ "interpret": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.0.0.tgz",
+ "integrity": "sha512-e0/LknJ8wpMMhTiWcjivB+ESwIuvHnBSlBbmP/pSb8CQJldoj1p2qv7xGZ/+BtbTziYRFSz8OsvdbiX45LtYQA=="
+ },
+ "ip": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
+ "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo="
+ },
+ "ip-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.1.0.tgz",
+ "integrity": "sha512-pKnZpbgCTfH/1NLIlOduP/V+WRXzC2MOz3Qo8xmxk8C5GudJLgK5QyLVXOSWy3ParAH7Eemurl3xjv/WXYFvMA=="
+ },
+ "is-absolute": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
+ "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
+ "requires": {
+ "is-relative": "^1.0.0",
+ "is-windows": "^1.0.1"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz",
+ "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=",
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "requires": {
+ "binary-extensions": "^2.0.0"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
+ },
+ "is-ci": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz",
+ "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==",
+ "dev": true,
+ "requires": {
+ "ci-info": "^1.5.0"
+ }
+ },
+ "is-data-descriptor": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
+ "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=",
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-descriptor": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz",
+ "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==",
+ "requires": {
+ "is-accessor-descriptor": "^0.1.6",
+ "is-data-descriptor": "^0.1.4",
+ "kind-of": "^5.0.0"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
+ "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw=="
+ }
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik="
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
+ },
+ "is-fullwidth-code-point": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+ "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+ "requires": {
+ "number-is-nan": "^1.0.0"
+ }
+ },
+ "is-glob": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+ "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-installed-globally": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz",
+ "integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=",
+ "dev": true,
+ "requires": {
+ "global-dirs": "^0.1.0",
+ "is-path-inside": "^1.0.0"
+ }
+ },
+ "is-ip": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz",
+ "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==",
+ "requires": {
+ "ip-regex": "^4.0.0"
+ }
+ },
+ "is-npm": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-1.0.0.tgz",
+ "integrity": "sha1-8vtjpl5JBbQGyGBydloaTceTufQ=",
+ "dev": true
+ },
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "is-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
+ "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
+ "dev": true
+ },
+ "is-path-inside": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz",
+ "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=",
+ "dev": true,
+ "requires": {
+ "path-is-inside": "^1.0.1"
+ }
+ },
+ "is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "requires": {
+ "isobject": "^3.0.1"
+ }
+ },
+ "is-promise": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz",
+ "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=",
+ "dev": true
+ },
+ "is-redirect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz",
+ "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=",
+ "dev": true
+ },
+ "is-relative": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
+ "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
+ "requires": {
+ "is-unc-path": "^1.0.0"
+ }
+ },
+ "is-retry-allowed": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz",
+ "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==",
+ "dev": true
+ },
+ "is-stream": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+ "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=",
+ "dev": true
+ },
+ "is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
+ },
+ "is-unc-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
+ "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
+ "requires": {
+ "unc-path-regex": "^0.1.2"
+ }
+ },
+ "is-windows": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+ },
+ "isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
+ },
+ "isstream": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+ "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
+ },
+ "js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "js-yaml": {
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
+ "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
+ "dev": true,
+ "requires": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ }
+ },
+ "jsbn": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM="
+ },
+ "json-buffer": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
+ "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg="
+ },
+ "json-schema": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
+ "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
+ },
+ "json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+ "dev": true
+ },
+ "json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
+ },
+ "json5": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.1.tgz",
+ "integrity": "sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==",
+ "requires": {
+ "minimist": "^1.2.0"
+ },
+ "dependencies": {
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ }
+ }
+ },
+ "jsprim": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
+ "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
+ "requires": {
+ "assert-plus": "1.0.0",
+ "extsprintf": "1.3.0",
+ "json-schema": "0.2.3",
+ "verror": "1.10.0"
+ }
+ },
+ "keyv": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz",
+ "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==",
+ "requires": {
+ "json-buffer": "3.0.0"
+ }
+ },
+ "kind-of": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
+ "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA=="
+ },
+ "knex": {
+ "version": "0.20.3",
+ "resolved": "https://registry.npmjs.org/knex/-/knex-0.20.3.tgz",
+ "integrity": "sha512-zzYO34pSCCYVqRTbCp8xL+Z7fvHQl5anif3Oacu6JaHFDubB7mFGWRRJBNSO3N8Ql4g4CxUgBctaPiliwoOsNA==",
+ "requires": {
+ "bluebird": "^3.7.1",
+ "colorette": "1.1.0",
+ "commander": "^4.0.1",
+ "debug": "4.1.1",
+ "getopts": "2.2.5",
+ "inherits": "~2.0.4",
+ "interpret": "^2.0.0",
+ "liftoff": "3.1.0",
+ "lodash": "^4.17.15",
+ "mkdirp": "^0.5.1",
+ "pg-connection-string": "2.1.0",
+ "tarn": "^2.0.0",
+ "tildify": "2.0.0",
+ "uuid": "^3.3.3",
+ "v8flags": "^3.1.3"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "lodash": {
+ "version": "4.17.15",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
+ "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+ },
+ "uuid": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz",
+ "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ=="
+ }
+ }
+ },
+ "knub-command-manager": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/knub-command-manager/-/knub-command-manager-6.1.0.tgz",
+ "integrity": "sha512-Bn//fk3ZKUNoJ+p0fNdUfbcyzTUdWHGaP12irSy8U1lfxy3pBrOZZsc0tpIkBFLpwLWw/VxHInX1x2b6MFhn0Q==",
+ "requires": {
+ "escape-string-regexp": "^2.0.0"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="
+ }
+ }
+ },
+ "latest-version": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz",
+ "integrity": "sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=",
+ "dev": true,
+ "requires": {
+ "package-json": "^4.0.0"
+ }
+ },
+ "levn": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
+ "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "~1.1.2",
+ "type-check": "~0.3.2"
+ }
+ },
+ "liftoff": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz",
+ "integrity": "sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==",
+ "requires": {
+ "extend": "^3.0.0",
+ "findup-sync": "^3.0.0",
+ "fined": "^1.0.1",
+ "flagged-respawn": "^1.0.0",
+ "is-plain-object": "^2.0.4",
+ "object.map": "^1.0.0",
+ "rechoir": "^0.6.2",
+ "resolve": "^1.1.7"
+ }
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "lodash": {
+ "version": "4.17.15",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
+ "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
+ "dev": true
+ },
+ "lowercase-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
+ "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA=="
+ },
+ "lru-cache": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
+ "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+ "dev": true,
+ "requires": {
+ "pseudomap": "^1.0.2",
+ "yallist": "^2.1.2"
+ }
+ },
+ "make-dir": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
+ "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==",
+ "dev": true,
+ "requires": {
+ "pify": "^3.0.0"
+ }
+ },
+ "make-iterator": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz",
+ "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==",
+ "requires": {
+ "kind-of": "^6.0.2"
+ }
+ },
+ "map-cache": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
+ "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8="
+ },
+ "map-visit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz",
+ "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=",
+ "requires": {
+ "object-visit": "^1.0.0"
+ }
+ },
+ "micromatch": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
+ "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "braces": "^2.3.1",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "extglob": "^2.0.4",
+ "fragment-cache": "^0.2.1",
+ "kind-of": "^6.0.2",
+ "nanomatch": "^1.2.9",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.2"
+ }
+ },
+ "mime": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz",
+ "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA=="
+ },
+ "mime-db": {
+ "version": "1.42.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz",
+ "integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ=="
+ },
+ "mime-types": {
+ "version": "2.1.25",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.25.tgz",
+ "integrity": "sha512-5KhStqB5xpTAeGqKBAMgwaYMnQik7teQN4IAzC7npDv6kzeU6prfkR67bc87J1kWMPGkoaZSq1npmexMgkmEVg==",
+ "requires": {
+ "mime-db": "1.42.0"
+ }
+ },
+ "mimic-response": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="
+ },
+ "minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "minimist": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+ "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
+ },
+ "minipass": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
+ "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
+ "requires": {
+ "safe-buffer": "^5.1.2",
+ "yallist": "^3.0.0"
+ },
+ "dependencies": {
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
+ }
+ }
+ },
+ "minizlib": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
+ "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
+ "requires": {
+ "minipass": "^2.9.0"
+ }
+ },
+ "mixin-deep": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
+ "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
+ "requires": {
+ "for-in": "^1.0.2",
+ "is-extendable": "^1.0.1"
+ },
+ "dependencies": {
+ "is-extendable": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
+ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
+ "requires": {
+ "is-plain-object": "^2.0.4"
+ }
+ }
+ }
+ },
+ "mkdirp": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+ "requires": {
+ "minimist": "0.0.8"
+ }
+ },
+ "moment": {
+ "version": "2.24.0",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
+ "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
+ "mute-stream": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
+ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
+ "dev": true
+ },
+ "mv": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
+ "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=",
+ "requires": {
+ "mkdirp": "~0.5.1",
+ "ncp": "~2.0.0",
+ "rimraf": "~2.4.0"
+ },
+ "dependencies": {
+ "glob": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
+ "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=",
+ "requires": {
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "2 || 3",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "rimraf": {
+ "version": "2.4.5",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
+ "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=",
+ "requires": {
+ "glob": "^6.0.1"
+ }
+ }
+ }
+ },
+ "nan": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
+ "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
+ },
+ "nanomatch": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
+ "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==",
+ "requires": {
+ "arr-diff": "^4.0.0",
+ "array-unique": "^0.3.2",
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "fragment-cache": "^0.2.1",
+ "is-windows": "^1.0.2",
+ "kind-of": "^6.0.2",
+ "object.pick": "^1.3.0",
+ "regex-not": "^1.0.0",
+ "snapdragon": "^0.8.1",
+ "to-regex": "^3.0.1"
+ }
+ },
+ "natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+ "dev": true
+ },
+ "ncp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
+ "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M="
+ },
+ "needle": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz",
+ "integrity": "sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==",
+ "requires": {
+ "debug": "^3.2.6",
+ "iconv-lite": "^0.4.4",
+ "sax": "^1.2.4"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+ "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ }
+ }
+ },
+ "nice-try": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
+ "dev": true
+ },
+ "node-pre-gyp": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz",
+ "integrity": "sha512-TwWAOZb0j7e9eGaf9esRx3ZcLaE5tQ2lvYy1pb5IAaG1a2e2Kv5Lms1Y4hpj+ciXJRofIxxlt5haeQ/2ANeE0Q==",
+ "requires": {
+ "detect-libc": "^1.0.2",
+ "mkdirp": "^0.5.1",
+ "needle": "^2.2.1",
+ "nopt": "^4.0.1",
+ "npm-packlist": "^1.1.6",
+ "npmlog": "^4.0.2",
+ "rc": "^1.2.7",
+ "rimraf": "^2.6.1",
+ "semver": "^5.3.0",
+ "tar": "^4"
+ }
+ },
+ "nodemon": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.1.tgz",
+ "integrity": "sha512-UC6FVhNLXjbbV4UzaXA3wUdbEkUZzLGgMGzmxvWAex5nzib/jhcSHVFlQODdbuUHq8SnnZ4/EABBAbC3RplvPg==",
+ "dev": true,
+ "requires": {
+ "chokidar": "^3.2.2",
+ "debug": "^3.2.6",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^3.0.4",
+ "pstree.remy": "^1.1.7",
+ "semver": "^5.7.1",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.2",
+ "update-notifier": "^2.5.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+ "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "semver": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "dev": true
+ }
+ }
+ },
+ "nopt": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
+ "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
+ "requires": {
+ "abbrev": "1",
+ "osenv": "^0.1.4"
+ }
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "normalize-url": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz",
+ "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ=="
+ },
+ "npm-bundled": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz",
+ "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g=="
+ },
+ "npm-packlist": {
+ "version": "1.4.6",
+ "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.6.tgz",
+ "integrity": "sha512-u65uQdb+qwtGvEJh/DgQgW1Xg7sqeNbmxYyrvlNznaVTjV3E5P6F/EFjM+BVHXl7JJlsdG8A64M0XI8FI/IOlg==",
+ "requires": {
+ "ignore-walk": "^3.0.1",
+ "npm-bundled": "^1.0.1"
+ }
+ },
+ "npm-run-path": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+ "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=",
+ "dev": true,
+ "requires": {
+ "path-key": "^2.0.0"
+ }
+ },
+ "npmlog": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+ "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+ "requires": {
+ "are-we-there-yet": "~1.1.2",
+ "console-control-strings": "~1.1.0",
+ "gauge": "~2.7.3",
+ "set-blocking": "~2.0.0"
+ }
+ },
+ "number-is-nan": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+ "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
+ },
+ "oauth-sign": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
+ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+ },
+ "object-copy": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
+ "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=",
+ "requires": {
+ "copy-descriptor": "^0.1.0",
+ "define-property": "^0.2.5",
+ "kind-of": "^3.0.3"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "object-visit": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz",
+ "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=",
+ "requires": {
+ "isobject": "^3.0.0"
+ }
+ },
+ "object.defaults": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
+ "integrity": "sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8=",
+ "requires": {
+ "array-each": "^1.0.1",
+ "array-slice": "^1.0.0",
+ "for-own": "^1.0.0",
+ "isobject": "^3.0.0"
+ }
+ },
+ "object.map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz",
+ "integrity": "sha1-z4Plncj8wK1fQlDh94s7gb2AHTc=",
+ "requires": {
+ "for-own": "^1.0.0",
+ "make-iterator": "^1.0.0"
+ }
+ },
+ "object.pick": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
+ "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=",
+ "requires": {
+ "isobject": "^3.0.1"
+ }
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "onetime": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz",
+ "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==",
+ "dev": true,
+ "requires": {
+ "mimic-fn": "^2.1.0"
+ },
+ "dependencies": {
+ "mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true
+ }
+ }
+ },
+ "optionator": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
+ "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
+ "dev": true,
+ "requires": {
+ "deep-is": "~0.1.3",
+ "fast-levenshtein": "~2.0.6",
+ "levn": "~0.3.0",
+ "prelude-ls": "~1.1.2",
+ "type-check": "~0.3.2",
+ "word-wrap": "~1.2.3"
+ }
+ },
+ "opusscript": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.4.tgz",
+ "integrity": "sha512-bEPZFE2lhUJYQD5yfTFO4RhbRZ937x6hRwBC1YoGacT35bwDVwKFP1+amU8NYfZL/v4EU7ZTU3INTqzYAnuP7Q==",
+ "optional": true
+ },
+ "os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
+ },
+ "os-tmpdir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+ "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
+ },
+ "osenv": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+ "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
+ "requires": {
+ "os-homedir": "^1.0.0",
+ "os-tmpdir": "^1.0.0"
+ }
+ },
+ "p-cancelable": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz",
+ "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw=="
+ },
+ "p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
+ "dev": true
+ },
+ "p-limit": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz",
+ "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==",
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
+ },
+ "package-json": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/package-json/-/package-json-4.0.1.tgz",
+ "integrity": "sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=",
+ "dev": true,
+ "requires": {
+ "got": "^6.7.1",
+ "registry-auth-token": "^3.0.1",
+ "registry-url": "^3.0.3",
+ "semver": "^5.1.0"
+ },
+ "dependencies": {
+ "got": {
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz",
+ "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=",
+ "dev": true,
+ "requires": {
+ "create-error-class": "^3.0.0",
+ "duplexer3": "^0.1.4",
+ "get-stream": "^3.0.0",
+ "is-redirect": "^1.0.0",
+ "is-retry-allowed": "^1.0.0",
+ "is-stream": "^1.0.0",
+ "lowercase-keys": "^1.0.0",
+ "safe-buffer": "^5.0.1",
+ "timed-out": "^4.0.0",
+ "unzip-response": "^2.0.1",
+ "url-parse-lax": "^1.0.0"
+ }
+ },
+ "prepend-http": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
+ "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=",
+ "dev": true
+ },
+ "url-parse-lax": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz",
+ "integrity": "sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=",
+ "dev": true,
+ "requires": {
+ "prepend-http": "^1.0.1"
+ }
+ }
+ }
+ },
+ "parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "requires": {
+ "callsites": "^3.0.0"
+ }
+ },
+ "parse-filepath": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
+ "integrity": "sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE=",
+ "requires": {
+ "is-absolute": "^1.0.0",
+ "map-cache": "^0.2.0",
+ "path-root": "^0.1.1"
+ }
+ },
+ "parse-passwd": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
+ "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY="
+ },
+ "pascalcase": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
+ "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ="
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU="
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+ },
+ "path-is-inside": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
+ "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=",
+ "dev": true
+ },
+ "path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",
+ "dev": true
+ },
+ "path-parse": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
+ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
+ },
+ "path-root": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
+ "integrity": "sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc=",
+ "requires": {
+ "path-root-regex": "^0.1.0"
+ }
+ },
+ "path-root-regex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
+ "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0="
+ },
+ "performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+ },
+ "pg-connection-string": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.1.0.tgz",
+ "integrity": "sha512-bhlV7Eq09JrRIvo1eKngpwuqKtJnNhZdpdOlvrPrA4dxqXPjxSrbNrfnIDmTpwMyRszrcV4kU5ZA4mMsQUrjdg=="
+ },
+ "picomatch": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.1.1.tgz",
+ "integrity": "sha512-OYMyqkKzK7blWO/+XZYP6w8hH0LDvkBvdvKukti+7kqYFCiEAk+gI3DWnryapc0Dau05ugGTy0foQ6mqn4AHYA==",
+ "dev": true
+ },
+ "pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
+ "dev": true
+ },
+ "posix-character-classes": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
+ "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs="
+ },
+ "prelude-ls": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
+ "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
+ "dev": true
+ },
+ "prepend-http": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
+ "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc="
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
+ "progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true
+ },
+ "pseudomap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+ "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
+ "dev": true
+ },
+ "psl": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.5.0.tgz",
+ "integrity": "sha512-4vqUjKi2huMu1OJiLhi3jN6jeeKvMZdI1tYgi/njW5zV52jNLgSAZSdN16m9bJFe61/cT8ulmw4qFitV9QRsEA=="
+ },
+ "pstree.remy": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.7.tgz",
+ "integrity": "sha512-xsMgrUwRpuGskEzBFkH8NmTimbZ5PcPup0LA8JJkHIm2IMUbQcpo3yeLNWVrufEYjh8YwtSVh0xz6UeWc5Oh5A==",
+ "dev": true
+ },
+ "public-ip": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/public-ip/-/public-ip-4.0.0.tgz",
+ "integrity": "sha512-Q5dcQ5qLPpMSyj0iEqucTfeINHVeEhuSjjaPVEU24+6RGlvCrEM6AXwOlWW4iIn10yyWROASRuS3rgbUZIE/5g==",
+ "requires": {
+ "dns-socket": "^4.2.0",
+ "got": "^9.6.0",
+ "is-ip": "^3.1.0"
+ }
+ },
+ "pump": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+ "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
+ },
+ "qs": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
+ },
+ "rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "requires": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "dependencies": {
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ }
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+ "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "readdirp": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz",
+ "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==",
+ "dev": true,
+ "requires": {
+ "picomatch": "^2.0.4"
+ }
+ },
+ "rechoir": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
+ "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=",
+ "requires": {
+ "resolve": "^1.1.6"
+ }
+ },
+ "regex-not": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
+ "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==",
+ "requires": {
+ "extend-shallow": "^3.0.2",
+ "safe-regex": "^1.1.0"
+ }
+ },
+ "regexpp": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz",
+ "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==",
+ "dev": true
+ },
+ "registry-auth-token": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.4.0.tgz",
+ "integrity": "sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==",
+ "dev": true,
+ "requires": {
+ "rc": "^1.1.6",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "registry-url": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz",
+ "integrity": "sha1-PU74cPc93h138M+aOBQyRE4XSUI=",
+ "dev": true,
+ "requires": {
+ "rc": "^1.0.1"
+ }
+ },
+ "repeat-element": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz",
+ "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g=="
+ },
+ "repeat-string": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
+ "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc="
+ },
+ "request": {
+ "version": "2.88.0",
+ "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz",
+ "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==",
+ "requires": {
+ "aws-sign2": "~0.7.0",
+ "aws4": "^1.8.0",
+ "caseless": "~0.12.0",
+ "combined-stream": "~1.0.6",
+ "extend": "~3.0.2",
+ "forever-agent": "~0.6.1",
+ "form-data": "~2.3.2",
+ "har-validator": "~5.1.0",
+ "http-signature": "~1.2.0",
+ "is-typedarray": "~1.0.0",
+ "isstream": "~0.1.2",
+ "json-stringify-safe": "~5.0.1",
+ "mime-types": "~2.1.19",
+ "oauth-sign": "~0.9.0",
+ "performance-now": "^2.1.0",
+ "qs": "~6.5.2",
+ "safe-buffer": "^5.1.2",
+ "tough-cookie": "~2.4.3",
+ "tunnel-agent": "^0.6.0",
+ "uuid": "^3.3.2"
+ },
+ "dependencies": {
+ "uuid": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz",
+ "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ=="
+ }
+ }
+ },
+ "require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
+ },
+ "require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
+ },
+ "resolve": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.13.1.tgz",
+ "integrity": "sha512-CxqObCX8K8YtAhOBRg+lrcdn+LK+WYOS8tSjqSFbjtrI5PnS63QPhZl4+yKfrU9tdsbMu9Anr/amegT87M9Z6w==",
+ "requires": {
+ "path-parse": "^1.0.6"
+ }
+ },
+ "resolve-dir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
+ "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=",
+ "requires": {
+ "expand-tilde": "^2.0.0",
+ "global-modules": "^1.0.0"
+ }
+ },
+ "resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true
+ },
+ "resolve-url": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+ "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo="
+ },
+ "responselike": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz",
+ "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=",
+ "requires": {
+ "lowercase-keys": "^1.0.0"
+ }
+ },
+ "restore-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
+ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+ "dev": true,
+ "requires": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "ret": {
+ "version": "0.1.15",
+ "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+ "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="
+ },
+ "rimraf": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
+ "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
+ "requires": {
+ "glob": "^7.1.3"
+ },
+ "dependencies": {
+ "glob": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
+ "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ }
+ }
+ },
+ "run-async": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz",
+ "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=",
+ "dev": true,
+ "requires": {
+ "is-promise": "^2.1.0"
+ }
+ },
+ "rxjs": {
+ "version": "6.5.3",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz",
+ "integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==",
+ "dev": true,
+ "requires": {
+ "tslib": "^1.9.0"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "safe-regex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+ "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
+ "requires": {
+ "ret": "~0.1.10"
+ }
+ },
+ "safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "sax": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
+ },
+ "semver": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz",
+ "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg=="
+ },
+ "semver-diff": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-2.1.0.tgz",
+ "integrity": "sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=",
+ "dev": true,
+ "requires": {
+ "semver": "^5.0.3"
+ }
+ },
+ "set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
+ },
+ "set-value": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
+ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-extendable": "^0.1.1",
+ "is-plain-object": "^2.0.3",
+ "split-string": "^3.0.1"
+ },
+ "dependencies": {
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ }
+ }
+ },
+ "shebang-command": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+ "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^1.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
+ "dev": true
+ },
+ "signal-exit": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
+ },
+ "slice-ansi": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz",
+ "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^3.2.0",
+ "astral-regex": "^1.0.0",
+ "is-fullwidth-code-point": "^2.0.0"
+ },
+ "dependencies": {
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
+ "dev": true
+ }
+ }
+ },
+ "snapdragon": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
+ "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==",
+ "requires": {
+ "base": "^0.11.1",
+ "debug": "^2.2.0",
+ "define-property": "^0.2.5",
+ "extend-shallow": "^2.0.1",
+ "map-cache": "^0.2.2",
+ "source-map": "^0.5.6",
+ "source-map-resolve": "^0.5.0",
+ "use": "^3.1.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ },
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ }
+ }
+ },
+ "snapdragon-node": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz",
+ "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==",
+ "requires": {
+ "define-property": "^1.0.0",
+ "isobject": "^3.0.0",
+ "snapdragon-util": "^3.0.1"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz",
+ "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=",
+ "requires": {
+ "is-descriptor": "^1.0.0"
+ }
+ },
+ "is-accessor-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz",
+ "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-data-descriptor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz",
+ "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==",
+ "requires": {
+ "kind-of": "^6.0.0"
+ }
+ },
+ "is-descriptor": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz",
+ "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==",
+ "requires": {
+ "is-accessor-descriptor": "^1.0.0",
+ "is-data-descriptor": "^1.0.0",
+ "kind-of": "^6.0.2"
+ }
+ }
+ }
+ },
+ "snapdragon-util": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz",
+ "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==",
+ "requires": {
+ "kind-of": "^3.2.0"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "source-map-resolve": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz",
+ "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==",
+ "requires": {
+ "atob": "^2.1.1",
+ "decode-uri-component": "^0.2.0",
+ "resolve-url": "^0.2.1",
+ "source-map-url": "^0.4.0",
+ "urix": "^0.1.0"
+ }
+ },
+ "source-map-url": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz",
+ "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM="
+ },
+ "split-string": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
+ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
+ "requires": {
+ "extend-shallow": "^3.0.0"
+ }
+ },
+ "sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+ "dev": true
+ },
+ "sqlite3": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-4.1.0.tgz",
+ "integrity": "sha512-RvqoKxq+8pDHsJo7aXxsFR18i+dU2Wp5o12qAJOV5LNcDt+fgJsc2QKKg3sIRfXrN9ZjzY1T7SNe/DFVqAXjaw==",
+ "requires": {
+ "nan": "^2.12.1",
+ "node-pre-gyp": "^0.11.0",
+ "request": "^2.87.0"
+ }
+ },
+ "sshpk": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
+ "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
+ "requires": {
+ "asn1": "~0.2.3",
+ "assert-plus": "^1.0.0",
+ "bcrypt-pbkdf": "^1.0.0",
+ "dashdash": "^1.12.0",
+ "ecc-jsbn": "~0.1.1",
+ "getpass": "^0.1.1",
+ "jsbn": "~0.1.0",
+ "safer-buffer": "^2.0.2",
+ "tweetnacl": "~0.14.0"
+ },
+ "dependencies": {
+ "tweetnacl": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
+ }
+ }
+ },
+ "static-extend": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
+ "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=",
+ "requires": {
+ "define-property": "^0.2.5",
+ "object-copy": "^0.1.0"
+ },
+ "dependencies": {
+ "define-property": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
+ "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=",
+ "requires": {
+ "is-descriptor": "^0.1.0"
+ }
+ }
+ }
+ },
+ "string-width": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+ "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+ "requires": {
+ "is-fullwidth-code-point": "^2.0.0",
+ "strip-ansi": "^4.0.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+ "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+ },
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+ },
+ "strip-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+ "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+ "requires": {
+ "ansi-regex": "^3.0.0"
+ }
+ }
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+ "requires": {
+ "ansi-regex": "^2.0.0"
+ }
+ },
+ "strip-eof": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+ "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=",
+ "dev": true
+ },
+ "strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
+ },
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ },
+ "table": {
+ "version": "5.4.6",
+ "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz",
+ "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.10.2",
+ "lodash": "^4.17.14",
+ "slice-ansi": "^2.1.0",
+ "string-width": "^3.0.0"
+ },
+ "dependencies": {
+ "ajv": {
+ "version": "6.10.2",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz",
+ "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^2.0.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ansi-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
+ "dev": true
+ },
+ "emoji-regex": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
+ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
+ "dev": true
+ },
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
+ "dev": true
+ },
+ "string-width": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
+ "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+ "dev": true,
+ "requires": {
+ "emoji-regex": "^7.0.1",
+ "is-fullwidth-code-point": "^2.0.0",
+ "strip-ansi": "^5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ }
+ }
+ }
+ },
+ "tar": {
+ "version": "4.4.13",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz",
+ "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==",
+ "requires": {
+ "chownr": "^1.1.1",
+ "fs-minipass": "^1.2.5",
+ "minipass": "^2.8.6",
+ "minizlib": "^1.2.1",
+ "mkdirp": "^0.5.0",
+ "safe-buffer": "^5.1.2",
+ "yallist": "^3.0.3"
+ },
+ "dependencies": {
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
+ }
+ }
+ },
+ "tarn": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tarn/-/tarn-2.0.0.tgz",
+ "integrity": "sha512-7rNMCZd3s9bhQh47ksAQd92ADFcJUjjbyOvyFjNLwTPpGieFHMC84S+LOzw0fx1uh6hnDz/19r8CPMnIjJlMMA=="
+ },
+ "term-size": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz",
+ "integrity": "sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=",
+ "dev": true,
+ "requires": {
+ "execa": "^0.7.0"
+ }
+ },
+ "text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+ "dev": true
+ },
+ "through": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+ "dev": true
+ },
+ "tildify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
+ "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw=="
+ },
+ "timed-out": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
+ "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=",
+ "dev": true
+ },
+ "tmp": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
+ "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
+ "requires": {
+ "rimraf": "^2.6.3"
+ }
+ },
+ "to-object-path": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
+ "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=",
+ "requires": {
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "to-readable-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz",
+ "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q=="
+ },
+ "to-regex": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz",
+ "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==",
+ "requires": {
+ "define-property": "^2.0.2",
+ "extend-shallow": "^3.0.2",
+ "regex-not": "^1.0.2",
+ "safe-regex": "^1.1.0"
+ }
+ },
+ "to-regex-range": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
+ "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=",
+ "requires": {
+ "is-number": "^3.0.0",
+ "repeat-string": "^1.6.1"
+ }
+ },
+ "touch": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
+ "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==",
+ "dev": true,
+ "requires": {
+ "nopt": "~1.0.10"
+ },
+ "dependencies": {
+ "nopt": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
+ "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=",
+ "dev": true,
+ "requires": {
+ "abbrev": "1"
+ }
+ }
+ }
+ },
+ "tough-cookie": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",
+ "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==",
+ "requires": {
+ "psl": "^1.1.24",
+ "punycode": "^1.4.1"
+ }
+ },
+ "transliteration": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.1.7.tgz",
+ "integrity": "sha512-o3678GPmKKGqOBB+trAKzhBUjHddU18He2V8AKB1XuegaGJekO0xmfkkvbc9LCBat62nb7IH8z5/OJY+mNugkg==",
+ "requires": {
+ "yargs": "^14.0.0"
+ }
+ },
+ "tslib": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz",
+ "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==",
+ "dev": true
+ },
+ "tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+ "requires": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "tweetnacl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.1.tgz",
+ "integrity": "sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==",
+ "optional": true
+ },
+ "type-check": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
+ "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "~1.1.2"
+ }
+ },
+ "type-fest": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+ "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+ "dev": true
+ },
+ "unc-path-regex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
+ "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo="
+ },
+ "undefsafe": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.2.tgz",
+ "integrity": "sha1-Il9rngM3Zj4Njnz9aG/Cg2zKznY=",
+ "dev": true,
+ "requires": {
+ "debug": "^2.2.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dev": true,
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+ "dev": true
+ }
+ }
+ },
+ "union-value": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
+ "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
+ "requires": {
+ "arr-union": "^3.1.0",
+ "get-value": "^2.0.6",
+ "is-extendable": "^0.1.1",
+ "set-value": "^2.0.1"
+ }
+ },
+ "unique-string": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
+ "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=",
+ "dev": true,
+ "requires": {
+ "crypto-random-string": "^1.0.0"
+ }
+ },
+ "unset-value": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz",
+ "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=",
+ "requires": {
+ "has-value": "^0.3.1",
+ "isobject": "^3.0.0"
+ },
+ "dependencies": {
+ "has-value": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz",
+ "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=",
+ "requires": {
+ "get-value": "^2.0.3",
+ "has-values": "^0.1.4",
+ "isobject": "^2.0.0"
+ },
+ "dependencies": {
+ "isobject": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
+ "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=",
+ "requires": {
+ "isarray": "1.0.0"
+ }
+ }
+ }
+ },
+ "has-values": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz",
+ "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E="
+ }
+ }
+ },
+ "unzip-response": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz",
+ "integrity": "sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=",
+ "dev": true
+ },
+ "update-notifier": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-2.5.0.tgz",
+ "integrity": "sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==",
+ "dev": true,
+ "requires": {
+ "boxen": "^1.2.1",
+ "chalk": "^2.0.1",
+ "configstore": "^3.0.0",
+ "import-lazy": "^2.1.0",
+ "is-ci": "^1.0.10",
+ "is-installed-globally": "^0.1.0",
+ "is-npm": "^1.0.0",
+ "latest-version": "^3.0.0",
+ "semver-diff": "^2.0.0",
+ "xdg-basedir": "^3.0.0"
+ }
+ },
+ "uri-js": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
+ "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
+ "requires": {
+ "punycode": "^2.1.0"
+ },
+ "dependencies": {
+ "punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+ }
+ }
+ },
+ "urix": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI="
+ },
+ "url-parse-lax": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
+ "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=",
+ "requires": {
+ "prepend-http": "^2.0.0"
+ }
+ },
+ "use": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
+ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+ },
+ "uuid": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz",
+ "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ=="
+ },
+ "v8-compile-cache": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz",
+ "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==",
+ "dev": true
+ },
+ "v8flags": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.1.3.tgz",
+ "integrity": "sha512-amh9CCg3ZxkzQ48Mhcb8iX7xpAfYJgePHxWMQCBWECpOSqJUXgY26ncA61UTV0BkPqfhcy6mzwCIoP4ygxpW8w==",
+ "requires": {
+ "homedir-polyfill": "^1.0.1"
+ }
+ },
+ "verror": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+ "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+ "requires": {
+ "assert-plus": "^1.0.0",
+ "core-util-is": "1.0.2",
+ "extsprintf": "^1.2.0"
+ }
+ },
+ "which": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz",
+ "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==",
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
+ "which-module": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+ "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho="
+ },
+ "wide-align": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+ "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+ "requires": {
+ "string-width": "^1.0.2 || 2"
+ }
+ },
+ "widest-line": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-2.0.1.tgz",
+ "integrity": "sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==",
+ "dev": true,
+ "requires": {
+ "string-width": "^2.1.1"
+ }
+ },
+ "word-wrap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+ "dev": true
+ },
+ "wrap-ansi": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
+ "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
+ "requires": {
+ "ansi-styles": "^3.2.0",
+ "string-width": "^3.0.0",
+ "strip-ansi": "^5.0.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
+ },
+ "emoji-regex": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
+ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="
+ },
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+ },
+ "string-width": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
+ "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+ "requires": {
+ "emoji-regex": "^7.0.1",
+ "is-fullwidth-code-point": "^2.0.0",
+ "strip-ansi": "^5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ }
+ }
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+ },
+ "write": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz",
+ "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==",
+ "dev": true,
+ "requires": {
+ "mkdirp": "^0.5.1"
+ }
+ },
+ "write-file-atomic": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz",
+ "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.11",
+ "imurmurhash": "^0.1.4",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "ws": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.0.tgz",
+ "integrity": "sha512-+SqNqFbwTm/0DC18KYzIsMTnEWpLwJsiasW/O17la4iDRRIO9uaHbvKiAS3AHgTiuuWerK/brj4O6MYZkei9xg==",
+ "requires": {
+ "async-limiter": "^1.0.0"
+ }
+ },
+ "xdg-basedir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz",
+ "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=",
+ "dev": true
+ },
+ "y18n": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
+ "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w=="
+ },
+ "yallist": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+ "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
+ "dev": true
+ },
+ "yargs": {
+ "version": "14.2.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.2.tgz",
+ "integrity": "sha512-/4ld+4VV5RnrynMhPZJ/ZpOCGSCeghMykZ3BhdFBDa9Wy/RH6uEGNWDJog+aUlq+9OM1CFTgtYRW5Is1Po9NOA==",
+ "requires": {
+ "cliui": "^5.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^3.0.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^3.0.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^15.0.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+ "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
+ },
+ "emoji-regex": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
+ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="
+ },
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+ },
+ "string-width": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
+ "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+ "requires": {
+ "emoji-regex": "^7.0.1",
+ "is-fullwidth-code-point": "^2.0.0",
+ "strip-ansi": "^5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ }
+ }
+ }
+ },
+ "yargs-parser": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.0.tgz",
+ "integrity": "sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ==",
+ "requires": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f3052ff
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "modmailbot",
+ "version": "2.30.1",
+ "description": "",
+ "license": "MIT",
+ "main": "src/index.js",
+ "scripts": {
+ "start": "node src/index.js",
+ "watch": "nodemon -w src src/index.js",
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "lint": "eslint ./src"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Dragory/modmailbot"
+ },
+ "dependencies": {
+ "eris": "^0.11.1",
+ "humanize-duration": "^3.12.1",
+ "ini": "^1.3.5",
+ "json5": "^2.1.1",
+ "knex": "^0.20.3",
+ "knub-command-manager": "^6.1.0",
+ "mime": "^2.4.4",
+ "moment": "^2.24.0",
+ "mv": "^2.1.1",
+ "public-ip": "^4.0.0",
+ "sqlite3": "^4.1.0",
+ "tmp": "^0.1.0",
+ "transliteration": "^2.1.7",
+ "uuid": "^3.3.3"
+ },
+ "devDependencies": {
+ "eslint": "^6.7.2",
+ "nodemon": "^2.0.1"
+ }
+}
diff --git a/src/bot.js b/src/bot.js
new file mode 100644
index 0000000..2a1591a
--- /dev/null
+++ b/src/bot.js
@@ -0,0 +1,9 @@
+const Eris = require('eris');
+const config = require('./config');
+
+const bot = new Eris.Client(config.token, {
+ getAllUsers: true,
+ restMode: true,
+});
+
+module.exports = bot;
diff --git a/src/commands.js b/src/commands.js
new file mode 100644
index 0000000..59c6506
--- /dev/null
+++ b/src/commands.js
@@ -0,0 +1,130 @@
+const { CommandManager, defaultParameterTypes, TypeConversionError } = require('knub-command-manager');
+const config = require('./config');
+const utils = require('./utils');
+const threads = require('./data/threads');
+
+module.exports = {
+ createCommandManager(bot) {
+ const manager = new CommandManager({
+ prefix: config.prefix,
+ types: Object.assign({}, defaultParameterTypes, {
+ userId(value) {
+ const userId = utils.getUserMention(value);
+ if (! userId) throw new TypeConversionError();
+ return userId;
+ },
+
+ delay(value) {
+ const ms = utils.convertDelayStringToMS(value);
+ if (ms === null) throw new TypeConversionError();
+ return ms;
+ }
+ })
+ });
+
+ const handlers = {};
+ const aliasMap = new Map();
+
+ bot.on('messageCreate', async msg => {
+ if (msg.author.bot) return;
+ if (msg.author.id === bot.user.id) return;
+ if (! msg.content) return;
+
+ const matchedCommand = await manager.findMatchingCommand(msg.content, { msg });
+ if (matchedCommand === null) return;
+ if (matchedCommand.error !== undefined) {
+ utils.postError(msg.channel, matchedCommand.error);
+ return;
+ }
+
+ const allArgs = {};
+ for (const [name, arg] of Object.entries(matchedCommand.args)) {
+ allArgs[name] = arg.value;
+ }
+ for (const [name, opt] of Object.entries(matchedCommand.opts)) {
+ allArgs[name] = opt.value;
+ }
+
+ handlers[matchedCommand.id](msg, allArgs);
+ });
+
+ /**
+ * Add a command that can be invoked anywhere
+ */
+ const addGlobalCommand = (trigger, parameters, handler, commandConfig = {}) => {
+ let aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : [];
+ if (commandConfig.aliases) aliases.push(...commandConfig.aliases);
+
+ const cmd = manager.add(trigger, parameters, { ...commandConfig, aliases });
+ handlers[cmd.id] = handler;
+ };
+
+ /**
+ * Add a command that can only be invoked on the inbox server
+ */
+ const addInboxServerCommand = (trigger, parameters, handler, commandConfig = {}) => {
+ const aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : [];
+ if (commandConfig.aliases) aliases.push(...commandConfig.aliases);
+
+ const cmd = manager.add(trigger, parameters, {
+ ...commandConfig,
+ aliases,
+ preFilters: [
+ (_, context) => {
+ if (! utils.messageIsOnInboxServer(context.msg)) return false;
+ if (! utils.isStaff(context.msg.member)) return false;
+ return true;
+ }
+ ]
+ });
+
+ handlers[cmd.id] = async (msg, args) => {
+ const thread = await threads.findOpenThreadByChannelId(msg.channel.id);
+ handler(msg, args, thread);
+ };
+ };
+
+ /**
+ * Add a command that can only be invoked in a thread on the inbox server
+ */
+ const addInboxThreadCommand = (trigger, parameters, handler, commandConfig = {}) => {
+ const aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : [];
+ if (commandConfig.aliases) aliases.push(...commandConfig.aliases);
+
+ let thread;
+ const cmd = manager.add(trigger, parameters, {
+ ...commandConfig,
+ aliases,
+ preFilters: [
+ async (_, context) => {
+ if (! utils.messageIsOnInboxServer(context.msg)) return false;
+ if (! utils.isStaff(context.msg.member)) return false;
+ thread = await threads.findOpenThreadByChannelId(context.msg.channel.id);
+ if (! thread) return false;
+ return true;
+ }
+ ]
+ });
+
+ handlers[cmd.id] = async (msg, args) => {
+ handler(msg, args, thread);
+ };
+ };
+
+ const addAlias = (originalCmd, alias) => {
+ if (! aliasMap.has(originalCmd)) {
+ aliasMap.set(originalCmd, new Set());
+ }
+
+ aliasMap.get(originalCmd).add(alias);
+ };
+
+ return {
+ manager,
+ addGlobalCommand,
+ addInboxServerCommand,
+ addInboxThreadCommand,
+ addAlias,
+ };
+ }
+};
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..a2e3beb
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,289 @@
+/**
+ * !!! NOTE !!!
+ *
+ * If you're setting up the bot, DO NOT EDIT THIS FILE DIRECTLY!
+ *
+ * Create a configuration file in the same directory as the example file.
+ * You never need to edit anything under src/ to use the bot.
+ *
+ * !!! NOTE !!!
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+let userConfig = {};
+
+// Config files to search for, in priority order
+const configFiles = [
+ 'config.ini',
+ 'config.ini.ini',
+ 'config.ini.txt',
+ 'config.json',
+ 'config.json5',
+ 'config.json.json',
+ 'config.json.txt',
+ 'config.js'
+];
+
+let foundConfigFile;
+for (const configFile of configFiles) {
+ try {
+ fs.accessSync(__dirname + '/../' + configFile);
+ foundConfigFile = configFile;
+ break;
+ } catch (e) {}
+}
+
+// Load config file
+if (foundConfigFile) {
+ console.log(`Loading configuration from ${foundConfigFile}...`);
+ try {
+ if (foundConfigFile.endsWith('.js')) {
+ userConfig = require(`../${foundConfigFile}`);
+ } else {
+ const raw = fs.readFileSync(__dirname + '/../' + foundConfigFile, {encoding: "utf8"});
+ if (foundConfigFile.endsWith('.ini') || foundConfigFile.endsWith('.ini.txt')) {
+ userConfig = require('ini').decode(raw);
+ } else {
+ userConfig = require('json5').parse(raw);
+ }
+ }
+ } catch (e) {
+ throw new Error(`Error reading config file! The error given was: ${e.message}`);
+ }
+}
+
+const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId'];
+const numericOptions = ['requiredAccountAge', 'requiredTimeOnServer', 'smallAttachmentLimit', 'port'];
+
+const defaultConfig = {
+ "token": null,
+ "mailGuildId": null,
+ "mainGuildId": null,
+ "logChannelId": null,
+
+ "prefix": "!",
+ "snippetPrefix": "!!",
+ "snippetPrefixAnon": "!!!",
+
+ "status": "Message me for help!",
+ "responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible.",
+ "closeMessage": null,
+ "allowUserClose": false,
+
+ "newThreadCategoryId": null,
+ "mentionRole": "here",
+ "pingOnBotMention": true,
+ "botMentionResponse": null,
+
+ "inboxServerPermission": null,
+ "alwaysReply": false,
+ "alwaysReplyAnon": false,
+ "useNicknames": false,
+ "ignoreAccidentalThreads": false,
+ "threadTimestamps": false,
+ "allowMove": false,
+ "syncPermissionsOnMove": true,
+ "typingProxy": false,
+ "typingProxyReverse": false,
+ "mentionUserInThreadHeader": false,
+ "rolesInThreadHeader": false,
+
+ "enableGreeting": false,
+ "greetingMessage": null,
+ "greetingAttachment": null,
+
+ "guildGreetings": {},
+
+ "requiredAccountAge": null, // In hours
+ "accountAgeDeniedMessage": "Your Discord account is not old enough to contact modmail.",
+
+ "requiredTimeOnServer": null, // In minutes
+ "timeOnServerDeniedMessage": "You haven't been a member of the server for long enough to contact modmail.",
+
+ "relaySmallAttachmentsAsAttachments": false,
+ "smallAttachmentLimit": 1024 * 1024 * 2,
+ "attachmentStorage": "local",
+ "attachmentStorageChannelId": null,
+
+ "categoryAutomation": {},
+
+ "updateNotifications": true,
+ "plugins": [],
+
+ "commandAliases": {},
+
+ "port": 8890,
+ "url": null,
+
+ "dbDir": path.join(__dirname, '..', 'db'),
+ "knex": null,
+
+ "logDir": path.join(__dirname, '..', 'logs'),
+};
+
+// Load config values from environment variables
+const envKeyPrefix = 'MM_';
+let loadedEnvValues = 0;
+
+for (const [key, value] of Object.entries(process.env)) {
+ if (! key.startsWith(envKeyPrefix)) continue;
+
+ // MM_CLOSE_MESSAGE -> closeMessage
+ // MM_COMMAND_ALIASES__MV => commandAliases.mv
+ const configKey = key.slice(envKeyPrefix.length)
+ .toLowerCase()
+ .replace(/([a-z])_([a-z])/g, (m, m1, m2) => `${m1}${m2.toUpperCase()}`)
+ .replace('__', '.');
+
+ userConfig[configKey] = value.includes('||')
+ ? value.split('||')
+ : value;
+
+ loadedEnvValues++;
+}
+
+if (process.env.PORT && !process.env.MM_PORT) {
+ // Special case: allow common "PORT" environment variable without prefix
+ userConfig.port = process.env.PORT;
+ loadedEnvValues++;
+}
+
+if (loadedEnvValues > 0) {
+ console.log(`Loaded ${loadedEnvValues} ${loadedEnvValues === 1 ? 'value' : 'values'} from environment variables`);
+}
+
+// Convert config keys with periods to objects
+// E.g. commandAliases.mv -> commandAliases: { mv: ... }
+for (const [key, value] of Object.entries(userConfig)) {
+ if (! key.includes('.')) continue;
+
+ const keys = key.split('.');
+ let cursor = userConfig;
+ for (let i = 0; i < keys.length; i++) {
+ if (i === keys.length - 1) {
+ cursor[keys[i]] = value;
+ } else {
+ cursor[keys[i]] = cursor[keys[i]] || {};
+ cursor = cursor[keys[i]];
+ }
+ }
+
+ delete userConfig[key];
+}
+
+// Combine user config with default config to form final config
+const finalConfig = Object.assign({}, defaultConfig);
+
+for (const [prop, value] of Object.entries(userConfig)) {
+ if (! defaultConfig.hasOwnProperty(prop)) {
+ throw new Error(`Unknown option: ${prop}`);
+ }
+
+ finalConfig[prop] = value;
+}
+
+// Default knex config
+if (! finalConfig['knex']) {
+ finalConfig['knex'] = {
+ client: 'sqlite',
+ connection: {
+ filename: path.join(finalConfig.dbDir, 'data.sqlite')
+ },
+ useNullAsDefault: true
+ };
+}
+
+// Make sure migration settings are always present in knex config
+Object.assign(finalConfig['knex'], {
+ migrations: {
+ directory: path.join(finalConfig.dbDir, 'migrations')
+ }
+});
+
+if (finalConfig.smallAttachmentLimit > 1024 * 1024 * 8) {
+ finalConfig.smallAttachmentLimit = 1024 * 1024 * 8;
+ console.warn('[WARN] smallAttachmentLimit capped at 8MB');
+}
+
+// Specific checks
+if (finalConfig.attachmentStorage === 'discord' && ! finalConfig.attachmentStorageChannelId) {
+ console.error('Config option \'attachmentStorageChannelId\' is required with attachment storage \'discord\'');
+ process.exit(1);
+}
+
+// Make sure mainGuildId is internally always an array
+if (! Array.isArray(finalConfig['mainGuildId'])) {
+ finalConfig['mainGuildId'] = [finalConfig['mainGuildId']];
+}
+
+// Make sure inboxServerPermission is always an array
+if (! Array.isArray(finalConfig['inboxServerPermission'])) {
+ if (finalConfig['inboxServerPermission'] == null) {
+ finalConfig['inboxServerPermission'] = [];
+ } else {
+ finalConfig['inboxServerPermission'] = [finalConfig['inboxServerPermission']];
+ }
+}
+
+// Move greetingMessage/greetingAttachment to the guildGreetings object internally
+// Or, in other words, if greetingMessage and/or greetingAttachment is set, it is applied for all servers that don't
+// already have something set up in guildGreetings. This retains backwards compatibility while allowing you to override
+// greetings for specific servers in guildGreetings.
+if (finalConfig.greetingMessage || finalConfig.greetingAttachment) {
+ for (const guildId of finalConfig.mainGuildId) {
+ if (finalConfig.guildGreetings[guildId]) continue;
+ finalConfig.guildGreetings[guildId] = {
+ message: finalConfig.greetingMessage,
+ attachment: finalConfig.greetingAttachment
+ };
+ }
+}
+
+// newThreadCategoryId is syntactic sugar for categoryAutomation.newThread
+if (finalConfig.newThreadCategoryId) {
+ finalConfig.categoryAutomation.newThread = finalConfig.newThreadCategoryId;
+ delete finalConfig.newThreadCategoryId;
+}
+
+// Turn empty string options to null (i.e. "option=" without a value in config.ini)
+for (const [key, value] of Object.entries(finalConfig)) {
+ if (value === '') {
+ finalConfig[key] = null;
+ }
+}
+
+// Cast numeric options to numbers
+for (const numericOpt of numericOptions) {
+ if (finalConfig[numericOpt] != null) {
+ const number = parseFloat(finalConfig[numericOpt]);
+ if (Number.isNaN(number)) {
+ console.error(`Invalid numeric value for ${numericOpt}: ${finalConfig[numericOpt]}`);
+ process.exit(1);
+ }
+ finalConfig[numericOpt] = number;
+ }
+}
+
+// Cast boolean options (on, true, 1) (off, false, 0)
+for (const [key, value] of Object.entries(finalConfig)) {
+ if (typeof value !== "string") continue;
+ if (["on", "true", "1"].includes(value)) {
+ finalConfig[key] = true;
+ } else if (["off", "false", "0"].includes(value)) {
+ finalConfig[key] = false;
+ }
+}
+
+// Make sure all of the required config options are present
+for (const opt of required) {
+ if (! finalConfig[opt]) {
+ console.error(`Missing required config.json value: ${opt}`);
+ process.exit(1);
+ }
+}
+
+console.log("Configuration ok!");
+
+module.exports = finalConfig;
diff --git a/src/data/Snippet.js b/src/data/Snippet.js
new file mode 100644
index 0000000..4d5b684
--- /dev/null
+++ b/src/data/Snippet.js
@@ -0,0 +1,15 @@
+const utils = require("../utils");
+
+/**
+ * @property {String} trigger
+ * @property {String} body
+ * @property {String} created_by
+ * @property {String} created_at
+ */
+class Snippet {
+ constructor(props) {
+ utils.setDataModelProps(this, props);
+ }
+}
+
+module.exports = Snippet;
diff --git a/src/data/Thread.js b/src/data/Thread.js
new file mode 100644
index 0000000..23e9e4a
--- /dev/null
+++ b/src/data/Thread.js
@@ -0,0 +1,468 @@
+const moment = require('moment');
+
+const bot = require('../bot');
+const knex = require('../knex');
+const utils = require('../utils');
+const config = require('../config');
+const attachments = require('./attachments');
+
+const ThreadMessage = require('./ThreadMessage');
+
+const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants');
+
+/**
+ * @property {String} id
+ * @property {Number} status
+ * @property {String} user_id
+ * @property {String} user_name
+ * @property {String} channel_id
+ * @property {String} scheduled_close_at
+ * @property {String} scheduled_close_id
+ * @property {String} scheduled_close_name
+ * @property {Number} scheduled_close_silent
+ * @property {String} alert_id
+ * @property {String} created_at
+ */
+class Thread {
+ constructor(props) {
+ utils.setDataModelProps(this, props);
+ }
+
+ /**
+ * @param {Eris~Member} moderator
+ * @param {String} text
+ * @param {Eris~MessageFile[]} replyAttachments
+ * @param {Boolean} isAnonymous
+ * @returns {Promise<boolean>} Whether we were able to send the reply
+ */
+ async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) {
+ // Username to reply with
+ let modUsername, logModUsername;
+ const mainRole = utils.getMainRole(moderator);
+
+ if (isAnonymous) {
+ modUsername = (mainRole ? mainRole.name : 'Moderator');
+ logModUsername = `(Anonymous) (${moderator.user.username}) ${mainRole ? mainRole.name : 'Moderator'}`;
+ } else {
+ const name = (config.useNicknames ? moderator.nick || moderator.user.username : moderator.user.username);
+ modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name);
+ logModUsername = modUsername;
+ }
+
+ // Build the reply message
+ let dmContent = `**${modUsername}:** ${text}`;
+ let threadContent = `**${logModUsername}:** ${text}`;
+ let logContent = text;
+
+ if (config.threadTimestamps) {
+ const timestamp = utils.getTimestamp();
+ threadContent = `[${timestamp}] » ${threadContent}`;
+ }
+
+ // Prepare attachments, if any
+ let files = [];
+
+ if (replyAttachments.length > 0) {
+ for (const attachment of replyAttachments) {
+ let savedAttachment;
+
+ await Promise.all([
+ attachments.attachmentToDiscordFileObject(attachment).then(file => {
+ files.push(file);
+ }),
+ attachments.saveAttachment(attachment).then(result => {
+ savedAttachment = result;
+ })
+ ]);
+
+ logContent += `\n\n**Attachment:** ${savedAttachment.url}`;
+ }
+ }
+
+ // Send the reply DM
+ let dmMessage;
+ try {
+ dmMessage = await this.postToUser(dmContent, files);
+ } catch (e) {
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.COMMAND,
+ user_id: moderator.id,
+ user_name: logModUsername,
+ body: logContent
+ });
+
+ await this.postSystemMessage(`Error while replying to user: ${e.message}`);
+
+ return false;
+ }
+
+ // Send the reply to the modmail thread
+ await this.postToThreadChannel(threadContent, files);
+
+ // Add the message to the database
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.TO_USER,
+ user_id: moderator.id,
+ user_name: logModUsername,
+ body: logContent,
+ is_anonymous: (isAnonymous ? 1 : 0),
+ dm_message_id: dmMessage.id
+ });
+
+ if (this.scheduled_close_at) {
+ await this.cancelScheduledClose();
+ await this.postSystemMessage(`Cancelling scheduled closing of this thread due to new reply`);
+ }
+
+ return true;
+ }
+
+ /**
+ * @param {Eris~Message} msg
+ * @returns {Promise<void>}
+ */
+ async receiveUserReply(msg) {
+ let content = msg.content;
+ if (msg.content.trim() === '' && msg.embeds.length) {
+ content = '<message contains embeds>';
+ }
+
+ let threadContent = `**${msg.author.username}#${msg.author.discriminator}:** ${content}`;
+ let logContent = msg.content;
+
+ if (config.threadTimestamps) {
+ const timestamp = utils.getTimestamp(msg.timestamp, 'x');
+ threadContent = `[${timestamp}] « ${threadContent}`;
+ }
+
+ // Prepare attachments, if any
+ let attachmentFiles = [];
+
+ for (const attachment of msg.attachments) {
+ const savedAttachment = await attachments.saveAttachment(attachment);
+
+ // Forward small attachments (<2MB) as attachments, just link to larger ones
+ const formatted = '\n\n' + await utils.formatAttachment(attachment, savedAttachment.url);
+ logContent += formatted; // Logs always contain the link
+
+ if (config.relaySmallAttachmentsAsAttachments && attachment.size <= 1024 * 1024 * 2) {
+ const file = await attachments.attachmentToDiscordFileObject(attachment);
+ attachmentFiles.push(file);
+ } else {
+ threadContent += formatted;
+ }
+ }
+
+ await this.postToThreadChannel(threadContent, attachmentFiles);
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.FROM_USER,
+ user_id: this.user_id,
+ user_name: `${msg.author.username}#${msg.author.discriminator}`,
+ body: logContent,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+
+ if (this.scheduled_close_at) {
+ await this.cancelScheduledClose();
+ await this.postSystemMessage(`<@!${this.scheduled_close_id}> Thread that was scheduled to be closed got a new reply. Cancelling.`);
+ }
+
+ if (this.alert_id) {
+ await this.setAlert(null);
+ await this.postSystemMessage(`<@!${this.alert_id}> New message from ${this.user_name}`);
+ }
+ }
+
+ /**
+ * @returns {Promise<PrivateChannel>}
+ */
+ getDMChannel() {
+ return bot.getDMChannel(this.user_id);
+ }
+
+ /**
+ * @param {String} text
+ * @param {Eris~MessageFile|Eris~MessageFile[]} file
+ * @returns {Promise<Eris~Message>}
+ * @throws Error
+ */
+ async postToUser(text, file = null) {
+ // Try to open a DM channel with the user
+ const dmChannel = await this.getDMChannel();
+ if (! dmChannel) {
+ throw new Error('Could not open DMs with the user. They may have blocked the bot or set their privacy settings higher.');
+ }
+
+ // Send the DM
+ const chunks = utils.chunk(text, 2000);
+ const messages = await Promise.all(chunks.map((chunk, i) => {
+ return dmChannel.createMessage(
+ chunk,
+ (i === chunks.length - 1 ? file : undefined) // Only send the file with the last message
+ );
+ }));
+ return messages[0];
+ }
+
+ /**
+ * @returns {Promise<Eris~Message>}
+ */
+ async postToThreadChannel(...args) {
+ try {
+ if (typeof args[0] === 'string') {
+ const chunks = utils.chunk(args[0], 2000);
+ const messages = await Promise.all(chunks.map((chunk, i) => {
+ const rest = (i === chunks.length - 1 ? args.slice(1) : []); // Only send the rest of the args (files, embeds) with the last message
+ return bot.createMessage(this.channel_id, chunk, ...rest);
+ }));
+ return messages[0];
+ } else {
+ return bot.createMessage(this.channel_id, ...args);
+ }
+ } catch (e) {
+ // Channel not found
+ if (e.code === 10003) {
+ console.log(`[INFO] Failed to send message to thread channel for ${this.user_name} because the channel no longer exists. Auto-closing the thread.`);
+ this.close(true);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * @param {String} text
+ * @param {*} args
+ * @returns {Promise<void>}
+ */
+ async postSystemMessage(text, ...args) {
+ const msg = await this.postToThreadChannel(text, ...args);
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.SYSTEM,
+ user_id: null,
+ user_name: '',
+ body: typeof text === 'string' ? text : text.content,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+ }
+
+ /**
+ * @param {*} args
+ * @returns {Promise<void>}
+ */
+ async postNonLogMessage(...args) {
+ await this.postToThreadChannel(...args);
+ }
+
+ /**
+ * @param {Eris.Message} msg
+ * @returns {Promise<void>}
+ */
+ async saveChatMessage(msg) {
+ return this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.CHAT,
+ user_id: msg.author.id,
+ user_name: `${msg.author.username}#${msg.author.discriminator}`,
+ body: msg.content,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+ }
+
+ async saveCommandMessage(msg) {
+ return this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.COMMAND,
+ user_id: msg.author.id,
+ user_name: `${msg.author.username}#${msg.author.discriminator}`,
+ body: msg.content,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+ }
+
+ /**
+ * @param {Eris.Message} msg
+ * @returns {Promise<void>}
+ */
+ async updateChatMessage(msg) {
+ await knex('thread_messages')
+ .where('thread_id', this.id)
+ .where('dm_message_id', msg.id)
+ .update({
+ body: msg.content
+ });
+ }
+
+ /**
+ * @param {String} messageId
+ * @returns {Promise<void>}
+ */
+ async deleteChatMessage(messageId) {
+ await knex('thread_messages')
+ .where('thread_id', this.id)
+ .where('dm_message_id', messageId)
+ .delete();
+ }
+
+ /**
+ * @param {Object} data
+ * @returns {Promise<void>}
+ */
+ async addThreadMessageToDB(data) {
+ await knex('thread_messages').insert({
+ thread_id: this.id,
+ created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'),
+ is_anonymous: 0,
+ ...data
+ });
+ }
+
+ /**
+ * @returns {Promise<ThreadMessage[]>}
+ */
+ async getThreadMessages() {
+ const threadMessages = await knex('thread_messages')
+ .where('thread_id', this.id)
+ .orderBy('created_at', 'ASC')
+ .orderBy('id', 'ASC')
+ .select();
+
+ return threadMessages.map(row => new ThreadMessage(row));
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async close(suppressSystemMessage = false, silent = false) {
+ if (! suppressSystemMessage) {
+ console.log(`Closing thread ${this.id}`);
+
+ if (silent) {
+ await this.postSystemMessage('Closing thread silently...');
+ } else {
+ await this.postSystemMessage('Closing thread...');
+ }
+ }
+
+ // Update DB status
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ status: THREAD_STATUS.CLOSED
+ });
+
+ // Delete channel
+ const channel = bot.getChannel(this.channel_id);
+ if (channel) {
+ console.log(`Deleting channel ${this.channel_id}`);
+ await channel.delete('Thread closed');
+ }
+ }
+
+ /**
+ * @param {String} time
+ * @param {Eris~User} user
+ * @param {Number} silent
+ * @returns {Promise<void>}
+ */
+ async scheduleClose(time, user, silent) {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_close_at: time,
+ scheduled_close_id: user.id,
+ scheduled_close_name: user.username,
+ scheduled_close_silent: silent
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async cancelScheduledClose() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_close_at: null,
+ scheduled_close_id: null,
+ scheduled_close_name: null,
+ scheduled_close_silent: null
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async suspend() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ status: THREAD_STATUS.SUSPENDED,
+ scheduled_suspend_at: null,
+ scheduled_suspend_id: null,
+ scheduled_suspend_name: null
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async unsuspend() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ status: THREAD_STATUS.OPEN
+ });
+ }
+
+ /**
+ * @param {String} time
+ * @param {Eris~User} user
+ * @returns {Promise<void>}
+ */
+ async scheduleSuspend(time, user) {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_suspend_at: time,
+ scheduled_suspend_id: user.id,
+ scheduled_suspend_name: user.username
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async cancelScheduledSuspend() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_suspend_at: null,
+ scheduled_suspend_id: null,
+ scheduled_suspend_name: null
+ });
+ }
+
+ /**
+ * @param {String} userId
+ * @returns {Promise<void>}
+ */
+ async setAlert(userId) {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ alert_id: userId
+ });
+ }
+
+ /**
+ * @returns {Promise<String>}
+ */
+ getLogUrl() {
+ return utils.getSelfUrl(`logs/${this.id}`);
+ }
+}
+
+module.exports = Thread;
diff --git a/src/data/ThreadMessage.js b/src/data/ThreadMessage.js
new file mode 100644
index 0000000..2704287
--- /dev/null
+++ b/src/data/ThreadMessage.js
@@ -0,0 +1,20 @@
+const utils = require("../utils");
+
+/**
+ * @property {Number} id
+ * @property {String} thread_id
+ * @property {Number} message_type
+ * @property {String} user_id
+ * @property {String} user_name
+ * @property {String} body
+ * @property {Number} is_anonymous
+ * @property {Number} dm_message_id
+ * @property {String} created_at
+ */
+class ThreadMessage {
+ constructor(props) {
+ utils.setDataModelProps(this, props);
+ }
+}
+
+module.exports = ThreadMessage;
diff --git a/src/data/attachments.js b/src/data/attachments.js
new file mode 100644
index 0000000..40b13b0
--- /dev/null
+++ b/src/data/attachments.js
@@ -0,0 +1,202 @@
+const Eris = require('eris');
+const fs = require('fs');
+const https = require('https');
+const {promisify} = require('util');
+const tmp = require('tmp');
+const config = require('../config');
+const utils = require('../utils');
+const mv = promisify(require('mv'));
+
+const getUtils = () => require('../utils');
+
+const access = promisify(fs.access);
+const readFile = promisify(fs.readFile);
+
+const localAttachmentDir = config.attachmentDir || `${__dirname}/../../attachments`;
+
+const attachmentSavePromises = {};
+
+const attachmentStorageTypes = {};
+
+function getErrorResult(msg = null) {
+ return {
+ url: `Attachment could not be saved${msg ? ': ' + msg : ''}`,
+ failed: true
+ };
+}
+
+/**
+ * Attempts to download and save the given attachement
+ * @param {Object} attachment
+ * @param {Number=0} tries
+ * @returns {Promise<{ url: string }>}
+ */
+async function saveLocalAttachment(attachment) {
+ const targetPath = getLocalAttachmentPath(attachment.id);
+
+ try {
+ // If the file already exists, resolve immediately
+ await access(targetPath);
+ const url = await getLocalAttachmentUrl(attachment.id, attachment.filename);
+ return { url };
+ } catch (e) {}
+
+ // Download the attachment
+ const downloadResult = await downloadAttachment(attachment);
+
+ // Move the temp file to the attachment folder
+ await mv(downloadResult.path, targetPath);
+
+ // Resolve the attachment URL
+ const url = await getLocalAttachmentUrl(attachment.id, attachment.filename);
+
+ return { url };
+}
+
+/**
+ * @param {Object} attachment
+ * @param {Number} tries
+ * @returns {Promise<{ path: string, cleanup: function }>}
+ */
+function downloadAttachment(attachment, tries = 0) {
+ return new Promise((resolve, reject) => {
+ if (tries > 3) {
+ console.error('Attachment download failed after 3 tries:', attachment);
+ reject('Attachment download failed after 3 tries');
+ return;
+ }
+
+ tmp.file((err, filepath, fd, cleanupCallback) => {
+ const writeStream = fs.createWriteStream(filepath);
+
+ https.get(attachment.url, (res) => {
+ res.pipe(writeStream);
+ writeStream.on('finish', () => {
+ writeStream.end();
+ resolve({
+ path: filepath,
+ cleanup: cleanupCallback
+ });
+ });
+ }).on('error', (err) => {
+ fs.unlink(filepath);
+ console.error('Error downloading attachment, retrying');
+ resolve(downloadAttachment(attachment, tries++));
+ });
+ });
+ });
+}
+
+/**
+ * Returns the filesystem path for the given attachment id
+ * @param {String} attachmentId
+ * @returns {String}
+ */
+function getLocalAttachmentPath(attachmentId) {
+ return `${localAttachmentDir}/${attachmentId}`;
+}
+
+/**
+ * Returns the self-hosted URL to the given attachment ID
+ * @param {String} attachmentId
+ * @param {String=null} desiredName Custom name for the attachment as a hint for the browser
+ * @returns {Promise<String>}
+ */
+function getLocalAttachmentUrl(attachmentId, desiredName = null) {
+ if (desiredName == null) desiredName = 'file.bin';
+ return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`);
+}
+
+/**
+ * @param {Object} attachment
+ * @returns {Promise<{ url: string }>}
+ */
+async function saveDiscordAttachment(attachment) {
+ if (attachment.size > 1024 * 1024 * 8) {
+ return getErrorResult('attachment too large (max 8MB)');
+ }
+
+ const attachmentChannelId = config.attachmentStorageChannelId;
+ const inboxGuild = utils.getInboxGuild();
+
+ if (! inboxGuild.channels.has(attachmentChannelId)) {
+ throw new Error('Attachment storage channel not found!');
+ }
+
+ const attachmentChannel = inboxGuild.channels.get(attachmentChannelId);
+ if (! (attachmentChannel instanceof Eris.TextChannel)) {
+ throw new Error('Attachment storage channel must be a text channel!');
+ }
+
+ const file = await attachmentToDiscordFileObject(attachment);
+ const savedAttachment = await createDiscordAttachmentMessage(attachmentChannel, file);
+ if (! savedAttachment) return getErrorResult();
+
+ return { url: savedAttachment.url };
+}
+
+async function createDiscordAttachmentMessage(channel, file, tries = 0) {
+ tries++;
+
+ try {
+ const attachmentMessage = await channel.createMessage(undefined, file);
+ return attachmentMessage.attachments[0];
+ } catch (e) {
+ if (tries > 3) {
+ console.error(`Attachment storage message could not be created after 3 tries: ${e.message}`);
+ return;
+ }
+
+ return createDiscordAttachmentMessage(channel, file, tries);
+ }
+}
+
+/**
+ * Turns the given attachment into a file object that can be sent forward as a new attachment
+ * @param {Object} attachment
+ * @returns {Promise<{file, name: string}>}
+ */
+async function attachmentToDiscordFileObject(attachment) {
+ const downloadResult = await downloadAttachment(attachment);
+ const data = await readFile(downloadResult.path);
+ downloadResult.cleanup();
+ return {file: data, name: attachment.filename};
+}
+
+/**
+ * Saves the given attachment based on the configured storage system
+ * @param {Object} attachment
+ * @returns {Promise<{ url: string }>}
+ */
+function saveAttachment(attachment) {
+ if (attachmentSavePromises[attachment.id]) {
+ return attachmentSavePromises[attachment.id];
+ }
+
+ if (attachmentStorageTypes[config.attachmentStorage]) {
+ attachmentSavePromises[attachment.id] = Promise.resolve(attachmentStorageTypes[config.attachmentStorage](attachment));
+ } else {
+ throw new Error(`Unknown attachment storage option: ${config.attachmentStorage}`);
+ }
+
+ attachmentSavePromises[attachment.id].then(() => {
+ delete attachmentSavePromises[attachment.id];
+ });
+
+ return attachmentSavePromises[attachment.id];
+}
+
+function addStorageType(name, handler) {
+ attachmentStorageTypes[name] = handler;
+}
+
+attachmentStorageTypes.local = saveLocalAttachment;
+attachmentStorageTypes.discord = saveDiscordAttachment;
+
+module.exports = {
+ getLocalAttachmentPath,
+ attachmentToDiscordFileObject,
+ saveAttachment,
+ addStorageType,
+ downloadAttachment
+};
diff --git a/src/data/blocked.js b/src/data/blocked.js
new file mode 100644
index 0000000..81c1214
--- /dev/null
+++ b/src/data/blocked.js
@@ -0,0 +1,94 @@
+const moment = require('moment');
+const knex = require('../knex');
+
+/**
+ * @param {String} userId
+ * @returns {Promise<{ isBlocked: boolean, expiresAt: string }>}
+ */
+async function getBlockStatus(userId) {
+ const row = await knex('blocked_users')
+ .where('user_id', userId)
+ .first();
+
+ return {
+ isBlocked: !! row,
+ expiresAt: row && row.expires_at
+ };
+}
+
+/**
+ * Checks whether userId is blocked
+ * @param {String} userId
+ * @returns {Promise<Boolean>}
+ */
+async function isBlocked(userId) {
+ return (await getBlockStatus(userId)).isBlocked;
+}
+
+/**
+ * Blocks the given userId
+ * @param {String} userId
+ * @param {String} userName
+ * @param {String} blockedBy
+ * @returns {Promise}
+ */
+async function block(userId, userName = '', blockedBy = null, expiresAt = null) {
+ if (await isBlocked(userId)) return;
+
+ return knex('blocked_users')
+ .insert({
+ user_id: userId,
+ user_name: userName,
+ blocked_by: blockedBy,
+ blocked_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'),
+ expires_at: expiresAt
+ });
+}
+
+/**
+ * Unblocks the given userId
+ * @param {String} userId
+ * @returns {Promise}
+ */
+async function unblock(userId) {
+ return knex('blocked_users')
+ .where('user_id', userId)
+ .delete();
+}
+
+/**
+ * Updates the expiry time of the block for the given userId
+ * @param {String} userId
+ * @param {String} expiresAt
+ * @returns {Promise<void>}
+ */
+async function updateExpiryTime(userId, expiresAt) {
+ return knex('blocked_users')
+ .where('user_id', userId)
+ .update({
+ expires_at: expiresAt
+ });
+}
+
+/**
+ * @returns {String[]}
+ */
+async function getExpiredBlocks() {
+ const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
+
+ const blocks = await knex('blocked_users')
+ .whereNotNull('expires_at')
+ .where('expires_at', '<=', now)
+ .select();
+
+ return blocks.map(block => block.user_id);
+}
+
+module.exports = {
+ getBlockStatus,
+ isBlocked,
+ block,
+ unblock,
+ updateExpiryTime,
+ getExpiredBlocks,
+};
diff --git a/src/data/constants.js b/src/data/constants.js
new file mode 100644
index 0000000..13a30e8
--- /dev/null
+++ b/src/data/constants.js
@@ -0,0 +1,55 @@
+module.exports = {
+ THREAD_STATUS: {
+ OPEN: 1,
+ CLOSED: 2,
+ SUSPENDED: 3
+ },
+
+ THREAD_MESSAGE_TYPE: {
+ SYSTEM: 1,
+ CHAT: 2,
+ FROM_USER: 3,
+ TO_USER: 4,
+ LEGACY: 5,
+ COMMAND: 6
+ },
+
+ ACCIDENTAL_THREAD_MESSAGES: [
+ 'ok',
+ 'okay',
+ 'thanks',
+ 'ty',
+ 'k',
+ 'kk',
+ 'thank you',
+ 'thanx',
+ 'thnx',
+ 'thx',
+ 'tnx',
+ 'ok thank you',
+ 'ok thanks',
+ 'ok ty',
+ 'ok thanx',
+ 'ok thnx',
+ 'ok thx',
+ 'ok no problem',
+ 'ok np',
+ 'okay thank you',
+ 'okay thanks',
+ 'okay ty',
+ 'okay thanx',
+ 'okay thnx',
+ 'okay thx',
+ 'okay no problem',
+ 'okay np',
+ 'okey thank you',
+ 'okey thanks',
+ 'okey ty',
+ 'okey thanx',
+ 'okey thnx',
+ 'okey thx',
+ 'okey no problem',
+ 'okey np',
+ 'cheers'
+ ],
+};
diff --git a/src/data/snippets.js b/src/data/snippets.js
new file mode 100644
index 0000000..a95b2b4
--- /dev/null
+++ b/src/data/snippets.js
@@ -0,0 +1,58 @@
+const moment = require('moment');
+const knex = require('../knex');
+const Snippet = require('./Snippet');
+
+/**
+ * @param {String} trigger
+ * @returns {Promise<Snippet>}
+ */
+async function getSnippet(trigger) {
+ const snippet = await knex('snippets')
+ .where('trigger', trigger)
+ .first();
+
+ return (snippet ? new Snippet(snippet) : null);
+}
+
+/**
+ * @param {String} trigger
+ * @param {String} body
+ * @returns {Promise<void>}
+ */
+async function addSnippet(trigger, body, createdBy = 0) {
+ if (await getSnippet(trigger)) return;
+
+ return knex('snippets').insert({
+ trigger,
+ body,
+ created_by: createdBy,
+ created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
+ });
+}
+
+/**
+ * @param {String} trigger
+ * @returns {Promise<void>}
+ */
+async function deleteSnippet(trigger) {
+ return knex('snippets')
+ .where('trigger', trigger)
+ .delete();
+}
+
+/**
+ * @returns {Promise<Snippet[]>}
+ */
+async function getAllSnippets() {
+ const snippets = await knex('snippets')
+ .select();
+
+ return snippets.map(s => new Snippet(s));
+}
+
+module.exports = {
+ get: getSnippet,
+ add: addSnippet,
+ del: deleteSnippet,
+ all: getAllSnippets,
+};
diff --git a/src/data/threads.js b/src/data/threads.js
new file mode 100644
index 0000000..6e700b0
--- /dev/null
+++ b/src/data/threads.js
@@ -0,0 +1,381 @@
+const {User, Member} = require('eris');
+
+const transliterate = require('transliteration');
+const moment = require('moment');
+const uuid = require('uuid');
+const humanizeDuration = require('humanize-duration');
+
+const bot = require('../bot');
+const knex = require('../knex');
+const config = require('../config');
+const utils = require('../utils');
+const updates = require('./updates');
+
+const Thread = require('./Thread');
+const {THREAD_STATUS} = require('./constants');
+
+const MINUTES = 60 * 1000;
+const HOURS = 60 * MINUTES;
+
+/**
+ * @param {String} id
+ * @returns {Promise<Thread>}
+ */
+async function findById(id) {
+ const thread = await knex('threads')
+ .where('id', id)
+ .first();
+
+ return (thread ? new Thread(thread) : null);
+}
+
+/**
+ * @param {String} userId
+ * @returns {Promise<Thread>}
+ */
+async function findOpenThreadByUserId(userId) {
+ const thread = await knex('threads')
+ .where('user_id', userId)
+ .where('status', THREAD_STATUS.OPEN)
+ .first();
+
+ return (thread ? new Thread(thread) : null);
+}
+
+function getHeaderGuildInfo(member) {
+ return {
+ nickname: member.nick || member.user.username,
+ joinDate: humanizeDuration(Date.now() - member.joinedAt, {largest: 2, round: true})
+ };
+}
+
+/**
+ * Creates a new modmail thread for the specified user
+ * @param {User} user
+ * @param {Member} member
+ * @param {Boolean} quiet If true, doesn't ping mentionRole or reply with responseMessage
+ * @returns {Promise<Thread|undefined>}
+ * @throws {Error}
+ */
+async function createNewThreadForUser(user, quiet = false, ignoreRequirements = false) {
+ const existingThread = await findOpenThreadByUserId(user.id);
+ if (existingThread) {
+ throw new Error('Attempted to create a new thread for a user with an existing open thread!');
+ }
+
+ // If set in config, check that the user's account is old enough (time since they registered on Discord)
+ // If the account is too new, don't start a new thread and optionally reply to them with a message
+ if (config.requiredAccountAge && ! ignoreRequirements) {
+ if (user.createdAt > moment() - config.requiredAccountAge * HOURS){
+ if (config.accountAgeDeniedMessage) {
+ const accountAgeDeniedMessage = utils.readMultilineConfigValue(config.accountAgeDeniedMessage);
+ const privateChannel = await user.getDMChannel();
+ await privateChannel.createMessage(accountAgeDeniedMessage);
+ }
+ return;
+ }
+ }
+
+ // Find which main guilds this user is part of
+ const mainGuilds = utils.getMainGuilds();
+ const userGuildData = new Map();
+
+ for (const guild of mainGuilds) {
+ let member = guild.members.get(user.id);
+
+ if (! member) {
+ try {
+ member = await bot.getRESTGuildMember(guild.id, user.id);
+ } catch (e) {
+ continue;
+ }
+ }
+
+ if (member) {
+ userGuildData.set(guild.id, { guild, member });
+ }
+ }
+
+ // If set in config, check that the user has been a member of one of the main guilds long enough
+ // If they haven't, don't start a new thread and optionally reply to them with a message
+ if (config.requiredTimeOnServer && ! ignoreRequirements) {
+ // Check if the user joined any of the main servers a long enough time ago
+ // If we don't see this user on any of the main guilds (the size check below), assume we're just missing some data and give the user the benefit of the doubt
+ const isAllowed = userGuildData.size === 0 || Array.from(userGuildData.values()).some(({guild, member}) => {
+ return member.joinedAt < moment() - config.requiredTimeOnServer * MINUTES;
+ });
+
+ if (! isAllowed) {
+ if (config.timeOnServerDeniedMessage) {
+ const timeOnServerDeniedMessage = utils.readMultilineConfigValue(config.timeOnServerDeniedMessage);
+ const privateChannel = await user.getDMChannel();
+ await privateChannel.createMessage(timeOnServerDeniedMessage);
+ }
+ return;
+ }
+ }
+
+ // Use the user's name+discrim for the thread channel's name
+ // Channel names are particularly picky about what characters they allow, so we gotta do some clean-up
+ let cleanName = transliterate.slugify(user.username);
+ if (cleanName === '') cleanName = 'unknown';
+ cleanName = cleanName.slice(0, 95); // Make sure the discrim fits
+
+ const channelName = `${cleanName}-${user.discriminator}`;
+
+ console.log(`[NOTE] Creating new thread channel ${channelName}`);
+
+ // Figure out which category we should place the thread channel in
+ let newThreadCategoryId;
+
+ if (config.categoryAutomation.newThreadFromGuild) {
+ // Categories for specific source guilds (in case of multiple main guilds)
+ for (const [guildId, categoryId] of Object.entries(config.categoryAutomation.newThreadFromGuild)) {
+ if (userGuildData.has(guildId)) {
+ newThreadCategoryId = categoryId;
+ break;
+ }
+ }
+ }
+
+ if (! newThreadCategoryId && config.categoryAutomation.newThread) {
+ // Blanket category id for all new threads (also functions as a fallback for the above)
+ newThreadCategoryId = config.categoryAutomation.newThread;
+ }
+
+ // Attempt to create the inbox channel for this thread
+ let createdChannel;
+ try {
+ createdChannel = await utils.getInboxGuild().createChannel(channelName, null, 'New Modmail thread', newThreadCategoryId);
+ } catch (err) {
+ console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
+ throw err;
+ }
+
+ // Save the new thread in the database
+ const newThreadId = await createThreadInDB({
+ status: THREAD_STATUS.OPEN,
+ user_id: user.id,
+ user_name: `${user.username}#${user.discriminator}`,
+ channel_id: createdChannel.id,
+ created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
+ });
+
+ const newThread = await findById(newThreadId);
+ let responseMessageError = null;
+
+ if (! quiet) {
+ // Ping moderators of the new thread
+ if (config.mentionRole) {
+ await newThread.postNonLogMessage({
+ content: `${utils.getInboxMention()}New modmail thread (${newThread.user_name})`,
+ disableEveryone: false
+ });
+ }
+
+ // Send auto-reply to the user
+ if (config.responseMessage) {
+ const responseMessage = utils.readMultilineConfigValue(config.responseMessage);
+
+ try {
+ await newThread.postToUser(responseMessage);
+ } catch (err) {
+ responseMessageError = err;
+ }
+ }
+ }
+
+ // Post some info to the beginning of the new thread
+ const infoHeaderItems = [];
+
+ // Account age
+ const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2, round: true});
+ infoHeaderItems.push(`ACCOUNT AGE **${accountAge}**`);
+
+ // User id (and mention, if enabled)
+ if (config.mentionUserInThreadHeader) {
+ infoHeaderItems.push(`ID **${user.id}** (<@!${user.id}>)`);
+ } else {
+ infoHeaderItems.push(`ID **${user.id}**`);
+ }
+
+ let infoHeader = infoHeaderItems.join(', ');
+
+ // Guild member info
+ for (const [guildId, guildData] of userGuildData.entries()) {
+ const {nickname, joinDate} = getHeaderGuildInfo(guildData.member);
+ const headerItems = [
+ `NICKNAME **${utils.escapeMarkdown(nickname)}**`,
+ `JOINED **${joinDate}** ago`
+ ];
+
+ if (guildData.member.voiceState.channelID) {
+ const voiceChannel = guildData.guild.channels.get(guildData.member.voiceState.channelID);
+ if (voiceChannel) {
+ headerItems.push(`VOICE CHANNEL **${utils.escapeMarkdown(voiceChannel.name)}**`);
+ }
+ }
+
+ if (config.rolesInThreadHeader && guildData.member.roles.length) {
+ const roles = guildData.member.roles.map(roleId => guildData.guild.roles.get(roleId)).filter(Boolean);
+ headerItems.push(`ROLES **${roles.map(r => r.name).join(', ')}**`);
+ }
+
+ const headerStr = headerItems.join(', ');
+
+ if (mainGuilds.length === 1) {
+ infoHeader += `\n${headerStr}`;
+ } else {
+ infoHeader += `\n**[${utils.escapeMarkdown(guildData.guild.name)}]** ${headerStr}`;
+ }
+ }
+
+ // Modmail history / previous logs
+ const userLogCount = await getClosedThreadCountByUserId(user.id);
+ if (userLogCount > 0) {
+ infoHeader += `\n\nThis user has **${userLogCount}** previous modmail threads. Use \`${config.prefix}logs\` to see them.`;
+ }
+
+ infoHeader += '\n────────────────';
+
+ await newThread.postSystemMessage(infoHeader);
+
+ if (config.updateNotifications) {
+ const availableUpdate = await updates.getAvailableUpdate();
+ if (availableUpdate) {
+ await newThread.postNonLogMessage(`📣 New bot version available (${availableUpdate})`);
+ }
+ }
+
+ // If there were errors sending a response to the user, note that
+ if (responseMessageError) {
+ await newThread.postSystemMessage(`**NOTE:** Could not send auto-response to the user. The error given was: \`${responseMessageError.message}\``);
+ }
+
+ // Return the thread
+ return newThread;
+}
+
+/**
+ * Creates a new thread row in the database
+ * @param {Object} data
+ * @returns {Promise<String>} The ID of the created thread
+ */
+async function createThreadInDB(data) {
+ const threadId = uuid.v4();
+ const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
+ const finalData = Object.assign({created_at: now, is_legacy: 0}, data, {id: threadId});
+
+ await knex('threads').insert(finalData);
+
+ return threadId;
+}
+
+/**
+ * @param {String} channelId
+ * @returns {Promise<Thread>}
+ */
+async function findByChannelId(channelId) {
+ const thread = await knex('threads')
+ .where('channel_id', channelId)
+ .first();
+
+ return (thread ? new Thread(thread) : null);
+}
+
+/**
+ * @param {String} channelId
+ * @returns {Promise<Thread>}
+ */
+async function findOpenThreadByChannelId(channelId) {
+ const thread = await knex('threads')
+ .where('channel_id', channelId)
+ .where('status', THREAD_STATUS.OPEN)
+ .first();
+
+ return (thread ? new Thread(thread) : null);
+}
+
+/**
+ * @param {String} channelId
+ * @returns {Promise<Thread>}
+ */
+async function findSuspendedThreadByChannelId(channelId) {
+ const thread = await knex('threads')
+ .where('channel_id', channelId)
+ .where('status', THREAD_STATUS.SUSPENDED)
+ .first();
+
+ return (thread ? new Thread(thread) : null);
+}
+
+/**
+ * @param {String} userId
+ * @returns {Promise<Thread[]>}
+ */
+async function getClosedThreadsByUserId(userId) {
+ const threads = await knex('threads')
+ .where('status', THREAD_STATUS.CLOSED)
+ .where('user_id', userId)
+ .select();
+
+ return threads.map(thread => new Thread(thread));
+}
+
+/**
+ * @param {String} userId
+ * @returns {Promise<number>}
+ */
+async function getClosedThreadCountByUserId(userId) {
+ const row = await knex('threads')
+ .where('status', THREAD_STATUS.CLOSED)
+ .where('user_id', userId)
+ .first(knex.raw('COUNT(id) AS thread_count'));
+
+ return parseInt(row.thread_count, 10);
+}
+
+async function findOrCreateThreadForUser(user) {
+ const existingThread = await findOpenThreadByUserId(user.id);
+ if (existingThread) return existingThread;
+
+ return createNewThreadForUser(user);
+}
+
+async function getThreadsThatShouldBeClosed() {
+ const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
+ const threads = await knex('threads')
+ .where('status', THREAD_STATUS.OPEN)
+ .whereNotNull('scheduled_close_at')
+ .where('scheduled_close_at', '<=', now)
+ .whereNotNull('scheduled_close_at')
+ .select();
+
+ return threads.map(thread => new Thread(thread));
+}
+
+async function getThreadsThatShouldBeSuspended() {
+ const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
+ const threads = await knex('threads')
+ .where('status', THREAD_STATUS.OPEN)
+ .whereNotNull('scheduled_suspend_at')
+ .where('scheduled_suspend_at', '<=', now)
+ .whereNotNull('scheduled_suspend_at')
+ .select();
+
+ return threads.map(thread => new Thread(thread));
+}
+
+module.exports = {
+ findById,
+ findOpenThreadByUserId,
+ findByChannelId,
+ findOpenThreadByChannelId,
+ findSuspendedThreadByChannelId,
+ createNewThreadForUser,
+ getClosedThreadsByUserId,
+ findOrCreateThreadForUser,
+ getThreadsThatShouldBeClosed,
+ getThreadsThatShouldBeSuspended,
+ createThreadInDB
+};
diff --git a/src/data/updates.js b/src/data/updates.js
new file mode 100644
index 0000000..a57bc62
--- /dev/null
+++ b/src/data/updates.js
@@ -0,0 +1,115 @@
+const url = require('url');
+const https = require('https');
+const moment = require('moment');
+const knex = require('../knex');
+const config = require('../config');
+
+const UPDATE_CHECK_FREQUENCY = 12; // In hours
+let updateCheckPromise = null;
+
+async function initUpdatesTable() {
+ const row = await knex('updates').first();
+ if (! row) {
+ await knex('updates').insert({
+ available_version: null,
+ last_checked: null,
+ });
+ }
+}
+
+/**
+ * Update current and available versions in the database.
+ * Only works when `repository` in package.json is set to a GitHub repository
+ * @returns {Promise<void>}
+ */
+async function refreshVersions() {
+ await initUpdatesTable();
+ const { last_checked } = await knex('updates').first();
+
+ // Only refresh available version if it's been more than UPDATE_CHECK_FREQUENCY since our last check
+ if (last_checked != null && last_checked > moment.utc().subtract(UPDATE_CHECK_FREQUENCY, 'hours').format('YYYY-MM-DD HH:mm:ss')) return;
+
+ const packageJson = require('../../package.json');
+ const repositoryUrl = packageJson.repository && packageJson.repository.url;
+ if (! repositoryUrl) return;
+
+ const parsedUrl = url.parse(repositoryUrl);
+ if (parsedUrl.hostname !== 'github.com') return;
+
+ const [, owner, repo] = parsedUrl.pathname.split('/');
+ if (! owner || ! repo) return;
+
+ https.get(
+ {
+ hostname: 'api.github.com',
+ path: `/repos/${owner}/${repo}/tags`,
+ headers: {
+ 'User-Agent': `Modmail Bot (https://github.com/${owner}/${repo}) (${packageJson.version})`
+ }
+ },
+ async res => {
+ if (res.statusCode !== 200) {
+ await knex('updates').update({
+ last_checked: moment.utc().format('YYYY-MM-DD HH:mm:ss')
+ });
+ console.warn(`[WARN] Got status code ${res.statusCode} when checking for available updates`);
+ return;
+ }
+
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', async () => {
+ const parsed = JSON.parse(data);
+ if (! Array.isArray(parsed) || parsed.length === 0) return;
+
+ const latestVersion = parsed[0].name;
+ await knex('updates').update({
+ available_version: latestVersion,
+ last_checked: moment.utc().format('YYYY-MM-DD HH:mm:ss')
+ });
+ });
+ }
+ );
+}
+
+/**
+ * @param {String} a Version string, e.g. "2.20.0"
+ * @param {String} b Version string, e.g. "2.20.0"
+ * @returns {Number} 1 if version a is larger than b, -1 is version a is smaller than b, 0 if they are equal
+ */
+function compareVersions(a, b) {
+ const aParts = a.split('.');
+ const bParts = b.split('.');
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
+ let aPart = parseInt((aParts[i] || '0').match(/\d+/)[0] || '0', 10);
+ let bPart = parseInt((bParts[i] || '0').match(/\d+/)[0] || '0', 10);
+ if (aPart > bPart) return 1;
+ if (aPart < bPart) return -1;
+ }
+ return 0;
+}
+
+async function getAvailableUpdate() {
+ await initUpdatesTable();
+
+ const packageJson = require('../../package.json');
+ const currentVersion = packageJson.version;
+ const { available_version: availableVersion } = await knex('updates').first();
+ if (availableVersion == null) return null;
+ if (currentVersion == null) return availableVersion;
+
+ const versionDiff = compareVersions(currentVersion, availableVersion);
+ if (versionDiff === -1) return availableVersion;
+
+ return null;
+}
+
+async function refreshVersionsLoop() {
+ await refreshVersions();
+ setTimeout(refreshVersionsLoop, UPDATE_CHECK_FREQUENCY * 60 * 60 * 1000);
+}
+
+module.exports = {
+ getAvailableUpdate,
+ startVersionRefreshLoop: refreshVersionsLoop
+};
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..dca80ac
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,93 @@
+// Verify NodeJS version
+const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10);
+if (nodeMajorVersion < 10) {
+ console.error('Unsupported NodeJS version! Please install NodeJS 10 or newer.');
+ process.exit(1);
+}
+
+// Verify node modules have been installed
+const fs = require('fs');
+const path = require('path');
+
+try {
+ fs.accessSync(path.join(__dirname, '..', 'node_modules'));
+} catch (e) {
+ console.error('Please run "npm install" before starting the bot');
+ process.exit(1);
+}
+
+// Error handling
+process.on('uncaughtException', err => {
+ // Unknown message types (nitro boosting messages at the time) should be safe to ignore
+ if (err && err.message && err.message.startsWith('Unhandled MESSAGE_CREATE type')) {
+ return;
+ }
+
+ // For everything else, crash with the error
+ console.error(err);
+ process.exit(1);
+});
+
+let testedPackage = '';
+try {
+ const packageJson = require('../package.json');
+ const modules = Object.keys(packageJson.dependencies);
+ modules.forEach(mod => {
+ testedPackage = mod;
+ fs.accessSync(path.join(__dirname, '..', 'node_modules', mod))
+ });
+} catch (e) {
+ console.error(`Please run "npm install" again! Package "${testedPackage}" is missing.`);
+ process.exit(1);
+}
+
+const config = require('./config');
+const utils = require('./utils');
+const main = require('./main');
+const knex = require('./knex');
+const legacyMigrator = require('./legacy/legacyMigrator');
+
+// Force crash on unhandled rejections (use something like forever/pm2 to restart)
+process.on('unhandledRejection', err => {
+ if (err instanceof utils.BotError || (err && err.code)) {
+ // We ignore stack traces for BotErrors (the message has enough info) and network errors from Eris (their stack traces are unreadably long)
+ console.error(`Error: ${err.message}`);
+ } else {
+ console.error(err);
+ }
+
+ process.exit(1);
+});
+
+(async function() {
+ // Make sure the database is up to date
+ await knex.migrate.latest();
+
+ // Migrate legacy data if we need to
+ if (await legacyMigrator.shouldMigrate()) {
+ console.log('=== MIGRATING LEGACY DATA ===');
+ console.log('Do not close the bot!');
+ console.log('');
+
+ await legacyMigrator.migrate();
+
+ const relativeDbDir = (path.isAbsolute(config.dbDir) ? config.dbDir : path.resolve(process.cwd(), config.dbDir));
+ const relativeLogDir = (path.isAbsolute(config.logDir) ? config.logDir : path.resolve(process.cwd(), config.logDir));
+
+ console.log('');
+ console.log('=== LEGACY DATA MIGRATION FINISHED ===');
+ console.log('');
+ console.log('IMPORTANT: After the bot starts, please verify that all logs, threads, blocked users, and snippets are still working correctly.');
+ console.log('Once you\'ve done that, the following files/directories are no longer needed. I would recommend keeping a backup of them, however.');
+ console.log('');
+ console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json'));
+ console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json'));
+ console.log('FILE: ' + path.resolve(relativeDbDir, 'snippets.json'));
+ console.log('DIRECTORY: ' + relativeLogDir);
+ console.log('');
+ console.log('Starting the bot...');
+ }
+
+ // Start the bot
+ main.start();
+})();
diff --git a/src/knex.js b/src/knex.js
new file mode 100644
index 0000000..b6b6346
--- /dev/null
+++ b/src/knex.js
@@ -0,0 +1,2 @@
+const config = require('./config');
+module.exports = require('knex')(config.knex);
diff --git a/src/legacy/jsonDb.js b/src/legacy/jsonDb.js
new file mode 100644
index 0000000..d2e8ca1
--- /dev/null
+++ b/src/legacy/jsonDb.js
@@ -0,0 +1,71 @@
+const fs = require('fs');
+const path = require('path');
+const config = require('../config');
+
+const dbDir = config.dbDir;
+
+const databases = {};
+
+/**
+ * @deprecated Only used for migrating legacy data
+ */
+class JSONDB {
+ constructor(path, def = {}, useCloneByDefault = false) {
+ this.path = path;
+ this.useCloneByDefault = useCloneByDefault;
+
+ this.load = new Promise(resolve => {
+ fs.readFile(path, {encoding: 'utf8'}, (err, data) => {
+ if (err) return resolve(def);
+
+ let unserialized;
+ try { unserialized = JSON.parse(data); }
+ catch (e) { unserialized = def; }
+
+ resolve(unserialized);
+ });
+ });
+ }
+
+ get(clone) {
+ if (clone == null) clone = this.useCloneByDefault;
+
+ return this.load.then(data => {
+ if (clone) return JSON.parse(JSON.stringify(data));
+ else return data;
+ });
+ }
+
+ save(newData) {
+ const serialized = JSON.stringify(newData);
+ this.load = new Promise((resolve, reject) => {
+ fs.writeFile(this.path, serialized, {encoding: 'utf8'}, () => {
+ resolve(newData);
+ });
+ });
+
+ return this.get();
+ }
+}
+
+function getDb(dbName, def) {
+ if (! databases[dbName]) {
+ const dbPath = path.resolve(dbDir, `${dbName}.json`);
+ databases[dbName] = new JSONDB(dbPath, def);
+ }
+
+ return databases[dbName];
+}
+
+function get(dbName, def) {
+ return getDb(dbName, def).get();
+}
+
+function save(dbName, data) {
+ return getDb(dbName, data).save(data);
+}
+
+module.exports = {
+ get,
+ save,
+};
diff --git a/src/legacy/legacyMigrator.js b/src/legacy/legacyMigrator.js
new file mode 100644
index 0000000..2c491e3
--- /dev/null
+++ b/src/legacy/legacyMigrator.js
@@ -0,0 +1,222 @@
+const fs = require('fs');
+const path = require('path');
+const promisify = require('util').promisify;
+const moment = require('moment');
+const Eris = require('eris');
+
+const knex = require('../knex');
+const config = require('../config');
+const jsonDb = require('./jsonDb');
+const threads = require('../data/threads');
+
+const {THREAD_STATUS, THREAD_MESSAGE_TYPE} = require('../data/constants');
+
+const readDir = promisify(fs.readdir);
+const readFile = promisify(fs.readFile);
+const access = promisify(fs.access);
+const writeFile = promisify(fs.writeFile);
+
+async function migrate() {
+ console.log('Migrating open threads...');
+ await migrateOpenThreads();
+
+ console.log('Migrating logs...');
+ await migrateLogs();
+
+ console.log('Migrating blocked users...');
+ await migrateBlockedUsers();
+
+ console.log('Migrating snippets...');
+ await migrateSnippets();
+
+ await writeFile(path.join(config.dbDir, '.migrated_legacy'), '');
+}
+
+async function shouldMigrate() {
+ // If there is a file marking a finished migration, assume we don't need to migrate
+ const migrationFile = path.join(config.dbDir, '.migrated_legacy');
+ try {
+ await access(migrationFile);
+ return false;
+ } catch (e) {}
+
+ // If there are any old threads, we need to migrate
+ const oldThreads = await jsonDb.get('threads', []);
+ if (oldThreads.length) {
+ return true;
+ }
+
+ // If there are any old blocked users, we need to migrate
+ const blockedUsers = await jsonDb.get('blocked', []);
+ if (blockedUsers.length) {
+ return true;
+ }
+
+ // If there are any old snippets, we need to migrate
+ const snippets = await jsonDb.get('snippets', {});
+ if (Object.keys(snippets).length) {
+ return true;
+ }
+
+ // If the log file dir exists and has logs in it, we need to migrate
+ try {
+ const files = await readDir(config.logDir);
+ if (files.length > 1) return true; // > 1, since .gitignore is one of them
+ } catch(e) {}
+
+ return false;
+}
+
+async function migrateOpenThreads() {
+ const bot = new Eris.Client(config.token);
+
+ const toReturn = new Promise(resolve => {
+ bot.on('ready', async () => {
+ const oldThreads = await jsonDb.get('threads', []);
+
+ const promises = oldThreads.map(async oldThread => {
+ const existingOpenThread = await knex('threads')
+ .where('channel_id', oldThread.channelId)
+ .first();
+
+ if (existingOpenThread) return;
+
+ const oldChannel = bot.getChannel(oldThread.channelId);
+ if (! oldChannel) return;
+
+ const threadMessages = await oldChannel.getMessages(1000);
+ const log = threadMessages.reverse().map(msg => {
+ const date = moment.utc(msg.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss');
+ return `[${date}] ${msg.author.username}#${msg.author.discriminator}: ${msg.content}`;
+ }).join('\n') + '\n';
+
+ const newThread = {
+ status: THREAD_STATUS.OPEN,
+ user_id: oldThread.userId,
+ user_name: oldThread.username,
+ channel_id: oldThread.channelId,
+ is_legacy: 1
+ };
+
+ const threadId = await threads.createThreadInDB(newThread);
+
+ await knex('thread_messages').insert({
+ thread_id: threadId,
+ message_type: THREAD_MESSAGE_TYPE.LEGACY,
+ user_id: oldThread.userId,
+ user_name: '',
+ body: log,
+ is_anonymous: 0,
+ created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss')
+ });
+ });
+
+ resolve(Promise.all(promises));
+ });
+
+ bot.connect();
+ });
+
+ await toReturn;
+
+ bot.disconnect();
+}
+
+async function migrateLogs() {
+ const logDir = config.logDir || `${__dirname}/../../logs`;
+ const logFiles = await readDir(logDir);
+
+ for (let i = 0; i < logFiles.length; i++) {
+ const logFile = logFiles[i];
+ if (! logFile.endsWith('.txt')) continue;
+
+ const [rawDate, userId, threadId] = logFile.slice(0, -4).split('__');
+ const date = `${rawDate.slice(0, 10)} ${rawDate.slice(11).replace('-', ':')}`;
+
+ const fullPath = path.join(logDir, logFile);
+ const contents = await readFile(fullPath, {encoding: 'utf8'});
+
+ const newThread = {
+ id: threadId,
+ status: THREAD_STATUS.CLOSED,
+ user_id: userId,
+ user_name: '',
+ channel_id: null,
+ is_legacy: 1,
+ created_at: date
+ };
+
+ await knex.transaction(async trx => {
+ const existingThread = await trx('threads')
+ .where('id', newThread.id)
+ .first();
+
+ if (existingThread) return;
+
+ await trx('threads').insert(newThread);
+
+ await trx('thread_messages').insert({
+ thread_id: newThread.id,
+ message_type: THREAD_MESSAGE_TYPE.LEGACY,
+ user_id: userId,
+ user_name: '',
+ body: contents,
+ is_anonymous: 0,
+ created_at: date
+ });
+ });
+
+ // Progress indicator for servers with tons of logs
+ if ((i + 1) % 500 === 0) {
+ console.log(` ${i + 1}...`);
+ }
+ }
+}
+
+async function migrateBlockedUsers() {
+ const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
+ const blockedUsers = await jsonDb.get('blocked', []);
+
+ for (const userId of blockedUsers) {
+ const existingBlockedUser = await knex('blocked_users')
+ .where('user_id', userId)
+ .first();
+
+ if (existingBlockedUser) return;
+
+ await knex('blocked_users').insert({
+ user_id: userId,
+ user_name: '',
+ blocked_by: null,
+ blocked_at: now
+ });
+ }
+}
+
+async function migrateSnippets() {
+ const now = moment.utc().format('YYYY-MM-DD HH:mm:ss');
+ const snippets = await jsonDb.get('snippets', {});
+
+ const promises = Object.entries(snippets).map(async ([trigger, data]) => {
+ const existingSnippet = await knex('snippets')
+ .where('trigger', trigger)
+ .first();
+
+ if (existingSnippet) return;
+
+ return knex('snippets').insert({
+ trigger,
+ body: data.text,
+ is_anonymous: data.isAnonymous ? 1 : 0,
+ created_by: null,
+ created_at: now
+ });
+ });
+
+ return Promise.all(promises);
+}
+
+module.exports = {
+ migrate,
+ shouldMigrate,
+};
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..b8ef515
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,281 @@
+const Eris = require('eris');
+const path = require('path');
+
+const config = require('./config');
+const bot = require('./bot');
+const knex = require('./knex');
+const {messageQueue} = require('./queue');
+const utils = require('./utils');
+const { createCommandManager } = require('./commands');
+const { getPluginAPI, loadPlugin } = require('./plugins');
+
+const blocked = require('./data/blocked');
+const threads = require('./data/threads');
+const updates = require('./data/updates');
+
+const reply = require('./modules/reply');
+const close = require('./modules/close');
+const snippets = require('./modules/snippets');
+const logs = require('./modules/logs');
+const move = require('./modules/move');
+const block = require('./modules/block');
+const suspend = require('./modules/suspend');
+const webserver = require('./modules/webserver');
+const greeting = require('./modules/greeting');
+const typingProxy = require('./modules/typingProxy');
+const version = require('./modules/version');
+const newthread = require('./modules/newthread');
+const idModule = require('./modules/id');
+const alert = require('./modules/alert');
+
+const {ACCIDENTAL_THREAD_MESSAGES} = require('./data/constants');
+
+module.exports = {
+ async start() {
+ console.log('Connecting to Discord...');
+
+ bot.once('ready', async () => {
+ console.log('Connected! Waiting for guilds to become available...');
+ await Promise.all([
+ ...config.mainGuildId.map(id => waitForGuild(id)),
+ waitForGuild(config.mailGuildId)
+ ]);
+
+ console.log('Initializing...');
+ initStatus();
+ initBaseMessageHandlers();
+ initPlugins();
+
+ console.log('');
+ console.log('Done! Now listening to DMs.');
+ console.log('');
+ });
+
+ bot.connect();
+ }
+};
+
+function waitForGuild(guildId) {
+ if (bot.guilds.has(guildId)) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ bot.on('guildAvailable', guild => {
+ if (guild.id === guildId) {
+ resolve();
+ }
+ });
+ });
+}
+
+function initStatus() {
+ function applyStatus() {
+ bot.editStatus(null, {name: config.status});
+ }
+
+ // Set the bot status initially, then reapply it every hour since in some cases it gets unset
+ applyStatus();
+ setInterval(applyStatus, 60 * 60 * 1000);
+}
+
+function initBaseMessageHandlers() {
+ /**
+ * When a moderator posts in a modmail thread...
+ * 1) If alwaysReply is enabled, reply to the user
+ * 2) If alwaysReply is disabled, save that message as a chat message in the thread
+ */
+ bot.on('messageCreate', async msg => {
+ if (! utils.messageIsOnInboxServer(msg)) return;
+ if (msg.author.bot) return;
+
+ const thread = await threads.findByChannelId(msg.channel.id);
+ if (! thread) return;
+
+ if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) {
+ // Save commands as "command messages"
+ if (msg.content.startsWith(config.snippetPrefix)) return; // Ignore snippets
+ thread.saveCommandMessage(msg);
+ } else if (config.alwaysReply) {
+ // AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies
+ if (! utils.isStaff(msg.member)) return; // Only staff are allowed to reply
+
+ const replied = await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false);
+ if (replied) msg.delete();
+ } else {
+ // Otherwise just save the messages as "chat" in the logs
+ thread.saveChatMessage(msg);
+ }
+ });
+
+ /**
+ * When we get a private message...
+ * 1) Find the open modmail thread for this user, or create a new one
+ * 2) Post the message as a user reply in the thread
+ */
+ bot.on('messageCreate', async msg => {
+ if (! (msg.channel instanceof Eris.PrivateChannel)) return;
+ if (msg.author.bot) return;
+ if (msg.type !== 0) return; // Ignore pins etc.
+
+ if (await blocked.isBlocked(msg.author.id)) return;
+
+ // Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created
+ messageQueue.add(async () => {
+ let thread = await threads.findOpenThreadByUserId(msg.author.id);
+
+
+ // New thread
+ if (! thread) {
+ // Ignore messages that shouldn't usually open new threads, such as "ok", "thanks", etc.
+ if (config.ignoreAccidentalThreads && msg.content && ACCIDENTAL_THREAD_MESSAGES.includes(msg.content.trim().toLowerCase())) return;
+
+ thread = await threads.createNewThreadForUser(msg.author);
+ }
+
+ if (thread) await thread.receiveUserReply(msg);
+ });
+ });
+
+ /**
+ * When a message is edited...
+ * 1) If that message was in DMs, and we have a thread open with that user, post the edit as a system message in the thread
+ * 2) If that message was moderator chatter in the thread, update the corresponding chat message in the DB
+ */
+ bot.on('messageUpdate', async (msg, oldMessage) => {
+ if (! msg || ! msg.author) return;
+ if (msg.author.bot) return;
+ if (await blocked.isBlocked(msg.author.id)) return;
+
+ // Old message content doesn't persist between bot restarts
+ const oldContent = oldMessage && oldMessage.content || '*Unavailable due to bot restart*';
+ const newContent = msg.content;
+
+ // Ignore bogus edit events with no changes
+ if (newContent.trim() === oldContent.trim()) return;
+
+ // 1) Edit in DMs
+ if (msg.channel instanceof Eris.PrivateChannel) {
+ const thread = await threads.findOpenThreadByUserId(msg.author.id);
+ if (! thread) return;
+
+ const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`);
+ thread.postSystemMessage(editMessage);
+ }
+
+ // 2) Edit in the thread
+ else if (utils.messageIsOnInboxServer(msg) && utils.isStaff(msg.member)) {
+ const thread = await threads.findOpenThreadByChannelId(msg.channel.id);
+ if (! thread) return;
+
+ thread.updateChatMessage(msg);
+ }
+ });
+
+ /**
+ * When a staff message is deleted in a modmail thread, delete it from the database as well
+ */
+ bot.on('messageDelete', async msg => {
+ if (! msg.author) return;
+ if (msg.author.bot) return;
+ if (! utils.messageIsOnInboxServer(msg)) return;
+ if (! utils.isStaff(msg.member)) return;
+
+ const thread = await threads.findOpenThreadByChannelId(msg.channel.id);
+ if (! thread) return;
+
+ thread.deleteChatMessage(msg.id);
+ });
+
+ /**
+ * When the bot is mentioned on the main server, ping staff in the log channel about it
+ */
+ bot.on('messageCreate', async msg => {
+ if (! utils.messageIsOnMainServer(msg)) return;
+ if (! msg.mentions.some(user => user.id === bot.user.id)) return;
+ if (msg.author.bot) return;
+
+ if (utils.messageIsOnInboxServer(msg)) {
+ // For same server setups, check if the person who pinged modmail is staff. If so, ignore the ping.
+ if (utils.isStaff(msg.member)) return;
+ } else {
+ // For separate server setups, check if the member is staff on the modmail server
+ const inboxMember = utils.getInboxGuild().members.get(msg.author.id);
+ if (inboxMember && utils.isStaff(inboxMember)) return;
+ }
+
+ // If the person who mentioned the bot is blocked, ignore them
+ if (await blocked.isBlocked(msg.author.id)) return;
+
+ let content;
+ const mainGuilds = utils.getMainGuilds();
+ const staffMention = (config.pingOnBotMention ? utils.getInboxMention() : '');
+
+ if (mainGuilds.length === 1) {
+ content = `${staffMention}Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}(${msg.author.id})**: "${msg.cleanContent}"\n\n<https:\/\/discordapp.com\/channels\/${msg.channel.guild.id}\/${msg.channel.id}\/${msg.id}>`;
+ } else {
+ content = `${staffMention}Bot mentioned in ${msg.channel.mention} (${msg.channel.guild.name}) by **${msg.author.username}#${msg.author.discriminator}(${msg.author.id})**: "${msg.cleanContent}"\n\n<https:\/\/discordapp.com\/channels\/${msg.channel.guild.id}\/${msg.channel.id}\/${msg.id}>`;
+ }
+
+
+ bot.createMessage(utils.getLogChannel().id, {
+ content,
+ disableEveryone: false,
+ });
+
+ // Send an auto-response to the mention, if enabled
+ if (config.botMentionResponse) {
+ const botMentionResponse = utils.readMultilineConfigValue(config.botMentionResponse);
+ bot.createMessage(msg.channel.id, botMentionResponse.replace(/{userMention}/g, `<@${msg.author.id}>`));
+ }
+ });
+}
+
+function initPlugins() {
+ // Initialize command manager
+ const commands = createCommandManager(bot);
+
+ // Register command aliases
+ if (config.commandAliases) {
+ for (const alias in config.commandAliases) {
+ commands.addAlias(config.commandAliases[alias], alias);
+ }
+ }
+
+ // Load plugins
+ console.log('Loading plugins');
+ const builtInPlugins = [
+ reply,
+ close,
+ logs,
+ block,
+ move,
+ snippets,
+ suspend,
+ greeting,
+ webserver,
+ typingProxy,
+ version,
+ newthread,
+ idModule,
+ alert
+ ];
+
+ const plugins = [...builtInPlugins];
+
+ if (config.plugins && config.plugins.length) {
+ for (const plugin of config.plugins) {
+ const pluginFn = require(`../${plugin}`);
+ plugins.push(pluginFn);
+ }
+ }
+
+ const pluginApi = getPluginAPI({ bot, knex, config, commands });
+ plugins.forEach(pluginFn => loadPlugin(pluginFn, pluginApi));
+
+ console.log(`Loaded ${plugins.length} plugins (${builtInPlugins.length} built-in plugins, ${plugins.length - builtInPlugins.length} external plugins)`);
+
+ if (config.updateNotifications) {
+ updates.startVersionRefreshLoop();
+ }
+}
diff --git a/src/modules/alert.js b/src/modules/alert.js
new file mode 100644
index 0000000..a844230
--- /dev/null
+++ b/src/modules/alert.js
@@ -0,0 +1,11 @@
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxThreadCommand('alert', '[opt:string]', async (msg, args, thread) => {
+ if (args.opt && args.opt.startsWith('c')) {
+ await thread.setAlert(null);
+ await thread.postSystemMessage(`Cancelled new message alert`);
+ } else {
+ await thread.setAlert(msg.author.id);
+ await thread.postSystemMessage(`Pinging ${msg.author.username}#${msg.author.discriminator} when this thread gets a new reply`);
+ }
+ });
+};
diff --git a/src/modules/block.js b/src/modules/block.js
new file mode 100644
index 0000000..94913c4
--- /dev/null
+++ b/src/modules/block.js
@@ -0,0 +1,99 @@
+const humanizeDuration = require('humanize-duration');
+const moment = require('moment');
+const blocked = require("../data/blocked");
+const utils = require("../utils");
+
+module.exports = ({ bot, knex, config, commands }) => {
+ async function removeExpiredBlocks() {
+ const expiredBlocks = await blocked.getExpiredBlocks();
+ const logChannel = utils.getLogChannel();
+ for (const userId of expiredBlocks) {
+ await blocked.unblock(userId);
+ logChannel.createMessage(`Block of <@!${userId}> (id \`${userId}\`) expired`);
+ }
+ }
+
+ async function expiredBlockLoop() {
+ try {
+ removeExpiredBlocks();
+ } catch (e) {
+ console.error(e);
+ }
+
+ setTimeout(expiredBlockLoop, 2000);
+ }
+
+ bot.on('ready', expiredBlockLoop);
+
+ const blockCmd = async (msg, args, thread) => {
+ const userIdToBlock = args.userId || (thread && thread.user_id);
+ if (! userIdToBlock) return;
+
+ const isBlocked = await blocked.isBlocked(userIdToBlock);
+ if (isBlocked) {
+ msg.channel.createMessage('User is already blocked');
+ return;
+ }
+
+ const expiresAt = args.blockTime
+ ? moment.utc().add(args.blockTime, 'ms').format('YYYY-MM-DD HH:mm:ss')
+ : null;
+
+ const user = bot.users.get(userIdToBlock);
+ await blocked.block(userIdToBlock, (user ? `${user.username}#${user.discriminator}` : ''), msg.author.id, expiresAt);
+
+ if (expiresAt) {
+ const humanized = humanizeDuration(args.blockTime, { largest: 2, round: true });
+ msg.channel.createMessage(`Blocked <@${userIdToBlock}> (id \`${userIdToBlock}\`) from modmail for ${humanized}`);
+ } else {
+ msg.channel.createMessage(`Blocked <@${userIdToBlock}> (id \`${userIdToBlock}\`) from modmail indefinitely`);
+ }
+ };
+
+ commands.addInboxServerCommand('block', '<userId:userId> [blockTime:delay]', blockCmd);
+ commands.addInboxServerCommand('block', '[blockTime:delay]', blockCmd);
+
+ const unblockCmd = async (msg, args, thread) => {
+ const userIdToUnblock = args.userId || (thread && thread.user_id);
+ if (! userIdToUnblock) return;
+
+ const isBlocked = await blocked.isBlocked(userIdToUnblock);
+ if (! isBlocked) {
+ msg.channel.createMessage('User is not blocked');
+ return;
+ }
+
+ const unblockAt = args.unblockDelay
+ ? moment.utc().add(args.unblockDelay, 'ms').format('YYYY-MM-DD HH:mm:ss')
+ : null;
+
+ const user = bot.users.get(userIdToUnblock);
+ if (unblockAt) {
+ const humanized = humanizeDuration(args.unblockDelay, { largest: 2, round: true });
+ await blocked.updateExpiryTime(userIdToUnblock, unblockAt);
+ msg.channel.createMessage(`Scheduled <@${userIdToUnblock}> (id \`${userIdToUnblock}\`) to be unblocked in ${humanized}`);
+ } else {
+ await blocked.unblock(userIdToUnblock);
+ msg.channel.createMessage(`Unblocked <@${userIdToUnblock}> (id ${userIdToUnblock}) from modmail`);
+ }
+ };
+
+ commands.addInboxServerCommand('unblock', '<userId:userId> [unblockDelay:delay]', unblockCmd);
+ commands.addInboxServerCommand('unblock', '[unblockDelay:delay]', unblockCmd);
+
+ commands.addInboxServerCommand('is_blocked', '[userId:userId]',async (msg, args, thread) => {
+ const userIdToCheck = args.userId || (thread && thread.user_id);
+ if (! userIdToCheck) return;
+
+ const blockStatus = await blocked.getBlockStatus(userIdToCheck);
+ if (blockStatus.isBlocked) {
+ if (blockStatus.expiresAt) {
+ msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked until ${blockStatus.expiresAt} (UTC)`);
+ } else {
+ msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked indefinitely`);
+ }
+ } else {
+ msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is NOT blocked`);
+ }
+ });
+};
diff --git a/src/modules/close.js b/src/modules/close.js
new file mode 100644
index 0000000..b3e7421
--- /dev/null
+++ b/src/modules/close.js
@@ -0,0 +1,153 @@
+const moment = require('moment');
+const Eris = require('eris');
+const config = require('../config');
+const utils = require('../utils');
+const threads = require('../data/threads');
+const blocked = require('../data/blocked');
+const {messageQueue} = require('../queue');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ // Check for threads that are scheduled to be closed and close them
+ async function applyScheduledCloses() {
+ const threadsToBeClosed = await threads.getThreadsThatShouldBeClosed();
+ for (const thread of threadsToBeClosed) {
+ if (config.closeMessage && ! thread.scheduled_close_silent) {
+ const closeMessage = utils.readMultilineConfigValue(config.closeMessage);
+ await thread.postToUser(closeMessage).catch(() => {});
+ }
+
+ await thread.close(false, thread.scheduled_close_silent);
+
+ const logUrl = await thread.getLogUrl();
+ utils.postLog(utils.trimAll(`
+ Modmail thread with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name}
+ Logs: ${logUrl}
+ `));
+ }
+ }
+
+ async function scheduledCloseLoop() {
+ try {
+ await applyScheduledCloses();
+ } catch (e) {
+ console.error(e);
+ }
+
+ setTimeout(scheduledCloseLoop, 2000);
+ }
+
+ scheduledCloseLoop();
+
+ // Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel.
+ commands.addGlobalCommand('close', '[opts...]', async (msg, args) => {
+ let thread, closedBy;
+
+ let hasCloseMessage = !! config.closeMessage;
+ let silentClose = false;
+
+ if (msg.channel instanceof Eris.PrivateChannel) {
+ // User is closing the thread by themselves (if enabled)
+ if (! config.allowUserClose) return;
+ if (await blocked.isBlocked(msg.author.id)) return;
+
+ thread = await threads.findOpenThreadByUserId(msg.author.id);
+ if (! thread) return;
+
+ // We need to add this operation to the message queue so we don't get a race condition
+ // between showing the close command in the thread and closing the thread
+ await messageQueue.add(async () => {
+ thread.postSystemMessage('Thread closed by user, closing...');
+ await thread.close(true);
+ });
+
+ closedBy = 'the user';
+ } else {
+ // A staff member is closing the thread
+ if (! utils.messageIsOnInboxServer(msg)) return;
+ if (! utils.isStaff(msg.member)) return;
+
+ thread = await threads.findOpenThreadByChannelId(msg.channel.id);
+ if (! thread) return;
+
+ if (args.opts && args.opts.length) {
+ if (args.opts.includes('cancel') || args.opts.includes('c')) {
+ // Cancel timed close
+ if (thread.scheduled_close_at) {
+ await thread.cancelScheduledClose();
+ thread.postSystemMessage(`Cancelled scheduled closing`);
+ }
+
+ return;
+ }
+
+ // Silent close (= no close message)
+ if (args.opts.includes('silent') || args.opts.includes('s')) {
+ silentClose = true;
+ }
+
+ // Timed close
+ const delayStringArg = args.opts.find(arg => utils.delayStringRegex.test(arg));
+ if (delayStringArg) {
+ const delay = utils.convertDelayStringToMS(delayStringArg);
+ if (delay === 0 || delay === null) {
+ thread.postSystemMessage(`Invalid delay specified. Format: "1h30m"`);
+ return;
+ }
+
+ const closeAt = moment.utc().add(delay, 'ms');
+ await thread.scheduleClose(closeAt.format('YYYY-MM-DD HH:mm:ss'), msg.author, silentClose ? 1 : 0);
+
+ let response;
+ if (silentClose) {
+ response = `Thread is now scheduled to be closed silently in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
+ } else {
+ response = `Thread is now scheduled to be closed in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`;
+ }
+
+ thread.postSystemMessage(response);
+
+ return;
+ }
+ }
+
+ // Regular close
+ await thread.close(false, silentClose);
+ closedBy = msg.author.username;
+ }
+
+ // Send close message (unless suppressed with a silent close)
+ if (hasCloseMessage && ! silentClose) {
+ const closeMessage = utils.readMultilineConfigValue(config.closeMessage);
+ await thread.postToUser(closeMessage).catch(() => {});
+ }
+
+ const logUrl = await thread.getLogUrl();
+ utils.postLog(utils.trimAll(`
+ Modmail thread with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy}
+ Logs: ${logUrl}
+ `));
+ });
+
+ // Auto-close threads if their channel is deleted
+ bot.on('channelDelete', async (channel) => {
+ if (! (channel instanceof Eris.TextChannel)) return;
+ if (channel.guild.id !== utils.getInboxGuild().id) return;
+
+ const thread = await threads.findOpenThreadByChannelId(channel.id);
+ if (! thread) return;
+
+ console.log(`[INFO] Auto-closing thread with ${thread.user_name} because the channel was deleted`);
+ if (config.closeMessage) {
+ const closeMessage = utils.readMultilineConfigValue(config.closeMessage);
+ await thread.postToUser(closeMessage).catch(() => {});
+ }
+
+ await thread.close(true);
+
+ const logUrl = await thread.getLogUrl();
+ utils.postLog(utils.trimAll(`
+ Modmail thread with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted
+ Logs: ${logUrl}
+ `));
+ });
+};
diff --git a/src/modules/greeting.js b/src/modules/greeting.js
new file mode 100644
index 0000000..f85bde9
--- /dev/null
+++ b/src/modules/greeting.js
@@ -0,0 +1,37 @@
+const path = require('path');
+const fs = require('fs');
+const config = require('../config');
+const utils = require('../utils');
+
+module.exports = ({ bot }) => {
+ if (! config.enableGreeting) return;
+
+ bot.on('guildMemberAdd', (guild, member) => {
+ const guildGreeting = config.guildGreetings[guild.id];
+ if (! guildGreeting || (! guildGreeting.message && ! guildGreeting.attachment)) return;
+
+ function sendGreeting(message, file) {
+ bot.getDMChannel(member.id).then(channel => {
+ if (! channel) return;
+
+ channel.createMessage(message || '', file)
+ .catch(e => {
+ if (e.code === 50007) return;
+ throw e;
+ });
+ });
+ }
+
+ const greetingMessage = utils.readMultilineConfigValue(guildGreeting.message);
+
+ if (guildGreeting.attachment) {
+ const filename = path.basename(guildGreeting.attachment);
+ fs.readFile(guildGreeting.attachment, (err, data) => {
+ const file = {file: data, name: filename};
+ sendGreeting(greetingMessage, file);
+ });
+ } else {
+ sendGreeting(greetingMessage);
+ }
+ });
+};
diff --git a/src/modules/id.js b/src/modules/id.js
new file mode 100644
index 0000000..e3cf2e2
--- /dev/null
+++ b/src/modules/id.js
@@ -0,0 +1,5 @@
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxThreadCommand('id', [], async (msg, args, thread) => {
+ thread.postSystemMessage(thread.user_id);
+ });
+};
diff --git a/src/modules/logs.js b/src/modules/logs.js
new file mode 100644
index 0000000..0664c07
--- /dev/null
+++ b/src/modules/logs.js
@@ -0,0 +1,69 @@
+const threads = require("../data/threads");
+const moment = require('moment');
+const utils = require("../utils");
+
+const LOG_LINES_PER_PAGE = 10;
+
+module.exports = ({ bot, knex, config, commands }) => {
+ const logsCmd = async (msg, args, thread) => {
+ let userId = args.userId || (thread && thread.user_id);
+ if (! userId) return;
+
+ let userThreads = await threads.getClosedThreadsByUserId(userId);
+
+ // Descending by date
+ userThreads.sort((a, b) => {
+ if (a.created_at > b.created_at) return -1;
+ if (a.created_at < b.created_at) return 1;
+ return 0;
+ });
+
+ // Pagination
+ const totalUserThreads = userThreads.length;
+ const maxPage = Math.ceil(totalUserThreads / LOG_LINES_PER_PAGE);
+ const inputPage = args.page;
+ const page = Math.max(Math.min(inputPage ? parseInt(inputPage, 10) : 1, maxPage), 1); // Clamp page to 1-<max page>
+ const isPaginated = totalUserThreads > LOG_LINES_PER_PAGE;
+ const start = (page - 1) * LOG_LINES_PER_PAGE;
+ const end = page * LOG_LINES_PER_PAGE;
+ userThreads = userThreads.slice((page - 1) * LOG_LINES_PER_PAGE, page * LOG_LINES_PER_PAGE);
+
+ const threadLines = await Promise.all(userThreads.map(async thread => {
+ const logUrl = await thread.getLogUrl();
+ const formattedDate = moment.utc(thread.created_at).format('MMM Do [at] HH:mm [UTC]');
+ return `\`${formattedDate}\`: <${logUrl}>`;
+ }));
+
+ let message = isPaginated
+ ? `**Log files for <@${userId}>** (page **${page}/${maxPage}**, showing logs **${start + 1}-${end}/${totalUserThreads}**):`
+ : `**Log files for <@${userId}>:**`;
+
+ message += `\n${threadLines.join('\n')}`;
+
+ if (isPaginated) {
+ message += `\nTo view more, add a page number to the end of the command`;
+ }
+
+ // Send the list of logs in chunks of 15 lines per message
+ const lines = message.split('\n');
+ const chunks = utils.chunk(lines, 15);
+
+ let root = Promise.resolve();
+ chunks.forEach(lines => {
+ root = root.then(() => msg.channel.createMessage(lines.join('\n')));
+ });
+ };
+
+ commands.addInboxServerCommand('logs', '<userId:userId> [page:number]', logsCmd);
+ commands.addInboxServerCommand('logs', '[page:number]', logsCmd);
+
+ commands.addInboxServerCommand('loglink', [], async (msg, args, thread) => {
+ if (! thread) {
+ thread = await threads.findSuspendedThreadByChannelId(msg.channel.id);
+ if (! thread) return;
+ }
+
+ const logUrl = await thread.getLogUrl();
+ thread.postSystemMessage(`Log URL: ${logUrl}`);
+ });
+};
diff --git a/src/modules/move.js b/src/modules/move.js
new file mode 100644
index 0000000..973143d
--- /dev/null
+++ b/src/modules/move.js
@@ -0,0 +1,82 @@
+const config = require('../config');
+const Eris = require('eris');
+const transliterate = require("transliteration");
+const erisEndpoints = require('eris/lib/rest/Endpoints');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ if (! config.allowMove) return;
+
+ commands.addInboxThreadCommand('move', '<category:string$>', async (msg, args, thread) => {
+ const searchStr = args.category;
+ const normalizedSearchStr = transliterate.slugify(searchStr);
+
+ const categories = msg.channel.guild.channels.filter(c => {
+ // Filter to categories that are not the thread's current parent category
+ return (c instanceof Eris.CategoryChannel) && (c.id !== msg.channel.parentID);
+ });
+
+ if (categories.length === 0) return;
+
+ // See if any category name contains a part of the search string
+ const containsRankings = categories.map(cat => {
+ const normalizedCatName = transliterate.slugify(cat.name);
+
+ let i = 0;
+ do {
+ if (! normalizedCatName.includes(normalizedSearchStr.slice(0, i + 1))) break;
+ i++;
+ } while (i < normalizedSearchStr.length);
+
+ if (i > 0 && normalizedCatName.startsWith(normalizedSearchStr.slice(0, i))) {
+ // Slightly prioritize categories that *start* with the search string
+ i += 0.5;
+ }
+
+ return [cat, i];
+ });
+
+ // Sort by best match
+ containsRankings.sort((a, b) => {
+ return a[1] > b[1] ? -1 : 1;
+ });
+
+ if (containsRankings[0][1] === 0) {
+ thread.postSystemMessage('No matching category');
+ return;
+ }
+
+ const targetCategory = containsRankings[0][0];
+
+ try {
+ await bot.editChannel(thread.channel_id, {
+ parentID: targetCategory.id
+ });
+ } catch (e) {
+ thread.postSystemMessage(`Failed to move thread: ${e.message}`);
+ return;
+ }
+
+ // If enabled, sync thread channel permissions with the category it's moved to
+ if (config.syncPermissionsOnMove) {
+ const newPerms = Array.from(targetCategory.permissionOverwrites.map(ow => {
+ return {
+ id: ow.id,
+ type: ow.type,
+ allow: ow.allow,
+ deny: ow.deny
+ };
+ }));
+
+ try {
+ await bot.requestHandler.request("PATCH", erisEndpoints.CHANNEL(thread.channel_id), true, {
+ permission_overwrites: newPerms
+ });
+ } catch (e) {
+ thread.postSystemMessage(`Thread moved to ${targetCategory.name.toUpperCase()}, but failed to sync permissions: ${e.message}`);
+ return;
+ }
+ }
+
+ thread.postSystemMessage(`Thread moved to ${targetCategory.name.toUpperCase()}`);
+ });
+};
diff --git a/src/modules/newthread.js b/src/modules/newthread.js
new file mode 100644
index 0000000..aca6f54
--- /dev/null
+++ b/src/modules/newthread.js
@@ -0,0 +1,23 @@
+const utils = require("../utils");
+const threads = require("../data/threads");
+
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxServerCommand('newthread', '<userId:userId>', async (msg, args, thread) => {
+ const user = bot.users.get(args.userId);
+ if (! user) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, 'User not found!');
+ return;
+ }
+
+ const existingThread = await threads.findOpenThreadByUserId(user.id);
+ if (existingThread) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Cannot create a new thread; there is another open thread with this user: <#${existingThread.channel_id}>`);
+ return;
+ }
+
+ const createdThread = await threads.createNewThreadForUser(user, true, true);
+ createdThread.postSystemMessage(`Thread was opened by ${msg.author.username}#${msg.author.discriminator}`);
+
+ msg.channel.createMessage(`Thread opened: <#${createdThread.channel_id}>`);
+ });
+};
diff --git a/src/modules/reply.js b/src/modules/reply.js
new file mode 100644
index 0000000..bc2afe3
--- /dev/null
+++ b/src/modules/reply.js
@@ -0,0 +1,32 @@
+const attachments = require("../data/attachments");
+const utils = require('../utils');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ // Mods can reply to modmail threads using !r or !reply
+ // These messages get relayed back to the DM thread between the bot and the user
+ commands.addInboxThreadCommand('reply', '[text$]', async (msg, args, thread) => {
+ if (! args.text && msg.attachments.length === 0) {
+ utils.postError(msg.channel, 'Text or attachment required');
+ return;
+ }
+
+ const replied = await thread.replyToUser(msg.member, args.text || '', msg.attachments, false);
+ if (replied) msg.delete();
+ }, {
+ aliases: ['r']
+ });
+
+
+ // Anonymous replies only show the role, not the username
+ commands.addInboxThreadCommand('anonreply', '[text$]', async (msg, args, thread) => {
+ if (! args.text && msg.attachments.length === 0) {
+ utils.postError(msg.channel, 'Text or attachment required');
+ return;
+ }
+
+ const replied = await thread.replyToUser(msg.member, args.text || '', msg.attachments, true);
+ if (replied) msg.delete();
+ }, {
+ aliases: ['ar']
+ });
+};
diff --git a/src/modules/snippets.js b/src/modules/snippets.js
new file mode 100644
index 0000000..e9c1271
--- /dev/null
+++ b/src/modules/snippets.js
@@ -0,0 +1,138 @@
+const threads = require('../data/threads');
+const snippets = require('../data/snippets');
+const config = require('../config');
+const utils = require('../utils');
+const { parseArguments } = require('knub-command-manager');
+
+const whitespaceRegex = /\s/;
+const quoteChars = ["'", '"'];
+
+module.exports = ({ bot, knex, config, commands }) => {
+ /**
+ * "Renders" a snippet by replacing all argument placeholders e.g. {1} {2} with their corresponding arguments.
+ * The number in the placeholder is the argument's order in the argument list, i.e. {1} is the first argument (= index 0)
+ * @param {String} body
+ * @param {String[]} args
+ * @returns {String}
+ */
+ function renderSnippet(body, args) {
+ return body
+ .replace(/(?<!\\){\d+}/g, match => {
+ const index = parseInt(match.slice(1, -1), 10) - 1;
+ return (args[index] != null ? args[index] : match);
+ })
+ .replace(/\\{/g, '{');
+ }
+
+ /**
+ * When a staff member uses a snippet (snippet prefix + trigger word), find the snippet and post it as a reply in the thread
+ */
+ bot.on('messageCreate', async msg => {
+ if (! utils.messageIsOnInboxServer(msg)) return;
+ if (! utils.isStaff(msg.member)) return;
+
+ if (msg.author.bot) return;
+ if (! msg.content) return;
+ if (! msg.content.startsWith(config.snippetPrefix) && ! msg.content.startsWith(config.snippetPrefixAnon)) return;
+
+ let snippetPrefix, isAnonymous;
+
+ if (config.snippetPrefixAnon.length > config.snippetPrefix.length) {
+ // Anonymous prefix is longer -> check it first
+ if (msg.content.startsWith(config.snippetPrefixAnon)) {
+ snippetPrefix = config.snippetPrefixAnon;
+ isAnonymous = true;
+ } else {
+ snippetPrefix = config.snippetPrefix;
+ isAnonymous = false;
+ }
+ } else {
+ // Regular prefix is longer -> check it first
+ if (msg.content.startsWith(config.snippetPrefix)) {
+ snippetPrefix = config.snippetPrefix;
+ isAnonymous = false;
+ } else {
+ snippetPrefix = config.snippetPrefixAnon;
+ isAnonymous = true;
+ }
+ }
+
+ const thread = await threads.findByChannelId(msg.channel.id);
+ if (! thread) return;
+
+ let [, trigger, rawArgs] = msg.content.slice(snippetPrefix.length).match(/(\S+)(?:\s+(.*))?/s);
+ trigger = trigger.toLowerCase();
+
+ const snippet = await snippets.get(trigger);
+ if (! snippet) return;
+
+ let args = rawArgs ? parseArguments(rawArgs) : [];
+ args = args.map(arg => arg.value);
+ const rendered = renderSnippet(snippet.body, args);
+
+ const replied = await thread.replyToUser(msg.member, rendered, [], isAnonymous);
+ if (replied) msg.delete();
+ });
+
+ // Show or add a snippet
+ commands.addInboxServerCommand('snippet', '<trigger> [text$]', async (msg, args, thread) => {
+ const snippet = await snippets.get(args.trigger);
+
+ if (snippet) {
+ if (args.text) {
+ // If the snippet exists and we're trying to create a new one, inform the user the snippet already exists
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" already exists! You can edit or delete it with ${config.prefix}edit_snippet and ${config.prefix}delete_snippet respectively.`);
+ } else {
+ // If the snippet exists and we're NOT trying to create a new one, show info about the existing snippet
+ utils.postSystemMessageWithFallback(msg.channel, thread, `\`${config.snippetPrefix}${args.trigger}\` replies with: \`\`\`${utils.disableCodeBlocks(snippet.body)}\`\`\``);
+ }
+ } else {
+ if (args.text) {
+ // If the snippet doesn't exist and the user wants to create it, create it
+ await snippets.add(args.trigger, args.text, msg.author.id);
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" created!`);
+ } else {
+ // If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist! You can create it with \`${config.prefix}snippet ${args.trigger} text\``);
+ }
+ }
+ }, {
+ aliases: ['s']
+ });
+
+ commands.addInboxServerCommand('delete_snippet', '<trigger>', async (msg, args, thread) => {
+ const snippet = await snippets.get(args.trigger);
+ if (! snippet) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`);
+ return;
+ }
+
+ await snippets.del(args.trigger);
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" deleted!`);
+ }, {
+ aliases: ['ds']
+ });
+
+ commands.addInboxServerCommand('edit_snippet', '<trigger> [text$]', async (msg, args, thread) => {
+ const snippet = await snippets.get(args.trigger);
+ if (! snippet) {
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`);
+ return;
+ }
+
+ await snippets.del(args.trigger);
+ await snippets.add(args.trigger, args.text, msg.author.id);
+
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" edited!`);
+ }, {
+ aliases: ['es']
+ });
+
+ commands.addInboxServerCommand('snippets', [], async (msg, args, thread) => {
+ const allSnippets = await snippets.all();
+ const triggers = allSnippets.map(s => s.trigger);
+ triggers.sort();
+
+ utils.postSystemMessageWithFallback(msg.channel, thread, `Available snippets (prefix ${config.snippetPrefix}):\n${triggers.join(', ')}`);
+ });
+};
diff --git a/src/modules/suspend.js b/src/modules/suspend.js
new file mode 100644
index 0000000..7edd096
--- /dev/null
+++ b/src/modules/suspend.js
@@ -0,0 +1,77 @@
+const moment = require('moment');
+const threads = require("../data/threads");
+const utils = require('../utils');
+const config = require('../config');
+
+const {THREAD_STATUS} = require('../data/constants');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ // Check for threads that are scheduled to be suspended and suspend them
+ async function applyScheduledSuspensions() {
+ const threadsToBeSuspended = await threads.getThreadsThatShouldBeSuspended();
+ for (const thread of threadsToBeSuspended) {
+ if (thread.status === THREAD_STATUS.OPEN) {
+ await thread.suspend();
+ await thread.postSystemMessage(`**Thread suspended** as scheduled by ${thread.scheduled_suspend_name}. This thread will act as closed until unsuspended with \`!unsuspend\``);
+ }
+ }
+ }
+
+ async function scheduledSuspendLoop() {
+ try {
+ await applyScheduledSuspensions();
+ } catch (e) {
+ console.error(e);
+ }
+
+ setTimeout(scheduledSuspendLoop, 2000);
+ }
+
+ scheduledSuspendLoop();
+
+ commands.addInboxThreadCommand('suspend cancel', [], async (msg, args, thread) => {
+ // Cancel timed suspend
+ if (thread.scheduled_suspend_at) {
+ await thread.cancelScheduledSuspend();
+ thread.postSystemMessage(`Cancelled scheduled suspension`);
+ } else {
+ thread.postSystemMessage(`Thread is not scheduled to be suspended`);
+ }
+ });
+
+ commands.addInboxThreadCommand('suspend', '[delay:delay]', async (msg, args, thread) => {
+ if (args.delay) {
+ const suspendAt = moment.utc().add(args.delay, 'ms');
+ await thread.scheduleSuspend(suspendAt.format('YYYY-MM-DD HH:mm:ss'), msg.author);
+
+ thread.postSystemMessage(`Thread will be suspended in ${utils.humanizeDelay(args.delay)}. Use \`${config.prefix}suspend cancel\` to cancel.`);
+
+ return;
+ }
+
+ await thread.suspend();
+ thread.postSystemMessage(`**Thread suspended!** This thread will act as closed until unsuspended with \`!unsuspend\``);
+ });
+
+ commands.addInboxServerCommand('unsuspend', [], async (msg, args, thread) => {
+ if (thread) {
+ thread.postSystemMessage(`Thread is not suspended`);
+ return;
+ }
+
+ thread = await threads.findSuspendedThreadByChannelId(msg.channel.id);
+ if (! thread) {
+ thread.postSystemMessage(`Not in a thread`);
+ return;
+ }
+
+ const otherOpenThread = await threads.findOpenThreadByUserId(thread.user_id);
+ if (otherOpenThread) {
+ thread.postSystemMessage(`Cannot unsuspend; there is another open thread with this user: <#${otherOpenThread.channel_id}>`);
+ return;
+ }
+
+ await thread.unsuspend();
+ thread.postSystemMessage(`**Thread unsuspended!**`);
+ });
+};
diff --git a/src/modules/typingProxy.js b/src/modules/typingProxy.js
new file mode 100644
index 0000000..5881808
--- /dev/null
+++ b/src/modules/typingProxy.js
@@ -0,0 +1,33 @@
+const config = require('../config');
+const threads = require("../data/threads");
+const Eris = require("eris");
+
+module.exports = ({ bot }) => {
+ // Typing proxy: forwarding typing events between the DM and the modmail thread
+ if(config.typingProxy || config.typingProxyReverse) {
+ bot.on("typingStart", async (channel, user) => {
+ // config.typingProxy: forward user typing in a DM to the modmail thread
+ if (config.typingProxy && (channel instanceof Eris.PrivateChannel)) {
+ const thread = await threads.findOpenThreadByUserId(user.id);
+ if (! thread) return;
+
+ try {
+ await bot.sendChannelTyping(thread.channel_id);
+ } catch (e) {}
+ }
+
+ // config.typingProxyReverse: forward moderator typing in a thread to the DM
+ else if (config.typingProxyReverse && (channel instanceof Eris.GuildChannel) && ! user.bot) {
+ const thread = await threads.findByChannelId(channel.id);
+ if (! thread) return;
+
+ const dmChannel = await thread.getDMChannel();
+ if (! dmChannel) return;
+
+ try {
+ await bot.sendChannelTyping(dmChannel.id);
+ } catch(e) {}
+ }
+ });
+ }
+};
diff --git a/src/modules/version.js b/src/modules/version.js
new file mode 100644
index 0000000..033fbf0
--- /dev/null
+++ b/src/modules/version.js
@@ -0,0 +1,53 @@
+const path = require('path');
+const fs = require('fs');
+const {promisify} = require('util');
+const utils = require("../utils");
+const updates = require('../data/updates');
+const config = require('../config');
+
+const access = promisify(fs.access);
+const readFile = promisify(fs.readFile);
+
+const GIT_DIR = path.join(__dirname, '..', '..', '.git');
+
+module.exports = ({ bot, knex, config, commands }) => {
+ commands.addInboxServerCommand('version', [], async (msg, args, thread) => {
+ const packageJson = require('../../package.json');
+ const packageVersion = packageJson.version;
+
+ let response = `Modmail v${packageVersion}`;
+
+ let isGit;
+ try {
+ await access(GIT_DIR);
+ isGit = true;
+ } catch (e) {
+ isGit = false;
+ }
+
+ if (isGit) {
+ let commitHash;
+ const HEAD = await readFile(path.join(GIT_DIR, 'HEAD'), {encoding: 'utf8'});
+
+ if (HEAD.startsWith('ref:')) {
+ // Branch
+ const ref = HEAD.match(/^ref: (.*)$/m)[1];
+ commitHash = (await readFile(path.join(GIT_DIR, ref), {encoding: 'utf8'})).trim();
+ } else {
+ // Detached head
+ commitHash = HEAD.trim();
+ }
+
+ response += ` (${commitHash.slice(0, 7)})`;
+ }
+
+ if (config.updateNotifications) {
+ const availableUpdate = await updates.getAvailableUpdate();
+ if (availableUpdate) {
+ response += ` (version ${availableUpdate} available)`;
+ }
+ }
+
+ utils.postSystemMessageWithFallback(msg.channel, thread, response);
+ });
+};
diff --git a/src/modules/webserver.js b/src/modules/webserver.js
new file mode 100644
index 0000000..5d8b2c9
--- /dev/null
+++ b/src/modules/webserver.js
@@ -0,0 +1,92 @@
+const http = require('http');
+const mime = require('mime');
+const url = require('url');
+const fs = require('fs');
+const moment = require('moment');
+const config = require('../config');
+const threads = require('../data/threads');
+const attachments = require('../data/attachments');
+
+const {THREAD_MESSAGE_TYPE} = require('../data/constants');
+
+function notfound(res) {
+ res.statusCode = 404;
+ res.end('Page Not Found');
+}
+
+async function serveLogs(res, pathParts) {
+ const threadId = pathParts[pathParts.length - 1];
+ if (threadId.match(/^[0-9a-f\-]+$/) === null) return notfound(res);
+
+ const thread = await threads.findById(threadId);
+ if (! thread) return notfound(res);
+
+ const threadMessages = await thread.getThreadMessages();
+ const lines = threadMessages.map(message => {
+ // Legacy messages are the entire log in one message, so just serve them as they are
+ if (message.message_type === THREAD_MESSAGE_TYPE.LEGACY) {
+ return message.body;
+ }
+
+ let line = `[${moment.utc(message.created_at).format('YYYY-MM-DD HH:mm:ss')}] `;
+
+ if (message.message_type === THREAD_MESSAGE_TYPE.SYSTEM) {
+ // System messages don't need the username
+ line += message.body;
+ } else if (message.message_type === THREAD_MESSAGE_TYPE.FROM_USER) {
+ line += `[FROM USER] ${message.user_name}: ${message.body}`;
+ } else if (message.message_type === THREAD_MESSAGE_TYPE.TO_USER) {
+ line += `[TO USER] ${message.user_name}: ${message.body}`;
+ } else {
+ line += `${message.user_name}: ${message.body}`;
+ }
+
+ return line;
+ });
+
+ res.setHeader('Content-Type', 'text/plain; charset=UTF-8');
+ res.end(lines.join('\n'));
+}
+
+function serveAttachments(res, pathParts) {
+ const desiredFilename = pathParts[pathParts.length - 1];
+ const id = pathParts[pathParts.length - 2];
+
+ if (id.match(/^[0-9]+$/) === null) return notfound(res);
+ if (desiredFilename.match(/^[0-9a-z._-]+$/i) === null) return notfound(res);
+
+ const attachmentPath = attachments.getLocalAttachmentPath(id);
+ fs.access(attachmentPath, (err) => {
+ if (err) return notfound(res);
+
+ const filenameParts = desiredFilename.split('.');
+ const ext = (filenameParts.length > 1 ? filenameParts[filenameParts.length - 1] : 'bin');
+ const fileMime = mime.getType(ext);
+
+ res.setHeader('Content-Type', fileMime);
+
+ const read = fs.createReadStream(attachmentPath);
+ read.pipe(res);
+ })
+}
+
+module.exports = () => {
+ const server = http.createServer((req, res) => {
+ const parsedUrl = url.parse(`http://${req.url}`);
+ const pathParts = parsedUrl.path.split('/').filter(v => v !== '');
+
+ if (parsedUrl.path.startsWith('/logs/')) {
+ serveLogs(res, pathParts);
+ } else if (parsedUrl.path.startsWith('/attachments/')) {
+ serveAttachments(res, pathParts);
+ } else {
+ notfound(res);
+ }
+ });
+
+ server.on('error', err => {
+ console.log('[WARN] Web server error:', err.message);
+ });
+
+ server.listen(config.port);
+};
diff --git a/src/plugins.js b/src/plugins.js
new file mode 100644
index 0000000..64c9df6
--- /dev/null
+++ b/src/plugins.js
@@ -0,0 +1,26 @@
+const attachments = require('./data/attachments');
+
+module.exports = {
+ getPluginAPI({ bot, knex, config, commands }) {
+ return {
+ bot,
+ knex,
+ config,
+ commands: {
+ manager: commands.manager,
+ addGlobalCommand: commands.addGlobalCommand,
+ addInboxServerCommand: commands.addInboxServerCommand,
+ addInboxThreadCommand: commands.addInboxThreadCommand,
+ addAlias: commands.addAlias
+ },
+ attachments: {
+ addStorageType: attachments.addStorageType,
+ downloadAttachment: attachments.downloadAttachment
+ },
+ };
+ },
+
+ loadPlugin(plugin, api) {
+ plugin(api);
+ }
+};
diff --git a/src/queue.js b/src/queue.js
new file mode 100644
index 0000000..589425e
--- /dev/null
+++ b/src/queue.js
@@ -0,0 +1,40 @@
+class Queue {
+ constructor() {
+ this.running = false;
+ this.queue = [];
+ }
+
+ add(fn) {
+ const promise = new Promise(resolve => {
+ this.queue.push(async () => {
+ await Promise.resolve(fn());
+ resolve();
+ });
+
+ if (! this.running) this.next();
+ });
+
+ return promise;
+ }
+
+ next() {
+ this.running = true;
+
+ if (this.queue.length === 0) {
+ this.running = false;
+ return;
+ }
+
+ const fn = this.queue.shift();
+ new Promise(resolve => {
+ // Either fn() completes or the timeout of 10sec is reached
+ fn().then(resolve);
+ setTimeout(resolve, 10000);
+ }).then(() => this.next());
+ }
+}
+
+module.exports = {
+ Queue,
+ messageQueue: new Queue()
+};
diff --git a/src/utils.js b/src/utils.js
new file mode 100644
index 0000000..3e475ab
--- /dev/null
+++ b/src/utils.js
@@ -0,0 +1,357 @@
+const Eris = require('eris');
+const bot = require('./bot');
+const moment = require('moment');
+const humanizeDuration = require('humanize-duration');
+const publicIp = require('public-ip');
+const config = require('./config');
+
+class BotError extends Error {}
+
+const userMentionRegex = /^<@!?([0-9]+?)>$/;
+
+let inboxGuild = null;
+let mainGuilds = [];
+let logChannel = null;
+
+/**
+ * @returns {Eris~Guild}
+ */
+function getInboxGuild() {
+ if (! inboxGuild) inboxGuild = bot.guilds.find(g => g.id === config.mailGuildId);
+ if (! inboxGuild) throw new BotError('The bot is not on the modmail (inbox) server!');
+ return inboxGuild;
+}
+
+/**
+ * @returns {Eris~Guild[]}
+ */
+function getMainGuilds() {
+ if (mainGuilds.length === 0) {
+ mainGuilds = bot.guilds.filter(g => config.mainGuildId.includes(g.id));
+ }
+
+ if (mainGuilds.length !== config.mainGuildId.length) {
+ if (config.mainGuildId.length === 1) {
+ console.warn(`[WARN] The bot hasn't joined the main guild!`);
+ } else {
+ console.warn(`[WARN] The bot hasn't joined one or more main guilds!`);
+ }
+ }
+
+ return mainGuilds;
+}
+
+/**
+ * Returns the designated log channel, or the default channel if none is set
+ * @returns {Eris~TextChannel}
+ */
+function getLogChannel() {
+ const inboxGuild = getInboxGuild();
+ const logChannel = inboxGuild.channels.get(config.logChannelId);
+
+ if (! logChannel) {
+ throw new BotError('Log channel (logChannelId) not found!');
+ }
+
+ if (! (logChannel instanceof Eris.TextChannel)) {
+ throw new BotError('Make sure the logChannelId option is set to a text channel!');
+ }
+
+ return logChannel;
+}
+
+function postLog(...args) {
+ getLogChannel().createMessage(...args);
+}
+
+function postError(channel, str, opts = {}) {
+ return channel.createMessage({
+ ...opts,
+ content: `⚠ ${str}`
+ });
+}
+
+/**
+ * Returns whether the given member has permission to use modmail commands
+ * @param member
+ * @returns {boolean}
+ */
+function isStaff(member) {
+ if (! member) return false;
+ if (config.inboxServerPermission.length === 0) return true;
+
+ return config.inboxServerPermission.some(perm => {
+ if (isSnowflake(perm)) {
+ // If perm is a snowflake, check it against the member's user id and roles
+ if (member.id === perm) return true;
+ if (member.roles.includes(perm)) return true;
+ } else {
+ // Otherwise assume perm is the name of a permission
+ return member.permission.has(perm);
+ }
+
+ return false;
+ });
+}
+
+/**
+ * Returns whether the given message is on the inbox server
+ * @param msg
+ * @returns {boolean}
+ */
+function messageIsOnInboxServer(msg) {
+ if (! msg.channel.guild) return false;
+ if (msg.channel.guild.id !== getInboxGuild().id) return false;
+ return true;
+}
+
+/**
+ * Returns whether the given message is on the main server
+ * @param msg
+ * @returns {boolean}
+ */
+function messageIsOnMainServer(msg) {
+ if (! msg.channel.guild) return false;
+
+ return getMainGuilds()
+ .some(g => msg.channel.guild.id === g.id);
+}
+
+/**
+ * @param attachment
+ * @returns {Promise<string>}
+ */
+async function formatAttachment(attachment, attachmentUrl) {
+ let filesize = attachment.size || 0;
+ filesize /= 1024;
+
+ return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`;
+}
+
+/**
+ * Returns the user ID of the user mentioned in str, if any
+ * @param {String} str
+ * @returns {String|null}
+ */
+function getUserMention(str) {
+ if (! str) return null;
+
+ str = str.trim();
+
+ if (isSnowflake(str)) {
+ // User ID
+ return str;
+ } else {
+ let mentionMatch = str.match(userMentionRegex);
+ if (mentionMatch) return mentionMatch[1];
+ }
+
+ return null;
+}
+
+/**
+ * Returns the current timestamp in an easily readable form
+ * @returns {String}
+ */
+function getTimestamp(...momentArgs) {
+ return moment.utc(...momentArgs).format('HH:mm');
+}
+
+/**
+ * Disables link previews in the given string by wrapping links in < >
+ * @param {String} str
+ * @returns {String}
+ */
+function disableLinkPreviews(str) {
+ return str.replace(/(^|[^<])(https?:\/\/\S+)/ig, '$1<$2>');
+}
+
+/**
+ * Returns a URL to the bot's web server
+ * @param {String} path
+ * @returns {Promise<String>}
+ */
+async function getSelfUrl(path = '') {
+ if (config.url) {
+ return `${config.url}/${path}`;
+ } else {
+ const port = config.port || 8890;
+ const ip = await publicIp.v4();
+ return `http://${ip}:${port}/${path}`;
+ }
+}
+
+/**
+ * Returns the highest hoisted role of the given member
+ * @param {Eris~Member} member
+ * @returns {Eris~Role}
+ */
+function getMainRole(member) {
+ const roles = member.roles.map(id => member.guild.roles.get(id));
+ roles.sort((a, b) => a.position > b.position ? -1 : 1);
+ return roles.find(r => r.hoist);
+}
+
+/**
+ * Splits array items into chunks of the specified size
+ * @param {Array|String} items
+ * @param {Number} chunkSize
+ * @returns {Array}
+ */
+function chunk(items, chunkSize) {
+ const result = [];
+
+ for (let i = 0; i < items.length; i += chunkSize) {
+ result.push(items.slice(i, i + chunkSize));
+ }
+
+ return result;
+}
+
+/**
+ * Trims every line in the string
+ * @param {String} str
+ * @returns {String}
+ */
+function trimAll(str) {
+ return str
+ .split('\n')
+ .map(str => str.trim())
+ .join('\n');
+}
+
+const delayStringRegex = /^([0-9]+)(?:([dhms])[a-z]*)?/i;
+
+/**
+ * Turns a "delay string" such as "1h30m" to milliseconds
+ * @param {String} str
+ * @returns {Number|null}
+ */
+function convertDelayStringToMS(str) {
+ let match;
+ let ms = 0;
+
+ str = str.trim();
+
+ while (str !== '' && (match = str.match(delayStringRegex)) !== null) {
+ if (match[2] === 'd') ms += match[1] * 1000 * 60 * 60 * 24;
+ else if (match[2] === 'h') ms += match[1] * 1000 * 60 * 60;
+ else if (match[2] === 's') ms += match[1] * 1000;
+ else if (match[2] === 'm' || ! match[2]) ms += match[1] * 1000 * 60;
+
+ str = str.slice(match[0].length);
+ }
+
+ // Invalid delay string
+ if (str !== '') {
+ return null;
+ }
+
+ return ms;
+}
+
+function getInboxMention() {
+ const mentionRoles = Array.isArray(config.mentionRole) ? config.mentionRole : [config.mentionRole];
+ const mentions = [];
+ for (const role of mentionRoles) {
+ if (role == null) continue;
+ else if (role === 'here') mentions.push('@here');
+ else if (role === 'everyone') mentions.push('@everyone');
+ else mentions.push(`<@&${role}>`);
+ }
+ return mentions.join(' ') + ' ';
+}
+
+function postSystemMessageWithFallback(channel, thread, text) {
+ if (thread) {
+ thread.postSystemMessage(text);
+ } else {
+ channel.createMessage(text);
+ }
+}
+
+/**
+ * A normalized way to set props in data models, fixing some inconsistencies between different DB drivers in knex
+ * @param {Object} target
+ * @param {Object} props
+ */
+function setDataModelProps(target, props) {
+ for (const prop in props) {
+ if (! props.hasOwnProperty(prop)) continue;
+ // DATETIME fields are always returned as Date objects in MySQL/MariaDB
+ if (props[prop] instanceof Date) {
+ // ...even when NULL, in which case the date's set to unix epoch
+ if (props[prop].getUTCFullYear() === 1970) {
+ target[prop] = null;
+ } else {
+ // Set the value as a string in the same format it's returned in SQLite
+ target[prop] = moment.utc(props[prop]).format('YYYY-MM-DD HH:mm:ss');
+ }
+ } else {
+ target[prop] = props[prop];
+ }
+ }
+}
+
+const snowflakeRegex = /^[0-9]{17,}$/;
+function isSnowflake(str) {
+ return str && snowflakeRegex.test(str);
+}
+
+const humanizeDelay = (delay, opts = {}) => humanizeDuration(delay, Object.assign({conjunction: ' and '}, opts));
+
+const markdownCharsRegex = /([\\_*|`~])/g;
+function escapeMarkdown(str) {
+ return str.replace(markdownCharsRegex, '\\$1');
+}
+
+function disableCodeBlocks(str) {
+ return str.replace(/`/g, "`\u200b");
+}
+
+/**
+ *
+ */
+function readMultilineConfigValue(str) {
+ return Array.isArray(str) ? str.join('\n') : str;
+}
+
+module.exports = {
+ BotError,
+
+ getInboxGuild,
+ getMainGuilds,
+ getLogChannel,
+ postError,
+ postLog,
+
+ isStaff,
+ messageIsOnInboxServer,
+ messageIsOnMainServer,
+
+ formatAttachment,
+
+ getUserMention,
+ getTimestamp,
+ disableLinkPreviews,
+ getSelfUrl,
+ getMainRole,
+ delayStringRegex,
+ convertDelayStringToMS,
+ getInboxMention,
+ postSystemMessageWithFallback,
+
+ chunk,
+ trimAll,
+
+ setDataModelProps,
+
+ isSnowflake,
+
+ humanizeDelay,
+
+ escapeMarkdown,
+ disableCodeBlocks,
+
+ readMultilineConfigValue,
+};
diff --git a/start.bat b/start.bat
new file mode 100644
index 0000000..40accb6
--- /dev/null
+++ b/start.bat
@@ -0,0 +1,17 @@
+@echo off
+
+echo Installing/updating bot dependencies
+call npm ci --only=production --loglevel=warn >NUL
+
+if NOT ["%errorlevel%"]==["0"] (
+ pause
+ exit /b %errorlevel%
+)
+
+echo Starting the bot
+call npm run start
+
+if NOT ["%errorlevel%"]==["0"] (
+ pause
+ exit /b %errorlevel%
+)