Undiscord

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

Size

64.6 KB

Version

5.2.6

Created

Mar 21, 2026

Updated

26 days ago

1// ==UserScript==
2// @name            Undiscord
3// @description     Delete all messages in a Discord channel or DM (Bulk deletion)
4// @version         5.2.6
5// @author          victornpb
6// @homepageURL     https://github.com/victornpb/undiscord
7// @supportURL      https://github.com/victornpb/undiscord/discussions
8// @match           https://*.discord.com/app
9// @match           https://*.discord.com/channels/*
10// @match           https://*.discord.com/login
11// @license         MIT
12// @namespace       https://github.com/victornpb/deleteDiscordMessages
13// @icon            https://victornpb.github.io/undiscord/images/icon128.png
14// @contributionURL https://www.buymeacoffee.com/vitim
15// @grant           none
16// @attribution     Original project (https://github.com/victornpb/undiscord)
17// @downloadURL https://update.greasyfork.org/scripts/406540/Undiscord.user.js
18// @updateURL https://update.greasyfork.org/scripts/406540/Undiscord.meta.js
19// ==/UserScript==
20(function () {
21	'use strict';
22
23	/* rollup-plugin-baked-env */
24	const VERSION = "5.2.6";
25
26	var themeCss = (`
27/* undiscord window */
28#undiscord.browser { box-shadow: var(--shadow-border), var(--shadow-high); border: 1px solid var(--border-subtle); overflow: hidden; }
29#undiscord.container,
30#undiscord .container { background-color: var(--background-surface-high); border-radius: 8px; box-sizing: border-box; cursor: default; flex-direction: column; }
31#undiscord .header { background-color: var(--background-tertiary); height: 48px; align-items: center; min-height: 48px; padding: 0 16px; display: flex; color: var(--header-secondary); cursor: grab; }
32#undiscord .header .icon { color: var(--interactive-normal); margin-right: 8px; flex-shrink: 0; width: 24; height: 24; }
33#undiscord .header .icon:hover { color: var(--interactive-hover); }
34#undiscord .header h3 { font-size: 16px; line-height: 20px; font-weight: 500; font-family: var(--font-display); color: var(--header-primary); flex-shrink: 0; margin-right: 16px; }
35#undiscord .spacer { flex-grow: 1; }
36#undiscord .header .vert-divider { width: 1px; height: 24px; background-color: var(--background-modifier-accent); margin-right: 16px; flex-shrink: 0; }
37#undiscord legend,
38#undiscord label { 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; }
39#undiscord .multiInput { display: flex; align-items: center; font-size: 16px; box-sizing: border-box; width: 100%; border-radius: 3px; color: var(--text-default); background-color: var(--input-background); border: none; transition: border-color 0.2s ease-in-out 0s; }
40#undiscord .multiInput :first-child { flex-grow: 1; }
41#undiscord .multiInput button:last-child { margin-right: 4px; }
42#undiscord .input { font-size: 16px; width: 100%; transition: border-color 0.2s ease-in-out 0s; padding: 10px; height: 44px; background-color: var(--input-background); border: 1px solid var(--input-border); border-radius: 8px; box-sizing: border-box; color: var(--text-default); }
43#undiscord fieldset { margin-top: 16px; }
44#undiscord .input-wrapper { display: flex; align-items: center; font-size: 16px; box-sizing: border-box; width: 100%; border-radius: 3px; color: var(--text-default); background-color: var(--input-background); border: none; transition: border-color 0.2s ease-in-out 0s; }
45#undiscord input[type="text"],
46#undiscord input[type="search"],
47#undiscord input[type="password"],
48#undiscord input[type="datetime-local"],
49#undiscord input[type="number"],
50#undiscord input[type="range"] { background-color: var(--input-background); border: 1px solid var(--input-border); border-radius: 8px; box-sizing: border-box; color: var(--text-default); font-size: 16px; height: 44px; padding: 12px 10px; transition: border-color .2s ease-in-out; width: 100%; }
51#undiscord .divider,
52#undiscord hr { border: none; margin-bottom: 24px; padding-bottom: 4px; border-bottom: 1px solid var(--background-modifier-accent); }
53#undiscord .sectionDescription { margin-bottom: 16px; color: var(--header-secondary); font-size: 14px; line-height: 20px; font-weight: 400; }
54#undiscord a { color: var(--text-link); text-decoration: none; }
55#undiscord .btn,
56#undiscord button { position: relative; display: flex; -webkit-box-pack: center; justify-content: center; -webkit-box-align: center; align-items: center; box-sizing: border-box; background: none; border: none; border-radius: 3px; font-size: 14px; font-weight: 500; line-height: 16px; padding: 2px 16px; user-select: none; /* sizeSmall */     width: 60px; height: 32px; min-width: 60px; min-height: 32px; /* lookFilled colorPrimary */     color: rgb(255, 255, 255); background-color: var(--button-secondary-background); }
57#undiscord .sizeMedium { width: 96px; height: 38px; min-width: 96px; min-height: 38px; }
58#undiscord .sizeMedium.icon { width: 38px; min-width: 38px; }
59#undiscord sup { vertical-align: top; }
60/* lookFilled colorPrimary */
61#undiscord .accent { background-color: var(--brand-experiment); }
62#undiscord .danger { background-color: var(--button-danger-background); }
63#undiscord .positive { background-color: var(--button-positive-background); }
64#undiscord .info { font-size: 12px; line-height: 16px; padding: 8px 10px; color: var(--text-muted); }
65/* Scrollbar */
66#undiscord .scroll::-webkit-scrollbar { width: 8px; height: 8px; }
67#undiscord .scroll::-webkit-scrollbar-corner { background-color: transparent; }
68#undiscord .scroll::-webkit-scrollbar-thumb { background-clip: padding-box; border: 2px solid transparent; border-radius: 4px; background-color: var(--scrollbar-thin-thumb); min-height: 40px; }
69#undiscord .scroll::-webkit-scrollbar-track { border-color: var(--scrollbar-thin-track); background-color: var(--scrollbar-thin-track); border: 2px solid var(--scrollbar-thin-track); }
70/* fade scrollbar */
71#undiscord .scroll::-webkit-scrollbar-thumb,
72#undiscord .scroll::-webkit-scrollbar-track { visibility: hidden; }
73#undiscord .scroll:hover::-webkit-scrollbar-thumb,
74#undiscord .scroll:hover::-webkit-scrollbar-track { visibility: visible; }
75/**** functional classes ****/
76#undiscord.redact .priv { display: none !important; }
77#undiscord.redact x:not(:active) { color: transparent !important; background-color: var(--primary-700) !important; cursor: default; user-select: none; }
78#undiscord.redact x:hover { position: relative; }
79#undiscord.redact x:hover::after { content: "Redacted information (Streamer mode: ON)"; position: absolute; display: inline-block; top: -32px; left: -20px; padding: 4px; width: 150px; font-size: 8pt; text-align: center; white-space: pre-wrap; background-color: var(--background-floating); -webkit-box-shadow: var(--elevation-high); box-shadow: var(--elevation-high); color: var(--text-default); border-radius: 5px; pointer-events: none; }
80#undiscord.redact [priv] { -webkit-text-security: disc !important; }
81#undiscord :disabled { display: none; }
82/**** layout and utility classes ****/
83#undiscord,
84#undiscord * { box-sizing: border-box; }
85#undiscord .col { display: flex; flex-direction: column; }
86#undiscord .row { display: flex; flex-direction: row; align-items: center; }
87#undiscord .mb1 { margin-bottom: 8px; }
88#undiscord .log { margin-bottom: 0.25em; }
89#undiscord .log-debug { color: inherit; }
90#undiscord .log-info { color: #00b0f4; }
91#undiscord .log-verb { color: #72767d; }
92#undiscord .log-warn { color: #faa61a; }
93#undiscord .log-error { color: #f04747; }
94#undiscord .log-success { color: #43b581; }
95`);
96
97	var mainCss = (`
98/**** Undiscord Button ****/
99#undicord-btn { position: relative; width: auto; height: 24px; margin: 0 8px; cursor: pointer; color: var(--interactive-normal); flex: 0 0 auto; }
100#undicord-btn progress { position: absolute; top: 23px; left: -4px; width: 32px; height: 12px; display: none; }
101#undicord-btn.running { color: var(--button-danger-background) !important; }
102#undicord-btn.running progress { display: block; }
103/**** Undiscord Interface ****/
104#undiscord { position: fixed; z-index: 100; top: 58px; right: 10px; display: flex; flex-direction: column; width: 800px; height: 80vh; min-width: 610px; max-width: 100vw; min-height: 448px; max-height: 100vh; 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; }
105#undiscord .header .icon { cursor: pointer; }
106#undiscord .window-body { height: calc(100% - 48px); }
107#undiscord .sidebar { overflow: hidden scroll; overflow-y: auto; width: 270px; min-width: 250px; height: 100%; max-height: 100%; padding: 8px; background: var(--bg-overlay-4, var(--background-base-lowest)); }
108#undiscord .sidebar legend,
109#undiscord .sidebar label { display: block; width: 100%; }
110#undiscord .main { display: flex; max-width: calc(100% - 250px); background-color: var(--bg-overlay-chat, var(--background-base-lower)); flex-grow: 1; }
111#undiscord.hide-sidebar .sidebar { display: none; }
112#undiscord.hide-sidebar .main { max-width: 100%; }
113#undiscord #logArea { font-family: Consolas, Liberation Mono, Menlo, Courier, monospace; font-size: 0.75rem; overflow: auto; padding: 10px; user-select: text; flex-grow: 1; flex-grow: 1; cursor: auto; }
114#undiscord .tbar { padding: 8px; background-color: var(--bg-overlay-2, var(--__header-bar-background)); }
115#undiscord .tbar button { margin-right: 4px; margin-bottom: 4px; }
116#undiscord .footer { cursor: se-resize; padding-right: 30px; }
117#undiscord .footer #progressPercent { padding: 0 1em; font-size: small; color: var(--interactive-muted); flex-grow: 1; }
118.resize-handle { position: absolute; bottom: -15px; right: -15px; width: 30px; height: 30px; transform: rotate(-45deg); background: repeating-linear-gradient(0, var(--background-modifier-accent), var(--background-modifier-accent) 1px, transparent 2px, transparent 4px); cursor: nwse-resize; }
119/**** Elements ****/
120#undiscord summary { font-size: 16px; font-weight: 500; line-height: 20px; position: relative; overflow: hidden; margin-bottom: 2px; padding: 6px 10px; cursor: pointer; white-space: nowrap; text-overflow: ellipsis; color: var(--interactive-normal); border-radius: 4px; flex-shrink: 0; }
121#undiscord fieldset { padding-left: 8px; }
122#undiscord legend a { float: right; text-transform: initial; }
123#undiscord progress { height: 8px; margin-top: 4px; flex-grow: 1; }
124#undiscord .importJson { display: flex; flex-direction: row; }
125#undiscord .importJson button { margin-left: 5px; width: fit-content; }
126`);
127
128	var dragCss = (`
129[name^="grab-"] { position: absolute; --size: 6px; --corner-size: 16px; --offset: -1px; z-index: 9; }
130[name^="grab-"]:hover{ background: rgba(128,128,128,0.1); }
131[name="grab-t"] { top: 0px; left: var(--corner-size); right: var(--corner-size); height: var(--size); margin-top: var(--offset); cursor: ns-resize; }
132[name="grab-r"] { top: var(--corner-size); bottom: var(--corner-size); right: 0px; width: var(--size); margin-right: var(--offset); 
133  cursor: ew-resize; }
134[name="grab-b"] { bottom: 0px; left: var(--corner-size); right: var(--corner-size); height: var(--size); margin-bottom: var(--offset); cursor: ns-resize; }
135[name="grab-l"] { top: var(--corner-size); bottom: var(--corner-size); left: 0px; width: var(--size); margin-left: var(--offset); cursor: ew-resize; }
136[name="grab-tl"] { top: 0px; left: 0px; width: var(--corner-size); height: var(--corner-size); margin-top: var(--offset); margin-left: var(--offset); cursor: nwse-resize; }
137[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; }
138[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; }
139[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; }
140`);
141
142	var buttonHtml = (`
143<div id="undicord-btn" tabindex="0" role="button" aria-label="Delete Messages" title="Delete Messages with Undiscord">
144    <svg aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
145        <path fill="currentColor" d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z"></path>
146        <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>
147    </svg>
148    <progress></progress>
149</div>
150`);
151
152	var undiscordTemplate = (`
153<div id="undiscord" class="browser container redact" style="display:none;">
154    <div class="header">
155        <svg class="icon" aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
156            <path fill="currentColor" d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z"></path>
157            <path fill="currentColor"
158                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">
159            </path>
160        </svg>
161        <h3>Undiscord</h3>
162        <div class="vert-divider"></div>
163        <span> Bulk delete messages</span>
164        <div class="spacer"></div>
165        <div id="hide" class="icon" aria-label="Close" role="button" tabindex="0">
166            <svg aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
167                <path fill="currentColor"
168                    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">
169                </path>
170            </svg>
171        </div>
172    </div>
173    <div class="window-body" style="display: flex; flex-direction: row;">
174        <div class="sidebar scroll">
175            <details open>
176                <summary>General</summary>
177                <fieldset>
178                    <legend>
179                        Author ID
180                        <a href="{{WIKI}}/authorId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
181                    </legend>
182                    <div class="multiInput">
183                        <div class="input-wrapper">
184                            <input class="input" id="authorId" type="text" priv>
185                        </div>
186                        <button id="getAuthor">me</button>
187                    </div>
188                </fieldset>
189                <hr>
190                <fieldset>
191                    <legend>
192                        Server ID
193                        <a href="{{WIKI}}/guildId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
194                    </legend>
195                    <div class="multiInput">
196                        <div class="input-wrapper">
197                            <input class="input" id="guildId" type="text" priv>
198                        </div>
199                        <button id="getGuild">current</button>
200                    </div>
201                </fieldset>
202                <fieldset>
203                    <legend>
204                        Channel ID
205                        <a href="{{WIKI}}/channelId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
206                    </legend>
207                    <div class="multiInput mb1">
208                        <div class="input-wrapper">
209                            <input class="input" id="channelId" type="text" priv>
210                        </div>
211                        <button id="getChannel">current</button>
212                    </div>
213                    <div class="sectionDescription">
214                        <label class="row"><input id="includeNsfw" type="checkbox">This is a NSFW channel</label>
215                    </div>
216                </fieldset>
217            </details>
218            <details>
219                <summary>Wipe Archive</summary>
220                <fieldset>
221                    <legend>
222                        Import index.json
223                        <a href="{{WIKI}}/importJson" title="Help" target="_blank" rel="noopener noreferrer">help</a>
224                    </legend>
225                    <div class="input-wrapper">
226                        <input type="file" id="importJsonInput" accept="application/json,.json" style="width:100%";>
227                    </div>
228                    <div class="sectionDescription">
229                        <br>
230                        After requesting your data from discord, you can import it here.<br>
231                        Select the "messages/index.json" file from the discord archive.
232                    </div>
233                </fieldset>
234            </details>
235            <hr>
236            <details>
237                <summary>Filter</summary>
238                <fieldset>
239                    <legend>
240                        Search
241                        <a href="{{WIKI}}/filters" title="Help" target="_blank" rel="noopener noreferrer">help</a>
242                    </legend>
243                    <div class="input-wrapper">
244                        <input id="search" type="text" placeholder="Containing text" priv>
245                    </div>
246                    <div class="sectionDescription">
247                        Only delete messages that contain the text
248                    </div>
249                    <div class="sectionDescription">
250                        <label><input id="hasLink" type="checkbox">has: link</label>
251                    </div>
252                    <div class="sectionDescription">
253                        <label><input id="hasFile" type="checkbox">has: file</label>
254                    </div>
255                    <div class="sectionDescription">
256                        <label><input id="includePinned" type="checkbox">Include pinned</label>
257                    </div>
258                </fieldset>
259                <hr>
260                <fieldset>
261                    <legend>
262                        Pattern
263                        <a href="{{WIKI}}/pattern" title="Help" target="_blank" rel="noopener noreferrer">help</a>
264                    </legend>
265                    <div class="sectionDescription">
266                        Delete messages that match the regular expression
267                    </div>
268                    <div class="input-wrapper">
269                        <span class="info">/</span>
270                        <input id="pattern" type="text" placeholder="regular expression" priv>
271                        <span class="info">/</span>
272                    </div>
273                </fieldset>
274            </details>
275            <details>
276                <summary>Messages interval</summary>
277                <fieldset>
278                    <legend>
279                        Interval of messages
280                        <a href="{{WIKI}}/messageId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
281                    </legend>
282                    <div class="multiInput mb1">
283                        <div class="input-wrapper">
284                            <input id="minId" type="text" placeholder="After a message" priv>
285                        </div>
286                        <button id="pickMessageAfter">Pick</button>
287                    </div>
288                    <div class="multiInput">
289                        <div class="input-wrapper">
290                            <input id="maxId" type="text" placeholder="Before a message" priv>
291                        </div>
292                        <button id="pickMessageBefore">Pick</button>
293                    </div>
294                    <div class="sectionDescription">
295                        Specify an interval to delete messages.
296                    </div>
297                </fieldset>
298            </details>
299            <details>
300                <summary>Date interval</summary>
301                <fieldset>
302                    <legend>
303                        After date
304                        <a href="{{WIKI}}/dateRange" title="Help" target="_blank" rel="noopener noreferrer">help</a>
305                    </legend>
306                    <div class="input-wrapper mb1">
307                        <input id="minDate" type="datetime-local" title="Messages posted AFTER this date">
308                    </div>
309                    <legend>
310                        Before date
311                        <a href="{{WIKI}}/dateRange" title="Help" target="_blank" rel="noopener noreferrer">help</a>
312                    </legend>
313                    <div class="input-wrapper">
314                        <input id="maxDate" type="datetime-local" title="Messages posted BEFORE this date">
315                    </div>
316                    <div class="sectionDescription">
317                        Delete messages that were posted between the two dates.
318                    </div>
319                    <div class="sectionDescription">
320                        * Filtering by date doesn't work if you use the "Messages interval".
321                    </div>
322                </fieldset>
323            </details>
324            <hr>
325            <details>
326                <summary>Advanced settings</summary>
327                <fieldset>
328                    <legend>
329                        Search delay
330                        <a href="{{WIKI}}/delay" title="Help" target="_blank" rel="noopener noreferrer">help</a>
331                    </legend>
332                    <div class="input-wrapper">
333                        <input id="searchDelay" type="range" value="30000" step="100" min="100" max="60000">
334                        <div id="searchDelayValue"></div>
335                    </div>
336                </fieldset>
337                <fieldset>
338                    <legend>
339                        Delete delay
340                        <a href="{{WIKI}}/delay" title="Help" target="_blank" rel="noopener noreferrer">help</a>
341                    </legend>
342                    <div class="input-wrapper">
343                        <input id="deleteDelay" type="range" value="1000" step="50" min="50" max="10000">
344                        <div id="deleteDelayValue"></div>
345                    </div>
346                    <br>
347                    <div class="sectionDescription">
348                        This will affect the speed in which the messages are deleted.
349                        Use the help link for more information.
350                    </div>
351                </fieldset>
352                <hr>
353                <fieldset>
354                    <legend>
355                        Authorization Token
356                        <a href="{{WIKI}}/authToken" title="Help" target="_blank" rel="noopener noreferrer">help</a>
357                    </legend>
358                    <div class="multiInput">
359                        <div class="input-wrapper">
360                            <input class="input" id="token" type="text" autocomplete="dont" priv>
361                        </div>
362                        <button id="getToken">fill</button>
363                    </div>
364                </fieldset>
365            </details>
366            <hr>
367            <div></div>
368            <div class="info">
369                Undiscord {{VERSION}}
370                <br> victornpb
371            </div>
372        </div>
373        <div class="main col">
374            <div class="tbar col">
375                <div class="row">
376                    <button id="toggleSidebar" class="sizeMedium icon">☰</button>
377                    <button id="start" class="sizeMedium danger" style="width: 150px;" title="Start the deletion process">▶︎ Delete</button>
378                    <button id="stop" class="sizeMedium" title="Stop the deletion process" disabled>🛑 Stop</button>
379                    <button id="clear" class="sizeMedium">Clear log</button>
380                    <label class="row" title="Hide sensitive information on your screen for taking screenshots">
381                        <input id="redact" type="checkbox" checked> Streamer mode
382                    </label>
383                </div>
384                <div class="row">
385                    <progress id="progressBar" style="display:none;"></progress>
386                </div>
387            </div>
388            <pre id="logArea" class="logarea scroll">
389                <div class="" style="background: var(--background-mentioned); padding: .5em;">Notice: Undiscord may be working slower than usual and<wbr>require multiple attempts due to a recent Discord update.<br>We're working on a fix, and we thank you for your patience.</div>
390                <center>
391                    <div>Star <a href="{{HOME}}" target="_blank" rel="noopener noreferrer">this project</a> on GitHub!</div>
392                    <div><a href="{{HOME}}/discussions" target="_blank" rel="noopener noreferrer">Issues or help</a></div>
393                </center>
394            </pre>
395            <div class="tbar footer row">
396                <div id="progressPercent"></div>
397                <span class="spacer"></span>
398                <label>
399                    <input id="autoScroll" type="checkbox" checked> Auto scroll
400                </label>
401                <div class="resize-handle"></div>
402            </div>
403        </div>
404    </div>
405</div>
406
407`);
408
409	const log = {
410	  debug() { return logFn ? logFn('debug', arguments) : console.debug.apply(console, arguments); },
411	  info() { return logFn ? logFn('info', arguments) : console.info.apply(console, arguments); },
412	  verb() { return logFn ? logFn('verb', arguments) : console.log.apply(console, arguments); },
413	  warn() { return logFn ? logFn('warn', arguments) : console.warn.apply(console, arguments); },
414	  error() { return logFn ? logFn('error', arguments) : console.error.apply(console, arguments); },
415	  success() { return logFn ? logFn('success', arguments) : console.info.apply(console, arguments); },
416	};
417
418	var logFn; // custom console.log function
419	const setLogFn = (fn) => logFn = fn;
420
421	// Helpers
422	const wait = async ms => new Promise(done => setTimeout(done, ms));
423	const msToHMS = s => `${s / 3.6e6 | 0}h ${(s % 3.6e6) / 6e4 | 0}m ${(s % 6e4) / 1000 | 0}s`;
424	const escapeHTML = html => String(html).replace(/[&<"']/g, m => ({ '&': '&amp;', '<': '&lt;', '"': '&quot;', '\'': '&#039;' })[m]);
425	const redact = str => `<x>${escapeHTML(str)}</x>`;
426	const queryString = params => params.filter(p => p[1] !== undefined).map(p => p[0] + '=' + encodeURIComponent(p[1])).join('&');
427	const ask = async msg => new Promise(resolve => setTimeout(() => resolve(window.confirm(msg)), 10));
428	const toSnowflake = (date) => /:/.test(date) ? ((new Date(date).getTime() - 1420070400000) * Math.pow(2, 22)) : date;
429	const replaceInterpolations = (str, obj, removeMissing = false) => str.replace(/\{\{([\w_]+)\}\}/g, (m, key) => obj[key] || (removeMissing ? '' : m));
430
431	const PREFIX$1 = '[UNDISCORD]';
432
433	/**
434	 * Delete all messages in a Discord channel or DM
435	 * @author Victornpb <https://www.github.com/victornpb>
436	 * @see https://github.com/victornpb/undiscord
437	 */
438	class UndiscordCore {
439
440	  options = {
441	    authToken: null, // Your authorization token
442	    authorId: null, // Author of the messages you want to delete
443	    guildId: null, // Server were the messages are located
444	    channelId: null, // Channel were the messages are located
445	    minId: null, // Only delete messages after this, leave blank do delete all
446	    maxId: null, // Only delete messages before this, leave blank do delete all
447	    content: null, // Filter messages that contains this text content
448	    hasLink: null, // Filter messages that contains link
449	    hasFile: null, // Filter messages that contains file
450	    includeNsfw: null, // Search in NSFW channels
451	    includePinned: null, // Delete messages that are pinned
452	    pattern: null, // Only delete messages that match the regex (insensitive)
453	    searchDelay: null, // Delay each time we fetch for more messages
454	    deleteDelay: null, // Delay between each delete operation
455	    maxAttempt: 2, // Attempts to delete a single message if it fails
456	    askForConfirmation: true,
457	  };
458
459	  state = {
460	    running: false,
461	    delCount: 0,
462	    failCount: 0,
463	    grandTotal: 0,
464	    offset: 0,
465	    iterations: 0,
466
467	    _seachResponse: null,
468	    _messagesToDelete: [],
469	    _skippedMessages: [],
470	  };
471
472	  stats = {
473	    startTime: new Date(), // start time
474	    throttledCount: 0, // how many times you have been throttled
475	    throttledTotalTime: 0, // the total amount of time you spent being throttled
476	    lastPing: null, // the most recent ping
477	    avgPing: null, // average ping used to calculate the estimated remaining time
478	    etr: 0,
479	  };
480
481	  // events
482	  onStart = undefined;
483	  onProgress = undefined;
484	  onStop = undefined;
485
486	  resetState() {
487	    this.state = {
488	      running: false,
489	      delCount: 0,
490	      failCount: 0,
491	      grandTotal: 0,
492	      offset: 0,
493	      iterations: 0,
494
495	      _seachResponse: null,
496	      _messagesToDelete: [],
497	      _skippedMessages: [],
498	    };
499
500	    this.options.askForConfirmation = true;
501	  }
502
503	  /** Automate the deletion process of multiple channels */
504	  async runBatch(queue) {
505	    if (this.state.running) return log.error('Already running!');
506
507	    log.info(`Runnning batch with queue of ${queue.length} jobs`);
508	    for (let i = 0; i < queue.length; i++) {
509	      const job = queue[i];
510	      log.info('Starting job...', `(${i + 1}/${queue.length})`);
511
512	      // set options
513	      this.options = {
514	        ...this.options, // keep current options
515	        ...job, // override with options for that job
516	      };
517
518	      await this.run(true);
519	      if (!this.state.running) break;
520
521	      log.info('Job ended.', `(${i + 1}/${queue.length})`);
522	      this.resetState();
523	      this.options.askForConfirmation = false;
524	      this.state.running = true; // continue running
525	    }
526
527	    log.info('Batch finished.');
528	    this.state.running = false;
529	  }
530
531	  /** Start the deletion process */
532	  async run(isJob = false) {
533	    if (this.state.running && !isJob) return log.error('Already running!');
534
535	    this.state.running = true;
536	    this.stats.startTime = new Date();
537
538	    log.success(`\nStarted at ${this.stats.startTime.toLocaleString()}`);
539	    log.debug(
540	      `authorId = "${redact(this.options.authorId)}"`,
541	      `guildId = "${redact(this.options.guildId)}"`,
542	      `channelId = "${redact(this.options.channelId)}"`,
543	      `minId = "${redact(this.options.minId)}"`,
544	      `maxId = "${redact(this.options.maxId)}"`,
545	      `hasLink = ${!!this.options.hasLink}`,
546	      `hasFile = ${!!this.options.hasFile}`,
547	    );
548
549	    if (this.onStart) this.onStart(this.state, this.stats);
550
551	    do {
552	      this.state.iterations++;
553
554	      log.verb('Fetching messages...');
555	      // Search messages
556	      await this.search();
557
558	      // Process results and find which messages should be deleted
559	      await this.filterResponse();
560
561	      log.verb(
562	        `Grand total: ${this.state.grandTotal}`,
563	        `(Messages in current page: ${this.state._seachResponse.messages.length}`,
564	        `To be deleted: ${this.state._messagesToDelete.length}`,
565	        `Skipped: ${this.state._skippedMessages.length})`,
566	        `offset: ${this.state.offset}`
567	      );
568	      this.printStats();
569
570	      // Calculate estimated time
571	      this.calcEtr();
572	      log.verb(`Estimated time remaining: ${msToHMS(this.stats.etr)}`);
573
574	      // if there are messages to delete, delete them
575	      if (this.state._messagesToDelete.length > 0) {
576
577	        if (await this.confirm() === false) {
578	          this.state.running = false; // break out of a job
579	          break; // immmediately stop this iteration
580	        }
581
582	        await this.deleteMessagesFromList();
583	      }
584	      else if (this.state._skippedMessages.length > 0) {
585	        // There are stuff, but nothing to delete (example a page full of system messages)
586	        // check next page until we see a page with nothing in it (end of results).
587	        const oldOffset = this.state.offset;
588	        this.state.offset += this.state._skippedMessages.length;
589	        log.verb('There\'s nothing we can delete on this page, checking next page...');
590	        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})`);
591	      }
592	      else {
593	        log.verb('Ended because API returned an empty page.');
594	        log.verb('[End state]', this.state);
595	        if (isJob) break; // break without stopping if this is part of a job
596	        this.state.running = false;
597	      }
598
599	      // wait before next page (fix search page not updating fast enough)
600	      log.verb(`Waiting ${(this.options.searchDelay / 1000).toFixed(2)}s before next page...`);
601	      await wait(this.options.searchDelay);
602
603	    } while (this.state.running);
604
605	    this.stats.endTime = new Date();
606	    log.success(`Ended at ${this.stats.endTime.toLocaleString()}! Total time: ${msToHMS(this.stats.endTime.getTime() - this.stats.startTime.getTime())}`);
607	    this.printStats();
608	    log.debug(`Deleted ${this.state.delCount} messages, ${this.state.failCount} failed.\n`);
609
610	    if (this.onStop) this.onStop(this.state, this.stats);
611	  }
612
613	  stop() {
614	    this.state.running = false;
615	    if (this.onStop) this.onStop(this.state, this.stats);
616	  }
617
618	  /** Calculate the estimated time remaining based on the current stats */
619	  calcEtr() {
620	    this.stats.etr = (this.options.searchDelay * Math.round(this.state.grandTotal / 25)) + ((this.options.deleteDelay + this.stats.avgPing) * this.state.grandTotal);
621	  }
622
623	  /** As for confirmation in the beggining process */
624	  async confirm() {
625	    if (!this.options.askForConfirmation) return true;
626
627	    log.verb('Waiting for your confirmation...');
628	    const preview = this.state._messagesToDelete.map(m => `${m.author.username}#${m.author.discriminator}: ${m.attachments.length ? '[ATTACHMENTS]' : m.content}`).join('\n');
629
630	    const answer = await ask(
631	      `Do you want to delete ~${this.state.grandTotal} messages? (Estimated time: ${msToHMS(this.stats.etr)})` +
632	      '(The actual number of messages may be less, depending if you\'re using filters to skip some messages)' +
633	      '\n\n---- Preview ----\n' +
634	      preview
635	    );
636
637	    if (!answer) {
638	      log.error('Aborted by you!');
639	      return false;
640	    }
641	    else {
642	      log.verb('OK');
643	      this.options.askForConfirmation = false; // do not ask for confirmation again on the next request
644	      return true;
645	    }
646	  }
647
648	  async search() {
649	    let API_SEARCH_URL;
650	    if (this.options.guildId === '@me') API_SEARCH_URL = `https://discord.com/api/v9/channels/${this.options.channelId}/messages/`; // DMs
651	    else API_SEARCH_URL = `https://discord.com/api/v9/guilds/${this.options.guildId}/messages/`; // Server
652
653	    let resp;
654	    try {
655	      this.beforeRequest();
656	      resp = await fetch(API_SEARCH_URL + 'search?' + queryString([
657	        ['author_id', this.options.authorId || undefined],
658	        ['channel_id', (this.options.guildId !== '@me' ? this.options.channelId : undefined) || undefined],
659	        ['min_id', this.options.minId ? toSnowflake(this.options.minId) : undefined],
660	        ['max_id', this.options.maxId ? toSnowflake(this.options.maxId) : undefined],
661	        ['sort_by', 'timestamp'],
662	        ['sort_order', 'desc'],
663	        ['offset', this.state.offset],
664	        ['has', this.options.hasLink ? 'link' : undefined],
665	        ['has', this.options.hasFile ? 'file' : undefined],
666	        ['content', this.options.content || undefined],
667	        ['include_nsfw', this.options.includeNsfw ? true : undefined],
668	      ]), {
669	        headers: {
670	          'Authorization': this.options.authToken,
671	        }
672	      });
673	      this.afterRequest();
674	    } catch (err) {
675	      this.state.running = false;
676	      log.error('Search request threw an error:', err);
677	      throw err;
678	    }
679
680	    // not indexed yet
681	    if (resp.status === 202) {
682	      let w = (await resp.json()).retry_after * 1000;
683	      w = w || this.stats.searchDelay; // Fix retry_after 0
684	      this.stats.throttledCount++;
685	      this.stats.throttledTotalTime += w;
686	      log.warn(`This channel isn't indexed yet. Waiting ${w}ms for discord to index it...`);
687	      await wait(w);
688	      return await this.search();
689	    }
690
691	    if (!resp.ok) {
692	      // searching messages too fast
693	      if (resp.status === 429) {
694	        let w = (await resp.json()).retry_after * 1000;
695	        w = w || this.stats.searchDelay; // Fix retry_after 0
696
697	        this.stats.throttledCount++;
698	        this.stats.throttledTotalTime += w;
699	        this.stats.searchDelay += w; // increase delay
700	        w = this.stats.searchDelay;
701	        log.warn(`Being rate limited by the API for ${w}ms! Increasing search delay...`);
702	        this.printStats();
703	        log.verb(`Cooling down for ${w * 2}ms before retrying...`);
704
705	        await wait(w * 2);
706	        return await this.search();
707	      }
708	      else {
709	        this.state.running = false;
710	        log.error(`Error searching messages, API responded with status ${resp.status}!\n`, await resp.json());
711	        throw resp;
712	      }
713	    }
714	    const data = await resp.json();
715	    this.state._seachResponse = data;
716	    console.log(PREFIX$1, 'search', data);
717	    return data;
718	  }
719
720	  async filterResponse() {
721	    const data = this.state._seachResponse;
722
723	    // the search total will decrease as we delete stuff
724	    const total = data.total_results;
725	    if (total > this.state.grandTotal) this.state.grandTotal = total;
726
727	    // search returns messages near the the actual message, only get the messages we searched for.
728	    const discoveredMessages = data.messages.map(convo => convo.find(message => message.hit === true));
729
730	    // we can only delete some types of messages, system messages are not deletable.
731	    let messagesToDelete = discoveredMessages;
732	    messagesToDelete = messagesToDelete.filter(msg => msg.type === 0 || (msg.type >= 6 && msg.type <= 21));
733	    messagesToDelete = messagesToDelete.filter(msg => msg.pinned ? this.options.includePinned : true);
734
735	    // custom filter of messages
736	    try {
737	      const regex = new RegExp(this.options.pattern, 'i');
738	      messagesToDelete = messagesToDelete.filter(msg => regex.test(msg.content));
739	    } catch (e) {
740	      log.warn('Ignoring RegExp because pattern is malformed!', e);
741	    }
742
743	    // create an array containing everything we skipped. (used to calculate offset for next searches)
744	    const skippedMessages = discoveredMessages.filter(msg => !messagesToDelete.find(m => m.id === msg.id));
745
746	    this.state._messagesToDelete = messagesToDelete;
747	    this.state._skippedMessages = skippedMessages;
748
749	    console.log(PREFIX$1, 'filterResponse', this.state);
750	  }
751
752	  async deleteMessagesFromList() {
753	    for (let i = 0; i < this.state._messagesToDelete.length; i++) {
754	      const message = this.state._messagesToDelete[i];
755	      if (!this.state.running) return log.error('Stopped by you!');
756
757	      log.debug(
758	        // `${((this.state.delCount + 1) / this.state.grandTotal * 100).toFixed(2)}%`,
759	        `[${this.state.delCount + 1}/${this.state.grandTotal}] ` +
760	        `<sup>${new Date(message.timestamp).toLocaleString()}</sup> ` +
761	        `<b>${redact(message.author.username + '#' + message.author.discriminator)}</b>` +
762	        `: <i>${redact(message.content).replace(/\n/g, '↵')}</i>` +
763	        (message.attachments.length ? redact(JSON.stringify(message.attachments)) : ''),
764	        `<sup>{ID:${redact(message.id)}}</sup>`
765	      );
766
767	      // Delete a single message (with retry)
768	      let attempt = 0;
769	      while (attempt < this.options.maxAttempt) {
770	        const result = await this.deleteMessage(message);
771
772	        if (result === 'RETRY') {
773	          attempt++;
774	          log.verb(`Retrying in ${this.options.deleteDelay}ms... (${attempt}/${this.options.maxAttempt})`);
775	          await wait(this.options.deleteDelay);
776	        }
777	        else break;
778	      }
779
780	      this.calcEtr();
781	      if (this.onProgress) this.onProgress(this.state, this.stats);
782
783	      await wait(this.options.deleteDelay);
784	    }
785	  }
786
787	  async deleteMessage(message) {
788	    const API_DELETE_URL = `https://discord.com/api/v9/channels/${message.channel_id}/messages/${message.id}`;
789	    let resp;
790	    try {
791	      this.beforeRequest();
792	      resp = await fetch(API_DELETE_URL, {
793	        method: 'DELETE',
794	        headers: {
795	          'Authorization': this.options.authToken,
796	        },
797	      });
798	      this.afterRequest();
799	    } catch (err) {
800	      // no response error (e.g. network error)
801	      log.error('Delete request throwed an error:', err);
802	      log.verb('Related object:', redact(JSON.stringify(message)));
803	      this.state.failCount++;
804	      return 'FAILED';
805	    }
806
807	    if (!resp.ok) {
808	      if (resp.status === 429) {
809	        // deleting messages too fast
810	        const w = (await resp.json()).retry_after * 1000;
811	        this.stats.throttledCount++;
812	        this.stats.throttledTotalTime += w;
813	        this.options.deleteDelay = w; // increase delay
814	        log.warn(`Being rate limited by the API for ${w}ms! Adjusted delete delay to ${this.options.deleteDelay}ms.`);
815	        this.printStats();
816	        log.verb(`Cooling down for ${w * 2}ms before retrying...`);
817	        await wait(w * 2);
818	        return 'RETRY';
819	      } else {
820	        const body = await resp.text();
821
822	        try {
823	          const r = JSON.parse(body);
824
825	          if (resp.status === 400 && r.code === 50083) {
826	            // 400 can happen if the thread is archived (code=50083)
827	            // in this case we need to "skip" this message from the next search
828	            // otherwise it will come up again in the next page (and fail to delete again)
829	            log.warn('Error deleting message (Thread is archived). Will increment offset so we don\'t search this in the next page...');
830	            this.state.offset++;
831	            this.state.failCount++;
832	            return 'FAIL_SKIP'; // Failed but we will skip it next time
833	          }
834
835	          log.error(`Error deleting message, API responded with status ${resp.status}!`, r);
836	          log.verb('Related object:', redact(JSON.stringify(message)));
837	          this.state.failCount++;
838	          return 'FAILED';
839	        } catch (e) {
840	          log.error(`Fail to parse JSON. API responded with status ${resp.status}!`, body);
841	        }
842	      }
843	    }
844
845	    this.state.delCount++;
846	    return 'OK';
847	  }
848
849	  #beforeTs = 0; // used to calculate latency
850	  beforeRequest() {
851	    this.#beforeTs = Date.now();
852	  }
853	  afterRequest() {
854	    this.stats.lastPing = (Date.now() - this.#beforeTs);
855	    this.stats.avgPing = this.stats.avgPing > 0 ? (this.stats.avgPing * 0.9) + (this.stats.lastPing * 0.1) : this.stats.lastPing;
856	  }
857
858	  printStats() {
859	    log.verb(
860	      `Delete delay: ${this.options.deleteDelay}ms, Search delay: ${this.options.searchDelay}ms`,
861	      `Last Ping: ${this.stats.lastPing}ms, Average Ping: ${this.stats.avgPing | 0}ms`,
862	    );
863	    log.verb(
864	      `Rate Limited: ${this.stats.throttledCount} times.`,
865	      `Total time throttled: ${msToHMS(this.stats.throttledTotalTime)}.`
866	    );
867	  }
868	}
869
870	const MOVE = 0;
871	const RESIZE_T = 1;
872	const RESIZE_B = 2;
873	const RESIZE_L = 4;
874	const RESIZE_R = 8;
875	const RESIZE_TL = RESIZE_T + RESIZE_L;
876	const RESIZE_TR = RESIZE_T + RESIZE_R;
877	const RESIZE_BL = RESIZE_B + RESIZE_L;
878	const RESIZE_BR = RESIZE_B + RESIZE_R;
879
880	/**
881	 * Make an element draggable/resizable
882	 * @author Victor N. wwww.vitim.us
883	 */
884	class DragResize {
885	  constructor({ elm, moveHandle, options }) {
886	    this.options = defaultArgs({
887	      enabledDrag: true,
888	      enabledResize: true,
889	      minWidth: 200,
890	      maxWidth: Infinity,
891	      minHeight: 100,
892	      maxHeight: Infinity,
893	      dragAllowX: true,
894	      dragAllowY: true,
895	      resizeAllowX: true,
896	      resizeAllowY: true,
897	      draggingClass: 'drag',
898	      useMouseEvents: true,
899	      useTouchEvents: true,
900	      createHandlers: true,
901	    }, options);
902	    Object.assign(this, options);
903	    options = undefined;
904
905	    elm.style.position = 'fixed';
906
907	    this.drag_m = new Draggable(elm, moveHandle, MOVE, this.options);
908
909	    if (this.options.createHandlers) {
910	      this.el_t = createElement('div', { name: 'grab-t' }, elm);
911	      this.drag_t = new Draggable(elm, this.el_t, RESIZE_T, this.options);
912	      this.el_r = createElement('div', { name: 'grab-r' }, elm);
913	      this.drag_r = new Draggable(elm, this.el_r, RESIZE_R, this.options);
914	      this.el_b = createElement('div', { name: 'grab-b' }, elm);
915	      this.drag_b = new Draggable(elm, this.el_b, RESIZE_B, this.options);
916	      this.el_l = createElement('div', { name: 'grab-l' }, elm);
917	      this.drag_l = new Draggable(elm, this.el_l, RESIZE_L, this.options);
918	      this.el_tl = createElement('div', { name: 'grab-tl' }, elm);
919	      this.drag_tl = new Draggable(elm, this.el_tl, RESIZE_TL, this.options);
920	      this.el_tr = createElement('div', { name: 'grab-tr' }, elm);
921	      this.drag_tr = new Draggable(elm, this.el_tr, RESIZE_TR, this.options);
922	      this.el_br = createElement('div', { name: 'grab-br' }, elm);
923	      this.drag_br = new Draggable(elm, this.el_br, RESIZE_BR, this.options);
924	      this.el_bl = createElement('div', { name: 'grab-bl' }, elm);
925	      this.drag_bl = new Draggable(elm, this.el_bl, RESIZE_BL, this.options);
926	    }
927	  }
928	}
929
930	class Draggable {
931	  constructor(targetElm, handleElm, op, options) {
932	    Object.assign(this, options);
933	    options = undefined;
934
935	    this._targetElm = targetElm;
936	    this._handleElm = handleElm;
937
938	    let vw = window.innerWidth;
939	    let vh = window.innerHeight;
940	    let initialX, initialY, initialT, initialL, initialW, initialH;
941
942	    const clamp = (value, min, max) => value < min ? min : value > max ? max : value;
943
944	    const moveOp = (x, y) => {
945	      const deltaX = (x - initialX);
946	      const deltaY = (y - initialY);
947	      const t = clamp(initialT + deltaY, 0, vh - initialH);
948	      const l = clamp(initialL + deltaX, 0, vw - initialW);
949	      this._targetElm.style.top = t + 'px';
950	      this._targetElm.style.left = l + 'px';
951	    };
952
953	    const resizeOp = (x, y) => {
954	      x = clamp(x, 0, vw);
955	      y = clamp(y, 0, vh);
956	      const deltaX = (x - initialX);
957	      const deltaY = (y - initialY);
958	      const resizeDirX = (op & RESIZE_L) ? -1 : 1;
959	      const resizeDirY = (op & RESIZE_T) ? -1 : 1;
960	      const deltaXMax = (this.maxWidth - initialW);
961	      const deltaXMin = (this.minWidth - initialW);
962	      const deltaYMax = (this.maxHeight - initialH);
963	      const deltaYMin = (this.minHeight - initialH);
964	      const t = initialT + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax) * resizeDirY;
965	      const l = initialL + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax) * resizeDirX;
966	      const w = initialW + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax);
967	      const h = initialH + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax);
968	      if (op & RESIZE_T) { // resize ↑
969	        this._targetElm.style.top = t + 'px';
970	        this._targetElm.style.height = h + 'px';
971	      }
972	      if (op & RESIZE_B) { // resize ↓
973	        this._targetElm.style.height = h + 'px';
974	      }
975	      if (op & RESIZE_L) { // resize ←
976	        this._targetElm.style.left = l + 'px';
977	        this._targetElm.style.width = w + 'px';
978	      }
979	      if (op & RESIZE_R) { // resize →
980	        this._targetElm.style.width = w + 'px';
981	      }
982	    };
983
984	    let operation = op === MOVE ? moveOp : resizeOp;
985
986	    function dragStartHandler(e) {
987	      const touch = e.type === 'touchstart';
988	      if ((e.buttons === 1 || e.which === 1) || touch) {
989	        e.preventDefault();
990	        const x = touch ? e.touches[0].clientX : e.clientX;
991	        const y = touch ? e.touches[0].clientY : e.clientY;
992	        initialX = x;
993	        initialY = y;
994	        vw = window.innerWidth;
995	        vh = window.innerHeight;
996	        initialT = this._targetElm.offsetTop;
997	        initialL = this._targetElm.offsetLeft;
998	        initialW = this._targetElm.clientWidth;
999	        initialH = this._targetElm.clientHeight;
1000	        if (this.useMouseEvents) {
1001	          document.addEventListener('mousemove', this._dragMoveHandler);
1002	          document.addEventListener('mouseup', this._dragEndHandler);
1003	        }
1004	        if (this.useTouchEvents) {
1005	          document.addEventListener('touchmove', this._dragMoveHandler, { passive: false });
1006	          document.addEventListener('touchend', this._dragEndHandler);
1007	        }
1008	        this._targetElm.classList.add(this.draggingClass);
1009	      }
1010	    }
1011
1012	    function dragMoveHandler(e) {
1013	      e.preventDefault();
1014	      let x, y;
1015	      const touch = e.type === 'touchmove';
1016	      if (touch) {
1017	        const t = e.touches[0];
1018	        x = t.clientX;
1019	        y = t.clientY;
1020	      } else { //mouse
1021	        // If the button is not down, dispatch a "fake" mouse up event, to stop listening to mousemove
1022	        // This happens when the mouseup is not captured (outside the browser)
1023	        if ((e.buttons || e.which) !== 1) {
1024	          this._dragEndHandler();
1025	          return;
1026	        }
1027	        x = e.clientX;
1028	        y = e.clientY;
1029	      }
1030	      // perform drag / resize operation
1031	      operation(x, y);
1032	    }
1033
1034	    function dragEndHandler(e) {
1035	      if (this.useMouseEvents) {
1036	        document.removeEventListener('mousemove', this._dragMoveHandler);
1037	        document.removeEventListener('mouseup', this._dragEndHandler);
1038	      }
1039	      if (this.useTouchEvents) {
1040	        document.removeEventListener('touchmove', this._dragMoveHandler);
1041	        document.removeEventListener('touchend', this._dragEndHandler);
1042	      }
1043	      this._targetElm.classList.remove(this.draggingClass);
1044	    }
1045
1046	    // We need to bind the handlers to this instance
1047	    this._dragStartHandler = dragStartHandler.bind(this);
1048	    this._dragMoveHandler = dragMoveHandler.bind(this);
1049	    this._dragEndHandler = dragEndHandler.bind(this);
1050
1051	    this.enable();
1052	  }
1053
1054	  /** Turn on the drag and drop of the instance */
1055	  enable() {
1056	    this.destroy(); // prevent events from getting binded twice
1057	    if (this.useMouseEvents) this._handleElm.addEventListener('mousedown', this._dragStartHandler);
1058	    if (this.useTouchEvents) this._handleElm.addEventListener('touchstart', this._dragStartHandler, { passive: false });
1059	  }
1060
1061	  /** Teardown all events bound to the document and elements. You can resurrect this instance by calling enable() */
1062	  destroy() {
1063	    this._targetElm.classList.remove(this.draggingClass);
1064	    if (this.useMouseEvents) {
1065	      this._handleElm.removeEventListener('mousedown', this._dragStartHandler);
1066	      document.removeEventListener('mousemove', this._dragMoveHandler);
1067	      document.removeEventListener('mouseup', this._dragEndHandler);
1068	    }
1069	    if (this.useTouchEvents) {
1070	      this._handleElm.removeEventListener('touchstart', this._dragStartHandler);
1071	      document.removeEventListener('touchmove', this._dragMoveHandler);
1072	      document.removeEventListener('touchend', this._dragEndHandler);
1073	    }
1074	  }
1075	}
1076
1077	function createElement(tag='div', attrs, parent) {
1078	  const elm = document.createElement(tag);
1079	  if (attrs) Object.entries(attrs).forEach(([k, v]) => elm.setAttribute(k, v));
1080	  if (parent) parent.appendChild(elm);
1081	  return elm;
1082	}
1083
1084	function defaultArgs(defaults, options) {
1085	  function isObj(x) { return x !== null && typeof x === 'object'; }
1086	  function hasOwn(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
1087	  if (isObj(options)) for (let prop in defaults) {
1088	    if (hasOwn(defaults, prop) && hasOwn(options, prop) && options[prop] !== undefined) {
1089	      if (isObj(defaults[prop])) defaultArgs(defaults[prop], options[prop]);
1090	      else defaults[prop] = options[prop];
1091	    }
1092	  }
1093	  return defaults;
1094	}
1095
1096	function createElm(html) {
1097	  const temp = document.createElement('div');
1098	  temp.innerHTML = html;
1099	  return temp.removeChild(temp.firstElementChild);
1100	}
1101
1102	function insertCss(css) {
1103	  const style = document.createElement('style');
1104	  style.appendChild(document.createTextNode(css));
1105	  document.head.appendChild(style);
1106	  return style;
1107	}
1108
1109	const messagePickerCss = `
1110body.undiscord-pick-message [data-list-id="chat-messages"] {
1111  background-color: var(--background-secondary-alt);
1112  box-shadow: inset 0 0 0px 2px var(--button-outline-brand-border);
1113}
1114
1115body.undiscord-pick-message [id^="message-content-"]:hover {
1116  cursor: pointer;
1117  cursor: cell;
1118  background: var(--background-message-automod-hover);
1119}
1120body.undiscord-pick-message [id^="message-content-"]:hover::after {
1121  position: absolute;
1122  top: calc(50% - 11px);
1123  left: 4px;
1124  z-index: 1;
1125  width: 65px;
1126  height: 22px;
1127  line-height: 22px;
1128  font-family: var(--font-display);
1129  background-color: var(--button-secondary-background);
1130  color: var(--header-secondary);
1131  font-size: 12px;
1132  font-weight: 500;
1133  text-transform: uppercase;
1134  text-align: center;
1135  border-radius: 3px;
1136  content: 'This 👉';
1137}
1138body.undiscord-pick-message.before [id^="message-content-"]:hover::after {
1139  content: 'Before 👆';
1140}
1141body.undiscord-pick-message.after [id^="message-content-"]:hover::after {
1142  content: 'After 👇';
1143}
1144`;
1145
1146	const messagePicker = {
1147	  init() {
1148	    insertCss(messagePickerCss);
1149	  },
1150	  grab(auxiliary) {
1151	    return new Promise((resolve, reject) => {
1152	      document.body.classList.add('undiscord-pick-message');
1153	      if (auxiliary) document.body.classList.add(auxiliary);
1154	      function clickHandler(e) {
1155	        const message = e.target.closest('[id^="message-content-"]');
1156	        if (message) {
1157	          e.preventDefault();
1158	          e.stopPropagation();
1159	          e.stopImmediatePropagation();
1160	          if (auxiliary) document.body.classList.remove(auxiliary);
1161	          document.body.classList.remove('undiscord-pick-message');
1162	          document.removeEventListener('click', clickHandler);
1163	          try {
1164	            resolve(message.id.match(/message-content-(\d+)/)[1]);
1165	          } catch (e) {
1166	            resolve(null);
1167	          }
1168	        }
1169	      }
1170	      document.addEventListener('click', clickHandler);
1171	    });
1172	  }
1173	};
1174	window.messagePicker = messagePicker;
1175
1176	function getToken() {
1177	  window.dispatchEvent(new Event('beforeunload'));
1178	  const LS = document.body.appendChild(document.createElement('iframe')).contentWindow.localStorage;
1179	  try {
1180	    return JSON.parse(LS.token);
1181	  } catch {
1182	    log.info('Could not automatically detect Authorization Token in local storage!');
1183	    log.info('Attempting to grab token using webpack');
1184	    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();
1185	  }
1186	}
1187
1188	function getAuthorId() {
1189	  const LS = document.body.appendChild(document.createElement('iframe')).contentWindow.localStorage;
1190	  return JSON.parse(LS.user_id_cache);
1191	}
1192
1193	function getGuildId() {
1194	  const m = location.href.match(/channels\/([\w@]+)\/(\d+)/);
1195	  if (m) return m[1];
1196	  else alert('Could not find the Guild ID!\nPlease make sure you are on a Server or DM.');
1197	}
1198
1199	function getChannelId() {
1200	  const m = location.href.match(/channels\/([\w@]+)\/(\d+)/);
1201	  if (m) return m[2];
1202	  else alert('Could not find the Channel ID!\nPlease make sure you are on a Channel or DM.');
1203	}
1204
1205	function fillToken() {
1206	  try {
1207	    return getToken();
1208	  } catch (err) {
1209	    log.verb(err);
1210	    log.error('Could not automatically detect Authorization Token!');
1211	    log.info('Please make sure Undiscord is up to date');
1212	    log.debug('Alternatively, you can try entering a Token manually in the "Advanced Settings" section.');
1213	  }
1214	  return '';
1215	}
1216
1217	const PREFIX = '[UNDISCORD]';
1218
1219	// -------------------------- User interface ------------------------------- //
1220
1221	// links
1222	const HOME = 'https://github.com/victornpb/undiscord';
1223	const WIKI = 'https://github.com/victornpb/undiscord/wiki';
1224
1225	const undiscordCore = new UndiscordCore();
1226	messagePicker.init();
1227
1228	const ui = {
1229	  undiscordWindow: null,
1230	  undiscordBtn: null,
1231	  logArea: null,
1232	  autoScroll: null,
1233
1234	  // progress handler
1235	  progressMain: null,
1236	  progressIcon: null,
1237	  percent: null,
1238	};
1239	const $ = s => ui.undiscordWindow.querySelector(s);
1240
1241	function initUI() {
1242
1243	  insertCss(themeCss);
1244	  insertCss(mainCss);
1245	  insertCss(dragCss);
1246
1247	  // create undiscord window
1248	  const undiscordUI = replaceInterpolations(undiscordTemplate, {
1249	    VERSION,
1250	    HOME,
1251	    WIKI,
1252	  });
1253	  ui.undiscordWindow = createElm(undiscordUI);
1254	  document.body.appendChild(ui.undiscordWindow);
1255
1256	  // enable drag and resize on undiscord window
1257	  new DragResize({ elm: ui.undiscordWindow, moveHandle: $('.header') });
1258
1259	  // create undiscord Trash icon
1260	  ui.undiscordBtn = createElm(buttonHtml);
1261	  ui.undiscordBtn.onclick = toggleWindow;
1262	  function mountBtn() {
1263	    const toolbar = document.querySelector('#app-mount [class*="-toolbar"]');
1264	    if (toolbar) toolbar.appendChild(ui.undiscordBtn);
1265	  }
1266	  mountBtn();
1267	  // watch for changes and re-mount button if necessary
1268	  const discordElm = document.querySelector('#app-mount');
1269	  let observerThrottle = null;
1270	  const observer = new MutationObserver((_mutationsList, _observer) => {
1271	    if (observerThrottle) return;
1272	    observerThrottle = setTimeout(() => {
1273	      observerThrottle = null;
1274	      if (!discordElm.contains(ui.undiscordBtn)) mountBtn(); // re-mount the button to the toolbar
1275	    }, 3000);
1276	  });
1277	  observer.observe(discordElm, { attributes: false, childList: true, subtree: true });
1278
1279	  function toggleWindow() {
1280	    if (ui.undiscordWindow.style.display !== 'none') {
1281	      ui.undiscordWindow.style.display = 'none';
1282	      ui.undiscordBtn.style.color = 'var(--interactive-normal)';
1283	    }
1284	    else {
1285	      ui.undiscordWindow.style.display = '';
1286	      ui.undiscordBtn.style.color = 'var(--interactive-active)';
1287	    }
1288	  }
1289
1290	  // cached elements
1291	  ui.logArea = $('#logArea');
1292	  ui.autoScroll = $('#autoScroll');
1293	  ui.progressMain = $('#progressBar');
1294	  ui.progressIcon = ui.undiscordBtn.querySelector('progress');
1295	  ui.percent = $('#progressPercent');
1296
1297	  // register event listeners
1298	  $('#hide').onclick = toggleWindow;
1299	  $('#toggleSidebar').onclick = ()=> ui.undiscordWindow.classList.toggle('hide-sidebar');
1300	  $('button#start').onclick = startAction;
1301	  $('button#stop').onclick = stopAction;
1302	  $('button#clear').onclick = () => ui.logArea.innerHTML = '';
1303	  $('button#getAuthor').onclick = () => $('input#authorId').value = getAuthorId();
1304	  $('button#getGuild').onclick = () => {
1305	    const guildId = $('input#guildId').value = getGuildId();
1306	    if (guildId === '@me') $('input#channelId').value = getChannelId();
1307	  };
1308	  $('button#getChannel').onclick = () => {
1309	    $('input#channelId').value = getChannelId();
1310	    $('input#guildId').value = getGuildId();
1311	  };
1312	  $('#redact').onchange = () => {
1313	    const b = ui.undiscordWindow.classList.toggle('redact');
1314	    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!');
1315	  };
1316	  $('#pickMessageAfter').onclick = async () => {
1317	    alert('Select a message on the chat.\nThe message below it will be deleted.');
1318	    toggleWindow();
1319	    const id = await messagePicker.grab('after');
1320	    if (id) $('input#minId').value = id;
1321	    toggleWindow();
1322	  };
1323	  $('#pickMessageBefore').onclick = async () => {
1324	    alert('Select a message on the chat.\nThe message above it will be deleted.');
1325	    toggleWindow();
1326	    const id = await messagePicker.grab('before');
1327	    if (id) $('input#maxId').value = id;
1328	    toggleWindow();
1329	  };
1330	  $('button#getToken').onclick = () => $('input#token').value = fillToken();
1331
1332	  // sync delays
1333	  $('input#searchDelay').onchange = (e) => {
1334	    const v = parseInt(e.target.value);
1335	    if (v) undiscordCore.options.searchDelay = v;
1336	  };
1337	  $('input#deleteDelay').onchange = (e) => {
1338	    const v = parseInt(e.target.value);
1339	    if (v) undiscordCore.options.deleteDelay = v;
1340	  };
1341
1342	  $('input#searchDelay').addEventListener('input', (event) => {
1343	    $('div#searchDelayValue').textContent = event.target.value + 'ms';
1344	  });
1345	  $('input#deleteDelay').addEventListener('input', (event) => {
1346	    $('div#deleteDelayValue').textContent = event.target.value + 'ms';
1347	  });
1348
1349	  // import json
1350	  const fileSelection = $('input#importJsonInput');
1351	  fileSelection.onchange = async () => {
1352	    const files = fileSelection.files;
1353
1354	    // No files added
1355	    if (files.length === 0) return log.warn('No file selected.');
1356
1357	    // Get channel id field to set it later
1358	    const channelIdField = $('input#channelId');
1359
1360	    // Force the guild id to be ourself (@me)
1361	    const guildIdField = $('input#guildId');
1362	    guildIdField.value = '@me';
1363
1364	    // Set author id in case its not set already
1365	    $('input#authorId').value = getAuthorId();
1366	    try {
1367	      const file = files[0];
1368	      const text = await file.text();
1369	      const json = JSON.parse(text);
1370	      const channelIds = Object.keys(json);
1371	      channelIdField.value = channelIds.join(',');
1372	      log.info(`Loaded ${channelIds.length} channels.`);
1373	    } catch(err) {
1374	      log.error('Error parsing file!', err);
1375	    }
1376	  };
1377
1378	  // redirect console logs to inside the window after setting up the UI
1379	  setLogFn(printLog);
1380
1381	  setupUndiscordCore();
1382	}
1383
1384	function printLog(type = '', args) {
1385	  ui.logArea.insertAdjacentHTML('beforeend', `<div class="log log-${type}">${Array.from(args).map(o => typeof o === 'object' ? JSON.stringify(o, o instanceof Error && Object.getOwnPropertyNames(o)) : o).join('\t')}</div>`);
1386	  if (ui.autoScroll.checked) ui.logArea.querySelector('div:last-child').scrollIntoView(false);
1387	  if (type==='error') console.error(PREFIX, ...Array.from(args));
1388	}
1389
1390	function setupUndiscordCore() {
1391
1392	  undiscordCore.onStart = (state, stats) => {
1393	    console.log(PREFIX, 'onStart', state, stats);
1394	    $('#start').disabled = true;
1395	    $('#stop').disabled = false;
1396
1397	    ui.undiscordBtn.classList.add('running');
1398	    ui.progressMain.style.display = 'block';
1399	    ui.percent.style.display = 'block';
1400	  };
1401
1402	  undiscordCore.onProgress = (state, stats) => {
1403	    // console.log(PREFIX, 'onProgress', state, stats);
1404	    let max = state.grandTotal;
1405	    const value = state.delCount + state.failCount;
1406	    max = Math.max(max, value, 0); // clamp max
1407
1408	    // status bar
1409	    const percent = value >= 0 && max ? Math.round(value / max * 100) + '%' : '';
1410	    const elapsed = msToHMS(Date.now() - stats.startTime.getTime());
1411	    const remaining = msToHMS(stats.etr);
1412	    ui.percent.innerHTML = `${percent} (${value}/${max}) Elapsed: ${elapsed} Remaining: ${remaining}`;
1413
1414	    ui.progressIcon.value = value;
1415	    ui.progressMain.value = value;
1416
1417	    // indeterminate progress bar
1418	    if (max) {
1419	      ui.progressIcon.setAttribute('max', max);
1420	      ui.progressMain.setAttribute('max', max);
1421	    } else {
1422	      ui.progressIcon.removeAttribute('value');
1423	      ui.progressMain.removeAttribute('value');
1424	      ui.percent.innerHTML = '...';
1425	    }
1426
1427	    // update delays
1428	    const searchDelayInput = $('input#searchDelay');
1429	    searchDelayInput.value = undiscordCore.options.searchDelay;
1430	    $('div#searchDelayValue').textContent = undiscordCore.options.searchDelay+'ms';
1431
1432	    const deleteDelayInput = $('input#deleteDelay');
1433	    deleteDelayInput.value = undiscordCore.options.deleteDelay;
1434	    $('div#deleteDelayValue').textContent = undiscordCore.options.deleteDelay+'ms';
1435	  };
1436
1437	  undiscordCore.onStop = (state, stats) => {
1438	    console.log(PREFIX, 'onStop', state, stats);
1439	    $('#start').disabled = false;
1440	    $('#stop').disabled = true;
1441	    ui.undiscordBtn.classList.remove('running');
1442	    ui.progressMain.style.display = 'none';
1443	    ui.percent.style.display = 'none';
1444	  };
1445	}
1446
1447	async function startAction() {
1448	  console.log(PREFIX, 'startAction');
1449	  // general
1450	  const authorId = $('input#authorId').value.trim();
1451	  const guildId = $('input#guildId').value.trim();
1452	  const channelIds = $('input#channelId').value.trim().split(/\s*,\s*/);
1453	  const includeNsfw = $('input#includeNsfw').checked;
1454	  // filter
1455	  const content = $('input#search').value.trim();
1456	  const hasLink = $('input#hasLink').checked;
1457	  const hasFile = $('input#hasFile').checked;
1458	  const includePinned = $('input#includePinned').checked;
1459	  const pattern = $('input#pattern').value;
1460	  // message interval
1461	  const minId = $('input#minId').value.trim();
1462	  const maxId = $('input#maxId').value.trim();
1463	  // date range
1464	  const minDate = $('input#minDate').value.trim();
1465	  const maxDate = $('input#maxDate').value.trim();
1466	  //advanced
1467	  const searchDelay = parseInt($('input#searchDelay').value.trim());
1468	  const deleteDelay = parseInt($('input#deleteDelay').value.trim());
1469	 
1470	  // token
1471	  const authToken = $('input#token').value.trim() || fillToken();
1472	  if (!authToken) return; // get token already logs an error.
1473	  
1474	  // validate input
1475	  if (!guildId) return log.error('You must fill the "Server ID" field!');
1476	 
1477	  // clear logArea
1478	  ui.logArea.innerHTML = '';
1479
1480	  undiscordCore.resetState();
1481	  undiscordCore.options = {
1482	    ...undiscordCore.options,
1483	    authToken,
1484	    authorId,
1485	    guildId,
1486	    channelId: channelIds.length === 1 ? channelIds[0] : undefined, // single or multiple channel
1487	    minId: minId || minDate,
1488	    maxId: maxId || maxDate,
1489	    content,
1490	    hasLink,
1491	    hasFile,
1492	    includeNsfw,
1493	    includePinned,
1494	    pattern,
1495	    searchDelay,
1496	    deleteDelay,
1497	    // maxAttempt: 2,
1498	  };
1499	  if (channelIds.length > 1) {
1500	    const jobs = channelIds.map(ch => ({
1501	      guildId: guildId,
1502	      channelId: ch,
1503	    }));
1504
1505	    try {
1506	      await undiscordCore.runBatch(jobs);
1507	    } catch (err) {
1508	      log.error('CoreException', err);
1509	    }
1510	  }
1511	  // single channel
1512	  else {
1513	    try {
1514	      await undiscordCore.run();
1515	    } catch (err) {
1516	      log.error('CoreException', err);
1517	      undiscordCore.stop();
1518	    }
1519	  }
1520	}
1521
1522	function stopAction() {
1523	  console.log(PREFIX, 'stopAction');
1524	  undiscordCore.stop();
1525	}
1526
1527	// ---- END Undiscord ----
1528
1529	initUI();
1530
1531})();
1532
Undiscord | Robomonkey