Skip to content

Commit 6272c0e

Browse files
TheCellMasterclaude
andcommitted
5.3.0
Major refactor: modular architecture + upstream PR features + security fixes. === ARCHITECTURE (complete rewrite) === - Split monolithic undiscord-core.js (584 lines) into 5 focused modules: - src/core/undiscord-core.js: Orchestrator (run, runBatch, stop, confirm) - src/core/search.js: Search with iterative retry (202/429 handling) - src/core/filter.js: Pure message filtering (types, pinned, bots, threads, regex) - src/core/delete.js: Delete with retry loop + rate limit adaptation - src/core/unarchive.js: Thread unarchive before delete - Split monolithic undiscord-ui.js (378 lines) into 4 modules: - src/ui/init.js: DOM mount, CSS injection, toolbar button + MutationObserver - src/ui/handlers.js: All event handlers (start, stop, getChannel, pick, etc) - src/ui/progress.js: onStart/onProgress/onStop callbacks - src/ui/logger.js: XSS-safe log rendering with ring buffer - Created src/api/discord-api.js: Pure fetch layer with AbortSignal.timeout(30s) - Split helpers.js (8 functions in 8 lines) into semantic modules: - src/utils/time.js: wait(), msToHMS() - src/utils/html.js: escapeHTML(), redact(), replaceInterpolations() - src/utils/discord.js: queryString(), ask(), toSnowflake() - Merged createElm.js + insertCss.js into src/utils/dom.js - Split CSS (theme.css 355 lines + main.css 172 lines) into 6 modules: - layout.css, components.css, scrollbar.css, redact.css, log.css, drag.css - Moved HTML templates to src/ui/html/ - Renamed utils to kebab-case (getIds -> get-ids, messagePicker -> message-picker) - Removed 12 legacy files replaced by modular architecture - Created src/utils/constants.js with shared constants === NEW FEATURES (from upstream PRs) === - victornpb#741: Poll messages (type 46) can now be deleted - victornpb#741: Bot slash command responses (type 20) excluded from deletion - victornpb#742: HTTP 403 on delete returns FAIL_SKIP instead of infinite retry loop - victornpb#743: Retry logic refactored: FAILED/FAIL_SKIP properly handled, failCount centralized - victornpb#740: HTTP 403 on search gracefully skips channel instead of canceling batch - victornpb#739: 30s delay between batch jobs to prevent API spam - victornpb#737: Thread unarchiving: attempts PATCH to unarchive before skipping - victornpb#729: Empty page retries (configurable, default 2) before stopping - victornpb#629: "Include bot/application messages" checkbox in Filter section - victornpb#610: Thread auto-detection via API when clicking "current" channel button - victornpb#603: Graceful handling of API errors 50024 (channel not found) and 50001 (missing access) - victornpb#643: Date filter warning: "Make sure you enter both date AND time" - victornpb#527/victornpb#519: Rate limit delay adds on top (never decreases) with caps === SECURITY FIXES === - S1 XSS fix: printLog now escapes all external data via escapeHTML(), preserving <x> redact tags via split pattern for streamer mode - escapeHTML() now also escapes > character - Log type validated against whitelist before becoming CSS class name - AbortSignal.timeout(30s) on ALL fetch calls (search, delete, unarchive, getChannel) replacing leaky setTimeout-based AbortController - retry_after clamped with Math.max(w, 0) to prevent negative values causing tight loops === BUG FIXES === - Fixed onStop called twice (stop() + end of run()) via guard check - Fixed missing return DELETE_RESULT.FAILED in JSON.parse catch path - Fixed filterResponse was async unnecessarily (now sync) - Fixed _searchResponse null crash with guard in filterResponse - Fixed .filter(Boolean) after map().find() to prevent undefined entries - Fixed replaceInterpolations treating falsy values (0, false, "") as missing (|| -> ??) === QUALITY IMPROVEMENTS === - DELETE_RESULT enum (Object.freeze) replaces magic strings - DELETABLE_MSG_TYPES Set replaces compound conditional - Set-based lookup for skipped messages (O(n) vs O(n^2)) - search() converted from recursive to iterative with MAX_SEARCH_RETRIES=20 - searchDelay capped at MAX_SEARCH_DELAY_MS (60s) - deleteDelay capped at MAX_DELETE_DELAY_MS (30s) - Log ring buffer: MAX_LOG_ENTRIES=5000 prevents unbounded DOM growth - Confirm preview limited to 10 messages - messagePicker timeout (30s) with automatic cleanup - drag.css selectors scoped with #undiscord - .resize-handle scoped with #undiscord - Orphan .logarea CSS class removed - MutationObserver throttle extracted to OBSERVER_THROTTLE_MS constant - All CSS variables use fallback pattern var(--new, var(--old)) - JSDoc on all public methods === BUILD === - metadata.mjs supports contributors array from package.json - @author now shows both victornpb and TheCellMaster - CLAUDE.md created with project documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f73f615 commit 6272c0e

