// ==UserScript== // @name RemoveCord // @namespace https://github.com/05v/RemoveCord // @version 1.5.0 // @description Free open source Tampermonkey script to delete Discord messages in a server you can manage. Working UnDiscord alternative for Discord servers. // @author 05v // @license MIT // @homepageURL https://github.com/05v/RemoveCord // @supportURL https://github.com/05v/RemoveCord/issues // @downloadURL https://raw.githubusercontent.com/05v/RemoveCord/main/RemoveCord.user.js // @updateURL https://raw.githubusercontent.com/05v/RemoveCord/main/RemoveCord.user.js // @match https://discord.com/* // @match https://ptb.discord.com/* // @match https://canary.discord.com/* // @run-at document-start // @grant none // ==/UserScript== (function () { "use strict"; const PAGE_SCRIPT = function () { const NS = "removecord"; const API = "https://discord.com/api/v9"; const state = { token: null, superProps: null, me: null, running: false, stop: false, modal: null, lastScan: null, }; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function rand(min, max) { return min + Math.random() * (max - min); } function randInt(min, max) { return Math.floor(rand(min, max + 1)); } function formatWait(ms) { const sec = ms / 1000; return sec >= 10 ? `${Math.round(sec)}s` : `${sec.toFixed(1)}s`; } function formatDuration(ms) { const sec = Math.max(0, Math.round(ms / 1000)); if (sec < 60) return `${sec}s`; const mins = Math.floor(sec / 60); const rem = sec % 60; if (mins < 60) return rem ? `${mins}m ${rem}s` : `${mins}m`; const hours = Math.floor(mins / 60); const leftMins = mins % 60; if (hours < 48) return leftMins ? `${hours}h ${leftMins}m` : `${hours}h`; const days = Math.floor(hours / 24); const leftHours = hours % 24; return leftHours ? `${days}d ${leftHours}h` : `${days}d`; } function formatDate(iso) { if (!iso) return "Unknown"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "Unknown"; return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); } function esc(value) { return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[ch])); } function estimateWipe(count, channelCount, recentCount) { const n = Math.max(0, count); const recent = Math.max(0, Math.min(n, recentCount == null ? 0 : recentCount)); const old = n - recent; const searches = Math.max(1, Math.ceil(n / 25)); const batches = Math.ceil(recent / 100); return { low: 200 + searches * 180 + batches * 350 + old * 220, mid: 400 + searches * 320 + batches * 600 + old * 360, high: 800 + searches * 550 + batches * 1100 + old * 700, }; } function isRecent(id) { try { const created = Number((BigInt(id) >> 22n) + 1420070400000n); return Date.now() - created < 14 * 24 * 60 * 60 * 1000; } catch (_) { return false; } } async function humanWait(ms, onTick) { const end = Date.now() + ms; while (Date.now() < end) { if (state.stop) return; const left = end - Date.now(); if (onTick) onTick(left); await sleep(Math.min(250, left)); } } function headerGet(headers, name) { if (!headers) return null; if (typeof headers.get === "function") return headers.get(name); const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); return key ? headers[key] : null; } function captureAuth(headers) { const auth = headerGet(headers, "Authorization"); if (auth && auth !== "undefined" && auth.length > 20) state.token = auth; const props = headerGet(headers, "X-Super-Properties"); if (props) state.superProps = props; } function hookNetwork() { const origFetch = window.fetch; window.fetch = function (...args) { try { captureAuth(args[1] && args[1].headers); } catch (_) {} return origFetch.apply(this, args); }; const origOpen = XMLHttpRequest.prototype.open; const origSet = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function (...args) { this.__rcHeaders = {}; return origOpen.apply(this, args); }; XMLHttpRequest.prototype.setRequestHeader = function (name, value) { try { this.__rcHeaders[name] = value; if (name.toLowerCase() === "authorization") state.token = value; if (name.toLowerCase() === "x-super-properties") state.superProps = value; } catch (_) {} return origSet.apply(this, arguments); }; } function tokenFromWebpack() { const chunk = window.webpackChunkdiscord_app; if (!chunk) return null; let found = null; try { chunk.push([ [Symbol(NS)], {}, (req) => { if (!req || !req.c) return; for (const id of Object.keys(req.c)) { const exp = req.c[id] && req.c[id].exports; if (!exp) continue; const cands = [exp, exp.default, exp.Z, exp.ZP]; for (const cand of cands) { if (cand && typeof cand.getToken === "function") { try { const t = cand.getToken(); if (t && typeof t === "string" && t.length > 20) found = t; } catch (_) {} } } } }, ]); } catch (_) {} return found; } function getToken() { if (state.token) return state.token; const fromWp = tokenFromWebpack(); if (fromWp) state.token = fromWp; return state.token; } function routeParts() { const m = location.pathname.match(/^\/channels\/(\d+|@me)\/(\d+)/); if (!m) return { guildId: null, channelId: null }; return { guildId: m[1] === "@me" ? null : m[1], channelId: m[2], }; } async function api(method, path, body) { const token = getToken(); if (!token) throw new Error("Discord session not ready yet. Open a channel and try again."); const headers = { Authorization: token, Accept: "*/*", }; if (body !== undefined) headers["Content-Type"] = "application/json"; if (state.superProps) headers["X-Super-Properties"] = state.superProps; const res = await fetch(API + path, { method, headers, credentials: "include", body: body !== undefined ? JSON.stringify(body) : undefined, }); if (res.status === 429) { const data = await res.json().catch(() => ({})); const wait = Math.ceil((data.retry_after || 5) * 1000) + randInt(400, 2200); await sleep(wait); return api(method, path, body); } return res; } async function apiJson(method, path, body) { const res = await api(method, path, body); if (res.status === 204) return null; const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data.message || data.code || res.statusText || String(res.status); const err = new Error(msg); err.status = res.status; err.data = data; throw err; } return data; } function extractHits(data, authorId) { const hits = []; for (const group of data.messages || []) { let picked = group.find((m) => m.hit); if (!picked) { picked = group.find((m) => m.author && m.author.id === authorId); } if (picked) hits.push(picked); } return hits; } async function searchMessages(guildId, authorId, channelId, opts) { const options = opts || {}; const params = new URLSearchParams({ author_id: authorId, sort_by: "timestamp", sort_order: options.sortOrder || "desc", offset: String(options.offset || 0), include_nsfw: "true", }); if (channelId) params.set("channel_id", channelId); if (options.maxId) params.set("max_id", options.maxId); const res = await api("GET", `/guilds/${guildId}/messages/search?${params}`); if (res.status === 202) { const data = await res.json().catch(() => ({})); const wait = Math.ceil((data.retry_after || 2) * 1000); await sleep(wait); return searchMessages(guildId, authorId, channelId, options); } const data = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(data.message || "Search failed"); err.status = res.status; err.data = data; throw err; } return data; } async function deleteOne(channelId, messageId) { const res = await api("DELETE", `/channels/${channelId}/messages/${messageId}`); if (res.status === 204 || res.status === 404) return "ok"; if (res.status === 429) return "retry"; const data = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(data.message || `Delete failed (${res.status})`); err.status = res.status; throw err; } return "ok"; } async function bulkDelete(channelId, ids) { const chunk = ids.slice(0, 100); if (chunk.length === 1) return deleteOne(channelId, chunk[0]); const res = await api("POST", `/channels/${channelId}/messages/bulk-delete`, { messages: chunk, }); if (res.status === 204 || res.status === 404) return "ok"; const data = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(data.message || `Bulk delete failed (${res.status})`); err.status = res.status; throw err; } return "ok"; } async function loadMe() { if (state.me) return state.me; state.me = await apiJson("GET", "/users/@me"); return state.me; } async function loadUser(userId) { return apiJson("GET", `/users/${userId}`); } async function loadGuild(guildId) { return apiJson("GET", `/guilds/${guildId}`); } async function loadChannel(channelId) { return apiJson("GET", `/channels/${channelId}`); } async function searchMembers(guildId, query) { const q = encodeURIComponent(query); return apiJson("GET", `/guilds/${guildId}/members/search?query=${q}&limit=8`); } function qs(sel, root) { return (root || document).querySelector(sel); } function findToolbar() { const inbox = document.querySelector('[aria-label="Inbox"]'); if (!inbox) return null; let node = inbox; for (let i = 0; i < 8 && node; i++) { const hasHelp = node.querySelector('[aria-label="Help"]'); if (hasHelp && node !== inbox) return node; node = node.parentElement; } return inbox.closest('[class*="trailing"]'); } function iconSvg() { return ` `; } function injectStyles() { if (document.getElementById(`${NS}-css`)) return; const style = document.createElement("style"); style.id = `${NS}-css`; style.textContent = ` #${NS}-btn { display: flex; align-items: center; margin: 0 4px; } #${NS}-btn .${NS}-hit { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; cursor: pointer; border-radius: 8px; color: #fff; background: var(--status-danger, #f23f43); box-shadow: 0 0 0 2px rgba(242, 63, 67, 0.28); } #${NS}-btn .${NS}-hit:hover { background: #da373c; box-shadow: 0 0 0 2px rgba(242, 63, 67, 0.5); } #${NS}-overlay { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.7); display: flex; align-items: center; justify-content: center; font-family: var(--font-primary, gg sans, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif); } #${NS}-modal { width: 480px; max-width: calc(100vw - 32px); background: var(--background-floating, #111214); color: var(--text-default, #dbdee1); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.4); overflow: hidden; } #${NS}-modal header { padding: 16px 16px 0; } #${NS}-modal h2 { margin: 0; font-size: 20px; line-height: 24px; color: var(--header-primary, #f2f3f5); font-weight: 700; } #${NS}-modal .${NS}-sub { margin: 6px 0 0; font-size: 14px; color: var(--header-secondary, #b5bac1); } #${NS}-modal .${NS}-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; } #${NS}-modal label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--header-secondary, #b5bac1); } #${NS}-modal input[type="text"] { width: 100%; box-sizing: border-box; background: var(--input-background, #1e1f22); color: var(--text-default, #dbdee1); border: none; border-radius: 4px; padding: 10px; font-size: 16px; outline: none; } #${NS}-modal .${NS}-row { display: flex; gap: 8px; } #${NS}-modal .${NS}-row input { flex: 1; } #${NS}-modal .${NS}-hint { font-size: 12px; color: var(--text-muted, #949ba4); } #${NS}-modal .${NS}-picks { display: flex; flex-direction: column; gap: 4px; } #${NS}-modal .${NS}-pick { text-align: left; border: none; border-radius: 4px; cursor: pointer; background: var(--background-modifier-hover, #3f4248); color: var(--interactive-active, #f2f3f5); padding: 8px 10px; font-size: 14px; } #${NS}-modal .${NS}-pick:hover { background: var(--background-modifier-selected, #43464d); } #${NS}-modal .${NS}-stats { background: var(--background-secondary, #2b2d31); border-radius: 4px; padding: 10px 12px; font-size: 13px; line-height: 1.45; max-height: 280px; overflow: auto; } #${NS}-modal .${NS}-stat-k { color: var(--header-secondary, #b5bac1); } #${NS}-modal .${NS}-stat-v { color: var(--header-primary, #f2f3f5); font-weight: 600; } #${NS}-modal .${NS}-chans { margin: 6px 0 0; padding: 0; list-style: none; } #${NS}-modal .${NS}-chans li { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; color: var(--text-default, #dbdee1); } #${NS}-modal .${NS}-eta { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--background-tertiary, #1e1f22); } #${NS}-modal .${NS}-eta-main { font-size: 15px; font-weight: 700; color: var(--header-primary, #f2f3f5); } #${NS}-modal .${NS}-preview { margin: 8px 0 0; padding: 0; list-style: none; color: var(--text-muted, #949ba4); font-size: 12px; } #${NS}-modal .${NS}-preview li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #${NS}-modal .${NS}-bar { height: 6px; border-radius: 99px; background: var(--background-tertiary, #1e1f22); overflow: hidden; } #${NS}-modal .${NS}-bar > div { height: 100%; width: 0; background: var(--status-danger, #f23f43); transition: width .2s ease; } #${NS}-modal footer { display: flex; justify-content: flex-end; gap: 8px; padding: 0 16px 16px; } #${NS}-modal button.${NS}-btn { border: none; border-radius: 4px; cursor: pointer; padding: 8px 16px; font-size: 14px; font-weight: 500; } #${NS}-modal button.${NS}-ghost { background: transparent; color: var(--text-default, #dbdee1); } #${NS}-modal button.${NS}-ghost:hover { text-decoration: underline; } #${NS}-modal button.${NS}-brand { background: var(--button-filled-brand-background, #5865f2); color: #fff; } #${NS}-modal button.${NS}-danger { background: var(--button-danger-background, #da373c); color: #fff; } #${NS}-modal button:disabled { opacity: .5; cursor: not-allowed; } #${NS}-modal .${NS}-err { color: var(--text-danger, #fa777c); font-size: 13px; } #${NS}-modal .${NS}-check { display: flex; align-items: center; gap: 8px; font-size: 14px; } #${NS}-modal .${NS}-check input { accent-color: var(--brand-500, #5865f2); } `; document.documentElement.appendChild(style); } function injectButton() { if (document.getElementById(`${NS}-btn`)) return; const toolbar = findToolbar(); if (!toolbar) return; const wrap = document.createElement("div"); wrap.id = `${NS}-btn`; wrap.innerHTML = `
${iconSvg()}
`; wrap.querySelector(`.${NS}-hit`).addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); openModal(); }); const help = toolbar.querySelector('[aria-label="Help"]'); const helpAnchor = help ? help.closest("a") || help.parentElement : null; if (helpAnchor && helpAnchor.parentElement === toolbar) { toolbar.insertBefore(wrap, helpAnchor); } else { toolbar.appendChild(wrap); } } function closeModal() { if (state.running) return; if (state.modal) { state.modal.remove(); state.modal = null; } } function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; } function setHtml(id, html) { const el = document.getElementById(id); if (el) el.innerHTML = html; } function renderScanReport(scan) { if (!scan || !scan.total) { setHtml( `${NS}-stats`, `
Nothing to delete.
Target: ${esc(scan && scan.userLabel)}
` ); return; } const channelRows = scan.channels .slice(0, 8) .map((ch) => `
  • #${esc(ch.name)}${ch.count}
  • `) .join(""); const extraChannels = Math.max(0, scan.channels.length - 8); const previews = (scan.previews || []) .map((p) => `
  • ${esc(p)}
  • `) .join(""); setHtml( `${NS}-stats`, `
    Target · ${esc(scan.userLabel)}
    Scope · ${esc(scan.scope)}
    ${scan.total.toLocaleString()} indexed messages
    Newest ${esc(scan.newest)} · oldest ${esc(scan.oldest)}
    ${scan.withAttachments} with files · ${scan.withEmbeds} with embeds · ${scan.channels.length} channels in sample of ${scan.sampled}
    ${channelRows ? `` : ""} ${extraChannels ? `
    +${extraChannels} more channels in the sample
    ` : ""} ${previews ? `` : ""}
    Estimated wipe time
    About ${esc(formatDuration(scan.eta.mid))}
    Likely ${esc(formatDuration(scan.eta.low))} to ${esc(formatDuration(scan.eta.high))}
    ${scan.recent || 0} newer than 14 days (bulk) · ${scan.old || 0} older (one by one)
    ~${(scan.eta.mid / Math.max(1, scan.total) / 1000).toFixed(1)}s per message at fast pace
    ` ); } function openModal() { if (state.modal) return; injectStyles(); const { guildId, channelId } = routeParts(); const overlay = document.createElement("div"); overlay.id = `${NS}-overlay`; overlay.innerHTML = ` `; overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); }); overlay.querySelector(`#${NS}-close`).addEventListener("click", closeModal); overlay.querySelector(`#${NS}-me`).addEventListener("click", async () => { try { const me = await loadMe(); qs(`#${NS}-user`).value = me.id; setText(`${NS}-err`, ""); setText(`${NS}-stats`, `Target: ${me.global_name || me.username} (${me.id})`); } catch (err) { setText(`${NS}-err`, err.message); } }); let lookupTimer = null; qs(`#${NS}-lookup`, overlay).addEventListener("input", () => { clearTimeout(lookupTimer); lookupTimer = setTimeout(() => runMemberSearch(guildId), 250); }); qs(`#${NS}-scan`, overlay).addEventListener("click", () => runScan(guildId, channelId)); qs(`#${NS}-wipe`, overlay).addEventListener("click", () => runWipe(guildId, channelId)); qs(`#${NS}-stop`, overlay).addEventListener("click", () => { state.stop = true; setText(`${NS}-err`, "Stopping after the current request..."); }); document.body.appendChild(overlay); state.modal = overlay; if (!guildId) { setText(`${NS}-guild`, "Open a server channel first."); return; } loadGuild(guildId) .then((g) => setText(`${NS}-guild`, `Server: ${g.name}`)) .catch(() => setText(`${NS}-guild`, "Server detected from the current channel.")); } async function runMemberSearch(guildId) { const box = qs(`#${NS}-picks`); const query = qs(`#${NS}-lookup`).value.trim(); box.innerHTML = ""; if (!guildId || query.length < 2) return; try { const members = await searchMembers(guildId, query); for (const member of members) { const user = member.user || {}; const btn = document.createElement("button"); btn.type = "button"; btn.className = `${NS}-pick`; const name = member.nick || user.global_name || user.username || user.id; btn.textContent = `${name} @${user.username || "unknown"} ${user.id}`; btn.addEventListener("click", () => { qs(`#${NS}-user`).value = user.id; box.innerHTML = ""; setText(`${NS}-stats`, `Target: ${name} (${user.id})`); }); box.appendChild(btn); } } catch (err) { setText(`${NS}-err`, err.message); } } function targetUserId() { const raw = qs(`#${NS}-user`).value.trim(); if (!/^\d{17,20}$/.test(raw)) { throw new Error("Enter a valid Discord user ID."); } return raw; } function scopedChannel(channelId) { return qs(`#${NS}-channel`).checked ? channelId : null; } async function runScan(guildId, channelId) { setText(`${NS}-err`, ""); qs(`#${NS}-wipe`).disabled = true; if (!guildId) { setText(`${NS}-err`, "Open a server channel first."); return; } const scanBtn = qs(`#${NS}-scan`); scanBtn.disabled = true; setText(`${NS}-stats`, "Scanning Discord search index..."); try { const authorId = targetUserId(); const scopeId = scopedChannel(channelId); let userLabel = authorId; try { const user = await loadUser(authorId); userLabel = `${user.global_name || user.username} (${user.id})`; } catch (_) {} const first = await searchMessages(guildId, authorId, scopeId); const total = first.total_results || 0; const hits = extractHits(first, authorId); const pages = Math.min(5, Math.max(1, Math.ceil(total / 25))); for (let i = 1; i < pages; i++) { setText(`${NS}-stats`, `Scanning page ${i + 1} of ${pages}...`); await sleep(randInt(250, 700)); const page = await searchMessages(guildId, authorId, scopeId, { offset: i * 25 }); hits.push(...extractHits(page, authorId)); } let oldestIso = hits.length ? hits[hits.length - 1].timestamp : null; if (total > hits.length) { try { const oldestPage = await searchMessages(guildId, authorId, scopeId, { sortOrder: "asc" }); const oldestHits = extractHits(oldestPage, authorId); if (oldestHits[0] && oldestHits[0].timestamp) oldestIso = oldestHits[0].timestamp; } catch (_) {} } const byChannel = new Map(); let withAttachments = 0; let withEmbeds = 0; for (const msg of hits) { if (!byChannel.has(msg.channel_id)) byChannel.set(msg.channel_id, []); byChannel.get(msg.channel_id).push(msg); if (msg.attachments && msg.attachments.length) withAttachments += 1; if (msg.embeds && msg.embeds.length) withEmbeds += 1; } const channels = []; for (const [id, msgs] of byChannel) { let name = id; try { const ch = await loadChannel(id); name = ch.name || ch.id || id; } catch (_) {} channels.push({ id, name, count: msgs.length }); } channels.sort((a, b) => b.count - a.count); const newest = hits[0] && hits[0].timestamp; const previews = hits.slice(0, 3).map((msg) => { const text = (msg.content || "").replace(/\s+/g, " ").trim(); const extra = msg.attachments && msg.attachments.length ? " [file]" : ""; return `${formatDate(msg.timestamp)} ${text || "(no text)"}${extra}`; }); const recentSample = hits.filter((m) => isRecent(m.id)).length; const recent = hits.length ? Math.round(total * (recentSample / hits.length)) : 0; const old = Math.max(0, total - recent); const scan = { total, sampled: hits.length, userLabel, scope: scopeId ? "This channel only" : "Entire server", newest: formatDate(newest), oldest: formatDate(oldestIso), withAttachments, withEmbeds, channels, previews, recent, old, eta: estimateWipe(total, channels.length, recent), }; state.lastScan = scan; renderScanReport(scan); qs(`#${NS}-wipe`).disabled = total === 0; } catch (err) { setText(`${NS}-err`, err.message); } finally { scanBtn.disabled = false; } } async function runWipe(guildId, channelId) { if (state.running) return; const authorId = targetUserId(); const scope = scopedChannel(channelId); const first = await searchMessages(guildId, authorId, scope); const total = first.total_results || 0; if (!total) { setText(`${NS}-stats`, "Nothing to delete."); return; } const eta = (state.lastScan && state.lastScan.total === total && state.lastScan.eta) ? state.lastScan.eta : estimateWipe(total, 1); const ok = confirm( `Permanently delete about ${total} message(s)?\n\nThis takes about ${formatDuration(eta.mid)} at fast pace (${formatDuration(eta.low)} to ${formatDuration(eta.high)}).\n\nThis cannot be undone.` ); if (!ok) return; state.running = true; state.stop = false; qs(`#${NS}-wipe`).hidden = true; qs(`#${NS}-scan`).disabled = true; qs(`#${NS}-stop`).hidden = false; qs(`#${NS}-close`).disabled = true; const seen = new Set(); const startedAt = Date.now(); let deleted = 0; let skipped = 0; let indexedLeft = total; let finishKind = "incomplete"; const leftNow = () => Math.max(0, total - (deleted + skipped)); const update = (extra) => { const done = deleted + skipped; const left = leftNow(); const denom = Math.max(total, done); const pct = Math.min(100, Math.round((done / denom) * 100)); qs(`#${NS}-prog`).style.width = pct + "%"; const elapsed = Date.now() - startedAt; const recentLeft = state.lastScan && state.lastScan.total ? Math.round(left * (state.lastScan.recent / state.lastScan.total)) : 0; const remainingEta = left ? (done >= 8 ? (elapsed / done) * left : estimateWipe(left, 1, recentLeft).mid) : 0; setHtml( `${NS}-stats`, `
    ${deleted.toLocaleString()} deleted · ${skipped} skipped · ${left.toLocaleString()} left
    Elapsed ${esc(formatDuration(elapsed))}${left ? ` · about ${esc(formatDuration(remainingEta))} remaining` : ""}
    ${extra ? `
    ${esc(extra)}
    ` : ""}` ); }; const oldestId = (msgs) => msgs.reduce((min, m) => (BigInt(m.id) < BigInt(min) ? m.id : min), msgs[0].id); const beforeId = (id) => { try { return (BigInt(id) - 1n).toString(); } catch (_) { return id; } }; async function wipeHits(hits) { const byChannel = new Map(); for (const msg of hits) { seen.add(msg.id); if (!byChannel.has(msg.channel_id)) byChannel.set(msg.channel_id, []); byChannel.get(msg.channel_id).push(msg); } for (const [ch, msgs] of byChannel) { if (state.stop) break; const recent = msgs.filter((m) => isRecent(m.id)).map((m) => m.id); const older = msgs.filter((m) => !isRecent(m.id)).map((m) => m.id); for (let i = 0; i < recent.length; i += 100) { if (state.stop) break; const chunk = recent.slice(i, i + 100); try { if (chunk.length >= 2) { await bulkDelete(ch, chunk); deleted += chunk.length; } else { await deleteOne(ch, chunk[0]); deleted += 1; } } catch (err) { for (const id of chunk) { if (state.stop) break; try { await deleteOne(ch, id); deleted += 1; } catch (inner) { skipped += 1; setText(`${NS}-err`, inner.message); if (inner.status === 401 || inner.status === 403) { state.stop = true; break; } } update(err.message); await humanWait(randInt(220, 380)); } } update("Deleting a batch of recent messages..."); await humanWait(randInt(450, 750)); } for (const id of older) { if (state.stop) break; try { await deleteOne(ch, id); deleted += 1; } catch (err) { skipped += 1; setText(`${NS}-err`, err.message); if (err.status === 401 || err.status === 403) { state.stop = true; break; } } update("Deleting older messages one by one..."); await humanWait(randInt(220, 400)); } } } try { let cursor = null; let staleWaits = 0; while (!state.stop) { const page = await searchMessages(guildId, authorId, scope, { maxId: cursor || undefined, }); indexedLeft = page.total_results || 0; const hits = extractHits(page, authorId).filter((m) => m.id); const fresh = hits.filter((m) => !seen.has(m.id)); if (fresh.length) { staleWaits = 0; update(`Found ${fresh.length} more messages. Deleting...`); await wipeHits(fresh); cursor = beforeId(oldestId(fresh)); await humanWait(randInt(150, 320)); continue; } if (hits.length) { const nextCursor = beforeId(oldestId(hits)); if (cursor && BigInt(nextCursor) >= BigInt(cursor)) { cursor = null; staleWaits += 1; await humanWait(Math.min(8000, 1500 * staleWaits), (left) => { update( `Search is stuck on already-deleted messages. Waiting ${formatWait(left)}...` ); }); if (staleWaits >= 8) { finishKind = "incomplete"; break; } continue; } cursor = nextCursor; update("Search still lists messages already deleted. Skipping to older ones..."); await humanWait(randInt(200, 400)); continue; } if (!cursor && indexedLeft === 0) { finishKind = "finished"; break; } if (cursor) { cursor = null; staleWaits += 1; const wait = Math.min(8000, 1500 * staleWaits); await humanWait(wait, (left) => { update( `Reached the end of this search page. Waiting ${formatWait(left)} for Discord's index...` ); }); if (staleWaits >= 8) { finishKind = "incomplete"; break; } continue; } staleWaits += 1; const wait = Math.min(8000, 1500 * staleWaits); await humanWait(wait, (left) => { update( `Search still reports about ${indexedLeft.toLocaleString()} messages. Waiting ${formatWait(left)} for the index...` ); }); if (staleWaits >= 8) { finishKind = "incomplete"; break; } } if (state.stop) finishKind = "stopped"; const left = leftNow(); if (finishKind === "finished" && left === 0) { update("Finished. Scan again to confirm Discord's search index is empty."); } else if (finishKind === "stopped") { update( `Stopped on purpose. ${left.toLocaleString()} still to go. Click Wipe to continue from here.` ); } else { update( `Not finished. Deleted ${deleted.toLocaleString()} of about ${total.toLocaleString()}. Discord search is slow to drop old hits. Click Wipe to continue.` ); } } catch (err) { setText(`${NS}-err`, err.message); } finally { state.running = false; qs(`#${NS}-wipe`).hidden = false; qs(`#${NS}-wipe`).disabled = false; qs(`#${NS}-scan`).disabled = false; qs(`#${NS}-stop`).hidden = true; qs(`#${NS}-close`).disabled = false; } } function boot() { hookNetwork(); const ready = () => { injectStyles(); injectButton(); }; const obs = new MutationObserver(ready); const start = () => { obs.observe(document.body, { childList: true, subtree: true }); ready(); }; if (document.body) start(); else document.addEventListener("DOMContentLoaded", start, { once: true }); } boot(); }; if (document.documentElement.dataset.removecord === "1") return; document.documentElement.dataset.removecord = "1"; PAGE_SCRIPT(); })();