Undiscord

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

目前为 2023-03-06 提交的版本,查看 最新版本

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