Delete all messages in a Discord channel or DM (Bulk deletion)
// ==UserScript== // @name TGD-Discord // @description Delete all messages in a Discord channel or DM (Bulk deletion) // @version 7.0.0 // @author Solomon Shalom Lijo // @homepageURL https://github.com/solomonshalom/TGD-Discord // @supportURL https://github.com/solomonshalom/TGD-Discord/issues // @match https://*.discord.com/app // @match https://*.discord.com/channels/* // @match https://*.discord.com/login // @license MIT // @namespace https://github.com/solomonshalom/TGD-Discord // @contributionURL https://ko-fi.com/solomonlijo // @grant none // ==/UserScript== (function () { 'use strict'; var version = "5.0.2"; var discordStyles = (` /* tgddiscord window */ #tgddiscord.browser { box-shadow: var(--elevation-stroke), var(--elevation-high); overflow: hidden; } #tgddiscord.container, #tgddiscord .container { background-color: var(--background-secondary); border-radius: 8px; box-sizing: border-box; cursor: default; flex-direction: column; } #tgddiscord .header { background-color: var(--background-tertiary); height: 48px; align-items: center; min-height: 48px; padding: 0 16px; display: flex; color: var(--header-secondary); } #tgddiscord .header .icon { color: var(--interactive-normal); margin-right: 8px; flex-shrink: 0; width: 24; height: 24; } #tgddiscord .header .icon:hover { color: var(--interactive-hover); } #tgddiscord .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; } #tgddiscord .header .spacer { flex-grow: 1; } #tgddiscord .header .vert-divider { width: 1px; height: 24px; background-color: var(--background-modifier-accent); margin-right: 16px; flex-shrink: 0; } #tgddiscord legend, #tgddiscord 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; } #tgddiscord .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; } #tgddiscord .multiInput :first-child { flex-grow: 1; } #tgddiscord .multiInput button:last-child { margin-right: 4px; } #tgddiscord .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; } #tgddiscord fieldset { margin-top: 16px; } #tgddiscord .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; } #tgddiscord input[type="text"], #tgddiscord input[type="search"], #tgddiscord input[type="password"], #tgddiscord input[type="datetime-local"], #tgddiscord 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; } #tgddiscord .divider, #tgddiscord hr { border: none; margin-bottom: 24px; padding-bottom: 4px; border-bottom: 1px solid var(--background-modifier-accent); } #tgddiscord .sectionDescription { margin-bottom: 16px; color: var(--header-secondary); font-size: 14px; line-height: 20px; font-weight: 400; } #tgddiscord a { color: var(--text-link); text-decoration: none; } #tgddiscord .btn, #tgddiscord 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); } #tgddiscord .sizeMedium { width: 96px; height: 38px; min-width: 96px; min-height: 38px; } /* lookFilled colorPrimary */ #tgddiscord .accent { background-color: var(--brand-experiment); } #tgddiscord .danger { background-color: var(--button-danger-background); } #tgddiscord .positive { background-color: var(--button-positive-background); } #tgddiscord .info { font-size: 12px; line-height: 16px; padding: 8px 10px; color: var(--text-muted); } /* Scrollbar */ #tgddiscord .scroll::-webkit-scrollbar { width: 8px; height: 8px; } #tgddiscord .scroll::-webkit-scrollbar-corner { background-color: transparent; } #tgddiscord .scroll::-webkit-scrollbar-thumb { background-clip: padding-box; border: 2px solid transparent; border-radius: 4px; background-color: var(--scrollbar-thin-thumb); min-height: 40px; } #tgddiscord .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 */ #tgddiscord .scroll::-webkit-scrollbar-thumb, #tgddiscord .scroll::-webkit-scrollbar-track { visibility: hidden; } #tgddiscord .scroll:hover::-webkit-scrollbar-thumb, #tgddiscord .scroll:hover::-webkit-scrollbar-track { visibility: visible; } `); var tgddiscordStyles = (` /**** tgddiscord 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; } /**** tgddiscord Interface ****/ #tgddiscord { 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; } #tgddiscord .header .icon { cursor: pointer; } #tgddiscord .window-body { height: calc(100% - 48px); } #tgddiscord .sidebar { overflow: hidden scroll; overflow-y: auto; width: 270px; min-width: 250px; height: 100%; max-height: 100%; padding: 8px; background: var(--background-secondary); } #tgddiscord .main { display: flex; max-width: calc(100% - 250px); background-color: var(--background-primary); flex-grow: 1; } #tgddiscord #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; } #tgddiscord .tbar { padding: 8px; background-color: var(--background-secondary-alt); } #tgddiscord .tbar button { margin-right: 4px; margin-bottom: 4px; } #tgddiscord .footer { cursor: se-resize; } /**** Elements ****/ #tgddiscord 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; } #tgddiscord fieldset { padding-left: 8px; } /* help link */ #tgddiscord legend a { float: right; text-transform: initial; } #tgddiscord progress { height: 8px; margin-top: 4px; flex-grow: 1; /* background-color: var(--background-primary); border-radius: 3px; */ } /* #tgddiscord progress::-webkit-progress-value{ background-color: var(--brand-experiment); } */ /**** functional classes ****/ #tgddiscord.redact .priv { display: none !important; } #tgddiscord:not(.redact) .mask { display: none !important; } #tgddiscord.redact [priv] { -webkit-text-security: disc !important; } #tgddiscord :disabled { display: none; } /**** layout misc ****/ #tgddiscord, #tgddiscord * { box-sizing: border-box; } #tgddiscord .col { display: flex; flex-direction: column; } #tgddiscord .row { display: flex; flex-direction: row; align-items: center; } #tgddiscord .mb1 { margin-bottom: 8px; } `); var buttonHtml = (` <div id="undicord-btn" tabindex="0" role="button" aria-label="Delete Messages" title="Delete Messages with tgddiscord"> <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 tgddiscordTemplate = (` <div id="tgddiscord" 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>tgddiscord</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"> tgddiscord {{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/solomonshalom/TGD-Discord" 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 Solomon Shalom Lijo <https://www.github.com/solomonshalom> * @see https://github.com/solomonshalom/TGD-Discord */ 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 => ({ '&': '&', '<': '<', '"': '"', '\'': ''' })[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_r###lts; 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_r###lts}`, `(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 r###rrect 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.tgddiscord-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.tgddiscord-pick-message [id^="message-content-"]:hover { cursor: pointer; cursor: cell; background: var(--background-message-automod-hover); } body.tgddiscord-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.tgddiscord-pick-message.before [id^="message-content-"]:hover::after { content: 'Before 👆'; } body.tgddiscord-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('tgddiscord-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('tgddiscord-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/solomonshalom/TGD-Discord'; const WIKI = 'https://github.com/solomonshalom/TGD-Discord/wiki'; const $ = s => tgddiscordWindow.querySelector(s); let tgddiscordWindow; let tgddiscordBtn; function initUI() { insertCss(discordStyles); insertCss(tgddiscordStyles); function replaceInterpolations(str, obj, removeMissing = false) { return str.replace(/\{\{([\w_]+)\}\}/g, (m, key) => obj[key] || (removeMissing ? '' : m)); } const templateVariables = { VERSION: version, HOME, WIKI, }; // create tgddiscord window const tgddiscordUI = replaceInterpolations(tgddiscordTemplate, templateVariables); tgddiscordWindow = createElm(tgddiscordUI); document.body.appendChild(tgddiscordWindow); new Drag(tgddiscordWindow, $('.header'), { mode: 'move' }); new Drag(tgddiscordWindow, $('.footer'), { mode: 'resize' }); // create tgddiscord button tgddiscordBtn = createElm(buttonHtml); tgddiscordBtn.onclick = toggleWindow; function mountBtn() { const toolbar = document.querySelector('#app-mount [class^=toolbar]'); if (toolbar) toolbar.appendChild(tgddiscordBtn); } 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(tgddiscordBtn)) mountBtn(); // re-mount the button to the toolbar }, 3000); }); observer.observe(discordElm, { attributes: false, childList: true, subtree: true }); function toggleWindow() { if (tgddiscordWindow.style.display !== 'none') { tgddiscordWindow.style.display = 'none'; tgddiscordBtn.style.color = 'var(--interactive-normal)'; } else { tgddiscordWindow.style.display = ''; tgddiscordBtn.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 = tgddiscordWindow.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 = tgddiscordBtn.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 tgddiscord 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'; tgddiscordBtn.querySelector('progress').style.display = 'none'; } initUI(); // ---- END tgddiscord ---- })();