Undiscord

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

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