Browse Source

Merge tag 'v2.1.0' of https://github.com/get-got/discord-downloader-go

Fred Damstra (k8s1) 2 years ago
parent
commit
08adb9064f
19 changed files with 3893 additions and 3869 deletions
  1. 0 76
      .github/workflows/codeql.yml
  2. 11 2
      .gitignore
  3. 4 4
      .travis.yml
  4. 1 1
      Dockerfile
  5. 474 930
      README.md
  6. 442 239
      commands.go
  7. 237 55
      common.go
  8. 452 341
      config.go
  9. 57 31
      database.go
  10. 411 268
      discord.go
  11. 412 291
      downloads.go
  12. 37 39
      go.mod
  13. 0 548
      go.sum
  14. 113 90
      handlers.go
  15. 504 221
      history.go
  16. 629 332
      main.go
  17. 70 296
      parse.go
  18. 18 52
      regex.go
  19. 21 53
      vars.go

+ 0 - 76
.github/workflows/codeql.yml

@@ -1,76 +0,0 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
-name: "CodeQL"
-
-on:
-  push:
-    branches: [ "master" ]
-  pull_request:
-    # The branches below must be a subset of the branches above
-    branches: [ "master" ]
-  schedule:
-    - cron: '35 11 * * 1'
-
-jobs:
-  analyze:
-    name: Analyze
-    runs-on: ubuntu-latest
-    permissions:
-      actions: read
-      contents: read
-      security-events: write
-
-    strategy:
-      fail-fast: false
-      matrix:
-        language: [ 'go' ]
-        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
-        # Use only 'java' to analyze code written in Java, Kotlin or both
-        # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
-        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
-
-    steps:
-    - name: Checkout repository
-      uses: actions/checkout@v3
-
-    # Initializes the CodeQL tools for scanning.
-    - name: Initialize CodeQL
-      uses: github/codeql-action/init@v2
-      with:
-        languages: ${{ matrix.language }}
-        # If you wish to specify custom queries, you can do so here or in a config file.
-        # By default, queries listed here will override any specified in a config file.
-        # Prefix the list here with "+" to use these queries and those in the config file.
-
-        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
-        # queries: security-extended,security-and-quality
-
-
-    # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
-    # If this step fails, then you should remove it and run the build manually (see below)
-    - name: Autobuild
-      uses: github/codeql-action/autobuild@v2
-
-    # ℹ️ Command-line programs to run using the OS shell.
-    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
-
-    #   If the Autobuild fails above, remove it and uncomment the following three lines.
-    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
-
-    # - run: |
-    #   echo "Run, Build Application using script"
-    #   ./location_of_script_within_repo/buildscript.sh
-
-    - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v2
-      with:
-        category: "/language:${{matrix.language}}"

+ 11 - 2
.gitignore

@@ -11,13 +11,22 @@
 # Output of the go coverage tool, specifically when used with LiteIDE
 *.out
 
