Undiscord

Delete all messages in a Discord channel or DM (Bulk deletion)

2022/05/06時点のページです。最新版はこちら。

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name            Undiscord
// @description     Delete all messages in a Discord channel or DM (Bulk deletion)
// @version         5.0.2
// @author          victornpb
// @homepageURL     https://github.com/victornpb/undiscord
// @supportURL      https://github.com/victornpb/undiscord/issues
// @match           https://*.discord.com/app
// @match           https://*.discord.com/channels/*
// @match           https://*.discord.com/login
// @license         MIT
// @namespace       https://github.com/victornpb/deleteDiscordMessages
// @contributionURL https://www.buymeacoffee.com/vitim
// @grant           none
// ==/UserScript==
(function () {
  'use strict';

  var version = "5.0.2";

  var discordStyles = (`
/* undiscord window */
#undiscord.browser {
    box-shadow: var(--elevation-stroke), var(--elevation-high);
    overflow: hidden;
}

#undiscord.container,
#undiscord .container {
    background-color: var(--background-secondary);
    border-radius: 8px;
    box-sizing: border-box;
    cursor: default;
    flex-direction: column;
}

#undiscord .header {
    background-color: var(--background-tertiary);
    height: 48px;
    align-items: center;
    min-height: 48px;
    padding: 0 16px;
    display: flex;
    color: var(--header-secondary);
}

#undiscord .header .icon {
    color: var(--interactive-normal);
    margin-right: 8px;
    flex-shrink: 0;
    width: 24;
    height: 24;
}

#undiscord .header .icon:hover {
    color: var(--interactive-hover);
}

#undiscord .header h3 {
    font-size: 16px;
    line-height: 20px;
    font-weight: 500;
    font-family: var(--font-display);
    color: var(--header-primary);
    flex-shrink: 0;
    margin-right: 16px;
}

#undiscord .header .spacer {
    flex-grow: 1;
}

#undiscord .header .vert-divider {
    width: 1px;
    height: 24px;
    background-color: var(--background-modifier-accent);
    margin-right: 16px;
    flex-shrink: 0;
}

#undiscord legend,
#undiscord label {
    display: block;
    width: 100%;
    color: var(--header-secondary);
    font-size: 12px;
    line-height: 16px;
    font-weight: 500;
    text-transform: uppercase;
    cursor: default;
    font-family: var(--font-display);
    margin-bottom: 8px;
}

#undiscord .multiInput {
    display: flex;
    align-items: center;
    font-size: 16px;
    box-sizing: border-box;
    width: 100%;
    border-radius: 3px;
    color: var(--text-normal);
    background-color: var(--input-background);
    border: none;
    transition: border-color 0.2s ease-in-out 0s;
}

#undiscord .multiInput :first-child {
    flex-grow: 1;
}

#undiscord .multiInput button:last-child {
    margin-right: 4px;
}

#undiscord .input {
    font-size: 16px;
    box-sizing: border-box;
    width: 100%;
    border-radius: 3px;
    color: var(--text-normal);
    background-color: var(--input-background);
    border: none;
    transition: border-color 0.2s ease-in-out 0s;

    padding: 10px;
    height: 40px;
}

#undiscord fieldset {
    margin-top: 16px;
}

#undiscord .input-wrapper {
    display: flex;
    align-items: center;
    font-size: 16px;
    box-sizing: border-box;
    width: 100%;
    border-radius: 3px;
    color: var(--text-normal);
    background-color: var(--input-background);
    border: none;
    transition: border-color 0.2s ease-in-out 0s;
}

#undiscord input[type="text"],
#undiscord input[type="search"],
#undiscord input[type="password"],
#undiscord input[type="datetime-local"],
#undiscord input[type="number"] {
    font-size: 16px;
    box-sizing: border-box;
    width: 100%;
    border-radius: 3px;
    color: var(--text-normal);
    background-color: var(--input-background);
    border: none;
    transition: border-color 0.2s ease-in-out 0s;
    padding: 10px;
    height: 40px;
}

#undiscord .divider,
#undiscord hr {
    border: none;
    margin-bottom: 24px;
    padding-bottom: 4px;
    border-bottom: 1px solid var(--background-modifier-accent);
}

#undiscord .sectionDescription {
    margin-bottom: 16px;
    color: var(--header-secondary);
    font-size: 14px;
    line-height: 20px;
    font-weight: 400;
}

#undiscord a {
    color: var(--text-link);
    text-decoration: none;
}

#undiscord .btn,
#undiscord button {
    position: relative;
    display: flex;
    -webkit-box-pack: center;
    justify-content: center;
    -webkit-box-align: center;
    align-items: center;
    box-sizing: border-box;
    background: none;
    border: none;
    border-radius: 3px;
    font-size: 14px;
    font-weight: 500;
    line-height: 16px;
    padding: 2px 16px;
    user-select: none;

    /* sizeSmall */
    width: 60px;
    height: 32px;
    min-width: 60px;
    min-height: 32px;

    /* lookFilled colorPrimary */
    color: rgb(255, 255, 255);
    background-color: var(--button-secondary-background);
}

#undiscord .sizeMedium {
    width: 96px;
    height: 38px;
    min-width: 96px;
    min-height: 38px;
}

/* lookFilled colorPrimary */
#undiscord .accent {
    background-color: var(--brand-experiment);
}

#undiscord .danger {
    background-color: var(--button-danger-background);
}

#undiscord .positive {
    background-color: var(--button-positive-background);
}


#undiscord .info {
    font-size: 12px;
    line-height: 16px;
    padding: 8px 10px;
    color: var(--text-muted);
}

/* Scrollbar */
#undiscord .scroll::-webkit-scrollbar {
    width: 8px;
    height: 8px;
}

#undiscord .scroll::-webkit-scrollbar-corner {
    background-color: transparent;
}

#undiscord .scroll::-webkit-scrollbar-thumb {
    background-clip: padding-box;
    border: 2px solid transparent;
    border-radius: 4px;
    background-color: var(--scrollbar-thin-thumb);
    min-height: 40px;
}

#undiscord .scroll::-webkit-scrollbar-track {
    border-color: var(--scrollbar-thin-track);
    background-color: var(--scrollbar-thin-track);
    border: 2px solid var(--scrollbar-thin-track);
}

/* fade scrollbar */
#undiscord .scroll::-webkit-scrollbar-thumb,
#undiscord .scroll::-webkit-scrollbar-track {
    visibility: hidden;
}

#undiscord .scroll:hover::-webkit-scrollbar-thumb,
#undiscord .scroll:hover::-webkit-scrollbar-track {
    visibility: visible;
}
`);

  var undiscordStyles = (`
/**** Undiscord Button ****/
#undicord-btn {
    position: relative;
    width: auto;
    height: 24px;
    margin: 0 8px;
    cursor: pointer;
    color: var(--interactive-normal);
    flex: 0 0 auto;
}

#undicord-btn progress {
    position: absolute;
    top: 7px;
    left: 5px;
    width: 14px;
    height: 14px;
}

/**** Undiscord Interface ****/
#undiscord {
    position: fixed;
    z-index: 99;
    top: 44px;
    right: 10px;
    display: flex;
    flex-direction: column;
    width: 610px;
    min-width: 610px;
    max-width: 100%;
    height: 448px;
    min-height: 448px;
    max-height: 100%;
    color: var(--text-normal);
    border-radius: 4px;
    background-color: var(--background-secondary);
    box-shadow: var(--elevation-stroke), var(--elevation-high);
    will-change: top, left, width, height;
}

#undiscord .header .icon {
    cursor: pointer;
}

#undiscord .window-body {
    height: calc(100% - 48px);
}

#undiscord .sidebar {
    overflow: hidden scroll;
    overflow-y: auto;
    width: 270px;
    min-width: 250px;
    height: 100%;
    max-height: 100%;
    padding: 8px;
    background: var(--background-secondary);
}

#undiscord .main {
    display: flex;
    max-width: calc(100% - 250px);
    background-color: var(--background-primary);
    flex-grow: 1;
}

#undiscord #logArea {
    font-family: Consolas, Liberation Mono, Menlo, Courier, monospace;
    font-size: .75rem;
    overflow: auto;
    padding: 10px;
    user-select: text;
    flex-grow: 1;
    flex-grow: 1;
}

#undiscord .tbar {
    padding: 8px;
    background-color: var(--background-secondary-alt);
}

#undiscord .tbar button {
    margin-right: 4px;
    margin-bottom: 4px;
}

#undiscord .footer {
    cursor: se-resize;
}

/**** Elements ****/

#undiscord summary {
    font-size: 16px;
    font-weight: 500;
    line-height: 20px;
    position: relative;
    overflow: hidden;
    margin-bottom: 2px;
    padding: 6px 10px;
    cursor: pointer;
    white-space: nowrap;
    text-overflow: ellipsis;
    color: var(--interactive-normal);
    border-radius: 4px;
    flex-shrink: 0;
}

#undiscord fieldset {
    padding-left: 8px;
}

/* help link */
#undiscord legend a {
    float: right;
    text-transform: initial;
}

#undiscord progress {
    height: 8px;
    margin-top: 4px;
    flex-grow: 1;
    /* background-color: var(--background-primary);
    border-radius: 3px; */
}

/* #undiscord progress::-webkit-progress-value{
    background-color: var(--brand-experiment);
} */

/**** functional classes ****/

#undiscord.redact .priv {
    display: none !important;
}

#undiscord:not(.redact) .mask {
    display: none !important;
}

#undiscord.redact [priv] {
    -webkit-text-security: disc !important;
}

#undiscord :disabled {
    display: none;
}

/**** layout misc ****/

#undiscord,
#undiscord * {
    box-sizing: border-box;
}

#undiscord .col {
    display: flex;
    flex-direction: column;
}

#undiscord .row {
    display: flex;
    flex-direction: row;
    align-items: center;
}

#undiscord .mb1 {
    margin-bottom: 8px;
}
`);

  var buttonHtml = (`
<div id="undicord-btn" tabindex="0" role="button" aria-label="Delete Messages" title="Delete Messages with Undiscord">
    <svg aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
        <path fill="currentColor" d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z"></path>
        <path fill="currentColor" d="M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z"></path>
    </svg>
    <progress style="display:none;"></progress>
</div>
`);

  var undiscordTemplate = (`
<div id="undiscord" class="browser container redact" style="display:none;">
    <div class="header">
        <svg class="icon" aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
            <path fill="currentColor" d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z"></path>
            <path fill="currentColor"
                d="M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z">
            </path>
        </svg>
        <h3>Undiscord</h3>
        <div class="vert-divider"></div>
        <span> Bulk delete messages</span>
        <div class="spacer"></div>
        <div id="hide" class="icon" aria-label="Close" role="button" tabindex="0">
            <svg aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
                <path fill="currentColor"
                    d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z">
                </path>
            </svg>
        </div>
    </div>
    <div class="window-body" style="display: flex; flex-direction: row;">
        <div class="sidebar scroll">
            <details open>
                <summary>General</summary>
                <fieldset>
                    <legend>
                        Author ID
                        <a href="{{WIKI}}/authorId" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="multiInput">
                        <div class="input-wrapper">
                            <input class="input" id="authorId" type="text" priv>
                        </div>
                        <button id="getAuthor">me</button>
                    </div>
                </fieldset>
                <hr>
                <fieldset>
                    <legend>
                        Server ID
                        <a href="{{WIKI}}/guildId" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="multiInput">
                        <div class="input-wrapper">
                            <input class="input" id="guildId" type="text" priv>
                        </div>
                        <button id="getGuild">current</button>
                    </div>
                </fieldset>
                <fieldset>
                    <legend>
                        Channel ID
                        <a href="{{WIKI}}/channelId" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="multiInput mb1">
                        <div class="input-wrapper">
                            <input class="input" id="channelId" type="text" priv>
                        </div>
                        <button id="getChannel">current</button>
                    </div>
                    <div class="sectionDescription">
                        <label class="row"><input id="includeNsfw" type="checkbox">This is a NSFW channel</label>
                    </div>
                </fieldset>
            </details>
            <details>
                <summary>Import</summary>
                <fieldset>
                    <legend>
                        Import JSON
                        <a href="{{WIKI}}/importJson" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="sectionDescription">
                        The import feature will be added back in the future.
                    </div>
                    <div class="">
                        <button id="importJson" disabled>Import</button>
                    </div>
                </fieldset>
            </details>
            <hr>
            <details>
                <summary>Filter</summary>
                <fieldset>
                    <legend>
                        Search
                        <a href="{{WIKI}}/filters" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="input-wrapper">
                        <input id="search" type="text" placeholder="Containing text" priv>
                    </div>
                    <div class="sectionDescription">
                        Only delete messages that contain the text
                    </div>
                    <div class="sectionDescription">
                        <label><input id="hasLink" type="checkbox">has: link</label>
                    </div>
                    <div class="sectionDescription">
                        <label><input id="hasFile" type="checkbox">has: file</label>
                    </div>
                    <div class="sectionDescription">
                        <label><input id="includePinned" type="checkbox">Include pinned</label>
                    </div>
                </fieldset>
                <hr>
                <fieldset>
                    <legend>
                        Pattern
                        <a href="{{WIKI}}/pattern" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="sectionDescription">
                        Delete messages that match the regular expression
                    </div>
                    <div class="input-wrapper">
                        <span class="info">/</span>
                        <input id="pattern" type="text" placeholder="regular expression" priv>
                        <span class="info">/</span>
                    </div>
                </fieldset>
            </details>
            <details>
                <summary>Messages interval</summary>
                <fieldset>
                    <legend>
                        Interval of messages
                        <a href="{{WIKI}}/messageId" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="multiInput mb1">
                        <div class="input-wrapper">
                            <input id="minId" type="text" placeholder="After a message" priv>
                        </div>
                        <button id="pickMessageAfter">select</button>
                    </div>
                    <div class="multiInput">
                        <div class="input-wrapper">
                            <input id="maxId" type="text" placeholder="Before a message" priv>
                        </div>
                        <button id="pickMessageBefore">select</button>
                    </div>
                    <div class="sectionDescription">
                        Specify an interval to delete messages.
                    </div>
                </fieldset>
            </details>
            <details>
                <summary>Date</summary>
                <fieldset>
                    <legend>
                        After date
                        <a href="{{WIKI}}/dateRange" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="input-wrapper mb1">
                        <input id="minDate" type="datetime-local" title="Messages posted AFTER this date">
                    </div>
                    <legend>
                        Before date
                        <a href="{{WIKI}}/dateRange" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="input-wrapper">
                        <input id="maxDate" type="datetime-local" title="Messages posted BEFORE this date">
                    </div>
                    <div class="sectionDescription">
                        Delete messages that were posted between the two dates.
                    </div>
                    <div class="sectionDescription">
                        * Filtering by date doesn't work if you use the "Messages interval".
                    </div>
                </fieldset>
            </details>
            <hr>
            <details>
                <summary>Advanced settings</summary>
                <fieldset>
                    <legend>
                        Search delay
                        <a href="{{WIKI}}/delay" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="input-wrapper">
                        <input id="searchDelay" type="number" value="100" step="100">
                    </div>
                </fieldset>
                <fieldset>
                    <legend>
                        Delete delay
                        <a href="{{WIKI}}/delay" title="Help" target="_blank">help</a>
                    </legend>
                    <div class="input-wrapper">
                        <input id="deleteDelay" type="number" value="1000" step="100">
                    </div>
                    <br>
                    <div class="sectionDescription">
                        This will affect the speed in which the messages are deleted.
                        Use the help link for more information.
                    </div>
                </fieldset>
            </details>
            <hr>
            <div></div>
            <div class="info">
                Undiscord {{VERSION}}
                <br> victornpb
            </div>
        </div>
        <div class="main col">
            <div class="tbar col">
                <div class="row">
                    <button id="start" class="sizeMedium accent">Start</button>
                    <button id="stop" class="sizeMedium danger" disabled>Stop</button>
                    <button id="clear" class="sizeMedium">Clear log</button>
                    <label class="row" title="Hide sensitive information on your screen for taking screenshots">
                        <input id="redact" type="checkbox" checked> Streamer mode
                    </label>
                </div>
                <div class="row">
                    <progress id="progressBar" value="-1"></progress>
                </div>
            </div>
            <pre id="logArea" class="logarea scroll">
                <center>
                    <div><a href="https://github.com/victornpb/undiscord/discussions/350" target="_blank" style="color: var(--text-brand);">Tell me what you think about this update</a></div>
                    <div>Star <a href="{{HOME}}" target="_blank">this project</a> on GitHub!</div>
                    <div><a href="{{HOME}}/discussions" target="_blank">Issues or help</a></div>
                </center>
            </pre>
            <div class="tbar footer row">
                <label>
                    <input id="autoScroll" type="checkbox" checked> Auto scroll
                </label>
                <span id="progressPercent"></span>
            </div>
        </div>
    </div>
</div>
`);

  /**
   * Delete all messages in a Discord channel or DM
   * @param {string} authToken Your authorization token
   * @param {string} authorId Author of the messages you want to delete
   * @param {string} guildId Server were the messages are located
   * @param {string} channelId Channel were the messages are located
   * @param {string} minId Only delete messages after this, leave blank do delete all
   * @param {string} maxId Only delete messages before this, leave blank do delete all
   * @param {string} content Filter messages that contains this text content
   * @param {boolean} hasLink Filter messages that contains link
   * @param {boolean} hasFile Filter messages that contains file
   * @param {boolean} includeNsfw Search in NSFW channels
   * @param {function(string, Array)} extLogger Function for logging
   * @param {function} stopHndl stopHndl used for stopping
   * @author Victornpb <https://www.github.com/victornpb>
   * @see https://github.com/victornpb/undiscord
   */
  async function deleteMessages(authToken, authorId, guildId, channelId, minId, maxId, content, hasLink, hasFile, includeNsfw, includePinned, pattern, searchDelay, deleteDelay, extLogger, stopHndl, onProgress) {
    const start = new Date();
    let delCount = 0;
    let failCount = 0;
    let avgPing;
    let lastPing;
    let grandTotal;
    let throttledCount = 0;
    let throttledTotalTime = 0;
    let offset = 0;
    let iterations = -1;

    const wait = async ms => new Promise(done => setTimeout(done, ms));
    const msToHMS = s => `${s / 3.6e6 | 0}h ${(s % 3.6e6) / 6e4 | 0}m ${(s % 6e4) / 1000 | 0}s`;
    const escapeHTML = html => html.replace(/[&<"']/g, m => ({ '&': '&amp;', '<': '&lt;', '"': '&quot;', '\'': '&#039;' })[m]);
    const redact = str => `<span class="priv">${escapeHTML(str)}</span><span class="mask">REDACTED</span>`;
    const queryString = params => params.filter(p => p[1] !== undefined).map(p => p[0] + '=' + encodeURIComponent(p[1])).join('&');
    const ask = async msg => new Promise(resolve => setTimeout(() => resolve(window.confirm(msg)), 10));
    const printDelayStats = () => log.verb(`Delete delay: ${deleteDelay}ms, Search delay: ${searchDelay}ms`, `Last Ping: ${lastPing}ms, Average Ping: ${avgPing | 0}ms`);
    const toSnowflake = (date) => /:/.test(date) ? ((new Date(date).getTime() - 1420070400000) * Math.pow(2, 22)) : date;

    const log = {
      debug() { return extLogger ? extLogger('debug', arguments) : console.debug.apply(console, arguments); },
      info() { return extLogger ? extLogger('info', arguments) : console.info.apply(console, arguments); },
      verb() { return extLogger ? extLogger('verb', arguments) : console.log.apply(console, arguments); },
      warn() { return extLogger ? extLogger('warn', arguments) : console.warn.apply(console, arguments); },
      error() { return extLogger ? extLogger('error', arguments) : console.error.apply(console, arguments); },
      success() { return extLogger ? extLogger('success', arguments) : console.info.apply(console, arguments); },
    };

    async function recurse() {
      let API_SEARCH_URL;
      if (guildId === '@me') {
        API_SEARCH_URL = `https://discord.com/api/v9/channels/${channelId}/messages/`; // DMs
      }
      else {
        API_SEARCH_URL = `https://discord.com/api/v9/guilds/${guildId}/messages/`; // Server
      }

      const headers = {
        'Authorization': authToken
      };

      if (onProgress) onProgress(-1, 1);

      let resp;
      try {
        const s = Date.now();
        resp = await fetch(API_SEARCH_URL + 'search?' + queryString([
          ['author_id', authorId || undefined],
          ['channel_id', (guildId !== '@me' ? channelId : undefined) || undefined],
          ['min_id', minId ? toSnowflake(minId) : undefined],
          ['max_id', maxId ? toSnowflake(maxId) : undefined],
          ['sort_by', 'timestamp'],
          ['sort_order', 'desc'],
          ['offset', offset],
          ['has', hasLink ? 'link' : undefined],
          ['has', hasFile ? 'file' : undefined],
          ['content', content || undefined],
          ['include_nsfw', includeNsfw ? true : undefined],
        ]), { headers });
        lastPing = (Date.now() - s);
        avgPing = avgPing > 0 ? (avgPing * 0.9) + (lastPing * 0.1) : lastPing;
      } catch (err) {
        return log.error('Search request threw an error:', err);
      }

      // not indexed yet
      if (resp.status === 202) {
        const w = (await resp.json()).retry_after * 1000;
        throttledCount++;
        throttledTotalTime += w;
        log.warn(`This channel wasn't indexed, waiting ${w}ms for discord to index it...`);
        await wait(w);
        return await recurse();
      }

      if (!resp.ok) {
        // searching messages too fast
        if (resp.status === 429) {
          const w = (await resp.json()).retry_after * 1000;
          throttledCount++;
          throttledTotalTime += w;
          searchDelay += w; // increase delay
          log.warn(`Being rate limited by the API for ${w}ms! Increasing search delay...`);
          printDelayStats();
          log.verb(`Cooling down for ${w * 2}ms before retrying...`);

          await wait(w * 2);
          return await recurse();
        } else {
          return log.error(`Error searching messages, API responded with status ${resp.status}!\n`, await resp.json());
        }
      }

      let regex;

      try {
        regex = new RegExp(pattern);
      } catch (e) {
        log.warn('Ignoring RegExp because pattern is malformed');
      }

      const data = await resp.json();
      const total = data.total_results;
      if (!grandTotal) grandTotal = total;
      const discoveredMessages = data.messages.map(convo => convo.find(message => message.hit === true));
      const messagesToDelete = discoveredMessages.filter(msg => {
        return (msg.type === 0 || (msg.type >= 6 && msg.type <= 21) || (msg.pinned && includePinned)) && (!regex || msg.content.match(regex));
      });
      const skippedMessages = discoveredMessages.filter(msg => !messagesToDelete.find(m => m.id === msg.id));

      const end = () => {
        log.success(`Ended at ${new Date().toLocaleString()}! Total time: ${msToHMS(Date.now() - start.getTime())}`);
        printDelayStats();
        log.verb(`Rate Limited: ${throttledCount} times. Total time throttled: ${msToHMS(throttledTotalTime)}.`);
        log.debug(`Deleted ${delCount} messages, ${failCount} failed.\n`);
      };

      const etr = msToHMS((searchDelay * Math.round(total / 25)) + ((deleteDelay + avgPing) * total));
      log.info(`Total messages found: ${data.total_results}`, `(Messages in current page: ${data.messages.length}, To be deleted: ${messagesToDelete.length}, System: ${skippedMessages.length})`, `offset: ${offset}`);
      printDelayStats();
      log.verb(`Estimated time remaining: ${etr}`);


      if (messagesToDelete.length > 0 || skippedMessages.length > 0) {

        if (++iterations < 1) {
          log.verb('Waiting for your confirmation...');
          if (!await ask(`Do you want to delete ~${total} messages?\nEstimated time: ${etr}\n\n---- Preview ----\n` +
                      messagesToDelete.map(m => `${m.author.username}#${m.author.discriminator}: ${m.attachments.length ? '[ATTACHMENTS]' : m.content}`).join('\n')))
            return end(log.error('Aborted by you!'));
          log.verb('OK');
        }

        for (let i = 0; i < messagesToDelete.length; i++) {
          const message = messagesToDelete[i];
          if (stopHndl && stopHndl()) return end(log.error('Stopped by you!'));

          log.debug(`${((delCount + 1) / grandTotal * 100).toFixed(2)}% (${delCount + 1}/${grandTotal})`,
            `Deleting ID:${redact(message.id)} <b>${redact(message.author.username + '#' + message.author.discriminator)} <small>(${redact(new Date(message.timestamp).toLocaleString())})</small>:</b> <i>${redact(message.content).replace(/\n/g, '↵')}</i>`,
            message.attachments.length ? redact(JSON.stringify(message.attachments)) : '');
          if (onProgress) onProgress(delCount + 1, grandTotal);

          let resp;
          try {
            const s = Date.now();
            const API_DELETE_URL = `https://discord.com/api/v9/channels/${message.channel_id}/messages/${message.id}`;
            resp = await fetch(API_DELETE_URL, {
              headers,
              method: 'DELETE'
            });
            lastPing = (Date.now() - s);
            avgPing = (avgPing * 0.9) + (lastPing * 0.1);
            delCount++;
          } catch (err) {
            log.error('Delete request throwed an error:', err);
            log.verb('Related object:', redact(JSON.stringify(message)));
            failCount++;
          }

          if (!resp.ok) {
            // deleting messages too fast
            if (resp.status === 429) {
              const w = (await resp.json()).retry_after * 1000;
              throttledCount++;
              throttledTotalTime += w;
              deleteDelay = w; // increase delay
              log.warn(`Being rate limited by the API for ${w}ms! Adjusted delete delay to ${deleteDelay}ms.`);
              printDelayStats();
              log.verb(`Cooling down for ${w * 2}ms before retrying...`);
              await wait(w * 2);
              i--; // retry
            } else {
              log.error(`Error deleting message, API responded with status ${resp.status}!`, await resp.json());
              log.verb('Related object:', redact(JSON.stringify(message)));
              failCount++;
            }
          }

          await wait(deleteDelay);
        }

        if (skippedMessages.length > 0) {
          grandTotal -= skippedMessages.length;
          offset += skippedMessages.length;
          log.verb(`Found ${skippedMessages.length} system messages! Decreasing grandTotal to ${grandTotal} and increasing offset to ${offset}.`);
        }

        log.verb(`Searching next messages in ${searchDelay}ms...`, (offset ? `(offset: ${offset})` : ''));
        await wait(searchDelay);

        if (stopHndl && stopHndl()) return end(log.error('Stopped by you!'));

        return await recurse();
      } else {
        if (total - offset > 0) log.warn('Ended because API returned an empty page.');
        return end();
      }
    }

    log.success(`\nStarted at ${start.toLocaleString()}`);
    log.debug(`authorId="${redact(authorId)}" guildId="${redact(guildId)}" channelId="${redact(channelId)}" minId="${redact(minId)}" maxId="${redact(maxId)}" hasLink=${!!hasLink} hasFile=${!!hasFile}`);
    if (onProgress) onProgress(null, 1);
    return await recurse();
  }

  class Drag {
    /**
       * Make an element draggable/resizable
       * @param {Element} targetElm The element that will be dragged/resized
       * @param {Element} handleElm The element that will listen to events (handdle/grabber)
       * @param {object} [options] Options
       * @param {string} [options.mode="move"] Define the type of operation (move/resize)
       * @param {number} [options.minWidth=200] Minimum width allowed to resize
       * @param {number} [options.maxWidth=Infinity] Maximum width allowed to resize
       * @param {number} [options.minHeight=100] Maximum height allowed to resize
       * @param {number} [options.maxHeight=Infinity] Maximum height allowed to resize
       * @param {string} [options.draggingClass="drag"] Class added to targetElm while being dragged
       * @param {boolean} [options.useMouseEvents=true] Use mouse events
       * @param {boolean} [options.useTouchEvents=true] Use touch events
       *
       * @author Victor N. wwww.vitim.us
       */
    constructor(targetElm, handleElm, options) {
      this.options = Object.assign({
        mode: 'move',

        minWidth: 200,
        maxWidth: Infinity,
        minHeight: 100,
        maxHeight: Infinity,
        xAxis: true,
        yAxis: true,

        draggingClass: 'drag',

        useMouseEvents: true,
        useTouchEvents: true,
      }, options);

      // Public properties
      this.minWidth = this.options.minWidth;
      this.maxWidth = this.options.maxWidth;
      this.minHeight = this.options.minHeight;
      this.maxHeight = this.options.maxHeight;
      this.xAxis = this.options.xAxis;
      this.yAxis = this.options.yAxis;
      this.draggingClass = this.options.draggingClass;

      /** @private */
      this._targetElm = targetElm;
      /** @private */
      this._handleElm = handleElm;

      const moveOp = (x, y) => {
        let l = x - offLeft;
        if (x - offLeft < 0) l = 0; //offscreen <-
        else if (x - offRight > vw) l = vw - this._targetElm.clientWidth; //offscreen ->
        let t = y - offTop;
        if (y - offTop < 0) t = 0; //offscreen /\
        else if (y - offBottom > vh) t = vh - this._targetElm.clientHeight; //offscreen \/

        if(this.xAxis) this._targetElm.style.left = `${l}px`;
        if(this.yAxis) this._targetElm.style.top = `${t}px`;
        // NOTE: profilling on chrome translate wasn't faster than top/left as expected. And it also permanently creates a new layer, increasing vram usage.
        // this._targetElm.style.transform = `translate(${l}px, ${t}px)`;
      };

      const resizeOp = (x, y) => {
        let w = x - this._targetElm.offsetLeft - offRight;
        if (x - offRight > vw) w = Math.min(vw - this._targetElm.offsetLeft, this.maxWidth); //offscreen ->
        else if (x - offRight - this._targetElm.offsetLeft > this.maxWidth) w = this.maxWidth; //max width
        else if (x - offRight - this._targetElm.offsetLeft < this.minWidth) w = this.minWidth; //min width
        let h = y - this._targetElm.offsetTop - offBottom;
        if (y - offBottom > vh) h = Math.min(vh - this._targetElm.offsetTop, this.maxHeight); //offscreen \/
        else if (y - offBottom - this._targetElm.offsetTop > this.maxHeight) h = this.maxHeight; //max height
        else if (y - offBottom - this._targetElm.offsetTop < this.minHeight) h = this.minHeight; //min height

        if(this.xAxis) this._targetElm.style.width = `${w}px`;
        if(this.yAxis) this._targetElm.style.height = `${h}px`;
      };

      // define which operation is performed on drag
      const operation = this.options.mode === 'move' ? moveOp : resizeOp;

      // offset from the initial click to the target boundaries
      let offTop, offLeft, offBottom, offRight;

      let vw = window.innerWidth;
      let vh = window.innerHeight;


      function dragStartHandler(e) {
        const touch = e.type === 'touchstart';

        if ((e.buttons === 1 || e.which === 1) || touch) {
          e.preventDefault();

          const x = touch ? e.touches[0].clientX : e.clientX;
          const y = touch ? e.touches[0].clientY : e.clientY;

          const targetOffset = this._targetElm.getBoundingClientRect();

          //offset from the click to the top-left corner of the target (drag)
          offTop = y - targetOffset.y;
          offLeft = x - targetOffset.x;
          //offset from the click to the bottom-right corner of the target (resize)
          offBottom = y - (targetOffset.y + targetOffset.height);
          offRight = x - (targetOffset.x + targetOffset.width);

          vw = window.innerWidth;
          vh = window.innerHeight;

          if (this.options.useMouseEvents) {
            document.addEventListener('mousemove', this._dragMoveHandler);
            document.addEventListener('mouseup', this._dragEndHandler);
          }
          if (this.options.useTouchEvents) {
            document.addEventListener('touchmove', this._dragMoveHandler, {
              passive: false,
            });
            document.addEventListener('touchend', this._dragEndHandler);
          }

          this._targetElm.classList.add(this.draggingClass);
        }
      }

      function dragMoveHandler(e) {
        e.preventDefault();
        let x, y;

        const touch = e.type === 'touchmove';
        if (touch) {
          const t = e.touches[0];
          x = t.clientX;
          y = t.clientY;
        } else { //mouse

          // If the button is not down, dispatch a "fake" mouse up event, to stop listening to mousemove
          // This happens when the mouseup is not captured (outside the browser)
          if ((e.buttons || e.which) !== 1) {
            this._dragEndHandler();
            return;
          }

          x = e.clientX;
          y = e.clientY;
        }

        operation(x, y);
      }

      function dragEndHandler(e) {
        if (this.options.useMouseEvents) {
          document.removeEventListener('mousemove', this._dragMoveHandler);
          document.removeEventListener('mouseup', this._dragEndHandler);
        }
        if (this.options.useTouchEvents) {
          document.removeEventListener('touchmove', this._dragMoveHandler);
          document.removeEventListener('touchend', this._dragEndHandler);
        }
        this._targetElm.classList.remove(this.draggingClass);
      }

      // We need to bind the handlers to this instance and expose them to methods enable and destroy
      /** @private */
      this._dragStartHandler = dragStartHandler.bind(this);
      /** @private */
      this._dragMoveHandler = dragMoveHandler.bind(this);
      /** @private */
      this._dragEndHandler = dragEndHandler.bind(this);

      this.enable();
    }

    /**
     * Turn on the drag and drop of the instancea
     * @memberOf Drag
     */
    enable() {
      // this.destroy(); // prevent events from getting binded twice
      if (this.options.useMouseEvents) this._handleElm.addEventListener('mousedown', this._dragStartHandler);
      if (this.options.useTouchEvents) this._handleElm.addEventListener('touchstart', this._dragStartHandler, { passive: false });
    }
    /**
     * Teardown all events bound to the document and elements
     * You can resurrect this instance by calling enable()
     * @memberOf Drag
     */
    destroy() {
      this._targetElm.classList.remove(this.draggingClass);

      if (this.options.useMouseEvents) {
        this._handleElm.removeEventListener('mousedown', this._dragStartHandler);
        document.removeEventListener('mousemove', this._dragMoveHandler);
        document.removeEventListener('mouseup', this._dragEndHandler);
      }
      if (this.options.useTouchEvents) {
        this._handleElm.removeEventListener('touchstart', this._dragStartHandler);
        document.removeEventListener('touchmove', this._dragMoveHandler);
        document.removeEventListener('touchend', this._dragEndHandler);
      }
    }
  }

  function createElm(html) {
    const temp = document.createElement('div');
    temp.innerHTML = html;
    return temp.removeChild(temp.firstElementChild);
  }

  function insertCss(css) {
    const style = document.createElement('style');
    style.appendChild(document.createTextNode(css));
    document.head.appendChild(style);
    return style;
  }

  const messagePickerCss = `
body.undiscord-pick-message [data-list-id="chat-messages"] {
  background-color: var(--background-secondary-alt);
  box-shadow: inset 0 0 0px 2px var(--button-outline-brand-border);
}

body.undiscord-pick-message [id^="message-content-"]:hover {
  cursor: pointer;
  cursor: cell;
  background: var(--background-message-automod-hover);
}
body.undiscord-pick-message [id^="message-content-"]:hover::after {
  position: absolute;
  top: calc(50% - 11px);
  left: 4px;
  z-index: 1;
  width: 65px;
  height: 22px;
  line-height: 22px;
  font-family: var(--font-display);
  background-color: var(--button-secondary-background);
  color: var(--header-secondary);
  font-size: 12px;
  font-weight: 500;
  text-transform: uppercase;
  text-align: center;
  border-radius: 3px;
  content: 'This 👉';
}
body.undiscord-pick-message.before [id^="message-content-"]:hover::after {
  content: 'Before 👆';
}
body.undiscord-pick-message.after [id^="message-content-"]:hover::after {
  content: 'After 👇';
}
`;

  const messagePicker = {
    init() {
      insertCss(messagePickerCss);
    },
    grab(auxiliary) {
      return new Promise((resolve, reject) => {
        document.body.classList.add('undiscord-pick-message');
        if (auxiliary) document.body.classList.add(auxiliary);
        function clickHandler(e) {
          const message = e.target.closest('[id^="message-content-"]');
          if (message) {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            if (auxiliary) document.body.classList.remove(auxiliary);
            document.body.classList.remove('undiscord-pick-message');
            document.removeEventListener('click', clickHandler);
            try {
              resolve(message.id.match(/message-content-(\d+)/)[1]);
            } catch (e) {
              resolve(null);
            }
          }
        }
        document.addEventListener('click', clickHandler);
      });
    }
  };

  var messagePicker$1 = messagePicker;
  window.messagePicker = messagePicker;

  function getToken() {
    window.dispatchEvent(new Event('beforeunload'));
    const LS = document.body.appendChild(document.createElement('iframe')).contentWindow.localStorage;
    return JSON.parse(LS.token);
  }

  function getAuthorId() {
    const LS = document.body.appendChild(document.createElement('iframe')).contentWindow.localStorage;
    return JSON.parse(LS.user_id_cache);
  }

  function getGuildId() {
    const m = location.href.match(/channels\/([\w@]+)\/(\d+)/);
    if (m) return m[1];
    else alert('Could not the Guild ID!\nPlease make sure you are on a Server or DM.');
  }

  function getChannelId() {
    const m = location.href.match(/channels\/([\w@]+)\/(\d+)/);
    if (m) return m[2];
    else alert('Could not the Channel ID!\nPlease make sure you are on a Channel or DM.');
  }

  // ------------------------- User interface ------------------------------ //

  const HOME = 'https://github.com/victornpb/undiscord';
  const WIKI = 'https://github.com/victornpb/undiscord/wiki';

  const $ = s => undiscordWindow.querySelector(s);

  let undiscordWindow;
  let undiscordBtn;

  function initUI() {

    insertCss(discordStyles);
    insertCss(undiscordStyles);

    function replaceInterpolations(str, obj, removeMissing = false) {
      return str.replace(/\{\{([\w_]+)\}\}/g, (m, key) => obj[key] || (removeMissing ? '' : m));
    }

    const templateVariables = {
      VERSION: version,
      HOME,
      WIKI,
    };

    // create undiscord window
    const undiscordUI = replaceInterpolations(undiscordTemplate, templateVariables);
    undiscordWindow = createElm(undiscordUI);
    document.body.appendChild(undiscordWindow);

    new Drag(undiscordWindow, $('.header'), { mode: 'move' });
    new Drag(undiscordWindow, $('.footer'), { mode: 'resize' });

    // create undiscord button
    undiscordBtn = createElm(buttonHtml);
    undiscordBtn.onclick = toggleWindow;
    function mountBtn() {
      const toolbar = document.querySelector('#app-mount [class^=toolbar]');
      if (toolbar) toolbar.appendChild(undiscordBtn);
    }
    mountBtn();

    // watch for changes and re-mount button if necessary
    const discordElm = document.querySelector('#app-mount');
    let observerThrottle = null;
    const observer = new MutationObserver((_mutationsList, _observer) => {
      if (observerThrottle) return;
      observerThrottle = setTimeout(() => {
        observerThrottle = null;
        if (!discordElm.contains(undiscordBtn)) mountBtn(); // re-mount the button to the toolbar
      }, 3000);
    });
    observer.observe(discordElm, { attributes: false, childList: true, subtree: true });

    function toggleWindow() {
      if (undiscordWindow.style.display !== 'none') {
        undiscordWindow.style.display = 'none';
        undiscordBtn.style.color = 'var(--interactive-normal)';
      }
      else {
        undiscordWindow.style.display = '';
        undiscordBtn.style.color = 'var(--interactive-active)';
      }
    }

    messagePicker$1.init();

    // register event listeners
    $('#hide').onclick = toggleWindow;
    $('button#start').onclick = start;
    $('button#stop').onclick = stop;
    $('button#clear').onclick = () => $('#logArea').innerHTML = '';
    $('button#getAuthor').onclick = () => $('input#authorId').value = getAuthorId();
    $('button#getGuild').onclick = () => {
      const guildId = $('input#guildId').value = getGuildId();
      if (guildId === '@me') $('input#channelId').value = getChannelId();
    };
    $('button#getChannel').onclick = () => {
      $('input#channelId').value = getChannelId();
      $('input#guildId').value = getGuildId();
    };
    $('#redact').onchange = () => {
      const b = undiscordWindow.classList.toggle('redact');
      if (b) alert('This mode will attempt to hide personal information, so you can screen share / take screenshots.\nAlways double check you are not sharing sensitive information!');
    };

    $('#pickMessageAfter').onclick = async () => {
      // alert('Select a message on the chat.\nThe message below it will be deleted.');
      const id = await messagePicker$1.grab('after');
      if (id) $('input#minId').value = id;
    };
    $('#pickMessageBefore').onclick = async () => {
      // alert('Select a message on the chat.\nThe message above it will be deleted.');
      const id = await messagePicker$1.grab('before');
      if (id) $('input#maxId').value = id;
    };

    // const fileSelection = $('input#importJson');
    // fileSelection.onchange = () => {
    //   const files = fileSelection.files;
    //   const channelIdField = $('input#channelId');
    //   if (files.length > 0) {
    //     const file = files[0];
    //     file.text().then(text => {
    //       let json = JSON.parse(text);
    //       let channels = Object.keys(json);
    //       channelIdField.value = channels.join(',');
    //     });
    //   }
    // };

  }

  let _stopFlag = false;
  const stopHndl = () => _stopFlag;

  async function start() {
    console.log('start');
    _stopFlag = false;

    // general
    const authToken = getToken();
    const authorId = $('input#authorId').value.trim();
    const guildId = $('input#guildId').value.trim();
    const channelIds = $('input#channelId').value.trim().split(/\s*,\s*/);
    const includeNsfw = $('input#includeNsfw').checked;
    // filter
    const content = $('input#search').value.trim();
    const hasLink = $('input#hasLink').checked;
    const hasFile = $('input#hasFile').checked;
    const includePinned = $('input#includePinned').checked;
    const pattern = $('input#pattern').value;
    // message interval
    const minId = $('input#minId').value.trim();
    const maxId = $('input#maxId').value.trim();
    // date range
    const minDate = $('input#minDate').value.trim();
    const maxDate = $('input#maxDate').value.trim();
    //advanced
    const searchDelay = parseInt($('input#searchDelay').value.trim());
    const deleteDelay = parseInt($('input#deleteDelay').value.trim());

    // progress handler
    const progress = $('#progressBar');
    const progress2 = undiscordBtn.querySelector('progress');
    const percent = $('#progressPercent');
    const onProg = (value, max) => {
      if (value && max && value > max) max = value;
      progress.setAttribute('max', max);
      progress2.setAttribute('max', max);
      progress.value = value;
      progress2.value = value;
      progress.style.display = max ? '' : 'none';
      progress2.style.display = max ? '' : 'none';
      percent.style.display = value && max ? '' : 'none';
      percent.innerHTML = value >= 0 && max ? Math.round(value / max * 100) + '%' : '';
      // indeterminate progress bar
      if (value === -1) {
        progress.removeAttribute('value');
        progress2.removeAttribute('value');
        percent.innerHTML = '...';
      }
    };

    let logArea = $('#logArea');
    let autoScroll = $('#autoScroll');
    const logger = (type = '', args) => {
      const style = { '': '', info: 'color:#00b0f4;', verb: 'color:#72767d;', warn: 'color:#faa61a;', error: 'color:#f04747;', success: 'color:#43b581;' }[type];
      logArea.insertAdjacentHTML('beforeend', `<div style="${style}">${Array.from(args).map(o => typeof o === 'object' ? JSON.stringify(o, o instanceof Error && Object.getOwnPropertyNames(o)) : o).join('\t')}</div>`);
      if (autoScroll.checked) logArea.querySelector('div:last-child').scrollIntoView(false);
    };

    logArea.innerHTML = '';

    // validate input
    if (!authToken) return logger('error', ['Could not detect the authorization token!']) || logger('info', ['Please make sure Undiscord is up to date']);
    else if (!authorId) return logger('error', ['You must provide an Author ID!']);
    else if (!guildId) return logger('error', ['You must provide a Server ID!']);

    for (let i = 0; i < channelIds.length; i++) {
      $('#start').disabled = true;
      $('#stop').disabled = false;
      await deleteMessages(authToken, authorId, guildId, channelIds[i], minId || minDate, maxId || maxDate, content, hasLink, hasFile, includeNsfw, includePinned, pattern, searchDelay, deleteDelay, logger, stopHndl, onProg);
      stop(); // clear the running state
    }

  }

  function stop() {
    _stopFlag = true;
    $('#start').disabled = false;
    $('#stop').disabled = true;

    $('#progressBar').style.display = 'none';
    $('#progressPercent').style.display = 'none';
    undiscordBtn.querySelector('progress').style.display = 'none';
  }

  initUI();


  // ---- END Undiscord ----

})();