// ==UserScript==
// @name Discord Friends Bulk Checker + Remover (Panel Selection)
// @namespace https://discord.com
// @version 1.0.0
// @description Select friends from an in-panel list (no inline checkboxes). Scan All to harvest names/IDs, then bulk remove safely.
// @author blanco
// @match https://discord.com/*
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
/*\
WHY: Automation may violate Discord's Terms of Service and trigger rate limits. Use at your own risk.
*/
(function () {
'use strict';
// --- Tunables -------------------------------------------------------------
const DELETE_DELAY_MS = 1750; // Cushion for rate limits
const MENU_APPEAR_TIMEOUT = 5000;
const MODAL_APPEAR_TIMEOUT = 5000;
const SCAN_SCROLL_STEP = 800; // px per tick
const SCAN_SCROLL_PAUSE = 120; // ms between steps
const SCAN_STALL_TICKS = 20; // stop after this many ticks without discovering new items
const LOCATE_MAX_TICKS = 400; // cap search scrolls per item
// --- Styles ---------------------------------------------------------------
GM_addStyle(`
.tm-bulk-panel { position: fixed; right: 18px; bottom: 18px; z-index: 999999; background: var(--background-secondary, #2b2d31); color: var(--text-normal, #fff); border: 1px solid var(--background-tertiary, #1e1f22); border-radius: 12px; padding: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.4); min-width: 360px; max-width: 420px; font-family: var(--font-primary, system-ui, sans-serif); display: none; }
.tm-bulk-panel.tm-visible { display: block; }
.tm-bulk-panel h4 { margin: 0 0 8px 0; font-size: 14px; font-weight: 700; }
.tm-bulk-row { display:flex; gap:8px; margin:6px 0; align-items:center; }
.tm-bulk-row button { flex:1; border: 1px solid var(--background-tertiary, #1e1f22); background: var(--background-primary, #313338); color: var(--text-normal, #fff); border-radius: 8px; padding: 8px 10px; cursor: pointer; font-size: 12px; }
.tm-bulk-row button:hover { filter: brightness(1.08); }
.tm-danger { background: #a12828 !important; border-color:#8b1f1f !important; }
.tm-muted { opacity: .7; }
.tm-chip { display:inline-block; font-size:11px; padding:2px 6px; border-radius:6px; background: var(--background-tertiary, #1e1f22); color: var(--text-muted, #b5bac1); }
.tm-input { flex: 1; border-radius: 8px; border: 1px solid var(--background-tertiary, #1e1f22); background: var(--background-primary, #313338); color: var(--text-normal, #fff); padding: 8px 10px; font-size: 12px; }
.tm-list { max-height: 260px; overflow: auto; border: 1px solid var(--background-tertiary, #1e1f22); border-radius: 8px; padding: 6px; }
.tm-item { display:flex; align-items:center; gap:8px; padding: 4px 6px; border-radius: 6px; }
.tm-item:hover { background: var(--background-tertiary, #1e1f22); }
.tm-item label { display:flex; align-items:center; gap:8px; width: 100%; cursor: pointer; }
.tm-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tm-id { font-size: 10px; opacity: .7; }
`);
// --- Selectors (with fallbacks) ------------------------------------------
const SELECTORS = {
listContainer: '.peopleList__5ec2f, [data-list-id="people-list"]',
listItem: '.peopleListItem_cc6179, [data-list-item-id], li[class*="peopleListItem"]',
usernameSpan: '.username__0a06e, [class*="username"], h3[role="heading"]',
moreButton: '.actions_fc004c [aria-label="More"], [aria-label="More"]',
menuRemoveFriendById: '#friend-row-remove-friend',
confirmRemoveButtonText: 'Remove Friend',
friendsHeaderNav: 'section.container__9293f[role="navigation"], section[role="navigation"]',
friendsToolbar: 'section.container__9293f[role="navigation"] .toolbar__9293f .inviteToolbar__133bf, section[role="navigation"] .toolbar__9293f .inviteToolbar__133bf'
};
// --- State ----------------------------------------------------------------
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const seenIds = new Set(); // All discovered row IDs
const selectedIds = new Set(); // Selection made in panel
const friendIndex = new Map(); // id -> { id, name }
let isCancelling = false; // Stop flag
// --- Utils ----------------------------------------------------------------
function inDoc(el){ return el && el.isConnected; }
function waitForSelector(selector, root = document, timeout = 10000) {
const start = performance.now();
return new Promise((resolve, reject) => {
const quick = root.querySelector(selector);
if (quick) return resolve(quick);
const obs = new MutationObserver(() => {
const el = root.querySelector(selector);
if (el) { obs.disconnect(); resolve(el); }
else if (performance.now() - start > timeout) { obs.disconnect(); reject(new Error(`Timeout waiting for ${selector}`)); }
});
obs.observe(root, { childList: true, subtree: true });
setTimeout(() => { obs.disconnect(); reject(new Error(`Timeout waiting for ${selector}`)); }, timeout);
});
}
function qsAllSafe(root, selector){
try { return root.querySelectorAll(selector); }
catch (e) {
try {
const parts = String(selector).split(',').map(s => s.trim()).filter(Boolean);
const uniq = new Set();
parts.forEach(sel => { try { root.querySelectorAll(sel).forEach(n => uniq.add(n)); } catch {} });
return Array.from(uniq);
} catch { return []; }
}
}
function safeClick(el) {
if (!el) return false;
const v = (el.ownerDocument && el.ownerDocument.defaultView) || window;
const optsMouse = { bubbles: true, cancelable: true, view: v, composed: true };
const optsPtr = { bubbles: true, cancelable: true, view: v, composed: true, pointerId: 1, isPrimary: true, pointerType: 'mouse' };
try {
el.dispatchEvent(new MouseEvent('mouseover', optsMouse));
if (typeof PointerEvent !== 'undefined') {
el.dispatchEvent(new PointerEvent('pointerdown', optsPtr));
}
el.dispatchEvent(new MouseEvent('mousedown', optsMouse));
el.dispatchEvent(new MouseEvent('mouseup', optsMouse));
if (typeof PointerEvent !== 'undefined') {
el.dispatchEvent(new PointerEvent('pointerup', optsPtr));
}
el.click();
return true;
} catch (e) {
try { el.click(); } catch {}
return false;
}
}
const getRowId = (li) => li?.getAttribute('data-list-item-id') || '';
function getUsernameFromItem(li) {
const el = li?.querySelector(SELECTORS.usernameSpan);
return el ? (el.textContent || '').trim() : null;
}
// --- Harvest (no inline checkboxes) --------------------------------------
function harvestRowsInContainer(container) {
let discovered = 0;
const items = qsAllSafe(container, SELECTORS.listItem);
items.forEach(li => {
const id = getRowId(li);
if (!id) return;
if (!seenIds.has(id)) discovered++;
seenIds.add(id);
const name = getUsernameFromItem(li) || friendIndex.get(id)?.name || '(unknown)';
if (!friendIndex.has(id) || friendIndex.get(id)?.name !== name) {
friendIndex.set(id, { id, name });
}
});
if (discovered > 0) scheduleRender();
return discovered;
}
function getContainer(){ return document.querySelector(SELECTORS.listContainer); }
// --- Menu & Modal ---------------------------------------------------------
async function openMoreMenuForItem(li) {
if (!li) throw new Error('Row not found');
if (inDoc(li)) { li.scrollIntoView({ block: 'center' }); await sleep(160); }
const moreBtn = li.querySelector(SELECTORS.moreButton);
if (!moreBtn) throw new Error('More button not found');
safeClick(moreBtn);
await waitForSelector(SELECTORS.menuRemoveFriendById + ', [role="menuitem"]', document.body, MENU_APPEAR_TIMEOUT);
}
function findRemoveFriendMenuItem(){
const byId = document.body.querySelector(SELECTORS.menuRemoveFriendById);
if (byId) return byId;
const items = [...document.body.querySelectorAll('[role="menuitem"], button, div[aria-role="menuitem"]')];
return items.find(n => /remove\s+friend/i.test(n.textContent || '')) || null;
}
async function clickRemoveFriendInMenu() {
const item = findRemoveFriendMenuItem();
if (!item) throw new Error('Remove Friend menu item not found');
safeClick(item);
}
async function confirmRemovalModal() {
// Some builds delete immediately with no modal. Treat missing modal as success after a short grace period.
const t = (SELECTORS.confirmRemoveButtonText || 'Remove Friend').toLowerCase();
const start = Date.now();
const SOFT_WAIT = 1200; // decide if no modal is coming
const HARD_WAIT = MODAL_APPEAR_TIMEOUT;
while (Date.now() - start < HARD_WAIT) {
const buttons = [...document.body.querySelectorAll('button')];
const dialog = document.body.querySelector('[role="dialog"], .root-1gCeng');
const btn = buttons.find(b => (b.textContent || '').trim().toLowerCase() === t);
if (btn) { safeClick(btn); return; }
if (!dialog && Date.now() - start > SOFT_WAIT) return; // no modal
await sleep(80);
}
// Timeout treated as no-op; continue
}
// --- Locate row by ID (virtualization-safe) -------------------------------
async function locateRowById(rowId, container) {
let li = [...qsAllSafe(container, SELECTORS.listItem)].find(el => getRowId(el) === rowId);
if (li) return li;
let tick = 0, dir = 1, lastTop = container.scrollTop;
while (tick++ < LOCATE_MAX_TICKS) {
container.scrollTop += dir * SCAN_SCROLL_STEP;
await sleep(SCAN_SCROLL_PAUSE);
li = [...qsAllSafe(container, SELECTORS.listItem)].find(el => getRowId(el) === rowId);
if (li) return li;
if (container.scrollTop === 0 && dir === -1) break;
if (container.scrollTop === lastTop) { if (dir === 1) dir = -1; else break; }
lastTop = container.scrollTop;
}
return null;
}
// --- Bulk Delete ----------------------------------------------------------
async function bulkDeleteSelected(statusCb) {
const ids = [...selectedIds];
if (ids.length === 0) { statusCb?.('No friends selected.'); return; }
statusCb?.(`Starting removal of ${ids.length} friend(s)... Press Stop or ESC to cancel.`);
isCancelling = false;
const container = getContainer();
if (!container) { statusCb?.('Friends list not found.'); return; }
for (let i = 0; i < ids.length; i++) {
if (isCancelling) { statusCb?.('Cancelled.'); break; }
const rowId = ids[i];
try {
const friend = friendIndex.get(rowId);
const label = friend?.name || rowId;
statusCb?.(`(${i + 1}/${ids.length}) Locating ${label}...`);
const li = await locateRowById(rowId, container);
if (!li) { statusCb?.(`(${i + 1}/${ids.length}) ${label}: not found in viewport.`); continue; }
statusCb?.(`(${i + 1}/${ids.length}) ${label}: opening menu...`);
await openMoreMenuForItem(li);
statusCb?.(`(${i + 1}/${ids.length}) ${label}: clicking "Remove Friend"...`);
await clickRemoveFriendInMenu();
statusCb?.(`(${i + 1}/${ids.length}) ${label}: confirming modal...`);
await confirmRemovalModal();
selectedIds.delete(rowId);
renderList();
statusCb?.(`(${i + 1}/${ids.length}) ${label}: removed. Cooling down...`);
await sleep(DELETE_DELAY_MS);
} catch (err) {
statusCb?.(`(${i + 1}/${ids.length}) ${rowId}: FAILED — ${err.message}`);
await sleep(800);
}
}
statusCb?.('Done. Scan/Refresh if needed.');
updateCount();
}
// --- Scanner --------------------------------------------------------------
async function scanEntireList(container, statusCb) {
let lastSeenCount = seenIds.size;
let stallTicks = 0;
container.scrollTop = 0; await sleep(150);
while (true) {
harvestRowsInContainer(container);
if (seenIds.size > lastSeenCount) { lastSeenCount = seenIds.size; stallTicks = 0; }
else { stallTicks++; }
if (stallTicks >= SCAN_STALL_TICKS) break;
container.scrollTop += SCAN_SCROLL_STEP;
await sleep(SCAN_SCROLL_PAUSE);
}
stallTicks = 0;
while (true) {
harvestRowsInContainer(container);
if (seenIds.size > lastSeenCount) { lastSeenCount = seenIds.size; stallTicks = 0; }
else { stallTicks++; }
if (stallTicks >= SCAN_STALL_TICKS) break;
container.scrollTop -= SCAN_SCROLL_STEP;
if (container.scrollTop <= 0) break;
await sleep(SCAN_SCROLL_PAUSE);
}
container.scrollTop = 0; await sleep(100);
harvestRowsInContainer(container);
statusCb?.(`Found ~${seenIds.size} rows (via data-list-item-id).`);
renderList();
return seenIds.size;
}
// --- UI: Panel with in-panel selection -----------------------------------
let dom = {};
function addPanel() {
if (document.querySelector('.tm-bulk-panel')) return;
const panel = document.createElement('div');
panel.className = 'tm-bulk-panel';
panel.innerHTML = `
<h4>Friends Bulk Tools</h4>
<div class="tm-bulk-row">
<input id="tm-search" class="tm-input" placeholder="Search name..." />
<span class="tm-chip" id="tm-count">0 selected · 0 seen</span>
</div>
<div class="tm-bulk-row">
<button id="tm-scan">Scan All</button>
<button id="tm-refresh">Refresh</button>
</div>
<div class="tm-bulk-row">
<button id="tm-select-filtered">Select All (filtered)</button>
<button id="tm-select-none">None</button>
</div>
<div class="tm-bulk-row">
<button id="tm-delete" class="tm-danger">Delete Selected</button>
<button id="tm-stop">Stop</button>
</div>
<div class="tm-bulk-row">
<div id="tm-list" class="tm-list" aria-label="Friends list (panel)"></div>
</div>
<div class="tm-bulk-row">
<div id="tm-status" class="tm-chip" style="flex:1; max-height:160px; overflow:auto;"></div>
</div>
`;
document.body.appendChild(panel);
dom = {
panel,
search: panel.querySelector('#tm-search'),
count: panel.querySelector('#tm-count'),
scan: panel.querySelector('#tm-scan'),
refresh: panel.querySelector('#tm-refresh'),
selectFiltered: panel.querySelector('#tm-select-filtered'),
selectNone: panel.querySelector('#tm-select-none'),
del: panel.querySelector('#tm-delete'),
stop: panel.querySelector('#tm-stop'),
list: panel.querySelector('#tm-list'),
status: panel.querySelector('#tm-status'),
};
const setStatus = (msg) => { const t = new Date().toLocaleTimeString(); dom.status.innerHTML = `${t}: ${msg}<br>` + dom.status.innerHTML; };
dom.search.addEventListener('input', () => renderList());
dom.refresh.addEventListener('click', () => {
const c = getContainer();
if (!c) return setStatus('Friends list not found.');
const n = harvestRowsInContainer(c);
renderList();
setStatus(n ? `Refreshed. +${n} discovered.` : 'Refreshed visible rows.');
});
dom.scan.addEventListener('click', async () => {
const c = getContainer();
if (!c) return setStatus('Friends list not found.');
dom.scan.disabled = true; dom.scan.classList.add('tm-muted');
try {
const total = await scanEntireList(c, setStatus);
setStatus(`Scan done. Discovered ${total} rows.`);
} finally {
dom.scan.disabled = false; dom.scan.classList.remove('tm-muted');
}
});
dom.selectFiltered.addEventListener('click', () => {
dom.list.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => selectedIds.add(cb.dataset.id));
renderList();
});
dom.selectNone.addEventListener('click', () => { selectedIds.clear(); renderList(); });
dom.del.addEventListener('click', async () => {
dom.del.disabled = true; dom.del.classList.add('tm-muted');
try { await bulkDeleteSelected(setStatus); }
finally { dom.del.disabled = false; dom.del.classList.remove('tm-muted'); }
});
dom.stop.addEventListener('click', () => { isCancelling = true; setStatus('Cancel requested.'); });
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') { isCancelling = true; setStatus('Cancel requested.'); } });
updateCount();
}
function updateCount(){ if (dom.count) dom.count.textContent = `${selectedIds.size} selected · ${seenIds.size} seen`; }
let renderScheduled = false;
function scheduleRender(){ if (!renderScheduled) { renderScheduled = true; requestAnimationFrame(() => { renderScheduled = false; renderList(); }); } }
function renderList(){
if (!dom.list) return;
const q = (dom.search?.value || '').trim().toLowerCase();
const entries = [...friendIndex.values()].sort((a,b) => a.name.localeCompare(b.name));
const filtered = q ? entries.filter(e => (e.name || '').toLowerCase().includes(q)) : entries;
const frag = document.createDocumentFragment();
filtered.forEach(e => {
const row = document.createElement('div');
row.className = 'tm-item';
const idShort = e.id.length > 10 ? e.id.slice(-8) : e.id;
row.innerHTML = `
<label>
<input type="checkbox" data-id="${e.id}" ${selectedIds.has(e.id) ? 'checked' : ''}/>
<span class="tm-name">${escapeHtml(e.name || '(unknown)')}</span>
<span class="tm-id">${idShort}</span>
</label>`;
const cb = row.querySelector('input[type="checkbox"]');
cb.addEventListener('change', () => { if (cb.checked) selectedIds.add(e.id); else selectedIds.delete(e.id); updateCount(); });
frag.appendChild(row);
});
dom.list.innerHTML = '';
dom.list.appendChild(frag);
updateCount();
}
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); }
// Header opener
async function addHeaderOpener() {
try {
await waitForSelector(SELECTORS.friendsHeaderNav, document, 10000);
const toolbar = await waitForSelector(SELECTORS.friendsToolbar, document, 10000);
if (toolbar.querySelector('.tm-opener')) return;
const opener = document.createElement('button');
opener.className = 'tm-opener';
opener.type = 'button';
opener.title = 'Open Bulk Tools';
opener.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" role="img" style="width:20px;height:20px;color:currentColor">
<path fill="currentColor" d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2Zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2Zm0 6h10a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2Z"></path>
<path fill="currentColor" d="M17.3 16.3a1 1 0 0 1 1.4 1.4l-3 3a1 1 0 0 1-1.4 0l-1.5-1.5a1 1 0 1 1 1.4-1.4l.8.8 2.3-2.3Z"></path>
</svg>`;
opener.addEventListener('click', () => { const panel = document.querySelector('.tm-bulk-panel'); if (panel) panel.classList.toggle('tm-visible'); });
toolbar.appendChild(opener);
} catch {/* noop */}
}
function observe() {
const obs = new MutationObserver(() => {
const list = getContainer();
if (list) harvestRowsInContainer(list);
addHeaderOpener();
});
obs.observe(document.documentElement, { childList: true, subtree: true });
}
async function init() {
addPanel();
addHeaderOpener();
observe();
const list = getContainer();
if (list) harvestRowsInContainer(list);
renderList();
}
init();
})();