39 files changed

Lines changed: 2953 additions & 1872 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,4 @@ $RECYCLE.BIN/
383383
# Windows shortcuts
384384
*.lnk
385385

386+
.claude/

CLAUDE.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Undiscord - Development Guide
2+
3+
## Commands
4+
5+
```bash
6+
npm run lint # ESLint check
7+
npm run lint:fix # ESLint auto-fix
8+
npm run build # Production build (rollup -> deleteDiscordMessages.user.js)
9+
npm run watch # Dev server with hot reload at localhost:10001
10+
npm test # lint + build
11+
```
12+
13+
## Architecture
14+
15+
Browser userscript (Tampermonkey/Violentmonkey) that bulk-deletes Discord messages.
16+
Rollup bundles `src/` into a single IIFE file `deleteDiscordMessages.user.js`.
17+
18+
### Module structure
19+
20+
```
21+
src/
22+
index.js # Entry point - calls initUI()
23+
api/
24+
discord-api.js # Pure fetch layer - all Discord REST calls with AbortController
25+
core/
26+
undiscord-core.js # Orchestrator class - run(), runBatch(), stop()
27+
search.js # Search with iterative retry (202/429 handling)
28+
filter.js # Message filtering (types, pinned, bots, threads, regex)
29+
delete.js # Delete with retry loop + rate limit adaptation
30+
unarchive.js # Thread unarchive before delete
31+
ui/
32+
init.js # Mount DOM, inject CSS, setup toolbar button + MutationObserver
33+
handlers.js # All event handlers (start, stop, getChannel, pick message, etc)
34+
progress.js # onStart/onProgress/onStop callbacks, progress bar
35+
logger.js # printLog with XSS-safe rendering, redact tag preservation
36+
css/ # CSS modules (layout, components, scrollbar, redact, log, drag)
37+
html/ # HTML templates
38+
utils/
39+
constants.js # API_VERSION, DELETE_RESULT, DELETABLE_MSG_TYPES
40+
time.js # wait(), msToHMS()
41+
html.js # escapeHTML(), redact(), replaceInterpolations()
42+
discord.js # queryString(), toSnowflake(), ask()
43+
log.js # Log system with custom function support
44+
dom.js # createElm(), insertCss()
45+
drag.js # DragResize + Draggable classes
46+
message-picker.js # Interactive message selection in chat
47+
get-ids.js # Token, authorId, guildId, channelId extraction
48+
```
49+
50+
### Data flow
51+
52+
```
53+
User clicks Delete -> startAction() -> core.run() or core.runBatch()
54+
-> search loop: api.searchMessages() -> filter.filterMessages() -> delete.deleteMessages()
55+
-> each message: api.deleteMessage() with retry
56+
-> callbacks update UI (onProgress, onStop)
57+
```
58+
59+
## Key decisions
60+
61+
- **IIFE bundle**: Required for userscripts - no module system in browser context
62+
- **`@grant none`**: Runs in Discord's page context, access to DOM and localStorage
63+
- **CSS injection via `<style>`**: Userscripts can't load external CSS files
64+
- **`innerHTML` in `createElm()`**: Accepted for static templates from our own code
65+
- **`insertAdjacentHTML` in logger**: Safe because all external data passes through `escapeHTML()`
66+
- **`window.messagePicker`**: Intentional global exposure for interactive message picking
67+
- **`webpackChunkdiscord_app`**: Required to extract token when localStorage fails
68+
69+
## CSS variables
70+
71+
Always use fallback pattern: `var(--new-name, var(--old-name))`
72+
Discord renames CSS variables frequently. See mapping in `/review` command.
73+
74+
## Discord API
75+
76+
- Version: `v9` (from `constants.js`)
77+
- Search: `GET /guilds/{id}/messages/search` or `/channels/{id}/messages/search`
78+
- Delete: `DELETE /channels/{id}/messages/{id}`
79+
- Unarchive: `PATCH /channels/{id}` with `{archived: false}`
80+
- Rate limit: 429 -> respect `retry_after`, increase delay
81+
- Indexing: 202 -> retry after `retry_after`
82+
83+
## Version
84+
85+
Single source of truth: `package.json` -> embedded via rollup `baked-env` plugin.
86+
Branch: `master`. Commit style: bare version number (e.g. `5.3.0`).

