DeleteCord

Adds a button to the Discord browser UI to mass delete messages from Discord channels and direct messages

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         DeleteCord
// @namespace    https://greasyfork.org/en/users/1431907-theeeunknown
// @version      1.1
// @description  Adds a button to the Discord browser UI to mass delete messages from Discord channels and direct messages
// @author       https://www.torn.com/2561502

// @license      MIT
// @match        https://discord.com/*
// @match        https://canary.discord.com/*
// @match        https://ptb.discord.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const CFG = {
    pageSizeFetch:  100,
    pageDelayMs:    600,
    minDeleteMs:    300,
    maxDeleteMs:   8000,
    emptyPageLimit:   3,
    speedSamples:    20,
  };

  const creds = { token: null, authorId: null };

  const _timestamps = [];
  function recordDelete () {
    _timestamps.push(Date.now());
    if (_timestamps.length > CFG.speedSamples) _timestamps.shift();
  }
  function msgsPerMin () {
    if (_timestamps.length < 2) return 0;
    const span = (_timestamps[_timestamps.length - 1] - _timestamps[0]) / 60000;
    return span > 0 ? Math.round((_timestamps.length - 1) / span) : 0;
  }

  function autoDelay (headers) {
    const remaining  = parseInt(headers.get('X-RateLimit-Remaining')   ?? '-1');
    const resetAfter = parseFloat(headers.get('X-RateLimit-Reset-After') ?? '0');
    if (remaining < 0)   return null;
    if (remaining === 0) return resetAfter * 1000 + 150;
    const ideal = (resetAfter * 1000 / remaining) * 1.15;
    return Math.max(CFG.minDeleteMs, Math.min(ideal, CFG.maxDeleteMs));
  }

  const _origFetch = window.fetch;
  window.fetch = async function (...args) {
    try {
      const [resource, options] = args;
      const url   = typeof resource === 'string' ? resource : resource?.url ?? '';
      const hdrs  = options?.headers ?? (resource instanceof Request ? resource.headers : null);

      if (url.includes('discord.com/api') && !creds.token) {
        const auth = hdrs instanceof Headers
          ? hdrs.get('Authorization')
          : (typeof hdrs === 'object' ? (hdrs?.Authorization ?? hdrs?.authorization) : null);
        if (auth) { creds.token = auth; _syncCredUI(); }
      }

      const res = await _origFetch.apply(this, args);

      if (url.includes('/users/@me') && !url.includes('/settings') && !creds.authorId)
        res.clone().json()
          .then(d => { if (d?.id) { creds.authorId = d.id; _syncCredUI(); } })
          .catch(() => {});

      if (url.includes('/science') && !creds.authorId && options?.body)
        try { const b = JSON.parse(options.body); if (b?.user_id) { creds.authorId = b.user_id; _syncCredUI(); } } catch {}

      return res;
    } catch { return _origFetch.apply(this, args); }
  };

  const _origSRH = XMLHttpRequest.prototype.setRequestHeader;
  XMLHttpRequest.prototype.setRequestHeader = function (k, v) {
    if (!creds.token && (k === 'Authorization' || k === 'authorization'))
      { creds.token = v; _syncCredUI(); }
    return _origSRH.apply(this, arguments);
  };

  function _syncCredUI () {
    const tEl = document.getElementById('dmd-token');
    const aEl = document.getElementById('dmd-author-id');
    if (tEl && creds.token)    tEl.value = creds.token;
    if (aEl && creds.authorId) aEl.value = creds.authorId;

    const dot = document.getElementById('dmd-cred-dot');
    const lbl = document.getElementById('dmd-cred-label');
    if (!dot || !lbl) return;
    if (creds.token && creds.authorId) {
      dot.className = 'dmd-dot green'; lbl.textContent = 'Ready to delete';
    } else if (creds.token) {
      dot.className = 'dmd-dot amber'; lbl.textContent = 'Token captured';
    }
  }

  const ST = Object.freeze({ IDLE: 'idle', RUNNING: 'running', PAUSED: 'paused' });
  let runState = ST.IDLE;
  let stats    = { deleted: 0, skipped: 0, failed: 0, scanned: 0 };
  let chProg   = { done: 0, total: 0, name: '' };
  let curDelay = 1000;

  const API = 'https://discord.com/api/v9';

  async function _df (path, opts = {}) {
    const token = document.getElementById('dmd-token')?.value.trim() || creds.token;
    const url   = path.startsWith('http') ? path : `${API}${path}`;
    const res   = await _origFetch(url, {
      ...opts,
      headers: { Authorization: token, 'Content-Type': 'application/json', ...opts.headers },
    });
    return res;
  }

  async function _guildChannels (guildId) {
    const res = await _df(`/guilds/${guildId}/channels`);
    if (!res.ok) return [];
    const all = await res.json();
    return all
      .filter(c => new Set([0, 5, 10, 11, 12]).has(c.type))
      .sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
  }

  async function _dmChannels () {
    const res = await _df('/users/@me/channels');
    return res.ok ? res.json() : [];
  }

  async function _resolveAuthorId () {
    const res = await _df('/users/@me');
    if (!res.ok) return null;
    const d = await res.json();
    if (d?.id) { creds.authorId = d.id; _syncCredUI(); }
    return d?.id ?? null;
  }

  async function _drainChannelViaSearch ({ channelId, authorId, guildId, keyword, hasFile, hasImage, hasVideo, hasSound, hasLink, hasEmbed, includePinned, includeNsfw, searchDelay, baseDeleteDelay }) {
    let offset = 0;
    let iterations = 0;

    while (runState !== ST.IDLE) {
      await _chkPause();

      let API_SEARCH_URL = guildId === '@me'
          ? `/channels/${channelId}/messages/search?`
          : `/guilds/${guildId}/messages/search?`;

      const qs = new URLSearchParams({ sort_by: 'timestamp', sort_order: 'desc', offset: offset });
      if (authorId) qs.set('author_id', authorId);
      if (guildId !== '@me') qs.set('channel_id', channelId);
      if (keyword) qs.set('content', keyword);
      if (includeNsfw) qs.set('include_nsfw', 'true');

      let urlStr = API_SEARCH_URL + qs.toString();

      if (hasFile) urlStr += '&has=file';
      if (hasImage) urlStr += '&has=image';
      if (hasVideo) urlStr += '&has=video';
      if (hasSound) urlStr += '&has=sound';
      if (hasLink) urlStr += '&has=link';
      if (hasEmbed) urlStr += '&has=embed';

      const res = await _df(urlStr);

      if (res.status === 202) {
        const w = (await res.json()).retry_after * 1000 || searchDelay;
        _log(`Indexing... ${(w/1000).toFixed(1)}s`, 'warn');
        await _sleep(w);
        continue;
      }

      if (!res.ok) {
        if (res.status === 429) {
          const w = (await res.json()).retry_after * 1000;
          _log(`Rate limited. Cooling down...`, 'warn');
          await _sleep(w * 1.5);
          continue;
        } else if (res.status === 403 || res.status === 404) {
          _log(`No read access`, 'warn');
          break;
        }
        _log(`Search failed: ${res.status}`, 'error');
        break;
      }

      const data = await res.json();
      stats.scanned = data.total_results || stats.scanned;
      _updateStats();

      if (!data.messages || data.messages.length === 0) {
        if (data.total_results - offset > 0) {
            offset += 25;
            await _sleep(searchDelay);
            continue;
        }
        break;
      }

      const discovered = data.messages.map(group => group.find(m => m.hit === true)).filter(Boolean);
      const toDelete = discovered.filter(msg => msg.type === 0 || msg.type === 6 || msg.type === 19 || msg.type === 20 || (msg.pinned && includePinned));
      const skipped = discovered.length - toDelete.length;

      if (iterations === 0 && toDelete.length > 0) {
          const previewMsgs = toDelete.slice(0, 5).map(m => {
              let content = m.content || '';
              if (m.attachments && m.attachments.length) content += ` [${m.attachments[0].filename}]`;
              return `${m.author.username}: ${content}`;
          }).join('\n');

          const estTime = ((data.total_results / 25) * searchDelay) + (data.total_results * baseDeleteDelay);
          const timeStr = msToHMS(estTime);

          const prompt = `DeleteCord\n\nDelete ~${data.total_results} messages?\nTime: ${timeStr}\n\n${previewMsgs}`;
          const confirmed = window.confirm(prompt);

          if (!confirmed) {
              runState = ST.IDLE;
              throw new Error('ABORTED');
          }
      }

      if (toDelete.length === 0) {
          offset += skipped;
          await _sleep(searchDelay);
          continue;
      }

      iterations++;

      for (const msg of toDelete) {
        await _chkPause();
        if (runState === ST.IDLE) throw new Error('ABORTED');

        const ch = msg.channel_id || channelId;

        let currentDeleteDelay = baseDeleteDelay;

        const delRes = await _df(`/channels/${ch}/messages/${msg.id}`, { method: 'DELETE' });

        const remaining = parseInt(delRes.headers.get('X-RateLimit-Remaining') ?? '-1');
        const resetAfter = parseFloat(delRes.headers.get('X-RateLimit-Reset-After') ?? '0');

        if (remaining === 0) {
            currentDeleteDelay = (resetAfter * 1000) + 150;
        } else if (remaining > 0) {
            const idealDelay = (resetAfter * 1000 / remaining) * 1.15;
            currentDeleteDelay = Math.max(currentDeleteDelay, idealDelay);
        }

        curDelay = currentDeleteDelay;
        _updateLiveMetrics();

        let logText = msg.content || '';
        if (msg.attachments && msg.attachments.length > 0) logText += ` [${msg.attachments[0].filename}]`;
        const snip = _trunc(logText || '[Empty]', 60);

        if (delRes.ok || delRes.status === 404) {
            stats.deleted++;
            recordDelete();
            _log(`✓ ${snip}`, 'success');
        } else if (delRes.status === 429) {
            const w = (await delRes.json()).retry_after * 1000;
            _log(`Rate limit ${w}ms`, 'warn');
            await _sleep(w * 1.5);
            stats.failed++;
            continue;
        } else if (delRes.status === 403) {
            stats.skipped++;
            offset++;
            _log(`Skipped ${msg.id}`, 'muted');
        } else {
            stats.failed++;
            offset++;
            _log(`Failed: ${msg.id}`, 'error');
        }

        _updateStats();
        await _sleep(currentDeleteDelay);
      }

      offset += skipped;
      await _sleep(searchDelay);
    }
  }

  async function _startRun ({ authorId, channelId, guildId, mode, keyword, hasFile, hasImage, hasVideo, hasSound, hasLink, hasEmbed, includePinned, includeNsfw, searchDelay, deleteDelay }) {
    if (mode === 'server') {
      _log('Switch to Channel tab to delete', 'warn');
      return;
    }

    stats    = { deleted: 0, skipped: 0, failed: 0, scanned: 0 };
    chProg   = { done: 0, total: 1, name: '' };
    _timestamps.length = 0;
    curDelay = deleteDelay;
    runState = ST.RUNNING;
    _updateStats(); _updateUI(); _updateLiveMetrics();

    try {
      chProg.total = 1; _setChannel(channelId);
      _log(`Scanning ${channelId}…`, 'info');
      await _drainChannelViaSearch({ channelId, authorId, guildId, keyword, hasFile, hasImage, hasVideo, hasSound, hasLink, hasEmbed, includePinned, includeNsfw, searchDelay, baseDeleteDelay: deleteDelay });

      if (runState !== ST.IDLE)
        _log(
          `Done • ${stats.deleted} deleted • ${stats.skipped} skipped • ${stats.failed} failed`,
          'success'
        );

    } catch (e) {
      if (e.message !== 'ABORTED') _log(`Error: ${e.message}`, 'error');
    }

    runState = ST.IDLE;
    _updateUI(); _updateLiveMetrics();
  }

  const _sleep     = ms => new Promise(r => setTimeout(r, ms));
  const _trunc     = (s, n) => s.length > n ? s.slice(0, n) + '…' : s;

  const msToHMS = s => `${s / 3.6e6 | 0}h ${(s % 3.6e6) / 6e4 | 0}m ${(s % 6e4) / 1000 | 0}s`;

  function _setChannel (name) { chProg.done++; chProg.name = name; _updateChBar(); }

  async function _chkPause () {
    while (runState === ST.PAUSED) await _sleep(100);
    if (runState === ST.IDLE) throw new Error('ABORTED');
  }

  function _getIdsFromUrl () {
    const p  = location.pathname;
    const dm = p.match(/\/channels\/@me\/(\d+)/);
    const sv = p.match(/\/channels\/(\d+)\/(\d+)/);
    if (dm) return { guildId: '@me', channelId: dm[1] };
    if (sv) return { guildId: sv[1], channelId: sv[2] };
    return { guildId: '', channelId: '' };
  }

  function _log (msg, type = 'info') {
    const el = document.getElementById('dmd-log');
    if (!el) return;
    const palette = {
      info:    '#8e9297',
      success: '#57f287',
      warn:    '#fee75c',
      error:   '#ed4245',
      muted:   '#555b62',
    };
    const line = document.createElement('div');
    line.style.cssText = `color:${palette[type] ?? palette.info};padding:1px 0;font-size:11.5px;line-height:1.55;word-break:break-word;white-space:pre-wrap;`;
    line.textContent = msg;
    el.appendChild(line);
    el.scrollTop = el.scrollHeight;
    while (el.children.length > 800) el.removeChild(el.firstChild);
  }

  function _updateStats () {
    const g = id => document.getElementById(id);
    const fmt = n => n.toLocaleString();
    if (g('s-del')) g('s-del').textContent = fmt(stats.deleted);
    if (g('s-ski')) g('s-ski').textContent = fmt(stats.skipped);
    if (g('s-fai')) g('s-fai').textContent = fmt(stats.failed);
    if (g('s-sca')) g('s-sca').textContent = fmt(stats.scanned);
  }

  function _updateLiveMetrics () {
    const speed = document.getElementById('dmd-speed');
    const delay = document.getElementById('dmd-cur-delay');
    if (speed) speed.textContent = runState === ST.RUNNING ? `${msgsPerMin()}` : '—';
    if (delay) delay.textContent = runState === ST.RUNNING ? `${Math.round(curDelay)}ms` : '—';
  }

  function _updateChBar () {
    const bar = document.getElementById('dmd-ch-bar');
    const txt = document.getElementById('dmd-ch-text');
    if (!bar || !txt) return;
    if (chProg.total > 0) {
      bar.style.display = 'flex';
      txt.textContent = chProg.total > 1
        ? `${chProg.done} / ${chProg.total}  •  ${_trunc(chProg.name, 30)}`
        : _trunc(chProg.name, 44);
    } else {
      bar.style.display = 'none';
    }
  }

  function _updateUI () {
    const g = id => document.getElementById(id);
    const runDot = g('dmd-run-dot');
    const runLbl = g('dmd-run-lbl');

    if (runState === ST.RUNNING) {
      if (runDot) { runDot.className = 'dmd-dot green pulse'; }
      if (runLbl) runLbl.textContent = 'Running';
      _setBtnState('dmd-btn-start', false);
      _setBtnState('dmd-btn-pause', true);
      _setBtnState('dmd-btn-stop',  true);
      if (g('dmd-btn-pause')) g('dmd-btn-pause').textContent = '⏸ Pause';
    } else if (runState === ST.PAUSED) {
      if (runDot) { runDot.className = 'dmd-dot amber pulse'; }
      if (runLbl) runLbl.textContent = 'Paused';
      if (g('dmd-btn-pause')) g('dmd-btn-pause').textContent = '▶ Resume';
    } else {
      if (runDot) { runDot.className = 'dmd-dot gray'; }
      if (runLbl) runLbl.textContent = 'Idle';
      _setBtnState('dmd-btn-start', true);
      _setBtnState('dmd-btn-pause', false);
      _setBtnState('dmd-btn-stop',  false);
      if (g('dmd-btn-pause')) g('dmd-btn-pause').textContent = '⏸ Pause';
      const cb = g('dmd-ch-bar');
      if (cb) cb.style.display = 'none';
    }
  }

  function _setBtnState (id, enabled) {
    const el = document.getElementById(id);
    if (!el) return;
    el.disabled = !enabled;
    el.style.opacity = enabled ? '1' : '0.32';
  }

  const PANEL_ID = 'dmd-panel';

  function _buildPanel () {
    if (document.getElementById(PANEL_ID)) return;

    const css = document.createElement('style');
    css.id = 'dmd-css';
    css.textContent = `
      #dmd-panel {
        position:fixed; top:58px; right:14px; width:318px;
        background:#111214; border:1px solid #232428; border-radius:12px;
        z-index:99999; font-family:'gg sans','Noto Sans',Whitney,Helvetica,sans-serif;
        box-shadow:0 8px 36px rgba(0,0,0,.65); overflow:hidden;
        resize:both; min-width:258px; min-height:72px; color:#dcddde;
      }
      #dmd-bar {
        display:flex; align-items:center; justify-content:space-between;
        background:#0b0c0e; padding:10px 14px; cursor:move; user-select:none;
        border-bottom:1px solid #1d1f23;
      }
      #dmd-bar-l { display:flex; align-items:center; gap:8px; }
      #dmd-bar-title { font-size:13px; font-weight:700; color:#fff; letter-spacing:.01em; }
      #dmd-bar-r { display:flex; gap:10px; }
      #dmd-bar-r span { font-size:15px; opacity:.35; cursor:pointer; transition:opacity .15s; line-height:1; }
      #dmd-bar-r span:hover { opacity:.8; }
      .dmd-main-tabs { display:flex; background:#0b0c0e; border-bottom:1px solid #1d1f23; padding:0; gap:0; }
      .dmd-main-tab {
        flex:1; padding:10px 0; border:none; background:transparent; color:#8e9297;
        font-size:11px; font-weight:600; cursor:pointer; transition:color .15s, border .15s;
        font-family:inherit; border-bottom:2px solid transparent;
      }
      .dmd-main-tab:hover { color:#dcddde; }
      .dmd-main-tab.on { color:#fff; border-bottom-color:#5865f2; }
      #dmd-body { padding:13px 14px; overflow-y:auto; max-height:calc(100vh - 160px); }
      .dmd-main-content { display:none; }
      .dmd-main-content.active { display:block; }
      .dmd-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
      .dmd-dot.green { background:#57f287; }
      .dmd-dot.amber { background:#fee75c; }
      .dmd-dot.gray  { background:#43454a; }
      .dmd-dot.red   { background:#ed4245; }
      .dmd-dot.pulse { animation:dmd-blink 1.6s ease-in-out infinite; }
      @keyframes dmd-blink { 0%,100%{opacity:1} 50%{opacity:.45} }
      .dmd-lbl {
        font-size:9.5px; font-weight:700; letter-spacing:.09em;
        text-transform:uppercase; color:#43454a; margin-bottom:6px;
      }
      .dmd-in {
        width:100%; box-sizing:border-box;
        background:#1a1c1f; border:1px solid #2a2d31; border-radius:6px;
        color:#dcddde; font-size:12px; padding:8px 10px; margin-bottom:5px;
        outline:none; transition:border-color .15s; font-family:inherit;
      }
      .dmd-in:focus { border-color:#5865f2; }
      .dmd-in::placeholder { color:#43454a; }
      .dmd-tabs { display:flex; background:#0b0c0e; border-radius:7px; padding:3px; gap:2px; }
      .dmd-tab {
        flex:1; padding:6px 0; border:none; border-radius:5px;
        background:transparent; color:#8e9297; font-size:11px; font-weight:600;
        cursor:pointer; transition:background .15s, color .15s; font-family:inherit;
      }
      .dmd-tab:hover:not(.on) { color:#dcddde; }
      .dmd-tab.on { background:#1a1c1f; color:#fff; }
      .dmd-info {
        font-size:11.5px; color:#8e9297; padding:9px 10px; line-height:1.55;
        background:#1a1c1f; border:1px solid #2a2d31; border-radius:6px;
      }
      .dmd-hr { height:1px; background:#1d1f23; margin:10px 0; }
      .dmd-ghost {
        border:none; border-radius:5px; font-size:11px; font-weight:600;
        padding:6px 10px; cursor:pointer; background:#1a1c1f; color:#8e9297;
        transition:background .15s, color .15s; font-family:inherit; white-space:nowrap;
      }
      .dmd-ghost:hover { background:#2a2d31; color:#dcddde; }
      .dmd-btn-row { display:flex; gap:6px; }
      .dmd-btn {
        flex:1; border:none; border-radius:6px; font-size:12px; font-weight:700;
        padding:9px 0; cursor:pointer; transition:filter .15s, transform .1s;
        font-family:inherit; letter-spacing:.02em;
      }
      .dmd-btn:hover:not(:disabled) { filter:brightness(1.12); transform:translateY(-1px); }
      .dmd-btn:active:not(:disabled){ transform:translateY(0); }
      .dmd-btn:disabled { cursor:not-allowed; }
      .dmd-btn.red   { background:#ed4245; color:#fff; }
      .dmd-btn.ghost { background:#2a2d31; color:#8e9297; }
      #dmd-strip {
        display:flex; align-items:center; justify-content:space-between;
        background:#1a1c1f; border:1px solid #2a2d31; border-radius:7px;
        padding:8px 11px; margin-bottom:11px;
      }
      #dmd-strip-l { display:flex; align-items:center; gap:7px; }
      #dmd-run-lbl  { font-size:12px; font-weight:700; color:#dcddde; }
      #dmd-strip-r  { display:flex; gap:14px; }
      .dmd-metric .v { font-size:13px; font-weight:700; color:#fff; font-variant-numeric:tabular-nums; text-align:right; }
      .dmd-metric .k { font-size:9px; color:#43454a; text-transform:uppercase; letter-spacing:.06em; text-align:right; }
      #dmd-cred-row { display:flex; align-items:center; gap:7px; margin-bottom:8px; }
      #dmd-cred-label { font-size:11.5px; color:#8e9297; flex:1; min-width:0; }
      #dmd-ch-bar {
        display:none; align-items:center; gap:7px;
        background:#1a1c1f; border:1px solid #2a2d31; border-radius:6px;
        padding:6px 10px; margin-bottom:8px;
      }
      #dmd-ch-text { font-size:11px; color:#8e9297; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
      #dmd-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:5px; margin-bottom:10px; }
      .dmd-cell {
        background:#1a1c1f; border:1px solid #2a2d31; border-radius:6px;
        display:flex; flex-direction:column; align-items:center; padding:8px 3px;
      }
      .dmd-cell .n { font-size:15px; font-weight:700; color:#dcddde; line-height:1; font-variant-numeric:tabular-nums; }
      .dmd-cell .l { font-size:8.5px; color:#43454a; text-transform:uppercase; letter-spacing:.07em; margin-top:3px; }
      .dmd-cell.d .n { color:#57f287; }
      .dmd-cell.f .n { color:#ed4245; }
      .dmd-cell.s .n { color:#fee75c; }
      #dmd-log {
        background:#0b0c0e; border:1px solid #1d1f23; border-radius:6px;
        padding:8px; height:138px; overflow-y:auto;
        font-family:'Consolas','Courier New',monospace;
        scrollbar-width:thin; scrollbar-color:#2a2d31 transparent;
      }
    `;
    document.head.appendChild(css);

    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    panel.innerHTML = `
      <div id="dmd-bar">
        <div id="dmd-bar-l">
          <span style="font-size:16px;line-height:1;">⚡</span>
          <span id="dmd-bar-title">DeleteCord</span>
        </div>
        <div id="dmd-bar-r">
          <span id="dmd-min" title="Minimize">─</span>
          <span id="dmd-cls" title="Close">✕</span>
        </div>
      </div>

      <div class="dmd-main-tabs">
        <button class="dmd-main-tab on" data-tab="main">Main</button>
        <button class="dmd-main-tab" data-tab="filters">Filters</button>
        <button class="dmd-main-tab" data-tab="scraper">Scraper</button>
      </div>

      <div id="dmd-body">

        <div id="tab-main" class="dmd-main-content active">
          <div id="dmd-strip">
            <div id="dmd-strip-l">
              <div class="dmd-dot gray" id="dmd-run-dot"></div>
              <span id="dmd-run-lbl">Idle</span>
            </div>
            <div id="dmd-strip-r">
              <div class="dmd-metric">
                <div class="v" id="dmd-speed">—</div>
                <div class="k">msgs/min</div>
              </div>
              <div class="dmd-metric">
                <div class="v" id="dmd-cur-delay">—</div>
                <div class="k">delay</div>
              </div>
            </div>
          </div>

          <div class="dmd-lbl">Credentials</div>
          <div id="dmd-cred-row">
            <div class="dmd-dot gray" id="dmd-cred-dot"></div>
            <span id="dmd-cred-label">Click a channel…</span>
            <button class="dmd-ghost" id="dmd-btn-fetchid">ID</button>
          </div>
          <input class="dmd-in" id="dmd-token"     type="password" placeholder="Token" autocomplete="off"/>
          <input class="dmd-in" id="dmd-author-id" placeholder="User ID"/>

          <div class="dmd-hr"></div>

          <div class="dmd-lbl">Channels</div>
          <input class="dmd-in" id="dmd-channel-id" placeholder="Channel ID"/>
          <input class="dmd-in" id="dmd-guild-id"   placeholder="Server ID"/>

          <button class="dmd-ghost" id="dmd-btn-fillurl" style="width:100%;text-align:center;margin-top:7px;">
            Fill from URL
          </button>

          <div class="dmd-hr"></div>

          <div id="dmd-grid">
            <div class="dmd-cell d"><span class="n" id="s-del">0</span><span class="l">Deleted</span></div>
            <div class="dmd-cell s"><span class="n" id="s-ski">0</span><span class="l">Skipped</span></div>
            <div class="dmd-cell f"><span class="n" id="s-fai">0</span><span class="l">Failed</span></div>
            <div class="dmd-cell">  <span class="n" id="s-sca">0</span><span class="l">Scanned</span></div>
          </div>

          <div class="dmd-lbl">Log</div>
          <div id="dmd-log" style="height:160px;"></div>

          <div id="dmd-ch-bar">
            <span style="font-size:12px;">📢</span>
            <span id="dmd-ch-text"></span>
          </div>

          <div class="dmd-btn-row" style="margin-top:10px;">
            <button class="dmd-btn red"   id="dmd-btn-start">Start</button>
            <button class="dmd-btn ghost" id="dmd-btn-pause" disabled>Pause</button>
            <button class="dmd-btn ghost" id="dmd-btn-stop"  disabled>Stop</button>
          </div>
        </div>

        <div id="tab-filters" class="dmd-main-content">
          <div class="dmd-lbl">Search</div>
          <input class="dmd-in" id="dmd-keyword" placeholder="Keyword filter" style="margin-bottom:10px;"/>

          <div class="dmd-lbl">Content Type</div>
          <div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:10px;">
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-has-file"> File
              </label>
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-has-image"> Image
              </label>
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-has-video"> Video
              </label>
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-has-sound"> Sound
              </label>
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-has-link"> Link
              </label>
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-has-embed"> Embed
              </label>
          </div>

          <div class="dmd-lbl">Options</div>
          <div style="display:flex; gap:10px; margin-bottom:10px;">
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-inc-pinned"> Pinned
              </label>
              <label style="color:#dcddde; font-size:11px; display:flex; align-items:center; gap:4px; cursor:pointer;">
                  <input type="checkbox" id="dmd-inc-nsfw" checked> NSFW
              </label>
          </div>

          <div class="dmd-hr"></div>

          <div class="dmd-lbl">Timing (ms)</div>
          <div style="display:flex; gap:6px;">
              <input class="dmd-in" id="dmd-search-delay" type="number" placeholder="Search" value="1000"/>
              <input class="dmd-in" id="dmd-delete-delay" type="number" placeholder="Delete" value="1000"/>
          </div>
        </div>

        <div id="tab-scraper" class="dmd-main-content">
          <div class="dmd-lbl">Server Scraper</div>
          <input class="dmd-in" id="dmd-guild-sv" placeholder="Server ID" style="margin-bottom:8px;"/>

          <button class="dmd-ghost" id="dmd-btn-copy-chans" style="width:100%;margin-bottom:8px;">📋 Copy Channels</button>

          <div class="dmd-lbl" style="margin-top:0;">Channels</div>
          <textarea class="dmd-in" id="dmd-chlist-text" style="height:260px; resize:vertical; font-family:monospace; white-space:pre;" readonly placeholder="Navigate to server to auto-scrape channels..."></textarea>
        </div>

      </div>
    `;
    document.body.appendChild(panel);
    _syncCredUI();

    let mode = 'single';
    panel.querySelectorAll('.dmd-main-tab').forEach(t => {
      t.addEventListener('click', () => {
        panel.querySelectorAll('.dmd-main-tab').forEach(x => x.classList.remove('on'));
        t.classList.add('on');

        document.querySelectorAll('.dmd-main-content').forEach(x => x.classList.remove('active'));
        const tabId = `tab-${t.dataset.tab}`;
        document.getElementById(tabId).classList.add('active');
      });
    });

    document.getElementById('dmd-btn-copy-chans').addEventListener('click', () => {
      const txt = document.getElementById('dmd-chlist-text');
      if (txt && txt.value && !txt.value.includes("Navigate")) {
          const guildId = document.getElementById('dmd-guild-sv').value || document.getElementById('dmd-guild-id').value;
          const originalText = txt.value;

          const urls = originalText.split('\n').map(line => {
              const parts = line.split(' : ');
              if (parts.length === 2) {
                  return `https://discord.com/channels/${guildId}/${parts[1].trim()}`;
              }
              return line;
          }).join('\n');

          txt.value = urls;
          txt.select();
          document.execCommand('copy');
          txt.value = originalText;

          _log('Copied to clipboard!', 'success');
          const btn = document.getElementById('dmd-btn-copy-chans');
          const oldText = btn.textContent;
          btn.textContent = '✅';
          setTimeout(() => btn.textContent = oldText, 2000);
      } else {
          _log('No channels to copy', 'warn');
      }
    });

    { let drag = false, ox = 0, oy = 0;
      document.getElementById('dmd-bar').addEventListener('mousedown', e => {
        if (e.target.id === 'dmd-min' || e.target.id === 'dmd-cls') return;
        drag = true; ox = e.clientX - panel.offsetLeft; oy = e.clientY - panel.offsetTop;
      });
      document.addEventListener('mousemove', e => {
        if (!drag) return;
        panel.style.right = 'auto';
        panel.style.left  = `${e.clientX - ox}px`;
        panel.style.top   = `${e.clientY - oy}px`;
      });
      document.addEventListener('mouseup', () => drag = false); }

    let mini = false;
    document.getElementById('dmd-min').addEventListener('click', () => {
      mini = !mini;
      document.getElementById('dmd-body').style.display = mini ? 'none' : 'block';
    });
    document.getElementById('dmd-cls').addEventListener('click', () => {
      panel.remove(); document.getElementById('dmd-css')?.remove();
    });

    document.getElementById('dmd-btn-fetchid').addEventListener('click', async () => {
      if (!creds.token && !document.getElementById('dmd-token').value.trim()) {
        _log('No token detected', 'warn'); return;
      }
      _log('Fetching ID…', 'info');
      const id = await _resolveAuthorId();
      if (id) _log(`ID: ${id}`, 'success');
      else    _log('Failed', 'error');
    });

    document.getElementById('dmd-btn-fillurl').addEventListener('click', async () => {
      const { guildId, channelId } = _getIdsFromUrl();
      if (!channelId) { _log('Navigate to a channel first', 'warn'); return; }
      document.getElementById('dmd-channel-id').value = channelId;
      document.getElementById('dmd-guild-id').value   = guildId;
      if (guildId !== '@me') document.getElementById('dmd-guild-sv').value = guildId;
      _log(`Ch: ${channelId} • Server: ${guildId}`, 'success');

      const token = document.getElementById('dmd-token').value.trim();
      if (token && guildId !== '@me') {
        const chs = await _guildChannels(guildId);
        if (chs.length) {
          const el = document.getElementById('dmd-chlist-text');
          if(el) el.value = chs.map(c => `#${c.name} : ${c.id}`).join('\n');
          _log(`${chs.length} channels found`, 'success');
        }
      }
    });

    document.getElementById('dmd-btn-start').addEventListener('click', async () => {
      const token    = document.getElementById('dmd-token').value.trim();
      const authorId = document.getElementById('dmd-author-id').value.trim();
      if (!token)    { _log('No token', 'error'); return; }
      if (!authorId) { _log('No User ID', 'error'); return; }
      creds.token = token; creds.authorId = authorId;

      const channelId = document.getElementById('dmd-channel-id').value.trim();
      const guildId   = document.getElementById('dmd-guild-id').value.trim() || '@me';
      if (!channelId) { _log('No Channel ID', 'error'); return; }

      document.getElementById('dmd-log').innerHTML = '';

      const searchDelay = parseInt(document.getElementById('dmd-search-delay').value) || 1000;
      const deleteDelay = parseInt(document.getElementById('dmd-delete-delay').value) || 1000;

      await _startRun({
        authorId, channelId, guildId, mode: 'single',
        keyword:       document.getElementById('dmd-keyword').value.trim() || null,
        hasFile:       document.getElementById('dmd-has-file').checked,
        hasImage:      document.getElementById('dmd-has-image').checked,
        hasVideo:      document.getElementById('dmd-has-video').checked,
        hasSound:      document.getElementById('dmd-has-sound').checked,
        hasLink:       document.getElementById('dmd-has-link').checked,
        hasEmbed:      document.getElementById('dmd-has-embed').checked,
        includePinned: document.getElementById('dmd-inc-pinned').checked,
        includeNsfw:   document.getElementById('dmd-inc-nsfw').checked,
        searchDelay:   searchDelay,
        deleteDelay:   deleteDelay
      });
    });

    document.getElementById('dmd-btn-pause').addEventListener('click', () => {
      if (runState === ST.RUNNING) {
        runState = ST.PAUSED; _log('Paused', 'warn');
      } else if (runState === ST.PAUSED) {
        runState = ST.RUNNING; _log('Resumed', 'info');
      }
      _updateUI();
    });

    document.getElementById('dmd-btn-stop').addEventListener('click', () => {
      runState = ST.IDLE; _log('Stopped', 'warn'); _updateUI(); _updateLiveMetrics();
    });

    setTimeout(() => {
      const { guildId, channelId } = _getIdsFromUrl();
      if (channelId) {
        document.getElementById('dmd-channel-id').value = channelId;
        document.getElementById('dmd-guild-id').value   = guildId;
        if (guildId !== '@me') document.getElementById('dmd-guild-sv').value = guildId;
      }
      _syncCredUI();
      _log(creds.token ? 'Ready' : 'Click a channel', creds.token ? 'success' : 'info');
    }, 400);

    setInterval(_updateLiveMetrics, 1500);
    _updateUI();
  }

  let _lastPath = location.pathname;
  let _lastGuildScraped = null;

  new MutationObserver(() => {
    if (location.pathname === _lastPath || runState !== ST.IDLE) return;
    _lastPath = location.pathname;
    const { guildId, channelId } = _getIdsFromUrl();
    if (!channelId) return;

    const map = {
      'dmd-channel-id': channelId,
      'dmd-guild-id':   guildId,
      'dmd-guild-sv':   guildId !== '@me' ? guildId : null,
    };
    for (const [id, val] of Object.entries(map)) {
      const el = document.getElementById(id);
      if (el && val) el.value = val;
    }

    if (guildId !== '@me' && guildId !== _lastGuildScraped) {
        _lastGuildScraped = guildId;
        _guildChannels(guildId).then(chs => {
            const el = document.getElementById('dmd-chlist-text');
            if (el && chs.length) {
                el.value = chs.map(c => `#${c.name} : ${c.id}`).join('\n');
            }
        }).catch(()=>{});
    }

  }).observe(document.body, { childList: true, subtree: true });

  function _addLauncher () {
    if (document.getElementById('dmd-fab')) return;
    const btn = document.createElement('button');
    btn.id = 'dmd-fab';
    btn.title = 'DeleteCord';

    btn.innerHTML = `
      <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6"/>
      </svg>
    `;

    btn.style.cssText = `
      position:fixed; bottom:24px; right:24px; width:52px; height:52px;
      border-radius:50%; background: linear-gradient(135deg, #ed4245, #da373c);
      color:#fff; font-size:20px; border:none; cursor:pointer; z-index:99999;
      box-shadow: 0 4px 15px rgba(237,66,69,0.4);
      display:flex; align-items:center; justify-content:center;
      transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.2s;
    `;
    btn.addEventListener('mouseenter', () => {
      btn.style.transform = 'scale(1.1)';
      btn.style.boxShadow = '0 6px 20px rgba(237,66,69,0.6)';
    });
    btn.addEventListener('mouseleave', () => {
      btn.style.transform = 'scale(1)';
      btn.style.boxShadow = '0 4px 15px rgba(237,66,69,0.4)';
    });
    btn.addEventListener('click', () => {
      document.getElementById(PANEL_ID) ? document.getElementById(PANEL_ID).remove() : _buildPanel();
    });
    document.body.appendChild(btn);
  }

  function _waitForDiscord () {
    return new Promise(r => {
      const t = setInterval(() => {
        if (document.querySelector('[class*="sidebar"]') || document.querySelector('nav'))
          { clearInterval(t); r(); }
      }, 500);
    });
  }

  _waitForDiscord().then(_addLauncher);

})();