Skip to content

Instantly share code, notes, and snippets.

@orion-v
Last active May 12, 2026 01:17
Show Gist options
  • Select an option

  • Save orion-v/95cb48fa73808cdc5c589fe415cc65f1 to your computer and use it in GitHub Desktop.

Select an option

Save orion-v/95cb48fa73808cdc5c589fe415cc65f1 to your computer and use it in GitHub Desktop.
Delete all messages of an user in a Discord channel or server

Delete discord user message history

This script allow for user specific message deletion from an entire server or a single channel using the browser console. This script uses discord search API and it will only delete messages of a chosen user.

How to use

1. Enable developer mode in discord

Go to user settings > appearance in discord and enable Developer mode.

2. Copy and paste the script to a file so you can change the server and author ids.

3. Replace the server and author ids with your own.

Open discord and right click on the server icon and click copy id. Replace the server id in the script with your server id. Do the same process for the author id by right clicking the avatar image.

4. Run script in console

Press F12 in Chrome or Firefox to open the console. Paste the modified script in the console and press enter.

The more messages the longer it takes. You can check if the messages have been deleted by using the search.


Notes

I think there are some channels that this script won't work with. I think they may be NSFW channels but I haven't tested enough.

Use this script at your own risk

This script was based on the following scripts https://gist.github.com/niahoo/c99284a8908cd33d59b4aff802179e9b#gistcomment-2397287 https://gist.github.com/IMcPwn/0c838a6248772c6fea1339ddad503cce

async function clearMessages() {
const server = "000000000000000000"; // server id number
const author = "000000000000000000"; // user id number
const channel = window.location.href.split('/').pop(); // remove this line to delete all messages of an user from a server
const authToken = document.body.appendChild(document.createElement`iframe`).contentWindow.localStorage.token.replace(/"/g, "");
const headers = { 'Authorization': authToken, 'Content-Type': 'application/json' };
const baseURL = `https://discordapp.com/api/v6/channels`;
let searchURL = `https://discordapp.com/api/v6/guilds/${server}/messages/search?author_id=${author}`;
if (typeof channel !== 'undefined') searchURL = searchURL + `&channel_id=${channel}`;
let clock = 0;
let interval = 500;
function delay(duration) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), duration);
});
}
const response = await fetch(searchURL, {headers});
const json = await response.json();
console.log("There are " + json.total_results + " messages left to delete.");
await Array.from(json.messages).map(message => {
message.forEach(async function(item) {
if(item.hit == true) {
await delay(clock += interval);
await fetch(`${baseURL}/${item.channel_id}/messages/${item.id}`, { headers, method: 'DELETE' });
}
});
});
if (json.total_results > 0) {
delay(clock += interval).then(() => { clearMessages(); });
} else {
console.log("Finished deleting messages")
};
}
clearMessages();
Copy link
Copy Markdown

ghost commented Jul 13, 2018

How exactly can this work in a DM?

Open discord and right click on the server icon and click copy id. Replace the server id in the script with your server id. Do the same process for the author id by right clicking the avatar image.

@Nicckonator
Copy link
Copy Markdown

Nicckonator commented Jul 21, 2018

Sadly doesnt work for me for a DM (not a server/channel)

Uncaught (in promise) TypeError: Cannot convert undefined or null to object VM280:24
at Function.from (native)
at clearMessages (:24:14)

is what i get as error.

any fix?

@TrunerReisar
Copy link
Copy Markdown

I tried using this (and the IMcPwn one which worked like a charm up to this point, but that page is gone now) and all I'm getting is this error now:

Uncaught (in promise) TypeError: Cannot read property 'replace' of undefined
at clearMessages (:5:110)
at :37:1

Is this something easily fixable?

@etch286
Copy link
Copy Markdown

etch286 commented Aug 7, 2018

same as above

@Alvin388
Copy link
Copy Markdown

Not working anymore

@instinctualjealousy
Copy link
Copy Markdown

instinctualjealousy commented Sep 21, 2018

If it's anything like the other script, the AuthToken isn't being correctly grabbed and has to be manually inserted instead.

const authToken = "AuthTokenHere";

You can grab it from the dev tools "Application -> Local Storage -> https://discordapp.com", under "token" after a page refresh, I think.

