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 => ({ '&': '&', '<': '<', '"': '"', '\'': ''' })[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