-# Exclude subdirectories (things like database, saved images during testing)
-/**
 # Exclude build command shortcut(s)
 *.cmd
+
 # Exclude own settings
 settings.json
 
 ## Hmm... not even sure why this was being ignored
 !kaniko.yaml
 !Jenkinsfile
+
+# Exclude runnning dev settings
+/*.json
+/*.jsonc
+
+# Exclude program output
+*.txt
+
+# Exclude program storage
+**

+ 4 - 4
.travis.yml

@@ -8,16 +8,16 @@ before_install:
   - go install github.com/mitchellh/gox
 
 script:
-  - env GO111MODULE=on gox -output="bin/discord-downloader-go_{{.OS}}-{{.Arch}}" -osarch="windows/amd64 linux/amd64 linux/arm"
+  - env GO111MODULE=on env GODEBUG=http2client=0 gox -osarch="windows/amd64 linux/amd64 linux/arm"
 
 deploy:
   provider: releases
   token:
     secure: VEt15uzzoleub7mqC7iTlxdZOb5Bv+FGAbB39rKoFRsPPMCR04X/P/lQBmTnIjb4/FyZw0DzlWTX6svmglMB/5Ne6L9WYhyJgLdjCiK2qmPmC1dNOYM8wRGV+FOFCPmWKgppE7Bu7+83RjYqachS8u7SLsCcyx9mINx4r434Bvd3iPyvQqMCBctwRLPTbg4rvRfIo6lHJUiEOQgWnzt8OZnVhgSmUUJqg1oYcrWarjTx+NcMPG9HySDLYlUtsJKHv73TPaXLPIyebC4hLyZ8Mm70qynMaxo8x7zDjYeqO46i7gIrBnTTMujukZpVkjd3PpkeNpSFFMIoEIAG+nvPTbF9kIGNLbMGb9rA/dkziaGbbgCU8GggXaqXlvsa8r/DGiqMNE86+ET3XqVtDs/So74LpxqqT6SSX0njx/zZfuAcrOpmFa+LBTWaUGk0p1kl8DI/K9Gg3d3PeGlCK94OyZNpANZm4KYYoENAiMYp+vtQC22ICR6DROPCN85us1OAplvq1E27W4ooL0pvBvmD+O89jg4iZ+2hlmNlohyByPsLnUtNpjhb03EJRg3FKdOBKMXBKK5FpuR7+39dtARi/lb4pxvNMbvEMZtboUlpKrkPJuoh4PC9x8NCPhaHrR4SFX8WdH15K3Okcl5w1gqA+YAQL61AwXM8shCLZPnOB9k=
   file:
-    - "bin/discord-downloader-go_windows-amd64.exe"
-    - "bin/discord-downloader-go_linux-amd64"
-    - "bin/discord-downloader-go_linux-arm"
+    - "discord-downloader-go_windows-amd64.exe"
+    - "discord-downloader-go_linux-amd64"
+    - "discord-downloader-go_linux-arm"
   cleanup: false
   on:
     repo: get-got/discord-downloader-go

+ 1 - 1
Dockerfile

@@ -10,7 +10,7 @@ COPY . /go/src/github.com/github.com/get-got/discord-downloader-go
 WORKDIR /go/src/github.com/github.com/get-got/discord-downloader-go
 
 RUN go mod download
-RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -o app .
+RUN CGO_ENABLED=0 GODEBUG=http2client=0 GOOS=linux GOARCH=arm64 go build -a -o app .
 
 FROM scratch
 WORKDIR /root/

+ 474 - 930
README.md

@@ -26,61 +26,94 @@
         <img src="https://img.shields.io/discord/780985109608005703?logo=discord"alt="Join the Discord">
     </a>
 </p>
-<h1 align="center">
-    <a href="https://github.com/get-got/discord-downloader-go/releases/latest">
-        <b>DOWNLOAD LATEST RELEASE</b>
-    </a>
-    <br/><br/>
+<h2 align="center">
     <a href="https://discord.com/invite/6Z6FJZVaDV">
         <b>Need help? Have suggestions? Join the Discord server!</b>
     </a>
-    <a href="https://github.com/get-got/discord-downloader-go/tree/rewrite">
-        <b>WORKING ON A v2.0.0 REWRITE, JOIN THE DISCORD TO TRY OUT!</b>
+    <br/><br/>
+    <a href="https://github.com/get-got/discord-downloader-go/releases/latest">
+        <b>DOWNLOAD LATEST RELEASE</b>
     </a>
-</h1>
+</h2>
+<div align="center">
+
+| Operating System  | Architectures _( ? = available but untested )_    |
+| -----------------:|:----------------------------------------------- |
+| Windows           | **amd64**, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_
+| Linux             | **amd64**, **arm64**, **armv7/6/5**,<br/>risc-v64 _(?)_, mips64/64le _(?)_, s390x _(?)_, 386 _(?)_
+| Darwin (Mac)      | amd64 _(?)_, arm64 _(?)_
+| FreeBSD           | amd64 _(?)_, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_
+| OpenBSD           | amd64 _(?)_, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_
+| NetBSD            | amd64 _(?)_, arm64 _(?)_, armv7/6/5 _(?)_, 386 _(?)_
 
-This is a program that connects to a Discord Bot or User to locally download files posted in Discord channels in real-time as well as old messages. It can download any directly linked files or Discord attachments, as well as the highest possible quality files from specific sources _(see list below)_. It also supports extensive channel-specific configuration and customization. _See [Features](#Features) below for full list!_
+</div><br>
+
+This project is a cross-platform cli single-file program to interact with a Discord Bot (genuine bot application or user account, limitations apply to both respectively) to locally download files posted from Discord in real-time as well as a full archive of old messages. It can download any directly sent Discord attachments or linked files and supports fetching highest possible quality files from specific sources _([see list below](#supported-download-sources))._ It also supports **very extensive** settings configurations and customization, applicable globally or per-server/category/channel/user. Tailor the bot to your exact needs and runtime environment. See the [Features](#-features) list below for the full list. See the [List of Settings](#-list-of-settings) below for a settings breakdown. See [Getting Started](#%EF%B8%8F-getting-started) or anything else in the table of contents right under this to learn more!
 
 <h3 align="center">
-    <b>This project is a fork of <a href="https://github.com/Seklfreak/discord-image-downloader-go">Seklfreak's <i>discord-image-downloader-go</i></a></b>
+    <b>Originally a fork of <a href="https://github.com/Seklfreak/discord-image-downloader-go">Seklfreak's <i>discord-image-downloader-go</i></a></b>
 </h3>
 <h4 align="center">
-    For list of differences and why I made an independent project, <a href="#differences-from-seklfreaks-discord-image-downloader-go--why-i-made-this"><b>see below</b></a>
+    The original project was abandoned, for a list of differences and why I made an independent project, <a href="#differences-from-seklfreaks-discord-image-downloader-go--why-i-made-this"><b>see below</b></a>
 </h4>
 
 ---
 
-### Sections
-* [**List of Features**](#features)
-* [**Getting Started**](#getting-started)
-* [**Guide: Downloading History _(Old Messages)_**](#guide-downloading-history-old-messages)
-* [**Guide: Settings / Configuration**](#guide-settings--configuration)
-* [**List of Settings**](#list-of-settings)
-* [**FAQ (Frequently Asked Questions)**](#faq)
-* [**Development, Credits, Dependencies**](#development)
+- [⚠️ **WARNING!** Discord does not allow Automated User Accounts (Self-Bots/User-Bots)](#️-warning-discord-does-not-allow-automated-user-accounts-self-botsuser-bots)
+- [🤖 Features](#-features)
+  - [Supported Download Sources](#supported-download-sources)
+  - [Commands](#commands)
+- [✔️ Getting Started](#️-getting-started)
+  - [Getting Started Step-by-Step](#getting-started-step-by-step)
+  - [Bot Login Credentials](#bot-login-credentials)
+  - [Permissions in Discord](#permissions-in-discord)
+    - [Bot Intents for Discord Application Bots](#bot-intents-for-discord-application-bots)
+      - [NECESSARY IF USING A GENUINE DISCORD APPLICATION](#necessary-if-using-a-genuine-discord-application)
+  - [How to Find Discord IDs](#how-to-find-discord-ids)
+  - [Differences from Seklfreak's _discord-image-downloader-go_ \& Why I made this](#differences-from-seklfreaks-discord-image-downloader-go--why-i-made-this)
+- [📚 Guide: Downloading History (Old Messages)](#-guide-downloading-history-old-messages)
+  - [Command Arguments](#command-arguments)
+    - [Examples](#examples)
+- [🛠 Settings / Configuration](#-settings--configuration)
+  - [EXAMPLE: ALL WITH DEFAULT VALUES](#example-all-with-default-values)
+  - [EXAMPLE: BARE MINIMUM](#example-bare-minimum)
+  - [EXAMPLE: SERVER WITH FRIENDS](#example-server-with-friends)
+  - [EXAMPLE: SCRAPING PUBLIC SERVERS](#example-scraping-public-servers)
+- [❔ FAQ](#-faq)
+- [⚙️ Development](#️-development)
 
 ---
 
-## Features
+## ⚠️ **WARNING!** Discord does not allow Automated User Accounts (Self-Bots/User-Bots)
+
+[Read more in Discord Trust & Safety Team's Official Statement...](https://support.discordapp.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-)
+
+While this project works for user logins, I do not reccomend it as you risk account termination. If you can, [use a proper Discord Bot user for this program.](https://discord.com/developers/applications)
+
+> _NOTE: This only applies to real User Accounts, not Bot users. This program currently works for either._
+
+Now that that's out of the way...
+
+---
 
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> LIST OF FEATURES & COMMANDS</b></summary>
+## 🤖 Features
 
 ### Supported Download Sources
-* Discord File Attachments
-* Direct Links to Files
-* Twitter _(requires API key, see config section)_
-* Instagram
-* Reddit
-* Imgur _(Single Posts & Albums)_
-* Flickr _(requires API key, see config section)_
-* Google Drive _(requires API Credentials, see config section)_
-* Mastodon
-* Tistory
-* Streamable
-* Gfycat
+
+- Direct Links to Files
+- Discord File Attachments
+- Twitter _(requires account login, see config section)_
+- Instagram _(requires account login, see config section)_
+- Reddit
+- Imgur
+- Streamable
+- Gfycat
+- Tistory
+- Flickr _(requires API key, see config section)_
+- _I'll always welcome requests but some sources can be tricky to parse..._
   
 ### Commands
+
 Commands are used as `ddg <command> <?arguments?>` _(unless you've changed the prefix)_
 Command     | Arguments? | Description
 ---         | ---   | ---
@@ -89,31 +122,20 @@ Command     | Arguments? | Description
 `info`      | No    | Displays relevant Discord info.
 `status`    | No    | Shows the status of the bot.
 `stats`     | No    | Shows channel stats.
-`history`   | [**SEE HISTORY SECTION**](#guide-downloading-history-old-messages) | **(BOT AND SERVER ADMINS ONLY)** Processes history for old messages in channel.
+`history`   | [**SEE HISTORY SECTION**](#-guide-downloading-history-old-messages) | **(BOT AND SERVER ADMINS ONLY)** Processes history for old messages in channel.
 `exit`, `kill`, `reload`    | No    | **(BOT ADMINS ONLY)** Exits the bot _(or restarts if using a keep-alive process manager)_.
 `emojis`    | Optionally specify server IDs to download emojis from; separate by commas | **(BOT ADMINS ONLY)** Saves all emojis for channel.
 
-</details>
-
 ---
 
-## **WARNING!** Discord does not allow Automated User Accounts (Self-Bots/User-Bots)
-[Read more in Discord Trust & Safety Team's Official Statement...](https://support.discordapp.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-)
-
-While this project works for user logins, I do not reccomend it as you risk account termination. If you can, [use a proper Discord Bot user for this program.](https://discord.com/developers/applications)
-
-> _NOTE: This only applies to real User Accounts, not Bot users. This program currently works for either._
-
----
-
-## Getting Started
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> GETTING STARTED, HOW-TO, OTHER INFO...</b></summary>
+## ✔️ Getting Started
 
 _Confused? Try looking at [the step-by-step list.](#getting-started-step-by-step)_
 
 Depending on your purpose for this program, there are various ways you can run it.
+
 - [Run the executable file for your platform. _(Process managers like **pm2** work well for this)_](https://github.com/get-got/discord-downloader-go/releases/latest)
+- [Run the executable file via command prompt. _(`discord-downloader-go.exe settings2` or similar to run multiple instances sharing a database with separate settings files)_](https://github.com/get-got/discord-downloader-go/releases/latest)
 - [Run automated image builds in Docker.](https://hub.docker.com/r/getgot/discord-downloader-go) _(Google it)._
   - Mount your settings.json to ``/root/settings.json``
   - Mount a folder named "database" to ``/root/database``
@@ -122,70 +144,77 @@ Depending on your purpose for this program, there are various ways you can run i
 - Install Golang and compile/run the source code yourself. _(Google it)_
 
 You can either create a `settings.json` following the examples & variables listed below, or have the program create a default file (if it is missing when you run the program, it will make one, and ask you if you want to enter in basic info for the new file).
+
 - [Ensure you follow proper JSON syntax to avoid any unexpected errors.](https://www.w3schools.com/js/js_json_syntax.asp)
 - [Having issues? Try this JSON Validator to ensure it's correctly formatted.](https://jsonformatter.curiousconcept.com/)
 
+[![Tutorial Video](http://img.youtube.com/vi/06UUXDQ80f8/0.jpg)](http://www.youtube.com/watch?v=06UUXDQ80f8)
+
 ### Getting Started Step-by-Step
+
 1. Download & put executable within it's own folder.
-2. Configure Main Settings (or run once to have settings generated). [_(SEE BELOW)_](#list-of-settings)
-3. Enter your login credentials in the `"credentials"` section. [_(SEE BELOW)_](#list-of-settings)
-4. Put your Discord User ID as in the `"admins"` list of the settings. [_(SEE BELOW)_](#list-of-settings)
-5. Put a Discord Channel ID for a private channel you have access to into the `"adminChannels"`. [_(SEE BELOW)_](#list-of-settings)
-6. Put your desired Discord Channel IDs into the `"channels"` section. [_(SEE BELOW)_](#list-of-settings)
-- I know it can be confusing if you don't have experience with programming or JSON in general, but this was the ideal setup for extensive configuration like this. Just be careful with comma & quote placement and you should be fine. [See examples below for help.](#settings-examples)
-
-### Bot Login Credentials...
+2. Configure Main Settings (or run once to have settings generated). [_(SEE BELOW)_](#-list-of-settings)
+3. Enter your login credentials in the `"credentials"` section. [_(SEE BELOW)_](#-list-of-settings)
+4. Put your Discord User ID as in the `"admins"` list of the settings. [_(SEE BELOW)_](#-list-of-settings)
+5. Put a Discord Channel ID for a private channel you have access to into the `"adminChannels"`. [_(SEE BELOW)_](#-list-of-settings)
+6. Put your desired Discord Channel IDs into th6e `"channels"` section. [_(SEE BELOW)_](#-list-of-settings)
+   - I know it can be confusing if you don't have experience with programming or JSON in general, but this was the ideal setup for extensive configuration like this. Just be careful with comma & quote placement and you should be fine. [See examples below for help.](#-settings-examples)
+
+### Bot Login Credentials
+
 - If using a **Bot Application,** enter the token into the `"token"` setting. Remove the lines for `"username"` and `"password"` or leave blank (`""`). **To create a Bot User,** go to [discord.com/developers/applications](https://discord.com/developers/applications) and create a `New Application`. Once created, go to `Bot` and create. The token can be found on the `Bot` page. To invite to your server(s), go to `OAuth2` and check `"bot"`, copy the url, paste into browser and follow prompts for adding to server(s).
 - If using a **User Account (Self-Bot) WITHOUT 2FA (2-Factor Authentication),** fill out the `"username"` and `"password"` settings. Remove the line for `"token"` or leave blank (`""`).
 - If using a **User Account (Self-Bot) WITH 2FA (2-Factor Authentication),** enter the token into the `"token"` setting. Remove the lines for `"username"` and `"password"` or leave blank (`""`). Your account token can be found by `opening the browser console / dev tools / inspect element` > `Network` tab > `filter for "library"` and reload the page if nothing appears. Assuming there is an item that looks like the below screenshot, click it and `find "Authorization" within the "Request Headers" section` of the Headers tab. The random text is your token.
 
 <img src="https://i.imgur.com/2BdaJSH.png"> <img src="https://i.imgur.com/i9DItcH.png">
 
-### Bot Permissions in Discord...
-* In order to perform basic downloading functions, the bot will need `Read Message` permissions in the server(s) of your designated channel(s).
-* In order to respond to commands, the bot will need `Send Message` permissions in the server(s) of your designated channel(s). If executing commands via an Admin Channel, the bot will only need `Send Message` permissions for that channel, and that permission will not be required for the source channel.
-* In order to process history commands, the bot will need `Read Message History` permissions in the server(s) of your designated channel(s).
+### Permissions in Discord
+
+- In order to perform basic downloading functions, the bot will need `Read Message` permissions in the server(s) of your designated channel(s).
+- In order to respond to commands, the bot will need `Send Message` permissions in the server(s) of your designated channel(s). If executing commands via an Admin Channel, the bot will only need `Send Message` permissions for that channel, and that permission will not be required for the source channel.
+- In order to process history commands, the bot will need `Read Message History` permissions in the server(s) of your designated channel(s).
+
+#### Bot Intents for Discord Application Bots
 
-#### NOTE: GENUINE DISCORD BOTS REQUIRE PERMISSIONS ENABLED!
-* Go to the Discord Application management page, choose your application, go to the `Bot` category, and ensure `Message Content Intent` is enabled.
+##### NECESSARY IF USING A GENUINE DISCORD APPLICATION
+
+- Go to the Discord Application management page, choose your application, go to the `Bot` category, and ensure `Message Content Intent` is enabled.
 
 <img src="https://i.imgur.com/2GcyA2B.png"/>
 
-### How to Find Discord IDs...
-* ***Use the info command!***
-* **Discord Developer Mode:** Enable `Developer Mode` in Discord settings under `Appearance`.
-* **Finding Channel ID:** _Enable Discord Developer Mode (see above),_ right click on the channel and `Copy ID`.
-* **Finding User ID:** _Enable Discord Developer Mode (see above),_ right click on the user and `Copy ID`.
-* **Finding Emoji ID:** _Enable Discord Developer Mode (see above),_ right click on the emoji and `Copy ID`.
-* **Finding DM/PM ID:** Inspect Element on the DM icon for the desired user. Look for `href="/channels/@me/CHANNEL_ID_HERE"`. Using this ID in place of a normal channel ID should work perfectly fine.
+### How to Find Discord IDs
+
+- **_Use the info command!_**
+- **Discord Developer Mode:** Enable `Developer Mode` in Discord settings under `Appearance`.
+- **Finding Channel ID:** _Enable Discord Developer Mode (see above),_ right click on the channel and `Copy ID`.
+- **Finding User ID:** _Enable Discord Developer Mode (see above),_ right click on the user and `Copy ID`.
+- **Finding Emoji ID:** _Enable Discord Developer Mode (see above),_ right click on the emoji and `Copy ID`.
+- **Finding DM/PM ID:** Inspect Element on the DM icon for the desired user. Look for `href="/channels/@me/CHANNEL_ID_HERE"`. Using this ID in place of a normal channel ID should work perfectly fine.
 
 ---
 
 ### Differences from [Seklfreak's _discord-image-downloader-go_](https://github.com/Seklfreak/discord-image-downloader-go) & Why I made this
-* _Better command formatting & support_
-* Configuration is JSON-based rather than ini to allow more elaborate settings and better organization. With this came many features such as channel-specific settings.
-* Channel-specific control of downloaded filetypes / content types (considers things like .mov as videos as well, rather than ignore them), Optional dividing of content types into separate folders.
-* **Download Support for Reddit & Mastodon.**
-* (Optional) Reactions upon download success.
-* (Optional) Discord messages upon encountered errors.
-* Extensive bot status/presence customization.
-* Consistent Log Formatting, Color-Coded Logging
-* Somewhat different organization than original project; initially created from scratch then components ported over.
-* _Various fixes, improvements, and dependency updates that I also contributed to Seklfreak's original project._
 
-> I've been a user of Seklfreak's project since ~2018 and it's been great for my uses, but there were certain aspects I wanted to expand upon, one of those being customization of channel configuration, and other features like message reactions upon success, differently formatted statuses, etc. If some aspects are rudimentary or messy, please make a pull request, as this is my first project using Go and I've learned everything from observation & Stack Overflow.
+- _Better command formatting & support_
+- Configuration is JSON-based rather than ini to allow more elaborate settings and better organization. With this came many features such as channel-specific settings.
+- Channel-specific control of downloaded filetypes / content types (considers things like .mov as videos as well, rather than ignore them), Optional dividing of content types into separate folders.
+- (Optional) Reactions upon download success.
+- (Optional) Discord messages upon encountered errors.
+- Extensive bot status/presence customization.
+- Consistent Log Formatting, Color-Coded Logging
+- Somewhat different organization than original project; initially created from scratch then components ported over.
+- _Various fixes, improvements, and dependency updates that I also contributed to Seklfreak's original project._
 
-</details>
+> I've been a user of Seklfreak's project since ~2018 and it's been great for my uses, but there were certain aspects I wanted to expand upon, one of those being customization of channel configuration, and other features like message reactions upon success, differently formatted statuses, etc. If some aspects are rudimentary or messy, please make a pull request, as this is my first project using Go and I've learned everything from observation & Stack Overflow.
 
 ---
 
-## Guide: Downloading History (Old Messages)
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> HISTORY GUIDE</b></summary>
+## 📚 Guide: Downloading History (Old Messages)
 
 > This guide is to show you how to make the bot go through all old messages in a channel and catalog them as though they were being sent right now, in order to download them all.
 
 ### Command Arguments
+
 If no channel IDs are specified, it will try and use the channel ID for the channel you're using the command in.
 
 Argument / Flag         | Details
@@ -193,920 +222,435 @@ Argument / Flag         | Details
 **channel ID(s)**       | One or more channel IDs, separated by commas if multiple.
 `all`                   | Use all available registered channels.
 `cancel` or `stop`      | Stop downloading history for specified channel(s).
+`list` or `status`      | Output running history jobs in Discord & program.
 `--since=YYYY-MM-DD`    | Will process messages sent after this date.
 `--since=message_id`    | Will process messages sent after this message.
 `--before=YYYY-MM-DD`   | Will process messages sent before this date.
 `--before=message_id`   | Will process messages sent before this message.
 
-***Order of arguments does not matter.***
+**_Order of arguments does not matter_**
 
 #### Examples
-* `ddg history`
-* `ddg history cancel`
-* `ddg history all`
-* `ddg history stop all`
-* `ddg history 000111000111000`
-* `ddg history 000111000111000, 000222000222000`
-* `ddg history 000111000111000,000222000222000,000333000333000`
-* `ddg history 000111000111000, 000333000333000 cancel`
-* `ddg history 000111000111000 --before=000555000555000`
-* `ddg history 000111000111000 --since=2020-01-02`
-* `ddg history 000111000111000 --since=2020-10-12 --before=2021-05-06`
-* `ddg history 000111000111000 --since=000555000555000 --before=2021-05-06`
-
-</details>
+
+- `ddg history`
+- `ddg history cancel`
+- `ddg history all`
+- `ddg history stop all`
+- `ddg history 000111000111000`
+- `ddg history 000111000111000, 000222000222000`
+- `ddg history 000111000111000,000222000222000,000333000333000`
+- `ddg history 000111000111000, 000333000333000 cancel`
+- `ddg history 000111000111000 --before=000555000555000`
+- `ddg history 000111000111000 --since=2020-01-02`
+- `ddg history 000111000111000 --since=2020-10-12 --before=2021-05-06`
+- `ddg history 000111000111000 --since=000555000555000 --before=2021-05-06`
+- `ddg history status`
+- `ddg history list`
 
 ---
 
-## Guide: Settings / Configuration
-> I tried to make the configuration as user friendly as possible, though you still need to follow proper JSON syntax (watch those commas). All settings specified below labeled `[DEFAULTS]` will use default values if missing from the settings file, and those labeled `[OPTIONAL]` will not be used if missing from the settings file.
+## 🛠 Settings / Configuration
+
+> I tried to make the configuration as user friendly as possible, though you still need to follow proper JSON syntax **(watch those commas)**. Most settings are optional and will use default values or be unused if missing from your settings file.
 
 When initially launching the bot it will create a default settings file if you do not create your own `settings.json` manually. All JSON settings follow camelCase format.
 
 **If you have a ``config.ini`` from _Seklfreak's discord-image-downloader-go_, it will import settings if it's in the same folder as the program.**
 
-### Settings Examples
-The following example is for a Bot Application _(using a token)_, bound to 1 channel.
+The bot accepts `.json` or `.jsonc` for comment-friendly json.
 
-This setup exempts many options so they will use default values _(see below)_. It shows the bare minimum required settings for the bot to function.
+---
 
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> SETTINGS EXAMPLE - Barebones:</b></summary>
+### EXAMPLE: ALL WITH DEFAULT VALUES
 
-```javascript
+**DO NOT LEAVE COMMAS `,` AFTER THE LAST ITEM IN A SECTION. JSON DOES NOT LIKE THIS, IT WILL ERROR.**
+
+```json
 {
     "credentials": {
-        "token": "YOUR_TOKEN"
+        "token": "YOUR_USER_OR_BOT_TOKEN",        // user with 2FA or genuine Discord bot.
+        "email": "YOUR_USER_EMAIL_NO_2FA",        // user without 2FA.
+        "password": "YOUR_USER_PASSWORD_NO_2FA",  // user without 2FA.
+        "twitterUsername": "", // Twitter Account, not required but can be used for scraping private accounts.
+        "twitterPassword": "", // Twitter Account, not required but can be used for scraping private accounts.
+        "instagramUsername": "", // Instagram Account, captcha required at some point(s).
+        "instagramPassword": "", // Instagram Account, captcha required at some point(s).
+        "flickrApiKey": ""
     },
-    "channels": [
+
+    "admins": [
+        "YOUR_DISCORD_USER_ID",
+        "YOUR_FRIENDS_DISCORD_USER_ID",
+        "YOUR_OTHER_FRIENDS_DISCORD_USER_ID"
+    ],
+    "adminChannels": [
         {
-            "channel": "DISCORD_CHANNEL_ID_TO_DOWNLOAD_FROM",
-            "destination": "FOLDER_LOCATION_TO_DOWNLOAD_TO"
+            "channel": "DISCORD_CHANNEL_ID_FOR_COMMANDS",
+            "logProgram": false,      // output most program logging to channel (this is typically a lot)
+            "logStatus": true,        // output program status updates to channel
+            "logErrors": true,        // output download errors to channel
+            "unlockCommands": false   // allow non-admins to use commands in this channel
         }
-    ]
-}
-```
+    ],
 
-</details>
+    "processLimit": 32, // # of concurrent tasks allowed at once.
+
+    "debug": false,                 // detailed program info.
+    "backupDatabaseOnStart": false, // backup database folder to dated zip file in local "backups" folder.
+    "watchSettings": true,          // update settings live if file is modified.
+    "settingsOutput": true,         // display parsed settings on load.
+    "messageOutput": true,          // display messages being processed in real time.
+    "messageOutputHistory": false,  // display messages being processed while running history.
+
+    "discordLogLevel": 0,         // discord API log leveling setting.
+    "discordTimeout": 180,        // seconds to wait before giving up on a stale Discord connection.
+    "downloadTimeout": 60,        // seconds to wait before giving up on a download.
+    "downloadRetryMax": 3,        // times to retry a failed download.
+    "exitOnBadConnection": false, // kill the program when encountering a faulty Discord connection rather than retrying.
+    "githubUpdateChecking": true, // check for program updates on launch.
+
+    "commandPrefix": "ddg ",      // prefix for discord commands.
+    "scanOwnMessages": false,     // checks messages of authenticated user, should be false if genuine bot, true if selfbot.
+    "allowGeneralCommands": true, // ping/help/info/etc.
+    "inflateDownloadCount": 0,    // +/- displayed download tally.
+    "europeanNumbers": false,     // 1.000.000,00 (disgusting) versus 1,000,000.00 (normal).
+
+    "checkupRate": 30,        // minutes to print checkup line in program.
+    "connectionCheckRate": 5, // minutes to check Discord connection for failure.
+    "presenceRefreshRate": 3, // minutes to refresh Discord presence, sometimes it randomly goes blank.
+
+    "save": true,
+    "allowCommands": true,  
+    "scanEdits": true,
+    "ignoreBots": true,
+
+    "sendErrorMessages": false,
+    "sendFileToChannel": "",      // Forward detected media to channel.
+    "sendFileToChannels": [ "" ], // ^.
+    "sendFileDirectly": true,     // Send direct file as attachment or embed in message.
+    "sendFileCaption": "",        // Caption to accompany media.
+
+    "filenameDateFormat": "2006-01-02_15-04-05",  // Format for {{date}}.
+    "filenameFormat": "{{date}} {{file}}",
 
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> SETTINGS EXAMPLE - Selfbot:</b></summary>
+    "presenceEnabled": true,
+    "presenceStatus": "idle", // "online" or "idle" or "dnd" or "invisible".
+    "presenceType": 0,        // 0 = Playing, 1 = Streaming, 2 = Listening, 3 = Watching.
+    "presenceLabel": "{{timeSavedShort}} - {{countShort}} files",
+    "presenceDetails": "{{timeSavedLong}}",       // user accounts only.
+    "presenceDetails": "{{count}} files total",   // user accounts only.
+
+    "reactWhenDownloaded": false,          // react to messages downloaded from.
+    "reactWhenDownloadedEmoji": "",       // specify emoji for reactions, random otherwise.
+    "reactWhenDownloadedHistory": false,  // react to messages downloaded from in history jobs.
+    "historyTyping": true,  // show user as typing when processing history.
+    "embedColor": "",       // override embed color, role color by default.
+
+    "historyMaxJobs": 3,      // # of history jobs allowed to run concurrently, constrained by processLimit above.
+    "autoHistory": false,     // automatically run history on launch.
+    "autoHistoryBefore": "",  // YYYY-MM-DD for date filtering.
+    "autoHistorySince": "",   // YYYY-MM-DD for date filtering.
+    "sendAutoHistoryStatus": false, // send status message for auto history jobs.
+    "sendHistoryStatus": true, // send status message for history jobs.
+
+    "divideByYear": false,
+    "divideByMonth": false,
+    "divideByServer": false,
+    "divideByChannel": false,
+    "divideByUser": false,
+    "divideByType": true, // images/videos/audio/text/other.
+    "divideFoldersUseID": false, // use Discord IDs rather than user/server/channel names.
+    "saveImages": true,
+    "saveVideos": true,
+    "saveAudioFiles": true,
+    "saveTextFiles": false,
+    "saveOtherFiles": false,
+    "savePossibleDuplicates": true, // save if exact filename already exists.
+    "filters": {
+      // when "allowed" options are enabled, nothing is allowed unless it meets their criteria.
+        "blockedPhrases": [ "" ],
+        "allowedPhrases": [ "" ],
+
+        "blockedUsers": [ "" ], // by Discord ID.
+        "allowedUsers": [ "" ], // by Discord ID.
+
+        "blockedRoles": [ "" ], // by Discord ID.
+        "allowedRoles": [ "" ], // by Discord ID.
+
+        "blockedDomains": [ "" ],
+        "allowedDomains": [ "" ],
+
+        "blockedExtensions": [ ".htm", ".html", ".php", ".exe", ".dll", ".bin", ".cmd", ".sh", ".py", ".jar" ],
+        "allowedExtensions": [ "" ],
+
+        "blockedFilenames": [ "" ], // List of phrases to check for in original filename, pre-formatting.
+        "allowedFilenames": [ "" ],
+
+        "blockedReactions": [ "" ], // List of reactions to block or allow, by ID only.
+        "allowedReactions": [ "" ]
+    },
+    // duplicate image filtering
+    // caching of image data is stored via a database file; it will not read all pre-existing images.
+    "duplo": false,
+    // threshold for what the bot considers too similar of an image comparison score.
+    // * lower = more similar (lowest is around -109.7).
+    // * higher = less similar (does not really have a maximum, would require your own testing).
+    // if score is lower than your threshold, the image will be skipped.
+    "duploThreshold": 0,
 
-```javascript
-{
-    "credentials": {
-        "email": "REPLACE_WITH_YOUR_EMAIL",
-        "password": "REPLACE_WITH_YOUR_PASSWORD"
+    "all": {
+        "destination": "FOLLOW_CHANNELS_BELOW_FOR_REST"
     },
-    "scanOwnMessages": true,
-    "presenceEnabled": false,
-    "channels": [
+    "allBlacklistUsers": [ "" ],
+    "allBlacklistServers": [ "" ],
+    "allBlacklistCategories": [ "" ],
+    "allBlacklistChannels": [ "" ],
+
+    "users": [
         {
-            "channel": "DISCORD_CHANNEL_ID_TO_DOWNLOAD_FROM",
-            "destination": "FOLDER_LOCATION_TO_DOWNLOAD_TO",
-            "allowCommands": false,
-            "errorMessages": false,
-            "reactWhenDownloaded": false
+            "user": "SOURCE_DISCORD_USER_ID",
+            "users": [ "SOURCE_DISCORD_USER_ID" ],
+            "destination": "FOLLOW_CHANNELS_BELOW_FOR_REST"
         }
-    ]
-}
-```
-
-</details>
+    ],
 
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> SETTINGS EXAMPLE - Advanced:</b></summary>
+    "servers": [
+        {
+            "server": "SOURCE_DISCORD_SERVER_ID",
+            "servers": [ "SOURCE_DISCORD_SERVER_ID" ],
+            "serverBlacklist": [ "DISCORD_CHANNELS_TO_BLOCK" ],
+            "destination": "FOLLOW_CHANNELS_BELOW_FOR_REST"
+        }
+    ],
 
-```javascript
-{
-    "credentials": {
-        "token": "YOUR_TOKEN",
-        "twitterAccessToken": "aaa",
-        "twitterAccessTokenSecret": "bbb",
-        "twitterConsumerKey": "ccc",
-        "twitterConsumerSecret": "ddd"
-    },
-    "admins": [ "YOUR_DISCORD_USER_ID", "YOUR_FRIENDS_DISCORD_USER_ID" ],
-    "adminChannels": [
+    "categories": [
         {
-            "channel": "CHANNEL_ID_FOR_ADMIN_CONTROL"
+            "category": "SOURCE_DISCORD_CATEGORY_ID",
+            "categories": [ "SOURCE_DISCORD_CATEGORY_ID" ],
+            "categoryBlacklist": [ "DISCORD_CHANNELS_TO_BLOCK" ],
+            "destination": "FOLLOW_CHANNELS_BELOW_FOR_REST"
         }
     ],
-    "debugOutput": true,
-    "commandPrefix": "downloader_",
-    "allowSkipping": true,
-    "allowGlobalCommands": true,
-    "asyncHistory": false,
-    "downloadRetryMax": 5,
-    "downloadTimeout": 120,
-    "githubUpdateChecking": true,
-    "discordLogLevel": 2,
-    "filterDuplicateImages": true,
-    "filterDuplicateImagesThreshold": 75,
-    "presenceEnabled": true,
-    "presenceStatus": "dnd",
-    "presenceType": 3,
-    "presenceOverwrite": "{{count}} files",
-    "filenameFormat": "{{date}} {{file}}",
-    "filenameDateFormat": "2006.01.02-15.04.05 ",
-    "embedColor": "#EE22CC",
-    "inflateCount": 12345,
+
     "channels": [
         {
-            "channel": "THIS_CHANNEL_ONLY_DOWNLOADS_MEDIA",
-            "destination": "media",
-            "overwriteAllowSkipping": false,
+            "channel": "SOURCE_DISCORD_CHANNEL_ID",
+            "channels": [ "SOURCE_DISCORD_CHANNEL_ID" ],
+            "destination": "files/example-folder",
+
+            "enabled": true,
+            "save": true,
+            "allowCommands": true,
+            "scanEdits": true,
+            "ignoreBots": true,
+
+            "sendErrorMessages": false,
+            "sendFileToChannel": "",
+            "sendFileToChannels": [ "" ],
+            "sendFileDirectly": true,
+            "sendFileCaption": "",
+
+            "filenameDateFormat": "2006-01-02_15-04-05",
+            "filenameFormat": "{{date}} {{file}}",
+
+            "presenceEnabled": true,
+            "reactWhenDownloaded": false,
+            "reactWhenDownloadedEmoji": "",
+            "reactWhenDownloadedHistory": false,
+            "blacklistReactEmojis": [ "" ],
+            "historyTyping": true,
+            "embedColor": "",
+
+            "autoHistory": false,
+            "autoHistoryBefore": "",
+            "autoHistorySince": "",
+            "sendAutoHistoryStatus": false,
+            "sendHistoryStatus": true,
+
+            "divideByYear": false,
+            "divideByMonth": false,
+            "divideByServer": false,
+            "divideByChannel": false,
+            "divideByUser": false,
+            "divideByType": true,
+            "divideFoldersUseID": false,
             "saveImages": true,
             "saveVideos": true,
             "saveAudioFiles": true,
             "saveTextFiles": false,
-            "saveOtherFiles": false
-        },
-        {
-            "channel": "THIS_CHANNEL_IS_STEALTHY",
-            "destination": "stealthy",
-            "allowCommands": false,
-            "errorMessages": false,
-            "updatePresence": false,
-            "reactWhenDownloaded": false
-        },
-        {
-            "channels": [ "CHANNEL_1", "CHANNEL_2", "CHANNEL_3", "CHANNEL_4", "CHANNEL_5" ],
-            "destination": "stuff",
-            "allowCommands": false,
-            "errorMessages": false,
-            "updatePresence": false,
-            "reactWhenDownloaded": false
+            "saveOtherFiles": false,
+            "savePossibleDuplicates": true,
+            "filters": {
+                "blockedPhrases": [ "" ],
+                "allowedPhrases": [ "" ],
+
+                "blockedUsers": [ "" ],
+                "allowedUsers": [ "" ],
+
+                "blockedRoles": [ "" ],
+                "allowedRoles": [ "" ],
+
+                "blockedExtensions": [ ".htm", ".html", ".php", ".exe", ".dll", ".bin", ".cmd", ".sh", ".py", ".jar" ],
+                "allowedExtensions": [ "" ],
+
+                "blockedDomains": [ "" ],
+                "allowedDomains": [ "" ]
+            },
+            "duplo": false,
+            "duploThreshold": 0,
+
+            "logLinks": {
+                "destination": "",
+                "destinationIsFolder": false,
+
+                "divideLogsByServer": true,
+                "divideLogsByChannel": true,
+                "divideLogsByUser": false,
+                "divideLogsByStatus": false,
+                
+                "logDownloads": true,
+                "logFailures": true,
+
+                "filterDuplicates": false,
+                "prefix": "",
+                "suffix": "",
+                "userData": false
+            },
+
+            "logMessages": {
+                "destination": "",
+                "destinationIsFolder": false,
+
+                "divideLogsByServer": true,
+                "divideLogsByChannel": true,
+                "divideLogsByUser": false,
+
+                "filterDuplicates": false,
+                "prefix": "",
+                "suffix": "",
+                "userData": false
+            }
+
         }
+
     ]
 }
 ```
 
-</details>
+### EXAMPLE: BARE MINIMUM
 
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> SETTINGS EXAMPLE - Pretty Much Everything:</b></summary>
-
-```javascript
+```json
 {
-    "_constants": {
-        "DOWNLOAD_FOLDER":              "X:/Discord Downloads",
-        "MY_TOKEN":                     "aaabbbccc111222333",
-        "TWITTER_ACCESS_TOKEN_SECRET":  "aaabbbccc111222333",
-        "TWITTER_ACCESS_TOKEN":         "aaabbbccc111222333",
-        "TWITTER_CONSUMER_KEY":         "aaabbbccc111222333",
-        "TWITTER_CONSUMER_SECRET":      "aaabbbccc111222333",
-        "FLICKR_API_KEY":               "aaabbbccc111222333",
-        "GOOGLE_DRIVE_CREDS":           "googleDriveCreds.json",
-
-        "MY_USER_ID":       "000111222333444555",
-        "BOBS_USER_ID":     "000111222333444555",
-
-        "SERVER_MAIN":               "000111222333444555",
-        "CHANNEL_MAIN_GENERAL":      "000111222333444555",
-        "CHANNEL_MAIN_MEMES":        "000111222333444555",
-        "CHANNEL_MAIN_SPAM":         "000111222333444555",
-        "CHANNEL_MAIN_PHOTOS":       "000111222333444555",
-        "CHANNEL_MAIN_ARCHIVE":      "000111222333444555",
-        "CHANNEL_MAIN_BOT_ADMIN":    "000111222333444555",
-
-        "SERVER_BOBS":              "000111222333444555",
-        "CHANNEL_BOBS_GENERAL":     "000111222333444555",
-        "CHANNEL_BOBS_MEMES":       "000111222333444555",
-        "CHANNEL_BOBS_SPAM":        "000111222333444555",
-        "CHANNEL_BOBS_BOT_ADMIN":   "000111222333444555",
-
-        "SERVER_GAMERZ":                "000111222333444555",
-        "CHANNEL_GAMERZ_GENERAL":       "000111222333444555",
-        "CHANNEL_GAMERZ_MEMES":         "000111222333444555",
-        "CHANNEL_GAMERZ_VIDEOS":        "000111222333444555",
-        "CHANNEL_GAMERZ_SPAM":          "000111222333444555",
-        "CHANNEL_GAMERZ_SCREENSHOTS":   "000111222333444555"
-    },
     "credentials": {
-        "token": "MY_TOKEN",
-        "userBot": true,
-        "twitterAccessToken": "TWITTER_ACCESS_TOKEN",
-        "twitterAccessTokenSecret": "TWITTER_ACCESS_TOKEN_SECRET",
-        "twitterConsumerKey": "TWITTER_CONSUMER_KEY",
-        "twitterConsumerSecret": "TWITTER_CONSUMER_SECRET",
-        "flickrApiKey": "FLICKR_API_KEY",
-        "googleDriveCredentialsJSON": "GOOGLE_DRIVE_CREDS"
+        "token": "YOUR_USER_OR_BOT_TOKEN",
+        "email": "YOUR_USER_EMAIL_NO_2FA",
+        "password": "YOUR_USER_PASSWORD_NO_2FA"
     },
-    "admins": [ "MY_USER_ID", "BOBS_USER_ID" ],
-    "adminChannels": [
-        {
-            "channel": "CHANNEL_MAIN_BOT_ADMIN"
-        },
+
+    "admins": [ "YOUR_DISCORD_USER_ID" ],
+    "adminChannels": [{ "channel": "DISCORD_CHANNEL_ID_FOR_COMMANDS" }],
+
+    "channels": [
         {
-            "channel": "CHANNEL_BOBS_BOT_ADMIN"
+            "channel": "SOURCE_DISCORD_CHANNEL_ID",
+            "destination": "files/example-folder"
         }
-    ],
-    "debugOutput": true,
-    "commandPrefix": "d_",
-    "allowSkipping": true,
-    "scanOwnMessages": true,
-    "checkPermissions": false,
-    "allowGlobalCommands": false,
-    "autorunHistory": true,
-    "autorunHistoryBefore": "2022-02-05",
-    "autorunHistorySince": "2020-02-05",
-    "asyncHistory": false,
-    "downloadRetryMax": 5,
-    "downloadTimeout": 120,
-    "discordLogLevel": 3,
-    "githubUpdateChecking": false,
-    "filterDuplicateImages": true,
-    "filterDuplicateImagesThreshold": 50,
+    ]
+}
+```
+
+### EXAMPLE: SERVER WITH FRIENDS
+
+```json
+{
+    "credentials": {
+        "token": "YOUR_USER_OR_BOT_TOKEN",
+        "email": "YOUR_USER_EMAIL_NO_2FA",
+        "password": "YOUR_USER_PASSWORD_NO_2FA"
+    },
+
+    "admins": [ "YOUR_DISCORD_USER_ID" ],
+    "adminChannels": [{ "channel": "DISCORD_CHANNEL_ID_FOR_COMMANDS" }],
+
+    "save": true,
+    "allowCommands": true,
+    "scanEdits": true,
+    "ignoreBots": false,
+
+    "sendErrorMessages": true,
+
     "presenceEnabled": true,
     "presenceStatus": "idle",
-    "presenceType": 3,
-    "presenceOverwrite": "{{count}} things",
-    "presenceOverwriteDetails": "these are my details",
-    "presenceOverwriteState": "this is my state",
-    "filenameFormat": "{{date}} {{file}}",
-    "filenameDateFormat": "2006.01.02_15.04.05_",
-    "embedColor": "#FF0000",
-    "inflateCount": 69,
-    "numberFormatEuropean": true,
-    "all": {
-        "destination": "DOWNLOAD_FOLDER/Unregistered",
-        "allowCommands": false,
-        "errorMessages": false,
-        "scanEdits": true,
-        "ignoreBots": false,
-        "overwriteAutorunHistory": false,
-        "overwriteAutorunHistoryBefore": "2022-02-05",
-        "overwriteAutorunHistorySince": "2020-02-05",
-        "updatePresence": false,
-        "reactWhenDownloaded": false,
-        "typeWhileProcessing": false,
-        "divideFoldersByServer": true,
-        "divideFoldersByChannel": true,
-        "divideFoldersByUser": false,
-        "divideFoldersByType": false,
-        "saveImages": true,
-        "saveVideos": true,
-        "saveAudioFiles": true,
-        "saveTextFiles": false,
-        "saveOtherFiles": true,
-        "savePossibleDuplicates": true,
-        "filters": {
-            "blockedExtensions": [
-                ".htm",
-                ".html",
-                ".php",
-                ".bat",
-                ".sh",
-                ".jar",
-                ".exe"
-            ]
-        },
-        "logLinks": {
-            "destination": "log_links",
-            "destinationIsFolder": true,
-            "divideLogsByServer": true,
-            "divideLogsByChannel": true,
-            "divideLogsByUser": true,
-            "userData": true
-        },
-        "logMessages": {
-            "destination": "log_messages",
-            "destinationIsFolder": true,
-            "divideLogsByServer": true,
-            "divideLogsByChannel": true,
-            "divideLogsByUser": true,
-            "userData": true
-        }
-    },
-    "allBlacklistChannels": [ "CHANNEL_I_DONT_LIKE", "OTHER_CHANNEL_I_DONT_LIKE" ],
-    "allBlacklistServers": [ "SERVER_MAIN", "SERVER_BOBS" ],
-    "servers": [
-        {
-            "server": "SERVER_MAIN",
-            "destination": "DOWNLOAD_FOLDER/- My Server",
-            "divideFoldersByChannel": true
-        },
-        {
-            "servers": [ "SERVER_BOBS", "SERVER_GAMERZ" ],
-            "destination": "DOWNLOAD_FOLDER/- Friends Servers",
-            "divideFoldersByServer": true,
-            "divideFoldersByChannel": true
-        }
-    ],
+    "presenceType": 0,
+    "presenceLabel": "{{timeSavedShort}} - {{countShort}} files",
+    "presenceDetails": "{{timeSavedLong}}",
+    "presenceDetails": "{{count}} files total",
+
+    "reactWhenDownloaded": true,
+    "reactWhenDownloadedHistory": true,
+    "historyTyping": true,
+
     "channels": [
         {
-            "channel": "CHANNEL_MAIN_SPAM",
-            "destination": "DOWNLOAD_FOLDER/Spam",
-            "overwriteAllowSkipping": false,
-            "saveImages": true,
-            "saveVideos": true,
-            "saveAudioFiles": true,
-            "saveTextFiles": false,
-            "saveOtherFiles": false
-        },
-        {
-            "channel": "CHANNEL_BOBS_SPAM",
-            "destination": "DOWNLOAD_FOLDER/Spam - Bob",
-            "overwriteAllowSkipping": false,
-            "saveImages": true,
-            "saveVideos": true,
-            "saveAudioFiles": true,
-            "saveTextFiles": false,
-            "saveOtherFiles": false
-        },
-        {
-            "channels": [ "CHANNEL_MAIN_MEMES", "CHANNEL_BOBS_MEMES", "CHANNEL_GAMERZ_MEMES" ],
-            "destination": "DOWNLOAD_FOLDER/Our Memes",
-            "allowCommands": true,
-            "errorMessages": true,
-            "updatePresence": true,
-            "reactWhenDownloaded": true,
-            "saveImages": true,
-            "saveVideos": true,
-            "saveAudioFiles": false,
-            "saveTextFiles": false,
-            "saveOtherFiles": true
+            "channel": "SOURCE_DISCORD_CHANNEL_ID",
+            "destination": "files/example-folder"
         }
     ]
 }
 ```
 
-</details>
+### EXAMPLE: SCRAPING PUBLIC SERVERS
 
----
+```json
+{
+    "credentials": {
+        "token": "YOUR_USER_OR_BOT_TOKEN",
+        "email": "YOUR_USER_EMAIL_NO_2FA",
+        "password": "YOUR_USER_PASSWORD_NO_2FA"
+    },
+
+    "admins": [ "YOUR_DISCORD_USER_ID" ],
+    "adminChannels": [{ "channel": "DISCORD_CHANNEL_ID_FOR_COMMANDS" }],
+
+    "save": true,
+    "allowCommands": false, // they'll probably kick you otherwise.
+    "scanEdits": true,
+    "ignoreBots": false,
 
-## List of Settings
-:small_red_triangle: means the setting (or alternative) is **required**.
-
-:small_blue_diamond: means the setting defaults to a prespecified value. List below should say all default values.
-
-:small_orange_diamond: means the setting is optional and the feature(s) related to the setting will not be implemented if missing.
-
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> LIST OF ALL SETTINGS</b></summary>
-
-* :small_orange_diamond: **"_constants"**
-    * — _settings.\_constants : list of name:value strings_
-    * Use constants to replace values throughout the rest of the settings.
-        * ***Note:*** _If a constants name is used within another longer constants name, make sure the longer one is higher in order than the shorter one, otherwise the longer one will not be used properly. (i.e. if you have MY\_CONSTANT and MY\_CONSTANT\_TWO, put MY\_CONSTANT\_TWO above MY\_CONSTANT)_
-    * **Basic Example:**
-    ```json
-    {
-        "_constants": {
-            "MY_TOKEN": "my token here",
-            "ADMIN_CHANNEL": "123456789"
-        },
-        "credentials": {
-            "token": "MY_TOKEN"
-        },
-        "adminChannels": {
-            "channel": "ADMIN_CHANNEL"
+    "sendErrorMessages": false, // they'll probably kick you otherwise.
+
+    "presenceEnabled": false, // they'll probably kick you otherwise.
+    "presenceStatus": "invisible", // or "dnd" or "idle"
+    
+    "reactWhenDownloaded": false, // they'll probably kick you otherwise.
+    "reactWhenDownloadedHistory": false, // they'll probably kick you otherwise.
+    "historyTyping": false, // they'll probably kick you otherwise.
+
+    "channels": [
+        {
+            "channel": "SOURCE_DISCORD_CHANNEL_ID",
+            "destination": "files/example-folder"
         }
-    }
-    ```
----
-* :small_red_triangle: **"credentials"**
-    * — _settings.credentials : setting:value list_
-    * :small_red_triangle: **"token"**
-        * — _settings.credentials.token : string_
-        * _REQUIRED FOR BOT APPLICATION LOGIN OR USER LOGIN WITH 2FA, don't include if using User Login without 2FA._
-    * :small_red_triangle: **"email"**
-        * — _settings.credentials.email : string_
-        * _REQUIRED FOR USER LOGIN WITHOUT 2FA, don't include if using Bot Application Login._
-    * :small_red_triangle: **"password"**
-        * — _settings.credentials.password : string_
-        * _REQUIRED FOR USER LOGIN WITHOUT 2FA, don't include if using Bot Application Login._
-    * :small_blue_diamond: **"userBot"**
-        * — _settings.credentials.userBot : boolean_
-        * _Default:_ `false`
-        * _SET TO `true` FOR A USER LOGIN WITH 2FA, keep as `false` if using a Bot Application._
-    ---
-    * :small_orange_diamond: "twitterAccessToken"
-        * — _settings.credentials.twitterAccessToken : string_
-        * _Won't use Twitter API for fetching media from tweets if credentials are missing._
-    * :small_orange_diamond: "twitterAccessTokenSecret"
-        * — _settings.credentials.twitterAccessTokenSecret : string_
-        * _Won't use Twitter API for fetching media from tweets if credentials are missing._
-    * :small_orange_diamond: "twitterConsumerKey"
-        * — _settings.credentials.twitterConsumerKey : string_
-        * _Won't use Twitter API for fetching media from tweets if credentials are missing._
-    * :small_orange_diamond: "twitterConsumerSecret"
-        * — _settings.credentials.twitterConsumerSecret : string_
-        * _Won't use Twitter API for fetching media from tweets if credentials are missing._
-    * :small_orange_diamond: "flickrApiKey"
-        * — _settings.credentials.flickrApiKey : string_
-        * _Won't use Flickr API for fetching media from posts/albums if credentials are missing._
-    * :small_orange_diamond: "googleDriveCredentialsJSON"
-        * — _settings.credentials.googleDriveCredentialsJSON : string_
-        * _Path for Google Drive API credentials JSON file._
-        * _Won't use Google Drive API for fetching files if credentials are missing._
----
-* :small_orange_diamond: "admins"
-    * — _settings.admins : list of strings_
-    * List of User ID strings for users allowed to use admin commands
-* :small_orange_diamond: "adminChannels"
-    * — _settings.adminChannels : list of setting:value groups_
-    * :small_red_triangle: **"channel"** _`[USE THIS OR "channels"]`_
-        * — _settings.adminChannel.channel : string_
-        * _Channel ID for admin commands & logging._
-    * :small_red_triangle: **"channels"** _`[USE THIS OR "channel"]`_
-        * — _settings.adminChannel.channels : list of strings_
-        * Channel IDs to monitor, for if you want the same configuration for multiple channels.
-    * :small_blue_diamond: "logStatus"
-        * — _settings.adminChannel.logStatus : boolean_
-        * _Default:_ `true`
-        * _Send status messages to admin channel(s) upon launch._
-    * :small_blue_diamond: "logErrors"
-        * — _settings.adminChannel.logErrors : boolean_
-        * _Default:_ `true`
-        * _Send error messages to admin channel(s) when encountering errors._
-    * :small_blue_diamond: "unlockCommands"
-        * — _settings.adminChannel.unlockCommands : boolean_
-        * _Default:_ `false`
-        * _Unrestrict admin commands so anyone can use within this admin channel._
----
-* :small_blue_diamond: "debugOutput"
-    * — _settings.debugOutput : boolean_
-    * _Default:_ `false`
-    * Output debugging information.
-* :small_blue_diamond: "messageOutput"
-    * — _settings.messageOutput : boolean_
-    * _Default:_ `true`
-    * Output handled Discord messages.
-* :small_blue_diamond: "commandPrefix"
-    * — _settings.commandPrefix : string_
-    * _Default:_ `"ddg "`
-* :small_blue_diamond: "allowSkipping"
-    * — _settings.allowSkipping : boolean_
-    * _Default:_ `true`
-    * Allow scanning for keywords to skip content downloading.
-    * `"skip", "ignore", "don't save", "no save"`
-* :small_blue_diamond: "scanOwnMessages"
-    * — _settings.scanOwnMessages : boolean_
-    * _Default:_ `false`
-    * Scans the bots own messages for content to download, only useful if using as a selfbot.
-* :small_blue_diamond: "checkPermissions"
-    * — _settings.checkPermissions : boolean_
-    * _Default:_ `true`
-    * Checks Discord permissions before attempting requests/actions.
-* :small_blue_diamond: "allowGlobalCommands"
-    * — _settings.allowGlobalCommands : boolean_
-    * _Default:_ `true`
-    * Allow certain commands to be used even if not registered in `channels` or `adminChannels`.
-* :small_orange_diamond: "autorunHistory"
-    * — _settings.autorunHistory : boolean_
-    * Autorun history for all registered channels in background upon launch.
-    * _This can take anywhere between 2 minutes and 2 hours. It depends on how many channels your bot monitors and how many messages it has to go through. It can help to disable it by-channel for channels that don't require it (see `overwriteAutorunHistory` in channel options)._
-* :small_orange_diamond: "autorunHistoryBefore"
-    * — _settings.autorunHistoryBefore : string_
-    * Date filter for `autorunHistory`
-* :small_orange_diamond: "autorunHistorySince"
-    * — _settings.autorunHistorySince : string_
-    * Date filter for `autorunHistory`
-* :small_orange_diamond: "asyncHistory"
-    * — _settings.asyncHistory : boolean_
-    * Runs history commands simultaneously rather than one after the other.
-      * **WARNING!!! May result in Discord API Rate Limiting with many channels**, difficulty troubleshooting, exploding CPUs, melted RAM.
-* :small_orange_diamond: "exitOnBadConnection"
-    * — _settings.exitOnBadConnection : boolean_
-    * Exits the program upon detecting a connection issue rather than attempting to reconnect.
-* :small_blue_diamond: "downloadRetryMax"
-    * — _settings.downloadRetryMax : number_
-    * _Default:_ `3`
-* :small_blue_diamond: "downloadTimeout"
-    * — _settings.downloadTimeout : number_
-    * _Default:_ `60`
-* :small_blue_diamond: "discordTimeout"
-    * — _settings.discordTimeout : number_
-    * _Default:_ `180`
-* :small_blue_diamond: "githubUpdateChecking"
-    * — _settings.githubUpdateChecking : boolean_
-    * _Default:_ `true`
-    * Check for updates from this repo.
-* :small_blue_diamond: "discordLogLevel"
-    * — _settings.discordLogLevel : number_
-    * _Default:_ `0`
-    * 0 = LogError
-    * 1 = LogWarning
-    * 2 = LogInformational
-    * 3 = LogDebug _(everything)_
-* :small_blue_diamond: "filterDuplicateImages"
-    * — _settings.filterDuplicateImages : boolean_
-    * _Default:_ `false`
-    * **Experimental** feature to filter out images that are too similar to other cached images.
-    * _Caching of image data is stored via a database file; it will not read all pre-existing images._
-* :small_blue_diamond: "filterDuplicateImagesThreshold"
-    * — _settings.filterDuplicateImagesThreshold : number with decimals_
-    * _Default:_ `0`
-    * Threshold for what the bot considers too similar of an image comparison score. Lower = more similar (lowest is around -109.7), Higher = less similar (does not really have a maximum, would require your own testing).
----
-* :small_blue_diamond: "presenceEnabled"
-    * — _settings.presenceEnabled : boolean_
-    * _Default:_ `true`
-* :small_blue_diamond: "presenceStatus"
-    * — _settings.presenceStatus : string_
-    * _Default:_ `"idle"`
-    * Presence status type.
-    * `"online"`, `"idle"`, `"dnd"`, `"invisible"`, `"offline"`
-* :small_blue_diamond: "presenceType"
-    * — _settings.presenceType : number_
-    * _Default:_ `0`
-    * Presence label type. _("Playing \<activity\>", "Listening to \<activity\>", etc)_
-    * `Game = 0, Streaming = 1, Listening = 2, Watching = 3, Custom = 4`
-        * If Bot User, Streaming & Custom won't work properly.
-* :small_orange_diamond: "presenceOverwrite"
-    * — _settings.presenceOverwrite : string_
-    * _Unused by Default_
-    * Replace counter status with custom string.
-    * [see Presence Placeholders for customization...](#presence-placeholders-for-settings)
-* :small_orange_diamond: "presenceOverwriteDetails"
-    * — _settings.presenceOverwriteDetails : string_
-    * _Unused by Default_
-    * Replace counter status details with custom string (only works for User, not Bot).
-    * [see Presence Placeholders for customization...](#presence-placeholders-for-settings)
-* :small_orange_diamond: "presenceOverwriteState"
-    * — _settings.presenceOverwriteState : string_
-    * _Unused by Default_
-    * Replace counter status state with custom string (only works for User, not Bot).
-    * [see Presence Placeholders for customization...](#presence-placeholders-for-settings)
----
-    * :small_blue_diamond: "reactWhenDownloaded"
-        * — _settings.reactWhenDownloaded : boolean_
-        * _Default:_ `true`
-        * Confirmation reaction that file(s) successfully downloaded. Is overwritten by the channel/server equivelant of this setting.
-    * :small_blue_diamond: "reactWhenDownloadedHistory"
-        * — _settings.reactWhenDownloaded : boolean_
-        * _Default:_ `false`
-        * Confirmation reaction that file(s) successfully downloaded when processing history commands. Is overwritten by the channel/server equivelant of this setting.
----
-* :small_blue_diamond: "filenameFormat"
-    * — _settings.filenameFormat : string_
-    * _Default:_ `"{{date}} {{file}}"`
-    * `"{{date}}"`, `"{{file}}"`, `"{{messageID}}"`, `"{{userID}}"`, `"{{username}}"`, `"{{channelID}}"`, `"{{serverID}}"`, `"{{message}}"`, `"{{fileType}}"`, `"{{nanoID}}"`, `"{{shortID}}"`
-    * `"{{nanoID}}"` is a 21 character unique string, eg: i25_rX9zwDdDn7Sg-ZoaH
-    * `"{{shortID}}"` is a short unique string, eg: NVovc6-QQy
-* :small_blue_diamond: "filenameDateFormat"
-    * — _settings.filenameDateFormat : string_
-    * _Default:_ `"2006-01-02_15-04-05 "`
-    * [see this Stack Overflow post regarding Golang date formatting.](https://stackoverflow.com/questions/20234104/how-to-format-current-time-using-a-yyyymmddhhmmss-format)
-* :small_orange_diamond: "embedColor"
-    * — _settings.embedColor : string_
-    * _Unused by Default_
-    * Supports `random`/`rand`, `role`/`user`, or RGB in hex or int format (ex: #FF0000 or 16711680).
-* :small_orange_diamond: "inflateCount"
-    * — _settings.inflateCount : number_
-    * _Unused by Default_
-    * Inflates the count of total files downloaded by the bot. I only added this for my own personal use to represent an accurate total amount of files downloaded by previous bots I used.
-* :small_blue_diamond: "numberFormatEuropean"
-    * — _settings.numberFormatEuropean : boolean_
-    * _Default:_ false
-    * Formats numbers as `123.456,78`/`123.46k` rather than `123,456.78`/`123,46k`.
----
-* :small_orange_diamond: **"all"**
-    * — _settings.all : list of setting:value options_
-    * **Follow `channels` below for variables, except channel & server ID(s) are not used.**
-    * If a pre-existing config for the channel or server is not found, it will download from **any and every channel** it has access to using your specified settings.
-* :small_orange_diamond: "allBlacklistServers"
-    * — _settings.allBlacklistServers : list of strings_
-    * _Unused by Default_
-    * Blacklists servers (by ID) from `all`.
-* :small_orange_diamond: "allBlacklistChannels"
-    * — _settings.allBlacklistChannels : list of strings_
-    * _Unused by Default_
-    * Blacklists channels (by ID) from `all`.
----
-* :small_red_triangle: **"servers"** _`[USE THIS OR "channels"]`_
-    * — _settings.servers : list of setting:value groups_
-    * :small_red_triangle: **"server"** _`[USE THIS OR "servers"]`_
-        * — _settings.servers[].server : string_
-        * Server ID to monitor.
-    * :small_red_triangle: **"servers"** _`[USE THIS OR "server"]`_
-        * — _settings.servers[].servers : list of strings_
-        * Server IDs to monitor, for if you want the same configuration for multiple servers.
-    * :small_orange_diamond: "serverBlacklist"
-        * — _settings.servers[].serverBlacklist : list of strings_
-        * Blacklist specific channels from the encompassing server(s).
-    * **ALL OTHER VARIABLES ARE SAME AS "channels" BELOW**
-* :small_red_triangle: **"channels"** _`[USE THIS OR "servers"]`_
-    * — _settings.channels : list of setting:value groups_
-    * :small_red_triangle: **"channel"** _`[USE THIS OR "channels"]`_
-        * — _settings.channels[].channel : string_
-        * Channel ID to monitor.
-    * :small_red_triangle: **"channels"** _`[USE THIS OR "channel"]`_
-        * — _settings.channels[].channels : list of strings_
-        * Channel IDs to monitor, for if you want the same configuration for multiple channels.
-    ---
-    * :small_red_triangle: **"destination"**
-        * — _settings.channels[].destination : string_
-        * Folder path for saving files, can be full path or local subfolder.
-    * :small_blue_diamond: "enabled"
-        * — _settings.channels[].enabled : boolean_
-        * _Default:_ `true`
-        * Toggles bot functionality for channel.
-    * :small_blue_diamond: "save"
-        * — _settings.channels[].save : boolean_
-        * _Default:_ `true`
-        * Toggles whether the files actually get downloaded/saved.
-    * :small_blue_diamond: "allowCommands"
-        * — _settings.channels[].allowCommands : boolean_
-        * _Default:_ `true`
-        * Allow use of commands like ping, help, etc.
-    * :small_blue_diamond: "scanEdits"
-        * — _settings.channels[].scanEdits : boolean_
-        * _Default:_ `true`
-        * Check edits for un-downloaded media.
-    * :small_blue_diamond: "ignoreBots"
-        * — _settings.channels[].ignoreBots : boolean_
-        * _Default:_ `false`
-        * Ignores messages from Bot users.
-    * :small_orange_diamond: overwriteAutorunHistory
-        * — _settings.channels[].overwriteAutorunHistory : boolean_
-        * Overwrite global setting for autorunning history for all registered channels in background upon launch.
-    * :small_orange_diamond: overwriteAutorunHistoryBefore
-        * — _settings.channels[].overwriteAutorunHistoryBefore : string_
-        * Date filter for `overwriteAutorunHistory`
-    * :small_orange_diamond: overwriteAutorunHistorySince
-        * — _settings.channels[].overwriteAutorunHistorySince : string_
-        * Date filter for `overwriteAutorunHistory`
-    * :small_blue_diamond: "sendErrorMessages"
-        * — _settings.channels[].sendErrorMessages : boolean_
-        * _Default:_ `true`
-        * Send response messages when downloads fail or other download-related errors are encountered.
-    * :small_orange_diamond: "sendFileToChannel"
-        * — _settings.channels[].sendFileToChannel : string_
-        * Forwards/crossposts/logs downloaded files to specified channel (or channels if used as `sendFileToChannels` below). By default will send as the actual file
-    * :small_orange_diamond: "sendFileToChannels"
-        * — _settings.channels[].sendFileToChannels : list of strings_
-        * List form of `sendFileToChannel` above.
-    * :small_blue_diamond: "sendFileDirectly"
-        * — _settings.channels[].sendFileDirectly : boolean_
-        * _Default:_ `true`
-        * Sends raw file to channel(s) rather than embedded download link.
-    ---
-    * :small_blue_diamond: "updatePresence"
-        * — _settings.channels[].updatePresence : boolean_
-        * _Default:_ `true`
-        * Update Discord Presence when download succeeds within this channel.
-    * :small_blue_diamond: "reactWhenDownloaded"
-        * — _settings.channels[].reactWhenDownloaded : boolean_
-        * _Default:_ `true`
-        * Confirmation reaction that file(s) successfully downloaded.
-    * :small_orange_diamond: "reactWhenDownloadedEmoji"
-        * — _settings.channels[].reactWhenDownloadedEmoji : string_
-        * _Unused by Default_
-        * Uses specified emoji rather than random server emojis. Simply pasting a standard emoji will work, for custom Discord emojis use "name:ID" format.
-    * :small_blue_diamond: "reactWhenDownloadedHistory"
-        * — _settings.channels[].reactWhenDownloadedHistory : boolean_
-        * _Default:_ `false`
-        * Reacts to old messages when processing history.
-    * :small_blue_diamond: "blacklistReactEmojis"
-        * — _settings.channels[].blacklistReactEmojis : list of strings_
-        * _Unused by Default_
-        * Block specific emojis from being used for reacts. Simply pasting a standard emoji will work, for custom Discord emojis use "name:ID" format.
-    * :small_blue_diamond: "typeWhileProcessing"
-        * — _settings.channels[].typeWhileProcessing : boolean_
-        * _Default:_ `false`
-        * Shows _"<name> is typing..."_ while processing things that aren't processed instantly, like history cataloging.
-    * :small_orange_diamond: "overwriteFilenameDateFormat"
-        * — _settings.channels[].overwriteFilenameDateFormat : string_
-        * _Unused by Default_
-        * Overwrites the global setting `filenameDateFormat` _(see above)_
-        * [see this Stack Overflow post regarding Golang date formatting.](https://stackoverflow.com/questions/20234104/how-to-format-current-time-using-a-yyyymmddhhmmss-format)
-    * :small_orange_diamond: "overwriteAllowSkipping"
-        * — _settings.channels[].overwriteAllowSkipping : boolean_
-        * _Unused by Default_
-        * Allow scanning for keywords to skip content downloading.
-        * `"skip", "ignore", "don't save", "no save"`
-    * :small_orange_diamond: "overwriteEmbedColor"
-        * — _settings.channels[].overwriteEmbedColor : string_
-        * _Unused by Default_
-        * Supports `random`/`rand`, `role`/`user`, or RGB in hex or int format (ex: #FF0000 or 16711680).
-    ---
-    * :small_blue_diamond: "divideFoldersByServer"
-        * — _settings.channels[].divideFoldersByServer : boolean_
-        * _Default:_ `false`
-        * Separate files into subfolders by server of origin _(e.g. "My Server", "My Friends Server")_
-    * :small_blue_diamond: "divideFoldersByChannel"
-        * — _settings.channels[].divideFoldersByChannel : boolean_
-        * _Default:_ `false`
-        * Separate files into subfolders by channel of origin _(e.g. "my-channel", "my-other-channel")_
-    * :small_blue_diamond: "divideFoldersByUser"
-        * — _settings.channels[].divideFoldersByUser : boolean_
-        * _Default:_ `false`
-        * Separate files into subfolders by user who sent _(e.g. "Me#1234", "My Friend#0000")_
-    * :small_blue_diamond: "divideFoldersByType"
-        * — _settings.channels[].divideFoldersByType : boolean_
-        * _Default:_ `true`
-        * Separate files into subfolders by type _(e.g. "images", "video", "audio", "text", "other")_
-    * :small_blue_diamond: "divideFoldersUseID"
-        * — _settings.channels[].divideFoldersUseID : boolean_
-        * _Default:_ `false`
-        * Uses ID rather than Name for `"divideFoldersByServer"`, `"divideFoldersByChannel"`, `"divideFoldersByUser"`. I would recommend this if any servers you download from have server/channel/usernames changed frequently.
-    * :small_blue_diamond: "saveImages"
-        * — _settings.channels[].saveImages : boolean_
-        * _Default:_ `true`
-    * :small_blue_diamond: "saveVideos"
-        * — _settings.channels[].saveVideos : boolean_
-        * _Default:_ `true`
-    * :small_blue_diamond: "saveAudioFiles"
-        * — _settings.channels[].saveAudioFiles : boolean_
-        * _Default:_ `false`
-    * :small_blue_diamond: "saveTextFiles"
-        * — _settings.channels[].saveTextFiles : boolean_
-        * _Default:_ `false`
-    * :small_blue_diamond: "saveOtherFiles"
-        * — _settings.channels[].saveOtherFiles : boolean_
-        * _Default:_ `false`
-    * :small_blue_diamond: "savePossibleDuplicates"
-        * — _settings.channels[].savePossibleDuplicates : boolean_
-        * _Default:_ `false`
-        * Save file even if exact filename already exists or exact URL is already recorded in database.
-    ---
-    * :small_orange_diamond: "filters"
-        * — _settings.channels[].filters : setting:value group_
-        * _Filter prioritizes Users before Roles before Phrases._
-        * :small_blue_diamond: "blockedPhrases"
-            * — _settings.channels[].filters.blockedPhrases : list of strings_
-            * List of phrases to make the bot ignore this message.
-            * Will ignore any message containing a blocked phrase UNLESS it also has an allowed phrase. Messages will be processed by default.
-            * _Default:_ `[ "skip", "ignore", "don't save", "no save" ]`
-        * :small_orange_diamond: "allowedPhrases"
-            * — _settings.channels[].filters.allowedPhrases : list of strings_
-            * List of phrases to allow the bot to process the message.
-            * _If used without blockedPhrases,_ no messages will be processed unless they contain an allowed phrase.
-        * :small_orange_diamond: "blockedUsers"
-            * — _settings.channels[].filters.blockedUsers : list of strings_
-            * Will ignore messages from the following users.
-        * :small_orange_diamond: "allowedUsers"
-            * — _settings.channels[].filters.allowedUsers : list of strings_
-            * Will ONLY process messages if they were sent from the following users.
-        * :small_orange_diamond: "blockedRoles"
-            * — _settings.channels[].filters.blockedRoles : list of strings_
-            * Will ignore messages from users with any of the following roles.
-        * :small_orange_diamond: "allowedRoles"
-            * — _settings.channels[].filters.allowedRoles : list of strings_
-            * Will ONLY process messages if they were sent from users with any of the following roles.
-        * :small_blue_diamond: "blockedExtensions"
-            * — _settings.channels[].filters.blockedExtensions : list of strings_
-            * List of file extensions for the bot to ignore (include periods).
-            * _Default:_ `[ ".htm", ".html", ".php", ".exe", ".dll", ".bin", ".cmd", ".sh", ".py", ".jar" ]`
-        * :small_orange_diamond: "allowedExtensions"
-            * — _settings.channels[].filters.allowedExtensions : list of strings_
-            * Will ONLY process files if they have the following extensions (include periods).
-        * :small_orange_diamond: "blockedDomains"
-            * — _settings.channels[].filters.blockedDomains : list of strings_
-            * List of file source domains (websites) for the bot to ignore.
-        * :small_orange_diamond: "allowedDomains"
-            * — _settings.channels[].filters.allowedDomains : list of strings_
-            * Will ONLY process files if they were sent from any of the following domains (websites).
-    ---
-    * :small_orange_diamond: "logLinks"
-        * — _settings.channels[].logLinks : setting:value group_
-        * :small_red_triangle: "destination"
-            * — _settings.channels[].logLinks.destination : string_
-            * Filepath for single log file to be stored, or directory path for multiple logs to be stored.
-        * :small_blue_diamond: "destinationIsFolder"
-            * — _settings.channels[].logLinks.destinationIsFolder : bool_
-            * _Default:_ `false`
-            * `true` if `"destination"` above is for a directory for multiple logs.
-        * :small_blue_diamond: "divideLogsByServer"
-            * — _settings.channels[].logLinks.divideLogsByServer : bool_
-            * _Default:_ `true`
-            * *ONLY USED IF `"destinationIsFolder"` ABOVE IS `true`*
-            * Separates log files by Server ID.
-        * :small_blue_diamond: "divideLogsByChannel"
-            * — _settings.channels[].logLinks.divideLogsByChannel : bool_
-            * _Default:_ `true`
-            * *ONLY USED IF `"destinationIsFolder"` ABOVE IS `true`*
-            * Separates log files by Channel ID.
-        * :small_blue_diamond: "divideLogsByUser"
-            * — _settings.channels[].logLinks.divideLogsByUser : bool_
-            * _Default:_ `false`
-            * *ONLY USED IF `"destinationIsFolder"` ABOVE IS `true`*
-            * Separates log files by User ID.
-        * :small_blue_diamond: "divideLogsByStatus"
-            * — _settings.channels[].logLinks.divideLogsByStatus : bool_
-            * _Default:_ `false`
-            * *ONLY USED IF `"destinationIsFolder"` ABOVE IS `true`*
-            * Separates log files download status.
-            * *DOES NOT APPLY TO `"logMessages"` BELOW*
-        * :small_blue_diamond: "logDownloads"
-            * — _settings.channels[].logLinks.logDownloads : bool_
-            * _Default:_ `true`
-            * Includes successfully downloaded links in logs.
-            * *DOES NOT APPLY TO `"logMessages"` BELOW*
-        * :small_blue_diamond: "logFailures"
-            * — _settings.channels[].logLinks.logFailures : bool_
-            * _Default:_ `true`
-            * Includes failed/skipped/ignored links in logs.
-            * *DOES NOT APPLY TO `"logMessages"` BELOW*
-        * :small_blue_diamond: "filterDuplicates"
-            * — _settings.channels[].logLinks.filterDuplicates : bool_
-            * _Default:_ `false`
-            * Filters out duplicate links (or messages) from being logged if already present in log file.
-        * :small_orange_diamond: "prefix"
-            * — _settings.channels[].logLinks.prefix : string_
-            * Prepend log line with string.
-        * :small_orange_diamond: "suffix"
-            * — _settings.channels[].logLinks.suffix : string_
-            * Append log line with string.
-        * :small_blue_diamond: "userData"
-            * — _settings.channels[].logLinks.userData : bool_
-            * _Default:_ `false`
-            * Include additional data such as SERVER/CHANNEL/USER ID's for logged files/messages.
-    * :small_orange_diamond: "logMessages"
-        * ***Identical to `"logLinks"` above unless noted otherwise.***
-
-</details>
+    ]
+}
+```
 
 ---
 
-### Presence Placeholders for Settings
-_For `presenceOverwrite`, `presenceOverwriteDetails`, `presenceOverwriteState`_
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i></b></summary>
-
-Key | Description
---- | ---
-`{{dgVersion}}`             | discord-go version
-`{{ddgVersion}}`            | Project version
-`{{apiVersion}}`            | Discord API version
-`{{countNoCommas}}`         | Raw total count of downloads (without comma formatting)
-`{{count}}`                 | Raw total count of downloads
-`{{countShort}}`            | Shortened total count of downloads
-`{{numServers}}`            | Number of servers bot is in
-`{{numBoundServers}}`       | Number of bound servers
-`{{numBoundChannels}}`      | Number of bound channels
-`{{numAdminChannels}}`      | Number of admin channels
-`{{numAdmins}}`             | Number of designated admins
-`{{timeSavedShort}}`        | Last save time formatted as `3:04pm`
-`{{timeSavedShortTZ}}`      | Last save time formatted as `3:04pm MST`
-`{{timeSavedMid}}`          | Last save time formatted as `3:04pm MST 1/2/2006`
-`{{timeSavedLong}}`         | Last save time formatted as `3:04:05pm MST - January 2, 2006`
-`{{timeSavedShort24}}`      | Last save time formatted as `15:04`
-`{{timeSavedShortTZ24}}`    | Last save time formatted as `15:04 MST`
-`{{timeSavedMid24}}`        | Last save time formatted as `15:04 MST 2/1/2006`
-`{{timeSavedLong24}}`       | Last save time formatted as `15:04:05 MST - 2 January, 2006`
-`{{timeNowShort}}`          | Current time formatted as `3:04pm`
-`{{timeNowShortTZ}}`        | Current time formatted as `3:04pm MST`
-`{{timeNowMid}}`            | Current time formatted as `3:04pm MST 1/2/2006`
-`{{timeNowLong}}`           | Current time formatted as `3:04:05pm MST - January 2, 2006`
-`{{timeNowShort24}}`        | Current time formatted as `15:04`
-`{{timeNowShortTZ24}}`      | Current time formatted as `15:04 MST`
-`{{timeNowMid24}}`          | Current time formatted as `15:04 MST 2/1/2006`
-`{{timeNowLong24}}`         | Current time formatted as `15:04:05 MST - 2 January, 2006`
-`{{uptime}}`                | Shortened duration of bot uptime
-
-</details>
+## ❔ FAQ
 
----
+- **_Q: How do I install?_**
+- **A: [SEE #getting-started](#%EF%B8%8F-getting-started)**
 
-## FAQ
-* ***Q: How do I install?***
-* **A: [SEE #getting-started](#getting-started)** 
 ---
-* ***Q: How do I convert from Seklfreak's discord-image-downloader-go?***
-* **A: Place your config.ini from that program in the same directory as this program and delete any settings.json file if present. The program will import your settings from the old project and make a new settings.json. It will still re-download files that DIDG already downloaded, as the database layout is different and the old database is not imported.**
+
+- **_Q: How do I convert from Seklfreak's discord-image-downloader-go?_**
+- **A: Place your config.ini from that program in the same directory as this program and delete any settings.json file if present. The program will import your settings from the old project and make a new settings.json. It will still re-download files that DIDG already downloaded, as the database layout is different and the old database is not imported.**
 
 ---
 
-## Development
-* I'm a complete amateur with Golang. If anything's bad please make a pull request.
-* Versioning is `[MAJOR].[MINOR].[PATCH]`
-
-<details>
-<summary><b><i>(COLLAPSABLE SECTION)</i> CREDITS & SOURCES</b></summary>
-
-### Credits & Dependencies
-* [github.com/Seklfreak/discord-image-downloader-go - the original project this originated from](https://github.com/Seklfreak/discord-image-downloader-go)
-
-#### Core Dependencies
-* [github.com/bwmarrin/discordgo](https://github.com/bwmarrin/discordgo)
-* [github.com/Necroforger/dgrouter](https://github.com/Necroforger/dgrouter)
-* [github.com/HouzuoGuo/tiedot/db](https://github.com/HouzuoGuo/tiedot)
-* [github.com/fatih/color](https://github.com/fatih/color)
-
-#### Other Dependencies
-* [github.com/AvraamMavridis/randomcolor](https://github.com/AvraamMavridis/randomcolor)
-* [github.com/ChimeraCoder/anaconda](https://github.com/ChimeraCoder/anaconda)
-* [github.com/ChimeraCoder/tokenbucket](https://github.com/ChimeraCoder/tokenbucket)
-* [github.com/Jeffail/gabs](https://github.com/Jeffail/gabs)
-* [github.com/PuerkitoBio/goquery](https://github.com/PuerkitoBio/goquery)
-* [github.com/azr/backoff](https://github.com/azr/backoff)
-* [github.com/dustin/go-jsonpointer](https://github.com/dustin/go-jsonpointer)
-* [github.com/dustin/gojson](https://github.com/dustin/gojson)
-* [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify)
-* [github.com/garyburd/go-oauth](https://github.com/garyburd/go-oauth)
-* [github.com/hako/durafmt](https://github.com/hako/durafmt)
-* [github.com/hashicorp/go-version](https://github.com/hashicorp/go-version)
-* [github.com/kennygrant/sanitize](https://github.com/kennygrant/sanitize)
-* [github.com/nfnt/resize](https://github.com/nfnt/resize)
-* [github.com/rivo/duplo](https://github.com/rivo/duplo)
-* [golang.org/x/net](https://golang.org/x/net)
-* [golang.org/x/oauth2](https://golang.org/x/oauth2)
-* [google.golang.org/api](https://google.golang.org/api)
-* [gopkg.in/ini.v1](https://gopkg.in/ini.v1)
-* [mvdan.cc/xurls/v2](https://mvdan.cc/xurls/v2)
-  
-</details>
+## ⚙️ Development
+
+- I'm a complete amateur with Golang. If anything's bad please make a pull request.
+- Follows Semantic Versioning: `[MAJOR].[MINOR].[PATCH]` <https://semver.org/>
+- [github.com/Seklfreak/discord-image-downloader-go - the original project this was founded on](https://github.com/Seklfreak/discord-image-downloader-go)

+ 442 - 239
commands.go

@@ -6,6 +6,7 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"runtime"
 	"strings"
 	"time"
 
@@ -16,32 +17,41 @@ import (
 	"github.com/kennygrant/sanitize"
 )
 
-// Multiple use messages to save space and make cleaner.
-//TODO: Implement this for more?
+// TODO: Implement this for more?
 const (
-	cmderrLackingLocalAdminPerms = "You do not have permission to use this command.\n" +
-		"\nTo use this command you must:" +
-		"\n• Be set as a bot administrator (in the settings)" +
-		"\n• Own this Discord Server" +
-		"\n• Have Server Administrator Permissions"
 	cmderrLackingBotAdminPerms = "You do not have permission to use this command. Your User ID must be set as a bot administrator in the settings file."
-	cmderrChannelNotRegistered = "Specified channel is not registered in the bot settings."
-	cmderrHistoryCancelled     = "History cataloging was cancelled."
+	cmderrSendFailure          = "Failed to send command message (requested by %s)...\t%s"
 )
 
+func safeReply(ctx *exrouter.Context, content string) bool {
+	if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+		if _, err := ctx.Reply(content); err != nil {
+			log.Println(lg("Command", "", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
+			return false
+		} else {
+			return true
+		}
+	} else {
+		log.Println(lg("Command", "", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
+		return false
+	}
+}
+
+// TODO: function for handling perm error messages, etc etc to reduce clutter
 func handleCommands() *exrouter.Route {
 	router := exrouter.New()
 
 	//#region Utility Commands
 
-	router.On("ping", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:ping]")
-		if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-			if isCommandableChannel(ctx.Msg) {
+	go router.On("ping", func(ctx *exrouter.Context) {
+		if isCommandableChannel(ctx.Msg) {
+			if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+				log.Println(lg("Command", "Ping", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
+			} else {
 				beforePong := time.Now()
 				pong, err := ctx.Reply("Pong!")
 				if err != nil {
-					log.Println(logPrefixHere, color.HiRedString("Error sending pong message:\t%s", err))
+					log.Println(lg("Command", "Ping", color.HiRedString, "Error sending pong message:\t%s", err))
 				} else {
 					afterPong := time.Now()
 					latency := bot.HeartbeatLatency().Milliseconds()
@@ -64,44 +74,41 @@ func handleCommands() *exrouter.Route {
 						}
 					}
 					// Log
-					log.Println(logPrefixHere, color.HiCyanString("%s pinged bot - Latency: %dms, Roundtrip: %dms",
+					log.Println(lg("Command", "Ping", color.HiCyanString, "%s pinged bot - Latency: %dms, Roundtrip: %dms",
 						getUserIdentifier(*ctx.Msg.Author),
 						latency,
 						roundtrip),
 					)
 				}
 			}
-		} else {
-			log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
 		}
 	}).Cat("Utility").Alias("test").Desc("Pings the bot")
 
-	router.On("help", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:help]")
-		if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-			if isGlobalCommandAllowed(ctx.Msg) {
-				text := ""
+	go router.On("help", func(ctx *exrouter.Context) {
+		if isCommandableChannel(ctx.Msg) {
+			if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+				log.Println(lg("Command", "Help", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
+			} else {
+				content := ""
 				for _, cmd := range router.Routes {
 					if cmd.Category != "Admin" || isBotAdmin(ctx.Msg) {
-						text += fmt.Sprintf("• \"%s\" : %s",
+						content += fmt.Sprintf("• \"%s\" : %s",
 							cmd.Name,
 							cmd.Description,
 						)
 						if len(cmd.Aliases) > 0 {
-							text += fmt.Sprintf("\n— Aliases: \"%s\"", strings.Join(cmd.Aliases, "\", \""))
+							content += fmt.Sprintf("\n— Aliases: \"%s\"", strings.Join(cmd.Aliases, "\", \""))
 						}
-						text += "\n\n"
+						content += "\n\n"
 					}
 				}
-				_, err := replyEmbed(ctx.Msg, "Command — Help", fmt.Sprintf("Use commands as ``\"%s<command> <arguments?>\"``\n```%s```\n%s", config.CommandPrefix, text, projectRepoURL))
-				// Failed to send
-				if err != nil {
-					log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
+				if _, err := replyEmbed(ctx.Msg, "Command — Help",
+					fmt.Sprintf("Use commands as ``\"%s<command> <arguments?>\"``\n```%s```\n%s",
+						config.CommandPrefix, content, projectRepoURL)); err != nil {
+					log.Println(lg("Command", "Help", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
 				}
-				log.Println(logPrefixHere, color.HiCyanString("%s asked for help", getUserIdentifier(*ctx.Msg.Author)))
+				log.Println(lg("Command", "Help", color.HiCyanString, "%s asked for help", getUserIdentifier(*ctx.Msg.Author)))
 			}
-		} else {
-			log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
 		}
 	}).Cat("Utility").Alias("commands").Desc("Outputs this help menu")
 
@@ -109,259 +116,456 @@ func handleCommands() *exrouter.Route {
 
 	//#region Info Commands
 
-	router.On("status", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:status]")
-		if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-			if isCommandableChannel(ctx.Msg) {
+	go router.On("status", func(ctx *exrouter.Context) {
+		if isCommandableChannel(ctx.Msg) {
+			if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+				log.Println(lg("Command", "Status", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
+			} else {
 				message := fmt.Sprintf("• **Uptime —** %s\n"+
 					"• **Started at —** %s\n"+
 					"• **Joined Servers —** %d\n"+
 					"• **Bound Channels —** %d\n"+
+					"• **Bound Cagetories —** %d\n"+
 					"• **Bound Servers —** %d\n"+
+					"• **Bound Users —** %d\n"+
 					"• **Admin Channels —** %d\n"+
 					"• **Heartbeat Latency —** %dms",
 					durafmt.Parse(time.Since(startTime)).String(),
 					startTime.Format("03:04:05pm on Monday, January 2, 2006 (MST)"),
 					len(bot.State.Guilds),
 					getBoundChannelsCount(),
+					getBoundCategoriesCount(),
 					getBoundServersCount(),
+					getBoundUsersCount(),
 					len(config.AdminChannels),
 					bot.HeartbeatLatency().Milliseconds(),
 				)
-				if isChannelRegistered(ctx.Msg.ChannelID) {
-					configJson, _ := json.MarshalIndent(getChannelConfig(ctx.Msg.ChannelID), "", "\t")
+				if sourceConfig := getSource(ctx.Msg, nil); sourceConfig != emptyConfig {
+					configJson, _ := json.MarshalIndent(sourceConfig, "", "\t")
 					message = message + fmt.Sprintf("\n• **Channel Settings...** ```%s```", string(configJson))
 				}
-				_, err := replyEmbed(ctx.Msg, "Command — Status", message)
-				// Failed to send
-				if err != nil {
-					log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
+				if _, err := replyEmbed(ctx.Msg, "Command — Status", message); err != nil {
+					log.Println(lg("Command", "Status", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
 				}
-				log.Println(logPrefixHere, color.HiCyanString("%s requested status report", getUserIdentifier(*ctx.Msg.Author)))
+				log.Println(lg("Command", "Status", color.HiCyanString, "%s requested status report", getUserIdentifier(*ctx.Msg.Author)))
 			}
-		} else {
-			log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
 		}
 	}).Cat("Info").Desc("Displays info regarding the current status of the bot")
 
-	router.On("stats", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:stats]")
-		if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-			if isChannelRegistered(ctx.Msg.ChannelID) {
-				channelConfig := getChannelConfig(ctx.Msg.ChannelID)
-				if *channelConfig.AllowCommands {
-					content := fmt.Sprintf("• **Total Downloads —** %s\n"+
-						"• **Downloads in this Channel —** %s",
-						formatNumber(int64(dbDownloadCount())),
-						formatNumber(int64(dbDownloadCountByChannel(ctx.Msg.ChannelID))),
-					)
-					//TODO: Count in channel by users
-					_, err := replyEmbed(ctx.Msg, "Command — Stats", content)
-					// Failed to send
-					if err != nil {
-						log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
+	go router.On("stats", func(ctx *exrouter.Context) {
+		if isCommandableChannel(ctx.Msg) {
+			if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+				log.Println(lg("Command", "Stats", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
+			} else {
+				if sourceConfig := getSource(ctx.Msg, nil); sourceConfig != emptyConfig {
+					if *sourceConfig.AllowCommands {
+						content := fmt.Sprintf("• **Total Downloads —** %s\n"+
+							"• **Downloads in this Channel —** %s",
+							formatNumber(int64(dbDownloadCount())),
+							formatNumber(int64(dbDownloadCountByChannel(ctx.Msg.ChannelID))),
+						)
+						//TODO: Count in channel by users
+						if _, err := replyEmbed(ctx.Msg, "Command — Stats", content); err != nil {
+							log.Println(lg("Command", "Stats", color.HiRedString, cmderrSendFailure,
+								getUserIdentifier(*ctx.Msg.Author), err))
+						}
+						log.Println(lg("Command", "Stats", color.HiCyanString, "%s requested stats",
+							getUserIdentifier(*ctx.Msg.Author)))
 					}
-					log.Println(logPrefixHere, color.HiCyanString("%s requested stats", getUserIdentifier(*ctx.Msg.Author)))
 				}
 			}
-		} else {
-			log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
 		}
 	}).Cat("Info").Desc("Outputs statistics regarding this channel")
 
-	router.On("info", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:info]")
-		if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-			if isGlobalCommandAllowed(ctx.Msg) {
+	go router.On("info", func(ctx *exrouter.Context) {
+		if isCommandableChannel(ctx.Msg) {
+			if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+				log.Println(lg("Command", "Info", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
+			} else {
 				content := fmt.Sprintf("Here is some useful info...\n\n"+
 					"• **Your User ID —** `%s`\n"+
 					"• **Bots User ID —** `%s`\n"+
 					"• **This Channel ID —** `%s`\n"+
-					"• **This Server ID —** `%s`"+
+					"• **This Server ID —** `%s`\n\n"+
+					"• **Versions —** `%s, discordgo v%s (modified), Discord API v%s`"+
 					"\n\nRemember to remove any spaces when copying to settings.",
-					ctx.Msg.Author.ID, user.ID, ctx.Msg.ChannelID, ctx.Msg.GuildID)
-				_, err := replyEmbed(ctx.Msg, "Command — Info", content)
-				// Failed to send
-				if err != nil {
-					log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
+					ctx.Msg.Author.ID, botUser.ID, ctx.Msg.ChannelID, ctx.Msg.GuildID, runtime.Version(), discordgo.VERSION, discordgo.APIVersion)
+				if _, err := replyEmbed(ctx.Msg, "Command — Info", content); err != nil {
+					log.Println(lg("Command", "Info", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
 				}
-				log.Println(logPrefixHere, color.HiCyanString("%s requested info", getUserIdentifier(*ctx.Msg.Author)))
-			} else {
-				log.Println(logPrefixHere, color.HiRedString("%s tried using the info command but commands are disabled here", getUserIdentifier(*ctx.Msg.Author)))
+				log.Println(lg("Command", "Info", color.HiCyanString, "%s requested info", getUserIdentifier(*ctx.Msg.Author)))
 			}
-		} else {
-			log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
 		}
-	}).Cat("Info").Desc("Displays info regarding Discord IDs")
+	}).Cat("Info").Alias("debug").Desc("Displays info regarding Discord IDs")
 
 	//#endregion
 
 	//#region Admin Commands
 
-	router.On("history", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:history]")
-		// Vars
-		var channels []string
-		var before string
-		var beforeID string
-		var since string
-		var sinceID string
-		var stop bool
-		// Keys
-		beforeKey := "--before="
-		sinceKey := "--since="
-		// Parse Args
-		for k, v := range ctx.Args {
-			// Skip "history" segment
-			if k == 0 {
-				continue
-			}
-			// Actually Parse Args
-			if strings.Contains(strings.ToLower(v), beforeKey) {
-				before = strings.ReplaceAll(strings.ToLower(v), beforeKey, "")
-				if isDate(before) {
-					beforeID = discordTimestampToSnowflake("2006-01-02", dateLocalToUTC(before))
-				} else if isNumeric(before) {
-					beforeID = before
-				}
-				if config.DebugOutput {
-					log.Println(logPrefixDebug, logPrefixHere, color.CyanString("Date range applied, before %s", beforeID))
-				}
-			} else if strings.Contains(strings.ToLower(v), sinceKey) {
-				since = strings.ReplaceAll(strings.ToLower(v), sinceKey, "")
-				if isDate(since) {
-					sinceID = discordTimestampToSnowflake("2006-01-02", dateLocalToUTC(since))
-				} else if isNumeric(since) {
-					sinceID = since
-				}
-				if config.DebugOutput {
-					log.Println(logPrefixDebug, logPrefixHere, color.CyanString("Date range applied, since %s", sinceID))
+	go router.On("history", func(ctx *exrouter.Context) {
+		if isCommandableChannel(ctx.Msg) {
+			// Vars
+			var all = false
+			var channels []string
+
+			var shouldAbort bool = false
+			var shouldProcess bool = true
+			var shouldWipeDB bool = false
+			var shouldWipeCache bool = false
+
+			var before string
+			var beforeID string
+			var since string
+			var sinceID string
+
+			//#region Parse Args
+			for argKey, argValue := range ctx.Args {
+				if argKey == 0 { // skip head
+					continue
 				}
-			} else if strings.Contains(strings.ToLower(v), "cancel") || strings.Contains(strings.ToLower(v), "stop") {
-				stop = true
-			} else {
-				// Actual Source ID(s)
-				targets := strings.Split(ctx.Args.Get(k), ",")
-				for _, target := range targets {
-					if isNumeric(target) {
-						// Test/Use if number is guild
-						guild, err := bot.State.Guild(target)
-						if err == nil {
-							if config.DebugOutput {
-								log.Println(logPrefixHere, logPrefixDebug, color.YellowString("Specified target %s is a guild: \"%s\", adding all channels...", target, guild.Name))
-							}
-							for _, ch := range guild.Channels {
-								channels = append(channels, ch.ID)
-								if config.DebugOutput {
-									log.Println(logPrefixHere, logPrefixDebug, color.YellowString("Added %s (#%s in \"%s\") to history queue", ch.ID, ch.Name, guild.Name))
-								}
-							}
-						} else { // Test/Use if number is channel
-							ch, err := bot.State.Channel(target)
+				//SUBCOMMAND: cancel
+				if strings.Contains(strings.ToLower(argValue), "cancel") ||
+					strings.Contains(strings.ToLower(argValue), "stop") {
+					shouldAbort = true
+				} else if strings.Contains(strings.ToLower(argValue), "dbwipe") ||
+					strings.Contains(strings.ToLower(argValue), "wipedb") { //SUBCOMMAND: dbwipe
+					shouldProcess = false
+					shouldWipeDB = true
+				} else if strings.Contains(strings.ToLower(argValue), "cachewipe") ||
+					strings.Contains(strings.ToLower(argValue), "wipecache") { //SUBCOMMAND: cachewipe
+					shouldProcess = false
+					shouldWipeCache = true
+				} else if strings.Contains(strings.ToLower(argValue), "help") ||
+					strings.Contains(strings.ToLower(argValue), "info") { //SUBCOMMAND: help
+					shouldProcess = false
+					if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+						//content := fmt.Sprintf("")
+						_, err := replyEmbed(ctx.Msg, "Command — History Help", "TODO: this")
+						if err != nil {
+							log.Println(lg("Command", "History",
+								color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
+						}
+					} else {
+						log.Println(lg("Command", "History", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
+					}
+					log.Println(lg("Command", "History", color.CyanString, "%s requested history help.", getUserIdentifier(*ctx.Msg.Author)))
+				} else if strings.Contains(strings.ToLower(argValue), "list") ||
+					strings.Contains(strings.ToLower(argValue), "status") ||
+					strings.Contains(strings.ToLower(argValue), "output") { //SUBCOMMAND: list
+					shouldProcess = false
+					//MARKER: history jobs list
+
+					// 1st
+					output := fmt.Sprintf("**CURRENT HISTORY JOBS** ~ `%d total, %d running",
+						historyJobCnt, historyJobCntRunning)
+					outputC := fmt.Sprintf("CURRENT HISTORY JOBS ~ %d total, %d running",
+						historyJobCnt, historyJobCntRunning)
+					if historyJobCntCompleted > 0 {
+						t := fmt.Sprintf(", %d completed", historyJobCntCompleted)
+						output += t
+						outputC += t
+					}
+					if historyJobCntWaiting > 0 {
+						t := fmt.Sprintf(", %d waiting", historyJobCntWaiting)
+						output += t
+						outputC += t
+					}
+					if historyJobCntAborted > 0 {
+						t := fmt.Sprintf(", %d cancelled", historyJobCntAborted)
+						output += t
+						outputC += t
+					}
+					if historyJobCntErrored > 0 {
+						t := fmt.Sprintf(", %d failed", historyJobCntErrored)
+						output += t
+						outputC += t
+					}
+					safeReply(ctx, output+"`")
+					log.Println(lg("Command", "History", color.HiCyanString, outputC))
+
+					// Following
+					output = ""
+					for pair := historyJobs.Oldest(); pair != nil; pair = pair.Next() {
+						channelID := pair.Key
+						job := pair.Value
+						jobSourceName, jobChannelName := channelDisplay(channelID)
+
+						newline := fmt.Sprintf("• _%s_ (%s) `%s - %s`, `updated %s ago, added %s ago`\n",
+							historyStatusLabel(job.Status), job.OriginUser, jobSourceName, jobChannelName,
+							shortenTime(durafmt.ParseShort(time.Since(job.Updated)).String()),
+							shortenTime(durafmt.ParseShort(time.Since(job.Added)).String()))
+					redothismath: // bad way but dont care right now
+						if len(output)+len(newline) > limitMsg {
+							// send batch
+							safeReply(ctx, output)
+							output = ""
+							goto redothismath
+						}
+						output += newline
+						log.Println(lg("Command", "History", color.HiCyanString,
+							fmt.Sprintf("%s (%s) %s - %s, updated %s ago, added %s ago",
+								historyStatusLabel(job.Status), job.OriginUser, jobSourceName, jobChannelName,
+								shortenTime(durafmt.ParseShort(time.Since(job.Updated)).String()),
+								shortenTime(durafmt.ParseShort(time.Since(job.Added)).String())))) // no batching
+					}
+					// finish off
+					if output != "" {
+						safeReply(ctx, output)
+					}
+					// done
+					log.Println(lg("Command", "History", color.HiRedString, "%s requested statuses of history jobs.",
+						getUserIdentifier(*ctx.Msg.Author)))
+				} else if strings.Contains(strings.ToLower(argValue), "--before=") { // before key
+					before = strings.ReplaceAll(strings.ToLower(argValue), "--before=", "")
+					if isDate(before) {
+						beforeID = discordTimestampToSnowflake("2006-01-02", before)
+					} else if isNumeric(before) {
+						beforeID = before
+					}
+					if config.Debug {
+						log.Println(lg("Command", "History", color.CyanString, "Date before range applied, snowflake %s, converts back to %s",
+							beforeID, discordSnowflakeToTimestamp(beforeID, "2006-01-02T15:04:05.000Z07:00")))
+					}
+				} else if strings.Contains(strings.ToLower(argValue), "--since=") { //  since key
+					since = strings.ReplaceAll(strings.ToLower(argValue), "--since=", "")
+					if isDate(since) {
+						sinceID = discordTimestampToSnowflake("2006-01-02", since)
+					} else if isNumeric(since) {
+						sinceID = since
+					}
+					if config.Debug {
+						log.Println(lg("Command", "History", color.CyanString, "Date since range applied, snowflake %s, converts back to %s",
+							sinceID, discordSnowflakeToTimestamp(sinceID, "2006-01-02T15:04:05.000Z07:00")))
+					}
+				} else {
+					// Actual Source ID(s)
+					targets := strings.Split(ctx.Args.Get(argKey), ",")
+					for _, target := range targets {
+						if isNumeric(target) {
+							// Test/Use if number is guild
+							guild, err := bot.State.Guild(target)
 							if err == nil {
-								channels = append(channels, target)
-								if config.DebugOutput {
-									log.Println(logPrefixHere, logPrefixDebug, color.YellowString("Added %s (#%s in %s) to history queue", ch.ID, ch.Name, ch.GuildID))
+								if config.Debug {
+									log.Println(lg("Command", "History", color.YellowString,
+										"Specified target %s is a guild: \"%s\", adding all channels...",
+										target, guild.Name))
+								}
+								for _, ch := range guild.Channels {
+									channels = append(channels, ch.ID)
+									if config.Debug {
+										log.Println(lg("Command", "History", color.YellowString,
+											"Added %s (#%s in \"%s\") to history queue",
+											ch.ID, ch.Name, guild.Name))
+									}
+								}
+							} else { // Test/Use if number is channel
+								ch, err := bot.State.Channel(target)
+								if err == nil {
+									channels = append(channels, target)
+									if config.Debug {
+										log.Println(lg("Command", "History", color.YellowString, "Added %s (#%s in %s) to history queue",
+											ch.ID, ch.Name, ch.GuildID))
+									}
+								} else {
+									// Category
+									for _, guild := range bot.State.Guilds {
+										for _, ch := range guild.Channels {
+											if ch.ParentID == target {
+												channels = append(channels, ch.ID)
+												if config.Debug {
+													log.Println(lg("Command", "History", color.YellowString, "Added %s (#%s in %s) to history queue",
+														ch.ID, ch.Name, ch.GuildID))
+												}
+											}
+										}
+									}
 								}
 							}
+						} else if strings.Contains(strings.ToLower(target), "all") {
+							channels = getAllRegisteredChannels()
+							all = true
 						}
-					} else if strings.Contains(strings.ToLower(target), "all") {
-						channels = getAllChannels()
 					}
 				}
 			}
-		}
-		if len(channels) == 0 { // Local
-			channels = append(channels, ctx.Msg.ChannelID)
-		}
-		// Foreach Channel
-		for _, channel := range channels {
-			if config.DebugOutput {
-				log.Println(logPrefixHere, logPrefixDebug, color.YellowString("Processing %s...", channel))
+			//#endregion
+
+			// Local
+			if len(channels) == 0 {
+				channels = append(channels, ctx.Msg.ChannelID)
 			}
-			// Registered check
-			if isCommandableChannel(ctx.Msg) {
-				// Permission check
-				if isBotAdmin(ctx.Msg) {
-					// Run
-					if !stop {
-						_, historyCommandIsSet := historyStatus[channel]
-						if !historyCommandIsSet || historyStatus[channel] == "" {
-							if config.AsynchronousHistory {
-								go handleHistory(ctx.Msg, channel, beforeID, sinceID)
-							} else {
-								handleHistory(ctx.Msg, channel, beforeID, sinceID)
+			// Foreach Channel
+			for _, channel := range channels {
+				//#region Process Channels
+				if shouldProcess && config.Debug {
+					nameGuild := channel
+					if chinfo, err := bot.State.Channel(channel); err == nil {
+						nameGuild = getGuildName(chinfo.GuildID)
+					}
+					nameCategory := getChannelCategoryName(channel)
+					nameChannel := getChannelName(channel, nil)
+					nameDisplay := fmt.Sprintf("%s / %s", nameGuild, nameChannel)
+					if nameCategory != "unknown" {
+						nameDisplay = fmt.Sprintf("%s / %s / %s", nameGuild, nameCategory, nameChannel)
+					}
+					log.Println(lg("Command", "History", color.HiMagentaString,
+						"Queueing history job for \"%s\"\t\t(%s) ...", nameDisplay, channel))
+				}
+				if !isBotAdmin(ctx.Msg) {
+					log.Println(lg("Command", "History", color.CyanString,
+						"%s tried to handle history for %s but lacked proper permission.",
+						getUserIdentifier(*ctx.Msg.Author), channel))
+					if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+						log.Println(lg("Command", "History", color.HiRedString, fmtBotSendPerm, channel))
+					} else {
+						if _, err := replyEmbed(ctx.Msg, "Command — History", cmderrLackingBotAdminPerms); err != nil {
+							log.Println(lg("Command", "History", color.HiRedString, cmderrSendFailure,
+								getUserIdentifier(*ctx.Msg.Author), err))
+						}
+					}
+				} else { // IS BOT ADMIN
+					if shouldProcess { // PROCESS TREE; MARKER: history queue via cmd
+						if shouldAbort { // ABORT
+							if job, exists := historyJobs.Get(channel); exists &&
+								(job.Status == historyStatusRunning || job.Status == historyStatusWaiting) {
+								// DOWNLOADING, ABORTING
+								job.Status = historyStatusAbortRequested
+								if job.Status == historyStatusWaiting {
+									job.Status = historyStatusAbortCompleted
+								}
+								historyJobs.Set(channel, job)
+								log.Println(lg("Command", "History", color.CyanString,
+									"%s cancelled history cataloging for \"%s\"",
+									getUserIdentifier(*ctx.Msg.Author), channel))
+							} else { // NOT DOWNLOADING, ABORTING
+								log.Println(lg("Command", "History", color.CyanString,
+									"%s tried to cancel history for \"%s\" but it's not running",
+									getUserIdentifier(*ctx.Msg.Author), channel))
+							}
+						} else { // RUN
+							if job, exists := historyJobs.Get(channel); !exists ||
+								(job.Status != historyStatusRunning && job.Status != historyStatusAbortRequested) {
+								job.Status = historyStatusWaiting
+								job.OriginChannel = ctx.Msg.ChannelID
+								job.OriginUser = getUserIdentifier(*ctx.Msg.Author)
+								job.TargetCommandingMessage = ctx.Msg
+								job.TargetChannelID = channel
+								job.TargetBefore = beforeID
+								job.TargetSince = sinceID
+								job.Updated = time.Now()
+								job.Added = time.Now()
+								historyJobs.Set(channel, job)
+							} else { // ALREADY RUNNING
+								log.Println(lg("Command", "History", color.CyanString,
+									"%s tried using history command but history is already running for %s...",
+									getUserIdentifier(*ctx.Msg.Author), channel))
 							}
-						} else { // ALREADY RUNNING
-							log.Println(logPrefixHere, color.CyanString("%s tried using history command but history is already running for %s...", getUserIdentifier(*ctx.Msg.Author), channel))
 						}
-					} else if historyStatus[channel] == "downloading" {
-						historyStatus[channel] = "cancel"
-						if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-							_, err := replyEmbed(ctx.Msg, "Command — History", cmderrHistoryCancelled)
-							if err != nil {
-								log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
+					}
+					if shouldWipeDB {
+						if all {
+							myDB.Close()
+							time.Sleep(1 * time.Second)
+							if _, err := os.Stat(pathDatabaseBase); err == nil {
+								err = os.RemoveAll(pathDatabaseBase)
+								if err != nil {
+									log.Println(lg("Command", "History", color.HiRedString,
+										"Encountered error deleting database folder:\t%s", err))
+								} else {
+									log.Println(lg("Command", "History", color.HiGreenString,
+										"Deleted database."))
+								}
+								time.Sleep(1 * time.Second)
+								mainWg.Add(1)
+								go openDatabase()
+								break
+							} else {
+								log.Println(lg("Command", "History", color.HiRedString,
+									"Database folder inaccessible:\t%s", err))
 							}
 						} else {
-							log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, channel))
+							dbDeleteByChannelID(channel)
 						}
-						log.Println(logPrefixHere, color.CyanString("%s cancelled history cataloging for \"%s\"", getUserIdentifier(*ctx.Msg.Author), channel))
 					}
-				} else { // DOES NOT HAVE PERMISSION
-					if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-						_, err := replyEmbed(ctx.Msg, "Command — History", cmderrLackingLocalAdminPerms)
-						if err != nil {
-							log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
+					if shouldWipeCache {
+						if all {
+							if _, err := os.Stat(pathCacheHistory); err == nil {
+								err = os.RemoveAll(pathCacheHistory)
+								if err != nil {
+									log.Println(lg("Command", "History", color.HiRedString,
+										"Encountered error deleting database folder:\t%s", err))
+								} else {
+									log.Println(lg("Command", "History", color.HiGreenString,
+										"Deleted database."))
+									break
+								}
+							} else {
+								log.Println(lg("Command", "History", color.HiRedString,
+									"Cache folder inaccessible:\t%s", err))
+							}
+						} else {
+							fp := pathCacheHistory + string(os.PathSeparator) + channel + ".json"
+							if _, err := os.Stat(fp); err == nil {
+								err = os.RemoveAll(fp)
+								if err != nil {
+									log.Println(lg("Debug", "History", color.HiRedString,
+										"Encountered error deleting cache file for %s:\t%s", channel, err))
+								} else {
+									log.Println(lg("Debug", "History", color.HiGreenString,
+										"Deleted cache file for %s.", channel))
+								}
+							} else {
+								log.Println(lg("Command", "History", color.HiRedString,
+									"Cache folder inaccessible:\t%s", err))
+							}
 						}
-					} else {
-						log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, channel))
 					}
-					log.Println(logPrefixHere, color.CyanString("%s tried to cache history for %s but lacked proper permission.", getUserIdentifier(*ctx.Msg.Author), channel))
 				}
-			} else { // CHANNEL NOT REGISTERED
-				log.Println(logPrefixHere, color.CyanString("%s tried to catalog history for \"%s\" but channel is not registered...", getUserIdentifier(*ctx.Msg.Author), channel))
+				//#endregion
+			}
+			if shouldWipeDB {
+				cachedDownloadID = dbDownloadCount()
 			}
 		}
-	}).Alias("catalog", "cache").Cat("Admin").Desc("Catalogs history for this channel")
+	}).Cat("Admin").Alias("catalog", "cache").Desc("Catalogs history for this channel")
 
-	router.On("exit", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:exit]")
+	go router.On("exit", func(ctx *exrouter.Context) {
 		if isCommandableChannel(ctx.Msg) {
 			if isBotAdmin(ctx.Msg) {
-				if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-					_, err := replyEmbed(ctx.Msg, "Command — Exit", "Exiting...")
-					if err != nil {
-						log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
-					}
+				if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+					log.Println(lg("Command", "Exit", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
 				} else {
-					log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
+					if _, err := replyEmbed(ctx.Msg, "Command — Exit", "Exiting program..."); err != nil {
+						log.Println(lg("Command", "Exit", color.HiRedString,
+							cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
+					}
 				}
-				log.Println(logPrefixHere, color.HiCyanString("%s (bot admin) requested exit, goodbye...", getUserIdentifier(*ctx.Msg.Author)))
+				log.Println(lg("Command", "Exit", color.HiCyanString,
+					"%s (bot admin) requested exit, goodbye...",
+					getUserIdentifier(*ctx.Msg.Author)))
 				properExit()
 			} else {
-				if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-					_, err := replyEmbed(ctx.Msg, "Command — Exit", cmderrLackingBotAdminPerms)
-					if err != nil {
-						log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
-					}
+				if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+					log.Println(lg("Command", "Exit", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
 				} else {
-					log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
+					if _, err := replyEmbed(ctx.Msg, "Command — Exit", cmderrLackingBotAdminPerms); err != nil {
+						log.Println(lg("Command", "Exit", color.HiRedString,
+							cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
+					}
 				}
-				log.Println(logPrefixHere, color.HiCyanString("%s tried to exit but lacked bot admin perms.", getUserIdentifier(*ctx.Msg.Author)))
+				log.Println(lg("Command", "Exit", color.HiCyanString,
+					"%s tried to exit but lacked bot admin perms.", getUserIdentifier(*ctx.Msg.Author)))
 			}
 		}
-	}).Alias("reload", "kill").Cat("Admin").Desc("Kills the bot")
+	}).Cat("Admin").Alias("reload", "kill").Desc("Kills the bot")
 
-	router.On("emojis", func(ctx *exrouter.Context) {
-		logPrefixHere := color.CyanString("[dgrouter:emojis]")
-		if isGlobalCommandAllowed(ctx.Msg) {
+	go router.On("emojis", func(ctx *exrouter.Context) {
+		if isCommandableChannel(ctx.Msg) {
 			if isBotAdmin(ctx.Msg) {
 				if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-					args := ctx.Args.After(1)
-
 					// Determine which guild(s)
-					guilds := []string{ctx.Msg.GuildID}
-					if args != "" {
+					guilds := []string{ctx.Msg.GuildID}        // default to origin
+					if args := ctx.Args.After(1); args != "" { // specifics
 						guilds = nil
 						_guilds := strings.Split(args, ",")
 						if len(_guilds) > 0 {
@@ -371,48 +575,48 @@ func handleCommands() *exrouter.Route {
 							}
 						}
 					}
-
 					for _, guild := range guilds {
 						i := 0
 						s := 0
 
 						guildName := guild
 						guildNameO := guild
-						guildInfo, err := bot.Guild(guild)
-						if err == nil {
+						if guildInfo, err := bot.Guild(guild); err == nil {
 							guildName = sanitize.Name(guildInfo.Name)
 							guildNameO = guildInfo.Name
 						}
 
 						destination := "emojis" + string(os.PathSeparator) + guildName + string(os.PathSeparator)
-
-						err = os.MkdirAll(destination, 0755)
-						if err != nil {
-							log.Println(logPrefixHere, color.HiRedString("Error while creating destination folder \"%s\": %s", destination, err))
+						if err = os.MkdirAll(destination, 0755); err != nil {
+							log.Println(lg("Command", "Emojis", color.HiRedString, "Error while creating destination folder \"%s\": %s", destination, err))
 						} else {
 							emojis, err := bot.GuildEmojis(guild)
-							if err == nil {
+							if err != nil {
+								log.Println(lg("Command", "Emojis", color.HiRedString, "Failed to get server emojis:\t%s", err))
+							} else {
 								for _, emoji := range emojis {
 									var message discordgo.Message
 									message.ChannelID = ctx.Msg.ChannelID
 									url := "https://cdn.discordapp.com/emojis/" + emoji.ID
 
-									status := startDownload(
-										downloadRequestStruct{
-											InputURL:   url,
-											Filename:   emoji.ID,
-											Path:       destination,
-											Message:    &message,
-											FileTime:   time.Now(),
-											HistoryCmd: false,
-											EmojiCmd:   true,
-										})
+									status, _ := downloadRequestStruct{
+										InputURL:   url,
+										Filename:   emoji.ID,
+										Path:       destination,
+										Message:    &message,
+										FileTime:   time.Now(),
+										HistoryCmd: false,
+										EmojiCmd:   true,
+										StartTime:  time.Now(),
+									}.handleDownload()
 
 									if status.Status == downloadSuccess {
 										i++
 									} else {
 										s++
-										log.Println(logPrefixHere, color.HiRedString("Failed to download emoji \"%s\": \t[%d - %s] %v", url, status.Status, getDownloadStatusString(status.Status), status.Error))
+										log.Println(lg("Command", "Emojis", color.HiRedString,
+											"Failed to download emoji \"%s\": \t[%d - %s] %v",
+											url, status.Status, getDownloadStatusString(status.Status), status.Error))
 									}
 								}
 								destinationOut := destination
@@ -426,24 +630,23 @@ func handleCommands() *exrouter.Route {
 									),
 								)
 								if err != nil {
-									log.Println(logPrefixHere, color.HiRedString("Failed to send status message for emoji downloads:\t%s", err))
+									log.Println(lg("Command", "Emojis", color.HiRedString,
+										"Failed to send status message for emoji downloads:\t%s", err))
 								}
-							} else {
-								log.Println(err)
 							}
 						}
 					}
 				}
 			} else {
-				if hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
-					_, err := replyEmbed(ctx.Msg, "Command — Emojis", cmderrLackingBotAdminPerms)
-					if err != nil {
-						log.Println(logPrefixHere, color.HiRedString("Failed to send command embed message (requested by %s)...\t%s", getUserIdentifier(*ctx.Msg.Author), err))
-					}
+				if !hasPerms(ctx.Msg.ChannelID, discordgo.PermissionSendMessages) {
+					log.Println(lg("Command", "Emojis", color.HiRedString, fmtBotSendPerm, ctx.Msg.ChannelID))
 				} else {
-					log.Println(logPrefixHere, color.HiRedString(fmtBotSendPerm, ctx.Msg.ChannelID))
+					if _, err := replyEmbed(ctx.Msg, "Command — Emojis", cmderrLackingBotAdminPerms); err != nil {
+						log.Println(lg("Command", "Emojis", color.HiRedString, cmderrSendFailure, getUserIdentifier(*ctx.Msg.Author), err))
+					}
 				}
-				log.Println(logPrefixHere, color.HiCyanString("%s tried to download emojis but lacked bot admin perms.", getUserIdentifier(*ctx.Msg.Author)))
+				log.Println(lg("Command", "Emojis", color.HiCyanString,
+					"%s tried to download emojis but lacked bot admin perms.", getUserIdentifier(*ctx.Msg.Author)))
 			}
 		}
 	}).Cat("Admin").Desc("Saves all server emojis to download destination")
@@ -451,7 +654,7 @@ func handleCommands() *exrouter.Route {
 	//#endregion
 
 	// Handler for Command Router
-	bot.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) {
+	go bot.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) {
 		//NOTE: This setup makes it case-insensitive but message content will be lowercase, currently case sensitivity is not necessary.
 		router.FindAndExecute(bot, strings.ToLower(config.CommandPrefix), bot.State.User.ID, messageToLower(m.Message))
 	})

+ 237 - 55
common.go

@@ -12,11 +12,30 @@ import (
 	"strings"
 	"time"
 
+	"github.com/bwmarrin/discordgo"
 	"github.com/fatih/color"
 	"github.com/hako/durafmt"
 	"github.com/hashicorp/go-version"
 )
 
+//#region Instance
+
+func uptime() time.Duration {
+	return time.Since(startTime) //.Truncate(time.Second)
+}
+
+func properExit() {
+	// Not formatting string because I only want the exit message to be red.
+	log.Println(lg("Main", "", color.HiRedString, "[EXIT IN 15 SECONDS] Uptime was %s...", durafmt.Parse(time.Since(startTime)).String()))
+	log.Println(color.HiCyanString("--------------------------------------------------------------------------------"))
+	time.Sleep(15 * time.Second)
+	os.Exit(1)
+}
+
+//#endregion
+
+//#region Files
+
 var (
 	pathBlacklist = []string{"/", "\\", "<", ">", ":", "\"", "|", "?", "*"}
 )
@@ -29,32 +48,36 @@ func clearPath(p string) string {
 	return r
 }
 
-func uptime() time.Duration {
-	return time.Since(startTime)
+func filenameFromURL(inputURL string) string {
+	base := path.Base(inputURL)
+	parts := strings.Split(base, "?")
+	return path.Clean(parts[0])
 }
 
-func properExit() {
-	// Not formatting string because I only want the exit message to be red.
-	log.Println(color.HiRedString("[EXIT IN 15 SECONDS]"), " Uptime was", durafmt.Parse(time.Since(startTime)).String(), "...")
-	log.Println(color.HiCyanString("--------------------------------------------------------------------------------"))
-	time.Sleep(15 * time.Second)
-	os.Exit(1)
+func filepathExtension(filepath string) string {
+	if strings.Contains(filepath, "?") {
+		filepath = strings.Split(filepath, "?")[0]
+	}
+	filepath = path.Ext(filepath)
+	return filepath
 }
 
+//#endregion
+
+//#region Text Formatting & Querying
+
 func stringInSlice(a string, list []string) bool {
 	for _, b := range list {
-		if strings.ToLower(b) == strings.ToLower(a) {
+		if strings.EqualFold(a, b) {
 			return true
 		}
 	}
 	return false
 }
 
-//#region Formatting
-
 func formatNumber(n int64) string {
 	var numberSeparator byte = ','
-	if config.NumberFormatEuropean {
+	if config.EuropeanNumbers {
 		numberSeparator = '.'
 	}
 
@@ -78,11 +101,11 @@ func formatNumber(n int64) string {
 
 func formatNumberShort(x int64) string {
 	var numberSeparator string = ","
-	if config.NumberFormatEuropean {
+	if config.EuropeanNumbers {
 		numberSeparator = "."
 	}
 	var decimalSeparator string = "."
-	if config.NumberFormatEuropean {
+	if config.EuropeanNumbers {
 		decimalSeparator = ","
 	}
 
@@ -102,13 +125,6 @@ func formatNumberShort(x int64) string {
 	return fmt.Sprint(x)
 }
 
-func boolS(val bool) string {
-	if val {
-		return "ON"
-	}
-	return "OFF"
-}
-
 func pluralS(num int) string {
 	if num == 1 {
 		return ""
@@ -139,33 +155,83 @@ func stripSymbols(i string) string {
 	return re.ReplaceAllString(i, " ")
 }
 
+func isNumeric(s string) bool {
+	_, err := strconv.ParseFloat(s, 64)
+	return err == nil
+}
+
+func isDate(s string) bool {
+	_, err := time.Parse("2006-01-02", s)
+	return err == nil
+}
+
+func shortenTime(input string) string {
+	input = strings.ReplaceAll(input, " nanoseconds", "ns")
+	input = strings.ReplaceAll(input, " nanosecond", "ns")
+	input = strings.ReplaceAll(input, " microseconds", "μs")
+	input = strings.ReplaceAll(input, " microsecond", "μs")
+	input = strings.ReplaceAll(input, " milliseconds", "ms")
+	input = strings.ReplaceAll(input, " millisecond", "ms")
+	input = strings.ReplaceAll(input, " seconds", "s")
+	input = strings.ReplaceAll(input, " second", "s")
+	input = strings.ReplaceAll(input, " minutes", "m")
+	input = strings.ReplaceAll(input, " minute", "m")
+	input = strings.ReplaceAll(input, " hours", "h")
+	input = strings.ReplaceAll(input, " hour", "h")
+	input = strings.ReplaceAll(input, " days", "d")
+	input = strings.ReplaceAll(input, " day", "d")
+	input = strings.ReplaceAll(input, " weeks", "w")
+	input = strings.ReplaceAll(input, " week", "w")
+	input = strings.ReplaceAll(input, " months", "mo")
+	input = strings.ReplaceAll(input, " month", "mo")
+	return input
+}
+
+/*func condenseString(input string, length int) string {
+	filler := "....."
+	ret := input
+	if len(input) > length+len(filler) {
+		half := int((length / 2) - len(filler))
+		ret = input[0:half] + filler + input[len(input)-half:]
+	}
+	return ret
+}*/
+
 //#endregion
 
