|
/* |
|
* Delete your Discord messages from one channel before a local calendar date. |
|
* |
|
* Run this file in the Discord web app's DevTools Console. |
|
* |
|
* GET THE AUTHORIZATION VALUE IN CURRENT CHROME/DISCORD BUILDS |
|
* ----------------------------------------------------------- |
|
* The old localStorage-based automatic method is no longer reliable. |
|
* |
|
* 1. Open Discord in Chrome and open DevTools: |
|
* macOS: Command+Option+I |
|
* Windows/Linux: Ctrl+Shift+I |
|
* 2. Select the Network tab. |
|
* 3. Perform a Discord search so that a `messages/search` request appears. |
|
* 4. Select that request, open Headers, and find Request Headers. |
|
* 5. Copy the complete value of the `Authorization` header. |
|
* 6. Run this script and paste the value into its prompt. |
|
* |
|
* Treat that value like a password. Never put it in this file, commit it to a |
|
* gist, print it in the console, or share it. This script keeps it only in the |
|
* current tab's memory for the duration of the run. |
|
* |
|
* To stop after the current request finishes, run: |
|
* window.__discordMessageWipeStop = true |
|
*/ |
|
|
|
(async () => { |
|
const CONFIG = { |
|
guildId: "PASTE_GUILD_ID", |
|
channelId: "PASTE_CHANNEL_ID", |
|
authorId: "PASTE_YOUR_USER_ID", |
|
beforeLocalDate: "YYYY-MM-DD", |
|
includeThreads: true, |
|
deleteIntervalMs: 3000, |
|
searchIntervalMs: 1250, |
|
apiVersion: 9 |
|
}; |
|
|
|
if (window.__discordMessageWipeRunning) { |
|
throw new Error("A Discord message wipe is already running in this tab."); |
|
} |
|
|
|
if (location.hostname !== "discord.com") { |
|
throw new Error("Run this script only on https://discord.com/app."); |
|
} |
|
|
|
for (const field of ["guildId", "channelId", "authorId"]) { |
|
if (!/^\d+$/.test(CONFIG[field])) { |
|
throw new Error(`Set CONFIG.${field} to a Discord numeric ID.`); |
|
} |
|
} |
|
|
|
window.__discordMessageWipeRunning = true; |
|
window.__discordMessageWipeStop = false; |
|
|
|
try { |
|
const API = `https://discord.com/api/v${CONFIG.apiVersion}`; |
|
const DISCORD_EPOCH = 1420070400000n; |
|
const THREAD_TYPES = new Set([10, 11, 12]); |
|
const sleep = milliseconds => |
|
new Promise(resolve => setTimeout(resolve, milliseconds)); |
|
|
|
function parseLocalCalendarDate(value) { |
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); |
|
|
|
if (!match) { |
|
throw new Error("beforeLocalDate must use YYYY-MM-DD format."); |
|
} |
|
|
|
const [, year, month, day] = match.map(Number); |
|
const date = new Date(year, month - 1, day, 0, 0, 0, 0); |
|
|
|
if ( |
|
date.getFullYear() !== year || |
|
date.getMonth() !== month - 1 || |
|
date.getDate() !== day |
|
) { |
|
throw new Error(`Invalid beforeLocalDate: ${value}`); |
|
} |
|
|
|
return date; |
|
} |
|
|
|
const cutoff = parseLocalCalendarDate(CONFIG.beforeLocalDate); |
|
const cutoffMilliseconds = cutoff.getTime(); |
|
const cutoffSnowflake = ( |
|
(BigInt(cutoffMilliseconds) - DISCORD_EPOCH) << 22n |
|
).toString(); |
|
|
|
const authorization = window.prompt( |
|
"Paste the Authorization request-header value from Discord DevTools. " + |
|
"It stays in this tab and is not printed." |
|
)?.trim(); |
|
|
|
if (!authorization) { |
|
throw new Error("No Authorization value was provided."); |
|
} |
|
|
|
const authorizationHeaders = { Authorization: authorization }; |
|
|
|
async function discordFetch(url, options = {}) { |
|
for (;;) { |
|
const response = await fetch(url, { |
|
...options, |
|
headers: { |
|
...authorizationHeaders, |
|
...(options.headers || {}) |
|
} |
|
}); |
|
|
|
if (response.status === 429) { |
|
const body = await response.json().catch(() => ({})); |
|
const waitMilliseconds = |
|
Math.ceil((Number(body.retry_after) || 1) * 1000) + 250; |
|
|
|
console.warn(`Rate limited. Waiting ${waitMilliseconds} ms.`); |
|
await sleep(waitMilliseconds); |
|
continue; |
|
} |
|
|
|
// Discord can return 202 while its search index is not ready. |
|
if (response.status === 202) { |
|
const body = await response.json().catch(() => ({})); |
|
const waitMilliseconds = Math.max( |
|
Number(body.retry_after) || 5000, |
|
1000 |
|
); |
|
|
|
console.warn( |
|
`Discord search is indexing. Waiting ${waitMilliseconds} ms.` |
|
); |
|
await sleep(waitMilliseconds); |
|
continue; |
|
} |
|
|
|
return response; |
|
} |
|
} |
|
|
|
async function getJson(url) { |
|
const response = await discordFetch(url); |
|
|
|
if (!response.ok) { |
|
throw new Error( |
|
`Discord request failed: ${response.status} ${response.statusText}` |
|
); |
|
} |
|
|
|
return response.json(); |
|
} |
|
|
|
// Fail closed if the Authorization value belongs to another account. |
|
const currentUser = await getJson(`${API}/users/@me`); |
|
|
|
if (currentUser.id !== CONFIG.authorId) { |
|
throw new Error( |
|
`Wrong Discord account. Expected ${CONFIG.authorId}; ` + |
|
`got ${currentUser.id}.` |
|
); |
|
} |
|
|
|
function makeSearchUrl(offset) { |
|
const parameters = new URLSearchParams({ |
|
author_id: CONFIG.authorId, |
|
channel_id: CONFIG.channelId, |
|
include_nsfw: "true", |
|
sort_by: "timestamp", |
|
sort_order: "asc", |
|
max_id: cutoffSnowflake, |
|
offset: String(offset) |
|
}); |
|
|
|
return ( |
|
`${API}/guilds/${CONFIG.guildId}/messages/search?` + |
|
parameters.toString() |
|
); |
|
} |
|
|
|
console.log("Collecting all matching messages before deleting anything."); |
|
|
|
const messagesById = new Map(); |
|
let totalResults = Infinity; |
|
|
|
for (let offset = 0; offset < totalResults; offset += 25) { |
|
const page = await getJson(makeSearchUrl(offset)); |
|
totalResults = Number(page.total_results || 0); |
|
|
|
const hits = (page.messages || []) |
|
.flat() |
|
.filter(message => message?.hit === true); |
|
|
|
for (const message of hits) { |
|
messagesById.set(message.id, message); |
|
} |
|
|
|
console.log( |
|
`Scanned ${Math.min(offset + 25, totalResults)}/${totalResults}.` |
|
); |
|
|
|
if (totalResults === 0) break; |
|
await sleep(CONFIG.searchIntervalMs); |
|
} |
|
|
|
// A search scoped to a parent channel can include messages in its threads. |
|
// Verify every actual channel before allowing a DELETE request. |
|
const channelSafetyCache = new Map([[CONFIG.channelId, true]]); |
|
|
|
async function isAllowedChannel(channelId) { |
|
if (!channelId) return false; |
|
|
|
if (channelSafetyCache.has(channelId)) { |
|
return channelSafetyCache.get(channelId); |
|
} |
|
|
|
if (!CONFIG.includeThreads) { |
|
channelSafetyCache.set(channelId, false); |
|
return false; |
|
} |
|
|
|
try { |
|
const channel = await getJson(`${API}/channels/${channelId}`); |
|
const allowed = |
|
THREAD_TYPES.has(channel.type) && |
|
channel.parent_id === CONFIG.channelId; |
|
|
|
channelSafetyCache.set(channelId, allowed); |
|
return allowed; |
|
} catch (error) { |
|
console.warn( |
|
`Skipping channel ${channelId}: its parent could not be verified.`, |
|
error |
|
); |
|
channelSafetyCache.set(channelId, false); |
|
return false; |
|
} |
|
} |
|
|
|
const safeMessages = []; |
|
|
|
for (const message of messagesById.values()) { |
|
const correctAuthor = message.author?.id === CONFIG.authorId; |
|
const beforeCutoff = |
|
BigInt(message.id) < BigInt(cutoffSnowflake) && |
|
Date.parse(message.timestamp) < cutoffMilliseconds; |
|
const correctChannel = await isAllowedChannel(message.channel_id); |
|
|
|
if (correctAuthor && beforeCutoff && correctChannel) { |
|
safeMessages.push(message); |
|
} |
|
} |
|
|
|
safeMessages.sort((left, right) => |
|
BigInt(left.id) < BigInt(right.id) |
|
? -1 |
|
: BigInt(left.id) > BigInt(right.id) |
|
? 1 |
|
: 0 |
|
); |
|
|
|
console.log(`Verified ${safeMessages.length} messages for deletion.`); |
|
console.log( |
|
"Stop command: window.__discordMessageWipeStop = true" |
|
); |
|
|
|
if (safeMessages.length === 0) { |
|
console.log("Nothing matched the configured scope."); |
|
return; |
|
} |
|
|
|
const approved = window.confirm( |
|
`Delete ${safeMessages.length} Discord messages?\n\n` + |
|
`Account: ${currentUser.username} (${CONFIG.authorId})\n` + |
|
`Server: ${CONFIG.guildId}\n` + |
|
`Channel: ${CONFIG.channelId}` + |
|
`${CONFIG.includeThreads ? " and its threads" : ""}\n` + |
|
`Before: ${cutoff.toLocaleString()}\n\n` + |
|
"This cannot be undone." |
|
); |
|
|
|
if (!approved) { |
|
console.log("Deletion cancelled."); |
|
return; |
|
} |
|
|
|
let deleted = 0; |
|
let alreadyGone = 0; |
|
let failed = 0; |
|
|
|
for (const message of safeMessages) { |
|
if (window.__discordMessageWipeStop) { |
|
console.warn("Deletion stopped by user."); |
|
break; |
|
} |
|
|
|
// Repeat every destructive-action check immediately before DELETE. |
|
const stillSafe = |
|
message.author?.id === CONFIG.authorId && |
|
BigInt(message.id) < BigInt(cutoffSnowflake) && |
|
Date.parse(message.timestamp) < cutoffMilliseconds && |
|
await isAllowedChannel(message.channel_id); |
|
|
|
if (!stillSafe) { |
|
failed++; |
|
console.error(`Safety check rejected message ${message.id}.`); |
|
continue; |
|
} |
|
|
|
const response = await discordFetch( |
|
`${API}/channels/${message.channel_id}/messages/${message.id}`, |
|
{ method: "DELETE" } |
|
); |
|
|
|
if (response.status === 204) { |
|
deleted++; |
|
console.log( |
|
`[${deleted + alreadyGone + failed}/${safeMessages.length}] ` + |
|
`Deleted ${message.id}.` |
|
); |
|
} else if (response.status === 404) { |
|
alreadyGone++; |
|
console.warn(`Message ${message.id} was already gone.`); |
|
} else { |
|
failed++; |
|
console.error( |
|
`Failed to delete ${message.id}: ` + |
|
`${response.status} ${response.statusText}` |
|
); |
|
} |
|
|
|
await sleep(CONFIG.deleteIntervalMs); |
|
} |
|
|
|
console.log({ |
|
matched: safeMessages.length, |
|
deleted, |
|
alreadyGone, |
|
failed, |
|
stopped: window.__discordMessageWipeStop |
|
}); |
|
} finally { |
|
window.__discordMessageWipeRunning = false; |
|
} |
|
})().catch(error => { |
|
console.error("Discord message wipe failed:", error); |
|
}); |