Be sure to remove line #5 if you're trying to do what I'm doing, otherwise it'll say there's 0 messages to delete. It seems to be working for me so far.

@Knuckl3s
Copy link
Copy Markdown

Hello
Can someone give a complete code to delete private messages? It works very well on servers, but for private discussions I have no idea what to change.

Thank you

@PointMeAtTheDawn
Copy link
Copy Markdown

My guess is it is using https://discord.js.org/#/docs/main/stable/class/GuildMember, which doesn't exist in a DM. You'd have to repoint all references to GuildMembers to https://discord.js.org/#/docs/main/stable/class/User (and they don't have stuff like Nickname or DisplayName, just Username IIRC).

@GreenReaper
Copy link
Copy Markdown

You probably need &include_nsfw=true at the end of the channel string to get NSFW channels. That's what the search bar uses.

However right this moment it won't work because the search API as a whole appears to have been removed, breaking Discord's own search bar. This is presumably because it was causing the recent downtime (search is easy to abuse, causing a denial-of-service deliberately or not).

@orion-v
Copy link
Copy Markdown
Author

orion-v commented Dec 10, 2019

You probably need &include_nsfw=true at the end of the channel string to get NSFW channels. That's what the search bar uses.

Thanks for that info.

However right this moment it won't work because the search API as a whole appears to have been removed, breaking Discord's own search bar. This is presumably because it was causing the recent downtime (search is easy to abuse, causing a denial-of-service deliberately or not).

Yours was the first comment that githubgist alerted me. I didn't know there were so many comments here. I will see if I can answer some questions when I have some more time.

Discord search appears to be working again. I don't know if they changed anything or if the code will still work.

@GreenReaper
Copy link
Copy Markdown

It works, but the script gets rate limited at an interval of 500ms. Even 3000ms was too much after a while. The 429 response has this body:

{
  "global": false, 
  "message": "You are being rate limited.", 
  "retry_after": 26104
}

The value appears to be in ms. Typically further requests (at least of that type) are refused with a decreasing timeout from 30 seconds to 0.

@swordfite
Copy link
Copy Markdown

This script by a-SynKronus works well in DMs. But make sure you keep the dms of the person you want your dms deleted in open.


clearMessages= function() {
const author = "YOUR_ID_HERE";
const authToken = "YOUR_TOKEN_HERE";
const channel = window.location.href.split('/').pop();
const headers = { 'Authorization': authToken, 'Content-Type': 'application/json' };

let clock = 0;
let interval = 500;
function delay(duration) {
	return new Promise((resolve, reject) => {
		setTimeout(() => resolve(), duration);
	});
}

fetch(`https://discordapp.com/api/v6/channels/${channel}/messages/search?author_id=${author}`, {headers})
	.then(response => response.json())
	.then(json => {
		Array.from(json.messages).map(message => {
			message.forEach(function(item) {
				if(item.hit == true) {
					delay(clock += interval).then(() => { fetch(`https://discordapp.com/api/v6/channels/${item.channel_id}/messages/${item.id}`, { headers, method: 'PATCH', body: JSON.stringify({'content': 'This comment has been overwritten.'}) }) });
					delay(clock += interval).then(() => { fetch(`https://discordapp.com/api/v6/channels/${item.channel_id}/messages/${item.id}`, { headers, method: 'DELETE' }) });
				}
			});
		});

		if (json.total_results > 0) { delay(clock += interval).then(() => { clearMessages(); }); }
	});

}
clearMessages();

@marcosrocha85
Copy link
Copy Markdown

marcosrocha85 commented Nov 17, 2020

Uncaught (in promise) TypeError: document.body.appendChild(...).contentWindow.localStorage.token is undefined
When trying to use the script to delete all messages from myself in all channels.

I think Discord does not save token on localStorage anymore. Now we should use OAuth2 in order to get Authorization token.


Quick workaround: Follow this.

@scsmash3r
Copy link
Copy Markdown

Works well, but often catches up the rate limits. Thanks to @marcosrocha85 for the token tip.

@mikecann
Copy link
Copy Markdown

Okay just as a reference this works for me, deletes all of a users messages from the entire server:

async function clearMessages() {
	const server = "000000000000000000"; // server id number
	const author = "000000000000000000"; // user id number

	const authToken = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
	const headers = { 'Authorization': authToken, 'Content-Type': 'application/json' };
	const baseURL = `https://discordapp.com/api/v6/channels`;
	let searchURL = `https://discordapp.com/api/v6/guilds/${server}/messages/search?author_id=${author}`;

	let clock = 0;
	let interval = 2000;
	function delay(duration) {
		return new Promise((resolve, reject) => {
			setTimeout(() => resolve(), duration);
		});
	}

	const response = await fetch(searchURL, {headers});
	const json = await response.json();
	console.log("There are " + json.total_results + " messages left to delete.");
	await Array.from(json.messages).map(message => {
		message.forEach(async function(item) {
			if(item.hit == true) {
				await delay(clock += interval);
				await fetch(`${baseURL}/${item.channel_id}/messages/${item.id}`, { headers, method: 'DELETE' });
			}
		});
	});

	if (json.total_results > 0) { 
		delay(clock += interval).then(() => { clearMessages(); }); 
	} else {
		console.log("Finished deleting messages")
	};
}
clearMessages();

As @instinctualjealousy mentioned you need to manually get the auth token.

The "token" in Local Storage wasnt there so I refreshed the page by typing window.location.reload(). It then turned up. I grabbed it and smashed it into this script and it worked. I had to increase the timeout to 2000 otherise I would be rate limited.

@KLEPTOROTH
Copy link
Copy Markdown

Okay just as a reference this works for me, deletes all of a users messages from the entire server:

async function clearMessages() {
	const server = "000000000000000000"; // server id number
	const author = "000000000000000000"; // user id number

	const authToken = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
	const headers = { 'Authorization': authToken, 'Content-Type': 'application/json' };
	const baseURL = `https://discordapp.com/api/v6/channels`;
	let searchURL = `https://discordapp.com/api/v6/guilds/${server}/messages/search?author_id=${author}`;

	let clock = 0;
	let interval = 2000;
	function delay(duration) {
		return new Promise((resolve, reject) => {
			setTimeout(() => resolve(), duration);
		});
	}

	const response = await fetch(searchURL, {headers});
	const json = await response.json();
	console.log("There are " + json.total_results + " messages left to delete.");
	await Array.from(json.messages).map(message => {
		message.forEach(async function(item) {
			if(item.hit == true) {
				await delay(clock += interval);
				await fetch(`${baseURL}/${item.channel_id}/messages/${item.id}`, { headers, method: 'DELETE' });
			}
		});
	});

	if (json.total_results > 0) { 
		delay(clock += interval).then(() => { clearMessages(); }); 
	} else {
		console.log("Finished deleting messages")
	};
}
clearMessages();

As @instinctualjealousy mentioned you need to manually get the auth token.

The "token" in Local Storage wasnt there so I refreshed the page by typing window.location.reload(). It then turned up. I grabbed it and smashed it into this script and it worked. I had to increase the timeout to 2000 otherise I would be rate limited.

Thank you so much for this! One problem I'm having running this, is it didn't delete ALL messages from the user. I can still search and see many messages from the user I targeted. I did see some of them disappear, but it only got about 200 of 900+ messages. Any ideas why this would be?

@sbaysan21
Copy link
Copy Markdown

sbaysan21 commented Apr 2, 2022

Thank you so much for this! One problem I'm having running this, is it didn't delete ALL messages from the user. I can still search and see many messages from the user I targeted. I did see some of them disappear, but it only got about 200 of 900+ messages. Any ideas why this would be?

This same thing happened to me where it stopped deleting messages after about getting through 10% of what I wanted to get rid of. I figured out that it wasn't deleting messages that were archived in threads, so I had to unarchive them manually.

@0xAskar
Copy link
Copy Markdown

0xAskar commented Oct 13, 2022

I'm still getting rate limited even with using your exact script @mikecann. thoughts on why? Interval is 2000 ms right now

@PatrickKing
Copy link
Copy Markdown

PatrickKing commented Oct 26, 2022