-//#region Requests
+//#region Github Release Checking
 
 type githubReleaseApiObject struct {
 	TagName string `json:"tag_name"`
 }
 
-func isLatestGithubRelease() bool {
-	prefixHere := color.HiMagentaString("[Github Update Check]")
+var latestGithubRelease string
 
+func getLatestGithubRelease() string {
 	githubReleaseApiObject := new(githubReleaseApiObject)
-	err := getJSON(projectReleaseApiURL, githubReleaseApiObject)
+	err := getJSON("https://api.github.com/repos/"+projectRepoBase+"/releases/latest", githubReleaseApiObject)
 	if err != nil {
-		log.Println(prefixHere, color.RedString("Error fetching current Release JSON: %s", err))
+		log.Println(lg("API", "Github", color.RedString, "Error fetching current Release JSON: %s", err))
+		return ""
+	}
+	return githubReleaseApiObject.TagName
+}
+
+func isLatestGithubRelease() bool {
+	latestGithubRelease = getLatestGithubRelease()
+	if latestGithubRelease == "" {
 		return true
 	}
 
 	thisVersion, err := version.NewVersion(projectVersion)
 	if err != nil {
-		log.Println(prefixHere, color.RedString("Error parsing current version: %s", err))
+		log.Println(lg("API", "Github", color.RedString, "Error parsing current version: %s", err))
 		return true
 	}
 
-	latestVersion, err := version.NewVersion(githubReleaseApiObject.TagName)
+	latestVersion, err := version.NewVersion(latestGithubRelease)
 	if err != nil {
-		log.Println(prefixHere, color.RedString("Error parsing latest version: %s", err))
+		log.Println(lg("API", "Github", color.RedString, "Error parsing latest version: %s", err))
 		return true
 	}
 
@@ -176,6 +242,10 @@ func isLatestGithubRelease() bool {
 	return true
 }
 
+//#endregion
+
+//#region Requests
+
 func getJSON(url string, target interface{}) error {
 	r, err := http.Get(url)
 	if err != nil {
@@ -205,39 +275,151 @@ func getJSONwithHeaders(url string, target interface{}, headers map[string]strin
 
 //#endregion
 
-//#region Parsing
+//#region Log
+
+/*const (
+	logLevelOff       = -1
+	logLevelEssential = iota
+	logLevelFatal
+	logLevelError
+	logLevelWarning
+	logLevelInfo
+	logLevelDebug
+	logLevelVerbose
+	logLevelAll
+)*/
+
+func lg(group string, subgroup string, colorFunc func(string, ...interface{}) string, line string, p ...interface{}) string {
+	colorPrefix := group
+	switch strings.ToLower(group) {
+
+	case "main":
+		if subgroup == "" {
+			colorPrefix = ""
+		} else {
+			colorPrefix = ""
+		}
+	case "debug":
+		if subgroup == "" {
+			colorPrefix = color.HiYellowString("[DEBUG]")
+		} else {
+			colorPrefix = color.HiYellowString("[DEBUG | %s]", subgroup)
+		}
+	case "test":
+		if subgroup == "" {
+			colorPrefix = color.HiYellowString("[TEST]")
+		} else {
+			colorPrefix = color.HiYellowString("[TEST | %s]", subgroup)
+		}
+	case "info":
+		if subgroup == "" {
+			colorPrefix = color.CyanString("[Info]")
+		} else {
+			colorPrefix = color.CyanString("[Info | %s]", subgroup)
+		}
+	case "version":
+		colorPrefix = color.HiMagentaString("[Version]")
 
-func filenameFromURL(inputURL string) string {
-	base := path.Base(inputURL)
-	parts := strings.Split(base, "?")
-	return path.Clean(parts[0])
-}
+	case "settings":
+		colorPrefix = color.GreenString("[Settings]")
 
-func filepathExtension(filepath string) string {
-	if strings.Contains(filepath, "?") {
-		filepath = strings.Split(filepath, "?")[0]
+	case "database":
+		colorPrefix = color.HiYellowString("[Database]")
+
+	case "setup":
+		colorPrefix = color.HiGreenString("[Setup]")
+
+	case "checkup":
+		colorPrefix = color.HiGreenString("[Checkup]")
+
+	case "discord":
+		if subgroup == "" {
+			colorPrefix = color.HiBlueString("[Discord]")
+		} else {
+			colorPrefix = color.HiBlueString("[Discord | %s]", subgroup)
+		}
+
+	case "history":
+		if subgroup == "" {
+			colorPrefix = color.HiCyanString("[History]")
+		} else {
+			colorPrefix = color.HiCyanString("[History | %s]", subgroup)
+		}
+
+	case "command":
+		if subgroup == "" {
+			colorPrefix = color.HiGreenString("[Commands]")
+		} else {
+			colorPrefix = color.HiGreenString("[Command : %s]", subgroup)
+		}
+
+	case "download":
+		if subgroup == "" {
+			colorPrefix = color.GreenString("[Downloads]")
+		} else {
+			colorPrefix = color.GreenString("[Downloads | %s]", subgroup)
+		}
+
+	case "message":
+		if subgroup == "" {
+			colorPrefix = color.CyanString("[Messages]")
+		} else {
+			colorPrefix = color.CyanString("[Messages | %s]", subgroup)
+		}
+
+	case "regex":
+		if subgroup == "" {
+			colorPrefix = color.YellowString("[Regex]")
+		} else {
+			colorPrefix = color.YellowString("[Regex | %s]", subgroup)
+		}
+
+	case "api":
+		if subgroup == "" {
+			colorPrefix = color.HiMagentaString("[APIs]")
+		} else {
+			colorPrefix = color.HiMagentaString("[API | %s]", subgroup)
+		}
 	}
-	filepath = path.Ext(filepath)
-	return filepath
-}
 
-func isNumeric(s string) bool {
-	_, err := strconv.ParseFloat(s, 64)
-	return err == nil
-}
+	if bot != nil && botReady {
+		simplePrefix := group
+		if subgroup != "" {
+			simplePrefix += ":" + subgroup
+		}
+		for _, adminChannel := range config.AdminChannels {
+			if *adminChannel.LogProgram {
+				outputToChannel := func(channel string) {
+					if channel != "" {
+						if hasPerms(channel, discordgo.PermissionSendMessages) {
+							if _, err := bot.ChannelMessageSend(channel,
+								fmt.Sprintf("```%s | [%s] %s```",
+									time.Now().Format(time.RFC3339), simplePrefix, fmt.Sprintf(line, p...)),
+							); err != nil {
+								log.Println(color.HiRedString("Failed to send message...\t%s", err))
+							}
+						}
+					}
+				}
+				outputToChannel(adminChannel.ChannelID)
+				if adminChannel.ChannelIDs != nil {
+					for _, ch := range *adminChannel.ChannelIDs {
+						outputToChannel(ch)
+					}
+				}
+			}
+		}
+	}
 
-func isDate(s string) bool {
-	_, err := time.Parse("2006-01-02", s)
-	return err == nil
-}
+	pp := "> " // prefix prefix :)
+	if strings.ToLower(group) == "debug" || strings.ToLower(subgroup) == "debug" {
+		pp = color.YellowString("? ")
+	}
 
-func dateLocalToUTC(s string) string {
-	if s == "" || !isDate(s) {
-		return ""
+	if colorPrefix != "" {
+		colorPrefix += " "
 	}
-	rawDate, _ := time.Parse("2006-01-02", s)
-	localDate := time.Date(rawDate.Year(), rawDate.Month(), rawDate.Day(), 0, 0, 0, 0, time.Local)
-	return fmt.Sprintf("%s-%s-%s", localDate.In(time.UTC).Year(), localDate.In(time.UTC).Month(), localDate.In(time.UTC).Day())
+	return "\t" + pp + colorPrefix + colorFunc(line, p...)
 }
 
 //#endregion

File diff suppressed because it is too large
+ 452 - 341
config.go


+ 57 - 31
database.go

@@ -1,36 +1,65 @@
 package main
 
 import (
+	"archive/zip"
 	"encoding/json"
 	"fmt"
+	"io"
 	"log"
+	"os"
+	"path/filepath"
 	"time"
 
 	"github.com/HouzuoGuo/tiedot/db"
 	"github.com/fatih/color"
 )
 
-// Trim files already downloaded and stored in database
-func trimDownloadedLinks(linkList map[string]string, channelID string) map[string]string {
-	channelConfig := getChannelConfig(channelID)
-
-	newList := make(map[string]string, 0)
-	for link, filename := range linkList {
-		downloadedFiles := dbFindDownloadByURL(link)
-		alreadyDownloaded := false
-		for _, downloadedFile := range downloadedFiles {
-			if downloadedFile.ChannelID == channelID {
-				alreadyDownloaded = true
-			}
+func backupDatabase() error {
+	if err := os.MkdirAll(pathDatabaseBackups, 0755); err != nil {
+		return err
+	}
+	file, err := os.Create(pathDatabaseBackups + string(os.PathSeparator) + time.Now().Format("2006-01-02_15-04-05.000000000") + ".zip")
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+
+	w := zip.NewWriter(file)
+	defer w.Close()
+
+	err = filepath.Walk(pathDatabaseBase, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if info.IsDir() {
+			return nil
+		}
+		file, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		defer file.Close()
+
+		// Ensure that `path` is not absolute; it should not start with "/".
+		// This snippet happens to work because I don't use
+		// absolute paths, but ensure your real-world code
+		// transforms path into a zip-root relative path.
+		f, err := w.Create(path)
+		if err != nil {
+			return err
 		}
 
-		if !alreadyDownloaded || *channelConfig.SavePossibleDuplicates {
-			newList[link] = filename
-		} else if config.DebugOutput {
-			log.Println(logPrefixFileSkip, color.GreenString("Found URL has already been downloaded for this channel: %s", link))
+		_, err = io.Copy(f, file)
+		if err != nil {
+			return err
 		}
+
+		return nil
+	})
+	if err != nil {
+		return err
 	}
-	return newList
+	return nil
 }
 
 func dbInsertDownload(download *downloadItem) error {
@@ -49,7 +78,7 @@ func dbFindDownloadByID(id int) *downloadItem {
 	downloads := myDB.Use("Downloads")
 	readBack, err := downloads.Read(id)
 	if err != nil {
-		log.Println(color.HiRedString("Failed to read database:\t%s", err))
+		log.Println(lg("Database", "Downloads", color.HiRedString, "Failed to read database:\t%s", err))
 	}
 	timeT, _ := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", readBack["Time"].(string))
 	return &downloadItem{
@@ -75,6 +104,16 @@ func dbFindDownloadByURL(inputURL string) []*downloadItem {
 	return downloadedImages
 }
 
+func dbDeleteByChannelID(channelID string) {
+	var query interface{}
+	json.Unmarshal([]byte(fmt.Sprintf(`[{"eq": "%s", "in": ["ChannelID"]}]`, channelID)), &query)
+	queryResult := make(map[int]struct{})
+	db.EvalQuery(query, myDB.Use("Downloads"), &queryResult)
+	for id := range queryResult {
+		myDB.Use("Downloads").Delete(id)
+	}
+}
+
 //#region Statistics
 
 func dbDownloadCount() int {
@@ -99,17 +138,4 @@ func dbDownloadCountByChannel(channelID string) int {
 	return len(downloadedImages)
 }
 
-func dbDownloadCountByUser(userID string) int {
-	var query interface{}
-	json.Unmarshal([]byte(fmt.Sprintf(`[{"eq": "%s", "in": ["UserID"]}]`, userID)), &query)
-	queryResult := make(map[int]struct{})
-	db.EvalQuery(query, myDB.Use("Downloads"), &queryResult)
-
-	downloadedImages := make([]*downloadItem, 0)
-	for id := range queryResult {
-		downloadedImages = append(downloadedImages, dbFindDownloadByID(id))
-	}
-	return len(downloadedImages)
-}
-
 //#endregion

+ 411 - 268
discord.go

@@ -3,7 +3,8 @@ package main
 import (
 	"fmt"
 	"log"
-	"runtime"
+	"net/url"
+	"os"
 	"strconv"
 	"strings"
 	"time"
@@ -11,11 +12,100 @@ import (
 	"github.com/AvraamMavridis/randomcolor"
 	"github.com/aidarkhanov/nanoid/v2"
 	"github.com/bwmarrin/discordgo"
+	"github.com/dustin/go-humanize"
 	"github.com/fatih/color"
 	"github.com/hako/durafmt"
 	"github.com/teris-io/shortid"
 )
 
+//#region Get
+
+//TODO: Clean below
+
+/*func getChannelState(channelID string) *discordgo.Channel {
+	sourceChannel, _ := bot.State.Channel(channelID)
+	if sourceChannel != nil {
+		return sourceChannel
+	}
+	return &discordgo.Channel{}
+}*/
+
+/*func getGuildState(guildID string) *discordgo.Guild {
+	sourceGuild, _ := bot.State.Guild(guildID)
+	if sourceGuild != nil {
+		return sourceGuild
+	}
+	return &discordgo.Guild{}
+}*/
+
+const (
+	fmtBotSendPerm = "Bot does not have permission to send messages in %s"
+)
+
+func getChannelGuildID(channelID string) string {
+	sourceChannel, _ := bot.State.Channel(channelID)
+	if sourceChannel != nil {
+		return sourceChannel.GuildID
+	}
+	return ""
+}
+
+func getGuildName(guildID string) string {
+	sourceGuildName := "DM"
+	sourceGuild, err := bot.State.Guild(guildID)
+	if err != nil {
+		sourceGuild, _ = bot.Guild(guildID)
+	}
+	if sourceGuild != nil && sourceGuild.Name != "" {
+		sourceGuildName = sourceGuild.Name
+	}
+	return sourceGuildName
+}
+
+func getChannelCategoryName(channelID string) string {
+	sourceChannelName := "unknown"
+	sourceChannel, _ := bot.State.Channel(channelID)
+	if sourceChannel != nil {
+		sourceParent, _ := bot.State.Channel(sourceChannel.ParentID)
+		if sourceParent != nil {
+			if sourceChannel.Name != "" {
+				sourceChannelName = sourceParent.Name
+			}
+		}
+	}
+	return sourceChannelName
+}
+
+func getChannelName(channelID string, channelData *discordgo.Channel) string {
+	sourceChannelName := "unknown"
+	sourceChannel, err := bot.State.Channel(channelID)
+	if err != nil {
+		sourceChannel, _ = bot.Channel(channelID)
+	}
+	if channelData != nil {
+		sourceChannel = channelData
+	}
+	if sourceChannel != nil {
+		if sourceChannel.Name != "" {
+			sourceChannelName = sourceChannel.Name
+		} else if sourceChannel.Topic != "" {
+			sourceChannelName = sourceChannel.Topic
+		} else {
+			switch sourceChannel.Type {
+			case discordgo.ChannelTypeDM:
+				sourceChannelName = "DM"
+			case discordgo.ChannelTypeGroupDM:
+				sourceChannelName = "Group-DM"
+			}
+		}
+	}
+	return sourceChannelName
+}
+
+//#endregion
+
+//#region Time
+
 const (
 	discordEpoch = 1420070400000
 )
@@ -23,13 +113,12 @@ const (
 //TODO: Clean these two
 
 func discordTimestampToSnowflake(format string, timestamp string) string {
-	t, err := time.Parse(format, timestamp)
-	if err == nil {
-		return fmt.Sprint(((t.Local().UnixNano() / int64(time.Millisecond)) - discordEpoch) << 22)
+	if t, err := time.ParseInLocation(format, timestamp, time.Local); err == nil {
+		return fmt.Sprint(((t.UnixNano() / int64(time.Millisecond)) - discordEpoch) << 22)
 	}
-	log.Println(color.HiRedString("Failed to convert timestamp to discord snowflake... Format: '%s', Timestamp: '%s' - Error:\t%s",
-		format, timestamp, err),
-	)
+	log.Println(lg("Main", "", color.HiRedString,
+		"Failed to convert timestamp to discord snowflake... Format: '%s', Timestamp: '%s' - Error:\t%s",
+		format, timestamp, err))
 	return ""
 }
 
@@ -42,53 +131,96 @@ func discordSnowflakeToTimestamp(snowflake string, format string) string {
 	return t.Local().Format(format)
 }
 
-func getAllChannels() []string {
-	var channels []string
-	if config.All != nil { // ALL MODE
-		for _, guild := range bot.State.Guilds {
-			for _, channel := range guild.Channels {
-				if hasPerms(channel.ID, discordgo.PermissionReadMessages) && hasPerms(channel.ID, discordgo.PermissionReadMessageHistory) {
-					channels = append(channels, channel.ID)
+//#endregion
+
+//#region Messages
+
+// For command case-insensitivity
+func messageToLower(message *discordgo.Message) *discordgo.Message {
+	newMessage := *message
+	newMessage.Content = strings.ToLower(newMessage.Content)
+	return &newMessage
+}
+
+func fixMessage(m *discordgo.Message) *discordgo.Message {
+	// If message content is empty (likely due to userbot/selfbot)
+	ubIssue := "Message is corrupted due to endpoint restriction"
+	if m.Content == "" && len(m.Attachments) == 0 && len(m.Embeds) == 0 {
+		// Get message history
+		mCache, err := bot.ChannelMessages(m.ChannelID, 20, "", "", "")
+		if err == nil {
+			if len(mCache) > 0 {
+				for _, mCached := range mCache {
+					if mCached.ID == m.ID {
+						// Fix original message having empty Guild ID
+						guildID := m.GuildID
+						// Replace message
+						m = mCached
+						// ^^
+						if m.GuildID == "" && guildID != "" {
+							m.GuildID = guildID
+						}
+						// Parse commands
+						botCommands.FindAndExecute(bot, strings.ToLower(config.CommandPrefix), bot.State.User.ID, messageToLower(m))
+
+						break
+					}
 				}
+			} else if config.Debug {
+				log.Println(lg("Debug", "fixMessage",
+					color.RedString, "%s, and an attempt to get channel messages found nothing...",
+					ubIssue))
 			}
+		} else if config.Debug {
+			log.Println(lg("Debug", "fixMessage",
+				color.HiRedString, "%s, and an attempt to get channel messages encountered an error:\t%s", ubIssue, err))
 		}
-	} else { // STANDARD MODE
-		// Compile all config channels
-		for _, channel := range config.Channels {
-			if channel.ChannelIDs != nil {
-				for _, subchannel := range *channel.ChannelIDs {
-					channels = append(channels, subchannel)
-				}
-			} else if isNumeric(channel.ChannelID) {
-				channels = append(channels, channel.ChannelID)
-			}
+	}
+	if m.Content == "" && len(m.Attachments) == 0 && len(m.Embeds) == 0 {
+		if config.Debug && selfbot {
+			log.Println(lg("Debug", "fixMessage",
+				color.YellowString, "%s, and attempts to fix seem to have failed...", ubIssue))
 		}
-		// Compile all channels sourced from config servers
-		for _, server := range config.Servers {
-			if server.ServerIDs != nil {
-				for _, subserver := range *server.ServerIDs {
-					guild, err := bot.State.Guild(subserver)
-					if err == nil {
-						for _, channel := range guild.Channels {
-							if hasPerms(channel.ID, discordgo.PermissionReadMessageHistory) {
-								channels = append(channels, channel.ID)
-							}
-						}
-					}
+	}
+	return m
+}
+
+//#endregion
+
+func channelDisplay(channelID string) (string, string) {
+	sourceChannelName := channelID
+	sourceName := "UNKNOWN"
+	sourceChannel, _ := bot.State.Channel(channelID)
+	if sourceChannel != nil {
+		// Channel Naming
+		if sourceChannel.Name != "" {
+			sourceChannelName = "#" + sourceChannel.Name
+		}
+		switch sourceChannel.Type {
+		case discordgo.ChannelTypeGuildText:
+			// Server Naming
+			if sourceChannel.GuildID != "" {
+				sourceGuild, _ := bot.State.Guild(sourceChannel.GuildID)
+				if sourceGuild != nil && sourceGuild.Name != "" {
+					sourceName = sourceGuild.Name
 				}
-			} else if isNumeric(server.ServerID) {
-				guild, err := bot.State.Guild(server.ServerID)
-				if err == nil {
-					for _, channel := range guild.Channels {
-						if hasPerms(channel.ID, discordgo.PermissionReadMessageHistory) {
-							channels = append(channels, channel.ID)
-						}
+			}
+			// Category Naming
+			if sourceChannel.ParentID != "" {
+				sourceParent, _ := bot.State.Channel(sourceChannel.ParentID)
+				if sourceParent != nil {
+					if sourceParent.Name != "" {
+						sourceChannelName = sourceParent.Name + " / " + sourceChannelName
 					}
 				}
 			}
+		case discordgo.ChannelTypeDM:
+			sourceName = "Direct Messages"
+		case discordgo.ChannelTypeGroupDM:
+			sourceName = "Group Messages"
 		}
 	}
-	return channels
+	return sourceName, sourceChannelName
 }
 
 //#region Presence
@@ -96,37 +228,69 @@ func getAllChannels() []string {
 func dataKeyReplacement(input string) string {
 	//TODO: Case-insensitive key replacement. -- If no streamlined way to do it, convert to lower to find substring location but replace normally
 	if strings.Contains(input, "{{") && strings.Contains(input, "}}") {
-		countInt := int64(dbDownloadCount()) + *config.InflateCount
+		countInt := int64(dbDownloadCount()) + *config.InflateDownloadCount
 		timeNow := time.Now()
 		keys := [][]string{
-			{"{{dgVersion}}", discordgo.VERSION},
-			{"{{ddgVersion}}", projectVersion},
-			{"{{apiVersion}}", discordgo.APIVersion},
-			{"{{countNoCommas}}", fmt.Sprint(countInt)},
-			{"{{count}}", formatNumber(countInt)},
-			{"{{countShort}}", formatNumberShort(countInt)},
-			{"{{numServers}}", fmt.Sprint(len(bot.State.Guilds))},
-			{"{{numBoundChannels}}", fmt.Sprint(getBoundChannelsCount())},
-			{"{{numBoundServers}}", fmt.Sprint(getBoundServersCount())},
-			{"{{numAdminChannels}}", fmt.Sprint(len(config.AdminChannels))},
-			{"{{numAdmins}}", fmt.Sprint(len(config.Admins))},
-			{"{{timeSavedShort}}", timeLastUpdated.Format("3:04pm")},
-			{"{{timeSavedShortTZ}}", timeLastUpdated.Format("3:04pm MST")},
-			{"{{timeSavedMid}}", timeLastUpdated.Format("3:04pm MST 1/2/2006")},
-			{"{{timeSavedLong}}", timeLastUpdated.Format("3:04:05pm MST - January 2, 2006")},
-			{"{{timeSavedShort24}}", timeLastUpdated.Format("15:04")},
-			{"{{timeSavedShortTZ24}}", timeLastUpdated.Format("15:04 MST")},
-			{"{{timeSavedMid24}}", timeLastUpdated.Format("15:04 MST 2/1/2006")},
-			{"{{timeSavedLong24}}", timeLastUpdated.Format("15:04:05 MST - 2 January, 2006")},
-			{"{{timeNowShort}}", timeNow.Format("3:04pm")},
-			{"{{timeNowShortTZ}}", timeNow.Format("3:04pm MST")},
-			{"{{timeNowMid}}", timeNow.Format("3:04pm MST 1/2/2006")},
-			{"{{timeNowLong}}", timeNow.Format("3:04:05pm MST - January 2, 2006")},
-			{"{{timeNowShort24}}", timeNow.Format("15:04")},
-			{"{{timeNowShortTZ24}}", timeNow.Format("15:04 MST")},
-			{"{{timeNowMid24}}", timeNow.Format("15:04 MST 2/1/2006")},
-			{"{{timeNowLong24}}", timeNow.Format("15:04:05 MST - 2 January, 2006")},
-			{"{{uptime}}", durafmt.ParseShort(time.Since(startTime)).String()},
+			{"{{dgVersion}}",
+				discordgo.VERSION},
+			{"{{ddgVersion}}",
+				projectVersion},
+			{"{{apiVersion}}",
+				discordgo.APIVersion},
+			{"{{countNoCommas}}",
+				fmt.Sprint(countInt)},
+			{"{{count}}",
+				formatNumber(countInt)},
+			{"{{countShort}}",
+				formatNumberShort(countInt)},
+			{"{{numServers}}",
+				fmt.Sprint(len(bot.State.Guilds))},
+			{"{{numBoundChannels}}",
+				fmt.Sprint(getBoundChannelsCount())},
+			{"{{numBoundCategories}}",
+				fmt.Sprint(getBoundCategoriesCount())},
+			{"{{numBoundServers}}",
+				fmt.Sprint(getBoundServersCount())},
+			{"{{numBoundUsers}}",
+				fmt.Sprint(getBoundUsersCount())},
+			{"{{numAdminChannels}}",
+				fmt.Sprint(len(config.AdminChannels))},
+			{"{{numAdmins}}",
+				fmt.Sprint(len(config.Admins))},
+			{"{{timeSavedShort}}",
+				timeLastUpdated.Format("3:04pm")},
+			{"{{timeSavedShortTZ}}",
+				timeLastUpdated.Format("3:04pm MST")},
+			{"{{timeSavedMid}}",
+				timeLastUpdated.Format("3:04pm MST 1/2/2006")},
+			{"{{timeSavedLong}}",
+				timeLastUpdated.Format("3:04:05pm MST - January 2, 2006")},
+			{"{{timeSavedShort24}}",
+				timeLastUpdated.Format("15:04")},
+			{"{{timeSavedShortTZ24}}",
+				timeLastUpdated.Format("15:04 MST")},
+			{"{{timeSavedMid24}}",
+				timeLastUpdated.Format("15:04 MST 2/1/2006")},
+			{"{{timeSavedLong24}}",
+				timeLastUpdated.Format("15:04:05 MST - 2 January, 2006")},
+			{"{{timeNowShort}}",
+				timeNow.Format("3:04pm")},
+			{"{{timeNowShortTZ}}",
+				timeNow.Format("3:04pm MST")},
+			{"{{timeNowMid}}",
+				timeNow.Format("3:04pm MST 1/2/2006")},
+			{"{{timeNowLong}}",
+				timeNow.Format("3:04:05pm MST - January 2, 2006")},
+			{"{{timeNowShort24}}",
+				timeNow.Format("15:04")},
+			{"{{timeNowShortTZ24}}",
+				timeNow.Format("15:04 MST")},
+			{"{{timeNowMid24}}",
+				timeNow.Format("15:04 MST 2/1/2006")},
+			{"{{timeNowLong24}}",
+				timeNow.Format("15:04:05 MST - 2 January, 2006")},
+			{"{{uptime}}",
+				shortenTime(durafmt.ParseShort(time.Since(startTime)).String())},
 		}
 		for _, key := range keys {
 			if strings.Contains(input, key[0]) {
@@ -137,13 +301,32 @@ func dataKeyReplacement(input string) string {
 	return input
 }
 
-func filenameKeyReplacement(channelConfig configurationChannel, download downloadRequestStruct) string {
+func channelKeyReplacement(input string, srcchannel string) string {
+	ret := input
+	if strings.Contains(ret, "{{") && strings.Contains(ret, "}}") {
+		if channel, err := bot.State.Channel(srcchannel); err == nil {
+			keys := [][]string{
+				{"{{channelID}}", channel.ID},
+				{"{{serverID}}", channel.GuildID},
+				{"{{channelName}}", channel.Name},
+			}
+			for _, key := range keys {
+				if strings.Contains(ret, key[0]) {
+					ret = strings.ReplaceAll(ret, key[0], key[1])
+				}
+			}
+		}
+	}
+	return dataKeyReplacement(ret)
+}
+
+func dynamicKeyReplacement(channelConfig configurationSource, download downloadRequestStruct) string {
 	//TODO: same as dataKeyReplacement
 
 	ret := config.FilenameFormat
-	if channelConfig.OverwriteFilenameFormat != nil {
-		if *channelConfig.OverwriteFilenameFormat != "" {
-			ret = *channelConfig.OverwriteFilenameFormat
+	if channelConfig.FilenameFormat != nil {
+		if *channelConfig.FilenameFormat != "" {
+			ret = *channelConfig.FilenameFormat
 		}
 	}
 
@@ -151,27 +334,21 @@ func filenameKeyReplacement(channelConfig configurationChannel, download downloa
 
 		// Format Filename Date
 		filenameDateFormat := config.FilenameDateFormat
-		if channelConfig.OverwriteFilenameDateFormat != nil {
-			if *channelConfig.OverwriteFilenameDateFormat != "" {
-				filenameDateFormat = *channelConfig.OverwriteFilenameDateFormat
-			}
-		}
-		messageTime := time.Now()
-		if download.Message.Timestamp != "" {
-			messageTimestamp, err := download.Message.Timestamp.Parse()
-			if err == nil {
-				messageTime = messageTimestamp
+		if channelConfig.FilenameDateFormat != nil {
+			if *channelConfig.FilenameDateFormat != "" {
+				filenameDateFormat = *channelConfig.FilenameDateFormat
 			}
 		}
+		messageTime := download.Message.Timestamp
 
 		shortID, err := shortid.Generate()
-		if err != nil && config.DebugOutput {
-			log.Println(logPrefixDebug, color.HiCyanString("Error when generating a shortID %s", err))
+		if err != nil && config.Debug {
+			log.Println(lg("Debug", "dynamicKeyReplacement", color.HiCyanString, "Error when generating a shortID %s", err))
 		}
 
 		nanoID, err := nanoid.New()
-		if err != nil && config.DebugOutput {
-			log.Println(logPrefixDebug, color.HiCyanString("Error when creating a nanoID %s", err))
+		if err != nil && config.Debug {
+			log.Println(lg("Debug", "dynamicKeyReplacement", color.HiCyanString, "Error when creating a nanoID %s", err))
 		}
 
 		userID := ""
@@ -181,16 +358,51 @@ func filenameKeyReplacement(channelConfig configurationChannel, download downloa
 			username = download.Message.Author.Username
 		}
 
+		channelName := download.Message.ChannelID
+		categoryID := download.Message.ChannelID
+		categoryName := download.Message.ChannelID
+		guildName := download.Message.GuildID
+		if chinfo, err := bot.State.Channel(download.Message.ChannelID); err == nil {
+			channelName = chinfo.Name
+			categoryID = chinfo.ParentID
+			if catinfo, err := bot.State.Channel(categoryID); err == nil {
+				categoryName = catinfo.Name
+			}
+		}
+		if guildinfo, err := bot.State.Guild(download.Message.GuildID); err == nil {
+			guildName = guildinfo.Name
+		}
+
+		domain := "unknown"
+		if parsedURL, err := url.Parse(download.InputURL); err == nil {
+			domain = parsedURL.Hostname()
+		}
+
+		fileinfo, err := os.Stat(download.Path + download.Filename)
+		filesize := "unknown"
+		if err == nil {
+			filesize = humanize.Bytes(uint64(fileinfo.Size()))
+		}
+
 		keys := [][]string{
 			{"{{date}}", messageTime.Format(filenameDateFormat)},
 			{"{{file}}", download.Filename},
-			{"{{fileType}}", download.FileExtension},
+			{"{{fileType}}", download.Extension},
+			{"{{fileSize}}", filesize},
 			{"{{messageID}}", download.Message.ID},
 			{"{{userID}}", userID},
 			{"{{username}}", username},
 			{"{{channelID}}", download.Message.ChannelID},
+			{"{{channelName}}", channelName},
+			{"{{categoryID}}", categoryID},
+			{"{{categoryName}}", categoryName},
 			{"{{serverID}}", download.Message.GuildID},
+			{"{{serverName}}", guildName},
 			{"{{message}}", clearPath(download.Message.Content)},
+			{"{{downloadTime}}", shortenTime(durafmt.ParseShort(time.Since(download.StartTime)).String())},
+			{"{{downloadTimeLong}}", durafmt.Parse(time.Since(download.StartTime)).String()},
+			{"{{url}}", clearPath(download.InputURL)},
+			{"{{domain}}", domain},
 			{"{{nanoID}}", nanoID},
 			{"{{shortID}}", shortID},
 		}
@@ -200,13 +412,13 @@ func filenameKeyReplacement(channelConfig configurationChannel, download downloa
 			}
 		}
 	}
-	return ret
+	return dataKeyReplacement(ret)
 }
 
 func updateDiscordPresence() {
 	if config.PresenceEnabled {
 		// Vars
-		countInt := int64(dbDownloadCount()) + *config.InflateCount
+		countInt := int64(dbDownloadCount()) + *config.InflateDownloadCount
 		count := formatNumber(countInt)
 		countShort := formatNumberShort(countInt)
 		timeShort := timeLastUpdated.Format("3:04pm")
@@ -218,22 +430,22 @@ func updateDiscordPresence() {
 		statusState := fmt.Sprintf("%s files total", count)
 
 		// Overwrite Presence
-		if config.PresenceOverwrite != nil {
-			status = *config.PresenceOverwrite
+		if config.PresenceLabel != nil {
+			status = *config.PresenceLabel
 			if status != "" {
 				status = dataKeyReplacement(status)
 			}
 		}
 		// Overwrite Details
-		if config.PresenceOverwriteDetails != nil {
-			statusDetails = *config.PresenceOverwriteDetails
+		if config.PresenceDetails != nil {
+			statusDetails = *config.PresenceDetails
 			if statusDetails != "" {
 				statusDetails = dataKeyReplacement(statusDetails)
 			}
 		}
 		// Overwrite State
-		if config.PresenceOverwriteState != nil {
-			statusState = *config.PresenceOverwriteState
+		if config.PresenceState != nil {
+			statusState = *config.PresenceState
 			if statusState != "" {
 				statusState = dataKeyReplacement(statusState)
 			}
@@ -272,20 +484,22 @@ func getEmbedColor(channelID string) int {
 		}
 	}
 	// Overwrite with Defined Color for Channel
-	if isChannelRegistered(channelID) {
-		channelConfig := getChannelConfig(channelID)
+	/*var msg *discordgo.Message
+	msg.ChannelID = channelID
+	if channelRegistered(msg) {
+		channelConfig := getSource(channelID)
 		if channelConfig.OverwriteEmbedColor != nil {
 			if *channelConfig.OverwriteEmbedColor != "" {
 				color = channelConfig.OverwriteEmbedColor
 			}
 		}
-	}
+	}*/
 
 	// Use Defined Color
 	if color != nil {
 		// Defined as Role, fetch role color
 		if *color == "role" || *color == "user" {
-			botColor := bot.State.UserColor(user.ID, channelID)
+			botColor := bot.State.UserColor(botUser.ID, channelID)
 			if botColor != 0 {
 				return botColor
 			}
@@ -316,8 +530,8 @@ func getEmbedColor(channelID string) int {
 	channelInfo, err = bot.State.Channel(channelID)
 	if err == nil {
 		if channelInfo.Type != discordgo.ChannelTypeDM && channelInfo.Type != discordgo.ChannelTypeGroupDM {
-			if bot.State.UserColor(user.ID, channelID) != 0 {
-				return bot.State.UserColor(user.ID, channelID)
+			if bot.State.UserColor(botUser.ID, channelID) != 0 {
+				return bot.State.UserColor(botUser.ID, channelID)
 			}
 		}
 	}
@@ -360,94 +574,127 @@ func replyEmbed(m *discordgo.Message, title string, description string) (*discor
 				)
 			}
 		}
-		log.Println(color.HiRedString(fmtBotSendPerm, m.ChannelID))
+		log.Println(lg("Discord", "replyEmbed", color.HiRedString, fmtBotSendPerm, m.ChannelID))
 	}
 	return nil, nil
 }
 
-type logStatusType int
+//#endregion
+
+//#region Send Status
+
+type sendStatusType int
 
 const (
-	logStatusStartup logStatusType = iota
-	logStatusReconnect
-	logStatusExit
+	sendStatusStartup sendStatusType = iota
+	sendStatusReconnect
+	sendStatusExit
+	sendStatusSettings
 )
 
-func logStatusLabel(status logStatusType) string {
+func sendStatusLabel(status sendStatusType) string {
 	switch status {
-	case logStatusStartup:
+	case sendStatusStartup:
 		return "has launched"
-	case logStatusReconnect:
+	case sendStatusReconnect:
 		return "has reconnected"
-	case logStatusExit:
+	case sendStatusExit:
 		return "is exiting"
+	case sendStatusSettings:
+		return "updated settings"
 	}
-	return "<<ERROR>>"
+	return "is confused"
 }
 
-func logStatusMessage(status logStatusType) {
+func sendStatusMessage(status sendStatusType) {
 	for _, adminChannel := range config.AdminChannels {
 		if *adminChannel.LogStatus {
 			var message string
 			var label string
+			var emoji string
 
-			if status == logStatusStartup || status == logStatusReconnect {
+			//TODO: CLEAN
+			if status == sendStatusStartup || status == sendStatusReconnect {
 				label = "startup"
-				message += fmt.Sprintf("%s %s and connected to %d server%s...\n", projectLabel, logStatusLabel(status), len(bot.State.Guilds), pluralS(len(bot.State.Guilds)))
+				emoji = "🟩"
+				if status == sendStatusReconnect {
+					emoji = "🟧"
+				}
+				message += fmt.Sprintf("%s %s and connected to %d server%s...\n", projectLabel, sendStatusLabel(status), len(bot.State.Guilds), pluralS(len(bot.State.Guilds)))
 				message += fmt.Sprintf("\n• Uptime is %s", uptime())
 				message += fmt.Sprintf("\n• %s total downloads", formatNumber(int64(dbDownloadCount())))
-				message += fmt.Sprintf("\n• Bound to %d channel%s and %d server%s", getBoundChannelsCount(), pluralS(getBoundChannelsCount()), getBoundServersCount(), pluralS(getBoundServersCount()))
+				message += fmt.Sprintf("\n• Bound to %d channel%s, %d categories, %d server%s, %d user%s",
+					getBoundChannelsCount(), pluralS(getBoundChannelsCount()),
+					getBoundCategoriesCount(),
+					getBoundServersCount(), pluralS(getBoundServersCount()),
+					getBoundUsersCount(), pluralS(getBoundUsersCount()),
+				)
 				if config.All != nil {
 					message += "\n• **ALL MODE ENABLED -** Bot will use all available channels"
 				}
-				allChannels := getAllChannels()
+				allChannels := getAllRegisteredChannels()
 				message += fmt.Sprintf("\n• ***Listening to %s channel%s...***\n", formatNumber(int64(len(allChannels))), pluralS(len(allChannels)))
-				if twitterConnected {
-					message += "\n• Connected to Twitter API"
-				}
-				if googleDriveConnected {
-					message += "\n• Connected to Google Drive"
-				}
-				message += fmt.Sprintf("\n_%s-%s %s / discordgo v%s / Discord API v%s_",
-					runtime.GOOS, runtime.GOARCH, runtime.Version(), discordgo.VERSION, discordgo.APIVersion)
-			} else if status == logStatusExit {
+				message += fmt.Sprintf("\n_%s_", versions(true))
+			} else if status == sendStatusExit {
 				label = "exit"
-				message += fmt.Sprintf("%s %s...\n", projectLabel, logStatusLabel(status))
+				emoji = "🟥"
+				message += fmt.Sprintf("%s %s...\n", projectLabel, sendStatusLabel(status))
 				message += fmt.Sprintf("\n• Uptime was %s", uptime())
 				message += fmt.Sprintf("\n• %s total downloads", formatNumber(int64(dbDownloadCount())))
-				message += fmt.Sprintf("\n• Bound to %d channel%s and %d server%s", getBoundChannelsCount(), pluralS(getBoundChannelsCount()), getBoundServersCount(), pluralS(getBoundServersCount()))
+				message += fmt.Sprintf("\n• Bound to %d channel%s, %d categories, %d server%s, %d user%s",
+					getBoundChannelsCount(), pluralS(getBoundChannelsCount()),
+					getBoundCategoriesCount(),
+					getBoundServersCount(), pluralS(getBoundServersCount()),
+					getBoundUsersCount(), pluralS(getBoundUsersCount()),
+				)
+			} else if status == sendStatusSettings {
+				label = "settings"
+				emoji = "🟨"
+				message += fmt.Sprintf("%s %s...\n", projectLabel, sendStatusLabel(status))
+				message += fmt.Sprintf("\n• Bound to %d channel%s, %d categories, %d server%s, %d user%s",
+					getBoundChannelsCount(), pluralS(getBoundChannelsCount()),
+					getBoundCategoriesCount(),
+					getBoundServersCount(), pluralS(getBoundServersCount()),
+					getBoundUsersCount(), pluralS(getBoundUsersCount()),
+				)
 			}
 			// Send
-			if config.DebugOutput {
-				log.Println(logPrefixDebug, color.HiCyanString("Sending log for %s to admin channel %s", label, adminChannel.ChannelID))
+			if config.Debug {
+				log.Println(lg("Debug", "Status", color.HiCyanString, "Sending log for %s to admin channel %s",
+					label, adminChannel.ChannelID))
 			}
 			if hasPerms(adminChannel.ChannelID, discordgo.PermissionEmbedLinks) && !selfbot {
-				bot.ChannelMessageSendEmbed(adminChannel.ChannelID, buildEmbed(adminChannel.ChannelID, "Log — Status", message))
+				bot.ChannelMessageSendEmbed(adminChannel.ChannelID,
+					buildEmbed(adminChannel.ChannelID, emoji+" Log — Status", message))
 			} else if hasPerms(adminChannel.ChannelID, discordgo.PermissionSendMessages) {
 				bot.ChannelMessageSend(adminChannel.ChannelID, message)
 			} else {
-				log.Println(logPrefixDebug, color.HiRedString("Perms checks failed for sending status log to %s", adminChannel.ChannelID))
+				log.Println(lg("Debug", "Status", color.HiRedString, "Perms checks failed for sending status log to %s",
+					adminChannel.ChannelID))
 			}
 		}
 	}
 }
 
-func logErrorMessage(err string) {
+func sendErrorMessage(err string) {
 	for _, adminChannel := range config.AdminChannels {
 		if *adminChannel.LogErrors {
 			// Send
 			if hasPerms(adminChannel.ChannelID, discordgo.PermissionEmbedLinks) && !selfbot { // not confident this is the right permission
-				if config.DebugOutput {
-					log.Println(logPrefixDebug, color.HiCyanString("Sending embed log for error to %s", adminChannel.ChannelID))
+				if config.Debug {
+					log.Println(lg("Debug", "sendErrorMessage", color.HiCyanString, "Sending embed log for error to %s",
+						adminChannel.ChannelID))
 				}
 				bot.ChannelMessageSendEmbed(adminChannel.ChannelID, buildEmbed(adminChannel.ChannelID, "Log — Error", err))
 			} else if hasPerms(adminChannel.ChannelID, discordgo.PermissionSendMessages) {
-				if config.DebugOutput {
-					log.Println(logPrefixDebug, color.HiCyanString("Sending message log for error to %s", adminChannel.ChannelID))
+				if config.Debug {
+					log.Println(lg("Debug", "sendErrorMessage", color.HiCyanString, "Sending embed log for error to %s",
+						adminChannel.ChannelID))
 				}
 				bot.ChannelMessageSend(adminChannel.ChannelID, err)
 			} else {
-				log.Println(logPrefixDebug, color.HiRedString("Perms checks failed for sending error log to %s", adminChannel.ChannelID))
+				log.Println(lg("Debug", "sendErrorMessage", color.HiRedString, "Perms checks failed for sending error log to %s",
+					adminChannel.ChannelID))
 			}
 		}
 	}
@@ -466,31 +713,33 @@ func isBotAdmin(m *discordgo.Message) bool {
 	// configurationAdminChannel.UnlockCommands Bypass
 	if isAdminChannelRegistered(m.ChannelID) {
 		channelConfig := getAdminChannelConfig(m.ChannelID)
-		if *channelConfig.UnlockCommands == true {
+		if *channelConfig.UnlockCommands {
 			return true
 		}
 	}
 
-	return m.Author.ID == user.ID || stringInSlice(m.Author.ID, config.Admins)
+	return m.Author.ID == botUser.ID || stringInSlice(m.Author.ID, config.Admins)
 }
 
 // Checks if message author is a specified bot admin OR is server admin OR has message management perms in channel
-func isLocalAdmin(m *discordgo.Message) bool {
+/*func isLocalAdmin(m *discordgo.Message) bool {
 	if m == nil {
-		if config.DebugOutput {
-			log.Println(logPrefixDebug, color.YellowString("isLocalAdmin check failed due to empty message"))
+		if config.Debug {
+			log.Println(lg("Debug", "isLocalAdmin", color.YellowString, "check failed due to empty message"))
 		}
 		return true
 	}
 	sourceChannel, err := bot.State.Channel(m.ChannelID)
 	if err != nil || sourceChannel == nil {
-		if config.DebugOutput {
-			log.Println(logPrefixDebug, color.YellowString("isLocalAdmin check failed due to an error or received empty channel info for message:\t%s", err))
+		if config.Debug {
+			log.Println(lg("Debug", "isLocalAdmin", color.YellowString,
+				"check failed due to an error or received empty channel info for message:\t%s", err))
 		}
 		return true
 	} else if sourceChannel.Name == "" || sourceChannel.GuildID == "" {
-		if config.DebugOutput {
-			log.Println(logPrefixDebug, color.YellowString("isLocalAdmin check failed due to incomplete channel info"))
+		if config.Debug {
+			log.Println(lg("Debug", "isLocalAdmin", color.YellowString,
+				"check failed due to incomplete channel info"))
 		}
 		return true
 	}
@@ -498,23 +747,24 @@ func isLocalAdmin(m *discordgo.Message) bool {
 	guild, _ := bot.State.Guild(m.GuildID)
 	localPerms, err := bot.State.UserChannelPermissions(m.Author.ID, m.ChannelID)
 	if err != nil {
-		if config.DebugOutput {
-			log.Println(logPrefixDebug, color.YellowString("isLocalAdmin check failed due to error when checking permissions:\t%s", err))
+		if config.Debug {
+			log.Println(lg("Debug", "isLocalAdmin", color.YellowString,
+				"check failed due to error when checking permissions:\t%s", err))
 		}
 		return true
 	}
 
-	botSelf := m.Author.ID == user.ID
+	botSelf := m.Author.ID == botUser.ID
 	botAdmin := stringInSlice(m.Author.ID, config.Admins)
 	guildOwner := m.Author.ID == guild.OwnerID
 	guildAdmin := localPerms&discordgo.PermissionAdministrator > 0
 	localManageMessages := localPerms&discordgo.PermissionManageMessages > 0
 
 	return botSelf || botAdmin || guildOwner || guildAdmin || localManageMessages
-}
+}*/
 
-func hasPerms(channelID string, permission int) bool {
-	if !config.CheckPermissions {
+func hasPerms(channelID string, permission int64) bool {
+	if selfbot {
 		return true
 	}
 
@@ -526,14 +776,15 @@ func hasPerms(channelID string, permission int) bool {
 		case discordgo.ChannelTypeGroupDM:
 			return true
 		case discordgo.ChannelTypeGuildText:
-			perms, err := bot.UserChannelPermissions(user.ID, channelID)
+			perms, err := bot.UserChannelPermissions(botUser.ID, channelID)
 			if err == nil {
 				return perms&permission == permission
 			}
-			log.Println(color.HiRedString("Failed to check permissions (%d) for %s:\t%s", permission, channelID, err))
+			log.Println(lg("Debug", "hasPerms", color.HiRedString,
+				"Failed to check permissions (%d) for %s:\t%s", permission, channelID, err))
 		}
 	}
-	return false
+	return true
 }
 
 //#endregion
@@ -544,112 +795,4 @@ func getUserIdentifier(usr discordgo.User) string {
 	return fmt.Sprintf("\"%s\"#%s", usr.Username, usr.Discriminator)
 }
 
-//TODO: Clean below
-
-func getChannelState(channelID string) *discordgo.Channel {
-	sourceChannel, _ := bot.State.Channel(channelID)
-	if sourceChannel != nil {
-		return sourceChannel
-	}
-	return &discordgo.Channel{}
-}
-
-func getGuildState(guildID string) *discordgo.Guild {
-	sourceGuild, _ := bot.State.Guild(guildID)
-	if sourceGuild != nil {
-		return sourceGuild
-	}
-	return &discordgo.Guild{}
-}
-
-func getChannelGuildID(channelID string) string {
-	sourceChannel, _ := bot.State.Channel(channelID)
-	if sourceChannel != nil {
-		return sourceChannel.GuildID
-	}
-	return ""
-}
-
-func getGuildName(guildID string) string {
-	sourceGuildName := "UNKNOWN"
-	sourceGuild, _ := bot.State.Guild(guildID)
-	if sourceGuild != nil && sourceGuild.Name != "" {
-		sourceGuildName = sourceGuild.Name
-	}
-	return sourceGuildName
-}
-
-func getChannelName(channelID string) string {
-	sourceChannelName := "unknown"
-	sourceChannel, _ := bot.State.Channel(channelID)
-	if sourceChannel != nil {
-		if sourceChannel.Name != "" {
-			sourceChannelName = sourceChannel.Name
-		} else {
-			switch sourceChannel.Type {
-			case discordgo.ChannelTypeDM:
-				sourceChannelName = "dm"
-			case discordgo.ChannelTypeGroupDM:
-				sourceChannelName = "group-dm"
-			}
-		}
-	}
-	return sourceChannelName
-}
-
-func getSourceName(guildID string, channelID string) string {
-	guildName := getGuildName(guildID)
-	channelName := getChannelName(channelID)
-	if channelName == "dm" || channelName == "group-dm" {
-		return channelName
-	}
-	return fmt.Sprintf("\"%s\"#%s", guildName, channelName)
-}
-
 //#endregion
-
-// For command case-insensitivity
-func messageToLower(message *discordgo.Message) *discordgo.Message {
-	newMessage := *message
-	newMessage.Content = strings.ToLower(newMessage.Content)
-	return &newMessage
-}
-
-func fixMessage(m *discordgo.Message) *discordgo.Message {
-	// If message content is empty (likely due to userbot/selfbot)
-	ubIssue := "Message is corrupted due to endpoint restriction"
-	if m.Content == "" && len(m.Attachments) == 0 && len(m.Embeds) == 0 {
-		// Get message history
-		mCache, err := bot.ChannelMessages(m.ChannelID, 20, "", "", "")
-		if err == nil {
-			if len(mCache) > 0 {
-				for _, mCached := range mCache {
-					if mCached.ID == m.ID {
-						// Fix original message having empty Guild ID
-						guildID := m.GuildID
-						// Replace message
-						m = mCached
-						// ^^
-						if m.GuildID == "" && guildID != "" {
-							m.GuildID = guildID
-						}
-						// Parse commands
-						dgr.FindAndExecute(bot, strings.ToLower(config.CommandPrefix), bot.State.User.ID, messageToLower(m))
-
-						break
-					}
-				}
-			} else if config.DebugOutput {
-				log.Println(logPrefixDebug, color.RedString("%s, and an attempt to get channel messages found nothing...", ubIssue))
-			}
-		} else if config.DebugOutput {
-			log.Println(logPrefixDebug, color.HiRedString("%s, and an attempt to get channel messages encountered an error:\t%s", ubIssue, err))
-		}
-	}
-	if m.Content == "" && len(m.Attachments) == 0 && len(m.Embeds) == 0 {
-		if config.DebugOutput {
-			log.Println(logPrefixDebug, color.YellowString("%s, and attempts to fix seem to have failed...", ubIssue))
-		}
-	}
-	return m
-}

File diff suppressed because it is too large
+ 412 - 291
downloads.go


+ 37 - 39
go.mod

@@ -4,54 +4,52 @@ go 1.19
 
 require (
 	github.com/AvraamMavridis/randomcolor v0.0.0-20180822172341-208aff70bf2c
-	github.com/ChimeraCoder/anaconda v2.0.0+incompatible
-	github.com/HouzuoGuo/tiedot v0.0.0-20200330175510-6fb216206052
-	github.com/Jeffail/gabs v1.4.0
+	github.com/Davincible/goinsta/v3 v3.2.6
+	github.com/HouzuoGuo/tiedot v0.0.0-20210905174726-ae1e16866d06
 	github.com/Necroforger/dgrouter v0.0.0-20200517224846-e66453b957c1
-	github.com/PuerkitoBio/goquery v1.6.1
+	github.com/PuerkitoBio/goquery v1.8.1
 	github.com/aidarkhanov/nanoid/v2 v2.0.5
-	github.com/bwmarrin/discordgo v0.22.0
-	github.com/fatih/color v1.10.0
-	github.com/fsnotify/fsnotify v1.4.9
-	github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd
-	github.com/hashicorp/go-version v1.3.0
+	github.com/bwmarrin/discordgo v0.27.0
+	github.com/dustin/go-humanize v1.0.1
+	github.com/fatih/color v1.15.0
+	github.com/fsnotify/fsnotify v1.6.0
+	github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
+	github.com/hashicorp/go-version v1.6.0
 	github.com/kennygrant/sanitize v1.2.4
-	github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
-	github.com/rivo/duplo v0.0.0-20180323201418-c4ec823d58cd
+	github.com/muhammadmuzzammil1998/jsonc v1.0.0
+	github.com/n0madic/twitter-scraper v0.0.0-20230520222908-ec6e8f3e190e
+	github.com/rivo/duplo v0.0.0-20220703183130-751e882e6b83
 	github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569
-	golang.org/x/net v0.2.0
-	golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c
-	google.golang.org/api v0.46.0
-	gopkg.in/ini.v1 v1.62.0
-	mvdan.cc/xurls/v2 v2.2.0
+	github.com/wk8/go-ordered-map/v2 v2.1.7
+	gopkg.in/ini.v1 v1.67.0
+	mvdan.cc/xurls/v2 v2.5.0
 )
 
 require (
-	cloud.google.com/go v0.81.0 // indirect
-	github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 // indirect
-	github.com/andybalholm/cascadia v1.1.0 // indirect
-	github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 // indirect
-	github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc // indirect
-	github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect
-	github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect
-	github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
-	github.com/golang/protobuf v1.5.2 // indirect
-	github.com/googleapis/gax-go/v2 v2.0.5 // indirect
-	github.com/gorilla/websocket v1.4.0 // indirect
-	github.com/mattn/go-colorable v0.1.8 // indirect
-	github.com/mattn/go-isatty v0.0.12 // indirect
+	github.com/andybalholm/cascadia v1.3.2 // indirect
+	github.com/bahlo/generic-list-go v0.2.0 // indirect
+	github.com/buger/jsonparser v1.1.1 // indirect
+	github.com/chromedp/cdproto v0.0.0-20230506233603-4ea4c6dc2e5b // indirect
+	github.com/chromedp/chromedp v0.9.1 // indirect
+	github.com/chromedp/sysutil v1.0.0 // indirect
+	github.com/gobwas/httphead v0.1.0 // indirect
+	github.com/gobwas/pool v0.2.1 // indirect
+	github.com/gobwas/ws v1.2.0 // indirect
+	github.com/gorilla/websocket v1.5.0 // indirect
+	github.com/josharian/intern v1.0.0 // indirect
+	github.com/mailru/easyjson v0.7.7 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
-	github.com/smartystreets/goconvey v1.7.2 // indirect
-	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/crypto v0.3.0 // indirect
-	golang.org/x/sys v0.2.0 // indirect
-	golang.org/x/text v0.4.0 // indirect
-	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab // indirect
-	google.golang.org/grpc v1.37.0 // indirect
-	google.golang.org/protobuf v1.26.0 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/stretchr/testify v1.8.2 // indirect
+	golang.org/x/crypto v0.9.0 // indirect
+	golang.org/x/net v0.10.0 // indirect
+	golang.org/x/sys v0.8.0 // indirect
 )
 
 replace github.com/gorilla/websocket => github.com/gorilla/websocket v1.4.1
 
-replace github.com/bwmarrin/discordgo => github.com/get-got/discordgo v0.22.0-2
+replace github.com/bwmarrin/discordgo => github.com/get-got/discordgo v0.27.0-gg.1
+
+replace github.com/n0madic/twitter-scraper => github.com/get-got/twitter-scraper v0.0.0-20230525183600

+ 0 - 548
go.sum

@@ -1,548 +0,0 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
-cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
-cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
-cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
-cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
-cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
-cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
-cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
-cloud.google.com/go v0.81.0 h1:at8Tk2zUz63cLPR0JPWm5vp77pEZmzxEQBEfRKn1VV8=
-cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
-cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
-cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
-cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
-cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
-cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
-cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/AvraamMavridis/randomcolor v0.0.0-20180822172341-208aff70bf2c h1:XLynE8YGJdvPN65iI+G+Ys5ZUVS6YxWk8WPe/FmBReg=
-github.com/AvraamMavridis/randomcolor v0.0.0-20180822172341-208aff70bf2c/go.mod h1:vX+Cl5GOtK2DkzgsggLoeNUbxAcUWBaybCKzVRYsRMo=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/ChimeraCoder/anaconda v2.0.0+incompatible h1:F0eD7CHXieZ+VLboCD5UAqCeAzJZxcr90zSCcuJopJs=
-github.com/ChimeraCoder/anaconda v2.0.0+incompatible/go.mod h1:TCt3MijIq3Qqo9SBtuW/rrM4x7rDfWqYWHj8T7hLcLg=
-github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7 h1:r+EmXjfPosKO4wfiMLe1XQictsIlhErTufbWUsjOTZs=
-github.com/ChimeraCoder/tokenbucket v0.0.0-20131201223612-c5a927568de7/go.mod h1:b2EuEMLSG9q3bZ95ql1+8oVqzzrTNSiOQqSXWFBzxeI=
-github.com/HouzuoGuo/tiedot v0.0.0-20200330175510-6fb216206052 h1:JEzTqzN0IU5IePlaj/FbedlgANL92UCCMjEY8WkFyGA=
-github.com/HouzuoGuo/tiedot v0.0.0-20200330175510-6fb216206052/go.mod h1:J2FcoVwTshOscfh8D4LCCVRoHJJQTeCAEkeRSVGnLQs=
-github.com/Jeffail/gabs v1.4.0 h1://5fYRRTq1edjfIrQGvdkcd22pkYUrHZ5YC/H2GJVAo=
-github.com/Jeffail/gabs v1.4.0/go.mod h1:6xMvQMK4k33lb7GUUpaAPh6nKMmemQeg5d4gn7/bOXc=
-github.com/Necroforger/dgrouter v0.0.0-20200517224846-e66453b957c1 h1:3OHJOlf0r1CVSA1E3Ts4uLWsCnucYndMRjNk4rFiQdE=
-github.com/Necroforger/dgrouter v0.0.0-20200517224846-e66453b957c1/go.mod h1:FdMxPfOp4ppZW2OJjLagSMri7g5k9luvTm7Y3aIxQSc=
-github.com/PuerkitoBio/goquery v1.6.1 h1:FgjbQZKl5HTmcn4sKBgvx8vv63nhyhIpv7lJpFGCWpk=
-github.com/PuerkitoBio/goquery v1.6.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
-github.com/aidarkhanov/nanoid/v2 v2.0.5 h1:HLx5RyDuvOZ6YxlhYTxSU8Il+q7xVKmXM62MfSxziN0=
-github.com/aidarkhanov/nanoid/v2 v2.0.5/go.mod h1:YF/U48D1yA3AoGGUdRrCV95J/KJBShvR9TyLqQwdtlI=
-github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
-github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
-github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 h1:ekDALXAVvY/Ub1UtNta3inKQwZ/jMB/zpOtD8rAYh78=
-github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330/go.mod h1:nH+k0SvAt3HeiYyOlJpLLv1HG1p7KWP7qU9QPp2/pCo=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
-github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc h1:tP7tkU+vIsEOKiK+l/NSLN4uUtkyuxc6hgYpQeCWAeI=
-github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc/go.mod h1:ORH5Qp2bskd9NzSfKqAF7tKfONsEkCarTE5ESr/RVBw=
-github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA=
-github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
-github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
-github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
-github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE=
-github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ=
-github.com/get-got/discordgo v0.22.0-2 h1:qRyQiQB0h836AWVR/b6MQ4VWdEIEVqqMe2bFP69/bG0=
-github.com/get-got/discordgo v0.22.0-2/go.mod h1:V55L1D794gP9/0C53HxVDj7iKDwcn9lLeiWSatdzxTg=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
-github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
-github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd h1:FsX+T6wA8spPe4c1K9vi7T0LvNCO1TTqiL8u7Wok2hw=
-github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0=
-github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw=
-github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
-github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
-github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo=
-github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/rivo/duplo v0.0.0-20180323201418-c4ec823d58cd h1:if+/aco/wZitSP1n2F2S/eWjqajTLdWOSyZ9VdjL3JA=
-github.com/rivo/duplo v0.0.0-20180323201418-c4ec823d58cd/go.mod h1:gw8DEItjXFxacZzluOv7azm5G22Vvx/OBZb7Wqoqp9M=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
-github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
-github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
-github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 h1:xzABM9let0HLLqFypcxvLmlvEciCHL7+Lv+4vwZqecI=
-github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569/go.mod h1:2Ly+NIftZN4de9zRmENdYbvPQeaVIYKWpLFStLFEBgI=
-github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
-go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
-go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
-golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
-golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
-golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c h1:SgVl/sCtkicsS7psKkje4H9YtjdEl3xsYh7N+5TDHqY=
-golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
-golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
-golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
-golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
-golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
-golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
-google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
-google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
-google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
-google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
-google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
-google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
-google.golang.org/api v0.46.0 h1:jkDWHOBIoNSD0OQpq4rtBVu+Rh325MPjXG1rakAp8JU=
-google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
-google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
-google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab h1:dkb90hr43A2Q5as5ZBphcOF2II0+EqfCBqGp7qFSpN4=
-google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
-google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
-google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
-google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
-google.golang.org/grpc v1.37.0 h1:uSZWeQJX5j11bIQ4AJoj+McDBo29cY1MCoC1wO3ts+c=
-google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
-gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-mvdan.cc/xurls/v2 v2.2.0 h1:NSZPykBXJFCetGZykLAxaL6SIpvbVy/UFEniIfHAa8A=
-mvdan.cc/xurls/v2 v2.2.0/go.mod h1:EV1RMtya9D6G5DMYPGD8zTQzaHet6Jh8gFlRgGRJeO8=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

+ 113 - 90
handlers.go

@@ -2,7 +2,6 @@ package main
 
 import (
 	"fmt"
-	"io/ioutil"
 	"log"
 	"os"
 	"strings"
@@ -18,31 +17,34 @@ type fileItem struct {
 	Time     time.Time
 }
 
-var (
-	skipCommands = []string{
-		"skip",
-		"ignore",
-		"don't save",
-		"no save",
-	}
-)
-
 //#region Events
 
+var lastMessageID string
+
 func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
-	handleMessage(m.Message, false, false)
+	if lastMessageID != m.ID {
+		handleMessage(m.Message, nil, false, false)
+	}
+	lastMessageID = m.ID
 }
 
 func messageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) {
-	if m.EditedTimestamp != discordgo.Timestamp("") {
-		handleMessage(m.Message, true, false)
+	if lastMessageID != m.ID {
+		if m.EditedTimestamp != nil {
+			handleMessage(m.Message, nil, true, false)
+		}
 	}
+	lastMessageID = m.ID
 }
 
-func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
+func handleMessage(m *discordgo.Message, c *discordgo.Channel, edited bool, history bool) (int64, int64) {
 	// Ignore own messages unless told not to
-	if m.Author.ID == user.ID && !config.ScanOwnMessages {
-		return -1
+	if m.Author.ID == botUser.ID && !config.ScanOwnMessages {
+		return -1, 0
+	}
+
+	if !history && !edited {
+		timeLastMessage = time.Now()
 	}
 
 	// Admin Channel
@@ -52,29 +54,28 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 		// Log
 		sendLabel := fmt.Sprintf("%s in \"%s\"#%s",
 			getUserIdentifier(*m.Author),
-			getGuildName(m.GuildID), getChannelName(m.ChannelID),
+			getGuildName(m.GuildID), getChannelName(m.ChannelID, nil),
 		)
 		content := m.Content
 		if len(m.Attachments) > 0 {
 			content = content + fmt.Sprintf(" (%d attachments)", len(m.Attachments))
 		}
 		if edited {
-			log.Println(color.HiGreenString("[ADMIN CHANNEL] "), color.CyanString("Edited [%s]: %s", sendLabel, content))
+			log.Println(lg("Message", "ADMIN CHANNEL", color.CyanString, "Edited [%s]: %s", sendLabel, content))
 		} else {
-			log.Println(color.HiGreenString("[ADMIN CHANNEL] "), color.CyanString("Message [%s]: %s", sendLabel, content))
+			log.Println(lg("Message", "ADMIN CHANNEL", color.CyanString, "[%s]: %s", sendLabel, content))
 		}
 	}
 
 	// Registered Channel
-	if isChannelRegistered(m.ChannelID) {
-		channelConfig := getChannelConfig(m.ChannelID)
+	if channelConfig := getSource(m, c); channelConfig != emptyConfig {
 		// Ignore bots if told to do so
 		if m.Author.Bot && *channelConfig.IgnoreBots {
-			return -1
+			return -1, 0
 		}
 		// Ignore if told so by config
 		if (!history && !*channelConfig.Enabled) || (edited && !*channelConfig.ScanEdits) {
-			return -1
+			return -1, 0
 		}
 
 		m = fixMessage(m)
@@ -83,17 +84,24 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 		if config.MessageOutput {
 			sendLabel := fmt.Sprintf("%s in \"%s\"#%s",
 				getUserIdentifier(*m.Author),
-				getGuildName(m.GuildID), getChannelName(m.ChannelID),
+				getGuildName(m.GuildID), getChannelName(m.ChannelID, nil),
 			)
 			content := m.Content
 			if len(m.Attachments) > 0 {
-				content = content + fmt.Sprintf(" (%d attachments)", len(m.Attachments))
+				content += fmt.Sprintf(" \t[%d attachments]", len(m.Attachments))
 			}
+			content += fmt.Sprintf(" \t<%s>", m.ID)
 
-			if edited {
-				log.Println(color.CyanString("Edited [%s]: %s", sendLabel, content))
-			} else {
-				log.Println(color.CyanString("Message [%s]: %s", sendLabel, content))
+			if !history || config.MessageOutputHistory {
+				addOut := ""
+				if history && config.MessageOutputHistory && !m.Timestamp.IsZero() {
+					addOut = fmt.Sprintf(" @ %s", m.Timestamp.String()[:19])
+				}
+				if edited {
+					log.Println(lg("Message", "", color.CyanString, "Edited [%s%s]: %s", sendLabel, addOut, content))
+				} else {
+					log.Println(lg("Message", "", color.CyanString, "[%s%s]: %s", sendLabel, addOut, content))
+				}
 			}
 		}
 
@@ -101,42 +109,57 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 		if channelConfig.LogMessages != nil {
 			if channelConfig.LogMessages.Destination != "" {
 				logPath := channelConfig.LogMessages.Destination
-				if *channelConfig.LogMessages.DestinationIsFolder == true {
+				if *channelConfig.LogMessages.DestinationIsFolder {
 					if !strings.HasSuffix(logPath, string(os.PathSeparator)) {
 						logPath += string(os.PathSeparator)
 					}
 					err := os.MkdirAll(logPath, 0755)
 					if err == nil {
 						logPath += "Log_Messages"
-						if *channelConfig.LogMessages.DivideLogsByServer == true {
+						if *channelConfig.LogMessages.DivideLogsByServer {
 							if m.GuildID == "" {
 								ch, err := bot.State.Channel(m.ChannelID)
-								if err == nil {
+								if err != nil && c != nil {
+									ch = c
+								}
+								if ch != nil {
 									if ch.Type == discordgo.ChannelTypeDM {
 										logPath += " DM"
 									} else if ch.Type == discordgo.ChannelTypeGroupDM {
 										logPath += " GroupDM"
+									} else if ch.Type == discordgo.ChannelTypeGuildText {
+										logPath += " Text"
+									} else if ch.Type == discordgo.ChannelTypeGuildCategory {
+										logPath += " Category"
+									} else if ch.Type == discordgo.ChannelTypeGuildForum {
+										logPath += " Forum"
+									} else if ch.Type == discordgo.ChannelTypeGuildPrivateThread || ch.Type == discordgo.ChannelTypeGuildPublicThread {
+										logPath += " Thread"
 									} else {
 										logPath += " Unknown"
 									}
-								} else {
-									logPath += " Unknown"
+
+									if ch.Name != "" {
+										logPath += " - " + clearPath(ch.Name) + " -"
+									} else if ch.Topic != "" {
+										logPath += " - " + clearPath(ch.Topic) + " -"
+									}
 								}
 							} else {
 								logPath += " SID_" + m.GuildID
 							}
 						}
-						if *channelConfig.LogMessages.DivideLogsByChannel == true {
+						if *channelConfig.LogMessages.DivideLogsByChannel {
 							logPath += " CID_" + m.ChannelID
 						}
-						if *channelConfig.LogMessages.DivideLogsByUser == true {
+						if *channelConfig.LogMessages.DivideLogsByUser {
 							logPath += " UID_" + m.Author.ID
 						}
 					}
 					logPath += ".txt"
 				}
 				// Read
-				currentLog, err := ioutil.ReadFile(logPath)
+				currentLog, err := os.ReadFile(logPath)
 				currentLogS := ""
 				if err == nil {
 					currentLogS = string(currentLog)
@@ -155,7 +178,7 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 					// Writer
 					f, err := os.OpenFile(logPath, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0600)
 					if err != nil {
-						log.Println(color.RedString("[channelConfig.LogMessages] Failed to open log file:\t%s", err))
+						log.Println(lg("Message", "", color.RedString, "[channelConfig.LogMessages] Failed to open log file:\t%s", err))
 						f.Close()
 					}
 					defer f.Close()
@@ -169,7 +192,7 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 					// More Data
 					additionalInfo := ""
 					if channelConfig.LogMessages.UserData != nil {
-						if *channelConfig.LogMessages.UserData == true {
+						if *channelConfig.LogMessages.UserData {
 							additionalInfo = fmt.Sprintf("[%s/%s/%s] \"%s\"#%s (%s) @ %s: ", m.GuildID, m.ChannelID, m.ID, m.Author.Username, m.Author.Discriminator, m.Author.ID, m.Timestamp)
 						}
 					}
@@ -190,7 +213,7 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 					}
 
 					if _, err = f.WriteString(newLine); err != nil {
-						log.Println(color.RedString("[channelConfig.LogMessages] Failed to append file:\t%s", err))
+						log.Println(lg("Message", "", color.RedString, "[channelConfig.LogMessages] Failed to append file:\t%s", err))
 					}
 				}
 			}
@@ -204,8 +227,10 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 				channelConfig.Filters.AllowedUsers != nil ||
 				channelConfig.Filters.AllowedRoles != nil {
 				shouldAbort = true
-				if config.DebugOutput {
-					log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.YellowString("Filter will be ignoring by default..."))
+				if config.Debug {
+					log.Println(lg("Debug", "Message", color.YellowString,
+						"%s Filter will be ignoring by default...",
+						color.HiMagentaString("(FILTER)")))
 				}
 			}
 
@@ -213,8 +238,10 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 				for _, phrase := range *channelConfig.Filters.BlockedPhrases {
 					if strings.Contains(m.Content, phrase) {
 						shouldAbort = true
-						if config.DebugOutput {
-							log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.YellowString("blockedPhrases found \"%s\" in message, planning to abort...", phrase))
+						if config.Debug {
+							log.Println(lg("Debug", "Message", color.YellowString,
+								"%s blockedPhrases found \"%s\" in message, planning to abort...",
+								color.HiMagentaString("(FILTER)"), phrase))
 						}
 						break
 					}
@@ -224,8 +251,10 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 				for _, phrase := range *channelConfig.Filters.AllowedPhrases {
 					if strings.Contains(m.Content, phrase) {
 						shouldAbort = false
-						if config.DebugOutput {
-							log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.YellowString("allowedPhrases found \"%s\" in message, planning to process...", phrase))
+						if config.Debug {
+							log.Println(lg("Debug", "Message", color.YellowString,
+								"%s allowedPhrases found \"%s\" in message, planning to process...",
+								color.HiMagentaString("(FILTER)"), phrase))
 						}
 						break
 					}
@@ -235,16 +264,20 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 			if channelConfig.Filters.BlockedUsers != nil {
 				if stringInSlice(m.Author.ID, *channelConfig.Filters.BlockedUsers) {
 					shouldAbort = true
-					if config.DebugOutput {
-						log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.YellowString("blockedUsers caught %s, planning to abort...", m.Author.ID))
+					if config.Debug {
+						log.Println(lg("Debug", "Message", color.YellowString,
+							"%s blockedUsers caught %s, planning to abort...",
+							color.HiMagentaString("(FILTER)"), m.Author.ID))
 					}
 				}
 			}
 			if channelConfig.Filters.AllowedUsers != nil {
 				if stringInSlice(m.Author.ID, *channelConfig.Filters.AllowedUsers) {
 					shouldAbort = false
-					if config.DebugOutput {
-						log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.YellowString("allowedUsers caught %s, planning to process...", m.Author.ID))
+					if config.Debug {
+						log.Println(lg("Debug", "Message", color.YellowString,
+							"%s allowedUsers caught %s, planning to process...",
+							color.HiMagentaString("(FILTER)"), m.Author.ID))
 					}
 				}
 			}
@@ -258,8 +291,10 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 					for _, role := range member.Roles {
 						if stringInSlice(role, *channelConfig.Filters.BlockedRoles) {
 							shouldAbort = true
-							if config.DebugOutput {
-								log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.YellowString("blockedRoles caught %s, planning to abort...", role))
+							if config.Debug {
+								log.Println(lg("Debug", "Message", color.YellowString,
+									"%s blockedRoles caught %s, planning to abort...",
+									color.HiMagentaString("(FILTER)"), role))
 							}
 							break
 						}
@@ -275,8 +310,10 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 					for _, role := range member.Roles {
 						if stringInSlice(role, *channelConfig.Filters.AllowedRoles) {
 							shouldAbort = false
-							if config.DebugOutput {
-								log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.YellowString("allowedRoles caught %s, planning to allow...", role))
+							if config.Debug {
+								log.Println(lg("Debug", "Message", color.YellowString,
+									"%s allowedRoles caught %s, planning to allow...",
+									color.HiMagentaString("(FILTER)"), role))
 							}
 							break
 						}
@@ -286,59 +323,45 @@ func handleMessage(m *discordgo.Message, edited bool, history bool) int64 {
 
 			// Abort
 			if shouldAbort {
-				if config.DebugOutput {
-					log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.HiYellowString("Filter decided to ignore message..."))
-				}
-				return -1
-			} /*else {
-				if config.DebugOutput {
-					log.Println(logPrefixDebug, color.HiMagentaString("(FILTER)"), color.HiYellowString("Filter approved message..."))
-				}
-			}*/
-		}
-
-		// Skipping
-		canSkip := config.AllowSkipping
-		if channelConfig.OverwriteAllowSkipping != nil {
-			canSkip = *channelConfig.OverwriteAllowSkipping
-		}
-		if canSkip {
-			for _, cmd := range skipCommands {
-				if m.Content == cmd {
-					log.Println(color.HiYellowString("Message handling skipped due to use of skip command."))
-					return -1
+				if config.Debug {
+					log.Println(lg("Debug", "Message", color.YellowString,
+						"%s Filter decided to ignore message...",
+						color.HiMagentaString("(FILTER)")))
 				}
+				return -1, 0
 			}
 		}
 
 		// Process Files
-		var downloadCount int64
+		var downloadCount int64 = 0
+		var totalfilesize int64 = 0
 		files := getFileLinks(m)
 		for _, file := range files {
 			if file.Link == "" {
 				continue
 			}
-			if config.DebugOutput {
-				log.Println(logPrefixDebug, color.CyanString("FOUND FILE: "+file.Link))
+			if config.Debug && (!history || config.MessageOutputHistory) {
+				log.Println(lg("Debug", "Message", color.HiCyanString, "FOUND FILE: "+file.Link+fmt.Sprintf(" \t<%s>", m.ID)))
 			}
-			status := startDownload(
-				downloadRequestStruct{
-					InputURL:   file.Link,
-					Filename:   file.Filename,
-					Path:       channelConfig.Destination,
-					Message:    m,
-					FileTime:   file.Time,
-					HistoryCmd: history,
-					EmojiCmd:   false,
-				})
+			status, filesize := downloadRequestStruct{
+				InputURL:   file.Link,
+				Filename:   file.Filename,
+				Path:       channelConfig.Destination,
+				Message:    m,
+				FileTime:   file.Time,
+				HistoryCmd: history,
+				EmojiCmd:   false,
+				StartTime:  time.Now(),
+			}.handleDownload()
 			if status.Status == downloadSuccess {
 				downloadCount++
+				totalfilesize += filesize
 			}
 		}
-		return downloadCount
+		return downloadCount, totalfilesize
 	}
 
-	return -1
+	return -1, 0
 }
 
 //#endregion

+ 504 - 221
history.go

@@ -1,307 +1,590 @@
 package main
 
 import (
+	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"log"
 	"os"
 	"strconv"
 	"time"
 
 	"github.com/bwmarrin/discordgo"
+	"github.com/dustin/go-humanize"
 	"github.com/fatih/color"
 	"github.com/hako/durafmt"
+	orderedmap "github.com/wk8/go-ordered-map/v2"
 )
 
+type historyStatus int
+
+const (
+	historyStatusWaiting historyStatus = iota
+	historyStatusRunning
+	historyStatusAbortRequested
+	historyStatusAbortCompleted
+	historyStatusErrorReadMessageHistoryPerms
+	historyStatusErrorRequesting
+	historyStatusCompletedNoMoreMessages
+	historyStatusCompletedToBeforeFilter
+	historyStatusCompletedToSinceFilter
+)
+
+func historyStatusLabel(status historyStatus) string {
+	switch status {
+	case historyStatusWaiting:
+		return "Waiting..."
+	case historyStatusRunning:
+		return "Currently Downloading..."
+	case historyStatusAbortRequested:
+		return "Abort Requested..."
+	case historyStatusAbortCompleted:
+		return "Aborted..."
+	case historyStatusErrorReadMessageHistoryPerms:
+		return "ERROR: Cannot Read Message History"
+	case historyStatusErrorRequesting:
+		return "ERROR: Message Requests Failed"
+	case historyStatusCompletedNoMoreMessages:
+		return "COMPLETE: No More Messages"
+	case historyStatusCompletedToBeforeFilter:
+		return "COMPLETE: Exceeded Before Date Filter"
+	case historyStatusCompletedToSinceFilter:
+		return "COMPLETE: Exceeded Since Date Filter"
+	default:
+		return "Unknown"
+	}
+}
+
+type historyJob struct {
+	Status                  historyStatus
+	OriginUser              string
+	OriginChannel           string
+	TargetCommandingMessage *discordgo.Message
+	TargetChannelID         string
+	TargetBefore            string
+	TargetSince             string
+	DownloadCount           int64
+	DownloadSize            int64
+	Updated                 time.Time
+	Added                   time.Time
+}
+
 var (
-	historyStatus map[string]string
+	historyJobs            *orderedmap.OrderedMap[string, historyJob]
+	historyJobCnt          int
+	historyJobCntWaiting   int
+	historyJobCntRunning   int
+	historyJobCntAborted   int
+	historyJobCntErrored   int
+	historyJobCntCompleted int
 )
 
+// TODO: cleanup
+type historyCache struct {
+	Updated        time.Time
+	Running        bool
+	RunningBefore  string // messageID for last before range attempted if interrupted
+	CompletedSince string // messageID for last message the bot has 100% assumed completion on (since start of channel)
+}
+
 func handleHistory(commandingMessage *discordgo.Message, subjectChannelID string, before string, since string) int {
-	// Identifier
+	var err error
+
+	historyStartTime := time.Now()
+
+	// Log Prefix
 	var commander string = "AUTORUN"
-	if commandingMessage != nil {
+	var autorun bool = true
+	if commandingMessage != nil { // Only time commandingMessage is nil is Autorun
 		commander = getUserIdentifier(*commandingMessage.Author)
+		autorun = false
 	}
-
 	logPrefix := fmt.Sprintf("%s/%s: ", subjectChannelID, commander)
 
-	// Check Read History perms
-	if !hasPerms(subjectChannelID, discordgo.PermissionReadMessageHistory) {
-		log.Println(logPrefixHistory, color.HiRedString(logPrefix+"BOT DOES NOT HAVE PERMISSION TO READ MESSAGE HISTORY!!!"))
-		return 0
+	// Skip Requested
+	if job, exists := historyJobs.Get(subjectChannelID); exists && job.Status != historyStatusWaiting {
+		log.Println(lg("History", "", color.RedString, logPrefix+"History job skipped, Status: %s", historyStatusLabel(job.Status)))
+		return -1
+	}
+
+	// Vars
+	var totalMessages int64 = 0
+	var totalDownloads int64 = 0
+	var totalFilesize int64 = 0
+	var messageRequestCount int = 0
+	var responseMsg *discordgo.Message = &discordgo.Message{} // dummy message
+	responseMsg.ID = ""
+	responseMsg.ChannelID = subjectChannelID
+	responseMsg.GuildID = ""
+
+	baseChannelInfo, err := bot.State.Channel(subjectChannelID)
+	if err != nil {
+		baseChannelInfo, err = bot.Channel(subjectChannelID)
+		if err != nil {
+			log.Println(lg("History", "", color.HiRedString, logPrefix+"Error fetching channel data from discordgo:\t%s", err))
+		}
 	}
 
-	// Mark active
-	historyStatus[subjectChannelID] = "downloading"
+	guildName := getGuildName(baseChannelInfo.GuildID)
+	categoryName := getChannelCategoryName(baseChannelInfo.ID)
+
+	subjectChannels := []discordgo.Channel{}
 
-	var i int64 = 0
-	var d int64 = 0
-	var batch int = 0
+	// Check channel type
+	baseChannelIsForum := true
+	if baseChannelInfo.Type != discordgo.ChannelTypeGuildCategory &&
+		baseChannelInfo.Type != discordgo.ChannelTypeGuildForum &&
+		baseChannelInfo.Type != discordgo.ChannelTypeGuildNews &&
+		baseChannelInfo.Type != discordgo.ChannelTypeGuildStageVoice &&
+		baseChannelInfo.Type != discordgo.ChannelTypeGuildVoice &&
+		baseChannelInfo.Type != discordgo.ChannelTypeGuildStore {
+		subjectChannels = append(subjectChannels, *baseChannelInfo)
+		baseChannelIsForum = false
+	}
 
-	var beforeID string
-	if before != "" {
-		beforeID = before
+	// Index Threads
+	now := time.Now()
+	if threads, err := bot.ThreadsArchived(subjectChannelID, &now, 0); err == nil {
+		for _, thread := range threads.Threads {
+			subjectChannels = append(subjectChannels, *thread)
+		}
+	}
+	if threads, err := bot.ThreadsActive(subjectChannelID); err == nil {
+		for _, thread := range threads.Threads {
+			subjectChannels = append(subjectChannels, *thread)
+		}
 	}
-	var beforeTime time.Time
 
-	var sinceID string
-	if since != "" {
-		sinceID = since
+	// Send Status?
+	var sendStatus bool = true
+	if (autorun && !config.SendAutoHistoryStatus) || (!autorun && !config.SendHistoryStatus) {
+		sendStatus = false
 	}
 
-	rangeContent := ""
-	if since != "" {
-		if isDate(since) {
-			rangeContent += fmt.Sprintf("**Since:** `%s`\n", discordSnowflakeToTimestamp(since, "2006-01-02"))
-		} else if isNumeric(since) {
-			rangeContent += fmt.Sprintf("**Since:** `%s`\n", since)
+	// Check Read History perms
+	if !baseChannelIsForum && !hasPerms(subjectChannelID, discordgo.PermissionReadMessageHistory) {
+		if job, exists := historyJobs.Get(subjectChannelID); exists {
+			job.Status = historyStatusRunning
+			job.Updated = time.Now()
+			historyJobs.Set(subjectChannelID, job)
 		}
+		log.Println(lg("History", "", color.HiRedString, logPrefix+"BOT DOES NOT HAVE PERMISSION TO READ MESSAGE HISTORY!!!"))
+	}
+
+	// Update Job Status to Downloading
+	if job, exists := historyJobs.Get(subjectChannelID); exists {
+		job.Status = historyStatusRunning
+		job.Updated = time.Now()
+		historyJobs.Set(subjectChannelID, job)
 	}
-	if before != "" {
-		if isDate(before) {
-			rangeContent += fmt.Sprintf("**Before:** `%s`", discordSnowflakeToTimestamp(before, "2006-01-02"))
-		} else if isNumeric(before) {
-			rangeContent += fmt.Sprintf("**Before:** `%s`\n", before)
+
+	//#region Cache Files
+
+	openHistoryCache := func(channel string) historyCache {
+		if f, err := os.ReadFile(pathCacheHistory + string(os.PathSeparator) + channel + ".json"); err == nil {
+			var ret historyCache
+			if err = json.Unmarshal(f, &ret); err != nil {
+				log.Println(lg("Debug", "History", color.RedString,
+					logPrefix+"Failed to unmarshal json for cache:\t%s", err))
+			} else {
+				return ret
+			}
 		}
+		return historyCache{}
 	}
-	if rangeContent != "" {
-		rangeContent += "\n\n"
+
+	writeHistoryCache := func(channel string, cache historyCache) {
+		cacheJson, err := json.Marshal(cache)
+		if err != nil {
+			log.Println(lg("Debug", "History", color.RedString,
+				logPrefix+"Failed to format cache into json:\t%s", err))
+		} else {
+			if err := os.MkdirAll(pathCacheHistory, 0755); err != nil {
+				log.Println(lg("Debug", "History", color.HiRedString,
+					logPrefix+"Error while creating history cache folder \"%s\": %s", pathCacheHistory, err))
+			}
+			f, err := os.OpenFile(
+				pathCacheHistory+string(os.PathSeparator)+channel+".json",
+				os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
+			if err != nil {
+				log.Println(lg("Debug", "History", color.RedString,
+					logPrefix+"Failed to open cache file:\t%s", err))
+			}
+			if _, err = f.WriteString(string(cacheJson)); err != nil {
+				log.Println(lg("Debug", "History", color.RedString,
+					logPrefix+"Failed to write cache file:\t%s", err))
+			} else if !autorun && config.Debug {
+				log.Println(lg("Debug", "History", color.YellowString,
+					logPrefix+"Wrote to cache file."))
+			}
+			f.Close()
+		}
 	}
 
-	var err error
-	var message *discordgo.Message = nil
-
-	if isChannelRegistered(subjectChannelID) {
-		channelConfig := getChannelConfig(subjectChannelID)
-
-		// Open Cache File?
-		if historyCachePath != "" {
-			filepath := historyCachePath + string(os.PathSeparator) + subjectChannelID
-			if f, err := ioutil.ReadFile(filepath); err == nil {
-				beforeID = string(f)
-				if commandingMessage != nil && config.DebugOutput {
-					log.Println(logPrefixDebug, color.YellowString(logPrefix+"Found a cache file, picking up where we left off...", subjectChannelID, commander))
-				}
+	deleteHistoryCache := func(channel string) {
+		fp := pathCacheHistory + string(os.PathSeparator) + channel + ".json"
+		if _, err := os.Stat(fp); err == nil {
+			err = os.Remove(fp)
+			if err != nil {
+				log.Println(lg("Debug", "History", color.HiRedString,
+					logPrefix+"Encountered error deleting cache file:\t%s", err))
+			} else if commandingMessage != nil && config.Debug {
+				log.Println(lg("Debug", "History", color.HiRedString,
+					logPrefix+"Deleted cache file."))
 			}
 		}
+	}
+
+	//#endregion
 
-		historyStartTime := time.Now()
-
-		// Initial Status Message
-		if commandingMessage != nil {
-			if hasPerms(commandingMessage.ChannelID, discordgo.PermissionSendMessages) {
-				message, err = replyEmbed(commandingMessage, "Command — History", fmt.Sprintf("Starting to save history, please wait...\n\n`Server:` **%s**\n`Channel:` _#%s_\n\n",
-					getGuildName(getChannelGuildID(subjectChannelID)),
-					getChannelName(subjectChannelID),
-				))
-				if err != nil {
-					log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Failed to send command embed message:\t%s", err))
+	for _, channel := range subjectChannels {
+		logPrefix = fmt.Sprintf("%s/%s: ", channel.ID, commander)
+
+		channelConfig := getSource(responseMsg, &channel)
+
+		// Invalid Source?
+		if channelConfig == emptyConfig {
+			log.Println(lg("History", "", color.HiRedString,
+				logPrefix+"Invalid source: "+channel.ID))
+			if job, exists := historyJobs.Get(subjectChannelID); exists {
+				job.Status = historyStatusErrorRequesting
+				job.Updated = time.Now()
+				historyJobs.Set(subjectChannelID, job)
+			}
+			continue
+		} else { // Process
+
+			// Overwrite Send Status
+			if channelConfig.SendAutoHistoryStatus != nil {
+				if autorun && !*channelConfig.SendAutoHistoryStatus {
+					sendStatus = false
+				}
+			}
+			if channelConfig.SendHistoryStatus != nil {
+				if !autorun && !*channelConfig.SendHistoryStatus {
+					sendStatus = false
 				}
-			} else {
-				log.Println(logPrefixHistory, color.HiRedString(logPrefix+fmtBotSendPerm, commandingMessage.ChannelID))
 			}
-		}
-		log.Println(logPrefixHistory, color.CyanString(logPrefix+"Began checking history for %s...", subjectChannelID))
 
-	MessageRequestingLoop:
-		for true {
-			// Next 100
-			if beforeTime != (time.Time{}) {
-				batch++
+			hasPermsToRespond := hasPerms(channel.ID, discordgo.PermissionSendMessages)
+			if !autorun {
+				hasPermsToRespond = hasPerms(commandingMessage.ChannelID, discordgo.PermissionSendMessages)
+			}
 
-				// Write to cache file
-				if historyCachePath != "" {
-					err := os.MkdirAll(historyCachePath, 0755)
-					if err != nil {
-						log.Println(logPrefixHistory, color.HiRedString("Error while creating history cache folder \"%s\": %s", historyCachePath, err))
+			// Date Range Vars
+			rangeContent := ""
+			var beforeTime time.Time
+			var beforeID = before
+			var sinceID = ""
+
+			// Handle Cache File
+			if cache := openHistoryCache(channel.ID); cache != (historyCache{}) {
+				if cache.CompletedSince != "" {
+					if config.Debug {
+						log.Println(lg("Debug", "History", color.GreenString,
+							logPrefix+"Assuming history is completed prior to "+cache.CompletedSince))
+					}
+					since = cache.CompletedSince
+				}
+				if cache.Running {
+					if config.Debug {
+						log.Println(lg("Debug", "History", color.YellowString,
+							logPrefix+"Job was interrupted last run, picking up from "+beforeID))
 					}
+					beforeID = cache.RunningBefore
+				}
+			}
+
+			//#region Date Range Output
+
+			var beforeRange = before
+			if beforeRange != "" {
+				if isDate(beforeRange) {
+					beforeRange = discordTimestampToSnowflake(beforeID, "2006-01-02")
+				}
+				if isNumeric(beforeRange) {
+					rangeContent += fmt.Sprintf("**Before:** `%s`\n", beforeRange)
+				}
+				before = beforeRange
+			}
+
+			var sinceRange = since
+			if sinceRange != "" {
+				if isDate(sinceRange) {
+					sinceRange = discordTimestampToSnowflake(sinceRange, "2006-01-02")
+				}
+				if isNumeric(sinceRange) {
+					rangeContent += fmt.Sprintf("**Since:** `%s`\n", sinceRange)
+				}
+				since = sinceRange
+			}
 
-					filepath := historyCachePath + string(os.PathSeparator) + subjectChannelID
-					f, err := os.OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
+			if rangeContent != "" {
+				rangeContent += "\n"
+			}
+
+			//#endregion
+
+			channelName := getChannelName(channel.ID, &channel)
+			if channel.ParentID != "" {
+				channelName = getChannelName(channel.ParentID, nil) + " \"" + getChannelName(channel.ID, &channel) + "\""
+			}
+			sourceName := fmt.Sprintf("%s / %s", guildName, channelName)
+			msgSourceDisplay := fmt.Sprintf("`Server:` **%s**\n`Channel:` #%s", guildName, channelName)
+			if categoryName != "unknown" {
+				sourceName = fmt.Sprintf("%s / %s / %s", guildName, categoryName, channelName)
+				msgSourceDisplay = fmt.Sprintf("`Server:` **%s**\n`Category:` _%s_\n`Channel:` #%s",
+					guildName, categoryName, channelName)
+			}
+
+			// Initial Status Message
+			if sendStatus {
+				if hasPermsToRespond {
+					responseMsg, err = replyEmbed(commandingMessage, "Command — History", msgSourceDisplay)
 					if err != nil {
-						log.Println(logPrefixHistory, color.RedString("Failed to open cache file:\t%s", err))
+						log.Println(lg("History", "", color.HiRedString,
+							logPrefix+"Failed to send command embed message:\t%s", err))
 					}
-					if _, err = f.WriteString(beforeID); err != nil {
-						log.Println(logPrefixHistory, color.RedString("Failed to write cache file:\t%s", err))
-					} else if commandingMessage != nil && config.DebugOutput {
-						log.Println(logPrefixDebug, logPrefixHistory, color.YellowString(logPrefix+"Wrote to cache file."))
-					}
-					f.Close()
+				} else {
+					log.Println(lg("History", "", color.HiRedString,
+						logPrefix+fmtBotSendPerm, commandingMessage.ChannelID))
 				}
+			}
+			log.Println(lg("History", "", color.HiCyanString, logPrefix+"Began checking history for \"%s\"...", sourceName))
 
-				// Status Update
-				if commandingMessage != nil {
-					log.Println(logPrefixHistory, color.CyanString(logPrefix+"Requesting 100 more, %d downloaded, %d processed — Before %s",
-						d, i, beforeTime))
-					if message != nil {
-						if hasPerms(message.ChannelID, discordgo.PermissionSendMessages) {
-							content := fmt.Sprintf("``%s:`` **%s files downloaded**\n``%s messages processed``\n\n`Server:` **%s**\n`Channel:` _#%s_\n\n%s`(%d)` _Processing more messages, please wait..._",
-								durafmt.ParseShort(time.Since(historyStartTime)).String(),
-								formatNumber(d), formatNumber(i),
-								getGuildName(getChannelGuildID(subjectChannelID)),
-								getChannelName(subjectChannelID),
-								rangeContent, batch)
-							if selfbot {
-								message, err = bot.ChannelMessageEdit(message.ChannelID, message.ID, fmt.Sprintf("**Command — History**\n\n%s", content))
-								// Edit failure, so send replacement status
-								if err != nil {
-									log.Println(logPrefixHistory, color.RedString(logPrefix+"Failed to edit status message, sending new one:\t%s", err))
-									message, err = replyEmbed(message, "Command — History", content)
-									if err != nil {
-										log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Failed to send replacement status message:\t%s", err))
-									}
-								}
+			lastMessageID := ""
+		MessageRequestingLoop:
+			for {
+				// Next 100
+				if beforeTime != (time.Time{}) {
+					messageRequestCount++
+					writeHistoryCache(channel.ID, historyCache{
+						Updated:       time.Now(),
+						Running:       true,
+						RunningBefore: beforeID,
+					})
+
+					// Update Status
+					log.Println(lg("History", "", color.CyanString,
+						logPrefix+"Requesting more, \t%d downloaded (%s), \t%d processed, \tsearching before %s ago (%s)",
+						totalDownloads, humanize.Bytes(uint64(totalFilesize)), totalMessages, durafmt.ParseShort(time.Since(beforeTime)).String(), beforeTime.String()[:10]))
+					if sendStatus {
+						status := fmt.Sprintf(
+							"``%s:`` **%s files downloaded...** `(%s so far, avg %1.1f MB/s)`\n``"+
+								"%s messages processed...``\n\n"+
+								"%s\n\n"+
+								"%s`(%d)` _Processing more messages, please wait..._",
+							shortenTime(durafmt.ParseShort(time.Since(historyStartTime)).String()), formatNumber(totalDownloads),
+							humanize.Bytes(uint64(totalFilesize)), float64(totalFilesize/humanize.MByte)/time.Since(historyStartTime).Seconds(),
+							formatNumber(totalMessages),
+							msgSourceDisplay, rangeContent, messageRequestCount)
+						if responseMsg == nil {
+							log.Println(lg("History", "", color.RedString,
+								logPrefix+"Tried to edit status message but it doesn't exist, sending new one."))
+							if responseMsg, err = replyEmbed(responseMsg, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message
+								log.Println(lg("History", "", color.HiRedString,
+									logPrefix+"Failed to send replacement status message:\t%s", err))
+							}
+						} else {
+							if !hasPermsToRespond {
+								log.Println(lg("History", "", color.HiRedString,
+									logPrefix+fmtBotSendPerm+" - %s", responseMsg.ChannelID, status))
 							} else {
-								message, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{
-									ID:      message.ID,
-									Channel: message.ChannelID,
-									Embed:   buildEmbed(message.ChannelID, "Command — History", content),
-								})
-								// Edit failure, so send replacement status
+								// Edit Status
+								if selfbot {
+									responseMsg, err = bot.ChannelMessageEdit(responseMsg.ChannelID, responseMsg.ID,
+										fmt.Sprintf("**Command — History**\n\n%s", status))
+								} else {
+									responseMsg, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{
+										ID:      responseMsg.ID,
+										Channel: responseMsg.ChannelID,
+										Embed:   buildEmbed(responseMsg.ChannelID, "Command — History", status),
+									})
+								}
+								// Failed to Edit Status
 								if err != nil {
-									log.Println(logPrefixHistory, color.RedString(logPrefix+"Failed to edit status message, sending new one:\t%s", err))
-									message, err = replyEmbed(message, "Command — History", content)
-									if err != nil {
-										log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Failed to send replacement status message:\t%s", err))
+									log.Println(lg("History", "", color.HiRedString,
+										logPrefix+"Failed to edit status message, sending new one:\t%s", err))
+									if responseMsg, err = replyEmbed(responseMsg, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message
+										log.Println(lg("History", "", color.HiRedString,
+											logPrefix+"Failed to send replacement status message:\t%s", err))
 									}
 								}
 							}
-						} else {
-							log.Println(logPrefixHistory, color.HiRedString(logPrefix+fmtBotSendPerm, message.ChannelID))
 						}
-					} else {
-						log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Tried to edit status message but it doesn't exist."))
+					}
+
+					// Update presence
+					timeLastUpdated = time.Now()
+					if *channelConfig.PresenceEnabled {
+						go updateDiscordPresence()
 					}
 				}
-				// Update presence
-				timeLastUpdated = time.Now()
-				if *channelConfig.UpdatePresence {
-					updateDiscordPresence()
-				}
-			}
 
-			// Request More
-			messages, err := bot.ChannelMessages(subjectChannelID, 100, beforeID, sinceID, "")
-			if err == nil {
-				// No More Messages
-				if len(messages) <= 0 {
-					delete(historyStatus, subjectChannelID)
+				// Request More Messages
+				msg_rq_cnt := 0
+			request_messages:
+				msg_rq_cnt++
+				if messages, err := bot.ChannelMessages(channel.ID, 100, beforeID, sinceID, ""); err != nil {
+					// Error requesting messages
+					if sendStatus {
+						if !hasPermsToRespond {
+							log.Println(lg("History", "", color.HiRedString,
+								logPrefix+fmtBotSendPerm, responseMsg.ChannelID))
+						} else {
+							_, err = replyEmbed(responseMsg, "Command — History",
+								fmt.Sprintf("Encountered an error requesting messages for %s: %s", channel, err.Error()))
+							if err != nil {
+								log.Println(lg("History", "", color.HiRedString,
+									logPrefix+"Failed to send error message:\t%s", err))
+							}
+						}
+					}
+					log.Println(lg("History", "", color.HiRedString, logPrefix+"Error requesting messages:\t%s", err))
+					if job, exists := historyJobs.Get(subjectChannelID); exists {
+						job.Status = historyStatusErrorRequesting
+						job.Updated = time.Now()
+						historyJobs.Set(subjectChannelID, job)
+					}
+					//TODO: delete cahce or handle it differently?
 					break MessageRequestingLoop
-				}
-				// Go Back
-				beforeID = messages[len(messages)-1].ID
-				beforeTime, err = messages[len(messages)-1].Timestamp.Parse()
-				if err != nil {
-					log.Println(logPrefixHistory, color.RedString(logPrefix+"Failed to fetch message timestamp:\t%s", err))
-				}
-				sinceID = ""
-				// Process Messages
-				if channelConfig.TypeWhileProcessing != nil && commandingMessage != nil {
-					if *channelConfig.TypeWhileProcessing && hasPerms(commandingMessage.ChannelID, discordgo.PermissionSendMessages) {
-						bot.ChannelTyping(commandingMessage.ChannelID)
+				} else {
+					// No More Messages
+					if len(messages) <= 0 {
+						if msg_rq_cnt > 3 {
+							if job, exists := historyJobs.Get(subjectChannelID); exists {
+								job.Status = historyStatusCompletedNoMoreMessages
+								job.Updated = time.Now()
+								historyJobs.Set(subjectChannelID, job)
+							}
+							writeHistoryCache(channel.ID, historyCache{
+								Updated:        time.Now(),
+								Running:        false,
+								RunningBefore:  "",
+								CompletedSince: lastMessageID,
+							})
+							break MessageRequestingLoop
+						} else { // retry to make sure no more
+							time.Sleep(10 * time.Millisecond)
+							goto request_messages
+						}
 					}
-				}
-				for _, message := range messages {
 
-					// Ordered to Cancel
-					if historyStatus[message.ChannelID] == "cancel" {
-						delete(historyStatus, message.ChannelID)
-						break MessageRequestingLoop
+					// Set New Range, this shouldn't be changed regardless of before/since filters. The bot will always go latest to oldest.
+					beforeID = messages[len(messages)-1].ID
+					beforeTime = messages[len(messages)-1].Timestamp
+					sinceID = ""
+
+					// Process Messages
+					if channelConfig.HistoryTyping != nil && !autorun {
+						if *channelConfig.HistoryTyping && hasPermsToRespond {
+							bot.ChannelTyping(commandingMessage.ChannelID)
+						}
 					}
+					for _, message := range messages {
+						// Ordered to Cancel
+						if job, exists := historyJobs.Get(subjectChannelID); exists {
+							if job.Status == historyStatusAbortRequested {
+								job.Status = historyStatusAbortCompleted
+								job.Updated = time.Now()
+								historyJobs.Set(subjectChannelID, job)
+								deleteHistoryCache(channel.ID) //TODO: Replace with different variation of writing cache?
+								break MessageRequestingLoop
+							}
+						}
 
-					// Check Range
-					message64, _ := strconv.ParseInt(message.ID, 10, 64)
+						lastMessageID = message.ID
 
-					if before != "" && since != "" {
-						//
-					} else if before != "" {
-						before64, _ := strconv.ParseInt(before, 10, 64)
-						if message64 > before64 {
-							delete(historyStatus, message.ChannelID)
-							break MessageRequestingLoop
+						// Check Message Range
+						message64, _ := strconv.ParseInt(message.ID, 10, 64)
+						if before != "" {
+							before64, _ := strconv.ParseInt(before, 10, 64)
+							if message64 > before64 { // keep scrolling back in messages
+								continue
+							}
 						}
-					} else if since != "" {
-						since64, _ := strconv.ParseInt(since, 10, 64)
-						if message64 < since64 {
-							delete(historyStatus, message.ChannelID)
-							break MessageRequestingLoop
+						if since != "" {
+							since64, _ := strconv.ParseInt(since, 10, 64)
+							if message64 < since64 { // message too old, kill loop
+								if job, exists := historyJobs.Get(subjectChannelID); exists {
+									job.Status = historyStatusCompletedToSinceFilter
+									job.Updated = time.Now()
+									historyJobs.Set(subjectChannelID, job)
+								}
+								deleteHistoryCache(channel.ID) // unsure of consequences of caching when using filters, so deleting to be safe for now.
+								break MessageRequestingLoop
+							}
 						}
-					}
 
-					// Process
-					downloadCount := handleMessage(message, false, true)
-					if downloadCount > 0 {
-						d += downloadCount
-					}
-					i++
-				}
-			} else {
-				// Error requesting messages
-				if message != nil {
-					if hasPerms(message.ChannelID, discordgo.PermissionSendMessages) {
-						_, err = replyEmbed(message, "Command — History", fmt.Sprintf("Encountered an error requesting messages for %s: %s", subjectChannelID, err.Error()))
-						if err != nil {
-							log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Failed to send error message:\t%s", err))
+						// Process Message
+						downloadCount, filesize := handleMessage(message, &channel, false, true)
+						if downloadCount > 0 {
+							totalDownloads += downloadCount
+							totalFilesize += filesize
 						}
-					} else {
-						log.Println(logPrefixHistory, color.HiRedString(logPrefix+fmtBotSendPerm, message.ChannelID))
+						totalMessages++
 					}
 				}
-				log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Error requesting messages:\t%s", err))
-				delete(historyStatus, subjectChannelID)
-				break MessageRequestingLoop
 			}
-		}
 
-		// Final status update
-		if commandingMessage != nil {
-			if message != nil {
-				if hasPerms(message.ChannelID, discordgo.PermissionSendMessages) {
-					contentFinal := fmt.Sprintf("``%s:`` **%s total files downloaded!**\n``%s total messages processed``\n\n`Server:` **%s**\n`Channel:` _#%s_\n\n**FINISHED!**\nRan ``%d`` message history requests\n\n%s_Duration was %s_",
-						durafmt.ParseShort(time.Since(historyStartTime)).String(),
-						formatNumber(int64(d)), formatNumber(int64(i)),
-						getGuildName(getChannelGuildID(subjectChannelID)),
-						getChannelName(subjectChannelID),
-						batch, rangeContent,
-						durafmt.Parse(time.Since(historyStartTime)).String(),
-					)
-					if selfbot {
-						message, err = bot.ChannelMessageEdit(message.ChannelID, message.ID, fmt.Sprintf("**Command — History**\n\n%s", contentFinal))
-						// Edit failure
-						if err != nil {
-							log.Println(logPrefixHistory, color.RedString(logPrefix+"Failed to edit status message, sending new one:\t%s", err))
-							message, err = replyEmbed(message, "Command — History", contentFinal)
-							if err != nil {
-								log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Failed to send replacement status message:\t%s", err))
-							}
+			// Final log
+			log.Println(lg("History", "", color.HiGreenString, logPrefix+"Finished history for \"%s\", %s files, %s total",
+				sourceName, formatNumber(totalDownloads), humanize.Bytes(uint64(totalFilesize))))
+			// Final status update
+			if sendStatus {
+				jobStatus := "Unknown"
+				if job, exists := historyJobs.Get(subjectChannelID); exists {
+					jobStatus = historyStatusLabel(job.Status)
+				}
+				status := fmt.Sprintf(
+					"``%s:`` **%s total files downloaded!** `%s total, avg %1.1f MB/s`\n"+
+						"``%s total messages processed``\n\n"+
+						"%s\n\n"+ // msgSourceDisplay^
+						"**DONE!** - %s\n"+
+						"Ran ``%d`` message history requests\n\n"+
+						"%s_Duration was %s_",
+					durafmt.ParseShort(time.Since(historyStartTime)).String(), formatNumber(int64(totalDownloads)),
+					humanize.Bytes(uint64(totalFilesize)), float64(totalFilesize/humanize.MByte)/time.Since(historyStartTime).Seconds(),
+					formatNumber(int64(totalMessages)),
+					msgSourceDisplay,
+					jobStatus,
+					messageRequestCount,
+					rangeContent, durafmt.Parse(time.Since(historyStartTime)).String(),
+				)
+				if !hasPermsToRespond {
+					log.Println(lg("History", "", color.HiRedString, logPrefix+fmtBotSendPerm, responseMsg.ChannelID))
+				} else {
+					if responseMsg == nil {
+						log.Println(lg("History", "", color.RedString,
+							logPrefix+"Tried to edit status message but it doesn't exist, sending new one."))
+						if _, err = replyEmbed(responseMsg, "Command — History", status); err != nil { // Failed to Edit Status, Send New Message
+							log.Println(lg("History", "", color.HiRedString,
+								logPrefix+"Failed to send replacement status message:\t%s", err))
 						}
 					} else {
-						message, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{
-							ID:      message.ID,
-							Channel: message.ChannelID,
-							Embed:   buildEmbed(message.ChannelID, "Command — History", contentFinal),
-						})
+						if selfbot {
+							responseMsg, err = bot.ChannelMessageEdit(responseMsg.ChannelID, responseMsg.ID,
+								fmt.Sprintf("**Command — History**\n\n%s", status))
+						} else {
+							responseMsg, err = bot.ChannelMessageEditComplex(&discordgo.MessageEdit{
+								ID:      responseMsg.ID,
+								Channel: responseMsg.ChannelID,
+								Embed:   buildEmbed(responseMsg.ChannelID, "Command — History", status),
+							})
+						}
 						// Edit failure
 						if err != nil {
-							log.Println(logPrefixHistory, color.RedString(logPrefix+"Failed to edit status message, sending new one:\t%s", err))
-							message, err = replyEmbed(message, "Command — History", contentFinal)
-							if err != nil {
-								log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Failed to send replacement status message:\t%s", err))
+							log.Println(lg("History", "", color.RedString,
+								logPrefix+"Failed to edit status message, sending new one:\t%s", err))
+							if _, err = replyEmbed(responseMsg, "Command — History", status); err != nil {
+								log.Println(lg("History", "", color.HiRedString,
+									logPrefix+"Failed to send replacement status message:\t%s", err))
 							}
 						}
 					}
-				} else {
-					log.Println(logPrefixHistory, color.HiRedString(logPrefix+fmtBotSendPerm, message.ChannelID))
 				}
-			} else {
-				log.Println(logPrefixHistory, color.HiRedString(logPrefix+"Tried to edit status message but it doesn't exist.", subjectChannelID, commander))
 			}
 		}
-
-		// Final log
-		log.Println(logPrefixHistory, color.HiCyanString(logPrefix+"Finished history, %s files", formatNumber(d)))
 	}
 
-	return int(d)
+	return int(totalDownloads)
 }

File diff suppressed because it is too large
+ 629 - 332
main.go


+ 70 - 296
parse.go

@@ -11,20 +11,9 @@ import (
 	"strings"
 	"time"
 
-	"github.com/ChimeraCoder/anaconda"
-	"github.com/Jeffail/gabs"
+	"github.com/Davincible/goinsta/v3"
 	"github.com/PuerkitoBio/goquery"
-	"golang.org/x/net/html"
-	"google.golang.org/api/googleapi"
-)
-
-const (
-	imgurClientID   = "08af502a9e70d65"
-	sneakyUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36"
-)
-
-var (
-	twitterClient *anaconda.TwitterApi
+	"github.com/bwmarrin/discordgo"
 )
 
 //#region Twitter
@@ -32,48 +21,38 @@ var (
 func getTwitterUrls(inputURL string) (map[string]string, error) {
 	parts := strings.Split(inputURL, ":")
 	if len(parts) < 2 {
-		return nil, errors.New("Unable to parse Twitter URL")
+		return nil, errors.New("unable to parse Twitter URL")
 	}
 	return map[string]string{"https:" + parts[1] + ":orig": filenameFromURL(parts[1])}, nil
 }
 
-func getTwitterStatusUrls(inputURL string, channelID string) (map[string]string, error) {
-	if twitterClient == nil {
-		return nil, errors.New("Invalid Twitter API Keys Set")
+func getTwitterStatusUrls(inputURL string, m *discordgo.Message) (map[string]string, error) {
+	if strings.Contains(inputURL, "/photo/") {
+		inputURL = inputURL[:strings.Index(inputURL, "/photo/")]
+	}
+	if strings.Contains(inputURL, "/video/") {
+		inputURL = inputURL[:strings.Index(inputURL, "/video/")]
 	}
 
 	matches := regexUrlTwitterStatus.FindStringSubmatch(inputURL)
-	statusId, err := strconv.ParseInt(matches[4], 10, 64)
+	_, err := strconv.ParseInt(matches[4], 10, 64)
 	if err != nil {
 		return nil, err
 	}
-
-	tweet, err := twitterClient.GetTweet(statusId, nil)
+	tweet, err := twitterScraper.GetTweet(matches[4])
 	if err != nil {
 		return nil, err
 	}
 
 	links := make(map[string]string)
-	for _, tweetMedia := range tweet.ExtendedEntities.Media {
-		if len(tweetMedia.VideoInfo.Variants) > 0 {
-			var lastVideoVariant anaconda.Variant
-			for _, videoVariant := range tweetMedia.VideoInfo.Variants {
-				if videoVariant.Bitrate >= lastVideoVariant.Bitrate {
-					lastVideoVariant = videoVariant
-				}
-			}
-			if lastVideoVariant.Url != "" {
-				links[lastVideoVariant.Url] = ""
-			}
-		} else {
-			foundUrls := getDownloadLinks(tweetMedia.Media_url_https, channelID)
-			for foundUrlKey, foundUrlValue := range foundUrls {
-				links[foundUrlKey] = foundUrlValue
-			}
+	for _, photo := range tweet.Photos {
+		foundUrls := getDownloadLinks(photo.URL, m)
+		for foundUrlKey, foundUrlValue := range foundUrls {
+			links[foundUrlKey] = foundUrlValue
 		}
 	}
-	for _, tweetUrl := range tweet.Entities.Urls {
-		foundUrls := getDownloadLinks(tweetUrl.Expanded_url, channelID)
+	for _, video := range tweet.Videos {
+		foundUrls := getDownloadLinks(video.URL, m)
 		for foundUrlKey, foundUrlValue := range foundUrls {
 			links[foundUrlKey] = foundUrlValue
 		}
@@ -86,177 +65,53 @@ func getTwitterStatusUrls(inputURL string, channelID string) (map[string]string,
 
 //#region Instagram
 
-func getInstagramUrls(url string) (map[string]string, error) {
-	username, shortcode := getInstagramInfo(url)
-	filename := fmt.Sprintf("instagram %s - %s", username, shortcode)
-	// if instagram video
-	videoUrl := getInstagramVideoUrl(url)
-	if videoUrl != "" {
-		return map[string]string{videoUrl: filename + filepathExtension(videoUrl)}, nil
-	}
-	// if instagram album
-	albumUrls := getInstagramAlbumUrls(url)
-	if len(albumUrls) > 0 {
-		links := make(map[string]string)
-		for i, albumUrl := range albumUrls {
-			links[albumUrl] = filename + " " + strconv.Itoa(i+1) + filepathExtension(albumUrl)
-		}
-		return links, nil
-	}
-	// if instagram picture
-	afterLastSlash := strings.LastIndex(url, "/")
-	mediaUrl := url[:afterLastSlash]
-	mediaUrl += strings.Replace(strings.Replace(url[afterLastSlash:], "?", "&", -1), "/", "/media/?size=l", -1)
-	return map[string]string{mediaUrl: filename + ".jpg"}, nil
-}
-
-func getInstagramInfo(url string) (string, string) {
-	resp, err := http.Get(url)
-
-	if err != nil {
-		return "unknown", "unknown"
-	}
-
-	defer resp.Body.Close()
-	z := html.NewTokenizer(resp.Body)
-
-ParseLoop:
-	for {
-		tt := z.Next()
-		switch {
-		case tt == html.ErrorToken:
-			break ParseLoop
-		}
-		if tt == html.StartTagToken || tt == html.SelfClosingTagToken {
-			t := z.Token()
-			for _, a := range t.Attr {
-				if a.Key == "type" {
-					if a.Val == "text/javascript" {
-						z.Next()
-						content := string(z.Text())
-						if strings.Contains(content, "window._sharedData = ") {
-							content = strings.Replace(content, "window._sharedData = ", "", 1)
-							content = content[:len(content)-1]
-							jsonParsed, err := gabs.ParseJSON([]byte(content))
-							if err != nil {
-								log.Println("Error parsing instagram json:", err)
-								continue ParseLoop
-							}
-							entryChildren, err := jsonParsed.Path("entry_data.PostPage").Children()
-							if err != nil {
-								log.Println("Unable to find entries children:", err)
-								continue ParseLoop
-							}
-							for _, entryChild := range entryChildren {
-								shortcode := entryChild.Path("graphql.shortcode_media.shortcode").Data().(string)
-								username := entryChild.Path("graphql.shortcode_media.owner.username").Data().(string)
-								return username, shortcode
-							}
-						}
-					}
-				}
-			}
-		}
+func getInstagramUrls(inputURL string, m *discordgo.Message) (map[string]string, error) {
+	if instagramClient == nil {
+		return nil, errors.New("invalid Instagram API credentials")
 	}
-	return "unknown", "unknown"
-}
 
-func getInstagramVideoUrl(url string) string {
-	resp, err := http.Get(url)
+	links := make(map[string]string)
 
-	if err != nil {
-		return ""
+	// fix
+	shortcode := inputURL
+	if strings.Contains(shortcode, ".com/p/") {
+		shortcode = shortcode[strings.Index(shortcode, ".com/p/")+7:]
 	}
-
-	defer resp.Body.Close()
-	z := html.NewTokenizer(resp.Body)
-
-	for {
-		tt := z.Next()
-		switch {
-		case tt == html.ErrorToken:
-			return ""
-		}
-		if tt == html.StartTagToken || tt == html.SelfClosingTagToken {
-			t := z.Token()
-			if t.Data == "meta" {
-				for _, a := range t.Attr {
-					if a.Key == "property" {
-						if a.Val == "og:video" || a.Val == "og:video:secure_url" {
-							for _, at := range t.Attr {
-								if at.Key == "content" {
-									return at.Val
-								}
-							}
-						}
-					}
-				}
-			}
-		}
+	if strings.Contains(shortcode, ".com/reel/") {
+		shortcode = shortcode[strings.Index(shortcode, ".com/reel/")+10:]
 	}
-}
-
-func getInstagramAlbumUrls(url string) []string {
-	var links []string
-	resp, err := http.Get(url)
+	shortcode = strings.ReplaceAll(shortcode, "/", "")
 
-	if err != nil {
-		return links
-	}
-
-	defer resp.Body.Close()
-	z := html.NewTokenizer(resp.Body)
-
-ParseLoop:
-	for {
-		tt := z.Next()
-		switch {
-		case tt == html.ErrorToken:
-			break ParseLoop
-		}
-		if tt == html.StartTagToken || tt == html.SelfClosingTagToken {
-			t := z.Token()
-			for _, a := range t.Attr {
-				if a.Key == "type" {
-					if a.Val == "text/javascript" {
-						z.Next()
-						content := string(z.Text())
-						if strings.Contains(content, "window._sharedData = ") {
-							content = strings.Replace(content, "window._sharedData = ", "", 1)
-							content = content[:len(content)-1]
-							jsonParsed, err := gabs.ParseJSON([]byte(content))
-							if err != nil {
-								log.Println("Error parsing instagram json: ", err)
-								continue ParseLoop
-							}
-							entryChildren, err := jsonParsed.Path("entry_data.PostPage").Children()
-							if err != nil {
-								log.Println("Unable to find entries children: ", err)
-								continue ParseLoop
-							}
-							for _, entryChild := range entryChildren {
-								albumChildren, err := entryChild.Path("graphql.shortcode_media.edge_sidecar_to_children.edges").Children()
-								if err != nil {
-									continue ParseLoop
-								}
-								for _, albumChild := range albumChildren {
-									link, ok := albumChild.Path("node.display_url").Data().(string)
-									if ok {
-										links = append(links, link)
-									}
-								}
-							}
-						}
+	// fetch
+	mediaID, err := goinsta.MediaIDFromShortID(shortcode)
+	if err == nil {
+		media, err := instagramClient.GetMedia(mediaID)
+		if err != nil {
+			return nil, err
+		} else {
+			postType := media.Items[0].MediaToString()
+			if postType == "carousel" {
+				for index, item := range media.Items[0].CarouselMedia {
+					itemType := item.MediaToString()
+					if itemType == "video" {
+						url := item.Videos[0].URL
+						links[url] = fmt.Sprintf("%s %d %s", shortcode, index, media.Items[0].User.Username)
+					} else if itemType == "photo" {
+						url := item.Images.GetBest()
+						links[url] = fmt.Sprintf("%s %d %s", shortcode, index, media.Items[0].User.Username)
 					}
 				}
+			} else if postType == "video" {
+				url := media.Items[0].Videos[0].URL
+				links[url] = fmt.Sprintf("%s %s", shortcode, media.Items[0].User.Username)
+			} else if postType == "photo" {
+				url := media.Items[0].Images.GetBest()
+				links[url] = fmt.Sprintf("%s %s", shortcode, media.Items[0].User.Username)
 			}
 		}
 	}
-	if len(links) > 0 {
-		log.Printf("Found instagram album with %d images (url: %s)\n", len(links), url)
-	}
 
-	return links
+	return links, nil
 }
 
 //#endregion
@@ -323,13 +178,13 @@ func getStreamableUrls(url string) (map[string]string, error) {
 	matches := regexUrlStreamable.FindStringSubmatch(url)
 	shortcode := matches[3]
 	if shortcode == "" {
-		return nil, errors.New("Unable to get shortcode from URL")
+		return nil, errors.New("unable to get shortcode from URL")
 	}
 	reqUrl := fmt.Sprintf("https://api.streamable.com/videos/%s", shortcode)
 	streamable := new(streamableObject)
 	getJSON(reqUrl, streamable)
 	if streamable.Status != 2 || streamable.Files.Mp4.URL == "" {
-		return nil, errors.New("Streamable object has no download candidate")
+		return nil, errors.New("streamable object has no download candidate")
 	}
 	link := streamable.Files.Mp4.URL
 	if !strings.HasPrefix(link, "http") {
@@ -353,14 +208,14 @@ type gfycatObject struct {
 func getGfycatUrls(url string) (map[string]string, error) {
 	parts := strings.Split(url, "/")
 	if len(parts) < 3 {
-		return nil, errors.New("Unable to parse Gfycat URL")
+		return nil, errors.New("unable to parse Gfycat URL")
 	}
 	gfycatId := parts[len(parts)-1]
 	gfycatObject := new(gfycatObject)
 	getJSON("https://api.gfycat.com/v1/gfycats/"+gfycatId, gfycatObject)
 	gfycatUrl := gfycatObject.GfyItem.Mp4URL
 	if url == "" {
-		return nil, errors.New("Failed to read response from Gfycat")
+		return nil, errors.New("failed to read response from Gfycat")
 	}
 	return map[string]string{gfycatUrl: ""}, nil
 }
@@ -371,8 +226,8 @@ func getGfycatUrls(url string) (map[string]string, error) {
 
 type flickrPhotoSizeObject struct {
 	Label  string `json:"label"`
-	Width  int    `json:"width,int,string"`
-	Height int    `json:"height,int,string"`
+	Width  int    `json:"width"`
+	Height int    `json:"height"`
 	Source string `json:"source"`
 	URL    string `json:"url"`
 	Media  string `json:"media"`
@@ -408,12 +263,12 @@ func getFlickrUrlFromPhotoId(photoId string) string {
 
 func getFlickrPhotoUrls(url string) (map[string]string, error) {
 	if config.Credentials.FlickrApiKey == "" {
-		return nil, errors.New("Invalid Flickr API Key Set")
+		return nil, errors.New("invalid Flickr API Key Set")
 	}
 	matches := regexUrlFlickrPhoto.FindStringSubmatch(url)
 	photoId := matches[5]
 	if photoId == "" {
-		return nil, errors.New("Unable to get Photo ID from URL")
+		return nil, errors.New("unable to get Photo ID from URL")
 	}
 	return map[string]string{getFlickrUrlFromPhotoId(photoId): ""}, nil
 }
@@ -447,15 +302,15 @@ type flickrAlbumObject struct {
 
 func getFlickrAlbumUrls(url string) (map[string]string, error) {
 	if config.Credentials.FlickrApiKey == "" {
-		return nil, errors.New("Invalid Flickr API Key Set")
+		return nil, errors.New("invalid Flickr API Key Set")
 	}
 	matches := regexUrlFlickrAlbum.FindStringSubmatch(url)
 	if len(matches) < 10 || matches[9] == "" {
-		return nil, errors.New("Unable to find Flickr Album ID in URL")
+		return nil, errors.New("unable to find Flickr Album ID in URL")
 	}
 	albumId := matches[9]
 	if albumId == "" {
-		return nil, errors.New("Unable to get Album ID from URL")
+		return nil, errors.New("unable to get Album ID from URL")
 	}
 	reqUrl := fmt.Sprintf("https://www.flickr.com/services/rest/?format=json&nojsoncallback=1&method=%s&api_key=%s&photoset_id=%s&per_page=500",
 		"flickr.photosets.getPhotos", config.Credentials.FlickrApiKey, albumId)
@@ -476,61 +331,7 @@ func getFlickrAlbumShortUrls(url string) (map[string]string, error) {
 	if regexUrlFlickrAlbum.MatchString(result.Request.URL.String()) {
 		return getFlickrAlbumUrls(result.Request.URL.String())
 	}
-	return nil, errors.New("Encountered invalid URL while trying to get long URL from short Flickr Album URL")
-}
-
-//#endregion
-
-//#region Google Drive
-
-func getGoogleDriveUrls(url string) (map[string]string, error) {
-	parts := strings.Split(url, "/")
-	if len(parts) != 7 {
-		return nil, errors.New("unable to parse google drive url")
-	}
-	fileId := parts[len(parts)-2]
-	return map[string]string{"https://drive.google.com/uc?export=download&id=" + fileId: ""}, nil
-}
-
-func getGoogleDriveFolderUrls(url string) (map[string]string, error) {
-	matches := regexUrlGoogleDriveFolder.FindStringSubmatch(url)
-	if len(matches) < 4 || matches[3] == "" {
-		return nil, errors.New("unable to find google drive folder ID in link")
-	}
-	if googleDriveService.BasePath == "" {
-		return nil, errors.New("please set up google credentials")
-	}
-	googleDriveFolderID := matches[3]
-
-	links := make(map[string]string)
-
-	driveQuery := fmt.Sprintf("\"%s\" in parents", googleDriveFolderID)
-	driveFields := "nextPageToken, files(id)"
-	result, err := googleDriveService.Files.List().Q(driveQuery).Fields(googleapi.Field(driveFields)).PageSize(1000).Do()
-	if err != nil {
-		log.Println("driveQuery:", driveQuery)
-		log.Println("driveFields:", driveFields)
-		log.Println("err:", err)
-		return nil, err
-	}
-	for _, file := range result.Files {
-		fileUrl := "https://drive.google.com/uc?export=download&id=" + file.Id
-		links[fileUrl] = ""
-	}
-
-	for {
-		if result.NextPageToken == "" {
-			break
-		}
-		result, err = googleDriveService.Files.List().Q(driveQuery).Fields(googleapi.Field(driveFields)).PageSize(1000).PageToken(result.NextPageToken).Do()
-		if err != nil {
-			return nil, err
-		}
-		for _, file := range result.Files {
-			links[file.Id] = ""
-		}
-	}
-	return links, nil
+	return nil, errors.New("encountered invalid URL while trying to get long URL from short Flickr Album URL")
 }
 
 //#endregion
@@ -608,9 +409,7 @@ func getPossibleTistorySiteUrls(url string) (map[string]string, error) {
 	doc.Find(".article img, #content img, div[role=main] img, .section_blogview img").Each(func(i int, s *goquery.Selection) {
 		foundUrl, exists := s.Attr("src")
 		if exists {
-			isTistoryCdnUrl := regexUrlTistoryLegacyWithCDN.MatchString(foundUrl)
-			isTistoryUrl := regexUrlTistoryLegacy.MatchString(foundUrl)
-			if isTistoryCdnUrl == true {
+			if regexUrlTistoryLegacyWithCDN.MatchString(foundUrl) {
 				finalTistoryUrls, _ := getTistoryWithCDNUrls(foundUrl)
 				if len(finalTistoryUrls) > 0 {
 					for finalTistoryUrl := range finalTistoryUrls {
@@ -618,7 +417,7 @@ func getPossibleTistorySiteUrls(url string) (map[string]string, error) {
 						links[finalTistoryUrl] = foundFilename
 					}
 				}
-			} else if isTistoryUrl == true {
+			} else if regexUrlTistoryLegacy.MatchString(foundUrl) {
 				finalTistoryUrls, _ := getLegacyTistoryUrls(foundUrl)
 				if len(finalTistoryUrls) > 0 {
 					for finalTistoryUrl := range finalTistoryUrls {
@@ -649,13 +448,16 @@ type redditThreadObject []struct {
 }
 
 func getRedditPostUrls(link string) (map[string]string, error) {
+	if strings.Contains(link, "?") {
+		link = link[:strings.Index(link, "?")]
+	}
 	redditThread := new(redditThreadObject)
 	headers := make(map[string]string)
 	headers["Accept-Encoding"] = "identity"
 	headers["User-Agent"] = sneakyUserAgent
 	err := getJSONwithHeaders(link+".json", redditThread, headers)
 	if err != nil {
-		return nil, fmt.Errorf("Failed to parse json from reddit post:\t%s", err)
+		return nil, fmt.Errorf("failed to parse json from reddit post:\t%s", err)
 	}
 
 	redditPost := (*redditThread)[0].Data.Children.([]interface{})[0].(map[string]interface{})
@@ -669,31 +471,3 @@ func getRedditPostUrls(link string) (map[string]string, error) {
 }
 
 //#endregion
-
-//#region Mastodon
-
-func getMastodonPostUrls(link string) (map[string]string, error) {
-	var post map[string]interface{}
-	err := getJSON(link+".json", &post)
-	if err != nil {
-		return nil, fmt.Errorf("Failed to parse json from mastodon post:\t%s", err)
-	}
-	// Check for returned error
-	if errmsg, exists := post["error"]; exists {
-		return nil, fmt.Errorf("Mastodon JSON returned an error:\t%s", errmsg)
-	}
-
-	// Check validity
-	if attachments, exists := post["attachment"]; exists {
-		files := make(map[string]string)
-		for _, attachmentObj := range attachments.([]interface{}) {
-			attachment := attachmentObj.(map[string]interface{})
-			files[attachment["url"].(string)] = ""
-		}
-		return files, nil
-	}
-
-	return nil, nil
-}
-
-//#endregion

+ 18 - 52
regex.go

@@ -10,6 +10,7 @@ const (
 	regexpUrlTwitter              = `^http(s?):\/\/pbs(-[0-9]+)?\.twimg\.com\/media\/[^\./]+\.(jpg|png)((\:[a-z]+)?)$`
 	regexpUrlTwitterStatus        = `^http(s?):\/\/(www\.)?twitter\.com\/([A-Za-z0-9-_\.]+\/status\/|statuses\/|i\/web\/status\/)([0-9]+)$`
 	regexpUrlInstagram            = `^http(s?):\/\/(www\.)?instagram\.com\/p\/[^/]+\/(\?[^/]+)?$`
+	regexpUrlInstagramReel        = `^http(s?):\/\/(www\.)?instagram\.com\/reel\/[^/]+\/(\?[^/]+)?$`
 	regexpUrlImgurSingle          = `^http(s?):\/\/(i\.)?imgur\.com\/[A-Za-z0-9]+(\.gifv)?$`
 	regexpUrlImgurAlbum           = `^http(s?):\/\/imgur\.com\/(a\/|gallery\/|r\/[^\/]+\/)[A-Za-z0-9]+(#[A-Za-z0-9]+)?$`
 	regexpUrlStreamable           = `^http(s?):\/\/(www\.)?streamable\.com\/([0-9a-z]+)$`
@@ -17,21 +18,18 @@ const (
 	regexpUrlFlickrPhoto          = `^http(s)?:\/\/(www\.)?flickr\.com\/photos\/([0-9]+)@([A-Z0-9]+)\/([0-9]+)(\/)?(\/in\/album-([0-9]+)(\/)?)?$`
 	regexpUrlFlickrAlbum          = `^http(s)?:\/\/(www\.)?flickr\.com\/photos\/(([0-9]+)@([A-Z0-9]+)|[A-Za-z0-9]+)\/(albums\/(with\/)?|(sets\/)?)([0-9]+)(\/)?$`
 	regexpUrlFlickrAlbumShort     = `^http(s)?:\/\/((www\.)?flickr\.com\/gp\/[0-9]+@[A-Z0-9]+\/[A-Za-z0-9]+|flic\.kr\/s\/[a-zA-Z0-9]+)$`
-	regexpUrlGoogleDrive          = `^http(s?):\/\/drive\.google\.com\/file\/d\/[^/]+\/view$`
-	regexpUrlGoogleDriveFolder    = `^http(s?):\/\/drive\.google\.com\/(drive\/folders\/|open\?id=)([^/]+)$`
 	regexpUrlTistory              = `^http(s?):\/\/t[0-9]+\.daumcdn\.net\/cfile\/tistory\/([A-Z0-9]+?)(\?original)?$`
 	regexpUrlTistoryLegacy        = `^http(s?):\/\/[a-z0-9]+\.uf\.tistory\.com\/(image|original)\/[A-Z0-9]+$`
 	regexpUrlTistoryLegacyWithCDN = `^http(s)?:\/\/[0-9a-z]+.daumcdn.net\/[a-z]+\/[a-zA-Z0-9\.]+\/\?scode=mtistory&fname=http(s?)%3A%2F%2F[a-z0-9]+\.uf\.tistory\.com%2F(image|original)%2F[A-Z0-9]+$`
 	regexpUrlPossibleTistorySite  = `^http(s)?:\/\/[0-9a-zA-Z\.-]+\/(m\/)?(photo\/)?[0-9]+$`
 	regexpUrlRedditPost           = `^http(s?):\/\/(www\.)?reddit\.com\/r\/([0-9a-zA-Z'_]+)?\/comments\/([0-9a-zA-Z'_]+)\/?([0-9a-zA-Z'_]+)?(.*)?$`
-	regexpUrlMastodonPost1        = `^http(s)?:\/\/([0-9a-zA-Z\.-]+)?\/@([0-9a-zA-Z'_]+)?\/([0-9]+)?$`
-	regexpUrlMastodonPost2        = `^http(s)?:\/\/([0-9a-zA-Z\.-]+)?\/web\/statuses\/([0-9]+)?$`
 )
 
 var (
 	regexUrlTwitter              *regexp.Regexp
 	regexUrlTwitterStatus        *regexp.Regexp
 	regexUrlInstagram            *regexp.Regexp
+	regexUrlInstagramReel        *regexp.Regexp
 	regexUrlImgurSingle          *regexp.Regexp
 	regexUrlImgurAlbum           *regexp.Regexp
 	regexUrlStreamable           *regexp.Regexp
@@ -39,94 +37,62 @@ var (
 	regexUrlFlickrPhoto          *regexp.Regexp
 	regexUrlFlickrAlbum          *regexp.Regexp
 	regexUrlFlickrAlbumShort     *regexp.Regexp
-	regexUrlGoogleDrive          *regexp.Regexp
-	regexUrlGoogleDriveFolder    *regexp.Regexp
 	regexUrlTistory              *regexp.Regexp
 	regexUrlTistoryLegacy        *regexp.Regexp
 	regexUrlTistoryLegacyWithCDN *regexp.Regexp
 	regexUrlPossibleTistorySite  *regexp.Regexp
 	regexUrlRedditPost           *regexp.Regexp
-	regexUrlMastodonPost1        *regexp.Regexp
-	regexUrlMastodonPost2        *regexp.Regexp
 )
 
 func compileRegex() error {
 	var err error
 
-	regexUrlTwitter, err = regexp.Compile(regexpUrlTwitter)
-	if err != nil {
+	if regexUrlTwitter, err = regexp.Compile(regexpUrlTwitter); err != nil {
 		return err
 	}
-	regexUrlTwitterStatus, err = regexp.Compile(regexpUrlTwitterStatus)
-	if err != nil {
+	if regexUrlTwitterStatus, err = regexp.Compile(regexpUrlTwitterStatus); err != nil {
 		return err
 	}
-	regexUrlInstagram, err = regexp.Compile(regexpUrlInstagram)
-	if err != nil {
+	if regexUrlInstagram, err = regexp.Compile(regexpUrlInstagram); err != nil {
 		return err
 	}
-	regexUrlImgurSingle, err = regexp.Compile(regexpUrlImgurSingle)
-	if err != nil {
+	if regexUrlInstagramReel, err = regexp.Compile(regexpUrlInstagramReel); err != nil {
 		return err
 	}
-	regexUrlImgurAlbum, err = regexp.Compile(regexpUrlImgurAlbum)
-	if err != nil {
+	if regexUrlImgurSingle, err = regexp.Compile(regexpUrlImgurSingle); err != nil {
 		return err
 	}
-	regexUrlStreamable, err = regexp.Compile(regexpUrlStreamable)
-	if err != nil {
+	if regexUrlImgurAlbum, err = regexp.Compile(regexpUrlImgurAlbum); err != nil {
 		return err
 	}
-	regexUrlGfycat, err = regexp.Compile(regexpUrlGfycat)
-	if err != nil {
+	if regexUrlStreamable, err = regexp.Compile(regexpUrlStreamable); err != nil {
 		return err
 	}
-	regexUrlFlickrPhoto, err = regexp.Compile(regexpUrlFlickrPhoto)
-	if err != nil {
+	if regexUrlGfycat, err = regexp.Compile(regexpUrlGfycat); err != nil {
 		return err
 	}
-	regexUrlFlickrAlbum, err = regexp.Compile(regexpUrlFlickrAlbum)
-	if err != nil {
+	if regexUrlFlickrPhoto, err = regexp.Compile(regexpUrlFlickrPhoto); err != nil {
 		return err
 	}
-	regexUrlFlickrAlbumShort, err = regexp.Compile(regexpUrlFlickrAlbumShort)
-	if err != nil {
+	if regexUrlFlickrAlbum, err = regexp.Compile(regexpUrlFlickrAlbum); err != nil {
 		return err
 	}
-	regexUrlGoogleDrive, err = regexp.Compile(regexpUrlGoogleDrive)
-	if err != nil {
+	if regexUrlFlickrAlbumShort, err = regexp.Compile(regexpUrlFlickrAlbumShort); err != nil {
 		return err
 	}
-	regexUrlGoogleDriveFolder, err = regexp.Compile(regexpUrlGoogleDriveFolder)
-	if err != nil {
+	if regexUrlTistory, err = regexp.Compile(regexpUrlTistory); err != nil {
 		return err
 	}
-	regexUrlTistory, err = regexp.Compile(regexpUrlTistory)
-	if err != nil {
+	if regexUrlTistoryLegacy, err = regexp.Compile(regexpUrlTistoryLegacy); err != nil {
 		return err
 	}
-	regexUrlTistoryLegacy, err = regexp.Compile(regexpUrlTistoryLegacy)
-	if err != nil {
+	if regexUrlTistoryLegacyWithCDN, err = regexp.Compile(regexpUrlTistoryLegacyWithCDN); err != nil {
 		return err
 	}
-	regexUrlTistoryLegacyWithCDN, err = regexp.Compile(regexpUrlTistoryLegacyWithCDN)
-	if err != nil {
+	if regexUrlPossibleTistorySite, err = regexp.Compile(regexpUrlPossibleTistorySite); err != nil {
 		return err
 	}
-	regexUrlPossibleTistorySite, err = regexp.Compile(regexpUrlPossibleTistorySite)
-	if err != nil {
-		return err
-	}
-	regexUrlRedditPost, err = regexp.Compile(regexpUrlRedditPost)
-	if err != nil {
-		return err
-	}
-	regexUrlMastodonPost1, err = regexp.Compile(regexpUrlMastodonPost1)
-	if err != nil {
-		return err
-	}
-	regexUrlMastodonPost2, err = regexp.Compile(regexpUrlMastodonPost2)
-	if err != nil {
+	if regexUrlRedditPost, err = regexp.Compile(regexpUrlRedditPost); err != nil {
 		return err
 	}
 

+ 21 - 53
vars.go

@@ -2,63 +2,31 @@ package main
 
 import (
 	"os"
-
-	"github.com/fatih/color"
 )
 
 const (
-	projectName    = "discord-downloader-go"
-	projectLabel   = "Discord Downloader"
-	projectVersion = "1.6.9-dev"
-	projectIcon    = "https://cdn.discordapp.com/icons/780985109608005703/9dc25f1b91e6d92664590254e0797fad.webp?size=256"
-
-	projectRepo          = "get-got/discord-downloader-go"
-	projectRepoURL       = "https://github.com/" + projectRepo
-	projectReleaseURL    = projectRepoURL + "/releases/latest"
-	projectReleaseApiURL = "https://api.github.com/repos/" + projectRepo + "/releases/latest"
-
-	configFileBase   = "settings"
-	databasePath     = "database"
-	cachePath        = "cache"
-	historyCachePath = cachePath + string(os.PathSeparator) + "history"
-	imgStorePath     = cachePath + string(os.PathSeparator) + "imgStore"
-	constantsPath    = cachePath + string(os.PathSeparator) + "constants.json"
-
-	defaultReact = "✅"
+	projectName     = "discord-downloader-go"
+	projectLabel    = "Discord Downloader GO"
+	projectRepoBase = "get-got/discord-downloader-go"
+	projectRepoURL  = "https://github.com/" + projectRepoBase
+	projectIcon     = "https://cdn.discordapp.com/icons/780985109608005703/9dc25f1b91e6d92664590254e0797fad.webp?size=256"
+	projectVersion  = "2.1.0" // follows Semantic Versioning, (http://semver.org/)
+
+	pathCache           = "cache"
+	pathCacheHistory    = pathCache + string(os.PathSeparator) + "history"
+	pathCacheDuplo      = pathCache + string(os.PathSeparator) + ".duplo"
+	pathCacheInstagram  = pathCache + string(os.PathSeparator) + "instagram.json"
+	pathConstants       = pathCache + string(os.PathSeparator) + "constants.json"
+	pathDatabaseBase    = "database"
+	pathDatabaseBackups = "backups"
+
+	defaultReact   = "✅"
+	limitMsg       = 2000
+	limitEmbedDesc = 4096
 )
 
 var (
-	configFile  string
-	configFileC bool
-)
-
-// Log prefixes aren't to be used for constant messages where context is obvious.
-var (
-	logPrefixSetup = color.HiGreenString("[Setup]")
-
-	logPrefixDebug = color.HiYellowString("[Debug]")
-
-	logPrefixHistory = color.HiGreenString("[History]")
-	logPrefixInfo    = color.CyanString("[Info]")
-
-	logPrefixDatabase    = color.BlueString("[Database]")
-	logPrefixSettings    = color.GreenString("[Settings]")
-	logPrefixVersion     = color.HiMagentaString("[Version]")
-	logPrefixRegex       = color.HiRedString("[Regex]")
-	logPrefixDiscord     = color.HiBlueString("[Discord]")
-	logPrefixTwitter     = color.HiCyanString("[Twitter]")
-	logPrefixGoogleDrive = color.HiGreenString("[Google Drive]")
-
-	logPrefixFileSkip = color.GreenString(">>> SKIPPING FILE:")
-)
-
-func logPrefixDebugLabel(label string) string {
-	return color.HiYellowString("[Debug: %s]", label)
-}
-func logPrefixErrorLabel(label string) string {
-	return color.HiRedString("[Error: %s]", label)
-}
-
-const (
-	fmtBotSendPerm = "Bot does not have permission to send messages in %s"
+	configFileBase = "settings"
+	configFile     string
+	configFileC    bool
 )

Some files were not shown because too many files changed in this diff