build/metadata.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,15 @@ function generateComment(manifest) {
2929
export default function userScriptMetadataBlock() {
3030
const pkg = loadJSON('../package.json');
3131

32+
const authorLine = pkg.contributors
33+
? pkg.contributors.join(', ')
34+
: pkg.author;
35+
3236
const metadata = {
3337
name: pkg.nameFull,
3438
description: pkg.description,
3539
version: process.env.VERSION,
36-
author: pkg.author,
40+
author: authorLine,
3741
homepageURL: pkg.homepage,
3842
supportURL: pkg.bugs.url,
3943
match: pkg.userScript.match,

deleteDiscordMessages.user.js

Lines changed: 1086 additions & 585 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "undiscord",
33
"nameFull": "Undiscord",
4-
"version": "5.2.6",
4+
"version": "5.3.0",
55
"description": "Delete all messages in a Discord channel or DM (Bulk deletion)",
66
"userScript": {
77
"namespace": "https://github.com/victornpb/deleteDiscordMessages",
@@ -29,6 +29,10 @@
2929
},
3030
"homepage": "https://github.com/victornpb/undiscord",
3131
"author": "victornpb",
32+
"contributors": [
33+
"victornpb (https://github.com/victornpb)",
34+
"TheCellMaster (https://github.com/TheCellMaster)"
35+
],
3236
"main": "deleteDiscordMessages.user.js",
3337
"scripts": {
3438
"start": "npm run watch",

src/api/discord-api.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { API_VERSION } from '../utils/constants.js';
2+
import { queryString, toSnowflake } from '../utils/discord.js';
3+
4+
const BASE_URL = `https://discord.com/api/${API_VERSION}`;
5+
const DEFAULT_TIMEOUT_MS = 30000;
6+
7+
/**
8+
* Search messages in a guild or DM channel.
9+
* @param {string} authToken - Discord authorization token
10+
* @param {Object} params - Search parameters
11+
* @param {string} params.guildId - Guild ID or '@me' for DMs
12+
* @param {string} [params.channelId] - Channel ID
13+
* @param {string} [params.authorId] - Author ID filter
14+
* @param {string} [params.minId] - Min message ID or date
15+
* @param {string} [params.maxId] - Max message ID or date
16+
* @param {number} [params.offset] - Pagination offset
17+
* @param {boolean} [params.hasLink] - Filter messages with links
18+
* @param {boolean} [params.hasFile] - Filter messages with files
19+
* @param {string} [params.content] - Text content filter
20+
* @param {boolean} [params.includeNsfw] - Include NSFW channels
21+
* @returns {Promise<Response>} Raw fetch response
22+
*/
23+
export async function searchMessages(authToken, params) {
24+
const { guildId, channelId, authorId, minId, maxId, offset, hasLink, hasFile, content, includeNsfw } = params;
25+
26+
let url;
27+
if (guildId === '@me') url = `${BASE_URL}/channels/${channelId}/messages/`;
28+
else url = `${BASE_URL}/guilds/${guildId}/messages/`;
29+
30+
return fetch(url + 'search?' + queryString([
31+
['author_id', authorId || undefined],
32+
['channel_id', (guildId !== '@me' ? channelId : undefined) || undefined],
33+
['min_id', minId ? toSnowflake(minId) : undefined],
34+
['max_id', maxId ? toSnowflake(maxId) : undefined],
35+
['sort_by', 'timestamp'],
36+
['sort_order', 'desc'],
37+
['offset', offset],
38+
['has', hasLink ? 'link' : undefined],
39+
['has', hasFile ? 'file' : undefined],
40+
['content', content || undefined],
41+
['include_nsfw', includeNsfw ? true : undefined],
42+
]), {
43+
headers: { 'Authorization': authToken },
44+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
45+
});
46+
}
47+
48+
/**
49+
* Delete a single message from a channel.
50+
* @param {string} authToken - Discord authorization token
51+
* @param {string} channelId - Channel containing the message
52+
* @param {string} messageId - Message to delete
53+
* @returns {Promise<Response>} Raw fetch response
54+
*/
55+
export async function deleteMessage(authToken, channelId, messageId) {
56+
return fetch(`${BASE_URL}/channels/${channelId}/messages/${messageId}`, {
57+
method: 'DELETE',
58+
headers: { 'Authorization': authToken },
59+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
60+
});
61+
}
62+
63+
/**
64+
* Unarchive a thread channel so messages can be deleted.
65+
* @param {string} authToken - Discord authorization token
66+
* @param {string} channelId - Thread channel ID
67+
* @returns {Promise<Response>} Raw fetch response
68+
*/
69+
export async function unarchiveThread(authToken, channelId) {
70+
return fetch(`${BASE_URL}/channels/${channelId}`, {
71+
method: 'PATCH',
72+
headers: {
73+
'Authorization': authToken,
74+
'Content-Type': 'application/json',
75+
},
76+
body: JSON.stringify({ archived: false }),
77+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
78+
});
79+
}
80+
81+
/**
82+
* Get channel information (used for thread detection).
83+
* @param {string} authToken - Discord authorization token
84+
* @param {string} channelId - Channel ID
85+
* @returns {Promise<Response>} Raw fetch response
86+
*/
87+
export async function getChannel(authToken, channelId) {
88+
return fetch(`${BASE_URL}/channels/${channelId}`, {
89+
headers: { 'Authorization': authToken },
90+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
91+
});
92+
}

src/core/delete.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { log } from '../utils/log.js';
2+
import { wait } from '../utils/time.js';
3+
import { redact } from '../utils/html.js';
4+
import { DELETE_RESULT } from '../utils/constants.js';
5+
import { deleteMessage as apiDelete } from '../api/discord-api.js';
6+
import { MAX_DELETE_DELAY_MS } from '../utils/constants.js';
7+
import { tryUnarchiveThread } from './unarchive.js';
8+
9+
/**
10+
* Delete a single message via the Discord API with error handling.
11+
* @param {Object} message - Discord message object
12+
* @param {Object} options - Core options (authToken, deleteDelay)
13+
* @param {Object} stats - Core stats (throttledCount, throttledTotalTime)
14+
* @param {Function} beforeRequest - Ping tracking
15+
* @param {Function} afterRequest - Ping tracking
16+
* @param {Function} printStats - Logs current stats
17+
* @returns {Promise<string>} DELETE_RESULT value
18+
*/
19+
export async function deleteSingleMessage(message, options, stats, beforeRequest, afterRequest, printStats) {
20+
let resp;
21+
try {
22+
beforeRequest();
23+
resp = await apiDelete(options.authToken, message.channel_id, message.id);
24+
afterRequest();
25+
} catch (err) {
26+
log.error('Delete request throwed an error:', err);
27+
log.verb('Related object:', redact(JSON.stringify(message)));
28+
return DELETE_RESULT.FAILED;
29+
}
30+
31+
if (!resp.ok) {
32+
if (resp.status === 429) {
33+
const w = Math.max((await resp.json()).retry_after * 1000, 0) || options.deleteDelay;
34+
stats.throttledCount++;
35+
stats.throttledTotalTime += w;
36+
if (w > options.deleteDelay) {
37+
options.deleteDelay = Math.min(options.deleteDelay + w, MAX_DELETE_DELAY_MS);
38+
log.warn(`Being rate limited by the API for ${w}ms! Adjusted delete delay to ${options.deleteDelay}ms.`);
39+
} else {
40+
log.warn(`Being rate limited by the API for ${w}ms!`);
41+
}
42+
printStats();
43+
log.verb(`Cooling down for ${w * 2}ms before retrying...`);
44+
await wait(w * 2);
45+
return DELETE_RESULT.RETRY;
46+
} else if (resp.status === 403) {
47+
log.warn('Insufficient permissions to delete message. Skipping...');
48+
return DELETE_RESULT.FAIL_SKIP;
49+
} else {
50+
const body = await resp.text();
51+
try {
52+
const r = JSON.parse(body);
53+
if (resp.status === 400 && r.code === 50083) {
54+
log.warn('Thread is archived. Attempting to unarchive...');
55+
return tryUnarchiveThread(options.authToken, message.channel_id, beforeRequest, afterRequest);
56+
}
57+
log.error(`Error deleting message, API responded with status ${resp.status}!`, r);
58+
log.verb('Related object:', redact(JSON.stringify(message)));
59+
return DELETE_RESULT.FAILED;
60+
} catch (e) {
61+
log.error(`Fail to parse JSON. API responded with status ${resp.status}!`, body);
62+
return DELETE_RESULT.FAILED;
63+
}
64+
}
65+
}
66+
67+
return DELETE_RESULT.OK;
68+
}
69+
70+
/**
71+
* Delete all messages in the current batch with retry logic.
72+
* @param {Object} state - Core state (running, delCount, failCount, offset, grandTotal, _messagesToDelete)
73+
* @param {Object} options - Core options (maxAttempt, deleteDelay, authToken)
74+
* @param {Object} stats - Core stats
75+
* @param {Function} beforeRequest - Ping tracking
76+
* @param {Function} afterRequest - Ping tracking
77+
* @param {Function} printStats - Logs current stats
78+
* @param {Function} calcEtr - Recalculate estimated time remaining
79+
* @param {Function} [onProgress] - Progress callback
80+
*/
81+
export async function deleteMessagesFromList(state, options, stats, beforeRequest, afterRequest, printStats, calcEtr, onProgress) {
82+
for (let i = 0; i < state._messagesToDelete.length; i++) {
83+
const message = state._messagesToDelete[i];
84+
if (!state.running) return log.error('Stopped by you!');
85+
86+
log.debug(
87+
`[${state.delCount + 1}/${state.grandTotal}] ` +
88+
`${new Date(message.timestamp).toLocaleString()} ` +
89+
`${redact((message.author?.username || 'Unknown') + '#' + (message.author?.discriminator || '0000'))}` +
90+
`: ${redact((message.content || '').replace(/\n/g, '↵'))}` +
91+
(message.attachments?.length ? ` [${message.attachments.length} attachment(s)]` : ''),
92+
`{ID:${redact(message.id)}}`
93+
);
94+
95+
// retry loop
96+
let attempt = 0;
97+
while (attempt < options.maxAttempt) {
98+
const result = await deleteSingleMessage(message, options, stats, beforeRequest, afterRequest, printStats);
99+
attempt++;
100+
101+
if (result === DELETE_RESULT.RETRY || result === DELETE_RESULT.FAILED) {
102+
if (attempt >= options.maxAttempt) {
103+
state.offset++;
104+
state.failCount++;
105+
break;
106+
}
107+
log.verb(`Retrying in ${options.deleteDelay}ms... (${attempt}/${options.maxAttempt})`);
108+
await wait(options.deleteDelay);
109+
continue;
110+
} else if (result === DELETE_RESULT.FAIL_SKIP) {
111+
state.offset++;
112+
state.failCount++;
113+
} else {
114+
state.delCount++;
115+
}
116+
break;
117+
}
118+
119+
calcEtr();
120+
if (onProgress) onProgress(state, stats);
121+
122+
await wait(options.deleteDelay);
123+
}
124+
}

0 commit comments

Comments
 (0)