I've created a modified version of the above script which pays attention to the server error that indicates we're being rate limited, and which pauses for a bit. It still has issues, in particular it doesn't handle the failure to delete a message due to it being archived.

It works for me. As before, this comes with zero warranty, use at your own risk!

Edited to add: when deleting many messages from the same channel it's common to see 'try again later' values higher than 7000 ms. But, often many messages can be deleted in a row without being rate limited

Edited again: modified to print an URL to archived messages, to more easily track them down.

function clearMessages () {
  const server = "000000000000000000"; // server id number
  const author = "000000000000000000"; // user id number

  const channel = window.location.href.split('/').pop(); // remove this line to delete all messages of an user from a server

  const authToken = document.body.appendChild(document.createElement`iframe`).contentWindow.localStorage.token.replace(/"/g, "");
  const headers = { 'Authorization': authToken, 'Content-Type': 'application/json' };
  const baseURL = `https://discordapp.com/api/v6/channels`;
  let searchURL = `https://discordapp.com/api/v6/guilds/${server}/messages/search?author_id=${author}`;
  if (typeof channel !== 'undefined') searchURL = searchURL + `&channel_id=${channel}`;


  let itemsForDeletion = []

  function checkForMessages () {
    fetch(searchURL, {headers})
    .then((response) => {
      return response.json()
    })
    .then((json) => {
      console.log("There are " + json.total_results + " messages left to delete.");
      if (json.total_results === 0) {
        console.log('Finished deleting messages!')
        return
      }

      for (const message of json.messages) {
        for (const item of message) {
          if (item.hit === true) {
            itemsForDeletion.push(item)
          }
        }
      }

      deleteNextMessage()
    })
  }

  function deleteNextMessage () {

    if (itemsForDeletion.length === 0) {
      checkForMessages()
      return
    }

    const item = itemsForDeletion[itemsForDeletion.length - 1]

    fetch(`${baseURL}/${item.channel_id}/messages/${item.id}`, { headers, method: 'DELETE' })
    .then((response) => {

      if (response.status === 200 || response.status === 204) {
        itemsForDeletion.pop()
        deleteNextMessage()
      }
      else if (response.status === 429) {
        // too many requests
        response.json().then((errorJson) => {
          console.log(`Waiting (${errorJson.retry_after}ms)`)
          setTimeout(deleteNextMessage, errorJson.retry_after)
        })
      }
      else if (response.status === 400) {
        console.log(`Check whether this message is in an archived/closed thread: https://discord.com/channels/${server}/${item.channel_id}/${item.id}`)
        itemsForDeletion.pop()
        deleteNextMessage()
      }
      else {
        itemsForDeletion.pop()
        deleteNextMessage()
      }

    })
    .catch((error) => {
      console.log(error)
    })

  }

  checkForMessages()

}

clearMessages()

@geohenmar
Copy link
Copy Markdown

geohenmar commented Jun 27, 2024

Trying this 2 years later lol, but I keep getting this error:

injectScript.js:1 Uncaught TypeError: _.toLowerCase is not a function
at document.createElement (injectScript.js:1:28234)
at clearMessages (:7:68)
at :84:3

Any ideas?

@GreenReaper
Copy link
Copy Markdown

GreenReaper commented Jun 27, 2024

@geohenmar Looks like there need to be (parentheses) around the 'iframe' on the line starting const authtoken and it should probably have regular quotes there too, not backticks. The error comes because createElement is passed something that isn't a string.

@Ha2k4r
Copy link
Copy Markdown

Ha2k4r commented May 12, 2026

Hey folks, I know its a dead thread but i figured it liven it up a little to show off something i have been working on for a bit. I wrote this script because I hate my logs getting spammed with errors and having a UN-optimized deletion script as the first result on google. This script has a test mode for seeing if it works at all with a limited number of deletions. A list for the channels you want removed from and a automatic server wide channel detection system for server wide message wipes. I shouldhave also thrown in author deletions too but i dident haha!

Also, I added a method of caching, this will allow you to resume deletions even after you lose power or close the website, its a nifty feature.

Still a work in progress and if I improve it further i will post it here.

(async () => {
  const DELETE_ALL = false;
  const TEST_MODE = false;
  const TEST_COUNT = 1000;
  const CHANNEL_LIST = [
    "000000000000000000",
    "000000000000000000",
  ];

  const server = "000000000000000000";
  const author = "000000000000000000";
  const CACHE_KEY = "dc_cache";
  const CURSOR_KEY = "dc_cursor";
  const CHECKPOINT_INTERVAL = 100;
  const DISPLAY_INTERVAL = 3000;

  const iframe = document.body.appendChild(document.createElement('iframe'));
  const storage = iframe.contentWindow.localStorage;
  const authToken = storage.token.replace(/"/g, "");
  const headers = { 'Authorization': authToken, 'Content-Type': 'application/json' };
  let deletedCount = 0;
  let skippedCount = 0;
  let rateLimitHits = 0;
  let totalCount = 0;
  let queue = [];
  let lastRequestTime = 0;
  let statusEl = null;

  console.log(
    '%c=== Discord Message Cleaner ===',
    'font-size:16px;font-weight:bold;color:#00ff88'
  );
  console.log(
    '%cDont worry about red 429 errors in the console — those are normal rate limits that the script handles automatically.\n' +
    '%cCheck the NETWORK tab (Filter: DELETE) to watch progress in real time.\n' +
    '%cA status bar will appear at the top of the page.',
    'color:#aaa', 'color:#88ccff', 'color:#ffcc44'
  );

  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

  function createStatusBar() {
    const el = document.createElement('div');
    el.id = 'dc-cleaner-status';
    el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#1a1a2e;color:#e0e0e0;font:14px/1.4 monospace;padding:10px 16px;border-bottom:2px solid #00ff88;box-shadow:0 2px 12px rgba(0,0,0,0.5);';
    document.body.appendChild(el);
    document.body.style.marginTop = '44px';
    return el;
  }

  function setStatus(text, color) {
    if (!statusEl) statusEl = createStatusBar();
    statusEl.innerHTML = text;
    if (color) statusEl.style.borderBottomColor = color;
  }

  function clearStatusBar() {
    if (statusEl) {
      statusEl.remove();
      statusEl = null;
      document.body.style.marginTop = '';
    }
  }

  async function fetchWithRetry(url, retries = 5) {
    for (let i = 0; i < retries; i++) {
      const res = await fetch(url, { headers });
      if (res.ok) return res;
      if (res.status === 429) {
        const wait = +(res.headers.get('Retry-After') || 2) * 1000;
        await sleep(wait);
        continue;
      }
      return res;
    }
    return null;
  }

  function saveCache(done) {
    storage.setItem(CACHE_KEY, JSON.stringify({
      messages: queue,
      totalCount,
      deletedCount,
      skippedCount,
      rateLimitHits,
      timestamp: Date.now(),
      done: !!done
    }));
  }

  function markComplete() {
    const raw = storage.getItem(CACHE_KEY);
    if (raw) {
      const data = JSON.parse(raw);
      data.done = true;
      data.messages = [];
      storage.setItem(CACHE_KEY, JSON.stringify(data));
    }
  }

  function loadCache() {
    try {
      const raw = storage.getItem(CACHE_KEY);
      if (!raw) return null;
      const data = JSON.parse(raw);
      if (!Array.isArray(data.messages)) return null;
      return data;
    } catch { return null; }
  }

  function saveCursor(cursor) {
    storage.setItem(CURSOR_KEY, JSON.stringify(cursor));
  }

  function loadCursor() {
    try {
      const raw = storage.getItem(CURSOR_KEY);
      if (!raw) return null;
      return JSON.parse(raw);
    } catch { return null; }
  }

  function clearCache() {
    storage.removeItem(CACHE_KEY);
    storage.removeItem(CURSOR_KEY);
  }

  function getChannels(allChannels) {
    const textTypes = new Set([0, 5, 15, 11, 12]);
    const textChannels = allChannels.filter(c => textTypes.has(c.type));
    if (DELETE_ALL) {
      console.log(`DELETE_ALL: ${textChannels.length} channels found`);
      return textChannels;
    }
    const set = new Set(CHANNEL_LIST);
    const selected = textChannels.filter(c => set.has(c.id));
    const missing = CHANNEL_LIST.filter(id => !textChannels.some(c => c.id === id));
    if (missing.length) console.warn(`Channel IDs not found: ${missing.join(', ')}`);
    console.log(`SELECTED: ${selected.length} channels (${selected.map(c => '#' + c.name).join(', ')})`);
    return selected;
  }

  async function scrapeChannels(cursor, targetCount) {
    const guildRes = await fetchWithRetry(`https://discord.com/api/v9/guilds/${server}/channels`);
    if (!guildRes) { console.error("Failed to fetch channels"); return 0; }
    const allChannels = await guildRes.json();
    const selected = getChannels(allChannels);
    if (selected.length === 0) { console.error("No valid channels"); return 0; }

    let scraped = 0;
    const scrapeStart = Date.now();
    for (let i = cursor.channelIdx; i < selected.length; i++) {
      const ch = selected[i];
      let before = (i === cursor.channelIdx && cursor.before) ? cursor.before : null;

      while (true) {
        const url = `https://discord.com/api/v9/channels/${ch.id}/messages?limit=100${before ? `&before=${before}` : ''}`;
        const res = await fetchWithRetry(url);
        if (!res) break;
        const msgs = await res.json();
        if (!Array.isArray(msgs) || msgs.length === 0) break;

        for (const msg of msgs) {
          if (msg.author && msg.author.id === author) {
            queue.push({ channel_id: ch.id, id: msg.id });
            scraped++;
          }
        }

        before = msgs[msgs.length - 1].id;
        cursor.channelIdx = i;
        cursor.before = before;

        const elapsed = Math.round((Date.now() - scrapeStart) / 1000);
        const limitStr = targetCount ? ` / ${targetCount}` : '';
        setStatus(
          `Scanning #${ch.name}... Found <b>${scraped}${limitStr}</b> messages from you &nbsp;|&nbsp; Elapsed: <b>${elapsed}s</b>`,
          '#88ccff'
        );

        if (targetCount !== null && scraped >= targetCount) {
          return scraped;
        }
      }

      cursor.channelIdx = i + 1;
      cursor.before = null;
    }
    return scraped;
  }

  async function deleteAll() {
    let currentGap = 1000;
    let cleanStreak = 0;
    let displayTimer = Date.now();
    let displayDeleted = 0;
    let display429s = 0;
    let windowHTTP = 0;

    while (queue.length > 0) {
      const msg = queue[0];

      const gap = Date.now() - lastRequestTime;
      if (gap < currentGap) await sleep(currentGap - gap);

      let had429 = false;
      let res = null;

      for (let r = 5; r > 0; r--) {
        windowHTTP++;
        res = await fetch(`https://discord.com/api/v9/channels/${msg.channel_id}/messages/${msg.id}`, { headers, method: 'DELETE' });
        lastRequestTime = Date.now();

        if (res.ok || res.status === 400 || res.status === 404) break;

        if (res.status === 429) {
          had429 = true;
          rateLimitHits++;
          const base = +(res.headers.get('Retry-After') || 2) * 1000;
          const wait = Math.round(base * (0.9 + Math.random() * 0.5));
          await sleep(wait);
          continue;
        }
        break;
      }

      if (had429) {
        cleanStreak = 0;
        currentGap = Math.min(Math.round(currentGap * 1.2), 5000);
      } else {
        cleanStreak++;
        if (cleanStreak >= 20) {
          currentGap = Math.max(Math.round(currentGap * 0.95), 500);
          cleanStreak = 0;
        }
      }

      if (res && (res.ok || res.status === 400 || res.status === 404)) {
        deletedCount++;
        queue.shift();

        if (deletedCount % CHECKPOINT_INTERVAL === 0) {
          saveCache();
        }
      } else {
        skippedCount++;
        queue.shift();
      }

      const now = Date.now();
      if (now - displayTimer >= DISPLAY_INTERVAL || queue.length === 0) {
        const elapsedMin = (now - displayTimer) / 60000;
        const rateMin = elapsedMin > 0 ? Math.round((deletedCount - displayDeleted) / elapsedMin) : 0;
        const rateHour = rateMin * 60;
        const new429s = rateLimitHits - display429s;
        const errPct = windowHTTP > 0 ? (new429s / windowHTTP * 100).toFixed(1) : '0.0';

        let eta = '';
        if (rateMin > 0 && queue.length > 0) {
          const etaMin = queue.length / rateMin;
          if (etaMin < 1) {
            eta = '< 1m';
          } else {
            const etaH = Math.floor(etaMin / 60);
            const etaM = Math.round(etaMin % 60);
            eta = etaH > 0 ? `${etaH}h ${etaM}m` : `${etaM}m`;
          }
        }

        const pct = totalCount > 0 ? Math.round((deletedCount / totalCount) * 100) : 0;
        const bar = '█'.repeat(Math.round(pct / 5)) + '░'.repeat(20 - Math.round(pct / 5));
        setStatus(
          `<b>${rateHour}/h</b> ${rateMin}/m &nbsp;|&nbsp; ` +
          `<b>${currentGap}ms</b> delay/req &nbsp;|&nbsp; ` +
          `429 rate <b>${errPct}%</b> &nbsp;|&nbsp; ` +
          `ETA <b>${eta}</b> &nbsp;|&nbsp; ` +
          `${bar} <b>${deletedCount}/${totalCount}</b> (${pct}%)`,
          errPct > 20 ? '#ff6644' : errPct > 0 ? '#ffcc44' : '#00ff88'
        );

        displayTimer = now;
        displayDeleted = deletedCount;
        display429s = rateLimitHits;
        windowHTTP = 0;
      }
    }
  }

  // If the first cached message is already deleted, binary search to find
  // where the existing messages begin, then trim the stale prefix.
  async function trimStaleCache() {
    if (queue.length === 0) return;

    // Spot check: is the first message still there?
    const first = queue[0];
    let res = await fetch(`https://discord.com/api/v9/channels/${first.channel_id}/messages/${first.id}`, { headers, method: 'GET' });
    if (res.ok) return; // cache is fresh

    if (res.status === 429) {
      const wait = +(res.headers.get('Retry-After') || 1) * 1000;
      await sleep(wait);
      res = await fetch(`https://discord.com/api/v9/channels/${first.channel_id}/messages/${first.id}`, { headers, method: 'GET' });
      if (res.ok) return;
    }

    if (res.status !== 404) return; // other error, play it safe

    setStatus(`Cache is stale — scanning for existing messages...`, '#ff6644');

    // Binary search for the first existing message
    let lo = 0, hi = queue.length;
    while (lo < hi) {
      const mid = Math.floor((lo + hi) / 2);
      const msg = queue[mid];
      setStatus(`Validating cache... scanning message ${mid}/${queue.length}`, '#ffcc44');
      const r = await fetch(`https://discord.com/api/v9/channels/${msg.channel_id}/messages/${msg.id}`, { headers, method: 'GET' });
      if (r.ok) { hi = mid; }
      else if (r.status === 404) { lo = mid + 1; }
      else { hi = mid; } // error, search left to be safe
    }

    if (lo > 0) {
      console.log(`Trimmed ${lo} already-deleted messages from cache`);
      queue = queue.slice(lo);
      if (queue.length === 0) {
        console.log("All cached messages were already deleted. Will re-scan.");
        clearCache();
      }
    }
  }

  // --- Cache validation & loading ---
  let cache = loadCache();
  if (cache && cache.done) {
    console.log("Previous run completed. Cache cleared.");
    clearCache();
    cache = null;
  }

  if (cache) {
    const age = Math.round((Date.now() - cache.timestamp) / 1000);
    const answer = prompt(
      `[Cache] Found ${cache.messages.length} messages cached (${age}s old, deleted: ${cache.deletedCount || 0}).\n` +
      (cache.skippedCount > 0 ? `WARNING: ${cache.skippedCount} messages were skipped in the last run.\n` : '') +
      `Use cache? Enter "y" to use cache, anything else to re-scan.`
    );
    if (answer && answer.toLowerCase() === 'y') {
      queue = cache.messages;
      totalCount = cache.totalCount;
      deletedCount = cache.deletedCount || 0;
      skippedCount = cache.skippedCount || 0;
      rateLimitHits = cache.rateLimitHits || 0;
      console.log(`Loaded ${queue.length} messages from cache. Already deleted: ${deletedCount}`);

      // Check if cache is stale (messages were already deleted)
      await trimStaleCache();

      if (TEST_MODE) {
        console.log("NOTICE: Skipping test since cache was loaded. Set TEST_MODE=false or clear cache to re-test.");
      }
    } else {
      clearCache();
      console.log("Cache discarded, will re-scan.");
    }
  }

  // --- Scrape if no cache ---
  if (queue.length === 0) {
    if (TEST_MODE) {
      const cursor = { channelIdx: 0, before: null };
      setStatus('Scanning channels (test mode, limit 1000)...', '#88ccff');
      const scraped = await scrapeChannels(cursor, TEST_COUNT);
      if (scraped === 0) { console.log("Nothing to delete."); clearStatusBar(); return; }

      console.log(`✓ Scanning finished: ${scraped} messages found`);
      saveCursor(cursor);
      queue = queue.slice(0, TEST_COUNT);
      totalCount = queue.length;

      setStatus(`Caching scan position...`, '#ffcc44');
      console.log(`✓ Cursor saved to localStorage (key: ${CURSOR_KEY})`);

      console.log(`\n=== TEST MODE: Deleting ${queue.length} messages ===`);
      const t0 = Date.now();
      await deleteAll();
      const t1 = Date.now();

      if (skippedCount > 0) {
        console.error(`\nTEST FAILED after ${(t1-t0)/1000}s: ${skippedCount} messages could not be deleted`);
        clearStatusBar();
        return;
      }

      console.log(`\nTEST PASSED: ${TEST_COUNT} deleted in ${(t1-t0)/1000}s (${rateLimitHits} transient 429s)`);

      // --- Cache test ---
      setStatus(`Verifying cache...`, '#ffcc44');
      const cachedCursor = loadCursor();
      if (!cachedCursor) {
        console.error("CACHE TEST FAILED: cursor not saved");
        clearStatusBar();
        return;
      }
      saveCache();

      const verify = loadCache();
      if (!verify || !Array.isArray(verify.messages)) {
        console.error("CACHE TEST FAILED: queue not saved");
        clearStatusBar();
        return;
      }
      console.log("✓ Cache save/load verified");
      console.log("CACHE TEST PASSED");

      // --- Resume scraping remaining messages ---
      deletedCount = 0;
      skippedCount = 0;
      rateLimitHits = 0;
      lastRequestTime = 0;
      queue = [];

      setStatus(`Resuming scan from saved position...`, '#88ccff');
      console.log(`\nResuming scrape from cursor position...`);
      const totalScraped = await scrapeChannels(cachedCursor, null);
      if (totalScraped === 0) { console.log("No remaining messages."); clearStatusBar(); return; }
      totalCount = queue.length;
      console.log(`Total: ${totalCount} remaining messages queued`);

      if (totalCount > 0) {
        saveCache();
        console.log(`\n=== FULL RUN ===`);
        const t2 = Date.now();
        await deleteAll();
        markComplete();
        clearStatusBar();
        const t3 = Date.now();
        console.log(`\nDone. Deleted ${deletedCount} messages in ${(t3-t2)/1000}s`);
      }
    } else {
      const cursor = { channelIdx: 0, before: null };
      setStatus('Scanning all channels...', '#88ccff');
      console.log("Scraping all channels...");
      const totalScraped = await scrapeChannels(cursor, null);
      if (totalScraped === 0) { console.log("Nothing to delete."); clearStatusBar(); return; }
      totalCount = queue.length;
      console.log(`Total: ${totalCount} messages queued`);

      saveCache();
      console.log(`\n=== FULL RUN ===`);
      const t0 = Date.now();
      await deleteAll();
      markComplete();
      clearStatusBar();
      const t1 = Date.now();
      console.log(`\nDone. Deleted ${deletedCount} messages in ${(t1-t0)/1000}s`);
    }
  } else {
    console.log(`\n=== FULL RUN ===`);
    const t0 = Date.now();
    await deleteAll();
    markComplete();
    clearStatusBar();
    const t1 = Date.now();
    console.log(`\nDone. Deleted ${deletedCount} messages in ${(t1-t0)/1000}s`);
  }
})();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment