diff --git a/main.js b/main.js index 073ab5c3..7fae6973 100644 --- a/main.js +++ b/main.js @@ -17,12 +17,12 @@ // @updateURL https://update.greasyfork.org/scripts/406540/Undiscord.meta.js // ==/UserScript== (function () { - 'use strict'; + "use strict"; - /* rollup-plugin-baked-env */ - const VERSION = "5.2.3"; + /* rollup-plugin-baked-env */ + const VERSION = "5.2.3"; - var themeCss = (` + var themeCss = ` /* undiscord window */ #undiscord.browser { box-shadow: var(--elevation-stroke), var(--elevation-high); overflow: hidden; } #undiscord.container, @@ -91,9 +91,10 @@ #undiscord .log-warn { color: #faa61a; } #undiscord .log-error { color: #f04747; } #undiscord .log-success { color: #43b581; } -`); +#undiscord #token {-webkit-text-security: disc; } +`; - var mainCss = (` + var mainCss = ` /**** 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: 23px; left: -4px; width: 32px; height: 12px; display: none; } @@ -122,9 +123,9 @@ #undiscord progress { height: 8px; margin-top: 4px; flex-grow: 1; } #undiscord .importJson { display: flex; flex-direction: row; } #undiscord .importJson button { margin-left: 5px; width: fit-content; } -`); +`; - var dragCss = (` + var dragCss = ` [name^="grab-"] { position: absolute; --size: 6px; --corner-size: 16px; --offset: -1px; z-index: 9; } [name^="grab-"]:hover{ background: rgba(128,128,128,0.1); } [name="grab-t"] { top: 0px; left: var(--corner-size); right: var(--corner-size); height: var(--size); margin-top: var(--offset); cursor: ns-resize; } @@ -136,9 +137,9 @@ [name="grab-tr"] { top: 0px; right: 0px; width: var(--corner-size); height: var(--corner-size); margin-top: var(--offset); margin-right: var(--offset); cursor: nesw-resize; } [name="grab-br"] { bottom: 0px; right: 0px; width: var(--corner-size); height: var(--corner-size); margin-bottom: var(--offset); margin-right: var(--offset); cursor: nwse-resize; } [name="grab-bl"] { bottom: 0px; left: 0px; width: var(--corner-size); height: var(--corner-size); margin-bottom: var(--offset); margin-left: var(--offset); cursor: nesw-resize; } -`); +`; - var buttonHtml = (` + var buttonHtml = `
@@ -146,9 +147,9 @@
-`); +`; - var undiscordTemplate = (` + var undiscordTemplate = `
- + +
+
+

@@ -313,7 +323,7 @@
- Delete messages that were posted between the two dates. + Delete messages that were posted between the two dates. Make sure you enter both a date AND time, otherwise this will not work.
* Filtering by date doesn't work if you use the "Messages interval". @@ -329,7 +339,7 @@ help
- +
@@ -339,7 +349,7 @@ help
- +

@@ -348,6 +358,7 @@ Use the help link for more information.
+
@@ -356,7 +367,7 @@
- +
@@ -394,6 +405,9 @@ -`); +`; - const log = { - debug() { return logFn ? logFn('debug', arguments) : console.debug.apply(console, arguments); }, - info() { return logFn ? logFn('info', arguments) : console.info.apply(console, arguments); }, - verb() { return logFn ? logFn('verb', arguments) : console.log.apply(console, arguments); }, - warn() { return logFn ? logFn('warn', arguments) : console.warn.apply(console, arguments); }, - error() { return logFn ? logFn('error', arguments) : console.error.apply(console, arguments); }, - success() { return logFn ? logFn('success', arguments) : console.info.apply(console, arguments); }, + const log = { + debug() { + return logFn + ? logFn("debug", arguments) + : console.debug.apply(console, arguments); + }, + info() { + return logFn + ? logFn("info", arguments) + : console.info.apply(console, arguments); + }, + verb() { + return logFn + ? logFn("verb", arguments) + : console.log.apply(console, arguments); + }, + warn() { + return logFn + ? logFn("warn", arguments) + : console.warn.apply(console, arguments); + }, + error() { + return logFn + ? logFn("error", arguments) + : console.error.apply(console, arguments); + }, + success() { + return logFn + ? logFn("success", arguments) + : console.info.apply(console, arguments); + }, + }; + + var logFn; // custom console.log function + const setLogFn = (fn) => { + logFn = fn; + }; + + // Web Worker code as a string + const workerScript = ` + self.addEventListener('message', function(e) { + const ms = e.data; + setTimeout(() => { + self.postMessage('done'); + }, ms); + }); + `; + // Create a Blob URL for the Web Worker + const blob = new Blob([workerScript], { type: "application/javascript" }); + const workerUrl = URL.createObjectURL(blob); + + // Helpers + const wait = (ms) => { + return new Promise((resolve) => { + const worker = new Worker(workerUrl); + let start = Date.now(); + worker.postMessage(ms); + worker.addEventListener("message", function (e) { + if (e.data === "done") { + let delay = Date.now() - start - ms; + if (delay > 100) + console.warn( + `This action was delayed ${delay}ms more than it should've, make sure you don't have too many tabs open!`, + ); + resolve(); + worker.terminate(); + } + }); + worker.addEventListener("error", () => { + worker.terminate(); + resolve(); + }); + }); + }; + const msToHMS = (s) => + `${(s / 3.6e6) | 0}h ${((s % 3.6e6) / 6e4) | 0}m ${((s % 6e4) / 1000) | 0}s`; + const escapeHTML = (html) => + String(html).replace( + /[&<"']/g, + (m) => ({ "&": "&", "<": "<", '"': """, "'": "'" })[m], + ); + const redact = (str) => `${escapeHTML(str)}`; + 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 toSnowflake = (date) => + /:/.test(date) + ? (new Date(date).getTime() - 1420070400000) * Math.pow(2, 22) + : date; + const replaceInterpolations = (str, obj, removeMissing = false) => + str.replace( + /\{\{([\w_]+)\}\}/g, + (m, key) => obj[key] || (removeMissing ? "" : m), + ); + + const PREFIX$1 = "[UNDISCORD]"; + + /** + * Delete all messages in a Discord channel or DM + * @author Victornpb + * @see https://github.com/victornpb/undiscord + */ + class UndiscordCore { + options = { + authToken: null, // Your authorization token + authorId: null, // Author of the messages you want to delete + guildId: null, // Server were the messages are located + channelId: null, // Channel were the messages are located + threadId: null, // Thread/forum where the messages are located + isThread: false, // Delete only messages in thread + minId: null, // Only delete messages after this, leave blank do delete all + maxId: null, // Only delete messages before this, leave blank do delete all + content: null, // Filter messages that contains this text content + hasLink: null, // Filter messages that contains link + hasFile: null, // Filter messages that contains file + hasNoFile: null, // Filter messages that contains no file (opposite of hasFile) + includeNsfw: null, // Search in NSFW channels + includeServers: null, // Search in server channels + includePinned: null, // Delete messages that are pinned + pattern: null, // Only delete messages that match the regex (insensitive) + searchDelay: null, // Delay each time we fetch for more messages + deleteDelay: null, // Delay between each delete operation + rateLimitPrevention: null, // Whether rate limit prevention is enabled or not + maxAttempt: 2, // Attempts to delete a single message if it fails + askForConfirmation: true, }; - var logFn; // custom console.log function - const setLogFn = (fn) => logFn = fn; - - // Helpers - 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 => String(html).replace(/[&<"']/g, m => ({ '&': '&', '<': '<', '"': '"', '\'': ''' })[m]); - const redact = str => `${escapeHTML(str)}`; - 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 toSnowflake = (date) => /:/.test(date) ? ((new Date(date).getTime() - 1420070400000) * Math.pow(2, 22)) : date; - const replaceInterpolations = (str, obj, removeMissing = false) => str.replace(/\{\{([\w_]+)\}\}/g, (m, key) => obj[key] || (removeMissing ? '' : m)); - - const PREFIX$1 = '[UNDISCORD]'; - - /** - * Delete all messages in a Discord channel or DM - * @author Victornpb - * @see https://github.com/victornpb/undiscord - */ - class UndiscordCore { - - options = { - authToken: null, // Your authorization token - authorId: null, // Author of the messages you want to delete - guildId: null, // Server were the messages are located - channelId: null, // Channel were the messages are located - minId: null, // Only delete messages after this, leave blank do delete all - maxId: null, // Only delete messages before this, leave blank do delete all - content: null, // Filter messages that contains this text content - hasLink: null, // Filter messages that contains link - hasFile: null, // Filter messages that contains file - includeNsfw: null, // Search in NSFW channels - includePinned: null, // Delete messages that are pinned - pattern: null, // Only delete messages that match the regex (insensitive) - searchDelay: null, // Delay each time we fetch for more messages - deleteDelay: null, // Delay between each delete operation - maxAttempt: 2, // Attempts to delete a single message if it fails - askForConfirmation: true, - }; + state = { + running: false, + delCount: 0, + failCount: 0, + grandTotal: 0, + offset: { asc: 0, desc: 0 }, + iterations: 0, + sortOrder: "asc", + searchedPages: 0, + totalSkippedMessages: 0, + startEmptyPages: -1, + + _seachResponse: null, + _messagesToDelete: [], + _skippedMessages: [], + }; - state = { - running: false, - delCount: 0, - failCount: 0, - grandTotal: 0, - offset: 0, - iterations: 0, - - _seachResponse: null, - _messagesToDelete: [], - _skippedMessages: [], - }; + stats = { + startTime: new Date(), // start time + throttledCount: 0, // how many times you have been throttled + throttledTotalTime: 0, // the total amount of time you spent being throttled + lastPing: null, // the most recent ping + avgPing: null, // average ping used to calculate the estimated remaining time + etr: 0, + }; - stats = { - startTime: new Date(), // start time - throttledCount: 0, // how many times you have been throttled - throttledTotalTime: 0, // the total amount of time you spent being throttled - lastPing: null, // the most recent ping - avgPing: null, // average ping used to calculate the estimated remaining time - etr: 0, - }; + // events + onStart = undefined; + onProgress = undefined; + onStop = undefined; + + resetState() { + this.state = { + running: false, + delCount: 0, + failCount: 0, + grandTotal: 0, + offset: { asc: 0, desc: 0 }, + iterations: 0, + sortOrder: "asc", + searchedPages: 0, + totalSkippedMessages: 0, + startEmptyPages: -1, + + _seachResponse: null, + _messagesToDelete: [], + _skippedMessages: [], + }; + + this.options.askForConfirmation = true; + } - // events - onStart = undefined; - onProgress = undefined; - onStop = undefined; - - resetState() { - this.state = { - running: false, - delCount: 0, - failCount: 0, - grandTotal: 0, - offset: 0, - iterations: 0, - - _seachResponse: null, - _messagesToDelete: [], - _skippedMessages: [], - }; - - this.options.askForConfirmation = true; - } + /** Automate the deletion process of multiple channels */ + async runBatch(queue) { + if (this.state.running) return log.error("Already running!"); - /** Automate the deletion process of multiple channels */ - async runBatch(queue) { - if (this.state.running) return log.error('Already running!'); + log.info(`Runnning batch with queue of ${queue.length} jobs`); + for (let i = 0; i < queue.length; i++) { + const job = queue[i]; + log.info("Starting job...", `(${i + 1}/${queue.length})`); - log.info(`Runnning batch with queue of ${queue.length} jobs`); - for (let i = 0; i < queue.length; i++) { - const job = queue[i]; - log.info('Starting job...', `(${i + 1}/${queue.length})`); + // set options + this.options = { + ...this.options, // keep current options + ...job, // override with options for that job + }; - // set options - this.options = { - ...this.options, // keep current options - ...job, // override with options for that job - }; + await this.run(true); + if (!this.state.running) break; - await this.run(true); - if (!this.state.running) break; + log.info("Job ended.", `(${i + 1}/${queue.length})`); + this.resetState(); + this.options.askForConfirmation = false; + this.state.running = true; // continue running + } - log.info('Job ended.', `(${i + 1}/${queue.length})`); - this.resetState(); - this.options.askForConfirmation = false; - this.state.running = true; // continue running - } + log.info("Batch finished."); + this.state.running = false; + } - log.info('Batch finished.'); + /** Start the deletion process */ + async run(isJob = false) { + if (this.state.running && !isJob) return log.error("Already running!"); + + this.state.running = true; + this.stats.startTime = new Date(); + + log.success(`\nStarted at ${this.stats.startTime.toLocaleString()}`); + if (this.onStart) this.onStart(this.state, this.stats); + + if (!this.options.guildId) { + log.verb("Fetching channel info..."); + await this.fetchChannelInfo(); + } + if (!this.options.guildId) return; // message is handled in fetchChannelInfo + if ( + isJob && + this.options.guildId !== "@me" && + !this.options.includeServers + ) { + log.warn( + `Skipping the channel ${this.options.channelId} as it's a server channel.`, + ); + return; + } + + log.debug( + `authorId = "${redact(this.options.authorId)}"`, + `guildId = "${redact(this.options.guildId)}"`, + `channelId = "${redact(this.options.channelId)}"`, + `minId = "${redact(this.options.minId)}"`, + `maxId = "${redact(this.options.maxId)}"`, + `hasLink = ${!!this.options.hasLink}`, + `hasFile = ${!!this.options.hasFile}`, + `hasNoFile = ${!!this.options.hasNoFile}`, + ); + + do { + this.state.iterations++; + + log.verb("Fetching messages..."); + // Search messages + this.state.sortOrder = this.state.sortOrder == "desc" ? "asc" : "desc"; + log.verb(`Set sort order to ${this.state.sortOrder} for this search.`); + await this.search(); + this.state.searchedPages++; + + // Process results and find which messages should be deleted + await this.filterResponse(); + + log.verb( + `Grand total: ${this.state.grandTotal}`, + `(Messages in current page: ${this.state._seachResponse.messages.length}`, + `To be deleted: ${this.state._messagesToDelete.length}`, + `Skipped: ${this.state._skippedMessages.length})`, + `offset (asc): ${this.state.offset["asc"]}`, + `offset (desc): ${this.state.offset["desc"]}`, + ); + this.printStats(); + this.state.totalSkippedMessages += this.state._skippedMessages.length; + + // Calculate estimated time + this.calcEtr(); + log.verb(`Estimated time remaining: ${msToHMS(this.stats.etr)}`); + + const messagesRemaining = this.state.grandTotal - this.state.offset; + + // if there are messages to delete, delete them + if (this.state._messagesToDelete.length > 0) { + this.state.startEmptyPages = -1; + + if ((await this.confirm()) === false) { + this.state.running = false; // break out of a job + break; // immmediately stop this iteration + } + + await this.deleteMessagesFromList(); + } else if (this.state._skippedMessages.length > 0) { + // There are stuff, but nothing to delete (example a page full of system messages) + // check next page until we see a page with nothing in it (end of results). + this.state.startEmptyPages = -1; + const oldOffset = this.state.offset[this.state.sortOrder]; + this.state.offset[this.state.sortOrder] += + this.state._skippedMessages.length; + log.verb( + "There's nothing we can delete on this page, checking next page...", + ); + log.verb( + `Skipped ${this.state._skippedMessages.length} out of ${this.state._seachResponse.messages.length} in this page.`, + `(Offset for ${this.state.sortOrder} was ${oldOffset}, ajusted to ${this.state.offset[this.state.sortOrder]})`, + ); + } else if (this.state.delCount < messagesRemaining) { + log.verb("There's messages remaining, checking next page..."); + } else { + if (this.state.startEmptyPages == -1) + this.state.startEmptyPages = Date.now(); + // if the first page we are searching is empty + // or we've been getting empty page responses for the past 30 seconds (enough for Discord to re-index the pages) + // or (deleted messages + failed to delete + total skipped) >= total messages + // ONLY THEN proceed with ending the job + if ( + this.state.searchedPages == 1 || + Date.now() - this.state.startEmptyPages > 30 * 1000 || + this.state.delCount + + this.state.failCount + + this.state.totalSkippedMessages >= + this.state.grandTotal + ) { + log.verb("Ended because API returned an empty page."); + log.verb("[End state]", this.state); + if (isJob) break; // break without stopping if this is part of a job this.state.running = false; - } - - /** Start the deletion process */ - async run(isJob = false) { - if (this.state.running && !isJob) return log.error('Already running!'); - - this.state.running = true; - this.stats.startTime = new Date(); - - log.success(`\nStarted at ${this.stats.startTime.toLocaleString()}`); - log.debug( - `authorId = "${redact(this.options.authorId)}"`, - `guildId = "${redact(this.options.guildId)}"`, - `channelId = "${redact(this.options.channelId)}"`, - `minId = "${redact(this.options.minId)}"`, - `maxId = "${redact(this.options.maxId)}"`, - `hasLink = ${!!this.options.hasLink}`, - `hasFile = ${!!this.options.hasFile}`, + } else { + // wait 10 seconds for Discord to re-index the search page before retrying + const waitingTime = 10 * 1000; + log.verb( + `API returned an empty page, waiting an extra ${(waitingTime / 1000).toFixed(2)}s before searching again...`, ); + await wait(waitingTime); + } + } - if (this.onStart) this.onStart(this.state, this.stats); - - do { - this.state.iterations++; - - log.verb('Fetching messages...'); - // Search messages - await this.search(); - - // Process results and find which messages should be deleted - await this.filterResponse(); - - log.verb( - `Grand total: ${this.state.grandTotal}`, - `(Messages in current page: ${this.state._seachResponse.messages.length}`, - `To be deleted: ${this.state._messagesToDelete.length}`, - `Skipped: ${this.state._skippedMessages.length})`, - `offset: ${this.state.offset}` - ); - this.printStats(); - - // Calculate estimated time - this.calcEtr(); - log.verb(`Estimated time remaining: ${msToHMS(this.stats.etr)}`); - - // if there are messages to delete, delete them - if (this.state._messagesToDelete.length > 0) { - - if (await this.confirm() === false) { - this.state.running = false; // break out of a job - break; // immmediately stop this iteration - } - - await this.deleteMessagesFromList(); - } - else if (this.state._skippedMessages.length > 0) { - // There are stuff, but nothing to delete (example a page full of system messages) - // check next page until we see a page with nothing in it (end of results). - const oldOffset = this.state.offset; - this.state.offset += this.state._skippedMessages.length; - log.verb('There\'s nothing we can delete on this page, checking next page...'); - log.verb(`Skipped ${this.state._skippedMessages.length} out of ${this.state._seachResponse.messages.length} in this page.`, `(Offset was ${oldOffset}, ajusted to ${this.state.offset})`); - } - else { - log.verb('Ended because API returned an empty page.'); - log.verb('[End state]', this.state); - if (isJob) break; // break without stopping if this is part of a job - this.state.running = false; - } + // wait before next page (fix search page not updating fast enough) + log.verb( + `Waiting ${(this.options.searchDelay / 1000).toFixed(2)}s before next page...`, + ); + await wait(this.options.searchDelay); + } while (this.state.running); + + this.stats.endTime = new Date(); + log.success( + `Ended at ${this.stats.endTime.toLocaleString()}! Total time: ${msToHMS(this.stats.endTime.getTime() - this.stats.startTime.getTime())}`, + ); + this.printStats(); + log.debug( + `Deleted ${this.state.delCount} messages, ${this.state.failCount} failed.\n`, + ); + + if (this.onStop) this.onStop(this.state, this.stats); + } - // wait before next page (fix search page not updating fast enough) - log.verb(`Waiting ${(this.options.searchDelay / 1000).toFixed(2)}s before next page...`); - await wait(this.options.searchDelay); + stop() { + this.state.running = false; + if (this.onStop) this.onStop(this.state, this.stats); + } - } while (this.state.running); + /** Calculate the estimated time remaining based on the current stats */ + calcEtr() { + this.stats.etr = + (this.options.searchDelay + this.stats.avgPing) * + Math.round((this.state.grandTotal - this.state.delCount) / 25) + + (this.options.deleteDelay + this.stats.avgPing) * + (this.state.grandTotal - this.state.delCount); + } - this.stats.endTime = new Date(); - log.success(`Ended at ${this.stats.endTime.toLocaleString()}! Total time: ${msToHMS(this.stats.endTime.getTime() - this.stats.startTime.getTime())}`); - this.printStats(); - log.debug(`Deleted ${this.state.delCount} messages, ${this.state.failCount} failed.\n`); + /** As for confirmation in the beggining process */ + async confirm() { + if (!this.options.askForConfirmation) return true; + + log.verb("Waiting for your confirmation..."); + const preview = this.state._messagesToDelete + .map( + (m) => + `${m.author.username}#${m.author.discriminator}: ${m.attachments.length ? "[ATTACHMENTS]" : m.content}`, + ) + .join("\n"); + + const answer = await ask( + `Do you want to delete ~${this.state.grandTotal} messages? (Estimated time: ${msToHMS(this.stats.etr)})` + + "(The actual number of messages may be less, depending if you're using filters to skip some messages)" + + "\n\n---- Preview ----\n" + + preview, + ); + + if (!answer) { + log.error("Aborted by you!"); + return false; + } else { + log.verb("OK"); + this.options.askForConfirmation = false; // do not ask for confirmation again on the next request + return true; + } + } - if (this.onStop) this.onStop(this.state, this.stats); - } + async fetchChannelInfo() { + let API_CHANNEL_URL = `https://discord.com/api/v9/channels/${this.options.channelId}`; - stop() { - this.state.running = false; - if (this.onStop) this.onStop(this.state, this.stats); + let resp; + try { + await this.beforeRequest(); + resp = await fetch(API_CHANNEL_URL, { + headers: { + Authorization: this.options.authToken, + }, + }); + this.afterRequest(); + } catch (err) { + this.state.running = false; + log.error("Channel request threw an error:", err); + throw err; + } + + // not indexed yet + if (resp.status === 202) { + let w = (await resp.json()).retry_after; + w = !isNaN(w) ? w * 1000 : this.stats.searchDelay; // Fix retry_after 0 + this.stats.throttledCount++; + this.stats.throttledTotalTime += w; + log.warn( + `This channel isn't indexed yet. Waiting ${w}ms for discord to index it...`, + ); + await wait(w); + return await this.fetchChannelInfo(); + } + + if (!resp.ok) { + // rate limit + if (resp.status === 429) { + let w = (await resp.json()).retry_after; + w = !isNaN(w) ? w * 1000 : this.stats.searchDelay; // Fix retry_after 0 + + this.stats.throttledCount++; + this.stats.throttledTotalTime += w; + log.warn(`Being rate limited by the API for ${w}ms!`); + this.printStats(); + log.verb(`Cooling down for ${w * 2}ms before retrying...`); + + await wait(w * 2); + return await this.fetchChannelInfo(); + } else { + log.error( + `Error fetching the channel, API responded with status ${resp.status}!\n`, + await resp.json(), + ); + return {}; } + } + const data = await resp.json(); + this.options.guildId = data.guild_id ?? "@me"; + return data; + } - /** Calculate the estimated time remaining based on the current stats */ - calcEtr() { - this.stats.etr = (this.options.searchDelay * Math.round(this.state.grandTotal / 25)) + ((this.options.deleteDelay + this.stats.avgPing) * this.state.grandTotal); + async search() { + let API_SEARCH_URL; + if (this.options.guildId === "@me") + API_SEARCH_URL = `https://discord.com/api/v9/channels/${this.options.channelId}/messages/`; // DMs + else + API_SEARCH_URL = `https://discord.com/api/v9/guilds/${this.options.guildId}/messages/`; // Server + + let resp; + try { + await this.beforeRequest(); + resp = await fetch( + API_SEARCH_URL + + "search?" + + queryString([ + ["author_id", this.options.authorId || undefined], + [ + "channel_id", + (this.options.guildId !== "@me" + ? this.options.channelId + : undefined) || undefined, + ], + [ + "min_id", + this.options.minId + ? toSnowflake(this.options.minId) + : undefined, + ], + [ + "max_id", + this.options.maxId + ? toSnowflake(this.options.maxId) + : undefined, + ], + ["sort_by", "timestamp"], + ["sort_order", this.state.sortOrder], + ["offset", this.state.offset[this.state.sortOrder]], + ["has", this.options.hasLink ? "link" : undefined], + ["has", this.options.hasFile ? "file" : undefined], + ["content", this.options.content || undefined], + ["include_nsfw", this.options.includeNsfw ? true : undefined], + ]), + { + headers: { + Authorization: this.options.authToken, + }, + }, + ); + this.afterRequest(); + } catch (err) { + this.state.running = false; + log.error("Search request threw an error:", err); + throw err; + } + + // not indexed yet + if (resp.status === 202) { + let w = (await resp.json()).retry_after; + w = !isNaN(w) ? w * 1000 : this.stats.searchDelay; // Fix retry_after 0 + this.stats.throttledCount++; + this.stats.throttledTotalTime += w; + log.warn( + `This channel isn't indexed yet. Waiting ${w}ms for discord to index it...`, + ); + await wait(w); + return await this.search(); + } + + if (!resp.ok) { + // searching messages too fast + if (resp.status === 429) { + let w = (await resp.json()).retry_after; + w = !isNaN(w) ? w * 1000 : this.stats.searchDelay; // Fix retry_after 0 + + this.stats.throttledCount++; + this.stats.throttledTotalTime += w; + log.warn(`Being rate limited by the API for ${w}ms!`); + this.printStats(); + log.verb(`Cooling down for ${w * 2}ms before retrying...`); + + await wait(w * 2); + return await this.search(); } - - /** As for confirmation in the beggining process */ - async confirm() { - if (!this.options.askForConfirmation) return true; - - log.verb('Waiting for your confirmation...'); - const preview = this.state._messagesToDelete.map(m => `${m.author.username}#${m.author.discriminator}: ${m.attachments.length ? '[ATTACHMENTS]' : m.content}`).join('\n'); - - const answer = await ask( - `Do you want to delete ~${this.state.grandTotal} messages? (Estimated time: ${msToHMS(this.stats.etr)})` + - '(The actual number of messages may be less, depending if you\'re using filters to skip some messages)' + - '\n\n---- Preview ----\n' + - preview - ); - - if (!answer) { - log.error('Aborted by you!'); - return false; - } - else { - log.verb('OK'); - this.options.askForConfirmation = false; // do not ask for confirmation again on the next request - return true; - } + if (resp.status === 400) { + const data = await resp.json(); + if (data.code === 50024) { + // 400 can happen if the channel is not found (code=50024) + log.error("Error searching messages, channel not found!"); + // return fake empty messages data to skip the next channel + const data = { total_results: 0, messages: [] }; + this.state._seachResponse = data; + return data; + } } - - async search() { - let API_SEARCH_URL; - if (this.options.guildId === '@me') API_SEARCH_URL = `https://discord.com/api/v9/channels/${this.options.channelId}/messages/`; // DMs - else API_SEARCH_URL = `https://discord.com/api/v9/guilds/${this.options.guildId}/messages/`; // Server - - let resp; - try { - this.beforeRequest(); - resp = await fetch(API_SEARCH_URL + 'search?' + queryString([ - ['author_id', this.options.authorId || undefined], - ['channel_id', (this.options.guildId !== '@me' ? this.options.channelId : undefined) || undefined], - ['min_id', this.options.minId ? toSnowflake(this.options.minId) : undefined], - ['max_id', this.options.maxId ? toSnowflake(this.options.maxId) : undefined], - ['sort_by', 'timestamp'], - ['sort_order', 'desc'], - ['offset', this.state.offset], - ['has', this.options.hasLink ? 'link' : undefined], - ['has', this.options.hasFile ? 'file' : undefined], - ['content', this.options.content || undefined], - ['include_nsfw', this.options.includeNsfw ? true : undefined], - ]), { - headers: { - 'Authorization': this.options.authToken, - } - }); - this.afterRequest(); - } catch (err) { - this.state.running = false; - log.error('Search request threw an error:', err); - throw err; - } - - // not indexed yet - if (resp.status === 202) { - let w = (await resp.json()).retry_after * 1000; - w = w || this.stats.searchDelay; // Fix retry_after 0 - this.stats.throttledCount++; - this.stats.throttledTotalTime += w; - log.warn(`This channel isn't indexed yet. Waiting ${w}ms for discord to index it...`); - await wait(w); - return await this.search(); - } - - if (!resp.ok) { - // searching messages too fast - if (resp.status === 429) { - let w = (await resp.json()).retry_after * 1000; - w = w || this.stats.searchDelay; // Fix retry_after 0 - - this.stats.throttledCount++; - this.stats.throttledTotalTime += w; - this.stats.searchDelay += w; // increase delay - w = this.stats.searchDelay; - log.warn(`Being rate limited by the API for ${w}ms! Increasing search delay...`); - this.printStats(); - log.verb(`Cooling down for ${w * 2}ms before retrying...`); - - await wait(w * 2); - return await this.search(); - } - else { - this.state.running = false; - log.error(`Error searching messages, API responded with status ${resp.status}!\n`, await resp.json()); - throw resp; - } - } - const data = await resp.json(); + if (resp.status === 403) { + const data = await resp.json(); + if (data.code === 50001) { + // 403 can happen if the bot is not in the guild (code=50001) + log.error("Error searching messages, user is not in the guild!"); + // return fake empty messages data to skip the next channel + const data = { total_results: 0, messages: [] }; this.state._seachResponse = data; - console.log(PREFIX$1, 'search', data); return data; + } + } else { + log.error( + `Error searching messages, API responded with status ${resp.status}!\n`, + await resp.json(), + ); + const data = { messages: [] }; + this.state._seachResponse = data; + return data; } + } + const data = await resp.json(); + this.state._seachResponse = data; + console.log(PREFIX$1, "search", data); + return data; + } - async filterResponse() { - const data = this.state._seachResponse; - - // the search total will decrease as we delete stuff - const total = data.total_results; - if (total > this.state.grandTotal) this.state.grandTotal = total; - - // search returns messages near the the actual message, only get the messages we searched for. - const discoveredMessages = data.messages.map(convo => convo.find(message => message.hit === true)); - - // we can only delete some types of messages, system messages are not deletable. - let messagesToDelete = discoveredMessages; - messagesToDelete = messagesToDelete.filter(msg => msg.type === 0 || (msg.type >= 6 && msg.type <= 21)); - messagesToDelete = messagesToDelete.filter(msg => msg.pinned ? this.options.includePinned : true); - - // custom filter of messages - try { - const regex = new RegExp(this.options.pattern, 'i'); - messagesToDelete = messagesToDelete.filter(msg => regex.test(msg.content)); - } catch (e) { - log.warn('Ignoring RegExp because pattern is malformed!', e); - } - - // create an array containing everything we skipped. (used to calculate offset for next searches) - const skippedMessages = discoveredMessages.filter(msg => !messagesToDelete.find(m => m.id === msg.id)); - - this.state._messagesToDelete = messagesToDelete; - this.state._skippedMessages = skippedMessages; + async filterResponse() { + const data = this.state._seachResponse; + + // the search total will decrease as we delete stuff + const total = data.total_results; + if (total > this.state.grandTotal) this.state.grandTotal = total; + + // search returns messages near the the actual message, only get the messages we searched for. + const discoveredMessages = data.messages.map((convo) => + convo.find((message) => message.hit === true), + ); + + // we can only delete some types of messages, system messages are not deletable. + let messagesToDelete = discoveredMessages; + messagesToDelete = messagesToDelete.filter( + (msg) => msg.type === 0 || (msg.type >= 6 && msg.type <= 21), + ); + messagesToDelete = messagesToDelete.filter((msg) => + msg.pinned ? this.options.includePinned : true, + ); + + // if the user picked hasNoFile, we need to filter out messages with attachments + // this has to be done after the search because the search API doesn't have a filter for this + if (this.options.hasNoFile) { + messagesToDelete = messagesToDelete.filter( + (msg) => !msg.attachments.length, + ); + } + + // only delete messages in the thread + if (this.options.isThread) { + messagesToDelete = messagesToDelete.filter( + (msg) => msg.channel_id === this.options.threadId, + ); + } + + // custom filter of messages + try { + const regex = new RegExp(this.options.pattern, "i"); + messagesToDelete = messagesToDelete.filter((msg) => + regex.test(msg.content), + ); + } catch (e) { + log.warn("Ignoring RegExp because pattern is malformed!", e); + } + + // create an array containing everything we skipped. (used to calculate offset for next searches) + const skippedMessages = discoveredMessages.filter( + (msg) => !messagesToDelete.find((m) => m.id === msg.id), + ); + + this.state._messagesToDelete = messagesToDelete; + this.state._skippedMessages = skippedMessages; + + console.log(PREFIX$1, "filterResponse", this.state); + } - console.log(PREFIX$1, 'filterResponse', this.state); + async deleteMessagesFromList() { + for (let i = 0; i < this.state._messagesToDelete.length; i++) { + const message = this.state._messagesToDelete[i]; + if (!this.state.running) return log.error("Stopped by you!"); + + log.debug( + // `${((this.state.delCount + 1) / this.state.grandTotal * 100).toFixed(2)}%`, + `[${this.state.delCount + 1}/${this.state.grandTotal}] ` + + `${new Date(message.timestamp).toLocaleString()} ` + + `${redact(message.author.username + "#" + message.author.discriminator)}` + + `: ${redact(message.content).replace(/\n/g, "↵")}` + + (message.attachments.length + ? redact(JSON.stringify(message.attachments)) + : ""), + `{ID:${redact(message.id)}}`, + ); + + // Delete a single message (with retry) + let attempt = 0; + while (attempt < this.options.maxAttempt) { + const result = await this.deleteMessage(message); + + if (result === "RETRY") { + attempt++; + log.verb( + `Retrying in ${this.options.deleteDelay}ms... (${attempt}/${this.options.maxAttempt})`, + ); + await wait(this.options.deleteDelay); + } else break; } - async deleteMessagesFromList() { - for (let i = 0; i < this.state._messagesToDelete.length; i++) { - const message = this.state._messagesToDelete[i]; - if (!this.state.running) return log.error('Stopped by you!'); - - log.debug( - // `${((this.state.delCount + 1) / this.state.grandTotal * 100).toFixed(2)}%`, - `[${this.state.delCount + 1}/${this.state.grandTotal}] ` + - `${new Date(message.timestamp).toLocaleString()} ` + - `${redact(message.author.username + '#' + message.author.discriminator)}` + - `: ${redact(message.content).replace(/\n/g, '↵')}` + - (message.attachments.length ? redact(JSON.stringify(message.attachments)) : ''), - `{ID:${redact(message.id)}}` - ); + this.calcEtr(); + if (this.onProgress) this.onProgress(this.state, this.stats); - // Delete a single message (with retry) - let attempt = 0; - while (attempt < this.options.maxAttempt) { - const result = await this.deleteMessage(message); - - if (result === 'RETRY') { - attempt++; - log.verb(`Retrying in ${this.options.deleteDelay}ms... (${attempt}/${this.options.maxAttempt})`); - await wait(this.options.deleteDelay); - } - else break; - } - - this.calcEtr(); - if (this.onProgress) this.onProgress(this.state, this.stats); - - await wait(this.options.deleteDelay); - } - } + await wait(this.options.deleteDelay); + } + } - async deleteMessage(message) { - const API_DELETE_URL = `https://discord.com/api/v9/channels/${message.channel_id}/messages/${message.id}`; - let resp; - try { - this.beforeRequest(); - resp = await fetch(API_DELETE_URL, { - method: 'DELETE', + async deleteMessage(message) { + const API_DELETE_URL = `https://discord.com/api/v9/channels/${message.channel_id}/messages/${message.id}`; + let resp; + try { + await this.beforeRequest(); + resp = await fetch(API_DELETE_URL, { + method: "DELETE", + headers: { + Authorization: this.options.authToken, + }, + }); + this.afterRequest(); + } catch (err) { + // no response error (e.g. network error) + log.error("Delete request threw an error:", err); + log.verb("Related object:", redact(JSON.stringify(message))); + this.state.failCount++; + return "FAILED"; + } + + if (!resp.ok) { + if (resp.status === 429) { + // deleting messages too fast + let w = (await resp.json()).retry_after; + w = !isNaN(w) ? w * 1000 : this.stats.deleteDelay; + this.stats.throttledCount++; + this.stats.throttledTotalTime += w; + log.warn(`Being rate limited by the API for ${w}ms!`); + this.printStats(); + log.verb(`Cooling down for ${w * 2}ms before retrying...`); + await wait(w * 2); + return "RETRY"; + } else { + const body = await resp.text(); + + try { + const r = JSON.parse(body); + + if (resp.status === 400 && r.code === 50083) { + log.warn( + "Thread is archived. Attempting to send a message to reopen it...", + ); + const reopenResp = await fetch( + `https://discord.com/api/v9/channels/${message.channel_id}/messages`, + { + method: "POST", + headers: { + Authorization: this.options.authToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ content: "Reopening thread..." }), + }, + ); + if (reopenResp.ok) { + const reopenData = await reopenResp.json(); + const deleteReopenMessageResp = await fetch( + `https://discord.com/api/v9/channels/${message.channel_id}/messages/${reopenData.id}`, + { + method: "DELETE", headers: { - 'Authorization': this.options.authToken, + Authorization: this.options.authToken, }, - }); - this.afterRequest(); - } catch (err) { - // no response error (e.g. network error) - log.error('Delete request throwed an error:', err); - log.verb('Related object:', redact(JSON.stringify(message))); - this.state.failCount++; - return 'FAILED'; - } - - if (!resp.ok) { - if (resp.status === 429) { - // deleting messages too fast - const w = (await resp.json()).retry_after * 1000; - this.stats.throttledCount++; - this.stats.throttledTotalTime += w; - this.options.deleteDelay = w; // increase delay - log.warn(`Being rate limited by the API for ${w}ms! Adjusted delete delay to ${this.options.deleteDelay}ms.`); - this.printStats(); - log.verb(`Cooling down for ${w * 2}ms before retrying...`); - await wait(w * 2); - return 'RETRY'; + }, + ); + if (deleteReopenMessageResp.ok) { + log.info( + "Thread reopened and activation message deleted. Retrying message deletion...", + ); + const retryDeleteOriginal = await this.deleteMessage(message); + if (retryDeleteOriginal === "OK") { + log.info( + "Original message deleted successfully after reopening the thread.", + ); + } else { + log.error( + "Failed to delete the original message after reopening the thread.", + ); + } + const closeThreadResp = await fetch( + `https://discord.com/api/v9/channels/${message.channel_id}`, + { + method: "PATCH", + headers: { + Authorization: this.options.authToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ archived: true }), + }, + ); + if (closeThreadResp.ok) { + log.info( + "Thread closed after reopening and deleting the message.", + ); + return "RETRY"; + } else { + log.error("Failed to close the reopened thread."); + } } else { - const body = await resp.text(); - - try { - const r = JSON.parse(body); - - if (resp.status === 400 && r.code === 50083) { - log.warn('Thread is archived. Attempting to send a message to reopen it...'); - const reopenResp = await fetch(`https://discord.com/api/v9/channels/${message.channel_id}/messages`, { - method: 'POST', - headers: { - 'Authorization': this.options.authToken, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ content: 'Reopening thread...' }), - }); - if (reopenResp.ok) { - const reopenData = await reopenResp.json(); - const deleteReopenMessageResp = await fetch(`https://discord.com/api/v9/channels/${message.channel_id}/messages/${reopenData.id}`, { - method: 'DELETE', - headers: { - 'Authorization': this.options.authToken, - }, - }); - if (deleteReopenMessageResp.ok) { - log.info('Thread reopened and activation message deleted. Retrying message deletion...'); - const retryDeleteOriginal = await this.deleteMessage(message); - if (retryDeleteOriginal === 'OK') { - log.info('Original message deleted successfully after reopening the thread.'); - } else { - log.error('Failed to delete the original message after reopening the thread.'); - } - const closeThreadResp = await fetch(`https://discord.com/api/v9/channels/${message.channel_id}`, { - method: 'PATCH', - headers: { - 'Authorization': this.options.authToken, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ archived: true }), - }); - if (closeThreadResp.ok) { - log.info('Thread closed after reopening and deleting the message.'); - return 'RETRY'; - } else { - log.error('Failed to close the reopened thread.'); - } - } else { - log.error('Failed to delete the activation message.'); - } - } else { - log.error('Failed to reopen thread.'); - this.state.failCount++; - return 'FAIL_SKIP'; - } - } - log.error(`Error deleting message, API responded with status ${resp.status}!`, r); - log.verb('Related object:', redact(JSON.stringify(message))); - this.state.failCount++; - return 'FAILED'; - } catch (e) { - log.error(`Fail to parse JSON. API responded with status ${resp.status}!`, body); - } + log.error("Failed to delete the activation message."); } + } else { + log.error("Failed to reopen thread."); + this.state.offset[this.state.sortOrder]++; + this.state.failCount++; + return "FAIL_SKIP"; + } } - - this.state.delCount++; - return 'OK'; + log.error( + `Error deleting message, API responded with status ${resp.status}!`, + r, + ); + log.verb("Related object:", redact(JSON.stringify(message))); + this.state.failCount++; + return "FAILED"; + } catch { + log.error( + `Fail to parse JSON. API responded with status ${resp.status}!`, + body, + ); + } } + } - #beforeTs = 0; // used to calculate latency - beforeRequest() { - this.#beforeTs = Date.now(); - } - afterRequest() { - this.stats.lastPing = (Date.now() - this.#beforeTs); - this.stats.avgPing = this.stats.avgPing > 0 ? (this.stats.avgPing * 0.9) + (this.stats.lastPing * 0.1) : this.stats.lastPing; - } + this.state.delCount++; + return "OK"; + } - printStats() { - log.verb( - `Delete delay: ${this.options.deleteDelay}ms, Search delay: ${this.options.searchDelay}ms`, - `Last Ping: ${this.stats.lastPing}ms, Average Ping: ${this.stats.avgPing | 0}ms`, - ); + #beforeTs = 0; // used to calculate latency + #requestLog = []; // used to add any extra delay + async beforeRequest() { + this.#requestLog.push(Date.now()); + this.#requestLog = this.#requestLog.filter( + (timestamp) => Date.now() - timestamp < 60 * 1000, + ); + if (this.options.rateLimitPrevention) { + let rateLimits = [ + [45, 60], + [4, 5], + ]; // todo: confirm, testing shows these are right + for (let [maxRequests, timePeriod] of rateLimits) { + if ( + this.#requestLog.length >= maxRequests && + Date.now() - + this.#requestLog[this.#requestLog.length - maxRequests] < + timePeriod * 1000 + ) { + let delay = + timePeriod * 1000 - + (Date.now() - + this.#requestLog[this.#requestLog.length - maxRequests]); + delay = delay * 1.15 + 300; // adding a buffer and additional wait time log.verb( - `Rate Limited: ${this.stats.throttledCount} times.`, - `Total time throttled: ${msToHMS(this.stats.throttledTotalTime)}.` + `Delaying for an extra ${(delay / 1000).toFixed(2)}s to avoid rate limits...`, ); + await new Promise((resolve) => setTimeout(resolve, delay)); + break; + } } + } + this.#beforeTs = Date.now(); } - - const MOVE = 0; - const RESIZE_T = 1; - const RESIZE_B = 2; - const RESIZE_L = 4; - const RESIZE_R = 8; - const RESIZE_TL = RESIZE_T + RESIZE_L; - const RESIZE_TR = RESIZE_T + RESIZE_R; - const RESIZE_BL = RESIZE_B + RESIZE_L; - const RESIZE_BR = RESIZE_B + RESIZE_R; - - /** - * Make an element draggable/resizable - * @author Victor N. wwww.vitim.us - */ - class DragResize { - constructor({ elm, moveHandle, options }) { - this.options = defaultArgs({ - enabledDrag: true, - enabledResize: true, - minWidth: 200, - maxWidth: Infinity, - minHeight: 100, - maxHeight: Infinity, - dragAllowX: true, - dragAllowY: true, - resizeAllowX: true, - resizeAllowY: true, - draggingClass: 'drag', - useMouseEvents: true, - useTouchEvents: true, - createHandlers: true, - }, options); - Object.assign(this, options); - options = undefined; - - elm.style.position = 'fixed'; - - this.drag_m = new Draggable(elm, moveHandle, MOVE, this.options); - - if (this.options.createHandlers) { - this.el_t = createElement('div', { name: 'grab-t' }, elm); - this.drag_t = new Draggable(elm, this.el_t, RESIZE_T, this.options); - this.el_r = createElement('div', { name: 'grab-r' }, elm); - this.drag_r = new Draggable(elm, this.el_r, RESIZE_R, this.options); - this.el_b = createElement('div', { name: 'grab-b' }, elm); - this.drag_b = new Draggable(elm, this.el_b, RESIZE_B, this.options); - this.el_l = createElement('div', { name: 'grab-l' }, elm); - this.drag_l = new Draggable(elm, this.el_l, RESIZE_L, this.options); - this.el_tl = createElement('div', { name: 'grab-tl' }, elm); - this.drag_tl = new Draggable(elm, this.el_tl, RESIZE_TL, this.options); - this.el_tr = createElement('div', { name: 'grab-tr' }, elm); - this.drag_tr = new Draggable(elm, this.el_tr, RESIZE_TR, this.options); - this.el_br = createElement('div', { name: 'grab-br' }, elm); - this.drag_br = new Draggable(elm, this.el_br, RESIZE_BR, this.options); - this.el_bl = createElement('div', { name: 'grab-bl' }, elm); - this.drag_bl = new Draggable(elm, this.el_bl, RESIZE_BL, this.options); - } - } + afterRequest() { + this.stats.lastPing = Date.now() - this.#beforeTs; + this.stats.avgPing = + this.stats.avgPing > 0 + ? this.stats.avgPing * 0.9 + this.stats.lastPing * 0.1 + : this.stats.lastPing; } - class Draggable { - constructor(targetElm, handleElm, op, options) { - Object.assign(this, options); - options = undefined; - - this._targetElm = targetElm; - this._handleElm = handleElm; - - let vw = window.innerWidth; - let vh = window.innerHeight; - let initialX, initialY, initialT, initialL, initialW, initialH; - - const clamp = (value, min, max) => value < min ? min : value > max ? max : value; - - const moveOp = (x, y) => { - const deltaX = (x - initialX); - const deltaY = (y - initialY); - const t = clamp(initialT + deltaY, 0, vh - initialH); - const l = clamp(initialL + deltaX, 0, vw - initialW); - this._targetElm.style.top = t + 'px'; - this._targetElm.style.left = l + 'px'; - }; - - const resizeOp = (x, y) => { - x = clamp(x, 0, vw); - y = clamp(y, 0, vh); - const deltaX = (x - initialX); - const deltaY = (y - initialY); - const resizeDirX = (op & RESIZE_L) ? -1 : 1; - const resizeDirY = (op & RESIZE_T) ? -1 : 1; - const deltaXMax = (this.maxWidth - initialW); - const deltaXMin = (this.minWidth - initialW); - const deltaYMax = (this.maxHeight - initialH); - const deltaYMin = (this.minHeight - initialH); - const t = initialT + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax) * resizeDirY; - const l = initialL + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax) * resizeDirX; - const w = initialW + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax); - const h = initialH + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax); - if (op & RESIZE_T) { // resize ↑ - this._targetElm.style.top = t + 'px'; - this._targetElm.style.height = h + 'px'; - } - if (op & RESIZE_B) { // resize ↓ - this._targetElm.style.height = h + 'px'; - } - if (op & RESIZE_L) { // resize ← - this._targetElm.style.left = l + 'px'; - this._targetElm.style.width = w + 'px'; - } - if (op & RESIZE_R) { // resize → - this._targetElm.style.width = w + 'px'; - } - }; - - let operation = op === MOVE ? moveOp : resizeOp; - - 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; - initialX = x; - initialY = y; - vw = window.innerWidth; - vh = window.innerHeight; - initialT = this._targetElm.offsetTop; - initialL = this._targetElm.offsetLeft; - initialW = this._targetElm.clientWidth; - initialH = this._targetElm.clientHeight; - if (this.useMouseEvents) { - document.addEventListener('mousemove', this._dragMoveHandler); - document.addEventListener('mouseup', this._dragEndHandler); - } - if (this.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; - } - // perform drag / resize operation - operation(x, y); - } - - function dragEndHandler(e) { - if (this.useMouseEvents) { - document.removeEventListener('mousemove', this._dragMoveHandler); - document.removeEventListener('mouseup', this._dragEndHandler); - } - if (this.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 - this._dragStartHandler = dragStartHandler.bind(this); - this._dragMoveHandler = dragMoveHandler.bind(this); - this._dragEndHandler = dragEndHandler.bind(this); - - this.enable(); + printStats() { + log.verb( + `Delete delay: ${this.options.deleteDelay}ms, Search delay: ${this.options.searchDelay}ms`, + `Last Ping: ${this.stats.lastPing}ms, Average Ping: ${this.stats.avgPing | 0}ms`, + ); + log.verb( + `Rate Limited: ${this.stats.throttledCount} times.`, + `Total time throttled: ${msToHMS(this.stats.throttledTotalTime)}.`, + ); + } + } + + const MOVE = 0; + const RESIZE_T = 1; + const RESIZE_B = 2; + const RESIZE_L = 4; + const RESIZE_R = 8; + const RESIZE_TL = RESIZE_T + RESIZE_L; + const RESIZE_TR = RESIZE_T + RESIZE_R; + const RESIZE_BL = RESIZE_B + RESIZE_L; + const RESIZE_BR = RESIZE_B + RESIZE_R; + + /** + * Make an element draggable/resizable + * @author Victor N. wwww.vitim.us + */ + class DragResize { + constructor({ elm, moveHandle, options }) { + this.options = defaultArgs( + { + enabledDrag: true, + enabledResize: true, + minWidth: 200, + maxWidth: Infinity, + minHeight: 100, + maxHeight: Infinity, + dragAllowX: true, + dragAllowY: true, + resizeAllowX: true, + resizeAllowY: true, + draggingClass: "drag", + useMouseEvents: true, + useTouchEvents: true, + createHandlers: true, + }, + options, + ); + Object.assign(this, options); + options = undefined; + + elm.style.position = "fixed"; + + this.drag_m = new Draggable(elm, moveHandle, MOVE, this.options); + + if (this.options.createHandlers) { + this.el_t = createElement("div", { name: "grab-t" }, elm); + this.drag_t = new Draggable(elm, this.el_t, RESIZE_T, this.options); + this.el_r = createElement("div", { name: "grab-r" }, elm); + this.drag_r = new Draggable(elm, this.el_r, RESIZE_R, this.options); + this.el_b = createElement("div", { name: "grab-b" }, elm); + this.drag_b = new Draggable(elm, this.el_b, RESIZE_B, this.options); + this.el_l = createElement("div", { name: "grab-l" }, elm); + this.drag_l = new Draggable(elm, this.el_l, RESIZE_L, this.options); + this.el_tl = createElement("div", { name: "grab-tl" }, elm); + this.drag_tl = new Draggable(elm, this.el_tl, RESIZE_TL, this.options); + this.el_tr = createElement("div", { name: "grab-tr" }, elm); + this.drag_tr = new Draggable(elm, this.el_tr, RESIZE_TR, this.options); + this.el_br = createElement("div", { name: "grab-br" }, elm); + this.drag_br = new Draggable(elm, this.el_br, RESIZE_BR, this.options); + this.el_bl = createElement("div", { name: "grab-bl" }, elm); + this.drag_bl = new Draggable(elm, this.el_bl, RESIZE_BL, this.options); + } + } + } + + class Draggable { + constructor(targetElm, handleElm, op, options) { + Object.assign(this, options); + options = undefined; + + this._targetElm = targetElm; + this._handleElm = handleElm; + + let vw = window.innerWidth; + let vh = window.innerHeight; + let initialX, initialY, initialT, initialL, initialW, initialH; + + const clamp = (value, min, max) => + value < min ? min : value > max ? max : value; + + const moveOp = (x, y) => { + const deltaX = x - initialX; + const deltaY = y - initialY; + const t = clamp(initialT + deltaY, 0, vh - initialH); + const l = clamp(initialL + deltaX, 0, vw - initialW); + this._targetElm.style.top = t + "px"; + this._targetElm.style.left = l + "px"; + }; + + const resizeOp = (x, y) => { + x = clamp(x, 0, vw); + y = clamp(y, 0, vh); + const deltaX = x - initialX; + const deltaY = y - initialY; + const resizeDirX = op & RESIZE_L ? -1 : 1; + const resizeDirY = op & RESIZE_T ? -1 : 1; + const deltaXMax = this.maxWidth - initialW; + const deltaXMin = this.minWidth - initialW; + const deltaYMax = this.maxHeight - initialH; + const deltaYMin = this.minHeight - initialH; + const t = + initialT + + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax) * resizeDirY; + const l = + initialL + + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax) * resizeDirX; + const w = initialW + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax); + const h = initialH + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax); + if (op & RESIZE_T) { + // resize ↑ + this._targetElm.style.top = t + "px"; + this._targetElm.style.height = h + "px"; } - - /** Turn on the drag and drop of the instance */ - enable() { - this.destroy(); // prevent events from getting binded twice - if (this.useMouseEvents) this._handleElm.addEventListener('mousedown', this._dragStartHandler); - if (this.useTouchEvents) this._handleElm.addEventListener('touchstart', this._dragStartHandler, { passive: false }); + if (op & RESIZE_B) { + // resize ↓ + this._targetElm.style.height = h + "px"; } - - /** Teardown all events bound to the document and elements. You can resurrect this instance by calling enable() */ - destroy() { - this._targetElm.classList.remove(this.draggingClass); - if (this.useMouseEvents) { - this._handleElm.removeEventListener('mousedown', this._dragStartHandler); - document.removeEventListener('mousemove', this._dragMoveHandler); - document.removeEventListener('mouseup', this._dragEndHandler); - } - if (this.useTouchEvents) { - this._handleElm.removeEventListener('touchstart', this._dragStartHandler); - document.removeEventListener('touchmove', this._dragMoveHandler); - document.removeEventListener('touchend', this._dragEndHandler); - } + if (op & RESIZE_L) { + // resize ← + this._targetElm.style.left = l + "px"; + this._targetElm.style.width = w + "px"; } - } + if (op & RESIZE_R) { + // resize → + this._targetElm.style.width = w + "px"; + } + }; + + let operation = op === MOVE ? moveOp : resizeOp; + + 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; + initialX = x; + initialY = y; + vw = window.innerWidth; + vh = window.innerHeight; + initialT = this._targetElm.offsetTop; + initialL = this._targetElm.offsetLeft; + initialW = this._targetElm.clientWidth; + initialH = this._targetElm.clientHeight; + if (this.useMouseEvents) { + document.addEventListener("mousemove", this._dragMoveHandler); + document.addEventListener("mouseup", this._dragEndHandler); + } + if (this.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; + } + // perform drag / resize operation + operation(x, y); + } + + function dragEndHandler() { + if (this.useMouseEvents) { + document.removeEventListener("mousemove", this._dragMoveHandler); + document.removeEventListener("mouseup", this._dragEndHandler); + } + if (this.useTouchEvents) { + document.removeEventListener("touchmove", this._dragMoveHandler); + document.removeEventListener("touchend", this._dragEndHandler); + } + this._targetElm.classList.remove(this.draggingClass); + } - function createElement(tag = 'div', attrs, parent) { - const elm = document.createElement(tag); - if (attrs) Object.entries(attrs).forEach(([k, v]) => elm.setAttribute(k, v)); - if (parent) parent.appendChild(elm); - return elm; - } + // We need to bind the handlers to this instance + this._dragStartHandler = dragStartHandler.bind(this); + this._dragMoveHandler = dragMoveHandler.bind(this); + this._dragEndHandler = dragEndHandler.bind(this); - function defaultArgs(defaults, options) { - function isObj(x) { return x !== null && typeof x === 'object'; } - function hasOwn(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); } - if (isObj(options)) for (let prop in defaults) { - if (hasOwn(defaults, prop) && hasOwn(options, prop) && options[prop] !== undefined) { - if (isObj(defaults[prop])) defaultArgs(defaults[prop], options[prop]); - else defaults[prop] = options[prop]; - } - } - return defaults; + this.enable(); } - function createElm(html) { - const temp = document.createElement('div'); - temp.innerHTML = html; - return temp.removeChild(temp.firstElementChild); + /** Turn on the drag and drop of the instance */ + enable() { + this.destroy(); // prevent events from getting binded twice + if (this.useMouseEvents) + this._handleElm.addEventListener("mousedown", this._dragStartHandler); + if (this.useTouchEvents) + this._handleElm.addEventListener("touchstart", this._dragStartHandler, { + passive: false, + }); } - function insertCss(css) { - const style = document.createElement('style'); - style.appendChild(document.createTextNode(css)); - document.head.appendChild(style); - return style; + /** Teardown all events bound to the document and elements. You can resurrect this instance by calling enable() */ + destroy() { + this._targetElm.classList.remove(this.draggingClass); + if (this.useMouseEvents) { + this._handleElm.removeEventListener( + "mousedown", + this._dragStartHandler, + ); + document.removeEventListener("mousemove", this._dragMoveHandler); + document.removeEventListener("mouseup", this._dragEndHandler); + } + if (this.useTouchEvents) { + this._handleElm.removeEventListener( + "touchstart", + this._dragStartHandler, + ); + document.removeEventListener("touchmove", this._dragMoveHandler); + document.removeEventListener("touchend", this._dragEndHandler); + } } - - const messagePickerCss = ` + } + + function createElement(tag = "div", attrs, parent) { + const elm = document.createElement(tag); + if (attrs) + Object.entries(attrs).forEach(([k, v]) => elm.setAttribute(k, v)); + if (parent) parent.appendChild(elm); + return elm; + } + + function defaultArgs(defaults, options) { + function isObj(x) { + return x !== null && typeof x === "object"; + } + function hasOwn(obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop); + } + if (isObj(options)) { + for (let prop in defaults) { + if ( + hasOwn(defaults, prop) && + hasOwn(options, prop) && + options[prop] !== undefined + ) { + if (isObj(defaults[prop])) defaultArgs(defaults[prop], options[prop]); + else defaults[prop] = options[prop]; + } + } + return defaults; + } + } + + 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); @@ -1181,389 +1581,511 @@ body.undiscord-pick-message.after [id^="message-content-"]:hover::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); - }); - } - }; - window.messagePicker = messagePicker; - - function getToken() { - window.dispatchEvent(new Event('beforeunload')); - const LS = document.body.appendChild(document.createElement('iframe')).contentWindow.localStorage; - try { - return JSON.parse(LS.token); - } catch { - log.info('Could not automatically detect Authorization Token in local storage!'); - log.info('Attempting to grab token using webpack'); - return (window.webpackChunkdiscord_app.push([[''], {}, e => { window.m = []; for (let c in e.c) window.m.push(e.c[c]); }]), window.m).find(m => m?.exports?.default?.getToken !== void 0).exports.default.getToken(); + const messagePicker = { + init() { + insertCss(messagePickerCss); + }, + grab(auxiliary) { + return new Promise((resolve) => { + 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 { + resolve(null); + } + } } + document.addEventListener("click", clickHandler); + }); + }, + }; + window.messagePicker = messagePicker; + + function getToken() { + window.dispatchEvent(new Event("beforeunload")); + const LS = document.body.appendChild(document.createElement("iframe")) + .contentWindow.localStorage; + try { + return JSON.parse(LS.token); + } catch { + log.info( + "Could not automatically detect Authorization Token in local storage!", + ); + log.info("Attempting to grab token using webpack"); + return (window.webpackChunkdiscord_app.push([ + [""], + {}, + (e) => { + window.m = []; + for (let c in e.c) window.m.push(e.c[c]); + }, + ]), + window.m) + .find((m) => m?.exports?.default?.getToken !== void 0) + .exports.default.getToken(); } - - function getAuthorId() { - const LS = document.body.appendChild(document.createElement('iframe')).contentWindow.localStorage; - return JSON.parse(LS.user_id_cache); + } + + 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 find the Guild ID!\nPlease make sure you are on a Server or DM.", + ); + } + + async function getChannelId() { + const m = location.href.match(/channels\/([\w@]+)\/(\d+)/); + if (m) { + try { + const response = await fetch( + `https://discord.com/api/v9/channels/${m[2]}`, + { headers: { Authorization: getToken() } }, + ); + const data = await response.json(); + if ([10, 11, 12].includes(data.type)) { + log.info("selecting parent channel"); + return [data.parent_id, true, m[2]]; + } else { + return [m[2], false, null]; + } + } catch { + log.info("Could not get channel type, assuming not thread."); + return [m[2], false, null]; + } + } else + alert( + "Could not find the Channel ID!\nPlease make sure you are on a Channel or DM.", + ); + } + + function fillToken() { + try { + return getToken(); + } catch (err) { + log.verb(err); + log.error("Could not automatically detect Authorization Token!"); + log.info("Please make sure Undiscord is up to date"); + log.debug( + 'Alternatively, you can try entering a Token manually in the "Advanced Settings" section.', + ); } - - function getGuildId() { - const m = location.href.match(/channels\/([\w@]+)\/(\d+)/); - if (m) return m[1]; - else alert('Could not find the Guild ID!\nPlease make sure you are on a Server or DM.'); + return ""; + } + + const PREFIX = "[UNDISCORD]"; + + // -------------------------- User interface ------------------------------- // + + // links + const HOME = "https://github.com/victornpb/undiscord"; + const WIKI = "https://github.com/victornpb/undiscord/wiki"; + + const undiscordCore = new UndiscordCore(); + messagePicker.init(); + + const state = { + threadId: null, + isThread: false, + }; + const ui = { + undiscordWindow: null, + undiscordBtn: null, + logArea: null, + autoScroll: null, + trimLog: null, + + // progress handler + progressMain: null, + progressIcon: null, + percent: null, + }; + const $ = (s) => ui.undiscordWindow.querySelector(s); + + function initUI() { + insertCss(themeCss); + insertCss(mainCss); + insertCss(dragCss); + + // create undiscord window + const undiscordUI = replaceInterpolations(undiscordTemplate, { + VERSION, + HOME, + WIKI, + }); + ui.undiscordWindow = createElm(undiscordUI); + document.body.appendChild(ui.undiscordWindow); + + // enable drag and resize on undiscord window + new DragResize({ elm: ui.undiscordWindow, moveHandle: $(".header") }); + + // create undiscord Trash icon + ui.undiscordBtn = createElm(buttonHtml); + ui.undiscordBtn.onclick = toggleWindow; + function mountBtn() { + const toolbar = document.querySelector("#app-mount [class^=toolbar]"); + if (toolbar) toolbar.appendChild(ui.undiscordBtn); } - - function getChannelId() { - const m = location.href.match(/channels\/([\w@]+)\/(\d+)/); - if (m) return m[2]; - else alert('Could not find the Channel ID!\nPlease make sure you are on a Channel or DM.'); + mountBtn(); + // watch for changes and re-mount button if necessary + const discordElm = document.querySelector("#app-mount"); + let observerThrottle = null; + const observer = new MutationObserver(() => { + if (observerThrottle) return; + observerThrottle = setTimeout(() => { + observerThrottle = null; + if (!discordElm.contains(ui.undiscordBtn)) mountBtn(); // re-mount the button to the toolbar + }, 3000); + }); + observer.observe(discordElm, { + attributes: false, + childList: true, + subtree: true, + }); + + function toggleWindow() { + if (ui.undiscordWindow.style.display !== "none") { + ui.undiscordWindow.style.display = "none"; + ui.undiscordBtn.style.color = "var(--interactive-normal)"; + } else { + ui.undiscordWindow.style.display = ""; + ui.undiscordBtn.style.color = "var(--interactive-active)"; + } } - function fillToken() { - try { - return getToken(); - } catch (err) { - log.verb(err); - log.error('Could not automatically detect Authorization Token!'); - log.info('Please make sure Undiscord is up to date'); - log.debug('Alternatively, you can try entering a Token manually in the "Advanced Settings" section.'); - } - return ''; + function validateHasFileFilter(clickedElement) { + const hasFile = $("input#hasFile"); + const hasNoFile = $("input#hasNoFile"); + if (hasFile.checked && hasNoFile.checked) { + clickedElement.checked = false; + alert( + 'You cannot have both "Has File" and "Has No File" checked at the same time. Please uncheck one of them.', + ); + } } - const PREFIX = '[UNDISCORD]'; - - // -------------------------- User interface ------------------------------- // - - // links - const HOME = 'https://github.com/victornpb/undiscord'; - const WIKI = 'https://github.com/victornpb/undiscord/wiki'; - - const undiscordCore = new UndiscordCore(); - messagePicker.init(); - - const ui = { - undiscordWindow: null, - undiscordBtn: null, - logArea: null, - autoScroll: null, - - // progress handler - progressMain: null, - progressIcon: null, - percent: null, + // cached elements + ui.logArea = $("#logArea"); + ui.autoScroll = $("#autoScroll"); + ui.trimLog = $("#trimLog"); + ui.progressMain = $("#progressBar"); + ui.progressIcon = ui.undiscordBtn.querySelector("progress"); + ui.percent = $("#progressPercent"); + + // register event listeners + $("#hide").onclick = toggleWindow; + $("#toggleSidebar").onclick = () => + ui.undiscordWindow.classList.toggle("hide-sidebar"); + $("button#start").onclick = startAction; + $("button#stop").onclick = stopAction; + $("button#clear").onclick = () => { + ui.logArea.innerHTML = ""; + }; + $("button#getAuthor").onclick = () => { + $("input#authorId").value = getAuthorId(); + }; + $("button#getGuild").onclick = async () => { + const guildId = ($("input#guildId").value = getGuildId()); + if (guildId === "@me") { + state.isThread = 0; + $("input#channelId").value = await getChannelId()[0]; + } + }; + $("button#getChannel").onclick = async () => { + const id = await getChannelId(); + state.isThread = id[1]; + state.threadId = id[2]; + $("input#channelId").value = id[0]; + $("input#guildId").value = getGuildId(); + }; + $("#redact").onchange = () => { + const b = ui.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.", + ); + toggleWindow(); + const id = await messagePicker.grab("after"); + if (id) $("input#minId").value = id; + toggleWindow(); + }; + $("#pickMessageBefore").onclick = async () => { + alert( + "Select a message on the chat.\nThe message above it will be deleted.", + ); + toggleWindow(); + const id = await messagePicker.grab("before"); + if (id) $("input#maxId").value = id; + toggleWindow(); + }; + $("button#getToken").onclick = () => { + $("input#token").value = fillToken(); + }; + $("input#hasFile").onclick = function () { + validateHasFileFilter(this); + }; + $("input#hasNoFile").onclick = function () { + validateHasFileFilter(this); }; - const $ = s => ui.undiscordWindow.querySelector(s); - - function initUI() { - - insertCss(themeCss); - insertCss(mainCss); - insertCss(dragCss); - // create undiscord window - const undiscordUI = replaceInterpolations(undiscordTemplate, { - VERSION, - HOME, - WIKI, - }); - ui.undiscordWindow = createElm(undiscordUI); - document.body.appendChild(ui.undiscordWindow); - - // enable drag and resize on undiscord window - new DragResize({ elm: ui.undiscordWindow, moveHandle: $('.header') }); - - // create undiscord Trash icon - ui.undiscordBtn = createElm(buttonHtml); - ui.undiscordBtn.onclick = toggleWindow; - function mountBtn() { - const toolbar = document.querySelector('#app-mount [class^=toolbar]'); - if (toolbar) toolbar.appendChild(ui.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(ui.undiscordBtn)) mountBtn(); // re-mount the button to the toolbar - }, 3000); - }); - observer.observe(discordElm, { attributes: false, childList: true, subtree: true }); + // sync advanced settings + $("input#searchDelay").onchange = (e) => { + const v = parseInt(e.target.value); + if (v) undiscordCore.options.searchDelay = v; + }; + $("input#deleteDelay").onchange = (e) => { + const v = parseInt(e.target.value); + if (v) undiscordCore.options.deleteDelay = v; + }; + $("input#rateLimitPrevention").onchange = (e) => { + undiscordCore.options.rateLimitPrevention = e.target.checked ?? false; + }; + $("input#searchDelay").addEventListener("input", (event) => { + $("div#searchDelayValue").textContent = event.target.value + "ms"; + }); + $("input#deleteDelay").addEventListener("input", (event) => { + $("div#deleteDelayValue").textContent = event.target.value + "ms"; + }); + + // import json + const fileSelection = $("input#importJsonInput"); + fileSelection.onchange = async () => { + const files = fileSelection.files; + + // No files added + if (files.length === 0) return log.warn("No file selected."); + + // Get channel id field to set it later + const channelIdField = $("input#channelId"); + + // Force the guild id to be 'null' (placeholder value) + const guildIdField = $("input#guildId"); + guildIdField.value = "null"; + + // Set author id in case its not set already + $("input#authorId").value = getAuthorId(); + try { + const file = files[0]; + const text = await file.text(); + const json = JSON.parse(text); + const channelIds = Object.keys(json); + channelIdField.value = channelIds.join(","); + log.info(`Loaded ${channelIds.length} channels.`); + } catch (err) { + log.error("Error parsing file!", err); + } + }; - function toggleWindow() { - if (ui.undiscordWindow.style.display !== 'none') { - ui.undiscordWindow.style.display = 'none'; - ui.undiscordBtn.style.color = 'var(--interactive-normal)'; - } - else { - ui.undiscordWindow.style.display = ''; - ui.undiscordBtn.style.color = 'var(--interactive-active)'; - } + // redirect console logs to inside the window after setting up the UI + setLogFn(printLog); + + setupUndiscordCore(); + } + + function printLog(type = "", args) { + ui.logArea.insertAdjacentHTML( + "beforeend", + `
${Array.from(args) + .map((o) => + typeof o === "object" + ? JSON.stringify( + o, + o instanceof Error && Object.getOwnPropertyNames(o), + ) + : o, + ) + .join("\t")}
`, + ); + + if (ui.trimLog.checked) { + const maxLogEntries = 500; + const logEntries = ui.logArea.querySelectorAll(".log"); + if (logEntries.length > maxLogEntries) { + for (let i = 0; i < logEntries.length - maxLogEntries; i++) { + logEntries[i].remove(); } - - // cached elements - ui.logArea = $('#logArea'); - ui.autoScroll = $('#autoScroll'); - ui.progressMain = $('#progressBar'); - ui.progressIcon = ui.undiscordBtn.querySelector('progress'); - ui.percent = $('#progressPercent'); - - // register event listeners - $('#hide').onclick = toggleWindow; - $('#toggleSidebar').onclick = () => ui.undiscordWindow.classList.toggle('hide-sidebar'); - $('button#start').onclick = startAction; - $('button#stop').onclick = stopAction; - $('button#clear').onclick = () => ui.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 = ui.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.'); - toggleWindow(); - const id = await messagePicker.grab('after'); - if (id) $('input#minId').value = id; - toggleWindow(); - }; - $('#pickMessageBefore').onclick = async () => { - alert('Select a message on the chat.\nThe message above it will be deleted.'); - toggleWindow(); - const id = await messagePicker.grab('before'); - if (id) $('input#maxId').value = id; - toggleWindow(); - }; - $('button#getToken').onclick = () => $('input#token').value = fillToken(); - - // sync delays - $('input#searchDelay').onchange = (e) => { - const v = parseInt(e.target.value); - if (v) undiscordCore.options.searchDelay = v; - }; - $('input#deleteDelay').onchange = (e) => { - const v = parseInt(e.target.value); - if (v) undiscordCore.options.deleteDelay = v; - }; - - $('input#searchDelay').addEventListener('input', (event) => { - $('div#searchDelayValue').textContent = event.target.value + 'ms'; - }); - $('input#deleteDelay').addEventListener('input', (event) => { - $('div#deleteDelayValue').textContent = event.target.value + 'ms'; - }); - - // import json - const fileSelection = $('input#importJsonInput'); - fileSelection.onchange = async () => { - const files = fileSelection.files; - - // No files added - if (files.length === 0) return log.warn('No file selected.'); - - // Get channel id field to set it later - const channelIdField = $('input#channelId'); - - // Force the guild id to be ourself (@me) - const guildIdField = $('input#guildId'); - guildIdField.value = '@me'; - - // Set author id in case its not set already - $('input#authorId').value = getAuthorId(); - try { - const file = files[0]; - const text = await file.text(); - const json = JSON.parse(text); - const channelIds = Object.keys(json); - channelIdField.value = channelIds.join(','); - log.info(`Loaded ${channelIds.length} channels.`); - } catch (err) { - log.error('Error parsing file!', err); - } - }; - - // redirect console logs to inside the window after setting up the UI - setLogFn(printLog); - - setupUndiscordCore(); - } - - function printLog(type = '', args) { - ui.logArea.insertAdjacentHTML('beforeend', `
${Array.from(args).map(o => typeof o === 'object' ? JSON.stringify(o, o instanceof Error && Object.getOwnPropertyNames(o)) : o).join('\t')}
`); - if (ui.autoScroll.checked) ui.logArea.querySelector('div:last-child').scrollIntoView(false); - if (type === 'error') console.error(PREFIX, ...Array.from(args)); + } } - function setupUndiscordCore() { + if (ui.autoScroll.checked) + ui.logArea.querySelector("div:last-child").scrollIntoView(false); + if (type === "error") console.error(PREFIX, ...Array.from(args)); + } - undiscordCore.onStart = (state, stats) => { - console.log(PREFIX, 'onStart', state, stats); - $('#start').disabled = true; - $('#stop').disabled = false; - - ui.undiscordBtn.classList.add('running'); - ui.progressMain.style.display = 'block'; - ui.percent.style.display = 'block'; - }; - - undiscordCore.onProgress = (state, stats) => { - // console.log(PREFIX, 'onProgress', state, stats); - let max = state.grandTotal; - const value = state.delCount + state.failCount; - max = Math.max(max, value, 0); // clamp max - - // status bar - const percent = value >= 0 && max ? Math.round(value / max * 100) + '%' : ''; - const elapsed = msToHMS(Date.now() - stats.startTime.getTime()); - const remaining = msToHMS(stats.etr); - ui.percent.innerHTML = `${percent} (${value}/${max}) Elapsed: ${elapsed} Remaining: ${remaining}`; - - ui.progressIcon.value = value; - ui.progressMain.value = value; - - // indeterminate progress bar - if (max) { - ui.progressIcon.setAttribute('max', max); - ui.progressMain.setAttribute('max', max); - } else { - ui.progressIcon.removeAttribute('value'); - ui.progressMain.removeAttribute('value'); - ui.percent.innerHTML = '...'; - } - - // update delays - const searchDelayInput = $('input#searchDelay'); - searchDelayInput.value = undiscordCore.options.searchDelay; - $('div#searchDelayValue').textContent = undiscordCore.options.searchDelay + 'ms'; - - const deleteDelayInput = $('input#deleteDelay'); - deleteDelayInput.value = undiscordCore.options.deleteDelay; - $('div#deleteDelayValue').textContent = undiscordCore.options.deleteDelay + 'ms'; - }; + function setupUndiscordCore() { + undiscordCore.onStart = (state, stats) => { + console.log(PREFIX, "onStart", state, stats); + $("#start").disabled = true; + $("#stop").disabled = false; - undiscordCore.onStop = (state, stats) => { - console.log(PREFIX, 'onStop', state, stats); - $('#start').disabled = false; - $('#stop').disabled = true; - ui.undiscordBtn.classList.remove('running'); - ui.progressMain.style.display = 'none'; - ui.percent.style.display = 'none'; - }; - } + ui.undiscordBtn.classList.add("running"); + ui.progressMain.style.display = "block"; + ui.percent.style.display = "block"; + }; - async function startAction() { - console.log(PREFIX, 'startAction'); - // general - 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()); - - // token - const authToken = $('input#token').value.trim() || fillToken(); - if (!authToken) return; // get token already logs an error. - - // validate input - if (!guildId) return log.error('You must fill the "Server ID" field!'); - - // clear logArea - ui.logArea.innerHTML = ''; - - undiscordCore.resetState(); - undiscordCore.options = { - ...undiscordCore.options, - authToken, - authorId, - guildId, - channelId: channelIds.length === 1 ? channelIds[0] : undefined, // single or multiple channel - minId: minId || minDate, - maxId: maxId || maxDate, - content, - hasLink, - hasFile, - includeNsfw, - includePinned, - pattern, - searchDelay, - deleteDelay, - // maxAttempt: 2, - }; - if (channelIds.length > 1) { - const jobs = channelIds.map(ch => ({ - guildId: guildId, - channelId: ch, - })); + undiscordCore.onProgress = (state, stats) => { + // console.log(PREFIX, 'onProgress', state, stats); + let max = state.grandTotal; + const value = state.delCount + state.failCount; + max = Math.max(max, value, 0); // clamp max + + // status bar + const percent = + value >= 0 && max ? Math.round((value / max) * 100) + "%" : ""; + const elapsed = msToHMS(Date.now() - stats.startTime.getTime()); + const remaining = msToHMS(stats.etr); + ui.percent.innerHTML = `${percent} (${value}/${max}) Elapsed: ${elapsed} Remaining: ${remaining}`; + + ui.progressIcon.value = value; + ui.progressMain.value = value; + + // indeterminate progress bar + if (max) { + ui.progressIcon.setAttribute("max", max); + ui.progressMain.setAttribute("max", max); + } else { + ui.progressIcon.removeAttribute("value"); + ui.progressMain.removeAttribute("value"); + ui.percent.innerHTML = "..."; + } + + // update delays + const searchDelayInput = $("input#searchDelay"); + searchDelayInput.value = undiscordCore.options.searchDelay; + $("div#searchDelayValue").textContent = + undiscordCore.options.searchDelay + "ms"; + + const deleteDelayInput = $("input#deleteDelay"); + deleteDelayInput.value = undiscordCore.options.deleteDelay; + $("div#deleteDelayValue").textContent = + undiscordCore.options.deleteDelay + "ms"; + }; - try { - await undiscordCore.runBatch(jobs); - } catch (err) { - log.error('CoreException', err); - } - } - // single channel - else { - try { - await undiscordCore.run(); - } catch (err) { - log.error('CoreException', err); - undiscordCore.stop(); - } - } + undiscordCore.onStop = (state, stats) => { + console.log(PREFIX, "onStop", state, stats); + $("#start").disabled = false; + $("#stop").disabled = true; + ui.undiscordBtn.classList.remove("running"); + ui.progressMain.style.display = "none"; + ui.percent.style.display = "none"; + }; + } + + async function startAction() { + console.log(PREFIX, "startAction"); + // general + const authorId = $("input#authorId").value.trim(); + const guildId = $("input#guildId").value.trim(); + const channelIds = $("input#channelId") + .value.trim() + .split(/\s*,\s*/); + const isThread = state.isThread; + const threadId = state.threadId; + const includeNsfw = $("input#includeNsfw").checked; + const includeServers = $("input#includeServers").checked; + // filter + const content = $("input#search").value.trim(); + const hasLink = $("input#hasLink").checked; + const hasFile = $("input#hasFile").checked; + const hasNoFile = $("input#hasNoFile").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()); + const rateLimitPrevention = $("input#rateLimitPrevention").checked; + + // token + const authToken = $("input#token").value.trim() || fillToken(); + if (!authToken) return; // get token already logs an error. + + // validate input + if (!guildId) return log.error('You must fill the "Server ID" field!'); + + // clear logArea + ui.logArea.innerHTML = ""; + + undiscordCore.resetState(); + undiscordCore.options = { + ...undiscordCore.options, + authToken, + authorId, + guildId, + channelId: channelIds.length === 1 ? channelIds[0] : undefined, // single or multiple channel + isThread, + threadId, + minId: minId || minDate, + maxId: maxId || maxDate, + content, + hasLink, + hasFile, + hasNoFile, + includeNsfw, + includeServers, + includePinned, + pattern, + searchDelay, + deleteDelay, + rateLimitPrevention, + // maxAttempt: 2, + }; + if (channelIds.length > 1) { + const jobs = channelIds.map((ch) => ({ + guildId: null, + channelId: ch, + })); + + try { + await undiscordCore.runBatch(jobs); + } catch (err) { + log.error("CoreException", err); + } } - - function stopAction() { - console.log(PREFIX, 'stopAction'); + // single channel + else { + try { + await undiscordCore.run(); + } catch (err) { + log.error("CoreException", err); undiscordCore.stop(); + } } + } - // ---- END Undiscord ---- + function stopAction() { + console.log(PREFIX, "stopAction"); + undiscordCore.stop(); + } - initUI(); + // ---- END Undiscord ---- + initUI(); })();