diff --git a/.github/ISSUE_TEMPLATE/bugreport.yml b/.github/ISSUE_TEMPLATE/bugreport.yml index c7da156..55e5902 100644 --- a/.github/ISSUE_TEMPLATE/bugreport.yml +++ b/.github/ISSUE_TEMPLATE/bugreport.yml @@ -12,7 +12,7 @@ body: attributes: label: Del-Discord version description: Find it in your userscript manager or the panel header. - placeholder: v2.1.1 + placeholder: v2.5.0 validations: required: true - type: input diff --git a/.github/assets/install-greasyfork.svg b/.github/assets/install-greasyfork.svg index 18a9b07..1693475 100644 --- a/.github/assets/install-greasyfork.svg +++ b/.github/assets/install-greasyfork.svg @@ -1,9 +1,13 @@ - + Install from Greasy Fork - - - - - GET IT ON - Greasy Fork + + + + + + + Install from + Greasy Fork + + diff --git a/.github/assets/manual-script.svg b/.github/assets/manual-script.svg index fd4d932..88e2107 100644 --- a/.github/assets/manual-script.svg +++ b/.github/assets/manual-script.svg @@ -1,7 +1,13 @@ - - Get the manual script - - - GET THE SCRIPT - Manual Script + + Install from GitHub + + + + + + + Install from + GitHub + + diff --git a/README.md b/README.md index 6963dcc..e8713c5 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,40 @@ # Del-Discord -Delete your Discord messages, remove reactions, and manage cleanup queues from one browser toolbar. +Delete your Discord messages, remove reactions, and save conversations from one browser toolbar. -[![Version: v2.1.1][version-badge]][script] +[![Version: v2.5.0][version-badge]][script] [![License: MIT][license-badge]][license] -[![Install from Greasy Fork][greasyfork-button]][greasyfork] [![Manual Script][manual-button]][script] +[![Install from Greasy Fork][greasyfork-button]][greasyfork] [![Install from GitHub][manual-button]][script] -[![Installation Help][help-button]](#installation-help) +https://github.com/user-attachments/assets/4feff1ed-a4c5-4532-8b0d-e33aaccc0011 ---- +Found this useful? [Star the repository](https://github.com/azhomy/Del-Discord). -## Demo +[How to use](#how-to-use) · [Install](#install) · [Build](#build-from-source) · [Uninstall](#uninstall) · [Help][bug-report] · [License](#license) -[![Del-Discord demo showing message cleanup and reaction removal][demo-preview]][demo-video] +## Summary -Found this useful? [Star the repository](https://github.com/azhomy/Del-Discord). +Del-Discord adds cleanup tools to Discord in your browser. Use it to manage your messages in direct messages (DMs), server channels, and forum threads. + +- Delete messages or replace their text. +- Remove text while keeping links and attached files. +- Choose messages by date, text, or file type. +- Set up several cleanups and run them in order. +- Remove your reactions from messages. +- Save a copy of a conversation, including its attached files. +- Find older DMs and reopen them. + +Del-Discord works on the Discord website. It does not work in the desktop or mobile app. ---- +> [!CAUTION] +> [Discord does not allow this kind of automation][self-bot-policy] and may ban your account. Deleted messages cannot be restored. Save anything you want to keep before you delete it. -## Installation Guide +## Install ### 1. Install a userscript manager -Skip this step if you already have one installed. +A userscript manager is a browser extension that lets Del-Discord run. Install one from the table below. Skip this step if you already have one. | Browser | Userscript manager | | :--- | :--- | @@ -31,56 +42,97 @@ Skip this step if you already have one installed. | Firefox | [Greasemonkey][greasemonkey], [Tampermonkey][tampermonkey], or [Violentmonkey][violentmonkey] | | Edge | [Tampermonkey][tampermonkey] | -### 2. Get the script +### 2. Install Del-Discord -[![Install from Greasy Fork][greasyfork-button]][greasyfork] [![Manual Script][manual-button]][script] +Open [Del-Discord on Greasy Fork][greasyfork]. Click Install this script and confirm when your userscript manager asks. -### 3. Confirm installation +To install from GitHub instead: -Confirm installation in your userscript manager and enable Del-Discord. +1. Open [the script][script] and click Raw. +2. Copy all of the text. +3. Open your userscript manager and choose Create a new script. +4. Replace the sample text with the text you copied. Save it. -**Manual alternative:** Open [Del-Discord-v1.user.js][script] and copy the complete script; select Raw if viewing it on GitHub. In your manager, select Create a new script, replace the default contents, and Save. +Make sure Del-Discord is turned on. Allow the extension to run on Discord. If your browser shows an Allow User Scripts setting, turn it on. -Tampermonkey extension settings with Allow User Scripts enabled +Tampermonkey extension settings showing where to turn on Allow User Scripts -Enable "Allow User Scripts" (if you've never used a userscript manager) +### 3. Open Discord -### 4. Open Discord & check the toolbar +Open [Discord in your browser][discord] and refresh the page. Enter a DM or channel, then click the trash-can button at the top. -Open **[Discord][discord] in your browser**, refresh, and enter a DM or channel. This installation supports the Discord website; the desktop app is not supported. +Installing Del-Discord does not delete anything. You choose when to start. -Look for the 🗑️ button in the channel header. Open it to access Messages, Reactions, Queue, and DM History in one window. +## How to use ---- +### Clean up messages -## Installation help +Open the conversation you want to clean up, then open Del-Discord and go to Messages. Choose DMs, Channel, Server, or Forum/Thread. Check the person and conversation under Conversation & authorization. Under Action, choose whether to delete messages, replace their text, or remove text while keeping links and files. Add any filters you need and leave Scan all matches before cleanup checked. Pinned messages are kept by default. Click Start, review the messages, and confirm. For a forum, load and select its threads, then click Clean selected threads. Use Stop to end a cleanup. It does not undo changes already made. -| If… | Try… | -| :--- | :--- | -| The buttons do not appear | Enable the manager and Del-Discord, allow access to Discord, then refresh. | -| Your browser blocks userscripts | Enable the extension's userscript permission when prompted. | -| Duplicate buttons appear | Disable overlapping Discord scripts, then refresh. | -| You want to update | Check for updates in your userscript manager, or replace a manually installed copy with the new complete userscript and save. | +### Save a conversation + +Click Export Conversation, choose your dates and a file type, then click Export. Leave the dates blank to save all available messages. Pick CSV to open the file in a spreadsheet app. The saved copy includes other people's messages and does not use your cleanup filters. Turn on Download actual attachments (ZIP) to save the files too. Without it, the saved copy only includes links to them. + +### Run several cleanups + +Set up each cleanup in Messages and click Add to Queue. Review the list in Queue and click Start Queue. These jobs use the settings you already approved and run without another confirmation. Use Stop Queue to stop them. You can continue a saved queue when you reopen Del-Discord. To target specific messages, open Message IDs in Queue, paste message links or use Import ID file, then click Run IDs. + +### Remove reactions + +Open Reactions, check the channel, and leave Author(s) blank to target your own reactions. Click Scan and remove, review the result, and confirm. + +### Find older DMs + +Open DM History and click Refresh & Verify. Search for a person and click Open DM. If you have a Discord data package, unzip it and use Import Data Package to add its conversations. Select DMs and click Delete All to delete your messages in them, including pinned messages. Other people's messages and the conversations stay. Clear Saved only removes the saved DM list from your browser. + +## Build from source -Still having installation trouble? [Open an installation bug report][bug-report]. Include your browser, manager, and script version; leave account tokens and private messages out of reports. +This section is for people who want to change the code. To use Del-Discord, follow [Install](#install) above. ---- +Install Git, Node.js 24 or later, and npm. Then run: -[version-badge]: https://img.shields.io/badge/version-v2.1.1-5865F2?style=flat-square +```bash +git clone https://github.com/azhomy/Del-Discord.git +cd Del-Discord +npm ci +npm run build +``` + +The finished script is in [`dist/Del-Discord-v1.user.js`][script]. Copy its contents into your userscript manager to install it. Run `npm run verify` to check your code changes. + +## Uninstall + +1. Stop any cleanup that is running. +2. Open your userscript manager and remove Del-Discord. +3. Refresh any open Discord tabs. + +You can turn the script off instead if you only want a break. + +To remove saved settings before uninstalling, use Clear Filters in Messages, Clear Queue in Queue, and Clear Saved in DM History. Some browser settings and downloaded files may remain. + +Uninstalling does not bring back deleted messages or undo text changes. + +Need help? [Report a problem][bug-report]. Keep login details and private messages out of your report. + +## Contributing + +[Report bugs or suggest improvements][issues]. For code changes, open a pull request to `staging` and run `npm run verify` before submitting. + +## License + +[MIT License][license] © 2026 Del-Discord contributors. + +[version-badge]: https://img.shields.io/badge/version-v2.5.0-5865F2?style=flat-square [license-badge]: https://img.shields.io/badge/license-MIT-22C55E?style=flat-square [greasyfork-button]: ./.github/assets/install-greasyfork.svg [manual-button]: ./.github/assets/manual-script.svg -[help-button]: ./.github/assets/installation-help.svg [script]: ./dist/Del-Discord-v1.user.js [license]: ./LICENSE [discord]: https://discord.com/channels/@me [greasyfork]: https://greasyfork.org/en/scripts/599469-del-discord -[demo-preview]: ./.github/assets/demo.gif -[demo-video]: ./.github/assets/demo.mp4 [tampermonkey]: https://www.tampermonkey.net/ [violentmonkey]: https://violentmonkey.github.io/ [greasemonkey]: https://addons.mozilla.org/firefox/addon/greasemonkey/ -[bug-report]: https://github.com/azhomy/del-discord/issues/new?template=bugreport.yml - -> [!WARNING] -> Del-Discord automates actions on your user account. Discord prohibits this type of automation (self-bots), and using it may result in account suspension or termination. Randomized delays do not guarantee protection from detection. Read [Discord’s self-bot policy](https://support.discord.com/hc/en-us/articles/115002192352-Automated-User-Accounts-Self-Bots) before installing. Use at your own risk. +[self-bot-policy]: https://support.discord.com/hc/en-us/articles/115002192352-Automated-User-Accounts-Self-Bots +[bug-report]: https://github.com/azhomy/Del-Discord/issues/new?template=bugreport.yml +[issues]: https://github.com/azhomy/Del-Discord/issues diff --git a/dist/Del-Discord-v1.user.js b/dist/Del-Discord-v1.user.js index 3bf43af..4980ab7 100644 --- a/dist/Del-Discord-v1.user.js +++ b/dist/Del-Discord-v1.user.js @@ -1,11 +1,11 @@ // ==UserScript== // @name Del-Discord // @namespace local.del-discord -// @version 2.1.1 +// @version 2.5.0 // @author Del-Discord contributors // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjOTY5NjkwIiBkPSJNMTUgNFYySDl2MkgzdjJoMThWNGgtNlpNNSA3djEyYTIgMiAwIDAgMCAyIDJoMTBhMiAyIDAgMCAwIDItMlY3SDVabTYgMTBIOXYtNmgydjZabTQgMGgtMnYtNmgydjZaIi8+PC9zdmc+ -// @homepageURL https://github.com/zhomander/del-discord -// @supportURL https://github.com/zhomander/del-discord/issues +// @homepageURL https://github.com/azhomy/del-discord +// @supportURL https://github.com/azhomy/del-discord/issues // @license MIT // @description Personal message deletion, reaction removal, queues, exports, and DM history. // @match https://discord.com/* @@ -38,7 +38,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -(()=>{function et(e,t,r=()=>Date.now()){let a=()=>{try{return t||globalThis.localStorage}catch{return null}},o=`${e}_created_at`,d=()=>{let m=a();if(!m)return null;let p=m.getItem(e);if(p===null)return null;let n=Number(m.getItem(o));return n?r()-n>=2592e5||n>r()?(i(),null):p:(m.setItem(o,String(r())),p)},i=()=>{let m=a();m&&(m.removeItem(e),m.removeItem(o))};return{read:d,write:m=>{let p=a();if(!p)return!1;let n=Number(p.getItem(o));return(!n||r()-n>=2592e5||n>r())&&p.setItem(o,String(r())),p.setItem(e,m),!0},clear:i}}var er="del_discord_v1_message_settings";function Kt(e,{getMode:t,setMode:r,onReset:a=()=>{},onStorageError:o=()=>{}}){let d=et(er),i=[...e.querySelectorAll("#dmd-messages input:not([type=password]):not([type=file]), #dmd-messages textarea, #dmd-messages select:not([multiple])")],s=new Map(i.map(b=>[b.id,b.type==="checkbox"?b.checked:b.value])),m=!0;try{let b=JSON.parse(d.read()||"null");if(b&&typeof b=="object"){for(let f of i){let $=b.fields?.[f.id];f.type==="checkbox"&&typeof $=="boolean"?f.checked=$:typeof $=="string"&&(f.tagName!=="SELECT"||[...f.options].some(g=>g.value===$))&&(f.value=$)}["dm","channel","server","forums","thread"].includes(b.mode)&&r(b.mode)}}catch{}m=!1;let p=!1,n=()=>{if(m)return;let b=Object.fromEntries(i.map(f=>[f.id,f.type==="checkbox"?f.checked:f.value]));try{if(!d.write(JSON.stringify({mode:t(),fields:b})))throw new Error("Storage unavailable")}catch{p||(p=!0,o())}},u;return i.forEach(b=>{b.addEventListener("input",()=>{clearTimeout(u),u=setTimeout(n,150)}),b.addEventListener("change",()=>{clearTimeout(u),n()}),b.addEventListener("blur",()=>{clearTimeout(u),n()})}),{save:n,clear:()=>{clearTimeout(u),m=!0;for(let b of i)b.type==="checkbox"?b.checked=s.get(b.id):b.value=s.get(b.id),b.dispatchEvent(new Event("change",{bubbles:!0}));r("dm"),m=!1;try{d.clear()}catch{}a()}}}function Vt(e="currentColor",t=22){return``}function tt({ttl:e=6e4,limit:t=16}={}){let r=new Map,a=d=>{let i=r.get(d);if(i&&!i.pending&&Date.now()-i.time>=e){r.delete(d);return}return i};return{get:(d,i)=>{let s=a(d);if(s)return r.delete(d),r.set(d,s),s.promise;let m={pending:!0,time:Date.now()};for(m.promise=Promise.resolve().then(i).then(p=>(m.pending=!1,m.time=Date.now(),p),p=>{throw r.get(d)===m&&r.delete(d),p}),r.set(d,m);r.size>t;)r.delete(r.keys().next().value);return m.promise},peek:a}}var U="https://discord.com/api/v10";var Yt="del_discord_v1_queue";var G=e=>new Promise(t=>setTimeout(t,e)),ee=(e,t)=>{let r=t-e+1;if(globalThis.crypto?.getRandomValues&&Number.isSafeInteger(r)&&r>0&&r<=4294967296){let a=new Uint32Array(1),o=Math.floor(4294967296/r)*r;do crypto.getRandomValues(a);while(a[0]>=o);return e+a[0]%r}return Math.floor(Math.random()*r)+e},ye=e=>{let t=Number(e);return e!==null&&e!==""&&Number.isFinite(t)&&t>=0?t*1e3:1500};var X=class extends Error{constructor(){super("Stopped."),this.name="RunStoppedError"}},He=new Map;function Xt(e,t){let r=He.get(e),a=Promise.all([r,G(t+ee(250,750))]).finally(()=>{He.get(e)===a&&He.delete(e)});return He.set(e,a),a}async function J(e,t={},r=()=>{},a=()=>!1){let o=new Headers(t.headers).get("Authorization")||"";for(;;){if(a())throw new X;let d;for(;d=He.get(o);)if(await d,a())throw new X;if(a())throw new X;let i=await fetch(e,t);if(i.status!==429){let p=i.headers.get("X-RateLimit-Reset-After");return i.headers.get("X-RateLimit-Remaining")==="0"&&p!==null&&Number.isFinite(Number(p))&&Number(p)>=0&&Xt(o,ye(p)),i}let s={};try{s=await i.json()}catch{}let m=Math.max(ye(s.retry_after??i.headers.get("Retry-After")),i.headers.has("Retry-After")?ye(i.headers.get("Retry-After")):0);r("warn",`Rate limited. Waiting ${(m/1e3).toFixed(1)}s...`),await Xt(o,m)}}function Zt(e,t=()=>!0){let r=tt(),a=e("#dmd-token"),o=[["#dmd-guild","#dmd-channel",t],["#dmd-multi-guild","#dmd-multi-channel",()=>!0],["#dmd-rx-guild","#dmd-rx-channel",()=>!0]].filter(([i,s])=>e(i)&&e(s)).map(([i,s,m])=>{let p=e(i),n=e(s),u=document.createElement("div");u.className="dmd-channel-picker",u.hidden=!0;let y=document.createElement("button");y.type="button",y.className="dmd-select-trigger",y.setAttribute("aria-expanded","false"),y.setAttribute("aria-label","Choose channels");let b=document.createElement("div");b.className="dmd-channel-menu",b.id=`${n.id}-channel-menu`,b.hidden=!0,b.setAttribute("role","group"),b.setAttribute("aria-label","Channels"),y.setAttribute("aria-controls",b.id);let f=document.createElement("input");f.type="search",f.placeholder="Find a channel",f.maxLength=100,f.setAttribute("aria-label","Find a channel");let $=document.createElement("div");$.className="dmd-channel-choices";let g=document.createElement("button");g.type="button",g.className="dmd-btn",g.textContent="Enter IDs instead",b.append(f,$,g),u.append(y),n.after(u),document.body.appendChild(b);let T=[],L=new Map,l=[],h=!1,w=0,S,v="",E="",I="",M=n.closest("#dmd-panel"),D=()=>{b.hidden=!0,y.setAttribute("aria-expanded","false"),document.removeEventListener("pointerdown",x,!0),window.removeEventListener("resize",D),M?.removeEventListener("scroll",D,!0)},x=R=>{!u.contains(R.target)&&!b.contains(R.target)&&D()},N=()=>n.value.split(",").map(R=>R.trim()).filter(Boolean),_=document.createElement("div");_.className="dmd-muted",$.onchange=R=>{let z=R.target;if(z.type!=="checkbox")return;let W=new Set(N());z.checked?W.add(z.value):W.delete(z.value),n.value=[...W].join(", "),n.dispatchEvent(new Event("change",{bubbles:!0}))};let j=()=>{let R=N();if(y.textContent=R.length===1?L.has(R[0])?`#${L.get(R[0])}`:R[0]:R.length?`${R.length} channels selected`:"Select channels",b.hidden)return;let z=new Set(R),W=f.value.trim().toLowerCase(),K=0;for(let Q of l)Q.checkbox.checked=z.has(Q.id),Q.label.hidden=!Q.searchName.includes(W),Q.label.hidden||K++;_.hidden=K>0,_.textContent=T.length?"No matching channels.":"No message channels available."},q=()=>{let R=document.createDocumentFragment();l=[];for(let z of T){let W=document.createElement("label");W.className="dmd-channel-option";let K=document.createElement("input");K.type="checkbox",K.value=z.id;let Q=document.createElement("span");Q.textContent=`${[15,16].includes(z.type)?"Forum: ":"#"}${z.name}`,l.push({id:z.id,searchName:z.name.toLowerCase(),label:W,checkbox:K}),W.append(K,Q),R.appendChild(W)}R.appendChild(_),$.replaceChildren(R),h=!0};y.onclick=()=>{if(!b.hidden){D();return}b.hidden=!1,y.setAttribute("aria-expanded","true"),h||q(),j();let R=y.getBoundingClientRect(),z=Math.min(Math.max(R.width,240),window.innerWidth-16);b.style.width=`${z}px`,b.style.left=`${Math.max(8,Math.min(R.left,window.innerWidth-z-8))}px`;let W=window.innerHeight-R.bottom-12;b.style.top=W>=180?`${R.bottom+5}px`:"auto",b.style.bottom=W>=180?"auto":`${window.innerHeight-R.top+5}px`,b.style.maxHeight=`${Math.max(120,Math.min(320,W>=180?W:R.top-12))}px`,document.addEventListener("pointerdown",x,!0),window.addEventListener("resize",D),M?.addEventListener("scroll",D,!0),f.focus()},f.oninput=j,b.addEventListener("keydown",R=>{R.key==="Escape"&&(D(),y.focus())}),g.onclick=()=>{v=p.value.trim(),u.hidden=!0,n.hidden=!1,D(),n.focus()},n.addEventListener("change",j),n.addEventListener("input",j);let C=async()=>{let R=p.value.trim(),z=a.value.trim();if(!(/^\d{15,22}$/.test(R)&&m()&&z)||v===R){++w,u.hidden=!0,n.hidden=!1,D(),E="";return}let K=`${z}/${R}`,Q=r.peek(K);if(I===K&&E===K&&Q&&!Q.pending){j();return}E=K;let he=++w;u.hidden=!1,n.hidden=!0,y.disabled=!0,y.textContent="Loading channels\u2026",D();try{let ue=await r.get(K,async()=>{let se=await J(`${U}/guilds/${R}/channels`,{headers:{Authorization:z}});if(!se.ok)throw new Error(`Could not load channels (${se.status}).`);let ae=await se.json();if(!Array.isArray(ae))throw new Error("Invalid channel list.");return ae.filter(te=>(!te.guild_id||te.guild_id===R)&&[0,5,15,16].includes(te.type)&&/^\d{15,22}$/.test(te.id)).map(te=>({id:te.id,name:String(te.name||te.id),type:te.type,position:Number(te.position)||0})).sort((te,de)=>te.position-de.position||te.name.localeCompare(de.name))});if(he!==w||p.value.trim()!==R||!m())return;T!==ue&&(T=ue,L=new Map(T.map(se=>[se.id,se.name])),l=[],$.replaceChildren(),h=!1),I=K,y.disabled=!1,j()}catch{if(he!==w)return;E="",u.hidden=!0,n.hidden=!1,y.disabled=!1,n.title="Channel list unavailable. Enter channel IDs to continue."}};return n.addEventListener("blur",()=>{n.value.trim()||(v="",E="",C())}),p.addEventListener("input",()=>{++w,clearTimeout(S),D(),S=setTimeout(()=>{C()},400)}),p.addEventListener("change",()=>{clearTimeout(S),C()}),{refresh:C}}),d=()=>Promise.all(o.map(i=>i.refresh()));return a.addEventListener("change",()=>{d()}),{refresh:d}}var De=null;function ge(){return De?.isConnected||(De=document.createElement("iframe"),De.style.display="none",De.setAttribute("aria-hidden","true"),document.body.appendChild(De)),De.contentWindow.localStorage}function en(){try{let e=ge().getItem("UserAffinitiesStoreV2"),t=JSON.parse(e||"{}")?._state?.userAffinities;return Array.isArray(t)?t.filter(Boolean):[]}catch{return[]}}function tr(){try{let e=null;return window.webpackChunkdiscord_app?.push?.([[Math.random()],{},t=>{for(let r in t.c)try{let a=t.c[r]?.exports,o=[a,a?.default];a&&typeof a=="object"&&o.push(...Object.values(a));for(let d of o)if(d&&typeof d.getUser=="function"&&typeof d.getUsers=="function"){e=d;return}}catch{}}]),e}catch{return null}}var Se=tr(),nt=e=>e?.id&&e?.avatar?`https://cdn.discordapp.com/avatars/${e.id}/${e.avatar}.${String(e.avatar).startsWith("a_")?"gif":"png"}?size=64`:"",rt=e=>nt(e)||(/^\d+$/.test(e?.id||"")?`https://cdn.discordapp.com/embed/avatars/${e.discriminator&&e.discriminator!=="0"?Number(e.discriminator)%5:Number((BigInt(e.id)>>22n)%6n)}.png`:""),ot=e=>e?.id&&e?.icon?`https://cdn.discordapp.com/icons/${e.id}/${e.icon}.${String(e.icon).startsWith("a_")?"gif":"png"}?size=64`:"",$e=e=>e?.global_name||e?.globalName||e?.username||"",$t=e=>e?.username?e.discriminator&&e.discriminator!=="0"?`${e.username}#${e.discriminator}`:`@${e.username}`:"";var We=e=>String(e||"").replace(/^Discord\s*[|—–-]\s*/i,"").replace(/\s*\|\s*Discord.*$/i,"").trim();function tn(){let e=tt({limit:32}),t=(a,o,d,i)=>e.get(`${d}/${a}/${o}`,async()=>{let s=await J(`${U}/${a}/${o}`,{headers:{Authorization:d}},i);if(!s.ok)throw new Error(`Could not access ${a==="guilds"?"server":"conversation"} (${s.status}).`);let m=await s.json();if(m.id!==o)throw new Error("Discord returned invalid identity metadata.");return m});return{resolve:async(a,o,d=()=>{})=>{let i={...a,label:We(a.label)};if(!o||a.kind==="ids")return i;try{let s=a.guildId!=="@me",m=await t(s?"guilds":"channels",s?a.guildId:a.channelId,o,d);if(s)i.icon=ot(m),a.kind==="server"&&(i.label=m.name||i.label),i.iconName=m.name||i.label;else{let p=m.recipients?.[0];!p&&m.recipient_ids?.[0]&&(p=Se?.getUser?.(m.recipient_ids[0])),p?.id&&(i.label=$e(p)||i.label,i.icon=rt(p),i.iconName=i.label)}}catch{}return i},readGuild:(a,o,d=()=>{})=>t("guilds",a,o,d)}}async function Te({token:e,channelId:t,log:r,stopCheck:a=()=>!1}){if(!/^\d{15,22}$/.test(t))throw new Error("Enter a valid conversation ID.");let o=await J(`${U}/channels/${t}`,{headers:{Authorization:e}},r,a);if(a())throw new X;if(!o.ok)throw new Error(`Could not detect conversation type (${o.status}). Check your access.`);let d=await o.json();if(d.id!==t)throw new Error("Discord returned an invalid conversation.");let i=[1,3].includes(d.type)?"dm":[0,5].includes(d.type)?"channel":[15,16].includes(d.type)?"forums":[10,11,12].includes(d.type)?"thread":null;if(!i)throw new Error("This conversation type does not support message cleanup.");let s=i==="dm"?"@me":d.guild_id;if(!s&&i==="thread"&&d.parent_id){let m=await J(`${U}/channels/${d.parent_id}`,{headers:{Authorization:e}},r,a);if(a())throw new X;if(!m.ok)throw new Error(`Could not resolve thread server (${m.status}).`);let p=await m.json();if(p.id!==d.parent_id)throw new Error("Discord returned an invalid thread parent.");s=p.guild_id}if(i!=="dm"&&!/^\d{15,22}$/.test(s||""))throw new Error("Discord did not provide a valid server ID.");return{mode:i,guildId:s,channelId:t,name:d.name||t}}function nn(e=Te,t=Date.now){let r=null,a=null,o=(d,i)=>d?.token===i.token&&d?.channelId===i.channelId;return async d=>{if(o(r,d)&&t()-r.at<3e4)return r.target;if(o(a,d))return a.promise;let i={token:d.token,channelId:d.channelId};return a=i,i.promise=Promise.resolve().then(()=>e(d)).then(s=>(a===i&&(r={token:i.token,channelId:i.channelId,target:s,at:t()}),s)).finally(()=>{a===i&&(a=null)}),i.promise}}function xe(e,t="IDs",{required:r=!1}={}){let a=[...new Set(String(e||"").split(",").map(o=>o.trim()).filter(Boolean))];if(r&&!a.length)throw new Error(`Enter at least one ${t}.`);if(a.some(o=>!/^\d{15,22}$/.test(o)))throw new Error(`${t} must be Discord IDs separated by commas.`);return a}function rn(e){let t=()=>{},r=new Intl.DateTimeFormat(void 0,{dateStyle:"medium",timeStyle:"short"}),a=new Intl.DateTimeFormat(void 0,{month:"long",year:"numeric"}),o=new Intl.DateTimeFormat(void 0,{dateStyle:"full"});e.querySelectorAll("input[type=datetime-local]").forEach(d=>{let i=d.parentElement.querySelector("label");i.htmlFor=d.id,i.id=`${d.id}-label`;let s=document.createElement("button");s.type="button",s.className="dmd-select-trigger",s.setAttribute("aria-haspopup","dialog"),s.setAttribute("aria-expanded","false"),s.setAttribute("aria-labelledby",`${i.id} ${d.id}-display`);let m=document.createElement("span");m.id=`${d.id}-display`,s.append(m),d.classList.add("dmd-native-select"),d.tabIndex=-1,d.after(s);let p=()=>{m.textContent=d.value?r.format(new Date(d.value)):"Choose date & time",s.disabled=d.disabled};d.addEventListener("change",p),p();let n,u,y,b,f,$,g,T,L,l=[],h=[],w=(M,D)=>{let x=document.createElement("button");return x.type="button",x.textContent=M,x.onclick=D,x},S=()=>{let M=`${y.getFullYear()}-${y.getMonth()}`;if(M!==T){T=M,b.textContent=a.format(y);let N=(y.getDay()+6)%7;for(let _=0;_N.getTime()===D)];L!==x&&(L?.classList.remove("selected"),x?.classList.add("selected"),L=x)},v=()=>{n?.remove(),s.setAttribute("aria-expanded","false"),document.removeEventListener("pointerdown",E,!0),e.removeEventListener("scroll",v,!0),window.removeEventListener("resize",v),t===v&&(t=()=>{})},E=M=>{!n.contains(M.target)&&!s.contains(M.target)&&v()},I=()=>{n=document.createElement("div"),n.className="dmd-calendar",n.setAttribute("role","dialog");let M=document.createElement("div");M.className="dmd-calendar-header",b=document.createElement("span");let D=document.createElement("div");D.className="dmd-calendar-grid";for(let z of["Mo","Tu","We","Th","Fr","Sa","Su"]){let W=document.createElement("span");W.textContent=z,D.append(W)}for(let z=0;z<42;z++){let W=w("",()=>{u=h[z],S()});l.push(W),D.append(W)}f=document.createElement("input"),f.type="number",f.min="0",f.max="23",f.setAttribute("aria-label","Hour"),$=document.createElement("input"),$.type="number",$.min="0",$.max="59",$.setAttribute("aria-label","Minute");let x=w("\u2039",()=>{y.setMonth(y.getMonth()-1),S()});x.setAttribute("aria-label","Previous month");let N=w("\u203A",()=>{y.setMonth(y.getMonth()+1),S()});N.setAttribute("aria-label","Next month"),M.append(x,b,N);let _=document.createElement("div");_.className="dmd-calendar-time";let j=document.createElement("span");j.textContent="Time",_.append(j,f,document.createTextNode(":"),$);let q=document.createElement("div");q.className="dmd-calendar-footer";let C=w("Clear",()=>{d.value="",d.dispatchEvent(new Event("change",{bubbles:!0})),v(),s.focus()}),R=w("Cancel",()=>{v(),s.focus()});g=w("Apply",()=>{if(!f.value||!$.value||!f.checkValidity()||!$.checkValidity()){(!f.checkValidity()||!f.value?f:$).focus();return}let z=W=>String(W).padStart(2,"0");d.value=`${u.getFullYear()}-${z(u.getMonth()+1)}-${z(u.getDate())}T${z(f.value)}:${z($.value)}`,d.dispatchEvent(new Event("change",{bubbles:!0})),v(),s.focus()}),q.append(C,R,g),n.append(M,D,_,q),n.onkeydown=z=>{z.key==="Escape"&&(z.preventDefault(),v(),s.focus())}};s.onclick=()=>{if(t(),p(),d.disabled)return;u=d.value?new Date(d.value):new Date,y=new Date(u.getFullYear(),u.getMonth(),1),n||I(),n.setAttribute("aria-label",i.textContent),f.value=String(u.getHours()),$.value=String(u.getMinutes()),S(),document.body.append(n);let M=s.getBoundingClientRect();n.style.left=`${Math.max(8,Math.min(M.left,window.innerWidth-298))}px`,n.style.top=`${Math.max(8,Math.min(M.bottom+5,window.innerHeight-n.offsetHeight-8))}px`,s.setAttribute("aria-expanded","true"),document.addEventListener("pointerdown",E,!0),e.addEventListener("scroll",v,!0),window.addEventListener("resize",v),t=v,g.focus()}})}function on(e){let t=()=>{};e.querySelectorAll("select:not([multiple])").forEach((r,a)=>{let o=document.createElement("button");o.type="button",o.className="dmd-select-trigger",o.setAttribute("aria-haspopup","listbox"),o.setAttribute("aria-expanded","false");let d=e.querySelector(`label[for="${r.id}"]`);d?(d.id||=`dmd-select-label-${a}`,o.setAttribute("aria-labelledby",`${d.id} ${r.id}-value`)):o.setAttribute("aria-label",r.getAttribute("aria-label")||"Choose option");let i=document.createElement("span");i.id=`${r.id}-value`,o.appendChild(i),r.classList.add("dmd-native-select"),r.tabIndex=-1,r.insertAdjacentElement("afterend",o);let s=()=>{i.textContent=r.selectedOptions[0]?.textContent||"",o.disabled=r.disabled};r.addEventListener("change",s),s();let m,p=[],n=[],u,y=()=>{m?.remove(),o.setAttribute("aria-expanded","false"),document.removeEventListener("pointerdown",b,!0),window.removeEventListener("resize",y),e.removeEventListener("scroll",y,!0),t===y&&(t=()=>{})},b=g=>{!m.contains(g.target)&&!o.contains(g.target)&&y()},f=()=>p[u]?.focus(),$=()=>{if(t(),s(),r.disabled)return;for(m||(m=document.createElement("div"),m.className="dmd-select-menu",m.id=`${r.id}-menu`,m.setAttribute("role","listbox"),o.setAttribute("aria-controls",m.id),m.onkeydown=l=>{if(l.key==="Escape"||l.key==="Tab"){y(),o.focus(),l.key==="Escape"&&l.preventDefault();return}if(!["ArrowDown","ArrowUp","Home","End"].includes(l.key)||!n.length)return;l.preventDefault();let h=n.indexOf(u);u=l.key==="Home"?n[0]:l.key==="End"?n.at(-1):n[(h+(l.key==="ArrowDown"?1:-1)+n.length)%n.length],f()}),m.setAttribute("aria-label",d?.textContent||r.getAttribute("aria-label")||"Options");p.length>r.options.length;)p.pop().remove();for(;p.length{r.value=r.options[l].value,r.dispatchEvent(new Event("change",{bubbles:!0})),y(),o.focus()},p.push(h),m.appendChild(h)}n=[];for(let l=0;l=140?(m.style.top=`${g.bottom+5}px`,m.style.maxHeight=`${Math.min(280,L)}px`):(m.style.bottom=`${window.innerHeight-g.top+5}px`,m.style.maxHeight=`${Math.max(60,Math.min(280,g.top-12))}px`),u=n.includes(r.selectedIndex)?r.selectedIndex:n[0],o.setAttribute("aria-expanded","true"),f(),document.addEventListener("pointerdown",b,!0),window.addEventListener("resize",y),e.addEventListener("scroll",y,!0),t=y};o.onclick=$,o.onkeydown=g=>{["ArrowDown","ArrowUp"].includes(g.key)&&(g.preventDefault(),$())}})}var an=new WeakMap;function at(e,{author:t="#dmd-author",guild:r="#dmd-guild",userBadge:a="#dmd-user-avatar",guildBadge:o="#dmd-guild-avatar",cache:d=new Map}={}){let i=an.get(d)||new Map;an.set(d,i);let s=(g,T)=>{for(d.delete(g),d.set(g,T);d.size>128;)d.delete(d.keys().next().value)},m={user:t?e(t):null,guild:r?e(r):null},p={user:a?e(a):null,guild:o?e(o):null},n=e("#dmd-token"),u=g=>p[g],y={user:0,guild:0},b={},f=(g,T,L)=>{let l=u(g);l.textContent="",l.hidden=!1;let h=g==="user"?$e(L):L.name,w=g==="user"?rt(L):ot(L);if(l.title=h||T,l.setAttribute("aria-label",h||T),l.textContent=(h||"?").slice(0,1).toUpperCase(),w){let S=document.createElement("img");S.src=w,S.alt=h||(g==="user"?"User avatar":"Server icon"),S.onerror=()=>S.remove(),l.appendChild(S)}},$=async g=>{let T=m[g];if(!T)return;let L=T.value.split(",")[0].trim(),l=++y[g],h=u(g);if(h.hidden=!0,h.textContent="",g==="guild"&&L==="@me"){h.hidden=!1,h.textContent="@",h.title="Direct messages",h.setAttribute("aria-label","Direct messages");return}if(!/^\d{15,22}$/.test(L))return;let w=d.get(`${g}:${L}`);if(!w&&g==="user")try{w=Se?.getUser?.(L)}catch{}if(!w){let S=n.value.trim();if(!S)return;let v=`${S}/${g}:${L}`,E=()=>y[g]===l&&T.value.split(",")[0].trim()===L&&n.value.trim()===S,I=i.get(v);I||(I={readers:new Set},I.promise=Promise.resolve().then(async()=>{let M=()=>{for(let x of I.readers)if(x())return!1;return!0},D=await J(`${U}/${g==="user"?"users":"guilds"}/${L}`,{headers:{Authorization:S}},()=>{},M);return D.ok?D.json():null}).finally(()=>{i.get(v)===I&&i.delete(v)}),i.set(v,I)),I.readers.add(E);try{w=await I.promise}catch{return}finally{I.readers.delete(E)}}w?.id!==L||y[g]!==l||T.value.split(",")[0].trim()!==L||(s(`${g}:${L}`,w),f(g,L,w))};for(let g of["user","guild"]){let T=m[g];T&&(T.addEventListener("input",()=>{++y[g],u(g).hidden=!0,clearTimeout(b[g]),b[g]=setTimeout(()=>{$(g)},400)}),T.addEventListener("change",()=>{clearTimeout(b[g]),$(g)}))}return n.addEventListener("change",()=>{$("user"),$("guild")}),{refresh:$,seedGuild:g=>{g?.id&&s(`guild:${g.id}`,g)},seedUser:g=>{g?.id&&s(`user:${g.id}`,g)}}}function dt(e){let t=document.createElement("style");return t.textContent=e,document.head.appendChild(t),t}var Ct="del_discord_v1_history",it="del_discord_v1_history_state";function dn(){try{let e=JSON.parse(ge().getItem(Ct)||"{}");return e&&typeof e=="object"?e:{}}catch{return{}}}function pe(e){try{ge().setItem(Ct,JSON.stringify(e))}catch(t){console.warn("[DM History] Save failed",t)}}function sn(){try{let e=JSON.parse(ge().getItem(it)||"{}");return e&&typeof e=="object"?e:{}}catch{return{}}}function ln(e){try{let t=ge(),r=JSON.stringify(e);t.getItem(it)!==r&&t.setItem(it,r)}catch(t){console.warn("[DM History] State save failed",t)}}var Et=null;function cn(e=!1){if(Et&&!e)return Et;let t=[],r=new Set;try{let a=window.webpackChunkdiscord_app;if(a?.push){let o=[];a.push([[Math.random()],{},d=>{for(let i in d.c)o.push(d.c[i])}]);for(let d of o)try{let i=d?.exports,s=[i,i?.default];i&&typeof i=="object"&&s.push(...Object.values(i));for(let m of s){let p=m?.getToken;typeof p=="function"&&!r.has(p)&&(r.add(p),t.push(()=>p.call(m)))}}catch{}}}catch{}return Et=t,t}function st(){let e=[],t=new Set,r=o=>{typeof o=="string"&&o.length>20&&!t.has(o)&&(t.add(o),e.push(o))};try{let o=ge();o.token&&r(JSON.parse(o.token))}catch{}let a=cn();for(let o of a)try{r(o())}catch{}if(!e.length&&a.length){a=cn(!0);for(let o of a)try{r(o())}catch{}}return e}var Je=null;function nr(){Je=null}async function Qe(){let e=Date.now();if(Je&&e-Je.at<6e4)return Je.identity;for(let t of st())try{let r=await J(`${U}/users/@me`,{headers:{Authorization:t}});if(r.ok){let a={token:t,user:await r.json()};return Je={at:e,identity:a},a}}catch{}return nr(),null}var mn=new WeakMap,un=new WeakMap,At=e=>un.get(e)||0,pn=e=>un.set(e,At(e)+1);function fn(e){let t=mn.get(e);if(t)return t;t={byUser:new Map,byChannel:new Map};for(let r of Object.keys(e)){let a=e[r];a?.userId&&t.byUser.set(a.userId,r),a?.channelId&&t.byChannel.set(a.channelId,r)}return mn.set(e,t),t}function Ne(e,t){let r=fn(e),a=t.userId?r.byUser.get(t.userId):"",o=t.channelId?r.byChannel.get(t.channelId):"",d=a||o||"",i=t.userId?`u:${t.userId}`:t.channelId?`c:${t.channelId}`:"";if(!i)return;let s=d?e[d]||{}:e[i]||{};d&&d!==i&&(delete e[d],s.userId&&r.byUser.delete(s.userId),s.channelId&&r.byChannel.delete(s.channelId));let m={userId:t.userId||s.userId||"",channelId:t.channelId||s.channelId||"",name:t.name||s.name||"",username:t.username||s.username||"",avatar:t.avatar||s.avatar||"",dmRank:Number.isFinite(t.dmRank)?t.dmRank:Number.isFinite(s.dmRank)?s.dmRank:null,sources:[...new Set([...s.sources||[],...t.sources||[]])],seenAt:t.seenAt||s.seenAt||new Date().toISOString(),sentCount:Math.max(Number.isFinite(s.sentCount)?s.sentCount:0,Number.isFinite(t.sentCount)?t.sentCount:0),sentCountExact:t.sentCountExact===!0||s.sentCountExact===!0,verifiedSent:t.verifiedSent===!0||s.verifiedSent===!0};return e[i]=m,m.userId&&r.byUser.set(m.userId,i),m.channelId&&r.byChannel.set(m.channelId,i),pn(e),m}function ct(e,t,r){let a=fn(e),o=new Set;if(t){let d=a.byUser.get(t);d&&o.add(d)}if(r){let d=a.byChannel.get(r);d&&o.add(d)}for(let d of o){let i=e[d];i&&(i.userId&&a.byUser.delete(i.userId),i.channelId&&a.byChannel.delete(i.channelId),delete e[d],pn(e))}}function lt(e,t=[]){if(typeof e=="string"&&/^\d{15,22}$/.test(e))t.push(e);else if(Array.isArray(e))e.forEach(r=>lt(r,t));else if(e&&typeof e=="object")for(let[r,a]of Object.entries(e))/recipient|user|member|participant/i.test(r)&<(a,t);return t}function hn(){let e,t=-1,r=[],a=[],o=null,d=new WeakMap;return(i,s="")=>{let m=At(i);if(i!==e||m!==t){r=[];for(let n of Object.keys(i)){let u=i[n];u?.verifiedSent===!0&&Number(u.sentCount||0)>0&&r.push(u)}r.sort((n,u)=>{let y=Number.isFinite(n.dmRank)?n.dmRank:1e12,b=Number.isFinite(u.dmRank)?u.dmRank:1e12;return y-b||(n.name||n.username||n.userId||n.channelId).localeCompare(u.name||u.username||u.userId||u.channelId)}),e=i,t=m,o=null}let p=s.trim().toLowerCase();return p!==o&&(a=p?r.filter(n=>{let u=d.get(n);return u===void 0&&(u=[n.name,n.username,n.userId,n.channelId].join(" ").toLowerCase(),d.set(n,u)),u.includes(p)}):r,o=p),a}}async function rr(e,t,r){for(;;){let a=new URLSearchParams({author_id:t,sort_by:"timestamp",sort_order:"desc",offset:"0"}),o=await J(`${U}/channels/${r}/messages/search?${a.toString()}`,{headers:{Authorization:e}});if(o.status===202){let i={};try{i=await o.json()}catch{}await G(ye(i.retry_after??1)+250);continue}if(!o.ok)return null;let d=await o.json();return Number(d.total_results||0)}}async function gn(e,t,r,a,o){let d=a||"",i=!1;try{if(!d){let m=await J(`${U}/users/@me/channels`,{method:"POST",headers:{Authorization:e,"Content-Type":"application/json"},body:JSON.stringify({recipient_id:r})});if(!m.ok)return null;d=(await m.json())?.id||"",i=!!d&&!o}return d?{count:await rr(e,t,d),channelId:d}:null}finally{if(i&&d)try{await J(`${U}/channels/${d}`,{method:"DELETE",headers:{Authorization:e}})}catch{}}}async function xn(e){let t=!1,r=!1,a=0,o=d=>{for(let i=0;i32&&s!==65279&&(r=!0)}};if(typeof e.stream=="function"){let d=e.stream().getReader(),i=new TextDecoder;try{for(;;){let{value:s,done:m}=await d.read();if(m)break;for(let p=0;p{let t=JSON.parse(e||"[]");return Array.isArray(t)?t.length:Array.isArray(t?.messages)?t.messages.length:t&&typeof t=="object"&&Object.keys(t).length?1:0};async function vn(e){if(Number.isFinite(e.size)&&e.size<=65536&&typeof e.text=="function")return bn(await e.text());let t;if(typeof e.stream!="function"&&(t=await e.text()||"[]",t.length<=65536))return bn(t);let r=[],a=[],o=/["\\\x00-\x1f]/g,d="value",i="",s=0,m=0,p=null,n="",u="",y=0,b=!1,f=0,$=0,g=!1,T=!1,L=!0,l=0,h=()=>{throw new SyntaxError("Invalid message JSON.")},w=()=>r[r.length-1],S=M=>{let D=w();D?D.type==="array"?(D.state!=="first"&&D.state!=="value"&&h(),D.state="comma",D.count++):(D.state!=="value"&&h(),D.state="comma",r.length===1&&D.messagesKey&&(p=null)):(d!=="value"&&h(),d="end",i=M)},v=M=>{T&&(M!=="messages".charCodeAt(l)&&(L=!1),l++)},E=M=>{let D=M>=48&&M<=57;switch(n){case"minus":M===48?n="zero":D?n="integer":h();break;case"zero":if(M===46)n="dot";else if(M===69||M===101)n="exponent";else return!1;break;case"integer":if(D)break;if(M===46)n="dot";else if(M===69||M===101)n="exponent";else return!1;break;case"dot":D||h(),n="fraction";break;case"fraction":if(D)break;if(M===69||M===101)n="exponent";else return!1;break;case"exponent":M===43||M===45?n="exponentSign":D?n="exponentDigits":h();break;case"exponentSign":D||h(),n="exponentDigits";break;case"exponentDigits":if(!D)return!1;break}return!0},I=M=>{M.length&&(g=!0);for(let D=0;D1)&&!b&&!f){if(o.lastIndex=D,!o.test(M))break;D=o.lastIndex-1,x=M.charCodeAt(D)}if(f){let _=x>=48&&x<=57?x-48:x>=65&&x<=70?x-55:x>=97&&x<=102?x-87:-1;_<0&&h(),$=$*16+_,--f===0&&v($)}else if(b)b=!1,x===117?(f=4,$=0):([34,92,47,98,102,110,114,116].includes(x)||h(),v(x===98?8:x===102?12:x===110?10:x===114?13:x===116?9:x));else if(x===92)b=!0;else if(x===34){if(n="",T){let _=w();_.state="colon",_.messagesKey=L&&l===8,r.length===1&&s++}T=!1}else x<32&&h(),v(x);continue}if(n==="literal"){x!==u.charCodeAt(y++)&&h(),y===u.length&&(n="");continue}if(n){if(E(x))continue;n=""}if(x===32||x===9||x===10||x===13)continue;let N=w();if(x===34)T=!!N&&N.type==="object"&&(N.state==="first"||N.state==="key"),T?(L=!0,l=0):S("string"),n="string";else if(x===91||x===123){let _=x===91?"array":"object",j=r.length===1&&N.type==="object"&&N.messagesKey&&_==="array";S(_);let q=a[r.length]||(a[r.length]={});q.type=_,q.state="first",q.count=0,q.messages=j,q.messagesKey=!1,r.push(q)}else x===93||x===125?((!N||N.type!==(x===93?"array":"object")||N.state!=="first"&&N.state!=="comma")&&h(),r.pop(),N.messages&&(p=N.count),!r.length&&N.type==="array"&&(m=N.count)):x===44?((!N||N.state!=="comma")&&h(),N.state=N.type==="array"?"value":"key"):x===58?((!N||N.type!=="object"||N.state!=="colon")&&h(),N.state="value"):x===116||x===102||x===110?(S("literal"),n="literal",u=x===116?"true":x===102?"false":"null",y=1):x===45||x>=48&&x<=57?(S("number"),n=x===45?"minus":x===48?"zero":"integer"):h()}};if(typeof e.stream=="function"){let M=e.stream().getReader(),D=new TextDecoder("utf-8",{fatal:!0}),x=!1;try{for(;;){let{value:N,done:_}=await M.read();if(_)break;for(let j=0;jString(w.webkitRelativePath||w.name).replace(/\\/g,"/"),y=w=>w.slice(0,w.lastIndexOf("/")).toLowerCase(),b=new Map,f=[],$=new Map;for(let w of m){let S=u(w),v=S.toLowerCase();if(`/${v}`.endsWith("/messages/index.json")){try{let E=JSON.parse(await w.text());for(let[I,M]of Object.entries(E||{}))typeof M=="string"&&b.set(String(I),M)}catch{}continue}if(`/${v}`.endsWith("/channel.json")){f.push({file:w,path:S});continue}if(/\/messages(?:\.csv|\.json)$/.test("/"+v)){let E=y(S),I=$.get(E);I?I.push({file:w,lower:v}):$.set(E,[{file:w,lower:v}])}}let g=new Map,T=async w=>{let S=w.toLowerCase();if(g.has(S))return g.get(S);let v=$.get(S)||[],E=0;for(let{file:I,lower:M}of v)try{M.endsWith(".json")?E+=await vn(I):E+=await xn(I)}catch{return g.set(S,null),null}return g.set(S,E),E},L=0,l=0,h=Date.now();for(let{file:w,path:S}of f){l++,l%25===0&&(t("#dmh-summary").textContent=`Processing package conversations ${l}/${f.length}\u2026`,await new Promise(requestAnimationFrame));let v;try{v=JSON.parse(await w.text())}catch{continue}let E=S.slice(0,S.lastIndexOf("/")),I=String(v?.id||E.match(/(?:^|\/)c?(\d{15,22})$/)?.[1]||""),M=b.get(I)||"",D=String(v?.type??"").toUpperCase();if(!(D==="DM"||D==="1"||/^Direct Message with /i.test(M))||(D==="GROUP_DM"||D==="3"))continue;let _=await T(E);if(_===null)continue;if(_<=0){ct(s,"",I);continue}let j=[...new Set(lt(v))].filter(q=>q!==n&&q!==I);Ne(s,{userId:j[0]||"",channelId:I,name:M.replace(/^Direct Message with\s+/i,"").trim(),sources:["package"],sentCount:_,sentCountExact:!1,verifiedSent:!0,seenAt:new Date().toISOString()}),L++,Date.now()-h>=2e3&&(pe(s),h=Date.now())}pe(s),o({importedAt:new Date().toISOString(),importedCount:L,fileCount:m.length}),d(),i(),t("#dmh-summary").textContent=`Imported/updated ${L} DM conversations. Parsed package results are cached locally.`}finally{t("#dmh-import").disabled=!1,t("#dmh-folder").value=""}}}function re({title:e="Confirm",message:t="",details:r="",yesText:a="Yes",noText:o="No",danger:d=!1,render:i,getResult:s,onNo:m}={}){return new Promise(p=>{let n=document.createElement("div");n.className="dmd-confirm-overlay";let u=document.createElement("div");u.className="dmd-confirm-box";let y=document.createElement("div");y.className="dmd-confirm-title",y.textContent=e;let b=document.createElement("div");if(b.className="dmd-confirm-message",b.textContent=t,u.append(y,b),r){let S=document.createElement("pre");S.className="dmd-confirm-details",S.textContent=r,u.appendChild(S)}let f=document.createElement("div");f.className="dmd-confirm-actions";let $=document.createElement("button");$.className="dmd-btn",$.textContent=o;let g=document.createElement("button");g.className=`dmd-btn ${d?"dmd-red":"dmd-green"}`,g.textContent=a,i?.({box:u,yes:g}),f.append($,g),u.appendChild(f),n.appendChild(u);let T=document.activeElement,L=document.getElementById("dmd-panel")||document.body;u.setAttribute("role","dialog"),u.setAttribute("aria-modal","true"),y.id="dmd-confirm-heading",u.setAttribute("aria-labelledby",y.id),L.appendChild(n);let l=!1,h=S=>{l||(l=!0,document.removeEventListener("keydown",w,!0),n.remove(),T?.isConnected&&T.focus(),p(S&&s?s():S))},w=S=>{if(S.key==="Tab"){let v=[...u.querySelectorAll('button,input,select,[tabindex="0"]')].filter(I=>!I.disabled),E=v.indexOf(document.activeElement);S.preventDefault(),v[(E+(S.shiftKey?-1:1)+v.length)%v.length]?.focus()}S.key==="Escape"&&h(!1),S.key==="Enter"&&(S.metaKey||S.ctrlKey)&&!g.disabled&&h(!0)};$.onclick=()=>{m?.(),h(!1)},g.onclick=()=>h(!0),n.onclick=S=>{S.target===n&&h(!1)},document.addEventListener("keydown",w,!0),g.focus()})}var yn=`
+(()=>{function ut(e,t,r=()=>Date.now()){let o=()=>{try{return t||globalThis.localStorage}catch{return null}},a=`${e}_created_at`,i=()=>{let c=o();if(!c)return null;let f=c.getItem(e);if(f===null)return null;let n=Number(c.getItem(a));return n?r()-n>=2592e5||n>r()?(s(),null):f:(c.setItem(a,String(r())),f)},s=()=>{let c=o();c&&(c.removeItem(e),c.removeItem(a))};return{read:i,write:c=>{let f=o();if(!f)return!1;let n=Number(f.getItem(a));return(!n||r()-n>=2592e5||n>r())&&f.setItem(a,String(r())),f.setItem(e,c),!0},clear:s}}var Cr="del_discord_v1_message_settings";function wn(e,{getMode:t,setMode:r,onReset:o=()=>{},onStorageError:a=()=>{}}){let i=ut(Cr),s=[...e.querySelectorAll("#dmd-messages input:not([type=password]):not([type=file]), #dmd-messages textarea, #dmd-messages select:not([multiple])")],d=new Map(s.map(b=>[b.id,b.type==="checkbox"?b.checked:b.value])),c=!0;try{let b=JSON.parse(i.read()||"null");if(b&&typeof b=="object"){for(let h of s){let S=b.fields?.[h.id];h.type==="checkbox"&&typeof S=="boolean"?h.checked=S:typeof S=="string"&&(h.tagName!=="SELECT"||[...h.options].some(k=>k.value===S))&&(h.value=S)}["dm","channel","server","forums","thread"].includes(b.mode)&&r(b.mode)}}catch{}c=!1;let f=!1,n=()=>{if(c)return;let b=Object.fromEntries(s.map(h=>[h.id,h.type==="checkbox"?h.checked:h.value]));try{if(!i.write(JSON.stringify({mode:t(),fields:b})))throw new Error("Storage unavailable")}catch{f||(f=!0,a())}},u;return s.forEach(b=>{b.addEventListener("input",()=>{clearTimeout(u),u=setTimeout(n,150)}),b.addEventListener("change",()=>{clearTimeout(u),n()}),b.addEventListener("blur",()=>{clearTimeout(u),n()})}),{save:n,clear:()=>{clearTimeout(u),c=!0;for(let b of s)b.type==="checkbox"?b.checked=d.get(b.id):b.value=d.get(b.id),b.dispatchEvent(new Event("change",{bubbles:!0}));r("dm"),c=!1;try{i.clear()}catch{}o()}}}function vn(e="currentColor",t=22){return``}function pt({ttl:e=6e4,limit:t=16}={}){let r=new Map,o=i=>{let s=r.get(i);if(s&&!s.pending&&Date.now()-s.time>=e){r.delete(i);return}return s};return{get:(i,s)=>{let d=o(i);if(d)return r.delete(i),r.set(i,d),d.promise;let c={pending:!0,time:Date.now()};for(c.promise=Promise.resolve().then(s).then(f=>(c.pending=!1,c.time=Date.now(),f),f=>{throw r.get(i)===c&&r.delete(i),f}),r.set(i,c);r.size>t;)r.delete(r.keys().next().value);return c.promise},peek:o}}var B="https://discord.com/api/v10";var yn="del_discord_v1_queue";var Q=e=>{let t=Math.min(e,2147483647);return new Promise(r=>setTimeout(r,t)).then(()=>e>t?Q(e-t):void 0)},ee=(e,t)=>{let r=t-e+1;if(globalThis.crypto?.getRandomValues&&Number.isSafeInteger(r)&&r>0&&r<=4294967296){let o=new Uint32Array(1),a=Math.floor(4294967296/r)*r;do crypto.getRandomValues(o);while(o[0]>=a);return e+o[0]%r}return Math.floor(Math.random()*r)+e},Re=e=>{let t=Number(e);return e!==null&&e!==""&&Number.isFinite(t)&&t>=0?t*1e3:1500};function le(e,t){return t?new Promise((r,o)=>{let a=()=>{i(),o(t.reason||new DOMException("Stopped.","AbortError"))},i=()=>t.removeEventListener("abort",a);e.then(s=>{i(),r(s)},s=>{i(),o(s)}),t.aborted?a():t.addEventListener("abort",a,{once:!0})}):e}var V=class extends Error{constructor(){super("Stopped."),this.name="RunStoppedError"}},ze=class extends Error{constructor(){super("Repeated rate limits. Requests stopped; wait before trying again."),this.name="RateLimitError"}},kn=Promise.resolve(),Wt=null;async function Mr(e,t,r){let o=kn,a,i=new Promise(s=>{a=s});kn=o.then(()=>i);try{if(await le(o,r),t())throw new V;if(Wt!==null){let s=e-(performance.now()-Wt);s>0&&await le(Q(s),r)}if(t())throw new V;Wt=performance.now()}finally{a()}}var Fe=new Map;function In(e,t){let r=Fe.get(e),o=Promise.all([r,Q(t+ee(250,750))]).finally(()=>{Fe.get(e)===o&&Fe.delete(e)});return Fe.set(e,o),o}async function W(e,t={},r=()=>{},o=()=>!1,{minIntervalMs:a=0,maxRateLimitRetries:i=2}={}){let s=0,d=new Headers(t.headers).get("Authorization")||new URL(e).origin;for(;;){if(o())throw new V;let c;for(;c=Fe.get(d);)if(await le(c,t.signal),o())throw new V;if(a>0&&(await Mr(a,o,t.signal),Fe.has(d)))continue;if(o())throw new V;t.signal?.throwIfAborted();let f=await le(fetch(e,t),t.signal);if(f.status!==429){let M=f.headers.get("X-RateLimit-Reset-After");return f.headers.get("X-RateLimit-Remaining")==="0"&&M!==null&&Number.isFinite(Number(M))&&Number(M)>=0&&In(d,Re(M)),f}let n={};try{n=await le(f.json(),t.signal)||{}}catch{}let u=M=>M!==null&&M!==""&&["number","string"].includes(typeof M)&&Number.isFinite(Number(M))&&Number(M)>=0?Number(M)*1e3:null,w=f.headers.get("Retry-After"),b=u(w);if(b===null&&w&&/GMT$/i.test(w)){let M=Date.parse(w);Number.isFinite(M)&&(b=Math.max(0,M-Date.now()))}let h=[u(n.retry_after),b,u(f.headers.get("X-RateLimit-Reset-After"))].filter(M=>M!==null),S=h.length?Math.max(...h):Math.min(3e4,1500*2**s),k=In(d,S);if(t.signal?.throwIfAborted(),s++>=i)throw new ze;r("warn",`Rate limited. Waiting at least ${(S/1e3).toFixed(1)}s before retry ${s}/${i}...`),await le(k,t.signal)}}function Sn(e,t=()=>!0){let r=pt(),o=e("#dmd-token"),a=[["#dmd-guild","#dmd-channel",t],["#dmd-multi-guild","#dmd-multi-channel",()=>!0],["#dmd-rx-guild","#dmd-rx-channel",()=>!0]].filter(([s,d])=>e(s)&&e(d)).map(([s,d,c])=>{let f=e(s),n=e(d),u=document.createElement("div");u.className="dmd-channel-picker",u.hidden=!0;let w=document.createElement("button");w.type="button",w.className="dmd-select-trigger",w.setAttribute("aria-expanded","false"),w.setAttribute("aria-label","Choose channels");let b=document.createElement("div");b.className="dmd-channel-menu",b.id=`${n.id}-channel-menu`,b.hidden=!0,b.setAttribute("role","group"),b.setAttribute("aria-label","Channels"),w.setAttribute("aria-controls",b.id);let h=document.createElement("input");h.type="search",h.placeholder="Find a channel",h.maxLength=100,h.setAttribute("aria-label","Find a channel");let S=document.createElement("div");S.className="dmd-channel-choices";let k=document.createElement("button");k.type="button",k.className="dmd-btn",k.textContent="Enter IDs instead",b.append(h,S,k),u.append(w),n.after(u),document.body.appendChild(b);let M=[],T=new Map,l=[],y=!1,I=0,p,g="",x="",$="",E=n.closest("#dmd-panel"),A=()=>{b.hidden=!0,w.setAttribute("aria-expanded","false"),document.removeEventListener("pointerdown",v,!0),window.removeEventListener("resize",A),E?.removeEventListener("scroll",A,!0)},v=z=>{!u.contains(z.target)&&!b.contains(z.target)&&A()},N=()=>n.value.split(",").map(z=>z.trim()).filter(Boolean),_=document.createElement("div");_.className="dmd-muted",S.onchange=z=>{let P=z.target;if(P.type!=="checkbox")return;let K=new Set(N());P.checked?K.add(P.value):K.delete(P.value),n.value=[...K].join(", "),n.dispatchEvent(new Event("change",{bubbles:!0}))};let U=()=>{let z=N();if(w.textContent=z.length===1?T.has(z[0])?`#${T.get(z[0])}`:z[0]:z.length?`${z.length} channels selected`:"Select channels",b.hidden)return;let P=new Set(z),K=h.value.trim().toLowerCase(),q=0;for(let j of l)j.checkbox.checked=P.has(j.id),j.label.hidden=!j.searchName.includes(K),j.label.hidden||q++;_.hidden=q>0,_.textContent=M.length?"No matching channels.":"No message channels available."},H=()=>{let z=document.createDocumentFragment();l=[];for(let P of M){let K=document.createElement("label");K.className="dmd-channel-option";let q=document.createElement("input");q.type="checkbox",q.value=P.id;let j=document.createElement("span");j.textContent=`${[15,16].includes(P.type)?"Forum: ":"#"}${P.name}`,l.push({id:P.id,searchName:P.name.toLowerCase(),label:K,checkbox:q}),K.append(q,j),z.appendChild(K)}z.appendChild(_),S.replaceChildren(z),y=!0};w.onclick=()=>{if(!b.hidden){A();return}b.hidden=!1,w.setAttribute("aria-expanded","true"),y||H(),U();let z=w.getBoundingClientRect(),P=Math.min(Math.max(z.width,240),window.innerWidth-16);b.style.width=`${P}px`,b.style.left=`${Math.max(8,Math.min(z.left,window.innerWidth-P-8))}px`;let K=window.innerHeight-z.bottom-12;b.style.top=K>=180?`${z.bottom+5}px`:"auto",b.style.bottom=K>=180?"auto":`${window.innerHeight-z.top+5}px`,b.style.maxHeight=`${Math.max(120,Math.min(320,K>=180?K:z.top-12))}px`,document.addEventListener("pointerdown",v,!0),window.addEventListener("resize",A),E?.addEventListener("scroll",A,!0),h.focus()},h.oninput=U,b.addEventListener("keydown",z=>{z.key==="Escape"&&(A(),w.focus())}),k.onclick=()=>{g=f.value.trim(),u.hidden=!0,n.hidden=!1,A(),n.focus()},n.addEventListener("change",U),n.addEventListener("input",U);let L=async()=>{let z=f.value.trim(),P=o.value.trim();if(!(/^\d{15,22}$/.test(z)&&c()&&P)||g===z){++I,u.hidden=!0,n.hidden=!1,A(),x="";return}let q=`${P}/${z}`,j=r.peek(q);if($===q&&x===q&&j&&!j.pending){U();return}x=q;let ne=++I;u.hidden=!1,n.hidden=!0,w.disabled=!0,w.textContent="Loading channels\u2026",A();try{let fe=await r.get(q,async()=>{let ce=await W(`${B}/guilds/${z}/channels`,{headers:{Authorization:P}});if(!ce.ok)throw new Error(`Could not load channels (${ce.status}).`);let de=await ce.json();if(!Array.isArray(de))throw new Error("Invalid channel list.");return de.filter(Z=>(!Z.guild_id||Z.guild_id===z)&&[0,5,15,16].includes(Z.type)&&/^\d{15,22}$/.test(Z.id)).map(Z=>({id:Z.id,name:String(Z.name||Z.id),type:Z.type,position:Number(Z.position)||0})).sort((Z,ie)=>Z.position-ie.position||Z.name.localeCompare(ie.name))});if(ne!==I||f.value.trim()!==z||!c())return;M!==fe&&(M=fe,T=new Map(M.map(ce=>[ce.id,ce.name])),l=[],S.replaceChildren(),y=!1),$=q,w.disabled=!1,U()}catch{if(ne!==I)return;x="",u.hidden=!0,n.hidden=!1,w.disabled=!1,n.title="Channel list unavailable. Enter channel IDs to continue."}};return n.addEventListener("blur",()=>{n.value.trim()||(g="",x="",L())}),f.addEventListener("input",()=>{++I,clearTimeout(p),A(),p=setTimeout(()=>{L()},400)}),f.addEventListener("change",()=>{clearTimeout(p),L()}),{refresh:L}}),i=()=>Promise.all(a.map(s=>s.refresh()));return o.addEventListener("change",()=>{i()}),{refresh:i}}var Pe=null;function be(){return Pe?.isConnected||(Pe=document.createElement("iframe"),Pe.style.display="none",Pe.setAttribute("aria-hidden","true"),document.body.appendChild(Pe)),Pe.contentWindow.localStorage}function $n(){try{let e=be().getItem("UserAffinitiesStoreV2"),t=JSON.parse(e||"{}")?._state?.userAffinities;return Array.isArray(t)?t.filter(Boolean):[]}catch{return[]}}function Ar(){try{let e=null;return window.webpackChunkdiscord_app?.push?.([[Math.random()],{},t=>{for(let r in t.c)try{let o=t.c[r]?.exports,a=[o,o?.default];o&&typeof o=="object"&&a.push(...Object.values(o));for(let i of a)if(i&&typeof i.getUser=="function"&&typeof i.getUsers=="function"){e=i;return}}catch{}}]),e}catch{return null}}var Ee=Ar(),ft=e=>e?.id&&e?.avatar?`https://cdn.discordapp.com/avatars/${e.id}/${e.avatar}.${String(e.avatar).startsWith("a_")?"gif":"png"}?size=64`:"",ht=e=>ft(e)||(/^\d+$/.test(e?.id||"")?`https://cdn.discordapp.com/embed/avatars/${e.discriminator&&e.discriminator!=="0"?Number(e.discriminator)%5:Number((BigInt(e.id)>>22n)%6n)}.png`:""),gt=e=>e?.id&&e?.icon?`https://cdn.discordapp.com/icons/${e.id}/${e.icon}.${String(e.icon).startsWith("a_")?"gif":"png"}?size=64`:"",Ce=e=>e?.global_name||e?.globalName||e?.username||"",Jt=e=>e?.username?e.discriminator&&e.discriminator!=="0"?`${e.username}#${e.discriminator}`:`@${e.username}`:"";var nt=e=>String(e||"").replace(/^Discord\s*[|—–-]\s*/i,"").replace(/\s*\|\s*Discord.*$/i,"").trim();function En(){let e=pt({limit:32}),t=(o,a,i,s)=>e.get(`${i}/${o}/${a}`,async()=>{let d=await W(`${B}/${o}/${a}`,{headers:{Authorization:i}},s);if(!d.ok)throw new Error(`Could not access ${o==="guilds"?"server":"conversation"} (${d.status}).`);let c=await d.json();if(c.id!==a)throw new Error("Discord returned invalid identity metadata.");return c});return{resolve:async(o,a,i=()=>{})=>{let s={...o,label:nt(o.label)};if(!a||o.kind==="ids")return s;try{let d=o.guildId!=="@me",c=await t(d?"guilds":"channels",d?o.guildId:o.channelId,a,i);if(d)s.icon=gt(c),o.kind==="server"&&(s.label=c.name||s.label),s.iconName=c.name||s.label;else{let f=c.recipients?.[0];!f&&c.recipient_ids?.[0]&&(f=Ee?.getUser?.(c.recipient_ids[0])),f?.id&&(s.label=Ce(f)||s.label,s.icon=ht(f),s.iconName=s.label)}}catch{}return s},readGuild:(o,a,i=()=>{})=>t("guilds",o,a,i)}}async function Ue({token:e,channelId:t,log:r,stopCheck:o=()=>!1}){if(!/^\d{15,22}$/.test(t))throw new Error("Enter a valid conversation ID.");let a=await W(`${B}/channels/${t}`,{headers:{Authorization:e}},r,o);if(o())throw new V;if(!a.ok)throw new Error(`Could not detect conversation type (${a.status}). Check your access.`);let i=await a.json();if(i.id!==t)throw new Error("Discord returned an invalid conversation.");let s=[1,3].includes(i.type)?"dm":[0,5].includes(i.type)?"channel":[15,16].includes(i.type)?"forums":[10,11,12].includes(i.type)?"thread":null;if(!s)throw new Error("This conversation type does not support message cleanup.");let d=s==="dm"?"@me":i.guild_id;if(!d&&s==="thread"&&i.parent_id){let c=await W(`${B}/channels/${i.parent_id}`,{headers:{Authorization:e}},r,o);if(o())throw new V;if(!c.ok)throw new Error(`Could not resolve thread server (${c.status}).`);let f=await c.json();if(f.id!==i.parent_id)throw new Error("Discord returned an invalid thread parent.");d=f.guild_id}if(s!=="dm"&&!/^\d{15,22}$/.test(d||""))throw new Error("Discord did not provide a valid server ID.");return{mode:s,guildId:d,channelId:t,name:i.name||t}}function Cn(e=Ue,t=Date.now){let r=null,o=null,a=(i,s)=>i?.token===s.token&&i?.channelId===s.channelId;return async i=>{if(a(r,i)&&t()-r.at<3e4)return r.target;if(a(o,i))return o.promise;let s={token:i.token,channelId:i.channelId};return o=s,s.promise=Promise.resolve().then(()=>e(i)).then(d=>(o===s&&(r={token:s.token,channelId:s.channelId,target:d,at:t()}),d)).finally(()=>{o===s&&(o=null)}),s.promise}}function xe(e,t="IDs",{required:r=!1}={}){let o=[...new Set(String(e||"").split(",").map(a=>a.trim()).filter(Boolean))];if(r&&!o.length)throw new Error(`Enter at least one ${t}.`);if(o.some(a=>!/^\d{15,22}$/.test(a)))throw new Error(`${t} must be Discord IDs separated by commas.`);return o}function Mn(e){let t=()=>{},r=new Intl.DateTimeFormat(void 0,{dateStyle:"medium",timeStyle:"short"}),o=new Intl.DateTimeFormat(void 0,{month:"long",year:"numeric"}),a=new Intl.DateTimeFormat(void 0,{dateStyle:"full"});e.querySelectorAll("input[type=datetime-local]").forEach(i=>{let s=i.parentElement.querySelector("label");s.htmlFor=i.id,s.id=`${i.id}-label`;let d=document.createElement("button");d.type="button",d.className="dmd-select-trigger",d.setAttribute("aria-haspopup","dialog"),d.setAttribute("aria-expanded","false"),d.setAttribute("aria-labelledby",`${s.id} ${i.id}-display`);let c=document.createElement("span");c.id=`${i.id}-display`,d.append(c),i.classList.add("dmd-native-select"),i.tabIndex=-1,i.after(d);let f=()=>{c.textContent=i.value?r.format(new Date(i.value)):"Choose date & time",d.disabled=i.disabled};i.addEventListener("change",f),f();let n,u,w,b,h,S,k,M,T,l=[],y=[],I=(E,A)=>{let v=document.createElement("button");return v.type="button",v.textContent=E,v.onclick=A,v},p=()=>{let E=`${w.getFullYear()}-${w.getMonth()}`;if(E!==M){M=E,b.textContent=o.format(w);let N=(w.getDay()+6)%7;for(let _=0;_N.getTime()===A)];T!==v&&(T?.classList.remove("selected"),v?.classList.add("selected"),T=v)},g=()=>{n?.remove(),d.setAttribute("aria-expanded","false"),document.removeEventListener("pointerdown",x,!0),e.removeEventListener("scroll",g,!0),window.removeEventListener("resize",g),t===g&&(t=()=>{})},x=E=>{!n.contains(E.target)&&!d.contains(E.target)&&g()},$=()=>{n=document.createElement("div"),n.className="dmd-calendar",n.setAttribute("role","dialog");let E=document.createElement("div");E.className="dmd-calendar-header",b=document.createElement("span");let A=document.createElement("div");A.className="dmd-calendar-grid";for(let P of["Mo","Tu","We","Th","Fr","Sa","Su"]){let K=document.createElement("span");K.textContent=P,A.append(K)}for(let P=0;P<42;P++){let K=I("",()=>{u=y[P],p()});l.push(K),A.append(K)}h=document.createElement("input"),h.type="number",h.min="0",h.max="23",h.setAttribute("aria-label","Hour"),S=document.createElement("input"),S.type="number",S.min="0",S.max="59",S.setAttribute("aria-label","Minute");let v=I("\u2039",()=>{w.setMonth(w.getMonth()-1),p()});v.setAttribute("aria-label","Previous month");let N=I("\u203A",()=>{w.setMonth(w.getMonth()+1),p()});N.setAttribute("aria-label","Next month"),E.append(v,b,N);let _=document.createElement("div");_.className="dmd-calendar-time";let U=document.createElement("span");U.textContent="Time",_.append(U,h,document.createTextNode(":"),S);let H=document.createElement("div");H.className="dmd-calendar-footer";let L=I("Clear",()=>{i.value="",i.dispatchEvent(new Event("change",{bubbles:!0})),g(),d.focus()}),z=I("Cancel",()=>{g(),d.focus()});k=I("Apply",()=>{if(!h.value||!S.value||!h.checkValidity()||!S.checkValidity()){(!h.checkValidity()||!h.value?h:S).focus();return}let P=K=>String(K).padStart(2,"0");i.value=`${u.getFullYear()}-${P(u.getMonth()+1)}-${P(u.getDate())}T${P(h.value)}:${P(S.value)}`,i.dispatchEvent(new Event("change",{bubbles:!0})),g(),d.focus()}),H.append(L,z,k),n.append(E,A,_,H),n.onkeydown=P=>{P.key==="Escape"&&(P.preventDefault(),g(),d.focus())}};d.onclick=()=>{if(t(),f(),i.disabled)return;u=i.value?new Date(i.value):new Date,w=new Date(u.getFullYear(),u.getMonth(),1),n||$(),n.setAttribute("aria-label",s.textContent),h.value=String(u.getHours()),S.value=String(u.getMinutes()),p(),document.body.append(n);let E=d.getBoundingClientRect();n.style.left=`${Math.max(8,Math.min(E.left,window.innerWidth-298))}px`,n.style.top=`${Math.max(8,Math.min(E.bottom+5,window.innerHeight-n.offsetHeight-8))}px`,d.setAttribute("aria-expanded","true"),document.addEventListener("pointerdown",x,!0),e.addEventListener("scroll",g,!0),window.addEventListener("resize",g),t=g,k.focus()}})}function An(e){let t=()=>{};e.querySelectorAll("select:not([multiple])").forEach((r,o)=>{let a=document.createElement("button");a.type="button",a.className="dmd-select-trigger",a.setAttribute("aria-haspopup","listbox"),a.setAttribute("aria-expanded","false");let i=e.querySelector(`label[for="${r.id}"]`);i?(i.id||=`dmd-select-label-${o}`,a.setAttribute("aria-labelledby",`${i.id} ${r.id}-value`)):a.setAttribute("aria-label",r.getAttribute("aria-label")||"Choose option");let s=document.createElement("span");s.id=`${r.id}-value`,a.appendChild(s),r.classList.add("dmd-native-select"),r.tabIndex=-1,r.insertAdjacentElement("afterend",a);let d=()=>{s.textContent=r.selectedOptions[0]?.textContent||"",a.disabled=r.disabled};r.addEventListener("change",d),d();let c,f=[],n=[],u,w=()=>{c?.remove(),a.setAttribute("aria-expanded","false"),document.removeEventListener("pointerdown",b,!0),window.removeEventListener("resize",w),e.removeEventListener("scroll",w,!0),t===w&&(t=()=>{})},b=k=>{!c.contains(k.target)&&!a.contains(k.target)&&w()},h=()=>f[u]?.focus(),S=()=>{if(t(),d(),r.disabled)return;for(c||(c=document.createElement("div"),c.className="dmd-select-menu",c.id=`${r.id}-menu`,c.setAttribute("role","listbox"),a.setAttribute("aria-controls",c.id),c.onkeydown=l=>{if(l.key==="Escape"||l.key==="Tab"){w(),a.focus(),l.key==="Escape"&&l.preventDefault();return}if(!["ArrowDown","ArrowUp","Home","End"].includes(l.key)||!n.length)return;l.preventDefault();let y=n.indexOf(u);u=l.key==="Home"?n[0]:l.key==="End"?n.at(-1):n[(y+(l.key==="ArrowDown"?1:-1)+n.length)%n.length],h()}),c.setAttribute("aria-label",i?.textContent||r.getAttribute("aria-label")||"Options");f.length>r.options.length;)f.pop().remove();for(;f.length{r.value=r.options[l].value,r.dispatchEvent(new Event("change",{bubbles:!0})),w(),a.focus()},f.push(y),c.appendChild(y)}n=[];for(let l=0;l=140?(c.style.top=`${k.bottom+5}px`,c.style.maxHeight=`${Math.min(280,T)}px`):(c.style.bottom=`${window.innerHeight-k.top+5}px`,c.style.maxHeight=`${Math.max(60,Math.min(280,k.top-12))}px`),u=n.includes(r.selectedIndex)?r.selectedIndex:n[0],a.setAttribute("aria-expanded","true"),h(),document.addEventListener("pointerdown",b,!0),window.addEventListener("resize",w),e.addEventListener("scroll",w,!0),t=w};a.onclick=S,a.onkeydown=k=>{["ArrowDown","ArrowUp"].includes(k.key)&&(k.preventDefault(),S())}})}var Dn=new WeakMap;function bt(e,{author:t="#dmd-author",guild:r="#dmd-guild",userBadge:o="#dmd-user-avatar",guildBadge:a="#dmd-guild-avatar",cache:i=new Map}={}){let s=Dn.get(i)||new Map;Dn.set(i,s);let d=(k,M)=>{for(i.delete(k),i.set(k,M);i.size>128;)i.delete(i.keys().next().value)},c={user:t?e(t):null,guild:r?e(r):null},f={user:o?e(o):null,guild:a?e(a):null},n=e("#dmd-token"),u=k=>f[k],w={user:0,guild:0},b={},h=(k,M,T)=>{let l=u(k);l.textContent="",l.hidden=!1;let y=k==="user"?Ce(T):T.name,I=k==="user"?ht(T):gt(T);if(l.title=y||M,l.setAttribute("aria-label",y||M),l.textContent=(y||"?").slice(0,1).toUpperCase(),I){let p=document.createElement("img");p.src=I,p.alt=y||(k==="user"?"User avatar":"Server icon"),p.onerror=()=>p.remove(),l.appendChild(p)}},S=async k=>{let M=c[k];if(!M)return;let T=M.value.split(",")[0].trim(),l=++w[k],y=u(k);if(y.hidden=!0,y.textContent="",k==="guild"&&T==="@me"){y.hidden=!1,y.textContent="@",y.title="Direct messages",y.setAttribute("aria-label","Direct messages");return}if(!/^\d{15,22}$/.test(T))return;let I=i.get(`${k}:${T}`);if(!I&&k==="user")try{I=Ee?.getUser?.(T)}catch{}if(!I){let p=n.value.trim();if(!p)return;let g=`${p}/${k}:${T}`,x=()=>w[k]===l&&M.value.split(",")[0].trim()===T&&n.value.trim()===p,$=s.get(g);$||($={readers:new Set},$.promise=Promise.resolve().then(async()=>{let E=()=>{for(let v of $.readers)if(v())return!1;return!0},A=await W(`${B}/${k==="user"?"users":"guilds"}/${T}`,{headers:{Authorization:p}},()=>{},E);return A.ok?A.json():null}).finally(()=>{s.get(g)===$&&s.delete(g)}),s.set(g,$)),$.readers.add(x);try{I=await $.promise}catch{return}finally{$.readers.delete(x)}}I?.id!==T||w[k]!==l||M.value.split(",")[0].trim()!==T||(d(`${k}:${T}`,I),h(k,T,I))};for(let k of["user","guild"]){let M=c[k];M&&(M.addEventListener("input",()=>{++w[k],u(k).hidden=!0,clearTimeout(b[k]),b[k]=setTimeout(()=>{S(k)},400)}),M.addEventListener("change",()=>{clearTimeout(b[k]),S(k)}))}return n.addEventListener("change",()=>{S("user"),S("guild")}),{refresh:S,seedGuild:k=>{k?.id&&d(`guild:${k.id}`,k)},seedUser:k=>{k?.id&&d(`user:${k.id}`,k)}}}function xt(e){let t=document.createElement("style");return t.textContent=e,document.head.appendChild(t),t}var Gt="del_discord_v1_history",wt="del_discord_v1_history_state";function Tn(){try{let e=JSON.parse(be().getItem(Gt)||"{}");return e&&typeof e=="object"?e:{}}catch{return{}}}function ue(e){try{be().setItem(Gt,JSON.stringify(e))}catch(t){console.warn("[DM History] Save failed",t)}}function Nn(){try{let e=JSON.parse(be().getItem(wt)||"{}");return e&&typeof e=="object"?e:{}}catch{return{}}}function Ln(e){try{let t=be(),r=JSON.stringify(e);t.getItem(wt)!==r&&t.setItem(wt,r)}catch(t){console.warn("[DM History] State save failed",t)}}var Qt=null;function _n(e=!1){if(Qt&&!e)return Qt;let t=[],r=new Set;try{let o=window.webpackChunkdiscord_app;if(o?.push){let a=[];o.push([[Math.random()],{},i=>{for(let s in i.c)a.push(i.c[s])}]);for(let i of a)try{let s=i?.exports,d=[s,s?.default];s&&typeof s=="object"&&d.push(...Object.values(s));for(let c of d){let f=c?.getToken;typeof f=="function"&&!r.has(f)&&(r.add(f),t.push(()=>f.call(c)))}}catch{}}}catch{}return Qt=t,t}function vt(){let e=[],t=new Set,r=a=>{typeof a=="string"&&a.length>20&&!t.has(a)&&(t.add(a),e.push(a))};try{let a=be();a.token&&r(JSON.parse(a.token))}catch{}let o=_n();for(let a of o)try{r(a())}catch{}if(!e.length&&o.length){o=_n(!0);for(let a of o)try{r(a())}catch{}}return e}var rt=null;function Dr(){rt=null}async function at(){let e=Date.now();if(rt&&e-rt.at<6e4)return rt.identity;for(let t of vt())try{let r=await W(`${B}/users/@me`,{headers:{Authorization:t}});if(r.ok){let o={token:t,user:await r.json()};return rt={at:e,identity:o},o}}catch{}return Dr(),null}var Rn=new WeakMap,Fn=new WeakMap,Vt=e=>Fn.get(e)||0,zn=e=>Fn.set(e,Vt(e)+1);function Pn(e){let t=Rn.get(e);if(t)return t;t={byUser:new Map,byChannel:new Map};for(let r of Object.keys(e)){let o=e[r];o?.userId&&t.byUser.set(o.userId,r),o?.channelId&&t.byChannel.set(o.channelId,r)}return Rn.set(e,t),t}function Oe(e,t){let r=Pn(e),o=t.userId?r.byUser.get(t.userId):"",a=t.channelId?r.byChannel.get(t.channelId):"",i=o||a||"",s=t.userId?`u:${t.userId}`:t.channelId?`c:${t.channelId}`:"";if(!s)return;let d=i?e[i]||{}:e[s]||{},c={userId:t.userId||d.userId||"",channelId:t.channelId||d.channelId||"",name:t.name||d.name||"",username:t.username||d.username||"",avatar:t.avatar||d.avatar||"",dmRank:Number.isFinite(t.dmRank)?t.dmRank:Number.isFinite(d.dmRank)?d.dmRank:null,sources:[...new Set([...d.sources||[],...t.sources||[]])],seenAt:t.seenAt||d.seenAt||new Date().toISOString(),sentCount:Math.max(Number.isFinite(d.sentCount)?d.sentCount:0,Number.isFinite(t.sentCount)?t.sentCount:0),sentCountExact:t.sentCountExact===!0||d.sentCountExact===!0,verifiedSent:t.verifiedSent===!0||d.verifiedSent===!0};return i&&i!==s&&delete e[i],d.userId&&d.userId!==c.userId&&r.byUser.get(d.userId)===i&&r.byUser.delete(d.userId),d.channelId&&d.channelId!==c.channelId&&r.byChannel.get(d.channelId)===i&&r.byChannel.delete(d.channelId),e[s]=c,c.userId&&r.byUser.set(c.userId,s),c.channelId&&r.byChannel.set(c.channelId,s),zn(e),c}function kt(e,t,r){let o=Pn(e),a=new Set;if(t){let i=o.byUser.get(t);i&&a.add(i)}if(r){let i=o.byChannel.get(r);i&&a.add(i)}for(let i of a){let s=e[i];s&&(s.userId&&o.byUser.delete(s.userId),s.channelId&&o.byChannel.delete(s.channelId),delete e[i],zn(e))}}function yt(e,t=[]){if(typeof e=="string"&&/^\d{15,22}$/.test(e))t.push(e);else if(Array.isArray(e))e.forEach(r=>yt(r,t));else if(e&&typeof e=="object")for(let[r,o]of Object.entries(e))/recipient|user|member|participant/i.test(r)&&yt(o,t);return t}function Un(){let e,t=-1,r=[],o=[],a=null,i=new WeakMap;return(s,d="")=>{let c=Vt(s);if(s!==e||c!==t){r=[];for(let n of Object.keys(s)){let u=s[n];u?.verifiedSent===!0&&Number(u.sentCount||0)>0&&r.push(u)}r.sort((n,u)=>{let w=Number.isFinite(n.dmRank)?n.dmRank:1e12,b=Number.isFinite(u.dmRank)?u.dmRank:1e12;return w-b||(n.name||n.username||n.userId||n.channelId).localeCompare(u.name||u.username||u.userId||u.channelId)}),e=s,t=c,a=null}let f=d.trim().toLowerCase();return f!==a&&(o=f?r.filter(n=>{let u=i.get(n);return u===void 0&&(u=[n.name,n.username,n.userId,n.channelId].join(" ").toLowerCase(),i.set(n,u)),u.includes(f)}):r,a=f),o}}async function Tr(e,t,r){for(;;){let o=new URLSearchParams({author_id:t,sort_by:"timestamp",sort_order:"desc",offset:"0"}),a=await W(`${B}/channels/${r}/messages/search?${o.toString()}`,{headers:{Authorization:e}});if(a.status===202){let s={};try{s=await a.json()}catch{}await Q(Re(s.retry_after??1)+250);continue}if(!a.ok)return null;let i=await a.json();return Number(i.total_results||0)}}async function Nr(e,t){try{let r=await W(`${B}/channels/${t}`,{method:"DELETE",headers:{Authorization:e}});if(!r.ok&&r.status!==404)throw new Error(`HTTP ${r.status}`)}catch(r){throw new Error(`Could not close the temporarily opened DM. It may remain open in your DM list. ${r.message||r}`,{cause:r})}}async function On(e,t,r,o,a){let i=o||"",s=!1;try{if(!i){let c=await W(`${B}/users/@me/channels`,{method:"POST",headers:{Authorization:e,"Content-Type":"application/json"},body:JSON.stringify({recipient_id:r})});if(!c.ok)return null;i=(await c.json())?.id||"",s=!!i&&!a}return i?{count:await Tr(e,t,i),channelId:i}:null}finally{s&&i&&await Nr(e,i)}}async function jn(e){let t=!1,r=!1,o=0,a=i=>{for(let s=0;s32&&d!==65279&&(r=!0)}};if(typeof e.stream=="function"){let i=e.stream().getReader(),s=new TextDecoder;try{for(;;){let{value:d,done:c}=await i.read();if(c)break;for(let f=0;f{let t=JSON.parse(e||"[]");return Array.isArray(t)?t.length:Array.isArray(t?.messages)?t.messages.length:t&&typeof t=="object"&&Object.keys(t).length?1:0};async function Bn(e){if(Number.isFinite(e.size)&&e.size<=65536&&typeof e.text=="function")return qn(await e.text());let t;if(typeof e.stream!="function"&&(t=await e.text()||"[]",t.length<=65536))return qn(t);let r=[],o=[],a=/["\\\x00-\x1f]/g,i="value",s="",d=0,c=0,f=null,n="",u="",w=0,b=!1,h=0,S=0,k=!1,M=!1,T=!0,l=0,y=()=>{throw new SyntaxError("Invalid message JSON.")},I=()=>r[r.length-1],p=E=>{let A=I();A?A.type==="array"?(A.state!=="first"&&A.state!=="value"&&y(),A.state="comma",A.count++):(A.state!=="value"&&y(),A.state="comma",r.length===1&&A.messagesKey&&(f=null)):(i!=="value"&&y(),i="end",s=E)},g=E=>{M&&(E!=="messages".charCodeAt(l)&&(T=!1),l++)},x=E=>{let A=E>=48&&E<=57;switch(n){case"minus":E===48?n="zero":A?n="integer":y();break;case"zero":if(E===46)n="dot";else if(E===69||E===101)n="exponent";else return!1;break;case"integer":if(A)break;if(E===46)n="dot";else if(E===69||E===101)n="exponent";else return!1;break;case"dot":A||y(),n="fraction";break;case"fraction":if(A)break;if(E===69||E===101)n="exponent";else return!1;break;case"exponent":E===43||E===45?n="exponentSign":A?n="exponentDigits":y();break;case"exponentSign":A||y(),n="exponentDigits";break;case"exponentDigits":if(!A)return!1;break}return!0},$=E=>{E.length&&(k=!0);for(let A=0;A1)&&!b&&!h){if(a.lastIndex=A,!a.test(E))break;A=a.lastIndex-1,v=E.charCodeAt(A)}if(h){let _=v>=48&&v<=57?v-48:v>=65&&v<=70?v-55:v>=97&&v<=102?v-87:-1;_<0&&y(),S=S*16+_,--h===0&&g(S)}else if(b)b=!1,v===117?(h=4,S=0):([34,92,47,98,102,110,114,116].includes(v)||y(),g(v===98?8:v===102?12:v===110?10:v===114?13:v===116?9:v));else if(v===92)b=!0;else if(v===34){if(n="",M){let _=I();_.state="colon",_.messagesKey=T&&l===8,r.length===1&&d++}M=!1}else v<32&&y(),g(v);continue}if(n==="literal"){v!==u.charCodeAt(w++)&&y(),w===u.length&&(n="");continue}if(n){if(x(v))continue;n=""}if(v===32||v===9||v===10||v===13)continue;let N=I();if(v===34)M=!!N&&N.type==="object"&&(N.state==="first"||N.state==="key"),M?(T=!0,l=0):p("string"),n="string";else if(v===91||v===123){let _=v===91?"array":"object",U=r.length===1&&N.type==="object"&&N.messagesKey&&_==="array";p(_);let H=o[r.length]||(o[r.length]={});H.type=_,H.state="first",H.count=0,H.messages=U,H.messagesKey=!1,r.push(H)}else v===93||v===125?((!N||N.type!==(v===93?"array":"object")||N.state!=="first"&&N.state!=="comma")&&y(),r.pop(),N.messages&&(f=N.count),!r.length&&N.type==="array"&&(c=N.count)):v===44?((!N||N.state!=="comma")&&y(),N.state=N.type==="array"?"value":"key"):v===58?((!N||N.type!=="object"||N.state!=="colon")&&y(),N.state="value"):v===116||v===102||v===110?(p("literal"),n="literal",u=v===116?"true":v===102?"false":"null",w=1):v===45||v>=48&&v<=57?(p("number"),n=v===45?"minus":v===48?"zero":"integer"):y()}};if(typeof e.stream=="function"){let E=e.stream().getReader(),A=new TextDecoder("utf-8",{fatal:!0}),v=!1;try{for(;;){let{value:N,done:_}=await E.read();if(_)break;for(let U=0;Ur()===d,f=[...e||[]];if(f.length){t("#dmh-import").disabled=!0,t("#dmh-summary").textContent=`Indexing ${f.length} package files locally\u2026`;try{let n=await o();if(!c())return;let u=n?.user?.id||"",w=p=>String(p.webkitRelativePath||p.name).replace(/\\/g,"/"),b=p=>p.slice(0,p.lastIndexOf("/")).toLowerCase(),h=new Map,S=[],k=new Map;for(let p of f){if(!c())return;let g=w(p),x=g.toLowerCase();if(`/${x}`.endsWith("/messages/index.json")){try{let $=JSON.parse(await p.text());if(!c())return;for(let[E,A]of Object.entries($||{}))typeof A=="string"&&h.set(String(E),A)}catch{}continue}if(`/${x}`.endsWith("/channel.json")){S.push({file:p,path:g});continue}if(/\/messages(?:\.csv|\.json)$/.test("/"+x)){let $=b(g),E=k.get($);E?E.push({file:p,lower:x}):k.set($,[{file:p,lower:x}])}}let M=new Map,T=async p=>{let g=p.toLowerCase();if(M.has(g))return M.get(g);let x=k.get(g)||[],$=0;for(let{file:E,lower:A}of x)try{A.endsWith(".json")?$+=await Bn(E):$+=await jn(E)}catch{return M.set(g,null),null}return M.set(g,$),$},l=0,y=0,I=Date.now();for(let{file:p,path:g}of S){if(!c()||(y++,y%25===0&&(t("#dmh-summary").textContent=`Processing package conversations ${y}/${S.length}\u2026`,await new Promise(requestAnimationFrame),!c())))return;let x;try{x=JSON.parse(await p.text())}catch{continue}if(!c())return;let $=g.slice(0,g.lastIndexOf("/")),E=String(x?.id||$.match(/(?:^|\/)c?(\d{15,22})$/)?.[1]||""),A=h.get(E)||"",v=String(x?.type??"").toUpperCase();if(!(v==="DM"||v==="1"||/^Direct Message with /i.test(A))||(v==="GROUP_DM"||v==="3"))continue;let U=await T($);if(!c())return;if(U===null)continue;if(U<=0){kt(d,"",E);continue}let H=[...new Set(yt(x))].filter(L=>L!==u&&L!==E);Oe(d,{userId:H[0]||"",channelId:E,name:A.replace(/^Direct Message with\s+/i,"").trim(),sources:["package"],sentCount:U,sentCountExact:!1,verifiedSent:!0,seenAt:new Date().toISOString()}),l++,Date.now()-I>=2e3&&(ue(d),I=Date.now())}if(!c())return;ue(d),a({importedAt:new Date().toISOString(),importedCount:l,fileCount:f.length}),i(),s(),t("#dmh-summary").textContent=`Imported/updated ${l} DM conversations. Parsed package results are cached locally.`}finally{t("#dmh-import").disabled=!1,t("#dmh-folder").value=""}}}function se({title:e="Confirm",message:t="",details:r="",yesText:o="Yes",noText:a="No",danger:i=!1,render:s,getResult:d,onNo:c,signal:f}={}){return f?.aborted?Promise.resolve(!1):new Promise(n=>{let u=document.createElement("div");u.className="dmd-confirm-overlay";let w=document.createElement("div");w.className="dmd-confirm-box";let b=document.createElement("div");b.className="dmd-confirm-title",b.textContent=e;let h=document.createElement("div");if(h.className="dmd-confirm-message",h.textContent=t,w.append(b,h),r){let x=document.createElement("pre");x.className="dmd-confirm-details",x.textContent=r,w.appendChild(x)}let S=document.createElement("div");S.className="dmd-confirm-actions";let k=document.createElement("button");k.className="dmd-btn",k.textContent=a;let M=document.createElement("button");M.className=`dmd-btn ${i?"dmd-red":"dmd-green"}`,M.textContent=o,s?.({box:w,yes:M}),S.append(k,M),w.appendChild(S),u.appendChild(w);let T=document.activeElement,l=document.getElementById("dmd-panel")||document.body;w.setAttribute("role","dialog"),w.setAttribute("aria-modal","true"),b.id="dmd-confirm-heading",w.setAttribute("aria-labelledby",b.id),l.appendChild(u);let y=!1,I=x=>{y||(y=!0,document.removeEventListener("keydown",p,!0),f?.removeEventListener("abort",g),u.remove(),T?.isConnected&&T.focus(),n(x&&d?d():x))},p=x=>{if(x.key!=="Tab"&&x.key!=="Escape"&&!(x.key==="Enter"&&(x.metaKey||x.ctrlKey)))return;let $=document.querySelectorAll(".dmd-confirm-overlay");if($[$.length-1]===u){if(x.preventDefault(),x.stopImmediatePropagation(),x.key==="Tab"){let E=[...w.querySelectorAll('button,input,select,[tabindex="0"]')].filter(v=>!v.disabled),A=E.indexOf(document.activeElement);E[(A+(x.shiftKey?-1:1)+E.length)%E.length]?.focus()}x.key==="Escape"&&I(!1),x.key==="Enter"&&(x.metaKey||x.ctrlKey)&&!M.disabled&&I(!0)}},g=()=>I(!1);f?.addEventListener("abort",g,{once:!0}),k.onclick=()=>{c?.(),I(!1)},M.onclick=()=>{M.disabled||I(!0)},u.onclick=x=>{x.target===u&&I(!1)},document.addEventListener("keydown",p,!0),M.focus()})}var Wn=`
@@ -52,28 +52,28 @@ SOFTWARE.
-`;var kn=`#dmd-panel #dmh-panel{flex:1;min-height:0;flex-direction:column;gap:14px;overflow:hidden}#dmh-tools{display:flex;gap:8px;flex-wrap:wrap}#dmh-search{flex:1 0 100%;height:38px;background:#3a3a39;color:#eeeeea;border:1px solid #50504d;border-radius:4px;padding:8px 11px}.dmh-button{border:1px solid #545450;border-radius:4px;padding:9px 12px;background:#414140;color:#eeeeea;font:600 12px/1.4 Arial,sans-serif;cursor:pointer}.dmh-button:hover:not(:disabled){filter:brightness(1.15)}.dmh-button:disabled{opacity:.4;cursor:default}.dmh-button.green,.dmh-button.blue{background:#eeeeea;border-color:#eeeeea}.dmh-button.red{background:#3b2524;border-color:#60302c;color:#e99792}#dmh-summary{color:#a0a09a;font-size:12px}#dmh-list{flex:1;min-height:0;overflow:auto;background:#343433;border:1px solid #ffffff13;border-radius:4px;padding:4px}.dmh-row{display:flex;align-items:center;gap:12px;padding:12px;border-bottom:1px solid #ffffff13;min-height:68px}.dmh-row:last-child{border-bottom:0}.dmh-av,.dmh-fallback{width:38px;height:38px;border-radius:50%;flex:0 0 38px}.dmh-av{object-fit:cover}.dmh-fallback{display:flex;align-items:center;justify-content:center;background:#eeeeea;font-weight:700}.dmh-main{flex:1;min-width:0}.dmh-name{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmh-sub{font-size:11px;color:#a0a09a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}.dmh-badges{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}.dmh-badge{font-size:10px;padding:2px 6px;border-radius:4px;background:#2a2a28;color:#aaa9a5}#dmh-footer{border-top:1px solid #ffffff13;padding-top:14px;display:flex;gap:8px;align-items:center;justify-content:center;flex-wrap:wrap;font-size:12px}#dmh-page{color:#aaa9a5}#dmh-page-input{width:56px;height:34px;background:#3a3a39;color:#eeeeea;border:1px solid #50504d;border-radius:4px;padding:4px;text-align:center}#dmh-folder{display:none}#dmh-panel .dmd-view-title{margin-bottom:5px}#dmh-search{font:inherit}#dmh-list{min-height:100px}.dmh-row:hover{background:#ffffff04}.dmh-button{font-weight:500;font-family:inherit}.dmh-confirm-box{font:14px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.dmh-button.green,.dmh-button.blue{color:#2c2c2b}#dmh-summary:empty{display:none}.dmh-select{width:15px;height:15px;flex:none;margin:0;accent-color:#8b85ed;cursor:pointer} -`;function In(e,{deleteConversations:t=async()=>[]}={}){if(document.getElementById("dmh-panel"))return;dt(kn);let r=document.createElement("div");r.id="dmh-panel",r.className="dmd-view",r.style.display="none",r.innerHTML=yn,e.insertBefore(r,e.querySelector("#dmd-progress-dock"));let a=new Map,o=x=>{if(a.has(x))return a.get(x);let N=r.querySelector(x);return a.set(x,N),N},d=dn(),i=new Set,s=!1,m=x=>x.channelId||x.userId,p=()=>{o("#dmh-delete-all").hidden=i.size===0,o("#dmh-delete-all").textContent=`Delete All (${i.size})`},n=sn(),u=Number.isInteger(n.page)?Math.max(0,n.page):0,y=typeof n.query=="string"?n.query:"",b=n.packageInfo&&typeof n.packageInfo=="object"?n.packageInfo:null,f=null,$=[],g=!1,T=!1,L=!1,l=new WeakMap,h=hn();o("#dmh-search").value=y;let w=()=>ln({page:u,query:y,packageInfo:b}),S=x=>({open:"Open DM",affinity:"Closed/cache",package:"Data Package"})[x]||x;function v(){return h(d,y)}async function E(){if(g){T=!0;return}g=!0,T=!1;let x=d,N=$;try{let _=!1;for(let j of N){if(d!==x)break;if(!$.includes(j)||!j.userId||j.name&&j.username)continue;let q=l.get(j);if(q!==void 0&&Date.now()-q<6e4)continue;l.set(j,Date.now());let C=null,R=!1;try{C=Se?.getUser?.(j.userId)||null}catch{}if(!C){if(f||(f=await Qe()),!f||d!==x)break;R=!0;try{let z=await J(`${U}/users/${j.userId}`,{headers:{Authorization:f.token}});z.ok&&(C=await z.json())}catch{}}if(d!==x)break;if(C){let z=Ne(d,{userId:j.userId,name:$e(C),username:$t(C),avatar:nt(C),seenAt:new Date().toISOString()});l.set(z,Date.now()),_=!0}R&&await G(50)}_&&d===x&&(pe(d),I(!1))}finally{g=!1,T&&E()}}function I(x=!0){let N=v(),_=Math.max(1,Math.ceil(N.length/10));u=Math.max(0,Math.min(u,_-1)),w(),$=N.slice(u*10,u*10+10);let j=o("#dmh-list");j.textContent="";let q=document.createDocumentFragment();if(!$.length){let C=document.createElement("div");C.style.cssText="padding:28px;text-align:center;color:#949ba4",C.textContent=y?"No matches.":"No verified sent-DM history yet. Press Refresh & Verify or import your Discord Data Package.",q.appendChild(C)}for(let C of $){let R=document.createElement("div");R.className="dmh-row";let z;C.avatar?(z=document.createElement("img"),z.className="dmh-av",z.src=C.avatar,z.alt=""):(z=document.createElement("div"),z.className="dmh-fallback",z.textContent=(C.name||C.username||"?").charAt(0).toUpperCase());let W=document.createElement("div");W.className="dmh-main";let K=document.createElement("div");K.className="dmh-name",K.textContent=C.name||C.username||(C.userId?`User ${C.userId}`:`DM Channel ${C.channelId}`);let Q=document.createElement("div");Q.className="dmh-sub",Q.textContent=[C.username&&C.username!==C.name?C.username:"",C.userId?`User ID: ${C.userId}`:"",C.channelId?`Channel: ${C.channelId}`:""].filter(Boolean).join(" \u2022 ");let he=document.createElement("div");he.className="dmh-badges";for(let te of C.sources||[]){let de=document.createElement("span");de.className="dmh-badge",de.textContent=S(te),he.appendChild(de)}let ue=document.createElement("span");ue.className="dmh-badge",ue.textContent=C.cleanupFinishedAt?"Cleanup finished \xB7 refresh to verify":C.sentCountExact?`You sent ${C.sentCount}`:`You sent ${C.sentCount}+`,he.appendChild(ue),W.append(K,Q,he);let se=document.createElement("div");if(C.userId){let te=document.createElement("button");te.className="dmh-button green",te.textContent="Open DM",te.onclick=async()=>{if(f||(f=await Qe()),!f)return alert("Could not get a valid Discord authorization token.");let de=await J(`${U}/users/@me/channels`,{method:"POST",headers:{Authorization:f.token,"Content-Type":"application/json"},body:JSON.stringify({recipient_id:C.userId})});if(!de.ok)return alert(`Discord could not reopen this DM (HTTP ${de.status}).`);let ie=await de.json();Ne(d,{userId:C.userId,channelId:ie.id,sources:["open"],sentCount:C.sentCount,sentCountExact:C.sentCountExact,verifiedSent:!0,seenAt:new Date().toISOString()}),pe(d),location.href=`/channels/@me/${ie.id}`},se.appendChild(te)}let ae=document.createElement("input");ae.type="checkbox",ae.className="dmh-select",ae.checked=i.has(m(C)),ae.disabled=s,ae.setAttribute("aria-label",`Select ${C.name||C.username||C.channelId||C.userId}`),ae.onchange=()=>{ae.checked?i.add(m(C)):i.delete(m(C)),p()},R.append(ae,z,W,se),q.appendChild(R)}j.appendChild(q),p(),o("#dmh-page").textContent=`Page ${u+1} of ${_}`,o("#dmh-prev").disabled=u===0,o("#dmh-next").disabled=u>=_-1,o("#dmh-page-input").max=String(_),o("#dmh-page-input").value=String(u+1),o("#dmh-summary").textContent="",x&&E()}async function M(){if(!L){L=!0,o("#dmh-refresh").disabled=!0,o("#dmh-summary").textContent="Loading Discord DM data\u2026";try{if(f=await Qe(),!f){o("#dmh-summary").textContent="Could not get a valid Discord authorization token.";return}let x=new Map;try{let C=await J(`${U}/users/@me/channels`,{headers:{Authorization:f.token}});if(C.ok){let R=await C.json();for(let z of Array.isArray(R)?R:[]){if(z?.type!==1)continue;let W=z.recipients?.find?.(K=>K?.id&&K.id!==f.user.id);W&&x.set(W.id,{channelId:z.id,user:W})}}}catch{}let N=new Map;for(let[C,R]of x)N.set(C,{userId:C,channelId:R.channelId,user:R.user,wasOpen:!0,dmRank:null,source:"open"});for(let C of en().filter(R=>R?.otherUserId&&R.otherUserId!==f.user.id&&Number.isFinite(R.dmRank)&&R.dmRank>=0)){let R=N.get(C.otherUserId);R?R.dmRank=C.dmRank:N.set(C.otherUserId,{userId:C.otherUserId,channelId:"",user:null,wasOpen:!1,dmRank:C.dmRank,source:"affinity"})}let _=[...N.values()],j=0,q=0;for(let C of _){j++,o("#dmh-summary").textContent=`Verifying sent-message history ${j}/${_.length}\u2026`;let R=await gn(f.token,f.user.id,C.userId,C.channelId,C.wasOpen);if(!R||R.count===null){await G(200);continue}if(R.count<=0)ct(d,C.userId,R.channelId||C.channelId),q++;else{let z=C.user;if(!z)try{z=Se?.getUser?.(C.userId)||null}catch{}Ne(d,{userId:C.userId,channelId:R.channelId||C.channelId,name:$e(z),username:$t(z),avatar:nt(z),dmRank:C.dmRank,sources:[C.source],sentCount:R.count,sentCountExact:!0,cleanupFinishedAt:null,verifiedSent:!0,seenAt:new Date().toISOString()}),q++}q>=5&&(pe(d),q=0),await G(250)}q&&pe(d),I()}finally{o("#dmh-refresh").disabled=!1,L=!1}}}function D(){let x=Math.max(1,Math.ceil(v().length/10)),N=parseInt(o("#dmh-page-input").value,10);Number.isFinite(N)||(N=u+1),u=Math.max(1,Math.min(x,N))-1,I()}return o("#dmh-delete-all").onclick=async()=>{if(s)return;let x=Object.values(d).filter(N=>i.has(m(N)));if(x.length){s=!0,I(!1);try{let N=await t(x);for(let _ of N)i.delete(m(_)),_.cleanupFinishedAt=new Date().toISOString();N.length&&pe(d)}finally{s=!1,I(!1)}}},o("#dmh-search").oninput=x=>{y=x.target.value||"",u=0,I()},o("#dmh-refresh").onclick=M,o("#dmh-import").onclick=()=>o("#dmh-folder").click(),o("#dmh-folder").onchange=x=>wn(x.target.files,{$:o,getHistory:()=>d,getIdentity:async()=>f||(f=await Qe()),setPackageInfo:N=>{b=N},persist:w,render:I}),o("#dmh-prev").onclick=()=>{u--,I()},o("#dmh-next").onclick=()=>{u++,I()},o("#dmh-page-go").onclick=D,o("#dmh-page-input").onkeydown=x=>{x.key==="Enter"&&D()},o("#dmh-clear").onclick=async()=>{await re({title:"Clear saved DM history?",message:"This clears the locally saved history, package cache, page, and search state. It does not delete or close Discord DMs.",yesText:"Clear",noText:"Cancel",danger:!0})&&(i.clear(),d={},u=0,y="",b=null,o("#dmh-search").value="",pe(d),w(),I(!1))},I(!1),{activate:()=>I()}}function $n(e){let t=[],r=!1;return{log:(o,d)=>{let i=new Date,s=String(d),m=document.createElement("div");if(m.className=`log-${o||"verb"}`,m.textContent=s,e.appendChild(m),e.childElementCount>600)for(let p=0;p<120&&e.firstChild;p++)e.firstChild.remove();r||(r=!0,queueMicrotask(()=>{r=!1,e.scrollTop=e.scrollHeight})),t.push({time:i.toISOString(),type:o||"verb",message:s}),t.length>1e4&&t.splice(0,120)},logEntries:t}}function Ce(e){if(!e)return"";if(/^\d+$/.test(e))return e;let t=new Date(e).getTime();return Number.isFinite(t)?(BigInt(t-14200704e5)<<22n).toString():""}function Ke(e){let t=String(e||"").trim();if(!t)return null;if(/^\d{15,22}$/.test(t))return{messageId:t,guildId:"",channelId:""};let r=t.match(/(?:https?:\/\/)?(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/channels\/([^/\s]+)\/(\d+)\/(\d+)/i);return r?{guildId:r[1],channelId:r[2],messageId:r[3]}:null}function Le(){let e=location.pathname.match(/^\/channels\/([\w@]+)\/(\d+)/);return e?{guildId:e[1],channelId:e[2]}:null}function _e(){let e=String(document.title||"").replace(/^Discord\s*[|—–-]\s*/i,"").replace(/\s*\|\s*Discord.*$/i,"").trim(),t=Le();return!e||/^discord$/i.test(e)?t?t.guildId==="@me"?`DM ${t.channelId}`:`Channel ${t.channelId}`:"Unknown":e}function Cn(e){return e.filter(([,t])=>t!=null&&t!=="").map(([t,r])=>`${encodeURIComponent(t)}=${encodeURIComponent(r)}`).join("&")}var Re=null;async function sr(e){if(!e)return null;try{let t=await fetch(`${U}/users/@me`,{headers:{Authorization:e}});return t.ok?await t.json():null}catch{return null}}function ne(){Re=null}async function me(e=""){let t=Date.now();if(Re&&t-Re.at<6e4&&(!e||e===Re.token))return Re.identity;let r=[],a=new Set,o=d=>{d&&!a.has(d)&&(a.add(d),r.push(d))};o(e);for(let d of st())o(d);for(let d of r){let i=await sr(d);if(i?.id){let s={token:d,user:i};return Re={at:t,token:d,identity:s},s}}return ne(),null}function En({$:e,log:t,onChange:r=()=>{}}){let a=et(Yt),o=[],d=0;try{o=JSON.parse(a.read()||"[]"),Array.isArray(o)||(o=[])}catch{o=[]}let i=!1,s=()=>{try{if(!a.write(JSON.stringify(o)))throw new Error("Storage unavailable")}catch{i||(i=!0,t("warn","Queue could not be saved locally. Keep this page open; refreshing may lose these jobs."))}r()},m=new Map,p,n=!1,u=(l,h)=>{l.textContent!==h&&(l.textContent=h)},y=l=>{let h=document.createElement("div");h.className="dmd-queue-row";let w=document.createElement("span");w.className="dmd-queue-index";let S=document.createElement("span");S.className="dmd-queue-avatar";let v=document.createTextNode("");S.appendChild(v);let E=document.createElement("div");E.className="dmd-queue-info";let I=document.createElement("div");I.className="dmd-queue-title";let M=document.createElement("div");M.className="dmd-queue-ids",E.append(I,M);let D=document.createElement("button");D.className="dmd-btn",D.textContent="\u2191";let x=document.createElement("button");x.className="dmd-btn",x.textContent="\u2193";let N=document.createElement("button");return N.className="dmd-btn dmd-red",N.textContent="Remove",D.onclick=()=>{let _=o.indexOf(l);_<=0||([o[_-1],o[_]]=[o[_],o[_-1]],s(),b())},x.onclick=()=>{let _=o.indexOf(l);_<0||_>=o.length-1||([o[_],o[_+1]]=[o[_+1],o[_]],s(),b())},N.onclick=()=>{let _=o.indexOf(l);_<0||(o.splice(_,1),s(),b())},h.append(w,S,E,D,x,N),{row:h,num:w,avatar:S,letter:v,title:I,ids:M,up:D,down:x,icon:"",image:null}},b=()=>{let l=e("#dmd-multi-list");n||(l.textContent="",n=!0),u(e("#dmd-multi-count"),`${o.length} queued`);let h=new Set(o);for(let[v,E]of m)h.has(v)||(E.row.remove(),m.delete(v));if(!o.length){p||(p=document.createElement("div"),p.className="dmd-muted",p.style.padding="14px",p.textContent="Queue is empty."),p.parentNode!==l&&l.appendChild(p);return}p?.remove();let w=document.createDocumentFragment(),S=l.firstElementChild;o.forEach((v,E)=>{let I=m.get(v);I||(I=y(v),m.set(v,I));let M=We(v.label);u(I.num,`${E+1}.`),u(I.letter,(v.iconName||M||"?").slice(0,1).toUpperCase()),u(I.title,M||v.channelId),u(I.ids,v.kind==="ids"?`${v.targets.length} individual messages`:v.kind==="server"?`Server ${v.guildId}`:`${v.guildId} / ${v.channelId}`),I.up.disabled!==(E===0)&&(I.up.disabled=E===0),I.down.disabled!==(E===o.length-1)&&(I.down.disabled=E===o.length-1);let D=/^https:\/\/cdn\.discordapp\.com\/(?:avatars|icons|embed\/avatars)\//.test(v.icon||"")?v.icon:"";if(I.icon!==D&&(I.image?.remove(),I.image=null,I.icon=D,D)){let x=document.createElement("img");x.src=D,x.onerror=()=>x.remove(),I.avatar.appendChild(x),I.image=x}I.image&&I.image.alt!==(v.iconName||M||"Queue icon")&&(I.image.alt=v.iconName||M||"Queue icon"),S===I.row?S=S.nextElementSibling:S?l.insertBefore(I.row,S):w.appendChild(I.row)}),w.childNodes.length&&l.appendChild(w)},f=l=>{let h=new Set(o.map(S=>`${S.guildId}/${S.channelId}`)),w=0;for(let S of l){let v=String(S.guildId||"").trim(),E=String(S.channelId||"").trim();if(!v||!E){t("error","Server/DM ID and Channel ID are required.");continue}let I=`${v}/${E}`;if(h.has(I)){t("warn","That DM/channel is already queued.");continue}h.add(I),o.push({...S,guildId:v,channelId:E,thread:S.thread===!0,label:S.label||(v==="@me"?`DM ${E}`:`Channel ${E}`)}),w++}return w&&(s(),b()),w},$=(l,h,w="",S=!1,v={})=>f([{...v,guildId:l,channelId:h,label:w,thread:S}]),g=(l,h)=>{if(!l.length)return t("warn","Enter at least one message ID.");let w=E=>E.map(I=>`${I.channelId}/${I.messageId}`).sort(),S=w(l);if(o.some(E=>E.guildId==="@ids"&&Array.isArray(E.targets)&&E.targets.length===l.length&&w(E.targets).every((I,M)=>I===S[M])))return t("warn","That message ID batch is already queued.");let v;do v=`ids:${Date.now().toString(36)}:${(d++).toString(36)}`;while(o.some(E=>E.guildId==="@ids"&&E.channelId===v));$("@ids",v,`${l.length} message IDs`,!1,{kind:"ids",targets:l,options:h})},T=l=>{let h=o.findIndex(w=>w.guildId===l.guildId&&w.channelId===l.channelId);h<0||(o.splice(h,1),s(),b())},L=(l,h)=>{let w=o.find(S=>S.guildId===l.guildId&&S.channelId===l.channelId);w&&(Object.assign(w,{label:h.label,icon:h.icon,iconName:h.iconName}),s(),b())};return b(),{renderQueue:b,addQueueItem:$,addQueueItems:f,addIdJob:g,completeItem:T,updateIdentity:L,getItems:()=>o,clear:()=>{o=[],s(),b()}}}function Mn({panel:e,dragHandle:t=e,resizeHandle:r,resizeHandles:a=r?[r]:[],storage:o,storageKey:d,minWidth:i=560,defaultWidth:s=940,defaultRatio:m=1.35}){let p=i,n=8,u=null,y=!1,b=l=>{let h=!1,w=null,S=null,v=()=>{if(h=!1,w=null,!S)return;let M=S;S=null,l(M)};return{move:M=>{if(S={clientX:M.clientX,clientY:M.clientY},typeof window.requestAnimationFrame!="function"){v();return}h||(h=!0,w=window.requestAnimationFrame(v))},finish:()=>{w!==null&&window.cancelAnimationFrame?.(w),v()}}},f=()=>{try{let l=JSON.parse(o().getItem(d)||"null");return!l||!Number.isFinite(l.left)||!Number.isFinite(l.top)||!Number.isFinite(l.width)||!Number.isFinite(l.height)?null:l}catch{return null}},$=()=>{if(!y)return;let l=e.getBoundingClientRect();try{o().setItem(d,JSON.stringify({left:l.left,top:l.top,width:l.width,height:l.height,ratio:u||l.width/l.height}))}catch{}},g=(l,h)=>{let w=Math.max(260,window.innerWidth-n*2),S=Math.max(220,window.innerHeight-n*2),v=Math.min(p,w),E=Math.max(v,Math.min(w,l)),I=E/h;return I>S&&(I=S,E=I*h),E>w&&(E=w,I=E/h),{width:E,height:I}},T=()=>{if(!y)return;let l=e.getBoundingClientRect(),h=Math.max(n,window.innerWidth-l.width-n),w=Math.max(n,window.innerHeight-l.height-n),S=Math.min(h,Math.max(n,l.left)),v=Math.min(w,Math.max(n,l.top));e.style.left=`${S}px`,e.style.top=`${v}px`},L=()=>{if(y)return;let l=f(),h=e.getBoundingClientRect();u=l?.ratio&&Number.isFinite(l.ratio)&&l.ratio>0?l.ratio:h.width>0&&h.height>0?h.width/h.height:m;let w=l?.width||h.width||Math.min(s,window.innerWidth-36),S=g(w,u);e.style.right="auto",e.style.bottom="auto",e.style.width=`${S.width}px`,e.style.height=`${S.height}px`,e.style.left=`${l?.left??Math.max(n,window.innerWidth-S.width-18)}px`,e.style.top=`${l?.top??62}px`,y=!0,T()};t.addEventListener("pointerdown",l=>{if(l.button!==0||l.isPrimary===!1||l.target.closest('button,input,textarea,select,option,label,summary,a,[contenteditable]:not([contenteditable="false"]),[role="button"],[role="dialog"],[role="listbox"],[data-resize-corner],#dmd-log'))return;L(),l.preventDefault();let h=e.getBoundingClientRect(),w=l.clientX,S=l.clientY,v=h.left,E=h.top,{move:I,finish:M}=b(x=>{let N=Math.max(n,window.innerWidth-h.width-n),_=Math.max(n,window.innerHeight-h.height-n);e.style.left=`${Math.min(N,Math.max(n,v+x.clientX-w))}px`,e.style.top=`${Math.min(_,Math.max(n,E+x.clientY-S))}px`}),D=()=>{M(),window.removeEventListener("pointermove",I),window.removeEventListener("pointerup",D),window.removeEventListener("pointercancel",D),$()};window.addEventListener("pointermove",I),window.addEventListener("pointerup",D,{once:!0}),window.addEventListener("pointercancel",D,{once:!0})});for(let l of a)l.addEventListener("pointerdown",h=>{if(h.button!==0)return;L(),h.preventDefault(),h.stopPropagation();let w=e.getBoundingClientRect(),S=h.clientX,v=h.clientY,E=l.dataset.resizeCorner==="left",I=w.width,M=w.height,{move:D,finish:x}=b(_=>{let j=E?w.left+I-n:window.innerWidth-w.left-n,q=window.innerHeight-w.top-n,C=Math.max(Math.min(p,j),Math.min(j,I+(_.clientX-S)*(E?-1:1))),R=Math.max(Math.min(300,q),Math.min(q,M+_.clientY-v));e.style.width=`${C}px`,e.style.height=`${R}px`,u=C/R,E&&(e.style.left=`${w.left+I-C}px`)}),N=()=>{x(),window.removeEventListener("pointermove",D),window.removeEventListener("pointerup",N),window.removeEventListener("pointercancel",N),$()};window.addEventListener("pointermove",D),window.addEventListener("pointerup",N,{once:!0}),window.addEventListener("pointercancel",N,{once:!0})});return window.addEventListener("resize",()=>{if(!y||e.style.display!=="flex")return;let l=e.getBoundingClientRect(),h=u||l.width/l.height,w=g(l.width,h);e.style.width=`${w.width}px`,e.style.height=`${w.height}px`,T(),$()}),{ensureGeometry:L}}function An(){let e=null,t=-1/0;for(let r of document.querySelectorAll('[role="toolbar"], [class*="toolbar"]')){let a=r.getBoundingClientRect();if(a.width<=80||a.height<=20||a.top<0||a.top>=140||r.offsetParent===null)continue;let o=0;a.top<80&&(o+=20),a.right>innerWidth*.55&&(o+=20),o+=Math.min(10,r.querySelectorAll('button,[role="button"]').length),r.closest("header")&&(o+=15),o>t&&(e=r,t=o)}return e}var mt=new Set,Fe,ut=!1,Dt=0;function Dn(e){if(mt.add(e),!Fe){let t=++Dt;Fe=new MutationObserver(r=>{r?.length&&r.every(a=>{if(a.target?.closest?.("#dmd-panel, #dmh-panel, #dmd-toolbar-btn, .dmd-select-menu, .dmd-calendar"))return!0;let o=[...a.addedNodes||[],...a.removedNodes||[]];return o.length>0&&o.every(d=>d.matches?.(".dmd-select-menu, .dmd-calendar"))})||ut||(ut=!0,requestAnimationFrame(()=>{if(t===Dt){ut=!1;for(let a of mt)a()}}))}),Fe.observe(document.body,{childList:!0,subtree:!0})}return()=>{mt.delete(e),!mt.size&&Fe&&(Fe.disconnect(),Fe=null,Dt++,ut=!1)}}function Tn(e){return String(e||"discord").replace(/[\\/:*?"<>|]+/g,"-").replace(/\s+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").slice(0,80)||"discord"}function Tt(){let e=new Date,t=r=>String(r).padStart(2,"0");return`${e.getFullYear()}${t(e.getMonth()+1)}${t(e.getDate())}-${t(e.getHours())}${t(e.getMinutes())}${t(e.getSeconds())}`}function Nn(e,t,r="text/plain;charset=utf-8"){Nt(e,new Blob([t],{type:r}))}function Nt(e,t){let r=URL.createObjectURL(t),a=document.createElement("a");a.href=r,a.download=e,a.style.display="none",document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(r),1500)}function Ln(e){let t=new Set,r="json";return re({title:"Export Conversations",message:"Choose conversations and a file format. The selected Before/After range applies.",yesText:"Export",noText:"Cancel",getResult:()=>({conversations:e.filter((a,o)=>t.has(o)),format:r}),render:({box:a,yes:o})=>{o.disabled=!0;let d=document.createElement("button");d.type="button",d.className="dmd-btn",d.textContent="Select all",d.disabled=!e.length;let i=document.createElement("div");i.className="dmd-export-list",i.onchange=p=>{let n=p.target,u=Number(n.value);n.checked?t.add(u):t.delete(u),o.disabled=!t.size},e.forEach((p,n)=>{let u=document.createElement("label");u.className="dmd-check";let y=document.createElement("input");y.type="checkbox",y.value=String(n),u.append(y,document.createTextNode(p.label||p.channelId)),i.append(u)}),d.onclick=()=>{i.querySelectorAll("input").forEach(p=>{p.checked=!0,t.add(Number(p.value))}),o.disabled=!t.size};let s=document.createElement("fieldset");s.className="dmd-export-formats";let m=document.createElement("legend");m.textContent="File format",s.append(m);for(let p of["json","csv"]){let n=document.createElement("label");n.className="dmd-check";let u=document.createElement("input");u.type="radio",u.name="dmd-export-format",u.value=p,u.checked=p===r,u.onchange=()=>{u.checked&&(r=p)},n.append(u,document.createTextNode(p.toUpperCase())),s.append(n)}a.append(d,i,s)}})}var O={stopped:!1,reactionStopped:!1,multiStopped:!1};function Lt(e,t="i",r="Regular expression"){if(e=String(e||"").trim(),!e)return null;if(e.length>1e3)throw new Error(`${r} is too long (maximum 1,000 characters).`);if(!/^[imsu]*$/.test(t)||new Set(t).size!==t.length)throw new Error(`${r}: use only i, m, s, and u flags, without duplicates.`);try{return new RegExp(e,t)}catch(a){throw new Error(`${r}: ${a.message}`,{cause:a})}}function _t(e){let t=new Set,r=String(e.content||"");for(let a of r.matchAll(/https?:\/\/[^\s<>`"']+/gi)){let o=a[0].replace(/[.,!;:]+$/,"");for(let d of["**","__","~~","||","*","_"])r.slice(0,a.index).endsWith(d)&&o.endsWith(d)&&(o=o.slice(0,-d.length));for(;o.endsWith(")")&&(o.match(/\)/g)||[]).length>(o.match(/\(/g)||[]).length;)o=o.slice(0,-1);o&&t.add(o)}for(let a of e.embeds||[]){let o=a.url||a.image?.url;/^https?:\/\//i.test(o||"")&&t.add(o)}return[...t]}function lr(e){let t=(e.attachments||[]).map(r=>({name:String(r.filename||""),url:String(r.url||"")}));for(let r of _t(e))try{let o=new URL(r).pathname.split("/").at(-1)||"";try{o=decodeURIComponent(o)}catch{}t.push({name:o,url:r})}catch{}return t}function _n(e,t){if(!t.filename&&!t.extensions.length&&!t.assetPattern)return!0;let r=lr(e).some(a=>{if(t.filename&&!a.name.toLowerCase().includes(t.filename.toLowerCase()))return!1;let o=a.name.match(/\.([^.]+)$/)?.[1]?.toLowerCase()||"";return!(t.extensions.length&&!t.extensions.includes(o)||t.assetPattern&&!t.assetPattern.test(`${a.name} -${a.url}`))});return t.assetMode==="exclude"?!r:r}function be(e){let t=e.action||"delete";if(!["delete","overwrite","preserve"].includes(t))throw new Error("Choose a valid message action.");let r=String(e.overwriteText??"");if(t==="overwrite"&&(!r.trim()||r.length>2e3))throw new Error("Replacement text must contain between 1 and 2,000 characters.");let a=(f,$)=>{let g=f||$;if(!["any","with","without"].includes(g))throw new Error("Invalid message filter.");return g},o=f=>{let $=Ce(f);if(f&&(!$||!/^\d+$/.test($)))throw new Error("Invalid message/date boundary.");return $},d=o(e.minId),i=o(e.maxId);if(d&&i&&BigInt(d)>=BigInt(i))throw new Error("After must be earlier than Before.");let s=e.order||"desc";if(!["asc","desc"].includes(s))throw new Error("Invalid message order.");let m=e.textMode||"include";if(!["include","exclude"].includes(m))throw new Error("Invalid text filter.");let p=e.regexMode||"include",n=e.assetMode||"include";if(![p,n].every(f=>["include","exclude"].includes(f)))throw new Error("Invalid pattern filter.");let u=(Array.isArray(e.extensions)?e.extensions:String(e.extensions||"").split(/[\s,;]+/)).map(f=>String(f).trim().replace(/^\*?\./,"").toLowerCase()).filter(Boolean);if(u.some(f=>!/^[a-z0-9_-]+$/.test(f)))throw new Error("Use extensions such as png, jpg, or zip, separated by commas.");let y=Lt(e.textRegex,e.regexFlags??"i","Text regex"),b=Lt(e.assetRegex,e.assetRegexFlags??"i","File/URL regex");return{...e,action:t,overwriteText:r,order:s,textMode:m,minId:d,maxId:i,regexMode:p,assetMode:n,textPattern:y,assetPattern:b,extensions:[...new Set(u)],filename:String(e.filename||"").trim(),collectAll:e.collectAll===!0,content:String(e.content||"").trim(),linkMode:a(e.linkMode,e.hasLink?"with":"any"),fileMode:a(e.fileMode,e.hasFile?"with":"any"),pinnedMode:a(e.pinnedMode,e.includePinned?"any":"without")}}function fe(e,t){if(!t.authorId||e.author?.id!==t.authorId||t.authorIds?.length&&!t.authorIds.includes(e.author?.id)||t.channelId&&e.channel_id!==t.channelId||![0,6,19].includes(e.type)||!/^\d+$/.test(String(e.id||""))||t.minId&&BigInt(e.id)<=BigInt(t.minId)||t.maxId&&BigInt(e.id)>=BigInt(t.maxId))return!1;let r=(a,o)=>a==="any"||(a==="with"?o:!o);if(t.linkMode!=="any"){let a=/https?:\/\/\S+/i.test(e.content||"")||(e.embeds||[]).some(o=>!!o.url);if(!r(t.linkMode,a))return!1}if(!r(t.fileMode,!!e.attachments?.length)||!r(t.pinnedMode,!!e.pinned))return!1;if(t.content){let a=String(e.content||"").toLowerCase().includes(t.content.toLowerCase());if(t.textMode==="exclude"?a:!a)return!1}if(t.textPattern){let a=t.textPattern.test(String(e.content||""));if(t.regexMode==="exclude"?a:!a)return!1}return!!_n(e,t)}function Y(e,t){let r=String(e),a=String(t);if(r===a)return 0;if(r[0]==="0"||a[0]==="0"){let o=BigInt(r),d=BigInt(a);return od?1:0}return r.length===a.length?rr*Y(a.id,o.id))}function cr(e){return e?{id:e.id||null,timestamp:e.timestamp||null,content:e.content||"",author:e.author?{id:e.author.id||null,username:e.author.username||"",globalName:e.author.global_name||null,bot:!!e.author.bot}:null}:null}function mr(e){return{id:e.id,timestamp:e.timestamp,editedTimestamp:e.edited_timestamp||null,type:e.type,pinned:!!e.pinned,content:e.content||"",author:e.author?{id:e.author.id||null,username:e.author.username||"",globalName:e.author.global_name||null,discriminator:e.author.discriminator||null,bot:!!e.author.bot}:null,attachments:(e.attachments||[]).map(t=>({id:t.id,filename:t.filename,description:t.description||null,contentType:t.content_type||null,size:t.size||0,url:t.url,proxyUrl:t.proxy_url||null,width:t.width||null,height:t.height||null})),embeds:e.embeds||[],reactions:(e.reactions||[]).map(t=>({count:t.count,emoji:t.emoji,me:!!t.me})),mentions:(e.mentions||[]).map(t=>({id:t.id,username:t.username||"",globalName:t.global_name||null})),referencedMessage:cr(e.referenced_message)}}async function Fn({token:e,guildId:t,channelId:r,minId:a,maxId:o,label:d,log:i,stopCheck:s=()=>!1}){let m=a?Ce(a):"",p=o?Ce(o):"",n=null;try{m&&(n=BigInt(m))}catch{}let u=[],y=0;for(i("info",`Exporting conversation ${d||r}...`);!s();){let b=`${U}/channels/${r}/messages?limit=100${p?`&before=${p}`:""}`,f;try{f=await J(b,{headers:{Authorization:e}},i,s)}catch(h){if(h instanceof X)break;throw h}if(s())break;if(f.status===401)throw ne(),new Error("Discord rejected the authorization token while exporting.");if(f.status===403)throw new Error("Discord denied access to this conversation.");if(!f.ok)throw new Error(`Conversation export failed with HTTP ${f.status}.`);let $=await f.json();if(!Array.isArray($)||!$.length)break;y++;let g=p?BigInt(p):null,T=new Set,L=null,l=!1;for(let h of $){if(!/^\d+$/.test(String(h?.id||"")))continue;let w=null;try{w=BigInt(h.id)}catch{}if(w!==null&&!(g!==null&&w>=g)){if(n!==null&&w<=n){l=!0;continue}T.has(h.id)||(T.add(h.id),u.push(mr(h))),(L===null||wY(b.id,f.id)),{exportedAt:new Date().toISOString(),guildId:t,channelId:r,label:d||"",range:{after:a||null,before:o||null},messageCount:u.length,messages:u}}var ur=["guildId","channelId","conversation","id","timestamp","editedTimestamp","authorId","username","content","type","pinned","attachments","embeds","reactions","mentions","referencedMessage"],pr=["attachments","embeds","reactions","mentions","referencedMessage"],Rn=e=>'"'+String(e??"").replace(/"/g,'""')+'"';function*fr(e,t=!0){t&&(yield ur.map(Rn).join(","));for(let r of e)for(let a of r.messages)yield[r.guildId,r.channelId,r.label,a.id,a.timestamp,a.editedTimestamp,a.author?.id,a.author?.username,a.content,a.type,a.pinned,...pr.map(o=>JSON.stringify(a[o]??null))].map(Rn).join(",")}function*hr(e){let{messages:t,...r}=e;yield JSON.stringify({...r,messages:[]},null,2).slice(0,-4)+`[ -`;for(let o=0;o[]}={}){if(document.getElementById("dmh-panel"))return;xt(Jn);let r=document.createElement("div");r.id="dmh-panel",r.className="dmd-view",r.style.display="none",r.innerHTML=Wn,e.insertBefore(r,e.querySelector("#dmd-progress-dock"));let o=new Map,a=v=>{if(o.has(v))return o.get(v);let N=r.querySelector(v);return o.set(v,N),N},i=Tn(),s=new Set,d=!1,c=v=>v.channelId||v.userId,f=()=>{a("#dmh-delete-all").hidden=s.size===0,a("#dmh-delete-all").textContent=`Delete All (${s.size})`},n=Nn(),u=Number.isInteger(n.page)?Math.max(0,n.page):0,w=typeof n.query=="string"?n.query:"",b=n.packageInfo&&typeof n.packageInfo=="object"?n.packageInfo:null,h=null,S=[],k=!1,M=!1,T=!1,l=new WeakMap,y=Un();a("#dmh-search").value=w;let I=()=>Ln({page:u,query:w,packageInfo:b}),p=v=>({open:"Open DM",affinity:"Closed/cache",package:"Data Package"})[v]||v;function g(){return y(i,w)}async function x(){if(k){M=!0;return}k=!0,M=!1;let v=i,N=S;try{let _=!1;for(let U of N){if(i!==v)break;if(!S.includes(U)||!U.userId||U.name&&U.username)continue;let H=l.get(U);if(H!==void 0&&Date.now()-H<6e4)continue;l.set(U,Date.now());let L=null,z=!1;try{L=Ee?.getUser?.(U.userId)||null}catch{}if(!L){if(h||(h=await at()),!h||i!==v)break;z=!0;try{let P=await W(`${B}/users/${U.userId}`,{headers:{Authorization:h.token}});P.ok&&(L=await P.json())}catch{}}if(i!==v)break;if(L){let P=Oe(i,{userId:U.userId,name:Ce(L),username:Jt(L),avatar:ft(L),seenAt:new Date().toISOString()});l.set(P,Date.now()),_=!0}z&&await Q(50)}_&&i===v&&(ue(i),$(!1))}finally{k=!1,M&&x()}}function $(v=!0){let N=g(),_=Math.max(1,Math.ceil(N.length/10));u=Math.max(0,Math.min(u,_-1)),I(),S=N.slice(u*10,u*10+10);let U=a("#dmh-list");U.textContent="";let H=document.createDocumentFragment();if(!S.length){let L=document.createElement("div");L.style.cssText="padding:28px;text-align:center;color:#949ba4",L.textContent=w?"No matches.":"No verified sent-DM history yet. Press Refresh & Verify or import your Discord Data Package.",H.appendChild(L)}for(let L of S){let z=document.createElement("div");z.className="dmh-row";let P;L.avatar?(P=document.createElement("img"),P.className="dmh-av",P.src=L.avatar,P.alt=""):(P=document.createElement("div"),P.className="dmh-fallback",P.textContent=(L.name||L.username||"?").charAt(0).toUpperCase());let K=document.createElement("div");K.className="dmh-main";let q=document.createElement("div");q.className="dmh-name",q.textContent=L.name||L.username||(L.userId?`User ${L.userId}`:`DM Channel ${L.channelId}`);let j=document.createElement("div");j.className="dmh-sub",j.textContent=[L.username&&L.username!==L.name?L.username:"",L.userId?`User ID: ${L.userId}`:"",L.channelId?`Channel: ${L.channelId}`:""].filter(Boolean).join(" \u2022 ");let ne=document.createElement("div");ne.className="dmh-badges";for(let Z of L.sources||[]){let ie=document.createElement("span");ie.className="dmh-badge",ie.textContent=p(Z),ne.appendChild(ie)}let fe=document.createElement("span");fe.className="dmh-badge",fe.textContent=L.cleanupFinishedAt?"Cleanup finished \xB7 refresh to verify":L.sentCountExact?`You sent ${L.sentCount}`:`You sent ${L.sentCount}+`,ne.appendChild(fe),K.append(q,j,ne);let ce=document.createElement("div");if(L.userId){let Z=document.createElement("button");Z.className="dmh-button green",Z.textContent="Open DM",Z.onclick=async()=>{let ie=i;try{let ae=h||await at();if(i!==ie)return;if(h=ae,!h)return alert("Could not get a valid Discord authorization token.");let ye=await W(`${B}/users/@me/channels`,{method:"POST",headers:{Authorization:h.token,"Content-Type":"application/json"},body:JSON.stringify({recipient_id:L.userId})});if(i!==ie)return;if(!ye.ok)return alert(`Discord could not reopen this DM (HTTP ${ye.status}).`);let De=await ye.json();if(i!==ie)return;Oe(i,{userId:L.userId,channelId:De.id,sources:["open"],sentCount:L.sentCount,sentCountExact:L.sentCountExact,verifiedSent:!0,seenAt:new Date().toISOString()}),ue(i),location.href=`/channels/@me/${De.id}`}catch(ae){i===ie&&alert(`Discord could not reopen this DM: ${ae?.message||ae}`)}},ce.appendChild(Z)}let de=document.createElement("input");de.type="checkbox",de.className="dmh-select",de.checked=s.has(c(L)),de.disabled=d,de.setAttribute("aria-label",`Select ${L.name||L.username||L.channelId||L.userId}`),de.onchange=()=>{de.checked?s.add(c(L)):s.delete(c(L)),f()},z.append(de,P,K,ce),H.appendChild(z)}U.appendChild(H),f(),a("#dmh-page").textContent=`Page ${u+1} of ${_}`,a("#dmh-prev").disabled=u===0,a("#dmh-next").disabled=u>=_-1,a("#dmh-page-input").max=String(_),a("#dmh-page-input").value=String(u+1),a("#dmh-summary").textContent="",v&&x()}async function E(){if(T)return;T=!0,a("#dmh-refresh").disabled=!0,a("#dmh-summary").textContent="Loading Discord DM data\u2026";let v=i,N=0;try{let _=await at();if(i!==v)return;if(h=_,!h){a("#dmh-summary").textContent="Could not get a valid Discord authorization token.";return}let U=new Map,H=await W(`${B}/users/@me/channels`,{headers:{Authorization:h.token}});if(i!==v)return;if(!H.ok)throw new Error(`Could not load open DMs (HTTP ${H.status}).`);let L=await H.json();if(i!==v)return;for(let q of Array.isArray(L)?L:[]){if(q?.type!==1)continue;let j=q.recipients?.find?.(ne=>ne?.id&&ne.id!==h.user.id);j&&U.set(j.id,{channelId:q.id,user:j})}let z=new Map;for(let[q,j]of U)z.set(q,{userId:q,channelId:j.channelId,user:j.user,wasOpen:!0,dmRank:null,source:"open"});for(let q of $n().filter(j=>j?.otherUserId&&j.otherUserId!==h.user.id&&Number.isFinite(j.dmRank)&&j.dmRank>=0)){let j=z.get(q.otherUserId);j?j.dmRank=q.dmRank:z.set(q.otherUserId,{userId:q.otherUserId,channelId:"",user:null,wasOpen:!1,dmRank:q.dmRank,source:"affinity"})}let P=[...z.values()],K=0;for(let q of P){if(i!==v)return;K++,a("#dmh-summary").textContent=`Verifying sent-message history ${K}/${P.length}\u2026`;let j=await On(h.token,h.user.id,q.userId,q.channelId,q.wasOpen);if(i!==v)return;if(!j||j.count===null){await Q(200);continue}if(j.count<=0)kt(i,q.userId,j.channelId||q.channelId),N++;else{let ne=q.user;if(!ne)try{ne=Ee?.getUser?.(q.userId)||null}catch{}Oe(i,{userId:q.userId,channelId:j.channelId||q.channelId,name:Ce(ne),username:Jt(ne),avatar:ft(ne),dmRank:q.dmRank,sources:[q.source],sentCount:j.count,sentCountExact:!0,cleanupFinishedAt:null,verifiedSent:!0,seenAt:new Date().toISOString()}),N++}N>=5&&(ue(i),N=0),await Q(250)}if(i!==v)return;N&&ue(i),$()}catch(_){if(i!==v)return;N&&ue(i),$(!1),a("#dmh-summary").textContent=`Refresh stopped: ${_?.message||_}`}finally{a("#dmh-refresh").disabled=!1,T=!1}}function A(){let v=Math.max(1,Math.ceil(g().length/10)),N=parseInt(a("#dmh-page-input").value,10);Number.isFinite(N)||(N=u+1),u=Math.max(1,Math.min(v,N))-1,$()}return a("#dmh-delete-all").onclick=async()=>{if(d)return;let v=Object.values(i).filter(N=>s.has(c(N)));if(v.length){d=!0,$(!1);try{let N=await t(v);for(let _ of N)s.delete(c(_)),_.cleanupFinishedAt=new Date().toISOString();N.length&&ue(i)}finally{d=!1,$(!1)}}},a("#dmh-search").oninput=v=>{w=v.target.value||"",u=0,$()},a("#dmh-refresh").onclick=E,a("#dmh-import").onclick=()=>a("#dmh-folder").click(),a("#dmh-folder").onchange=v=>Hn(v.target.files,{$:a,getHistory:()=>i,getIdentity:async()=>h||(h=await at()),setPackageInfo:N=>{b=N},persist:I,render:$}),a("#dmh-prev").onclick=()=>{u--,$()},a("#dmh-next").onclick=()=>{u++,$()},a("#dmh-page-go").onclick=A,a("#dmh-page-input").onkeydown=v=>{v.key==="Enter"&&A()},a("#dmh-clear").onclick=async()=>{await se({title:"Clear saved DM history?",message:"This clears the locally saved history, package cache, page, and search state. It does not delete or close Discord DMs.",yesText:"Clear",noText:"Cancel",danger:!0})&&(s.clear(),i={},u=0,w="",b=null,a("#dmh-search").value="",ue(i),I(),$(!1))},$(!1),{activate:()=>$()}}function Kn(e){let t=[],r=!1;return{log:(a,i)=>{let s=new Date,d=String(i),c=document.createElement("div");if(c.className=`log-${a||"verb"}`,c.textContent=d,e.appendChild(c),e.childElementCount>600)for(let f=0;f<120&&e.firstChild;f++)e.firstChild.remove();r||(r=!0,queueMicrotask(()=>{r=!1,e.scrollTop=e.scrollHeight})),t.push({time:s.toISOString(),type:a||"verb",message:d}),t.length>1e4&&t.splice(0,120)},logEntries:t}}function Ie(e){if(!e)return"";if(/^\d+$/.test(e))return e;let t=new Date(e).getTime();return Number.isFinite(t)?(BigInt(t-14200704e5)<<22n).toString():""}function it(e){let t=String(e||"").trim();if(!t)return null;if(/^\d{15,22}$/.test(t))return{messageId:t,guildId:"",channelId:""};let r=t.match(/(?:https?:\/\/)?(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/channels\/([^/\s]+)\/(\d+)\/(\d+)/i);return r?{guildId:r[1],channelId:r[2],messageId:r[3]}:null}function je(){let e=location.pathname.match(/^\/channels\/([\w@]+)\/(\d+)/);return e?{guildId:e[1],channelId:e[2]}:null}function qe(){let e=String(document.title||"").replace(/^Discord\s*[|—–-]\s*/i,"").replace(/\s*\|\s*Discord.*$/i,"").trim(),t=je();return!e||/^discord$/i.test(e)?t?t.guildId==="@me"?`DM ${t.channelId}`:`Channel ${t.channelId}`:"Unknown":e}function Vn(e){return e.filter(([,t])=>t!=null&&t!=="").map(([t,r])=>`${encodeURIComponent(t)}=${encodeURIComponent(r)}`).join("&")}var Be=null;async function zr(e,{log:t=()=>{},stopCheck:r=()=>!1,strict:o=!1,signal:a}={}){if(!e)return null;try{let i=await W(`${B}/users/@me`,{headers:{Authorization:e},signal:a},t,r,{minIntervalMs:1e3});if(r())throw new V;if(!i.ok){if(o&&i.status!==401)throw new Error(`Authorization check failed with HTTP ${i.status}. Export stopped.`);return null}return await le(i.json(),a)}catch(i){if(o||i instanceof V)throw i;return null}}function re(){Be=null}async function pe(e="",t={}){if(t.signal?.throwIfAborted(),t.stopCheck?.())throw new V;let r=Date.now();if(Be&&r-Be.at<6e4&&(!e||e===Be.token))return Be.identity;let o=[],a=new Set,i=s=>{s&&!a.has(s)&&(a.add(s),o.push(s))};i(e);for(let s of vt())i(s);for(let s of o){let d=await zr(s,t);if(d?.id){let c={token:s,user:d};return Be={at:r,token:s,identity:c},c}if(t.strict)break}return re(),null}function Yn({$:e,log:t,onChange:r=()=>{}}){let o=ut(yn),a=[],i=0;try{a=JSON.parse(o.read()||"[]"),Array.isArray(a)||(a=[])}catch{a=[]}let s=!1,d=()=>{try{if(!o.write(JSON.stringify(a)))throw new Error("Storage unavailable")}catch{s||(s=!0,t("warn","Queue could not be saved locally. Keep this page open; refreshing may lose these jobs."))}r()},c=new Map,f,n=!1,u=(l,y)=>{l.textContent!==y&&(l.textContent=y)},w=l=>{let y=document.createElement("div");y.className="dmd-queue-row";let I=document.createElement("span");I.className="dmd-queue-index";let p=document.createElement("span");p.className="dmd-queue-avatar";let g=document.createTextNode("");p.appendChild(g);let x=document.createElement("div");x.className="dmd-queue-info";let $=document.createElement("div");$.className="dmd-queue-title";let E=document.createElement("div");E.className="dmd-queue-ids",x.append($,E);let A=document.createElement("button");A.className="dmd-btn",A.textContent="\u2191";let v=document.createElement("button");v.className="dmd-btn",v.textContent="\u2193";let N=document.createElement("button");return N.className="dmd-btn dmd-red",N.textContent="Remove",A.onclick=()=>{let _=a.indexOf(l);_<=0||([a[_-1],a[_]]=[a[_],a[_-1]],d(),b())},v.onclick=()=>{let _=a.indexOf(l);_<0||_>=a.length-1||([a[_],a[_+1]]=[a[_+1],a[_]],d(),b())},N.onclick=()=>{let _=a.indexOf(l);_<0||(a.splice(_,1),d(),b())},y.append(I,p,x,A,v,N),{row:y,num:I,avatar:p,letter:g,title:$,ids:E,up:A,down:v,icon:"",image:null}},b=()=>{let l=e("#dmd-multi-list");n||(l.textContent="",n=!0),u(e("#dmd-multi-count"),`${a.length} queued`);let y=new Set(a);for(let[g,x]of c)y.has(g)||(x.row.remove(),c.delete(g));if(!a.length){f||(f=document.createElement("div"),f.className="dmd-muted",f.style.padding="14px",f.textContent="Queue is empty."),f.parentNode!==l&&l.appendChild(f);return}f?.remove();let I=document.createDocumentFragment(),p=l.firstElementChild;a.forEach((g,x)=>{let $=c.get(g);$||($=w(g),c.set(g,$));let E=nt(g.label);u($.num,`${x+1}.`),u($.letter,(g.iconName||E||"?").slice(0,1).toUpperCase()),u($.title,E||g.channelId),u($.ids,g.kind==="ids"?`${g.targets.length} individual messages`:g.kind==="server"?`Server ${g.guildId}`:`${g.guildId} / ${g.channelId}`),$.up.disabled!==(x===0)&&($.up.disabled=x===0),$.down.disabled!==(x===a.length-1)&&($.down.disabled=x===a.length-1);let A=/^https:\/\/cdn\.discordapp\.com\/(?:avatars|icons|embed\/avatars)\//.test(g.icon||"")?g.icon:"";if($.icon!==A&&($.image?.remove(),$.image=null,$.icon=A,A)){let v=document.createElement("img");v.src=A,v.onerror=()=>v.remove(),$.avatar.appendChild(v),$.image=v}$.image&&$.image.alt!==(g.iconName||E||"Queue icon")&&($.image.alt=g.iconName||E||"Queue icon"),p===$.row?p=p.nextElementSibling:p?l.insertBefore($.row,p):I.appendChild($.row)}),I.childNodes.length&&l.appendChild(I)},h=l=>{let y=new Set(a.map(p=>`${p.guildId}/${p.channelId}`)),I=0;for(let p of l){let g=String(p.guildId||"").trim(),x=String(p.channelId||"").trim();if(!g||!x){t("error","Server/DM ID and Channel ID are required.");continue}let $=`${g}/${x}`;if(y.has($)){t("warn","That DM/channel is already queued.");continue}y.add($),a.push({...p,guildId:g,channelId:x,thread:p.thread===!0,label:p.label||(g==="@me"?`DM ${x}`:`Channel ${x}`)}),I++}return I&&(d(),b()),I},S=(l,y,I="",p=!1,g={})=>h([{...g,guildId:l,channelId:y,label:I,thread:p}]),k=(l,y)=>{if(!l.length)return t("warn","Enter at least one message ID.");let I=x=>x.map($=>`${$.channelId}/${$.messageId}`).sort(),p=I(l);if(a.some(x=>x.guildId==="@ids"&&Array.isArray(x.targets)&&x.targets.length===l.length&&I(x.targets).every(($,E)=>$===p[E])))return t("warn","That message ID batch is already queued.");let g;do g=`ids:${Date.now().toString(36)}:${(i++).toString(36)}`;while(a.some(x=>x.guildId==="@ids"&&x.channelId===g));S("@ids",g,`${l.length} message IDs`,!1,{kind:"ids",targets:l,options:y})},M=l=>{let y=a.findIndex(I=>I.guildId===l.guildId&&I.channelId===l.channelId);y<0||(a.splice(y,1),d(),b())},T=(l,y)=>{let I=a.find(p=>p.guildId===l.guildId&&p.channelId===l.channelId);I&&(Object.assign(I,{label:y.label,icon:y.icon,iconName:y.iconName}),d(),b())};return b(),{renderQueue:b,addQueueItem:S,addQueueItems:h,addIdJob:k,completeItem:M,updateIdentity:T,getItems:()=>a,clear:()=>{a=[],d(),b()}}}function Xn({panel:e,dragHandle:t=e,resizeHandle:r,resizeHandles:o=r?[r]:[],storage:a,storageKey:i,minWidth:s=560,defaultWidth:d=940,defaultRatio:c=1.35}){let f=s,n=8,u=null,w=!1,b=l=>{let y=!1,I=null,p=null,g=()=>{if(y=!1,I=null,!p)return;let E=p;p=null,l(E)};return{move:E=>{if(p={clientX:E.clientX,clientY:E.clientY},typeof window.requestAnimationFrame!="function"){g();return}y||(y=!0,I=window.requestAnimationFrame(g))},finish:()=>{I!==null&&window.cancelAnimationFrame?.(I),g()}}},h=()=>{try{let l=JSON.parse(a().getItem(i)||"null");return!l||!Number.isFinite(l.left)||!Number.isFinite(l.top)||!Number.isFinite(l.width)||!Number.isFinite(l.height)?null:l}catch{return null}},S=()=>{if(!w)return;let l=e.getBoundingClientRect();try{a().setItem(i,JSON.stringify({left:l.left,top:l.top,width:l.width,height:l.height,ratio:u||l.width/l.height}))}catch{}},k=(l,y)=>{let I=Math.max(260,window.innerWidth-n*2),p=Math.max(220,window.innerHeight-n*2),g=Math.min(f,I),x=Math.max(g,Math.min(I,l)),$=x/y;return $>p&&($=p,x=$*y),x>I&&(x=I,$=x/y),{width:x,height:$}},M=()=>{if(!w)return;let l=e.getBoundingClientRect(),y=Math.max(n,window.innerWidth-l.width-n),I=Math.max(n,window.innerHeight-l.height-n),p=Math.min(y,Math.max(n,l.left)),g=Math.min(I,Math.max(n,l.top));e.style.left=`${p}px`,e.style.top=`${g}px`},T=()=>{if(w)return;let l=h(),y=e.getBoundingClientRect();u=l?.ratio&&Number.isFinite(l.ratio)&&l.ratio>0?l.ratio:y.width>0&&y.height>0?y.width/y.height:c;let I=l?.width||y.width||Math.min(d,window.innerWidth-36),p=k(I,u);e.style.right="auto",e.style.bottom="auto",e.style.width=`${p.width}px`,e.style.height=`${p.height}px`,e.style.left=`${l?.left??Math.max(n,window.innerWidth-p.width-18)}px`,e.style.top=`${l?.top??62}px`,w=!0,M()};t.addEventListener("pointerdown",l=>{if(l.button!==0||l.isPrimary===!1||l.target.closest('button,input,textarea,select,option,label,summary,a,[contenteditable]:not([contenteditable="false"]),[role="button"],[role="dialog"],[role="listbox"],[data-resize-corner],#dmd-log'))return;T(),l.preventDefault();let y=e.getBoundingClientRect(),I=l.clientX,p=l.clientY,g=y.left,x=y.top,{move:$,finish:E}=b(v=>{let N=Math.max(n,window.innerWidth-y.width-n),_=Math.max(n,window.innerHeight-y.height-n);e.style.left=`${Math.min(N,Math.max(n,g+v.clientX-I))}px`,e.style.top=`${Math.min(_,Math.max(n,x+v.clientY-p))}px`}),A=()=>{E(),window.removeEventListener("pointermove",$),window.removeEventListener("pointerup",A),window.removeEventListener("pointercancel",A),S()};window.addEventListener("pointermove",$),window.addEventListener("pointerup",A,{once:!0}),window.addEventListener("pointercancel",A,{once:!0})});for(let l of o)l.addEventListener("pointerdown",y=>{if(y.button!==0)return;T(),y.preventDefault(),y.stopPropagation();let I=e.getBoundingClientRect(),p=y.clientX,g=y.clientY,x=l.dataset.resizeCorner==="left",$=I.width,E=I.height,{move:A,finish:v}=b(_=>{let U=x?I.left+$-n:window.innerWidth-I.left-n,H=window.innerHeight-I.top-n,L=Math.max(Math.min(f,U),Math.min(U,$+(_.clientX-p)*(x?-1:1))),z=Math.max(Math.min(300,H),Math.min(H,E+_.clientY-g));e.style.width=`${L}px`,e.style.height=`${z}px`,u=L/z,x&&(e.style.left=`${I.left+$-L}px`)}),N=()=>{v(),window.removeEventListener("pointermove",A),window.removeEventListener("pointerup",N),window.removeEventListener("pointercancel",N),S()};window.addEventListener("pointermove",A),window.addEventListener("pointerup",N,{once:!0}),window.addEventListener("pointercancel",N,{once:!0})});return window.addEventListener("resize",()=>{if(!w||e.style.display!=="flex")return;let l=e.getBoundingClientRect(),y=u||l.width/l.height,I=k(l.width,y);e.style.width=`${I.width}px`,e.style.height=`${I.height}px`,M(),S()}),{ensureGeometry:T}}function Zn(){let e=null,t=-1/0;for(let r of document.querySelectorAll('[role="toolbar"], [class*="toolbar"]')){let o=r.getBoundingClientRect();if(o.width<=80||o.height<=20||o.top<0||o.top>=140||r.offsetParent===null)continue;let a=0;o.top<80&&(a+=20),o.right>innerWidth*.55&&(a+=20),a+=Math.min(10,r.querySelectorAll('button,[role="button"]').length),r.closest("header")&&(a+=15),a>t&&(e=r,t=a)}return e}var It=new Set,He,St=!1,Yt=0;function er(e){if(It.add(e),!He){let t=++Yt;He=new MutationObserver(r=>{r?.length&&r.every(o=>{if(o.target?.closest?.("#dmd-panel, #dmh-panel, #dmd-toolbar-btn, .dmd-select-menu, .dmd-calendar"))return!0;let a=[...o.addedNodes||[],...o.removedNodes||[]];return a.length>0&&a.every(i=>i.matches?.(".dmd-select-menu, .dmd-calendar"))})||St||(St=!0,requestAnimationFrame(()=>{if(t===Yt){St=!1;for(let o of It)o()}}))}),He.observe(document.body,{childList:!0,subtree:!0})}return()=>{It.delete(e),!It.size&&He&&(He.disconnect(),He=null,Yt++,St=!1)}}function We(e){return String(e||"discord").replace(/[\\/:*?"<>|]+/g,"-").replace(/\s+/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").slice(0,80)||"discord"}function Xt(){let e=new Date,t=r=>String(r).padStart(2,"0");return`${e.getFullYear()}${t(e.getMonth()+1)}${t(e.getDate())}-${t(e.getHours())}${t(e.getMinutes())}${t(e.getSeconds())}`}function tr(e,t,r="text/plain;charset=utf-8"){$t(e,new Blob([t],{type:r}))}function $t(e,t){let r=URL.createObjectURL(t),o=document.createElement("a");o.href=r,o.download=e,o.style.display="none",document.body.appendChild(o),o.click(),o.remove(),setTimeout(()=>URL.revokeObjectURL(r),1500)}function Zt(e,t="i",r="Regular expression"){if(e=String(e||"").trim(),!e)return null;if(e.length>1e3)throw new Error(`${r} is too long (maximum 1,000 characters).`);if(!/^[imsu]*$/.test(t)||new Set(t).size!==t.length)throw new Error(`${r}: use only i, m, s, and u flags, without duplicates.`);try{return new RegExp(e,t)}catch(o){throw new Error(`${r}: ${o.message}`,{cause:o})}}function en(e){let t=new Set,r=String(e.content||"");for(let o of r.matchAll(/https?:\/\/[^\s<>`"']+/gi)){let a=o[0].replace(/[.,!;:]+$/,"");for(let i of["**","__","~~","||","*","_"])r.slice(0,o.index).endsWith(i)&&a.endsWith(i)&&(a=a.slice(0,-i.length));for(;a.endsWith(")")&&(a.match(/\)/g)||[]).length>(a.match(/\(/g)||[]).length;)a=a.slice(0,-1);a&&t.add(a)}for(let o of e.embeds||[]){let a=o.url||o.image?.url;/^https?:\/\//i.test(a||"")&&t.add(a)}return[...t]}function Pr(e){let t=(e.attachments||[]).map(r=>({name:String(r.filename||""),url:String(r.url||"")}));for(let r of en(e))try{let a=new URL(r).pathname.split("/").at(-1)||"";try{a=decodeURIComponent(a)}catch{}t.push({name:a,url:r})}catch{}return t}function nr(e,t){if(!t.filename&&!t.extensions.length&&!t.assetPattern)return!0;let r=Pr(e).some(o=>{if(t.filename&&!o.name.toLowerCase().includes(t.filename.toLowerCase()))return!1;let a=o.name.match(/\.([^.]+)$/)?.[1]?.toLowerCase()||"";return!(t.extensions.length&&!t.extensions.includes(a)||t.assetPattern&&!t.assetPattern.test(`${o.name} +${o.url}`))});return t.assetMode==="exclude"?!r:r}function we(e){let t=e.action||"delete";if(!["delete","overwrite","preserve"].includes(t))throw new Error("Choose a valid message action.");let r=String(e.overwriteText??"");if(t==="overwrite"&&(!r.trim()||r.length>2e3))throw new Error("Replacement text must contain between 1 and 2,000 characters.");let o=(h,S)=>{let k=h||S;if(!["any","with","without"].includes(k))throw new Error("Invalid message filter.");return k},a=h=>{let S=Ie(h);if(h&&(!S||!/^\d+$/.test(S)))throw new Error("Invalid message/date boundary.");return S},i=a(e.minId),s=a(e.maxId);if(i&&s&&BigInt(i)>=BigInt(s))throw new Error("After must be earlier than Before.");let d=e.order||"desc";if(!["asc","desc"].includes(d))throw new Error("Invalid message order.");let c=e.textMode||"include";if(!["include","exclude"].includes(c))throw new Error("Invalid text filter.");let f=e.regexMode||"include",n=e.assetMode||"include";if(![f,n].every(h=>["include","exclude"].includes(h)))throw new Error("Invalid pattern filter.");let u=(Array.isArray(e.extensions)?e.extensions:String(e.extensions||"").split(/[\s,;]+/)).map(h=>String(h).trim().replace(/^\*?\./,"").toLowerCase()).filter(Boolean);if(u.some(h=>!/^[a-z0-9_-]+$/.test(h)))throw new Error("Use extensions such as png, jpg, or zip, separated by commas.");let w=Zt(e.textRegex,e.regexFlags??"i","Text regex"),b=Zt(e.assetRegex,e.assetRegexFlags??"i","File/URL regex");return{...e,action:t,overwriteText:r,order:d,textMode:c,minId:i,maxId:s,regexMode:f,assetMode:n,textPattern:w,assetPattern:b,extensions:[...new Set(u)],filename:String(e.filename||"").trim(),collectAll:e.collectAll===!0,content:String(e.content||"").trim(),linkMode:o(e.linkMode,e.hasLink?"with":"any"),fileMode:o(e.fileMode,e.hasFile?"with":"any"),pinnedMode:o(e.pinnedMode,e.includePinned?"any":"without")}}function ge(e,t){if(!t.authorId||e.author?.id!==t.authorId||t.authorIds?.length&&!t.authorIds.includes(e.author?.id)||t.channelId&&e.channel_id!==t.channelId||![0,6,19].includes(e.type)||!/^\d+$/.test(String(e.id||""))||t.minId&&Y(e.id,t.minId)<=0||t.maxId&&Y(e.id,t.maxId)>=0)return!1;let r=(o,a)=>o==="any"||(o==="with"?a:!a);if(t.linkMode!=="any"){let o=/https?:\/\/\S+/i.test(e.content||"")||(e.embeds||[]).some(a=>!!a.url);if(!r(t.linkMode,o))return!1}if(!r(t.fileMode,!!e.attachments?.length)||!r(t.pinnedMode,!!e.pinned))return!1;if(t.content){let o=String(e.content||"").toLowerCase().includes(t.content.toLowerCase());if(t.textMode==="exclude"?o:!o)return!1}if(t.textPattern){let o=t.textPattern.test(String(e.content||""));if(t.regexMode==="exclude"?o:!o)return!1}return!!nr(e,t)}function Y(e,t){let r=String(e),o=String(t);if(r===o)return 0;if(r[0]==="0"||o[0]==="0"){let a=BigInt(r),i=BigInt(o);return ai?1:0}return r.length===o.length?rr*Y(o.id,a.id))}function Ur(e){return e?{id:e.id||null,timestamp:e.timestamp||null,content:e.content||"",author:e.author?{id:e.author.id||null,username:e.author.username||"",globalName:e.author.global_name||null,bot:!!e.author.bot}:null}:null}function Or(e){return{raw:e,id:e.id,timestamp:e.timestamp,editedTimestamp:e.edited_timestamp||null,type:e.type,pinned:!!e.pinned,content:e.content||"",author:e.author?{id:e.author.id||null,username:e.author.username||"",globalName:e.author.global_name||null,discriminator:e.author.discriminator||null,bot:!!e.author.bot}:null,attachments:(e.attachments||[]).map(t=>({id:t.id,filename:t.filename,description:t.description||null,contentType:t.content_type||null,size:t.size||0,url:t.url,proxyUrl:t.proxy_url||null,width:t.width||null,height:t.height||null})),embeds:e.embeds||[],reactions:(e.reactions||[]).map(t=>({count:t.count,emoji:t.emoji,me:!!t.me})),mentions:(e.mentions||[]).map(t=>({id:t.id,username:t.username||"",globalName:t.global_name||null})),referencedMessage:Ur(e.referenced_message)}}function tn({minId:e="",maxId:t=""}={}){e=String(e).trim(),t=String(t).trim();let r=i=>{let s=Ie(i);if(i&&(!s||!/^\d+$/.test(s)))throw new Error("Enter a valid date/time or message ID.");return s},o=r(e),a=r(t);if(o&&a&&BigInt(o)>=BigInt(a))throw new Error("After must be earlier than Before.");return{minId:e,maxId:t}}async function ar({token:e,guildId:t,channelId:r,minId:o,maxId:a,label:i,log:s,stopCheck:d=()=>!1,signal:c}){({minId:o,maxId:a}=tn({minId:o,maxId:a}));let f=o?Ie(o):"",n=a?Ie(a):"",u=null;try{f&&(u=BigInt(f))}catch{}let w=[],b=0;for(s("info",`Exporting conversation ${i||r}...`);!d();){let h=`${B}/channels/${r}/messages?limit=100${n?`&before=${n}`:""}`,S;try{S=await W(h,{headers:{Authorization:e},signal:c},s,d,{minIntervalMs:1e3})}catch(I){if(I instanceof V)break;throw I}if(d())break;if(S.status===401)throw re(),new Error("Discord rejected the authorization token while exporting.");if(S.status===403)throw new Error("Discord denied access to this conversation.");if(!S.ok)throw new Error(`Conversation export failed with HTTP ${S.status}.`);let k=await le(S.json(),c);if(d())break;if(!Array.isArray(k))throw new Error("Discord returned an invalid message page. Export stopped.");if(!k.length)break;b++;let M=n?BigInt(n):null,T=new Set,l=null,y=!1;for(let I of k){if(typeof I?.id!="string"||!/^\d+$/.test(I.id))throw new Error("Discord returned a malformed message. Export stopped.");let p=null;try{p=BigInt(I.id)}catch{}if(p!==null&&!(M!==null&&p>=M)){if(u!==null&&p<=u){y=!0;continue}T.has(I.id)||(T.add(I.id),w.push(Or(I))),(l===null||pY(h.id,S.id)),{exportedAt:new Date().toISOString(),guildId:t,channelId:r,label:i||"",range:{after:o||null,before:a||null},messageCount:w.length,messages:w}}var jr=["guildId","channelId","conversation","id","timestamp","editedTimestamp","authorId","username","content","type","pinned","attachments","embeds","reactions","mentions","referencedMessage","messageData"],qr=["attachments","embeds","reactions","mentions","referencedMessage"],rr=e=>'"'+String(e??"").replace(/"/g,'""')+'"';function*Br(e,t=!0){t&&(yield jr.map(rr).join(","));for(let r of e)for(let o of r.messages)yield[r.guildId,r.channelId,r.label,o.id,o.timestamp,o.editedTimestamp,o.author?.id,o.author?.username,o.content,o.type,o.pinned,...qr.map(a=>JSON.stringify(o[a]??null)),JSON.stringify(o.raw??o)].map(rr).join(",")}function*Hr(e){let{messages:t,...r}=e;yield JSON.stringify({...r,messages:[]},null,2).slice(0,-4)+`[ +`;for(let a=0;a!1}={}){let o=t==="csv"?gr(e,r):hr(e),d=[],i=[],s=0,m=performance.now(),p=()=>{if(a())throw new X},n=()=>{d.push(new Blob([i.join("")])),i=[],s=0};p();for(let u of o)i.push(u),s+=u.length,!(s<65536)&&(p(),n(),performance.now()-m>=8&&(await G(0),p(),m=performance.now()));return p(),i.length&&n(),new Blob(d,{type:t==="csv"?"text/csv;charset=utf-8":"application/json;charset=utf-8"})}function ze(e,t=r=>({id:r.id,channel_id:r.channel_id})){let r=[],a=[],o=e==="asc"?1:-1;return{messages:r,get length(){return r.length},add(d){if(!d.length)return;for(let n of d)r.push(t(n));let i=ke(d,e),s=[];if(a.length===20&&o*Y(i[0].id,a.at(-1).id)>=0)return;let m=0,p=0;for(;s.length<20&&(m=i.length||m[i.id,i]));for(let i=0;i2e3?null:r}var Oe=()=>({deleted:0,overwritten:0,failed:0,alreadyGone:0,skipped:0}),ve=e=>`Deleted ${e.deleted}; overwritten ${e.overwritten}; ${e.alreadyGone} already gone; ${e.skipped} skipped; ${e.failed} failed.`;function je(e,t,r=e.length){let a=t.action==="overwrite",o=t.action==="preserve",d=e.slice(0,20).map(i=>`${i.id} \xB7 ${i.author?.username||"you"}: ${String(i.content||"[NO TEXT]").slice(0,180)}${o?` -Keep: ${Rt(i)??"[UNCHANGED: no media or links, or preserved text exceeds 2,000 characters]"}`:""}`).join(` -`);return re({title:o?"Remove text and keep URLs/images?":a?"Overwrite message text?":"Delete messages?",message:o?`Remove surrounding text from up to ${r} messages, keeping HTTP/HTTPS URLs and all attached files (including images)? Text-only messages are left unchanged. No messages are deleted.`:a?`Replace the text of up to ${r} messages with the text below. Messages and attached files remain. +}`}function*Wr(e,t){let r=!0;for(let o of Br([e],t))yield(r?"":`\r +`)+o,r=!1}async function or(e,{format:t="json",includeCsvHeader:r=!0,stopCheck:o=()=>!1}={}){let a=t==="csv"?Wr(e,r):Hr(e),i=[],s=[],d=0,c=performance.now(),f=()=>{if(o())throw new V},n=()=>{i.push(new Blob([s.join("")])),s=[],d=0};f();for(let u of a)s.push(u),d+=u.length,!(d<65536)&&(f(),n(),performance.now()-c>=8&&(await Q(0),f(),c=performance.now()));return f(),s.length&&n(),new Blob(i,{type:t==="csv"?"text/csv;charset=utf-8":"application/json;charset=utf-8"})}function ir(e,{range:t={},select:r=!0,signal:o}={}){let a=new Set(r?[]:e.map((c,f)=>f)),i={minId:t.minId||"",maxId:t.maxId||""},s="json",d=!1;return se({signal:o,title:"Export Conversations",message:"Choose conversations, a date or message range, and a file format. Blank boundaries export all readable history. All message data returned by Discord is included; enable attachment downloads to include the actual files in a ZIP (up to 4 GB).",yesText:"Export",noText:"Cancel",getResult:()=>({conversations:e.filter((c,f)=>a.has(f)),format:s,range:i,downloadAttachments:d}),render:({box:c,yes:f})=>{let n=[],u=document.createElement("div");u.setAttribute("role","alert");let w=()=>{try{for(let{input:p,kind:g}of n){if(p.validity.badInput)throw new Error("Complete the date and time or clear the boundary.");if(g.value==="id"&&p.value&&!/^\d+$/.test(p.value.trim()))throw new Error("Enter a numeric message ID.")}i=tn(i),u.textContent=""}catch(p){u.textContent=p.message}f.disabled=!a.size||!!u.textContent};w();let b=document.createElement("button");b.type="button",b.className="dmd-btn",b.textContent="Select all",b.disabled=!e.length;let h=document.createElement("div");h.className="dmd-export-list",h.onchange=p=>{let g=p.target,x=Number(g.value);g.checked?a.add(x):a.delete(x),w()},e.forEach((p,g)=>{let x=document.createElement("label");x.className="dmd-check";let $=document.createElement("input");$.type="checkbox",$.value=String(g),$.checked=a.has(g),x.append($,document.createTextNode(p.label||p.channelId)),h.append(x)}),b.onclick=()=>{h.querySelectorAll("input").forEach(p=>{p.checked=!0,a.add(Number(p.value))}),w()};let S=document.createElement("fieldset");S.className="dmd-export-formats";let k=document.createElement("legend");k.textContent="File format",S.append(k);for(let p of["json","csv"]){let g=document.createElement("label");g.className="dmd-check";let x=document.createElement("input");x.type="radio",x.name="dmd-export-format",x.value=p,x.checked=p===s,x.onchange=()=>{x.checked&&(s=p)},g.append(x,document.createTextNode(p.toUpperCase())),S.append(g)}let M=document.createElement("fieldset");M.className="dmd-export-range";let T=document.createElement("legend");T.textContent="Date & message range (local time)",M.append(T);for(let[p,g]of[["minId","After"],["maxId","Before"]]){let x=document.createElement("div");x.className="dmd-field";let $=document.createElement("label");$.htmlFor=`dmd-export-${p}`,$.textContent=g;let E=document.createElement("select");E.setAttribute("aria-label",`${g} boundary type`);for(let[v,N]of[["date","Date & time"],["id","Message ID"]]){let _=document.createElement("option");_.value=v,_.textContent=N,E.append(_)}E.value=/^\d+$/.test(i[p])?"id":"date";let A=document.createElement("input");A.id=$.htmlFor,A.type=E.value==="date"?"datetime-local":"text",A.step="any",A.value=i[p],n.push({input:A,kind:E}),A.oninput=A.onchange=()=>{i[p]=A.value,w()},E.onchange=()=>{A.type=E.value==="date"?"datetime-local":"text",A.value="",i[p]="",w()},x.append($,E,A),M.append(x)}let l=document.createElement("button");l.type="button",l.className="dmd-btn",l.textContent="All dates",l.onclick=()=>{M.querySelectorAll("input").forEach(p=>{p.value=""}),i={minId:"",maxId:""},w()},M.append(l,u);let y=document.createElement("label");y.className="dmd-check";let I=document.createElement("input");I.type="checkbox",I.id="dmd-export-attachments",I.onchange=()=>{d=I.checked},y.append(I,document.createTextNode("Download actual attachments (ZIP)")),c.append(b,h,M,S,y)}})}var F={stopped:!1,reactionStopped:!1,multiStopped:!1};var Jr=Uint32Array.from({length:256},(e,t)=>{for(let r=0;r<8;r++)t=t>>>1^(t&1?3988292384:0);return t}),Me=class extends Error{constructor(t){super(t),this.name="ZipCapacityError"}},Et=(e,t)=>{if(e>=65535)throw new Me("Too many files for one ZIP. Export fewer conversations or a smaller date range.");if(t>=4294967295)throw new Me("ZIP exceeds 4 GB. Export fewer conversations or a smaller date range.")},sr=(e,t)=>{if(!e||e.includes("\\")||e.split("/").some(o=>!o||o==="."||o===".."))throw new Error("Invalid ZIP file path.");if(!Number.isSafeInteger(t)||t<0)throw new Error("Invalid ZIP file size.");let r=new TextEncoder().encode(e);if(r.length>65535||t>=4294967295)throw new Me("ZIP exceeds 4 GB. Export fewer conversations or a smaller date range.");return r};function dr(){let e=new Map,t=22,r=(o,a)=>{let i=e.get(o),s=76+2*sr(o,a).length+a,d=t-(i??0)+s;return Et(e.size+(i===void 0?1:0),d),{entrySize:s,nextSize:d}};return{checkFile(o,a){r(o,a)},setFile(o,a){let{entrySize:i,nextSize:s}=r(o,a);e.set(o,i),t=s}}}async function lr(e,t=()=>!1){let r=()=>{if(t())throw new V};r(),Et(e.length,22);let o=[],a=22,i=performance.now();for(let{name:w,blob:b}of e){r();let h=sr(w,b.size);o.push(h),a+=76+2*h.length+b.size,Et(e.length,a),performance.now()-i>=8&&(await Q(0),r(),i=performance.now())}let s=[],d=[],c=0,f=0;for(let[w,{blob:b}]of e.entries()){r();let h=o[w],S=4294967295;for(let y=0;y>>8^Jr[(S^I[p])&255];performance.now()-i>=8&&(await Q(0),r(),i=performance.now())}r(),S=(S^4294967295)>>>0;let k=new Uint8Array(30),M=new DataView(k.buffer);M.setUint32(0,67324752,!0),M.setUint16(4,20,!0),M.setUint16(6,2048,!0),M.setUint16(12,33,!0),M.setUint32(14,S,!0),M.setUint32(18,b.size,!0),M.setUint32(22,b.size,!0),M.setUint16(26,h.length,!0);let T=new Uint8Array(46),l=new DataView(T.buffer);l.setUint32(0,33639248,!0),l.setUint16(4,20,!0),l.setUint16(6,20,!0),l.setUint16(8,2048,!0),l.setUint16(14,33,!0),l.setUint32(16,S,!0),l.setUint32(20,b.size,!0),l.setUint32(24,b.size,!0),l.setUint16(28,h.length,!0),l.setUint32(42,c,!0),s.push(k,h,b),d.push(T,h),c+=k.length+h.length+b.size,f+=T.length+h.length,performance.now()-i>=8&&(await Q(0),r(),i=performance.now())}r(),Et(e.length,c+f+22);let n=new Uint8Array(22),u=new DataView(n.buffer);return u.setUint32(0,101010256,!0),u.setUint16(8,e.length,!0),u.setUint16(10,e.length,!0),u.setUint32(12,f,!0),u.setUint32(16,c,!0),new Blob([...s,...d,n],{type:"application/zip"})}var nn=()=>({cache:new Map,failedUrls:new Map,consecutiveFailures:0,suspendedReason:""});function*cr(e,t="",r=""){for(let[o,a]of(e.message_snapshots||[]).entries())yield{attachments:a.message?.attachments||[],prefix:`${t}snapshots/${o+1}/`,source:`${r}message_snapshots[${o}].message.attachments`}}function*Gr(e){let t=e.raw||e,r=[{attachments:t.attachments||e.attachments||[],prefix:"",source:"attachments"},...cr(t)];t.type===21&&t.referenced_message&&(r.push({attachments:t.referenced_message.attachments||[],prefix:"referenced-message/",source:"referenced_message.attachments"}),r.push(...cr(t.referenced_message,"referenced-message/","referenced_message.")));for(let{attachments:o,prefix:a,source:i}of r)for(let[s,d]of o.entries())yield{attachment:d,index:s,prefix:a,source:i}}async function mr(e,{files:t,report:r,log:o,stopCheck:a=()=>!1,state:i=nn(),signal:s,budget:d}){let c=()=>{if(a()||s?.aborted)throw new V},f=performance.now();for(let n of e.messages){c(),performance.now()-f>=8&&(await Q(0),c(),f=performance.now());for(let{attachment:u,index:w,prefix:b,source:h}of Gr(n)){c(),performance.now()-f>=8&&(await Q(0),c(),f=performance.now());let S=String(u.filename||"").match(/\.[a-z0-9]{1,16}$/i)?.[0]||"",k=We(Array.from(String(u.filename||"")).filter(p=>p.codePointAt(0)>=32&&p.codePointAt(0)!==127).join("")).replace(/^\.+|\.+$/g,"")||"file",M=S?k.replace(new RegExp(S.replace(".","\\.")+"$","i"),"").slice(0,80-S.length)+S:k,T=`attachments/${We(e.channelId)}/${We(n.id)}/${b}${w+1}-${M}`,l={channelId:e.channelId,messageId:n.id,attachmentId:u.id||null,filename:u.filename||"",url:u.url||null,path:T,source:h},y,I;try{let p=new URL(u.url);if(p.protocol!=="https:")throw new Error("Missing or invalid HTTPS attachment URL.");let g=["cdn.discordapp.com","media.discordapp.net"].includes(p.hostname),x=new URL(p);if(x.hash="",g)for(let _ of["ex","is","hm"])x.searchParams.delete(_);x.searchParams.sort(),y=x.href,I=p.href;let $=i.cache.get(y)||i.failedUrls.get(I);if($){r.push({...l,...$,status:$.status==="downloaded"?"reused":$.status});continue}if(i.suspendedReason){r.push({...l,path:null,status:"skipped",error:i.suspendedReason});continue}d?.checkFile(T,Number.isSafeInteger(u.size)&&u.size>=0?u.size:0),o("verb",`Downloading attachment: ${u.filename||T}`);let E=await W(p.href,{credentials:"omit",referrerPolicy:"no-referrer",signal:s},o,a,{minIntervalMs:1e3});if(c(),!E.ok)throw new Error(`HTTP ${E.status}`);let A=Number(E.headers.get("Content-Length"));if(Number.isSafeInteger(A)&&A>=0)try{d?.checkFile(T,A)}catch(_){throw E.body?.cancel().catch(()=>{}),_}let v=await le(E.blob(),s);c(),d?.setFile(T,v.size),t.push({name:T,blob:v});let N={path:T,status:"downloaded",size:v.size};i.cache.set(y,N),i.consecutiveFailures=0,r.push({...l,...N})}catch(p){if(c(),p instanceof V||p instanceof Me)throw p;let g={path:null,status:"failed",error:String(p.message||p)};I&&i.failedUrls.set(I,g),r.push({...l,...g}),i.consecutiveFailures++,(p instanceof ze||i.consecutiveFailures>=3)&&(i.suspendedReason=p instanceof ze?p.message:"Three consecutive attachment failures. Further attachment requests stopped for this export.",o("warn",i.suspendedReason)),o("warn",`Attachment unavailable: ${u.filename||T} (${p.message||p}).`)}}}}function Je(e,t=r=>({id:r.id,channel_id:r.channel_id})){let r=[],o=[],a=e==="asc"?1:-1;return{messages:r,get length(){return r.length},add(i){if(!i.length)return;for(let n of i)r.push(t(n));let s=Se(i,e),d=[];if(o.length===20&&a*Y(s[0].id,o.at(-1).id)>=0)return;let c=0,f=0;for(;d.length<20&&(c=s.length||c[s.id,s]));for(let s=0;s2e3?null:r}var Ge=()=>({deleted:0,overwritten:0,failed:0,alreadyGone:0,skipped:0}),ve=e=>`Deleted ${e.deleted}; overwritten ${e.overwritten}; ${e.alreadyGone} already gone; ${e.skipped} skipped; ${e.failed} failed.`;function Qe(e,t,r=e.length){let o=t.action==="overwrite",a=t.action==="preserve",i=e.slice(0,20).map(s=>`${s.id} \xB7 ${s.author?.username||"you"}: ${String(s.content||"[NO TEXT]").slice(0,180)}${a?` +Keep: ${rn(s)??"[UNCHANGED: no media or links, or preserved text exceeds 2,000 characters]"}`:""}`).join(` +`);return se({title:a?"Remove text and keep URLs/images?":o?"Overwrite message text?":"Delete messages?",message:a?`Remove surrounding text from up to ${r} messages, keeping HTTP/HTTPS URLs and all attached files (including images)? Text-only messages are left unchanged. No messages are deleted.`:o?`Replace the text of up to ${r} messages with the text below. Messages and attached files remain. Replacement: -${t.overwriteText}`:`Delete up to ${r} messages matching your filters?`,details:d,yesText:o?"Keep URLs/images":a?"Overwrite":"Delete",noText:"Cancel",danger:!0})}async function Pe(e,t,r){let{token:a,action:o,overwriteText:d,log:i,stopCheck:s=()=>!1}=t;if(s())return!1;let m=o!=="delete",p=o==="preserve"?Rt(e):d;if(m&&(p===null||String(e.content||"")===p))return r.skipped++,!0;let n=m,u=await J(`${U}/channels/${e.channel_id}/messages/${e.id}`,{method:n?"PATCH":"DELETE",headers:{Authorization:a,...n?{"Content-Type":"application/json"}:{}},...n?{body:JSON.stringify({content:p,allowed_mentions:{parse:[],replied_user:!1}})}:{}},i,s);if(u.ok)r[n?"overwritten":"deleted"]++,i("verb",`${n?"Overwrote":"Deleted"} message ${e.id}.`);else if(u.status===404)r.alreadyGone++;else{if(u.status===401)return ne(),i("error","401 Unauthorized. Stopping."),!1;r.failed++,i("error",`${n?"Overwrite":"Delete"} failed (${u.status}) for ${e.id}.`)}return s()||await G(ee(n?1600:700,n?2400:1600)),!0}function pt(e,t,r=()=>!1){let a=!1;return async(o,d)=>{let i={headers:{Authorization:e}};if(!a){let n=await J(`${U}/channels/${o}/messages/${d}`,i,t,r);if(n.status!==403)return n;a=!0}if(r())throw new X;let s=await J(`${U}/channels/${o}/messages?around=${d}&limit=3`,i,t,r);if(!s.ok)return s;let m=await s.json();if(!Array.isArray(m))throw new Error("Discord returned invalid message history. Stopping before changes.");let p=m.find(n=>n.id===d&&n.channel_id===o);return p?Response.json(p):new Response(null,{status:404})}}async function Ve(e,t,r){let{token:a,log:o,stopCheck:d=()=>!1,progress:i=()=>{}}=t;if(d())return{stopped:!0,...r};let s=ke(e,t.order);if(o("success",`Scan complete: ${s.length} matching messages collected. No messages changed during scanning.`),!s.length)return o("info","No messages matched the selected author, channels, dates and filters. Start again to scan for new messages."),{done:!0,...r};if(!t.skipConfirm&&!await je(s,t,s.length))return{cancelled:!0,...r};let m=pt(a,o,d);for(let[p,n]of s.entries()){if(d())return{stopped:!0,...r};let u=await m(n.channel_id,n.id);if(d())return{stopped:!0,...r};if(u.status===401)return ne(),{unauthorized:!0,...r};if(u.status===403)return r.failed++,o("error","Discord denied access to message history. Stopping; no further messages will be changed."),{forbidden:!0,...r};if(u.status===404)r.alreadyGone++;else if(!u.ok)r.failed++,o("error",`Could not recheck message ${n.id} (${u.status}).`);else{let y=await u.json();if(y.id!==n.id||y.channel_id!==n.channel_id||!fe(y,t))r.skipped++;else if(!await Pe(y,t,r))return{...d()?{stopped:!0}:{unauthorized:!0},...r}}i(p+1,s.length,t.action)}return o(d()?"warn":"success",`${d()?"Stopped":"Finished"}. ${ve(r)}`),{...d()?{stopped:!0}:{done:!0},...r}}async function On(e,t,r,a,o=0){let{token:d,guildId:i,channelId:s,log:m,stopCheck:p,progress:n=()=>{}}=e,u=Symbol("inaccessible"),y=async(f,$=!1)=>{let g=await J(f,{headers:{Authorization:d},cache:"no-store"},m,p);if(p())throw new X;if(g.status===403&&$)return m("warn","Skipping a server channel whose message history Discord denied access to."),u;if(g.status===401&&ne(),!g.ok)throw new Error(`Fresh message scan failed (${g.status}). Check your access and try again.`);return g.json()},b=[{id:s}];if(s&&i!=="@me"){let f=await y(`${U}/channels/${s}`);if(f.id!==s||f.guild_id!==i)throw new Error("Discord returned a channel outside the selected server.");if(f.nsfw&&!e.includeNsfw){m("info","Fresh history skipped for this NSFW channel. Enable Include NSFW to scan it.");return}}if(!s){let f=await y(`${U}/guilds/${i}/channels`);if(!Array.isArray(f))throw new Error("Discord returned an invalid channel list.");if(f.some(T=>T.guild_id&&T.guild_id!==i))throw new Error("Discord returned channels for another server.");b=f.filter(T=>[0,5].includes(T.type)&&(e.includeNsfw||!T.nsfw));let $=await y(`${U}/guilds/${i}/threads/active`);if(!Array.isArray($.threads))throw new Error("Discord returned an invalid active thread list.");let g=new Map(f.map(T=>[T.id,T]));b.push(...$.threads.filter(T=>{let L=g.get(T.parent_id);return L&&[10,11,12].includes(T.type)&&(!T.guild_id||T.guild_id===i)&&(e.includeNsfw||!L.nsfw)}))}m("info","Checking fresh channel history for messages that Discord search may not have indexed yet\u2026");for(let f of b){if(!/^\d{15,22}$/.test(String(f.id||"")))throw new Error("Discord returned an invalid channel ID.");let $=t.get(f.id),g=$&&(!e.minId||Y($,e.minId)>0)?$:e.minId,T=e.maxId;for(;!p()&&!(T&&g&&Y(T,g)<=0);){let L=new URLSearchParams({limit:"100"});T&&L.set("before",T);let l=await y(`${U}/channels/${f.id}/messages?${L}`,!s);if(l===u)break;if(!Array.isArray(l))throw new Error("Discord returned invalid message history. No further messages changed.");if(!l.length)break;if(l.some(v=>!/^\d+$/.test(String(v?.id||""))||v.channel_id!==f.id))throw new Error("Discord returned invalid history messages. No further messages changed.");let h=l.reduce((v,E)=>Y(E.id,v)<0?E.id:v,l[0].id);if(T&&Y(h,T)>=0)throw new Error("Fresh message history did not advance. Try again.");let w=new Set,S=[];for(let v of l)w.has(v.id)||T&&Y(v.id,T)>=0||g&&Y(v.id,g)<=0||(w.add(v.id),o++,fe(v,e)?S.push(v):a.skipped++);if(r.add(S),n(o,o,"Scanning"),m("verb",`Scanned ${o}; collected ${r.length} fresh and indexed matches.`),g&&Y(h,g)<=0)break;T=h,p()||await G(ee(900,1500))}if(p())throw new X}}async function Ye(e){let t=Oe(),r=e.stopCheck||(()=>O.stopped),a=be({...e,stopCheck:r}),{token:o,authorId:d,guildId:i,channelId:s,content:m,includeNsfw:p,skipConfirm:n=!1,log:u,progress:y=()=>{}}=a,b=a.minId,f=a.maxId,$=n,g=null,T=0,L=0,l=ze(a.order),h=new Map;y(0,1,"Scanning"),u("success",`Started ${a.action} \xB7 ${a.order==="asc"?"oldest first":"newest first"} \xB7 ${_e()} (${i}/${s||"all channels"})`);try{for(;!r();){let w=i==="@me"?`${U}/channels/${s}/messages/search`:`${U}/guilds/${i}/messages/search`,S=Cn([["author_id",d],["channel_id",i!=="@me"?s:void 0],["min_id",b],["max_id",f],["sort_by","timestamp"],["sort_order",a.order],["offset",0],["has",a.linkMode==="with"?"link":void 0],["has",a.fileMode==="with"?"file":void 0],["content",a.textMode==="include"?m:void 0],["include_nsfw",p?"true":void 0]]),v=await J(`${w}?${S}`,{headers:{Authorization:o},cache:"no-store"},u,r);if(r())break;if(v.status===202){let C={};try{C=await v.json()}catch{}await G(ye(C.retry_after||2));continue}if(v.status===401)return ne(),u("error","401 Unauthorized. Press GET next to Authorization and try again."),{unauthorized:!0,...t};if(!v.ok)return u("error",`Search failed with HTTP ${v.status}.`),{httpStatus:v.status,forbidden:v.status===403,...t};let E=await v.json(),M=(Array.isArray(E.messages)?E.messages:[]).map(C=>Array.isArray(C)?C.find(R=>R?.hit)||C[0]:null).filter(C=>C&&/^\d+$/.test(String(C.id||"")));if(g??=Number(E.total_results||M.length),!M.length)return a.freshHistory?(await On(a,h,l,t,L),await Ve(l.finish(),a,t)):a.collectAll?await Ve(l.finish(),a,t):(u("success",`Finished. ${ve(t)}`),{done:!0,...t});let D=ke(M,a.order);if(a.freshHistory)for(let C of D){let R=h.get(C.channel_id);(!R||Y(C.id,R)>0)&&h.set(C.channel_id,C.id)}let x=D.at(-1).id,N=a.order==="asc"?b:f;if(N&&(a.order==="asc"?Y(x,N)<=0:Y(x,N)>=0))return u("error","Discord returned a page that did not advance. Stopping to avoid repeating messages."),{stalled:!0,...t};a.order==="asc"?b=x:f=x;let _=new Set,j=D.filter(C=>_.has(C.id)||N&&(a.order==="asc"?Y(C.id,N)<=0:Y(C.id,N)>=0)?!1:(_.add(C.id),!0)),q=j.filter(C=>fe(C,a));if(L+=j.length,t.skipped+=j.length-q.length,T+=j.length-q.length,a.collectAll){l.add(q),y(L,Math.max(g,L),"Scanning"),u("verb",`Scanned ${L}; collected ${l.length} matches. No messages changed.`),r()||await G(ee(900,1500));continue}if(!$&&q.length&&($=await je(q,a,g),!$))return u("warn","Cancelled."),{cancelled:!0,...t};for(let C of q){if(r())break;if(!await Pe(C,a,t)){if(!r())return{unauthorized:!0,...t};break}T++,y(T,Math.max(g,T),a.action)}r()||await G(ee(900,1500))}}catch(w){if(!(w instanceof X))throw w}return u("warn",`Stopped. ${ve(t)}`),{stopped:!0,...t}}async function Ue(e,t){let r=await J(e,{headers:{Authorization:t.token}},t.log,t.stopCheck);if(t.stopCheck?.())throw new X;if(!r.ok)throw new Error(`Forum request failed (${r.status}). Check your access to this forum/thread.`);return r.json()}async function jn({token:e,forumId:t,includeArchived:r=!0,log:a=()=>{},stopCheck:o=()=>!1}){if(!/^\d{15,22}$/.test(String(t||"")))throw new Error("Enter a valid forum channel ID.");let d={token:e,log:a,stopCheck:o},i=await Ue(`${U}/channels/${t}`,d);if(![15,16].includes(i.type)||i.id!==t||!i.guild_id)throw new Error("Select a forum or media channel, rather than an individual thread.");let s=new Map,m=n=>{if(!Array.isArray(n))throw new Error("Discord returned an invalid thread list.");for(let u of n)u.parent_id===t&&u.type===11&&/^\d{15,22}$/.test(u.id)&&s.set(u.id,{...u,guild_id:i.guild_id})},p=await Ue(`${U}/guilds/${i.guild_id}/threads/active`,d);if(m(p.threads),r){let n="";for(;!o();){let u=new URLSearchParams({limit:"100"});n&&u.set("before",n);let y=await Ue(`${U}/channels/${t}/threads/archived/public?${u}`,d);if(m(y.threads),!y.has_more)break;let f=y.threads.map($=>$.thread_metadata?.archive_timestamp).filter($=>Number.isFinite(Date.parse($))).sort(($,g)=>Date.parse($)-Date.parse(g))[0];if(!f||n&&Date.parse(f)>=Date.parse(n))throw new Error("Archived thread list did not advance. No incomplete list will be used.");n=f,await G(ee(900,1500))}}if(o())throw new X;return[...s.values()].sort((n,u)=>String(n.name||"").localeCompare(String(u.name||"")))}async function ft(e){let t=be({...e,collectAll:!0}),{channelId:r,log:a,stopCheck:o=()=>!1,progress:d=()=>{}}=t,i=Oe(),s=ze(t.order),m=0;try{let p=await Ue(`${U}/channels/${r}`,t);if(p.id!==r||![10,11,12].includes(p.type))throw new Error("Select an individual thread for thread cleanup.");let n=await Ue(`${U}/channels/${p.parent_id}`,t);if(!(e.allowAnyThread?[0,5,15,16]:[15,16]).includes(n.type)||n.guild_id!==t.guildId||n.id!==p.parent_id)throw new Error("This thread does not belong to the selected forum/server.");let u=t.maxId;for(;!o();){let y=new URLSearchParams({limit:"100"});u&&y.set("before",u);let b=await Ue(`${U}/channels/${r}/messages?${y}`,t);if(!Array.isArray(b))throw new Error("Discord returned an invalid thread message page.");if(!b.length)break;let f=b.filter(L=>/^\d+$/.test(String(L.id||"")));if(f.length!==b.length)throw new Error("Thread page contains invalid message IDs. No messages changed.");let $=f.reduce((L,l)=>Y(l.id,L)<0?l.id:L,f[0].id);if(u&&Y($,u)>=0)throw new Error("Thread history did not advance. No messages changed.");let g=[],T=new Set;for(let L of f)T.has(L.id)||u&&Y(L.id,u)>=0||(T.add(L.id),m++,fe(L,t)?g.push(L):i.skipped++);if(s.add(g),d(m,m,"Scanning thread"),a("verb",`Scanned ${m} messages in ${p.name||r}; collected ${s.length} matches.`),t.minId&&Y($,t.minId)<=0)break;u=$,o()||await G(ee(900,1500))}return await Ve(s.finish(),t,i)}catch(p){if(!(p instanceof X))throw p;return{stopped:!0,...i}}}function Pn(e){let t=[],r=[],a="",o=!1,d=String(e).replace(/^\uFEFF/,"");for(let i=0;im!=="")&&t.push(r),r=[],a=""):a+=s}if(o)throw new Error("CSV has an unfinished quoted field.");return r.push(a),r.some(i=>i!=="")&&t.push(r),t}function Ft(e){let t=String(e).replace(/^\uFEFF/,""),r=[],a=/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y,o=0,d=!1;for(let i=0;i="0"&&s<="9"){a.lastIndex=i;let m=a.exec(t);if(!m)continue;let p=m[0];/^\d{16,}$/.test(p)&&(r.push(t.slice(o,i),'"',p,'"'),o=i+p.length),i+=p.length-1}}return r.length?(r.push(t.slice(o)),JSON.parse(r.join(""))):JSON.parse(t)}function Ot(e,t){if(typeof e!="string"||!/^\d{15,22}$/.test(e.trim()))throw new Error(`${t} must be a full Discord ID stored as text.`);return e.trim()}function qe(e){let t=new Map;for(let r of e){let a=Ot(r.channelId,"Channel ID"),o=Ot(r.messageId,"Message ID");t.set(`${a}/${o}`,{channelId:a,messageId:o})}return[...t.values()]}function xr(e,t){return e.map(r=>{if(typeof r=="string"){let a=Ke(r);return a?{channelId:a.channelId||t,messageId:a.messageId}:null}return!r||typeof r!="object"?null:{channelId:r.channel_id||r.channelId||t,messageId:r.id||r.ID||r.message_id||r.messageId}}).filter(r=>r&&/^\d{15,22}$/.test(String(r.messageId||"")))}function Ee(e,t=""){if(String(e||"").length>12e5)throw new Error(`Message ID input is too large. Use at most ${1e4} IDs per batch.`);let r=String(e||"").trim();if(r.split(/\r?\n/).length>1e4)throw new Error(`Use at most ${1e4} lines per batch.`);if(!r)return[];if(/^[[{]/.test(r)){let o=Ft(r);if(!o||typeof o!="object")throw new Error("Choose a JSON message array or message record.");let d=Array.isArray(o)?o:Array.isArray(o.messages)?o.messages:[o];if(d.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);return qe(xr(d,o.channelId||o.channel_id||t))}let a=r.split(/[\s,;]+/).filter(Boolean);if(a.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);return qe(a.map(o=>{let d=Ke(o);return!d||d.channelId&&!/^(?:https?:\/\/)?(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/channels\/[^/\s]+\/\d+\/\d+\/?$/i.test(o)?null:{channelId:d.channelId||t,messageId:d.messageId}}).filter(Boolean))}async function Un(e,t=""){let r=[...e||[]],a=m=>String(m.webkitRelativePath||m.name).replace(/\\/g,"/"),o=m=>m.slice(0,Math.max(0,m.lastIndexOf("/"))).toLowerCase(),d=new Map,i=[];for(let m of r){if(!/(?:^|\/)channel\.json$/i.test(a(m)))continue;let p=Ft(await m.text());d.set(o(a(m)),Ot(p.id,"Channel ID"))}let s=0;for(let m of r){let p=a(m),n=p.split("/").at(-1);if(!/\.(json|csv|txt)$/i.test(n)||/^(?:index|channel|user)\.json$/i.test(n)||m.webkitRelativePath&&!/^messages?\.(json|csv)$/i.test(n))continue;let u=o(p),y=d.get(u)||u.match(/(?:^|\/)c?(\d{15,22})$/)?.[1]||t;if(m.size>12e5)throw new Error(`${n}: file is too large. Split it into batches of at most ${1e4} message IDs.`);let b=await m.text();if(b.length>12e5)throw new Error(`${n}: file is too large. Split it into smaller batches.`);try{if(/\.csv$/i.test(n)){let[f=[],...$]=Pn(b);if($.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);let g=l=>l.trim().toLowerCase().replace(/[\s_]/g,""),T=f.findIndex(l=>["id","messageid"].includes(g(l))),L=f.findIndex(l=>g(l)==="channelid");if(T<0)throw new Error("CSV needs an ID or Message ID column.");i.push(...qe($.filter(l=>/^\d{15,22}$/.test(l[T]?.trim()||"")).map(l=>({messageId:l[T],channelId:L<0?y:l[L]}))))}else i.push(...Ee(b,y));if(i.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);s++}catch(f){throw new Error(`${n}: ${f.message}`,{cause:f})}}if(!s)throw new Error("No message ID files found. Choose message JSON/CSV files or an extracted data package folder.");return qe(i)}function br(e,t){let r={id:e.id,channel_id:e.channel_id};return t!=="delete"&&(r.content=e.content),t==="preserve"&&(e.attachments?.length&&(r.attachments=[{}]),r.embeds=(e.embeds||[]).map(a=>({url:a.url,...a.image?.url?{image:{url:a.image.url}}:{}}))),r}async function jt(e){let t=be(e),{token:r,authorId:a,log:o,progress:d=()=>{},stopCheck:i=()=>!1}=t,s=qe(e.targets||[]);if(!s.length)throw new Error("Paste or import at least one message ID.");let m=Oe(),p=ze(t.order,y=>br(y,t.action)),n=pt(r,o,i),u=ke(s.map(y=>({...y,id:y.messageId})),t.order);try{for(let[b,f]of u.entries()){if(i())break;let $=await n(f.channelId,f.messageId);if(i())break;if($.status===401)return ne(),o("error","401 Unauthorized while checking IDs."),{unauthorized:!0,...m};if($.status===404)m.alreadyGone++;else{if($.status===403)return m.failed++,o("error","Discord denied access to message history. Stopping before changes."),{forbidden:!0,...m};if(!$.ok)m.failed++,o("error",`Could not check message ${f.messageId} (${$.status}).`);else{let g=await $.json();g.id===f.messageId&&g.channel_id===f.channelId&&fe(g,{...t,authorId:a,channelId:f.channelId})?p.add([g]):m.skipped++}}d(b+1,s.length,"Checking IDs"),!i()&&b\s]+:(\d{15,22})>$/)?.[1]||r;if(!/^\d{15,22}$/.test(a))throw new Error("Reaction filter must contain emoji IDs or custom emoji tags, separated by commas.");t.add(a)}return t}function Bn(e,t){return!t.size||t.has(String(e?.id||""))}function vr(e){return encodeURIComponent(e?.id?`${e.name||"_"}:${e.id}`:e?.name||"")}async function Hn(e){let{token:t,channelId:r,startId:a,scanLimit:o,skipOwn:d,authorId:i,log:s,progress:m=()=>{}}=e,p=qn(e.reactionIds),n=e.reactionAuthorId?.trim()?[...new Set(e.reactionAuthorId.split(/[\s,;]+/).filter(Boolean))]:[i];if(n.some(b=>b!==i&&!/^\d{15,22}$/.test(b)))throw new Error("Author(s) must contain valid user IDs.");let u=n.length===1&&n[0]===i;O.reactionStopped=!1;let y=()=>O.reactionStopped;try{let b="";if(a){let h=Ce(a);if(!h)return s("error","Start message/date is invalid.");b=(BigInt(h)+1n).toString()}let f=[],$=[],g=0;for(;g!/^\d+$/.test(String(M.id||""))))throw new Error("Discord returned invalid reaction history IDs. No reactions changed.");let E=v.reduce((M,D)=>Y(D.id,M)<0?D.id:M,v[0].id);if(b&&Y(E,b)>=0)throw new Error("Reaction history did not advance. No reactions changed.");let I=new Set;for(let M of v){if(I.has(M.id)||b&&Y(M.id,b)>=0||(I.add(M.id),g++,d&&M.author?.id===i))continue;let D=new Set;for(let x of M.reactions||[]){if(!Bn(x.emoji,p))continue;let N=vr(x.emoji);for(let _ of n){if(y())break;let j=`${N}/${_}`;if(D.has(j))continue;let q=_===i,C=!!(x.me||x.me_burst);if(!q){let R=await J(`${U}/channels/${r}/messages/${M.id}/reactions/${N}?limit=1&after=${BigInt(_)-1n}`,{headers:{Authorization:t}},s,y);if(!R.ok)throw new Error(`Could not verify reaction authors (HTTP ${R.status}).`);let z=await R.json();if(C=Array.isArray(z)&&z.some(W=>W.id===_),await G(ee(550,900)),y())break}C&&(D.add(j),$.length<20&&$.push(`${_} \xB7 ${M.id} \xB7 ${x.emoji.name||"emoji"}${x.emoji.id?` (${x.emoji.id})`:""} -${(M.content||"").slice(0,500)}`),f.push({reactionAuthorId:_,messageId:M.id,encodedEmoji:N}))}}if(g>=o)break}b=E,m(g,o,"Scanning"),await G(ee(1700,2600))}if(O.reactionStopped)return s("warn","Reaction scan stopped.");if(!f.length)return s("success",`Scanned ${g} messages; no reactions from the selected author were found.`);if(!await re({title:"Remove reactions?",message:`Remove ${f.length} ${u?"of your reactions":`reactions from ${n.join(", ")}`} found in ${g} scanned messages?${p.size?` -Emoji IDs: ${[...p].join(", ")}`:""}`,details:$.join(` +${t.overwriteText}`:`Delete up to ${r} messages matching your filters?`,details:i,yesText:a?"Keep URLs/images":o?"Overwrite":"Delete",noText:"Cancel",danger:!0})}async function Ke(e,t,r){let{token:o,action:a,overwriteText:i,log:s,stopCheck:d=()=>!1}=t;if(d())return!1;let c=a!=="delete",f=a==="preserve"?rn(e):i;if(c&&(f===null||String(e.content||"")===f))return r.skipped++,!0;let n=c,u=await W(`${B}/channels/${e.channel_id}/messages/${e.id}`,{method:n?"PATCH":"DELETE",headers:{Authorization:o,...n?{"Content-Type":"application/json"}:{}},...n?{body:JSON.stringify({content:f,allowed_mentions:{parse:[],replied_user:!1}})}:{}},s,d);if(u.ok)r[n?"overwritten":"deleted"]++,s("verb",`${n?"Overwrote":"Deleted"} message ${e.id}.`);else if(u.status===404)r.alreadyGone++;else{if(u.status===401)return re(),s("error","401 Unauthorized. Stopping."),!1;r.failed++,s("error",`${n?"Overwrite":"Delete"} failed (${u.status}) for ${e.id}.`)}return d()||await Q(ee(n?1600:700,n?2400:1600)),!0}function Ct(e,t,r=()=>!1){let o=!1;return async(a,i)=>{let s={headers:{Authorization:e}};if(!o){let n=await W(`${B}/channels/${a}/messages/${i}`,s,t,r);if(n.status!==403)return n;o=!0}if(r())throw new V;let d=await W(`${B}/channels/${a}/messages?around=${i}&limit=3`,s,t,r);if(!d.ok)return d;let c=await d.json();if(!Array.isArray(c))throw new Error("Discord returned invalid message history. Stopping before changes.");let f=c.find(n=>n.id===i&&n.channel_id===a);return f?Response.json(f):new Response(null,{status:404})}}async function st(e,t,r){let{token:o,log:a,stopCheck:i=()=>!1,progress:s=()=>{}}=t;if(i())return{stopped:!0,...r};let d=Se(e,t.order);if(a("success",`Scan complete: ${d.length} matching messages collected. No messages changed during scanning.`),!d.length)return a("info","No messages matched the selected author, channels, dates and filters. Start again to scan for new messages."),{done:!0,...r};if(!t.skipConfirm&&!await Qe(d,t,d.length))return{cancelled:!0,...r};let c=Ct(o,a,i);for(let[f,n]of d.entries()){if(i())return{stopped:!0,...r};let u=await c(n.channel_id,n.id);if(i())return{stopped:!0,...r};if(u.status===401)return re(),{unauthorized:!0,...r};if(u.status===403)return r.failed++,a("error","Discord denied access to message history. Stopping; no further messages will be changed."),{forbidden:!0,...r};if(u.status===404)r.alreadyGone++;else if(!u.ok)r.failed++,a("error",`Could not recheck message ${n.id} (${u.status}).`);else{let w=await u.json();if(w.id!==n.id||w.channel_id!==n.channel_id||!ge(w,t))r.skipped++;else if(!await Ke(w,t,r))return{...i()?{stopped:!0}:{unauthorized:!0},...r}}s(f+1,d.length,t.action)}return a(i()?"warn":"success",`${i()?"Stopped":"Finished"}. ${ve(r)}`),{...i()?{stopped:!0}:{done:!0},...r}}async function ur(e,t,r,o,a=0){let{token:i,guildId:s,channelId:d,log:c,stopCheck:f,progress:n=()=>{}}=e,u=Symbol("inaccessible"),w=async(h,S="")=>{let k=await W(h,{headers:{Authorization:i},cache:"no-store"},c,f);if(f())throw new V;if(k.status===403&&S)return c("warn",S),u;if(k.status===401&&re(),!k.ok)throw new Error(`Fresh message scan failed (${k.status}) for ${new URL(h).pathname}. Check your access and try again.`);return k.json()},b=[{id:d}];if(d&&s!=="@me"){let h=await w(`${B}/channels/${d}`);if(h.id!==d||h.guild_id!==s)throw new Error("Discord returned a channel outside the selected server.");if(h.nsfw&&!e.includeNsfw){c("info","Fresh history skipped for this NSFW channel. Enable Include NSFW to scan it.");return}}if(!d){let h=await w(`${B}/guilds/${s}/channels`,"Discord denied server channel discovery (403). Continuing with channels found in search. Select individual channels to scan new messages in channels missing from search."),S=h===u?[]:h;if(!Array.isArray(S))throw new Error("Discord returned an invalid channel list.");if(S.some(p=>p.guild_id&&p.guild_id!==s))throw new Error("Discord returned channels for another server.");b=S.filter(p=>[0,5].includes(p.type)&&(e.includeNsfw||!p.nsfw));let k=await w(`${B}/guilds/${s}/threads/active`,"Discord denied active thread discovery (403). Continuing with server channels and threads found in search. Select individual threads to scan new messages in threads missing from search."),M=k===u?[]:k.threads;if(!Array.isArray(M))throw new Error("Discord returned an invalid active thread list.");let T=new Map(S.map(p=>[p.id,p])),l=new Map(M.map(p=>[p.id,p]));b.push(...M.filter(p=>{let g=T.get(p.parent_id);return g&&[10,11,12].includes(p.type)&&(!p.guild_id||p.guild_id===s)&&(e.includeNsfw||!g.nsfw)}));let y=new Set(b.map(p=>p.id)),I=async p=>{if(!/^\d{15,22}$/.test(String(p||"")))throw new Error("Discord returned an invalid channel ID.");let g=await w(`${B}/channels/${p}`,`Discord denied channel metadata (403) for ${p}. Skipping its fresh history scan because its server and NSFW setting could not be checked.`);if(g===u)return null;if(g?.id!==p||g.guild_id!==s)throw new Error("Discord returned a channel outside the selected server.");return g};for(let p of t.keys()){if(y.has(p))continue;let g=l.get(p);if(g&&(![10,11,12].includes(g.type)||g.guild_id&&g.guild_id!==s))continue;let x=T.get(p)||g||await I(p);if(x){if([10,11,12].includes(x.type)){if(g=x,x=T.get(g.parent_id)||await I(g.parent_id),!x)continue;if(![0,5,15,16].includes(x.type))throw new Error("Discord returned an invalid thread parent.");T.set(x.id,x)}else if(![0,5].includes(x.type))throw new Error("Discord returned an unsupported message channel.");(e.includeNsfw||!x.nsfw)&&(b.push({id:p}),y.add(p))}}}c("info","Checking fresh channel history for messages that Discord search may not have indexed yet\u2026");for(let h of b){if(!/^\d{15,22}$/.test(String(h.id||"")))throw new Error("Discord returned an invalid channel ID.");let S=t.get(h.id),k=S&&(!e.minId||Y(S,e.minId)>0)?S:e.minId,M=e.maxId;for(;!f()&&!(M&&k&&Y(M,k)<=0);){let T=new URLSearchParams({limit:"100"});M&&T.set("before",M);let l=await w(`${B}/channels/${h.id}/messages?${T}`,d?"":`Discord denied message history for channel ${h.id} (403). Skipping its fresh history scan; collected search matches will still be rechecked.`);if(l===u)break;if(!Array.isArray(l))throw new Error("Discord returned invalid message history. No further messages changed.");if(!l.length)break;if(l.some(g=>!/^\d+$/.test(String(g?.id||""))||g.channel_id!==h.id))throw new Error("Discord returned invalid history messages. No further messages changed.");let y=l.reduce((g,x)=>Y(x.id,g)<0?x.id:g,l[0].id);if(M&&Y(y,M)>=0)throw new Error("Fresh message history did not advance. Try again.");let I=new Set,p=[];for(let g of l)I.has(g.id)||M&&Y(g.id,M)>=0||k&&Y(g.id,k)<=0||(I.add(g.id),a++,ge(g,e)?p.push(g):o.skipped++);if(r.add(p),n(a,a,"Scanning"),c("verb",`Scanned ${a}; collected ${r.length} fresh and indexed matches.`),k&&Y(y,k)<=0)break;M=y,f()||await Q(ee(900,1500))}if(f())throw new V}}async function dt(e){let t=Ge(),r=e.stopCheck||(()=>F.stopped),o=we({...e,stopCheck:r}),{token:a,authorId:i,guildId:s,channelId:d,content:c,includeNsfw:f,skipConfirm:n=!1,log:u,progress:w=()=>{}}=o,b=o.minId,h=o.maxId,S=n,k=null,M=0,T=0,l=Je(o.order),y=new Map;w(0,1,"Scanning"),u("success",`Started ${o.action} \xB7 ${o.order==="asc"?"oldest first":"newest first"} \xB7 ${qe()} (${s}/${d||"all channels"})`);try{for(;!r();){let I=s==="@me"?`${B}/channels/${d}/messages/search`:`${B}/guilds/${s}/messages/search`,p=Vn([["author_id",i],["channel_id",s!=="@me"?d:void 0],["min_id",b],["max_id",h],["sort_by","timestamp"],["sort_order",o.order],["offset",0],["has",o.linkMode==="with"?"link":void 0],["has",o.fileMode==="with"?"file":void 0],["content",o.textMode==="include"?c:void 0],["include_nsfw",f?"true":void 0]]),g=await W(`${I}?${p}`,{headers:{Authorization:a},cache:"no-store"},u,r);if(r())break;if(g.status===202){let L={};try{L=await g.json()}catch{}await Q(Re(L.retry_after||2));continue}if(g.status===401)return re(),u("error","401 Unauthorized. Press GET next to Authorization and try again."),{unauthorized:!0,...t};if(!g.ok)return u("error",`Search failed with HTTP ${g.status}.`),{httpStatus:g.status,forbidden:g.status===403,...t};let x=await g.json(),E=(Array.isArray(x.messages)?x.messages:[]).map(L=>Array.isArray(L)?L.find(z=>z?.hit)||L[0]:null).filter(L=>L&&/^\d+$/.test(String(L.id||"")));if(k??=Number(x.total_results||E.length),!E.length)return o.freshHistory?(await ur(o,y,l,t,T),await st(l.finish(),o,t)):o.collectAll?await st(l.finish(),o,t):(u("success",`Finished. ${ve(t)}`),{done:!0,...t});let A=Se(E,o.order);if(o.freshHistory)for(let L of A){let z=y.get(L.channel_id);(!z||Y(L.id,z)>0)&&y.set(L.channel_id,L.id)}let v=A.at(-1).id,N=o.order==="asc"?b:h;if(N&&(o.order==="asc"?Y(v,N)<=0:Y(v,N)>=0))return u("error","Discord returned a page that did not advance. Stopping to avoid repeating messages."),{stalled:!0,...t};o.order==="asc"?b=v:h=v;let _=new Set,U=A.filter(L=>_.has(L.id)||N&&(o.order==="asc"?Y(L.id,N)<=0:Y(L.id,N)>=0)?!1:(_.add(L.id),!0)),H=U.filter(L=>ge(L,o));if(T+=U.length,t.skipped+=U.length-H.length,M+=U.length-H.length,o.collectAll){l.add(H),w(T,Math.max(k,T),"Scanning"),u("verb",`Scanned ${T}; collected ${l.length} matches. No messages changed.`),r()||await Q(ee(900,1500));continue}if(!S&&H.length&&(S=await Qe(H,o,k),!S))return u("warn","Cancelled."),{cancelled:!0,...t};for(let L of H){if(r())break;if(!await Ke(L,o,t)){if(!r())return{unauthorized:!0,...t};break}M++,w(M,Math.max(k,M),o.action)}r()||await Q(ee(900,1500))}}catch(I){if(!(I instanceof V))throw I}return u("warn",`Stopped. ${ve(t)}`),{stopped:!0,...t}}async function Ve(e,t){let r=await W(e,{headers:{Authorization:t.token}},t.log,t.stopCheck);if(t.stopCheck?.())throw new V;if(!r.ok)throw new Error(`Forum request failed (${r.status}). Check your access to this forum/thread.`);return r.json()}async function pr({token:e,forumId:t,includeArchived:r=!0,log:o=()=>{},stopCheck:a=()=>!1}){if(!/^\d{15,22}$/.test(String(t||"")))throw new Error("Enter a valid forum channel ID.");let i={token:e,log:o,stopCheck:a},s=await Ve(`${B}/channels/${t}`,i);if(![15,16].includes(s.type)||s.id!==t||!s.guild_id)throw new Error("Select a forum or media channel, rather than an individual thread.");let d=new Map,c=n=>{if(!Array.isArray(n))throw new Error("Discord returned an invalid thread list.");for(let u of n)u.parent_id===t&&u.type===11&&/^\d{15,22}$/.test(u.id)&&d.set(u.id,{...u,guild_id:s.guild_id})},f=await Ve(`${B}/guilds/${s.guild_id}/threads/active`,i);if(c(f.threads),r){let n="";for(;!a();){let u=new URLSearchParams({limit:"100"});n&&u.set("before",n);let w=await Ve(`${B}/channels/${t}/threads/archived/public?${u}`,i);if(c(w.threads),!w.has_more)break;let h=w.threads.map(S=>S.thread_metadata?.archive_timestamp).filter(S=>Number.isFinite(Date.parse(S))).sort((S,k)=>Date.parse(S)-Date.parse(k))[0];if(!h||n&&Date.parse(h)>=Date.parse(n))throw new Error("Archived thread list did not advance. No incomplete list will be used.");n=h,await Q(ee(900,1500))}}if(a())throw new V;return[...d.values()].sort((n,u)=>String(n.name||"").localeCompare(String(u.name||"")))}async function Mt(e){let t=we({...e,collectAll:!0}),{channelId:r,log:o,stopCheck:a=()=>!1,progress:i=()=>{}}=t,s=Ge(),d=Je(t.order),c=0;try{let f=await Ve(`${B}/channels/${r}`,t);if(f.id!==r||![10,11,12].includes(f.type))throw new Error("Select an individual thread for thread cleanup.");let n=await Ve(`${B}/channels/${f.parent_id}`,t);if(!(e.allowAnyThread?[0,5,15,16]:[15,16]).includes(n.type)||n.guild_id!==t.guildId||n.id!==f.parent_id)throw new Error("This thread does not belong to the selected forum/server.");if(n.nsfw&&!t.includeNsfw)return o("info","Thread cleanup skipped because its parent is NSFW. Enable Include NSFW to clean it."),{done:!0,...s};let u=t.maxId;for(;!a();){let w=new URLSearchParams({limit:"100"});u&&w.set("before",u);let b=await Ve(`${B}/channels/${r}/messages?${w}`,t);if(!Array.isArray(b))throw new Error("Discord returned an invalid thread message page.");if(!b.length)break;let h=b.filter(T=>/^\d+$/.test(String(T.id||"")));if(h.length!==b.length)throw new Error("Thread page contains invalid message IDs. No messages changed.");let S=h.reduce((T,l)=>Y(l.id,T)<0?l.id:T,h[0].id);if(u&&Y(S,u)>=0)throw new Error("Thread history did not advance. No messages changed.");let k=[],M=new Set;for(let T of h)M.has(T.id)||u&&Y(T.id,u)>=0||(M.add(T.id),c++,ge(T,t)?k.push(T):s.skipped++);if(d.add(k),i(c,c,"Scanning thread"),o("verb",`Scanned ${c} messages in ${f.name||r}; collected ${d.length} matches.`),t.minId&&Y(S,t.minId)<=0)break;u=S,a()||await Q(ee(900,1500))}return await st(d.finish(),t,s)}catch(f){if(!(f instanceof V))throw f;return{stopped:!0,...s}}}function fr(e){let t=[],r=[],o="",a=!1,i=String(e).replace(/^\uFEFF/,"");for(let s=0;sc!=="")&&t.push(r),r=[],o=""):o+=d}if(a)throw new Error("CSV has an unfinished quoted field.");return r.push(o),r.some(s=>s!=="")&&t.push(r),t}function an(e){let t=String(e).replace(/^\uFEFF/,""),r=[],o=/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y,a=0,i=!1;for(let s=0;s="0"&&d<="9"){o.lastIndex=s;let c=o.exec(t);if(!c)continue;let f=c[0];/^\d{16,}$/.test(f)&&(r.push(t.slice(a,s),'"',f,'"'),a=s+f.length),s+=f.length-1}}return r.length?(r.push(t.slice(a)),JSON.parse(r.join(""))):JSON.parse(t)}function sn(e,t){if(typeof e!="string"||!/^\d{15,22}$/.test(e.trim()))throw new Error(`${t} must be a full Discord ID stored as text.`);return e.trim()}function Ye(e){let t=new Map;for(let r of e){let o=sn(r.channelId,"Channel ID"),a=sn(r.messageId,"Message ID");t.set(`${o}/${a}`,{channelId:o,messageId:a})}return[...t.values()]}function Qr(e,t){return e.map(r=>{if(typeof r=="string"){let o=it(r);return o?{channelId:o.channelId||t,messageId:o.messageId}:null}return!r||typeof r!="object"?null:{channelId:r.channel_id||r.channelId||t,messageId:r.id||r.ID||r.message_id||r.messageId}}).filter(r=>r&&/^\d{15,22}$/.test(String(r.messageId||"")))}function Ae(e,t=""){if(String(e||"").length>12e5)throw new Error(`Message ID input is too large. Use at most ${1e4} IDs per batch.`);let r=String(e||"").trim();if(r.split(/\r?\n/).length>1e4)throw new Error(`Use at most ${1e4} lines per batch.`);if(!r)return[];if(/^[[{]/.test(r)){let a=an(r);if(!a||typeof a!="object")throw new Error("Choose a JSON message array or message record.");let i=Array.isArray(a)?a:Array.isArray(a.messages)?a.messages:[a];if(i.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);return Ye(Qr(i,a.channelId||a.channel_id||t))}let o=r.split(/[\s,;]+/).filter(Boolean);if(o.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);return Ye(o.map(a=>{let i=it(a);return!i||i.channelId&&!/^(?:https?:\/\/)?(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/channels\/[^/\s]+\/\d+\/\d+\/?$/i.test(a)?null:{channelId:i.channelId||t,messageId:i.messageId}}).filter(Boolean))}async function hr(e,t=""){let r=[...e||[]],o=c=>String(c.webkitRelativePath||c.name).replace(/\\/g,"/"),a=c=>c.slice(0,Math.max(0,c.lastIndexOf("/"))).toLowerCase(),i=new Map,s=[];for(let c of r){if(!/(?:^|\/)channel\.json$/i.test(o(c)))continue;let f=an(await c.text());i.set(a(o(c)),sn(f.id,"Channel ID"))}let d=0;for(let c of r){let f=o(c),n=f.split("/").at(-1);if(!/\.(json|csv|txt)$/i.test(n)||/^(?:index|channel|user)\.json$/i.test(n)||c.webkitRelativePath&&!/^messages?\.(json|csv)$/i.test(n))continue;let u=a(f),w=i.get(u)||u.match(/(?:^|\/)c?(\d{15,22})$/)?.[1]||t;if(c.size>12e5)throw new Error(`${n}: file is too large. Split it into batches of at most ${1e4} message IDs.`);let b=await c.text();if(b.length>12e5)throw new Error(`${n}: file is too large. Split it into smaller batches.`);try{if(/\.csv$/i.test(n)){let[h=[],...S]=fr(b);if(S.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);let k=l=>l.trim().toLowerCase().replace(/[\s_]/g,""),M=h.findIndex(l=>["id","messageid"].includes(k(l))),T=h.findIndex(l=>k(l)==="channelid");if(M<0)throw new Error("CSV needs an ID or Message ID column.");s.push(...Ye(S.filter(l=>/^\d{15,22}$/.test(l[M]?.trim()||"")).map(l=>({messageId:l[M],channelId:T<0?w:l[T]}))))}else s.push(...Ae(b,w));if(s.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch.`);d++}catch(h){throw new Error(`${n}: ${h.message}`,{cause:h})}}if(!d)throw new Error("No message ID files found. Choose message JSON/CSV files or an extracted data package folder.");return Ye(s)}function Kr(e,t){let r={id:e.id,channel_id:e.channel_id};return t!=="delete"&&(r.content=e.content),t==="preserve"&&(e.attachments?.length&&(r.attachments=[{}]),r.embeds=(e.embeds||[]).map(o=>({url:o.url,...o.image?.url?{image:{url:o.image.url}}:{}}))),r}async function dn(e){let t=we(e),{token:r,authorId:o,log:a,progress:i=()=>{},stopCheck:s=()=>!1}=t,d=Ye(e.targets||[]);if(!d.length)throw new Error("Paste or import at least one message ID.");let c=Ge(),f=Je(t.order,w=>Kr(w,t.action)),n=Ct(r,a,s),u=Se(d.map(w=>({...w,id:w.messageId})),t.order);try{for(let[b,h]of u.entries()){if(s())break;let S=await n(h.channelId,h.messageId);if(s())break;if(S.status===401)return re(),a("error","401 Unauthorized while checking IDs."),{unauthorized:!0,...c};if(S.status===404)c.alreadyGone++;else{if(S.status===403)return c.failed++,a("error","Discord denied access to message history. Stopping before changes."),{forbidden:!0,...c};if(!S.ok)c.failed++,a("error",`Could not check message ${h.messageId} (${S.status}).`);else{let k=await S.json();k.id===h.messageId&&k.channel_id===h.channelId&&ge(k,{...t,authorId:o,channelId:h.channelId})?f.add([k]):c.skipped++}}i(b+1,d.length,"Checking IDs"),!s()&&b\s]+:(\d{15,22})>$/)?.[1]||r;if(!/^\d{15,22}$/.test(o))throw new Error("Reaction filter must contain emoji IDs or custom emoji tags, separated by commas.");t.add(o)}return t}function br(e,t){return!t.size||t.has(String(e?.id||""))}function Vr(e){return encodeURIComponent(e?.id?`${e.name||"_"}:${e.id}`:e?.name||"")}async function xr(e){let{token:t,channelId:r,startId:o,scanLimit:a,skipOwn:i,authorId:s,log:d,progress:c=()=>{}}=e,f=gr(e.reactionIds),n=e.reactionAuthorId?.trim()?[...new Set(e.reactionAuthorId.split(/[\s,;]+/).filter(Boolean))]:[s];if(n.some(b=>b!==s&&!/^\d{15,22}$/.test(b)))throw new Error("Author(s) must contain valid user IDs.");let u=n.length===1&&n[0]===s;F.reactionStopped=!1;let w=()=>F.reactionStopped;try{let b="";if(o){let y=Ie(o);if(!y)return d("error","Start message/date is invalid.");b=(BigInt(y)+1n).toString()}let h=[],S=[],k=0;for(;k!/^\d+$/.test(String(E.id||""))))throw new Error("Discord returned invalid reaction history IDs. No reactions changed.");let x=g.reduce((E,A)=>Y(A.id,E)<0?A.id:E,g[0].id);if(b&&Y(x,b)>=0)throw new Error("Reaction history did not advance. No reactions changed.");let $=new Set;for(let E of g){if($.has(E.id)||b&&Y(E.id,b)>=0||($.add(E.id),k++,i&&E.author?.id===s))continue;let A=new Set;for(let v of E.reactions||[]){if(!br(v.emoji,f))continue;let N=Vr(v.emoji);for(let _ of n){if(w())break;let U=`${N}/${_}`;if(A.has(U))continue;let H=_===s,L=!!(v.me||v.me_burst);if(!H){let z=await W(`${B}/channels/${r}/messages/${E.id}/reactions/${N}?limit=1&after=${BigInt(_)-1n}`,{headers:{Authorization:t}},d,w);if(!z.ok)throw new Error(`Could not verify reaction authors (HTTP ${z.status}).`);let P=await z.json();if(L=Array.isArray(P)&&P.some(K=>K.id===_),await Q(ee(550,900)),w())break}L&&(A.add(U),S.length<20&&S.push(`${_} \xB7 ${E.id} \xB7 ${v.emoji.name||"emoji"}${v.emoji.id?` (${v.emoji.id})`:""} +${(E.content||"").slice(0,500)}`),h.push({reactionAuthorId:_,messageId:E.id,encodedEmoji:N}))}}if(k>=a)break}b=x,c(k,a,"Scanning"),await Q(ee(1700,2600))}if(F.reactionStopped)return d("warn","Reaction scan stopped.");if(!h.length)return d("success",`Scanned ${k} messages; no reactions from the selected author were found.`);if(!await se({title:"Remove reactions?",message:`Remove ${h.length} ${u?"of your reactions":`reactions from ${n.join(", ")}`} found in ${k} scanned messages?${f.size?` +Emoji IDs: ${[...f].join(", ")}`:""}`,details:S.join(` -`)+(f.length>$.length?` +`)+(h.length>S.length?` -Showing the first ${$.length} of ${f.length} reactions.`:""),yesText:"Remove",noText:"Cancel",danger:!0}))return s("warn","Cancelled.");let L=0,l=0;for(let h of f){if(O.reactionStopped)break;let w=await J(`${U}/channels/${r}/messages/${h.messageId}/reactions/${h.encodedEmoji}/${h.reactionAuthorId===i?"@me":h.reactionAuthorId}`,{method:"DELETE",headers:{Authorization:t}},s,y);if(w.ok||w.status===204||w.status===404)L++;else if(w.status===401){ne(),s("error","401 Unauthorized while removing reactions.");break}else if(w.status===403){s("error","Discord denied reaction removal. Removing another user\u2019s reactions requires Manage Messages in this channel.");break}else l++;m(L+l,f.length,"Removing"),await G(ee(900,1500))}s(O.reactionStopped?"warn":"success",`Reaction run ended. Removed ${L}; failed ${l}.`)}catch(b){if(!(b instanceof X))throw b;s("warn","Reaction run stopped.")}}var Wn=`
+Showing the first ${S.length} of ${h.length} reactions.`:""),yesText:"Remove",noText:"Cancel",danger:!0}))return d("warn","Cancelled.");let T=0,l=0;for(let y of h){if(F.reactionStopped)break;let I=await W(`${B}/channels/${r}/messages/${y.messageId}/reactions/${y.encodedEmoji}/${y.reactionAuthorId===s?"@me":y.reactionAuthorId}`,{method:"DELETE",headers:{Authorization:t}},d,w);if(I.ok||I.status===204||I.status===404)T++;else if(I.status===401){re(),d("error","401 Unauthorized while removing reactions.");break}else if(I.status===403){d("error","Discord denied reaction removal. Removing another user\u2019s reactions requires Manage Messages in this channel.");break}else l++;c(T+l,h.length,"Removing"),await Q(ee(900,1500))}d(F.reactionStopped?"warn":"success",`Reaction run ended. Removed ${T}; failed ${l}.`)}catch(b){if(!(b instanceof V))throw b;d("warn","Reaction run stopped.")}}var wr=`
@@ -136,20 +136,19 @@ Leave blank for case-sensitive matching.
Logs

 
-`;var Jn=`#dmd-toolbar-btn{width:32px!important;height:32px!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;border:0!important;border-radius:4px!important;background:transparent!important;color:var(--interactive-normal,#aaa)!important;cursor:pointer!important;padding:0!important;margin:0 2px!important}#dmd-toolbar-btn:hover{background:#ffffff0d!important;color:#fff!important}#dmd-panel{--bg:#2c2c2b;--surface:#343433;--hover:#ffffff08;--line:#ffffff13;--text:#eeeeea;--muted:#a8a8a2;position:fixed;top:62px;right:18px;width:min(660px,calc(100vw - 36px));height:min(480px,calc(100vh - 80px));display:none;flex-direction:column;background:var(--bg);color:var(--text);border:1px solid #4b4b49;border-radius:8px;box-shadow:0 20px 70px #0006;z-index:2147483646;font:14px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow:hidden;container-type:inline-size;color-scheme:dark}#dmd-panel *{box-sizing:border-box}#dmd-panel [hidden]{display:none!important}#dmd-tabs{display:flex;flex:none;align-items:center;gap:2px;padding:8px 12px 0;border-bottom:1px solid var(--line);background:#343433;position:relative}.dmd-tab{border:0;border-bottom:2px solid transparent;background:transparent;color:#a5a5a2;font:500 12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;padding:12px 13px;white-space:nowrap;cursor:pointer}.dmd-tab:hover{color:#eee;background:#ffffff06}.dmd-tab.active{color:#efefed;border-bottom-color:#d4d4d0}.dmd-view{flex:1 1 auto;min-height:0;overflow:auto;padding:28px 32px;scrollbar-width:thin;scrollbar-color:#484846 transparent}.dmd-view-title{margin:0 0 18px;font-size:25px;letter-spacing:-.6px;font-weight:650;line-height:1.3;color:#f0f0ec}.dmd-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:20px 20px}.dmd-field{min-width:0}#dmd-thread-target{margin-bottom:20px}.dmd-field label{display:block;font-size:12px;font-weight:500;color:#aaa9a5;margin:0 0 7px}.dmd-field input[type=text],.dmd-field input[type=password],.dmd-field input[type=datetime-local],.dmd-field input[type=number],.dmd-field select,.dmd-field textarea{width:100%;min-width:0;border:1px solid #50504d;border-radius:4px;background:#3a3a39;color:#eeeeea;padding:8px 10px;font:inherit;outline:none;transition:border-color .15s}.dmd-field input{height:36px}.dmd-field input::placeholder,.dmd-field textarea::placeholder{color:#979790}#dmd-panel select:not([multiple]){appearance:none;-webkit-appearance:none;padding-right:32px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='m4 6 4 4 4-4' fill='none' stroke='%239b9b98' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;background-size:14px;height:36px}.dmd-field input+select,.dmd-field input+label{margin-top:8px}.dmd-field textarea{resize:vertical;line-height:1.6}.dmd-field input:focus,.dmd-field select:focus,.dmd-field textarea:focus{border-color:#8b85ed;box-shadow:0 0 0 2px #8b85ed22}#dmd-panel button:focus-visible,#dmd-panel summary:focus-visible,.dmd-help-label:focus-visible{outline:2px solid #8b85ed;outline-offset:3px}.dmd-check{display:flex!important;align-items:center;gap:8px;font-weight:400!important;margin:8px 0!important}.dmd-check input[type=checkbox]{width:14px;height:14px;flex:none;margin:0;accent-color:#8b85ed}.dmd-pattern-options{display:grid;grid-template-columns:60px minmax(0,1fr);gap:8px;align-items:start}.dmd-field>input+.dmd-pattern-options{margin-top:12px}.dmd-pattern-options input{text-align:center}.dmd-pattern-options input,.dmd-pattern-options select{margin-top:0!important}.dmd-btn{border:1px solid #545450;border-radius:4px;padding:8px 12px;background:#414140;color:#d8d8d3;font:500 12px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;transition:background .15s}.dmd-btn:hover:not(:disabled){background:#4b4b49}.dmd-btn:disabled{opacity:.35;cursor:not-allowed}.dmd-green{background:#eeeeea;border-color:#eeeeea;color:#2c2c2b}.dmd-green:hover:not(:disabled){background:#fff}.dmd-red{color:#e99792;background:#3b2524;border-color:#60302c}.dmd-red:hover:not(:disabled){background:#4b2b29}.dmd-small{padding:2px 7px;font-size:10px;margin-left:5px}.dmd-actions{border-top:1px solid var(--line);margin-top:24px;padding-top:18px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dmd-muted,.dmd-note{color:#a0a09a;font-size:12px}.dmd-note{margin-top:10px}.dmd-wide{grid-column:1/-1}#dmd-panel .dmd-filter-group{margin-top:6px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;overflow:visible}#dmd-panel .dmd-filter-group summary{display:flex;align-items:center;gap:10px;padding:8px 3px;color:#c4c4bf;font-size:13px;font-weight:500;cursor:pointer;list-style:none}#dmd-panel .dmd-filter-group summary::-webkit-details-marker{display:none}#dmd-panel .dmd-filter-group summary:before{content:"";width:6px;height:6px;border-right:1.5px solid #878782;border-bottom:1.5px solid #878782;transform:rotate(-45deg);flex:none;margin-left:3px}#dmd-panel .dmd-filter-group[open] summary:before{transform:rotate(45deg);margin-top:-4px}#dmd-panel .dmd-filter-group summary:hover{background:var(--hover);border-radius:4px}.dmd-filter-group>.dmd-grid,.dmd-filter-group>.dmd-add-row{padding:12px 0 20px 20px}.dmd-scan-option{margin-top:20px}.dmd-add-row{display:flex;align-items:end;gap:10px}.dmd-add-row>input,.dmd-add-row>.dmd-field{flex:1;min-width:0}.dmd-add-row>.dmd-btn{flex:none;min-height:36px;white-space:nowrap}#dmd-multi-list{max-height:240px;overflow:auto;margin-top:22px;border:1px solid var(--line);border-radius:4px;background:#343433}.dmd-queue-row{display:flex;align-items:center;gap:8px;padding:12px;border-bottom:1px solid var(--line)}.dmd-queue-row:last-child{border-bottom:0}.dmd-queue-index{width:22px;color:#777773}.dmd-queue-info{flex:1;min-width:0}.dmd-queue-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmd-queue-ids{font-size:11px;color:#858580;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#dmd-forum-list{margin-top:8px}#dmd-forum-list option{padding:8px}#dmd-progress-dock{flex:none;background:#343433;border-top:1px solid var(--line);padding:14px 24px 10px}#dmd-panel .dmd-progress-area progress{appearance:none;display:block;width:100%;height:4px;border:0;border-radius:2px;overflow:hidden;background:#50504d}#dmd-panel progress::-webkit-progress-bar{background:#50504d}#dmd-panel progress::-webkit-progress-value{background:#8b85ed}#dmd-panel progress::-moz-progress-bar{background:#8b85ed}.dmd-progress-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#a0a09a}#dmd-multi-status:not(:empty){margin-top:6px;font-size:11px}#dmd-log{display:block;flex:0 0 126px;min-height:90px;max-height:26%;overflow:auto;background:#292928;border-top:1px solid var(--line);padding:10px 24px 14px;font:11px/1.65 Menlo,Consolas,monospace;white-space:pre-wrap;margin:0;scrollbar-width:thin}#dmd-log:empty:after{content:"No activity yet. Run an action to see updates here.";font:12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#91918a}.log-success{color:#88b99e}.log-error{color:#e99792}.log-warn{color:#d0b685}.log-info{color:#91b9ce}.log-verb{color:#a6a6a0}.dmd-help-label{position:relative;width:max-content;cursor:help}.dmd-help-icon{display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border:1px solid #777772;border-radius:50%;font-size:9px;margin-left:2px;color:#92928d}#dmd-panel .dmd-tooltip{position:absolute;left:0;bottom:calc(100% + 9px);width:225px;max-width:calc(100vw - 80px);padding:10px 12px;z-index:30;background:#414140;border:1px solid #565651;border-radius:5px;box-shadow:0 4px 16px #0007;color:#e4e4dc;font-size:12px;line-height:1.6;font-weight:400;white-space:normal;visibility:hidden;opacity:0;pointer-events:none}#dmd-panel .dmd-help-label:hover .dmd-tooltip,#dmd-panel .dmd-help-label:focus .dmd-tooltip{visibility:visible;opacity:1}#dmd-panel [data-resize-corner]{position:absolute;bottom:0;width:26px;height:26px;z-index:20;touch-action:none}#dmd-resize-handle{right:0;cursor:nwse-resize}#dmd-resize-left{left:0;cursor:nesw-resize}#dmd-panel [data-resize-corner]:after{content:"";position:absolute;bottom:6px;width:9px;height:9px;border-bottom:2px solid #979790}#dmd-resize-handle:after{right:6px;border-right:2px solid #979790}#dmd-resize-left:after{left:6px;border-left:2px solid #979790}#dmd-panel [data-resize-corner]:hover:after{border-color:#eeeeea}.dmd-confirm-overlay{position:fixed;inset:0;background:#0009;display:flex;align-items:center;justify-content:center;z-index:2147483647;padding:20px}.dmd-confirm-box{width:min(560px,calc(100vw - 40px));max-height:calc(100vh - 40px);overflow:auto;background:#343433;color:#eeeeea;border:1px solid #545450;border-radius:6px;box-shadow:0 18px 60px #0008;padding:24px;font:14px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.dmd-confirm-title{font-size:19px;font-weight:600;margin-bottom:10px}.dmd-confirm-message{color:#aaa9a2;white-space:pre-wrap}.dmd-confirm-details{margin:16px 0 0;background:#2c2c2b;border:1px solid #484845;border-radius:4px;padding:12px;max-height:280px;overflow:auto;color:#aaa9a2;font:12px/1.5 Menlo,Consolas,monospace;white-space:pre-wrap}.dmd-confirm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}@container (max-width:700px){#dmd-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));padding-right:24px}.dmd-tab{padding:10px 5px;font-size:12px}.dmd-view{padding:22px 24px}}@container (max-width:440px){.dmd-grid{grid-template-columns:1fr}.dmd-add-row{flex-wrap:wrap}.dmd-view{padding:18px}.dmd-filter-group>.dmd-grid{padding-left:12px}#dmd-log{flex-basis:100px}}#dmd-panel input:not([type=checkbox]):not([type=file]),#dmd-panel select:not([multiple]){min-width:0;max-width:100%;height:36px;min-height:36px;max-height:36px}#dmd-panel textarea{width:100%;min-width:0;max-width:100%;min-height:90px;max-height:min(320px,40vh);resize:vertical;overflow:auto}#dmd-panel select[multiple]{width:100%;min-width:0;max-width:100%;min-height:100px;max-height:min(320px,40vh);overflow:auto}#dmd-panel #dmd-log{overflow-y:auto;overflow-x:hidden;overflow-wrap:anywhere}#dmd-panel .dmd-actions+.dmd-field{margin-top:20px}#dmd-panel .dmd-native-select{display:none!important}#dmd-panel .dmd-select-trigger{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-width:0;height:36px;padding:8px 10px;border:1px solid #50504d;border-radius:4px;background:#3a3a39;color:#eeeeea;font:inherit;text-align:left;cursor:pointer}#dmd-panel .dmd-select-trigger span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#dmd-panel .dmd-select-trigger:after{content:"";width:6px;height:6px;border-right:1.5px solid #a8a8a2;border-bottom:1.5px solid #a8a8a2;transform:rotate(45deg);margin:0 3px 3px;flex:none}#dmd-panel .dmd-select-trigger:hover,#dmd-panel .dmd-select-trigger[aria-expanded=true]{background:#414140;border-color:#72726d}#dmd-panel input+.dmd-native-select+.dmd-select-trigger{margin-top:8px}.dmd-select-menu{position:fixed;z-index:2147483647;overflow-y:auto;padding:5px;background:#414140;border:1px solid #565651;border-radius:6px;box-shadow:0 8px 30px #0006;color:#eeeeea;font:13px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;scrollbar-width:thin}.dmd-select-menu button{display:block;width:100%;text-align:left;border:0;border-radius:3px;padding:8px 26px 8px 9px;background:transparent;color:inherit;font:inherit;cursor:pointer;position:relative}.dmd-select-menu button:hover,.dmd-select-menu button:focus{background:#ffffff0e;outline:none}.dmd-select-menu button[aria-selected=true]:after{content:"\\2713";position:absolute;right:9px;color:#b0aafa}.dmd-select-menu button:disabled{opacity:.4;cursor:default}#dmd-panel .dmd-identity-label{display:flex;align-items:center;gap:7px;min-height:20px}#dmd-panel .dmd-identity-avatar{position:relative;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#55554f;color:#eeeeea;font-size:10px;overflow:hidden;flex:none}#dmd-panel .dmd-identity-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}#dmd-panel>.dmd-confirm-overlay{position:absolute;inset:0;padding:18px;border-radius:inherit}#dmd-panel .dmd-confirm-box{width:min(560px,100%);max-height:100%;min-width:0}#dmd-panel .dmd-field>select+.dmd-pattern-options,#dmd-panel .dmd-select-trigger+.dmd-pattern-options{margin-top:12px}#dmd-panel .dmd-segments{display:flex;gap:4px;padding:4px;background:#343433;border:1px solid #4b4b49;border-radius:6px;margin-bottom:22px;flex-wrap:wrap}#dmd-panel .dmd-segments button{flex:1;border:0;border-radius:4px;background:transparent;color:#a8a8a2;padding:7px 10px;font:500 12px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer}#dmd-panel .dmd-segments button.active{background:#50504d;color:#eeeeea}#dmd-panel .dmd-segments button:hover{color:#eeeeea}#dmd-panel .dmd-subview{margin-top:24px}#dmd-panel #dmd-tabs{display:flex;flex-wrap:nowrap}#dmd-panel .dmd-tab{flex:1}.dmd-calendar{position:fixed;z-index:2147483647;width:290px;max-height:calc(100vh - 16px);overflow:auto;padding:12px;background:#414140;color:#eeeeea;border:1px solid #565651;border-radius:6px;box-shadow:0 8px 30px #0006;font:13px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.dmd-calendar button{border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;padding:6px;cursor:pointer}.dmd-calendar button:hover,.dmd-calendar button:focus-visible{background:#ffffff12;outline:1px solid #8b85ed}.dmd-calendar-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.dmd-calendar-header span{font-weight:600}.dmd-calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;text-align:center}.dmd-calendar-grid span{color:#a8a8a2;font-size:11px;padding:4px}.dmd-calendar-grid .outside{color:#92928e}.dmd-calendar-grid .selected{background:#8b85ed;color:#fff}.dmd-calendar-time{display:flex;align-items:center;gap:6px;border-top:1px solid #565651;margin-top:10px;padding-top:10px}.dmd-calendar-time span{margin-right:auto}.dmd-calendar input{width:48px;padding:5px;background:#343433;color:#eeeeea;border:1px solid #565651;border-radius:4px;font:inherit}.dmd-calendar-footer{display:flex;justify-content:space-between;gap:8px;margin-top:12px}.dmd-calendar-footer button:last-child{background:#eeeeea;color:#2c2c2b;padding:6px 12px}.dmd-queue-avatar{width:32px;height:32px;flex:0 0 32px;border-radius:50%;background:var(--surface);display:grid;place-items:center;position:relative;overflow:hidden;color:var(--muted)}.dmd-queue-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.dmd-channel-picker{position:relative;min-width:0}.dmd-channel-menu{position:fixed;z-index:2147483647;overflow:auto;padding:10px;background:#343433;color:#eeeeea;border:1px solid #565651;border-radius:6px;box-shadow:0 8px 30px #0006;font:13px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color-scheme:dark}.dmd-channel-menu[hidden]{display:none!important}.dmd-channel-menu input[type=search]{width:100%;padding:8px;border:1px solid #565651;border-radius:4px;background:#393938;color:inherit;font:inherit}.dmd-channel-menu input[type=checkbox]{width:14px;height:14px;accent-color:#8b85ed}.dmd-channel-choices{max-height:220px;overflow:auto;margin:8px 0;scrollbar-width:thin}.dmd-channel-option{display:flex!important;align-items:center;gap:9px;padding:7px 4px;margin:0!important;color:#eeeeea!important;cursor:pointer}.dmd-channel-option input{flex:0 0 auto}.dmd-channel-option span{overflow-wrap:anywhere}.dmd-channel-option:hover{background:#ffffff08}.dmd-channel-menu [hidden]{display:none!important}.dmd-orange{background:#bd7945;border-color:#cc8b56;color:#fff8f0}.dmd-orange:hover:not(:disabled){background:#ce8951}.dmd-start-value{margin-top:10px}.dmd-export-list{display:flex;flex-direction:column;gap:10px;max-height:200px;overflow:auto;margin:14px 0}.dmd-export-formats{border:1px solid #545450;border-radius:4px;display:flex;gap:20px;padding:10px 12px}.dmd-export-formats legend{color:#aaa9a2}#dmd-panel #dmd-log{scrollbar-color:#61615c #292928;scrollbar-gutter:stable;overscroll-behavior:contain}#dmd-panel #dmd-log::-webkit-scrollbar{width:10px}#dmd-panel #dmd-log::-webkit-scrollbar-track{background:transparent;margin:8px 0 26px}#dmd-panel #dmd-log::-webkit-scrollbar-thumb{background:#61615c;border:3px solid #292928;border-radius:10px;min-height:28px}#dmd-panel #dmd-log::-webkit-scrollbar-thumb:hover{background:#85857d}#dmd-panel #dmd-log::-webkit-scrollbar-thumb:active{background:#a4a49b}#dmd-panel #dmd-log::-webkit-scrollbar-button{display:none;width:0;height:0}#dmd-panel #dmd-log::-webkit-scrollbar-corner{background:transparent}@supports selector(::-webkit-scrollbar){#dmd-panel #dmd-log{scrollbar-width:auto;scrollbar-color:auto}}.dmd-scan-option{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center}#dmd-panel .dmd-progress-area{height:48px}.dmd-progress-meta{min-height:16px}#dmd-multi-status{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:14px}#dmd-panel .dmd-tooltip{width:260px;white-space:pre-line;line-height:1.75;padding:12px 14px}#dmd-reactions>.dmd-grid{gap:12px 16px}#dmd-reactions .dmd-start-value{margin-top:8px}#dmd-rx-start-field{display:grid;grid-template-columns:minmax(100px,.8fr) minmax(0,1.2fr);gap:0 8px}#dmd-rx-start-field>label{grid-column:1/-1}#dmd-rx-start-field .dmd-start-value{margin-top:0;min-width:0}#dmd-rx-start-date-field>label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}#dmd-log-area{display:flex;flex-direction:column;flex:0 0 150px;min-height:110px;max-height:30%;background:#292928;border-top:1px solid var(--line)}.dmd-log-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 24px;color:#a4a49f;font-size:11px}.dmd-log-toolbar>div{display:flex;gap:6px}.dmd-log-toolbar .dmd-btn{padding:3px 8px;min-height:24px;background:transparent;border-color:transparent;color:#b6b6ae}.dmd-log-toolbar .dmd-btn:hover{background:#ffffff0b;border-color:#50504d}#dmd-log-area #dmd-log{flex:1;min-height:0;max-height:none;border-top:0;padding-top:4px} -`;function Gn(){if(document.getElementById("dmd-panel"))return;dt(Jn);let e=document.createElement("button");e.id="dmd-toolbar-btn",e.type="button",e.title="Del-Discord v1 \u2014 Delete Messages",e.setAttribute("aria-label","Delete Messages"),e.innerHTML=Vt();let t=document.createElement("div");t.id="dmd-panel",t.innerHTML=Wn.replace("${MAX_REACTION_SCAN}",String(5e3)),document.body.appendChild(t);let r=t.querySelector("#dmd-messages"),a=t.querySelector("#dmd-forums");a.className="dmd-subview",r.appendChild(a);let o=t.querySelector("#dmd-multi"),d=document.createElement("div");d.id="dmd-queue-conversations",[...o.children].filter(c=>c.id!=="dmd-queue-modes").forEach(c=>d.appendChild(c)),o.appendChild(d);let i=t.querySelector("#dmd-ids");i.className="dmd-subview",o.appendChild(i);let s="dm",m="multi",p=new Map,n=c=>{if(p.has(c))return p.get(c);let k=t.querySelector(c);return p.set(c,k),k},u=Kt(t,{getMode:()=>s,setMode:c=>{s=c},onReset:()=>{ie(),j()},onStorageError:()=>l("warn","Messages settings could not be saved locally. Changes may be lost on refresh.")});n("#dmd-clear-filters").onclick=u.clear;let y=In(t,{deleteConversations:c=>Vn(c)}),b=new Map,f=at(n,{cache:b}),$=at(n,{author:"#dmd-rx-author",guild:"#dmd-rx-guild",userBadge:"#dmd-rx-user-avatar",guildBadge:"#dmd-rx-guild-avatar",cache:b}),g=at(n,{author:null,guild:"#dmd-server-id",userBadge:null,guildBadge:"#dmd-server-avatar",cache:b});on(t),rn(t);let T=Zt(n,()=>s!=="thread"),L=n("#dmd-log"),{log:l,logEntries:h}=$n(L),w=(c,k,A,F)=>{let P=String(c||"").trim();if(P){let H=Ke(P);if(!H)throw new Error(`${F} message must be a Discord message ID or message link.`);return H.channelId&&A&&H.channelId!==A&&l("warn",`${F} message link is for channel ${H.channelId}, but the selected channel is ${A}. Using the message ID anyway.`),H.messageId}return String(k||"").trim()},S=c=>({minId:w(n("#dmd-after-kind").value==="id"?n("#dmd-after-message").value:"",n("#dmd-after-kind").value==="date"?n("#dmd-after").value:"",c,"After"),maxId:w(n("#dmd-before-kind").value==="id"?n("#dmd-before-message").value:"",n("#dmd-before-kind").value==="date"?n("#dmd-before").value:"",c,"Before")}),v=c=>be({freshHistory:!0,...S(c),authorIds:xe(n("#dmd-author").value,"Author(s)"),action:n("#dmd-action").value,order:n("#dmd-order").value,overwriteText:n("#dmd-overwrite-text").value,content:n("#dmd-content").value,textMode:n("#dmd-text-mode").value,linkMode:n("#dmd-link-mode").value,fileMode:n("#dmd-file-mode").value,pinnedMode:n("#dmd-pinned-mode").value,includeNsfw:n("#dmd-nsfw").checked,filename:n("#dmd-filename").value,extensions:n("#dmd-extensions").value,textRegex:n("#dmd-text-regex").value,regexFlags:n("#dmd-regex-flags").value,regexMode:n("#dmd-regex-mode").value,assetRegex:n("#dmd-asset-regex").value,assetRegexFlags:n("#dmd-asset-regex-flags").value,assetMode:n("#dmd-asset-mode").value,collectAll:n("#dmd-collect-all").checked});n("#dmd-action").onchange=()=>{n("#dmd-overwrite-field").hidden=n("#dmd-action").value!=="overwrite"},n("#dmd-action").onchange();let E=!1,I=(c,k)=>{E=c;for(let A of["#dmd-export-conversation","#dmd-id-import","#dmd-id-package","#dmd-forum-load","#dmd-multi-export","#dmd-forum-export","#dmh-refresh","#dmh-import","#dmh-clear"])n(A).disabled=c;n(k).disabled=!c},M=(c,k)=>{let A=n(c);n(k).textContent=`${Math.round(A.value/Math.max(1,A.max)*100)}%`},D=(c,k)=>{k=Math.max(Number(k)||1,Number(c)||0,1);let A=n("#dmd-progress");A.max=k,A.value=Math.min(Number(c)||0,k),n("#dmd-pct").textContent=`${Math.round(A.value/A.max*100)}%`},x=(c,k,A="")=>{k=Math.max(Number(k)||1,1);let F=n("#dmd-multi-progress");F.max=k,F.value=Math.min(Number(c)||0,k),n("#dmd-multi-pct").textContent=`${Math.round(F.value/F.max*100)}%`,n("#dmd-multi-status").textContent=A},N=new Map,_=(c,k)=>{let A=n(c);(!A.value.trim()||A.value===N.get(c))&&(A.value=k,N.set(c,k))};for(let c of["#dmd-guild","#dmd-channel","#dmd-rx-channel","#dmd-rx-guild","#dmd-forum-id"])n(c).addEventListener("input",()=>N.delete(c)),n(c).addEventListener("change",()=>N.delete(c));let j=()=>{let c=Le();c&&(_("#dmd-guild",c.guildId),_("#dmd-channel",c.channelId),_("#dmd-rx-channel",c.channelId),_("#dmd-rx-guild",c.guildId),$.refresh("guild"),c.guildId!=="@me"&&_("#dmd-forum-id",c.channelId),f.refresh("guild"),T.refresh())},q=nn(),C=0,R=async()=>{let c=n("#dmd-channel").value.trim(),k=n("#dmd-token").value.trim(),A=++C;if(!(t.style.display!=="flex"||!k||!/^\d{15,22}$/.test(c)||E||s==="server"))try{let F=await q({token:k,channelId:c});if(A!==C||E||s==="server"||n("#dmd-channel").value.trim()!==c)return;s=F.mode,_("#dmd-guild",F.guildId),F.mode==="forums"&&_("#dmd-forum-id",F.channelId),ie()}catch(F){A===C&&!E&&l("warn",F.message)}},z=async()=>{let c=await me(n("#dmd-token").value.trim());return c?(n("#dmd-token").value=c.token,n("#dmd-author").value.trim()||(n("#dmd-author").value=c.user.id),f.seedUser(c.user),_("#dmd-rx-author",c.user.id),$.refresh("user"),$.refresh("guild"),f.refresh("user"),f.refresh("guild"),T.refresh(),l("success",`Authorization loaded for ${c.user.username||"your account"}.`),R(),c):(l("error","Could not find a valid Discord authorization token. Refresh Discord and press GET again."),null)},W=tn(),K=null,Q=En({$:n,log:l,onChange:()=>K?.()}),{renderQueue:he}=Q,ue=!1,se=new Set,ae=async()=>{let c=n("#dmd-token").value.trim();if(!(!c||ue||E)){ue=!0;try{for(let k of[...Q.getItems()]){if(E)break;let A=`${k.guildId}/${k.channelId}`;k.icon||k.kind==="ids"||se.has(A)||(se.add(A),Q.updateIdentity(k,await W.resolve(k,c,l)),await G(ee(350,600)))}}finally{ue=!1}}},te=Mn({panel:t,dragHandle:t,resizeHandles:[n("#dmd-resize-left"),n("#dmd-resize-handle")],storage:()=>localStorage,storageKey:"del_discord_v1_deleter_geometry_compact",defaultWidth:660,defaultRatio:660/480});function de(){let c=An();return c?(e.parentElement!==c&&c.appendChild(e),!0):!1}e.onclick=async()=>{let c=t.style.display==="flex";t.style.display=c?"none":"flex",e.classList.toggle("open",!c),c||(te.ensureGeometry(),j(),n("#dmd-token").value?R():await z(),Q.getItems().length&&!E&&await re({title:"Saved Queue",message:`${Q.getItems().length} queued cleanups are saved locally. Continue running them or clear the queue?`,yesText:"Continue",noText:"Clear Queue",onNo:()=>Q.clear()})&&!E&&(t.querySelector('[data-view="multi"]').click(),await n("#dmd-multi-start").onclick()))};let ie=()=>{let c=t.querySelector(".dmd-tab.active").dataset.view;n("#dmd-messages").style.display=c==="messages"?"":"none",n("#dmd-reactions").style.display=c==="reactions"?"":"none",n("#dmd-multi").style.display=c==="multi"?"":"none";let k=s==="forums"||s==="thread";n("#dmd-channel").previousElementSibling.textContent=s==="thread"?"Thread(s)":"Channel(s)",n("#dmd-channel").placeholder=s==="thread"?"Thread ID, thread ID":"Channel ID, channel ID",n("#dmd-thread-target").hidden=!k,n("#dmd-channel").closest(".dmd-field").hidden=s==="server",n("#dmd-archived-option").hidden=s!=="forums",k&&n("#dmd-thread-target-kind").value!==s&&(n("#dmd-thread-target-kind").value=s,n("#dmd-thread-target-kind").dispatchEvent(new Event("change"))),t.querySelectorAll("#dmd-message-modes button").forEach(F=>{let P=F.dataset.mode===s||k&&F.dataset.mode==="forums";F.classList.toggle("active",P),F.setAttribute("aria-pressed",String(P))}),n("#dmd-forums").style.display=s==="forums"?"":"none",n("#dmd-messages > .dmd-actions").hidden=s==="forums",n("#dmd-queue-conversations").hidden=m==="ids",n("#dmd-queue-server-inputs").hidden=m!=="server",n("#dmd-ids").style.display=m==="ids"?"":"none",n("#dmh-panel").style.display=c==="history"?"flex":"none",n("#dmd-progress-dock").hidden=!1;let A=c==="multi"?m==="server"?"multi":m:c;t.querySelectorAll("[data-progress-view]").forEach(F=>{F.hidden=F.dataset.progressView!==A}),T.refresh()};t.querySelectorAll(".dmd-tab").forEach(c=>{c.onclick=()=>{t.querySelectorAll(".dmd-tab").forEach(k=>k.classList.toggle("active",k===c)),ie(),c.dataset.view==="history"&&y.activate(),c.dataset.view==="multi"&&(he(),ae())}}),t.querySelectorAll("#dmd-message-modes button").forEach(c=>{c.onclick=()=>{s=c.dataset.mode==="forums"?n("#dmd-thread-target-kind").value:c.dataset.mode,f.refresh("guild"),ie(),u.save()}}),n("#dmd-thread-target-kind").addEventListener("change",()=>{s=n("#dmd-thread-target-kind").value,ie(),u.save()}),t.querySelectorAll("#dmd-queue-modes button").forEach(c=>{c.onclick=()=>{m=c.dataset.mode,t.querySelectorAll("#dmd-queue-modes button").forEach(k=>{k.classList.toggle("active",k===c),k.setAttribute("aria-pressed",String(k===c))}),ie()}}),ie();let Xe=!1,ht=null,Ie=async(c,k,A=!1,F=null)=>{if(Xe)return;if(!c.length&&!F?.length)return l("warn","Select at least one conversation or message ID.");if(c.some(H=>!H.guildId||!/^\d{15,22}$/.test(String(H.kind==="server"?H.guildId:H.channelId||""))))return l("error","Enter valid conversation IDs before adding to the queue.");Xe=!0;let P;ht=new Promise(H=>{P=H});try{if(!await re({title:"Add to Queue?",message:`Queue this ${k.action||"delete"} cleanup with the selected filters? Starting the queue will run it without another confirmation.`,details:F?`${F.length} message IDs`:c.map(B=>B.label||B.channelId).join(` -`),yesText:"Add to Queue",noText:"Cancel"}))return;let V={...k};if(delete V.textPattern,delete V.assetPattern,F)Q.addIdJob(F,V);else{let B=[];for(let Z of c){let ce=await W.resolve(Z,n("#dmd-token").value.trim(),l);B.push({...ce,options:V,allowAnyThread:A,detectConversation:Z.kind!=="server"})}Q.addQueueItems(B)}l("info",K?"Cleanup added to the running queue.":"Cleanup queued. Press Start Queue to begin.")}finally{Xe=!1,P(),ht=null}},Vn=async c=>{if(E)return await Ie(c.map(A=>({guildId:"@me",channelId:A.channelId,label:A.name||A.username||A.channelId})),{action:"delete",collectAll:!0,pinnedMode:"any"}),[];let k=[];I(!0,"#dmh-stop"),O.stopped=!1,n("#dmh-stop").hidden=!1;try{let A=await me(n("#dmd-token").value.trim())||await z();if(!A||O.stopped||!await re({title:"Are you sure?",message:`Delete all of your messages, including pinned messages, in ${c.length} selected DMs? Other people's messages and the DM conversations will remain.`,details:c.map(P=>P.name||P.username||P.channelId||P.userId).join(` -`),yesText:"Delete All",noText:"Cancel",danger:!0})||O.stopped)return k;n("#dmh-progress").max=c.length,n("#dmh-progress").value=0,M("#dmh-progress","#dmh-pct");for(let[P,H]of c.entries()){if(O.stopped)break;let V=H.channelId;if(!V&&H.userId){let Z=await J(`${U}/users/@me/channels`,{method:"POST",headers:{Authorization:A.token,"Content-Type":"application/json"},body:JSON.stringify({recipient_id:H.userId})},l,()=>O.stopped);if(!Z.ok)throw new Error(`Could not open DM (${Z.status}).`);V=(await Z.json()).id}if(O.stopped)break;if(!/^\d{15,22}$/.test(String(V||"")))throw new Error("This history entry has no valid DM channel.");n("#dmh-progress-status").textContent=`Cleaning ${P+1}/${c.length}`;let B=await Ye({token:A.token,authorId:A.user.id,guildId:"@me",channelId:V,action:"delete",pinnedMode:"any",collectAll:!0,freshHistory:!0,log:l,stopCheck:()=>O.stopped});if(!B?.done||B.failed>0)break;k.push(H),n("#dmh-progress").value=P+1,M("#dmh-progress","#dmh-pct")}l("info",`Finished ${k.length}/${c.length} selected DMs.`)}catch(A){l("error",A.message||A)}finally{I(!1,"#dmh-stop"),n("#dmh-stop").hidden=!0,O.stopped=!1}return k};n("#dmh-stop").onclick=()=>{O.stopped=!0},n("#dmd-get-token").onclick=z,n("#dmd-clear").onclick=()=>{L.textContent="",h.length=0,D(0,1)},n("#dmd-export-log").onclick=()=>{if(!h.length)return l("warn","There is no log to export yet.");let c=Le(),k=["Discord deletion log",`Exported: ${new Date().toISOString()}`,`Server: ${c?.guildId||n("#dmd-guild").value.trim()||""}`,`Channel: ${c?.channelId||n("#dmd-channel").value.trim()||""}`,""].join(` -`),A=h.map(F=>`[${F.time}] [${String(F.type).toUpperCase()}] ${F.message}`).join(` -`);Nn(`discord-log-${Tt()}.txt`,k+A)};let gt=async({targets:c,stopId:k,stopped:A,setStopped:F,progress:P,filename:H,select:V=!1})=>{if(E)return;let B;try{B=c()}catch(ce){return l("error",ce.message)}if(!B.length)return l("warn","Select or queue at least one conversation to export.");let Z="json";I(!0,k),F(!1);try{let ce=await me(n("#dmd-token").value.trim())||await z();if(!ce||A())return;if(V){let le=await Ln(B);if(!le||A())return;B=le.conversations,Z=le.format}let It=B.map(le=>S(le.channelId));if(!(V||await re({title:B.length===1?"Export conversation?":"Export conversations?",message:`Export all readable messages in the selected Before/After range from ${B.length} conversation${B.length===1?"":"s"}?`,details:B.map(le=>le.label||le.channelId).join(` -`),yesText:"Export",noText:"Cancel"}))||A())return;let we=[],Jt=0;P(0,B.length);for(let[le,Zn]of B.entries()){if(A())return;let Qt=await Fn({token:ce.token,...Zn,...It[le],log:l,stopCheck:A});if(A())return;let Gt=await zn(Qt,{format:Z,includeCsvHeader:le===0,stopCheck:A});if(A())return;Jt+=Qt.messageCount,(Z==="json"||Gt.size)&&(we.length&&we.push(Z==="csv"?`\r -`:`, -`),we.push(Gt)),P(le+1,B.length)}let Xn=Z==="json"&&B.length>1?new Blob([`{ - "exportedAt": `,JSON.stringify(new Date().toISOString()),`, - "conversationCount": `,String(B.length),`, +`;var vr=`#dmd-toolbar-btn{width:32px!important;height:32px!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;border:0!important;border-radius:4px!important;background:transparent!important;color:var(--interactive-normal,#aaa)!important;cursor:pointer!important;padding:0!important;margin:0 2px!important}#dmd-toolbar-btn:hover{background:#ffffff0d!important;color:#fff!important}#dmd-panel{--bg:#2c2c2b;--surface:#343433;--hover:#ffffff08;--line:#ffffff13;--text:#eeeeea;--muted:#a8a8a2;position:fixed;top:62px;right:18px;width:min(660px,calc(100vw - 36px));height:min(480px,calc(100vh - 80px));display:none;flex-direction:column;background:var(--bg);color:var(--text);border:1px solid #4b4b49;border-radius:8px;box-shadow:0 20px 70px #0006;z-index:2147483646;font:14px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow:hidden;container-type:inline-size;color-scheme:dark}#dmd-panel *{box-sizing:border-box}#dmd-panel [hidden]{display:none!important}#dmd-tabs{display:flex;flex:none;align-items:center;gap:2px;padding:8px 12px 0;border-bottom:1px solid var(--line);background:#343433;position:relative}.dmd-tab{border:0;border-bottom:2px solid transparent;background:transparent;color:#a5a5a2;font:500 12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;padding:12px 13px;white-space:nowrap;cursor:pointer}.dmd-tab:hover{color:#eee;background:#ffffff06}.dmd-tab.active{color:#efefed;border-bottom-color:#d4d4d0}.dmd-view{flex:1 1 auto;min-height:0;overflow:auto;padding:28px 32px;scrollbar-width:thin;scrollbar-color:#484846 transparent}.dmd-view-title{margin:0 0 18px;font-size:25px;letter-spacing:-.6px;font-weight:650;line-height:1.3;color:#f0f0ec}.dmd-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:20px 20px}.dmd-field{min-width:0}#dmd-thread-target{margin-bottom:20px}.dmd-field label{display:block;font-size:12px;font-weight:500;color:#aaa9a5;margin:0 0 7px}.dmd-field input[type=text],.dmd-field input[type=password],.dmd-field input[type=datetime-local],.dmd-field input[type=number],.dmd-field select,.dmd-field textarea{width:100%;min-width:0;border:1px solid #50504d;border-radius:4px;background:#3a3a39;color:#eeeeea;padding:8px 10px;font:inherit;outline:none;transition:border-color .15s}.dmd-field input{height:36px}.dmd-field input::placeholder,.dmd-field textarea::placeholder{color:#979790}#dmd-panel select:not([multiple]){appearance:none;-webkit-appearance:none;padding-right:32px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='m4 6 4 4 4-4' fill='none' stroke='%239b9b98' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;background-size:14px;height:36px}.dmd-field input+select,.dmd-field input+label{margin-top:8px}.dmd-field textarea{resize:vertical;line-height:1.6}.dmd-field input:focus,.dmd-field select:focus,.dmd-field textarea:focus{border-color:#8b85ed;box-shadow:0 0 0 2px #8b85ed22}#dmd-panel button:focus-visible,#dmd-panel summary:focus-visible,.dmd-help-label:focus-visible{outline:2px solid #8b85ed;outline-offset:3px}.dmd-check{display:flex!important;align-items:center;gap:8px;font-weight:400!important;margin:8px 0!important}.dmd-check input[type=checkbox]{width:14px;height:14px;flex:none;margin:0;accent-color:#8b85ed}.dmd-pattern-options{display:grid;grid-template-columns:60px minmax(0,1fr);gap:8px;align-items:start}.dmd-field>input+.dmd-pattern-options{margin-top:12px}.dmd-pattern-options input{text-align:center}.dmd-pattern-options input,.dmd-pattern-options select{margin-top:0!important}.dmd-btn{border:1px solid #545450;border-radius:4px;padding:8px 12px;background:#414140;color:#d8d8d3;font:500 12px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;transition:background .15s}.dmd-btn:hover:not(:disabled){background:#4b4b49}.dmd-btn:disabled{opacity:.35;cursor:not-allowed}.dmd-green{background:#eeeeea;border-color:#eeeeea;color:#2c2c2b}.dmd-green:hover:not(:disabled){background:#fff}.dmd-red{color:#e99792;background:#3b2524;border-color:#60302c}.dmd-red:hover:not(:disabled){background:#4b2b29}.dmd-small{padding:2px 7px;font-size:10px;margin-left:5px}.dmd-actions{border-top:1px solid var(--line);margin-top:24px;padding-top:18px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dmd-muted,.dmd-note{color:#a0a09a;font-size:12px}.dmd-note{margin-top:10px}.dmd-wide{grid-column:1/-1}#dmd-panel .dmd-filter-group{margin-top:6px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;overflow:visible}#dmd-panel .dmd-filter-group summary{display:flex;align-items:center;gap:10px;padding:8px 3px;color:#c4c4bf;font-size:13px;font-weight:500;cursor:pointer;list-style:none}#dmd-panel .dmd-filter-group summary::-webkit-details-marker{display:none}#dmd-panel .dmd-filter-group summary:before{content:"";width:6px;height:6px;border-right:1.5px solid #878782;border-bottom:1.5px solid #878782;transform:rotate(-45deg);flex:none;margin-left:3px}#dmd-panel .dmd-filter-group[open] summary:before{transform:rotate(45deg);margin-top:-4px}#dmd-panel .dmd-filter-group summary:hover{background:var(--hover);border-radius:4px}.dmd-filter-group>.dmd-grid,.dmd-filter-group>.dmd-add-row{padding:12px 0 20px 20px}.dmd-scan-option{margin-top:20px}.dmd-add-row{display:flex;align-items:end;gap:10px}.dmd-add-row>input,.dmd-add-row>.dmd-field{flex:1;min-width:0}.dmd-add-row>.dmd-btn{flex:none;min-height:36px;white-space:nowrap}#dmd-multi-list{max-height:240px;overflow:auto;margin-top:22px;border:1px solid var(--line);border-radius:4px;background:#343433}.dmd-queue-row{display:flex;align-items:center;gap:8px;padding:12px;border-bottom:1px solid var(--line)}.dmd-queue-row:last-child{border-bottom:0}.dmd-queue-index{width:22px;color:#777773}.dmd-queue-info{flex:1;min-width:0}.dmd-queue-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmd-queue-ids{font-size:11px;color:#858580;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#dmd-forum-list{margin-top:8px}#dmd-forum-list option{padding:8px}#dmd-progress-dock{flex:none;background:#343433;border-top:1px solid var(--line);padding:14px 24px 10px}#dmd-panel .dmd-progress-area progress{appearance:none;display:block;width:100%;height:4px;border:0;border-radius:2px;overflow:hidden;background:#50504d}#dmd-panel progress::-webkit-progress-bar{background:#50504d}#dmd-panel progress::-webkit-progress-value{background:#8b85ed}#dmd-panel progress::-moz-progress-bar{background:#8b85ed}.dmd-progress-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#a0a09a}#dmd-multi-status:not(:empty){margin-top:6px;font-size:11px}#dmd-log{display:block;flex:0 0 126px;min-height:90px;max-height:26%;overflow:auto;background:#292928;border-top:1px solid var(--line);padding:10px 24px 14px;font:11px/1.65 Menlo,Consolas,monospace;white-space:pre-wrap;margin:0;scrollbar-width:thin}#dmd-log:empty:after{content:"No activity yet. Run an action to see updates here.";font:12px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#91918a}.log-success{color:#88b99e}.log-error{color:#e99792}.log-warn{color:#d0b685}.log-info{color:#91b9ce}.log-verb{color:#a6a6a0}.dmd-help-label{position:relative;width:max-content;cursor:help}.dmd-help-icon{display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border:1px solid #777772;border-radius:50%;font-size:9px;margin-left:2px;color:#92928d}#dmd-panel .dmd-tooltip{position:absolute;left:0;bottom:calc(100% + 9px);width:225px;max-width:calc(100vw - 80px);padding:10px 12px;z-index:30;background:#414140;border:1px solid #565651;border-radius:5px;box-shadow:0 4px 16px #0007;color:#e4e4dc;font-size:12px;line-height:1.6;font-weight:400;white-space:normal;visibility:hidden;opacity:0;pointer-events:none}#dmd-panel .dmd-help-label:hover .dmd-tooltip,#dmd-panel .dmd-help-label:focus .dmd-tooltip{visibility:visible;opacity:1}#dmd-panel [data-resize-corner]{position:absolute;bottom:0;width:26px;height:26px;z-index:20;touch-action:none}#dmd-resize-handle{right:0;cursor:nwse-resize}#dmd-resize-left{left:0;cursor:nesw-resize}#dmd-panel [data-resize-corner]:after{content:"";position:absolute;bottom:6px;width:9px;height:9px;border-bottom:2px solid #979790}#dmd-resize-handle:after{right:6px;border-right:2px solid #979790}#dmd-resize-left:after{left:6px;border-left:2px solid #979790}#dmd-panel [data-resize-corner]:hover:after{border-color:#eeeeea}.dmd-confirm-overlay{position:fixed;inset:0;background:#0009;display:flex;align-items:center;justify-content:center;z-index:2147483647;padding:20px}.dmd-confirm-box{width:min(560px,calc(100vw - 40px));max-height:calc(100vh - 40px);overflow:auto;background:#343433;color:#eeeeea;border:1px solid #545450;border-radius:6px;box-shadow:0 18px 60px #0008;padding:24px;font:14px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.dmd-confirm-title{font-size:19px;font-weight:600;margin-bottom:10px}.dmd-confirm-message{color:#aaa9a2;white-space:pre-wrap}.dmd-confirm-details{margin:16px 0 0;background:#2c2c2b;border:1px solid #484845;border-radius:4px;padding:12px;max-height:280px;overflow:auto;color:#aaa9a2;font:12px/1.5 Menlo,Consolas,monospace;white-space:pre-wrap}.dmd-confirm-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}@container (max-width:700px){#dmd-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));padding-right:24px}.dmd-tab{padding:10px 5px;font-size:12px}.dmd-view{padding:22px 24px}}@container (max-width:440px){.dmd-grid{grid-template-columns:1fr}.dmd-add-row{flex-wrap:wrap}.dmd-view{padding:18px}.dmd-filter-group>.dmd-grid{padding-left:12px}#dmd-log{flex-basis:100px}}#dmd-panel input:not([type=checkbox]):not([type=file]),#dmd-panel select:not([multiple]){min-width:0;max-width:100%;height:36px;min-height:36px;max-height:36px}#dmd-panel textarea{width:100%;min-width:0;max-width:100%;min-height:90px;max-height:min(320px,40vh);resize:vertical;overflow:auto}#dmd-panel select[multiple]{width:100%;min-width:0;max-width:100%;min-height:100px;max-height:min(320px,40vh);overflow:auto}#dmd-panel #dmd-log{overflow-y:auto;overflow-x:hidden;overflow-wrap:anywhere}#dmd-panel .dmd-actions+.dmd-field{margin-top:20px}#dmd-panel .dmd-native-select{display:none!important}#dmd-panel .dmd-select-trigger{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-width:0;height:36px;padding:8px 10px;border:1px solid #50504d;border-radius:4px;background:#3a3a39;color:#eeeeea;font:inherit;text-align:left;cursor:pointer}#dmd-panel .dmd-select-trigger span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#dmd-panel .dmd-select-trigger:after{content:"";width:6px;height:6px;border-right:1.5px solid #a8a8a2;border-bottom:1.5px solid #a8a8a2;transform:rotate(45deg);margin:0 3px 3px;flex:none}#dmd-panel .dmd-select-trigger:hover,#dmd-panel .dmd-select-trigger[aria-expanded=true]{background:#414140;border-color:#72726d}#dmd-panel input+.dmd-native-select+.dmd-select-trigger{margin-top:8px}.dmd-select-menu{position:fixed;z-index:2147483647;overflow-y:auto;padding:5px;background:#414140;border:1px solid #565651;border-radius:6px;box-shadow:0 8px 30px #0006;color:#eeeeea;font:13px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;scrollbar-width:thin}.dmd-select-menu button{display:block;width:100%;text-align:left;border:0;border-radius:3px;padding:8px 26px 8px 9px;background:transparent;color:inherit;font:inherit;cursor:pointer;position:relative}.dmd-select-menu button:hover,.dmd-select-menu button:focus{background:#ffffff0e;outline:none}.dmd-select-menu button[aria-selected=true]:after{content:"\\2713";position:absolute;right:9px;color:#b0aafa}.dmd-select-menu button:disabled{opacity:.4;cursor:default}#dmd-panel .dmd-identity-label{display:flex;align-items:center;gap:7px;min-height:20px}#dmd-panel .dmd-identity-avatar{position:relative;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#55554f;color:#eeeeea;font-size:10px;overflow:hidden;flex:none}#dmd-panel .dmd-identity-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}#dmd-panel>.dmd-confirm-overlay{position:absolute;inset:0;padding:18px;border-radius:inherit}#dmd-panel .dmd-confirm-box{width:min(560px,100%);max-height:100%;min-width:0}#dmd-panel .dmd-field>select+.dmd-pattern-options,#dmd-panel .dmd-select-trigger+.dmd-pattern-options{margin-top:12px}#dmd-panel .dmd-segments{display:flex;gap:4px;padding:4px;background:#343433;border:1px solid #4b4b49;border-radius:6px;margin-bottom:22px;flex-wrap:wrap}#dmd-panel .dmd-segments button{flex:1;border:0;border-radius:4px;background:transparent;color:#a8a8a2;padding:7px 10px;font:500 12px/1.4 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer}#dmd-panel .dmd-segments button.active{background:#50504d;color:#eeeeea}#dmd-panel .dmd-segments button:hover{color:#eeeeea}#dmd-panel .dmd-subview{margin-top:24px}#dmd-panel #dmd-tabs{display:flex;flex-wrap:nowrap}#dmd-panel .dmd-tab{flex:1}.dmd-calendar{position:fixed;z-index:2147483647;width:290px;max-height:calc(100vh - 16px);overflow:auto;padding:12px;background:#414140;color:#eeeeea;border:1px solid #565651;border-radius:6px;box-shadow:0 8px 30px #0006;font:13px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.dmd-calendar button{border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;padding:6px;cursor:pointer}.dmd-calendar button:hover,.dmd-calendar button:focus-visible{background:#ffffff12;outline:1px solid #8b85ed}.dmd-calendar-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.dmd-calendar-header span{font-weight:600}.dmd-calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;text-align:center}.dmd-calendar-grid span{color:#a8a8a2;font-size:11px;padding:4px}.dmd-calendar-grid .outside{color:#92928e}.dmd-calendar-grid .selected{background:#8b85ed;color:#fff}.dmd-calendar-time{display:flex;align-items:center;gap:6px;border-top:1px solid #565651;margin-top:10px;padding-top:10px}.dmd-calendar-time span{margin-right:auto}.dmd-calendar input{width:48px;padding:5px;background:#343433;color:#eeeeea;border:1px solid #565651;border-radius:4px;font:inherit}.dmd-calendar-footer{display:flex;justify-content:space-between;gap:8px;margin-top:12px}.dmd-calendar-footer button:last-child{background:#eeeeea;color:#2c2c2b;padding:6px 12px}.dmd-queue-avatar{width:32px;height:32px;flex:0 0 32px;border-radius:50%;background:var(--surface);display:grid;place-items:center;position:relative;overflow:hidden;color:var(--muted)}.dmd-queue-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}.dmd-channel-picker{position:relative;min-width:0}.dmd-channel-menu{position:fixed;z-index:2147483647;overflow:auto;padding:10px;background:#343433;color:#eeeeea;border:1px solid #565651;border-radius:6px;box-shadow:0 8px 30px #0006;font:13px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color-scheme:dark}.dmd-channel-menu[hidden]{display:none!important}.dmd-channel-menu input[type=search]{width:100%;padding:8px;border:1px solid #565651;border-radius:4px;background:#393938;color:inherit;font:inherit}.dmd-channel-menu input[type=checkbox]{width:14px;height:14px;accent-color:#8b85ed}.dmd-channel-choices{max-height:220px;overflow:auto;margin:8px 0;scrollbar-width:thin}.dmd-channel-option{display:flex!important;align-items:center;gap:9px;padding:7px 4px;margin:0!important;color:#eeeeea!important;cursor:pointer}.dmd-channel-option input{flex:0 0 auto}.dmd-channel-option span{overflow-wrap:anywhere}.dmd-channel-option:hover{background:#ffffff08}.dmd-channel-menu [hidden]{display:none!important}.dmd-orange{background:#bd7945;border-color:#cc8b56;color:#fff8f0}.dmd-orange:hover:not(:disabled){background:#ce8951}.dmd-start-value{margin-top:10px}.dmd-export-list{display:flex;flex-direction:column;gap:10px;max-height:200px;overflow:auto;margin:14px 0}.dmd-export-formats{border:1px solid #545450;border-radius:4px;display:flex;gap:20px;padding:10px 12px}.dmd-export-formats legend{color:#aaa9a2}#dmd-panel #dmd-log{scrollbar-color:#61615c #292928;scrollbar-gutter:stable;overscroll-behavior:contain}#dmd-panel #dmd-log::-webkit-scrollbar{width:10px}#dmd-panel #dmd-log::-webkit-scrollbar-track{background:transparent;margin:8px 0 26px}#dmd-panel #dmd-log::-webkit-scrollbar-thumb{background:#61615c;border:3px solid #292928;border-radius:10px;min-height:28px}#dmd-panel #dmd-log::-webkit-scrollbar-thumb:hover{background:#85857d}#dmd-panel #dmd-log::-webkit-scrollbar-thumb:active{background:#a4a49b}#dmd-panel #dmd-log::-webkit-scrollbar-button{display:none;width:0;height:0}#dmd-panel #dmd-log::-webkit-scrollbar-corner{background:transparent}@supports selector(::-webkit-scrollbar){#dmd-panel #dmd-log{scrollbar-width:auto;scrollbar-color:auto}}.dmd-scan-option{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center}#dmd-panel .dmd-progress-area{height:48px}.dmd-progress-meta{min-height:16px}#dmd-multi-status{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:14px}#dmd-panel .dmd-tooltip{width:260px;white-space:pre-line;line-height:1.75;padding:12px 14px}#dmd-reactions>.dmd-grid{gap:12px 16px}#dmd-reactions .dmd-start-value{margin-top:8px}#dmd-rx-start-field{display:grid;grid-template-columns:minmax(100px,.8fr) minmax(0,1.2fr);gap:0 8px}#dmd-rx-start-field>label{grid-column:1/-1}#dmd-rx-start-field .dmd-start-value{margin-top:0;min-width:0}#dmd-rx-start-date-field>label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}#dmd-log-area{display:flex;flex-direction:column;flex:0 0 150px;min-height:110px;max-height:30%;background:#292928;border-top:1px solid var(--line)}.dmd-log-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 24px;color:#a4a49f;font-size:11px}.dmd-log-toolbar>div{display:flex;gap:6px}.dmd-log-toolbar .dmd-btn{padding:3px 8px;min-height:24px;background:transparent;border-color:transparent;color:#b6b6ae}.dmd-log-toolbar .dmd-btn:hover{background:#ffffff0b;border-color:#50504d}#dmd-log-area #dmd-log{flex:1;min-height:0;max-height:none;border-top:0;padding-top:4px}.dmd-export-range{border:1px solid #545450;border-radius:4px;display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:12px;margin:12px 0}.dmd-export-range legend{color:#aaa9a2}.dmd-export-range [role=alert]{grid-column:1/-1;color:#ffaaa5}.dmd-export-range .dmd-field{min-width:0} +`;function kr(){if(document.getElementById("dmd-panel"))return;xt(vr);let e=document.createElement("button");e.id="dmd-toolbar-btn",e.type="button",e.title="Del-Discord v1 \u2014 Delete Messages",e.setAttribute("aria-label","Delete Messages"),e.innerHTML=vn();let t=document.createElement("div");t.id="dmd-panel",t.innerHTML=wr.replace("${MAX_REACTION_SCAN}",String(5e3)),document.body.appendChild(t);let r=t.querySelector("#dmd-messages"),o=t.querySelector("#dmd-forums");o.className="dmd-subview",r.appendChild(o);let a=t.querySelector("#dmd-multi"),i=document.createElement("div");i.id="dmd-queue-conversations",[...a.children].filter(m=>m.id!=="dmd-queue-modes").forEach(m=>i.appendChild(m)),a.appendChild(i);let s=t.querySelector("#dmd-ids");s.className="dmd-subview",a.appendChild(s);let d="dm",c="multi",f=new Map,n=m=>{if(f.has(m))return f.get(m);let C=t.querySelector(m);return f.set(m,C),C},u=wn(t,{getMode:()=>d,setMode:m=>{d=m},onReset:()=>{ae(),U()},onStorageError:()=>l("warn","Messages settings could not be saved locally. Changes may be lost on refresh.")});n("#dmd-clear-filters").onclick=u.clear;let w=Gn(t,{deleteConversations:m=>Sr(m)}),b=new Map,h=bt(n,{cache:b}),S=bt(n,{author:"#dmd-rx-author",guild:"#dmd-rx-guild",userBadge:"#dmd-rx-user-avatar",guildBadge:"#dmd-rx-guild-avatar",cache:b}),k=bt(n,{author:null,guild:"#dmd-server-id",userBadge:null,guildBadge:"#dmd-server-avatar",cache:b});An(t),Mn(t);let M=Sn(n,()=>d!=="thread"),T=n("#dmd-log"),{log:l,logEntries:y}=Kn(T),I=(m,C,D,R)=>{let O=String(m||"").trim();if(O){let G=it(O);if(!G)throw new Error(`${R} message must be a Discord message ID or message link.`);return G.channelId&&D&&G.channelId!==D&&l("warn",`${R} message link is for channel ${G.channelId}, but the selected channel is ${D}. Using the message ID anyway.`),G.messageId}return String(C||"").trim()},p=m=>({minId:I(n("#dmd-after-kind").value==="id"?n("#dmd-after-message").value:"",n("#dmd-after-kind").value==="date"?n("#dmd-after").value:"",m,"After"),maxId:I(n("#dmd-before-kind").value==="id"?n("#dmd-before-message").value:"",n("#dmd-before-kind").value==="date"?n("#dmd-before").value:"",m,"Before")}),g=m=>we({freshHistory:!0,...p(m),authorIds:xe(n("#dmd-author").value,"Author(s)"),action:n("#dmd-action").value,order:n("#dmd-order").value,overwriteText:n("#dmd-overwrite-text").value,content:n("#dmd-content").value,textMode:n("#dmd-text-mode").value,linkMode:n("#dmd-link-mode").value,fileMode:n("#dmd-file-mode").value,pinnedMode:n("#dmd-pinned-mode").value,includeNsfw:n("#dmd-nsfw").checked,filename:n("#dmd-filename").value,extensions:n("#dmd-extensions").value,textRegex:n("#dmd-text-regex").value,regexFlags:n("#dmd-regex-flags").value,regexMode:n("#dmd-regex-mode").value,assetRegex:n("#dmd-asset-regex").value,assetRegexFlags:n("#dmd-asset-regex-flags").value,assetMode:n("#dmd-asset-mode").value,collectAll:n("#dmd-collect-all").checked});n("#dmd-action").onchange=()=>{n("#dmd-overwrite-field").hidden=n("#dmd-action").value!=="overwrite"},n("#dmd-action").onchange();let x=!1,$=(m,C)=>{x=m;for(let D of["#dmd-export-conversation","#dmd-id-import","#dmd-id-package","#dmd-forum-load","#dmd-multi-export","#dmd-forum-export","#dmh-refresh","#dmh-import","#dmh-clear"])n(D).disabled=m;n(C).disabled=!m},E=(m,C)=>{let D=n(m);n(C).textContent=`${Math.round(D.value/Math.max(1,D.max)*100)}%`},A=(m,C)=>{C=Math.max(Number(C)||1,Number(m)||0,1);let D=n("#dmd-progress");D.max=C,D.value=Math.min(Number(m)||0,C),n("#dmd-pct").textContent=`${Math.round(D.value/D.max*100)}%`},v=(m,C,D="")=>{C=Math.max(Number(C)||1,1);let R=n("#dmd-multi-progress");R.max=C,R.value=Math.min(Number(m)||0,C),n("#dmd-multi-pct").textContent=`${Math.round(R.value/R.max*100)}%`,n("#dmd-multi-status").textContent=D},N=new Map,_=(m,C)=>{let D=n(m);(!D.value.trim()||D.value===N.get(m))&&(D.value=C,N.set(m,C))};for(let m of["#dmd-guild","#dmd-channel","#dmd-rx-channel","#dmd-rx-guild","#dmd-forum-id"])n(m).addEventListener("input",()=>N.delete(m)),n(m).addEventListener("change",()=>N.delete(m));let U=()=>{let m=je();m&&(_("#dmd-guild",m.guildId),_("#dmd-channel",m.channelId),_("#dmd-rx-channel",m.channelId),_("#dmd-rx-guild",m.guildId),S.refresh("guild"),m.guildId!=="@me"&&_("#dmd-forum-id",m.channelId),h.refresh("guild"),M.refresh())},H=Cn(),L=0,z=async()=>{let m=n("#dmd-channel").value.trim(),C=n("#dmd-token").value.trim(),D=++L;if(!(t.style.display!=="flex"||!C||!/^\d{15,22}$/.test(m)||x||d==="server"))try{let R=await H({token:C,channelId:m});if(D!==L||x||d==="server"||n("#dmd-channel").value.trim()!==m)return;d=R.mode,_("#dmd-guild",R.guildId),R.mode==="forums"&&_("#dmd-forum-id",R.channelId),ae()}catch(R){D===L&&!x&&l("warn",R.message)}},P=async()=>{let m=await pe(n("#dmd-token").value.trim());return m?(n("#dmd-token").value=m.token,n("#dmd-author").value.trim()||(n("#dmd-author").value=m.user.id),h.seedUser(m.user),_("#dmd-rx-author",m.user.id),S.refresh("user"),S.refresh("guild"),h.refresh("user"),h.refresh("guild"),M.refresh(),l("success",`Authorization loaded for ${m.user.username||"your account"}.`),z(),m):(l("error","Could not find a valid Discord authorization token. Refresh Discord and press GET again."),null)},K=En(),q=null,j=Yn({$:n,log:l,onChange:()=>q?.()}),{renderQueue:ne}=j,fe=!1,ce=new Set,de=async()=>{let m=n("#dmd-token").value.trim();if(!(!m||fe||x)){fe=!0;try{for(let C of[...j.getItems()]){if(x)break;let D=`${C.guildId}/${C.channelId}`;C.icon||C.kind==="ids"||ce.has(D)||(ce.add(D),j.updateIdentity(C,await K.resolve(C,m,l)),await Q(ee(350,600)))}}finally{fe=!1}}},Z=Xn({panel:t,dragHandle:t,resizeHandles:[n("#dmd-resize-left"),n("#dmd-resize-handle")],storage:()=>localStorage,storageKey:"del_discord_v1_deleter_geometry_compact",defaultWidth:660,defaultRatio:660/480});function ie(){let m=Zn();return m?(e.parentElement!==m&&m.appendChild(e),!0):!1}e.onclick=async()=>{let m=t.style.display==="flex";t.style.display=m?"none":"flex",e.classList.toggle("open",!m),m||(Z.ensureGeometry(),U(),n("#dmd-token").value?z():await P(),j.getItems().length&&!x&&await se({title:"Saved Queue",message:`${j.getItems().length} queued cleanups are saved locally. Continue running them or clear the queue?`,yesText:"Continue",noText:"Clear Queue",onNo:()=>j.clear()})&&!x&&(t.querySelector('[data-view="multi"]').click(),await n("#dmd-multi-start").onclick()))};let ae=()=>{let m=t.querySelector(".dmd-tab.active").dataset.view;n("#dmd-messages").style.display=m==="messages"?"":"none",n("#dmd-reactions").style.display=m==="reactions"?"":"none",n("#dmd-multi").style.display=m==="multi"?"":"none";let C=d==="forums"||d==="thread";n("#dmd-channel").previousElementSibling.textContent=d==="thread"?"Thread(s)":"Channel(s)",n("#dmd-channel").placeholder=d==="thread"?"Thread ID, thread ID":"Channel ID, channel ID",n("#dmd-thread-target").hidden=!C,n("#dmd-channel").closest(".dmd-field").hidden=d==="server",n("#dmd-archived-option").hidden=d!=="forums",C&&n("#dmd-thread-target-kind").value!==d&&(n("#dmd-thread-target-kind").value=d,n("#dmd-thread-target-kind").dispatchEvent(new Event("change"))),t.querySelectorAll("#dmd-message-modes button").forEach(R=>{let O=R.dataset.mode===d||C&&R.dataset.mode==="forums";R.classList.toggle("active",O),R.setAttribute("aria-pressed",String(O))}),n("#dmd-forums").style.display=d==="forums"?"":"none",n("#dmd-messages > .dmd-actions").hidden=d==="forums",n("#dmd-queue-conversations").hidden=c==="ids",n("#dmd-queue-server-inputs").hidden=c!=="server",n("#dmd-ids").style.display=c==="ids"?"":"none",n("#dmh-panel").style.display=m==="history"?"flex":"none",n("#dmd-progress-dock").hidden=!1;let D=m==="multi"?c==="server"?"multi":c:m;t.querySelectorAll("[data-progress-view]").forEach(R=>{R.hidden=R.dataset.progressView!==D}),M.refresh()};t.querySelectorAll(".dmd-tab").forEach(m=>{m.onclick=()=>{t.querySelectorAll(".dmd-tab").forEach(C=>C.classList.toggle("active",C===m)),ae(),m.dataset.view==="history"&&w.activate(),m.dataset.view==="multi"&&(ne(),de())}}),t.querySelectorAll("#dmd-message-modes button").forEach(m=>{m.onclick=()=>{d=m.dataset.mode==="forums"?n("#dmd-thread-target-kind").value:m.dataset.mode,h.refresh("guild"),ae(),u.save()}}),n("#dmd-thread-target-kind").addEventListener("change",()=>{d=n("#dmd-thread-target-kind").value,ae(),u.save()}),t.querySelectorAll("#dmd-queue-modes button").forEach(m=>{m.onclick=()=>{c=m.dataset.mode,t.querySelectorAll("#dmd-queue-modes button").forEach(C=>{C.classList.toggle("active",C===m),C.setAttribute("aria-pressed",String(C===m))}),ae()}}),ae();let ye=!1,De=null,$e=async(m,C,D=!1,R=null)=>{if(ye)return;if(!m.length&&!R?.length)return l("warn","Select at least one conversation or message ID.");if(m.some(G=>!G.guildId||!/^\d{15,22}$/.test(String(G.kind==="server"?G.guildId:G.channelId||""))))return l("error","Enter valid conversation IDs before adding to the queue.");ye=!0;let O;De=new Promise(G=>{O=G});try{if(!await se({title:"Add to Queue?",message:`Queue this ${C.action||"delete"} cleanup with the selected filters? Starting the queue will run it without another confirmation.`,details:R?`${R.length} message IDs`:m.map(J=>J.label||J.channelId).join(` +`),yesText:"Add to Queue",noText:"Cancel"}))return;let X={...C};if(delete X.textPattern,delete X.assetPattern,R)j.addIdJob(R,X);else{let J=[];for(let te of m){let ke=await K.resolve(te,n("#dmd-token").value.trim(),l);J.push({...ke,options:X,allowAnyThread:D,detectConversation:te.kind!=="server"})}j.addQueueItems(J)}l("info",q?"Cleanup added to the running queue.":"Cleanup queued. Press Start Queue to begin.")}finally{ye=!1,O(),De=null}},Sr=async m=>{if(x)return await $e(m.map(D=>({guildId:"@me",channelId:D.channelId,label:D.name||D.username||D.channelId})),{action:"delete",collectAll:!0,pinnedMode:"any"}),[];let C=[];$(!0,"#dmh-stop"),F.stopped=!1,n("#dmh-stop").hidden=!1;try{let D=await pe(n("#dmd-token").value.trim())||await P();if(!D||F.stopped||!await se({title:"Are you sure?",message:`Delete all of your messages, including pinned messages, in ${m.length} selected DMs? Other people's messages and the DM conversations will remain.`,details:m.map(O=>O.name||O.username||O.channelId||O.userId).join(` +`),yesText:"Delete All",noText:"Cancel",danger:!0})||F.stopped)return C;n("#dmh-progress").max=m.length,n("#dmh-progress").value=0,E("#dmh-progress","#dmh-pct");for(let[O,G]of m.entries()){if(F.stopped)break;let X=G.channelId;if(!X&&G.userId){let te=await W(`${B}/users/@me/channels`,{method:"POST",headers:{Authorization:D.token,"Content-Type":"application/json"},body:JSON.stringify({recipient_id:G.userId})},l,()=>F.stopped);if(!te.ok)throw new Error(`Could not open DM (${te.status}).`);X=(await te.json()).id}if(F.stopped)break;if(!/^\d{15,22}$/.test(String(X||"")))throw new Error("This history entry has no valid DM channel.");n("#dmh-progress-status").textContent=`Cleaning ${O+1}/${m.length}`;let J=await dt({token:D.token,authorId:D.user.id,guildId:"@me",channelId:X,action:"delete",pinnedMode:"any",collectAll:!0,freshHistory:!0,log:l,stopCheck:()=>F.stopped});if(!J?.done||J.failed>0)break;C.push(G),n("#dmh-progress").value=O+1,E("#dmh-progress","#dmh-pct")}l("info",`Finished ${C.length}/${m.length} selected DMs.`)}catch(D){l("error",D.message||D)}finally{$(!1,"#dmh-stop"),n("#dmh-stop").hidden=!0,F.stopped=!1}return C};n("#dmh-stop").onclick=()=>{F.stopped=!0},n("#dmd-get-token").onclick=P,n("#dmd-clear").onclick=()=>{T.textContent="",y.length=0,A(0,1)},n("#dmd-export-log").onclick=()=>{if(!y.length)return l("warn","There is no log to export yet.");let m=je(),C=["Discord deletion log",`Exported: ${new Date().toISOString()}`,`Server: ${m?.guildId||n("#dmd-guild").value.trim()||""}`,`Channel: ${m?.channelId||n("#dmd-channel").value.trim()||""}`,""].join(` +`),D=y.map(R=>`[${R.time}] [${String(R.type).toUpperCase()}] ${R.message}`).join(` +`);tr(`discord-log-${Xt()}.txt`,C+D)};let Xe=null,At=async({targets:m,stopId:C,stopped:D,setStopped:R,progress:O,filename:G,select:X=!1})=>{if(x)return;let J;try{J=m()}catch(he){return l("error",he.message)}if(!J.length)return l("warn","Select or queue at least one conversation to export.");$(!0,C),R(!1);let te=new AbortController,{signal:ke}=te;Xe=te;try{let he=await ir(J,{range:p(J[0].channelId),select:X,signal:ke});if(!he||D())return;J=he.conversations;let{format:me,range:ct,downloadAttachments:Ft}=he,hn=await pe(n("#dmd-token").value.trim(),{log:l,stopCheck:D,strict:!0,signal:ke});if(D())return;if(!hn)return l("error","Authorization could not be validated. Refresh Discord and try again.");let mt=[],zt=[],Pt=[],Er=nn(),Ut=`discord-${We(G)}-${Xt()}`,Ot=`${Ut}.${me}`,jt=me==="json"&&J.length>1?`{ + "exportedAt": ${JSON.stringify(new Date().toISOString())}, + "conversationCount": ${J.length}, "conversations": [ -`,...we,` +`:"",gn=jt?` ] -}`],{type:"application/json;charset=utf-8"}):new Blob(we,{type:Z==="csv"?"text/csv;charset=utf-8":"application/json;charset=utf-8"});Nt(`discord-${Tn(H)}-${Tt()}.${Z}`,Xn),l("success",`Export complete: ${B.length} conversations, ${Jt} messages.`)}catch(ce){A()||l("error",ce?.message||ce)}finally{I(!1,k),F(!1)}};n("#dmd-export-conversation").onclick=()=>gt({targets:()=>{let c=n("#dmd-guild").value.trim();return c?xe(n("#dmd-channel").value,"Channel(s)",{required:!0}).map(k=>({guildId:c,channelId:k,label:_e()})):[]},stopId:"#dmd-stop",stopped:()=>O.stopped,setStopped:c=>{O.stopped=c},progress:D,filename:`conversation-${_e()}`}),n("#dmd-multi-export").onclick=()=>gt({targets:()=>Q.getItems().filter(c=>!["ids","server"].includes(c.kind)).map(c=>({...c})),stopId:"#dmd-multi-stop",stopped:()=>O.multiStopped,setStopped:c=>{O.multiStopped=c},progress:(c,k)=>x(c,k,`Exported ${c}/${k}`),filename:"queue-conversations",select:!0}),n("#dmd-forum-export").onclick=()=>gt({targets:()=>Be().map(c=>({guildId:c.guild_id,channelId:c.id,label:c.name||c.id})),stopId:"#dmd-forum-stop",stopped:()=>O.stopped,setStopped:c=>{O.stopped=c},progress:(c,k)=>{n("#dmd-progress").max=k,n("#dmd-progress").value=c,M("#dmd-progress","#dmd-pct"),n("#dmd-forum-status").textContent=`Exported ${c}/${k}`},filename:"forum-conversations"}),n("#dmd-stop").onclick=()=>{O.stopped=!0,l("warn","Stop requested...")},n("#dmd-rx-stop").onclick=()=>{O.reactionStopped=!0,l("warn","Stop requested...")};for(let c of["after","before"])n(`#dmd-${c}-kind`).onchange=()=>{let k=n(`#dmd-${c}-kind`).value==="date";n(`#dmd-${c}-date-field`).hidden=!k,n(`#dmd-${c}-id-field`).hidden=k},n(`#dmd-${c}-kind`).onchange();let Pt=async(c=null)=>{try{if(j(),s==="server"){let F=n("#dmd-guild").value.trim();if(!/^\d{15,22}$/.test(F))throw new Error("Enter a server ID in Server.");let P=await W.readGuild(F,n("#dmd-token").value.trim(),l);return await Ie([{kind:"server",guildId:F,channelId:`server:${F}`,label:P.name||F}],v(""))}let k=Le(),A=(c||xe(n("#dmd-channel").value,"Channel(s)",{required:!0})).map(F=>({guildId:n("#dmd-guild").value.trim(),channelId:F,label:k?.channelId===F?_e():F,thread:s==="thread"}));return await Ie(A,v(""),s==="thread")}catch(k){return l("error",k.message)}};n("#dmd-add-queue").onclick=()=>Pt(),n("#dmd-start").onclick=async(c,k=null)=>{if(j(),E)return Pt(k);I(!0,"#dmd-stop"),O.stopped=!1;try{let A=await me(n("#dmd-token").value.trim())||await z();if(!A||O.stopped)return;if(s==="server"){let B=n("#dmd-guild").value.trim();if(!/^\d{15,22}$/.test(B))throw new Error("Enter a server ID in Server.");await Ye({...v(""),guildId:B,token:A.token,authorId:A.user.id,log:l,progress:D,stopCheck:()=>O.stopped});return}let F=k||xe(n("#dmd-channel").value,"Channel(s)",{required:!0}),P=F.map(B=>v(B)),H=[];for(let B of F)H.push(await Te({token:A.token,channelId:B,log:l,stopCheck:()=>O.stopped}));if(O.stopped)return;let V=H.find(B=>B.mode==="forums");if(V){if(H.length!==1)throw new Error("Browse one forum at a time to select its threads.");s="forums",_("#dmd-forum-id",V.channelId),ie(),n("#dmd-stop").disabled=!0,n("#dmd-forum-stop").disabled=!1,await Bt(A,V.channelId),l("info","Select threads, then press Clean selected threads.");return}s=H[0].mode,ie();for(let[B,Z]of H.entries()){if(O.stopped)break;let{channelId:ce,guildId:It,mode:St}=Z,we=await(St==="thread"?ft:Ye)({...P[B],allowAnyThread:St==="thread",token:A.token,authorId:A.user.id,guildId:It,channelId:ce,log:l,progress:D,stopCheck:()=>O.stopped});if(!we?.done||we.failed>0)break}}catch(A){l("error",A?.message||A)}finally{I(!1,"#dmd-stop"),n("#dmd-forum-stop").disabled=!0,O.stopped=!1}};let Me=()=>{let c=xe(n("#dmd-channel").value,"Channel(s)");return c.length===1?c[0]:""},xt=c=>{if(c.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch. Split larger imports into smaller files.`);return c},bt,vt=()=>{clearTimeout(bt);try{let c=Ee(n("#dmd-id-input").value,Me()||"000000000000000");n("#dmd-id-count").textContent=`${c.length.toLocaleString()} IDs`}catch(c){n("#dmd-id-count").textContent=c.message}};n("#dmd-id-input").addEventListener("input",()=>{clearTimeout(bt),bt=setTimeout(vt,150)});let Ut=async c=>{if(!E){n("#dmd-id-import").disabled=!0,n("#dmd-id-package").disabled=!0;try{if(!c?.length)return;let k=await Un(c,Me()),A=n("#dmd-id-input"),F=Ee(A.value,Me()||"000000000000000"),P=new Set(F.map(Z=>`${Z.channelId}/${Z.messageId}`)),H=k.filter(Z=>!P.has(`${Z.channelId}/${Z.messageId}`));xt([...F,...H]);let V=H.map(Z=>`https://discord.com/channels/@me/${Z.channelId}/${Z.messageId}`).join(` -`),B=A.value+(A.value&&V?` -`:"")+V;Ee(B,Me()||"000000000000000"),A.value=B,A.dispatchEvent(new Event("input",{bubbles:!0})),vt(),l("success",`Loaded ${k.length} message IDs locally. No messages were changed.`)}catch(k){l("error",k?.message||k)}finally{n("#dmd-id-import").disabled=E,n("#dmd-id-package").disabled=E,n("#dmd-id-files").value="",n("#dmd-id-folder").value=""}}};n("#dmd-id-import").onclick=()=>n("#dmd-id-files").click(),n("#dmd-id-package").onclick=()=>n("#dmd-id-folder").click(),n("#dmd-id-files").onchange=c=>Ut(c.target.files),n("#dmd-id-folder").onchange=c=>Ut(c.target.files),n("#dmd-id-clear").onclick=()=>{n("#dmd-id-input").value="",vt()},n("#dmd-id-stop").onclick=()=>{O.stopped=!0,l("warn","Stop requested...")},n("#dmd-id-start").onclick=async()=>{if(E)try{let c=xt(Ee(n("#dmd-id-input").value,Me()));return await Ie([],v(""),!1,c)}catch(c){return l("error",c.message)}I(!0,"#dmd-id-stop"),O.stopped=!1;try{let c=await me(n("#dmd-token").value.trim())||await z();if(!c||O.stopped)return;let k=xt(Ee(n("#dmd-id-input").value,Me())),A=v("");await jt({...A,token:c.token,authorId:c.user.id,targets:k,log:l,stopCheck:()=>O.stopped,progress:(F,P,H)=>{n("#dmd-id-progress").max=Math.max(1,P),n("#dmd-id-progress").value=F,M("#dmd-id-progress","#dmd-id-pct"),n("#dmd-id-status").textContent=`${H}: ${F}/${P}`}})}catch(c){l("error",c?.message||c)}finally{I(!1,"#dmd-id-stop"),O.stopped=!1}},n("#dmd-rx-start-kind").onchange=()=>{let c=n("#dmd-rx-start-kind").value==="date";n("#dmd-rx-start-id-field").hidden=c,n("#dmd-rx-start-date-field").hidden=!c},n("#dmd-rx-go").onclick=async()=>{if(E)return l("warn","Wait for the current cleanup to finish before removing reactions.");I(!0,"#dmd-rx-stop"),n("#dmd-rx-go").disabled=!0,O.reactionStopped=!1;try{let c=await me(n("#dmd-token").value.trim())||await z();if(!c||O.reactionStopped)return;j();let k=xe(n("#dmd-rx-channel").value.trim()||n("#dmd-channel").value,"Channel(s)",{required:!0}),A=Math.max(1,Math.min(5e3,parseInt(n("#dmd-rx-limit").value,10)||1e3));for(let F of k){if(O.reactionStopped)break;await Hn({token:c.token,channelId:F,startId:(n("#dmd-rx-start-kind").value==="date"?n("#dmd-rx-start-date"):n("#dmd-rx-start")).value.trim(),scanLimit:A,reactionAuthorId:n("#dmd-rx-author").value.trim(),reactionIds:n("#dmd-rx-filter").value,skipOwn:n("#dmd-rx-skipown").checked,authorId:c.user.id,log:l,progress:(P,H,V)=>{n("#dmd-rx-progress").max=Math.max(1,H),n("#dmd-rx-progress").value=P,M("#dmd-rx-progress","#dmd-rx-pct"),n("#dmd-rx-phase").textContent=`${V||""} ${P}/${H}`}})}}catch(c){l("error",c?.message||c)}finally{I(!1,"#dmd-rx-stop"),n("#dmd-rx-go").disabled=!1,O.reactionStopped=!1}};let qt=async c=>{try{if(!/^\d{15,22}$/.test(c||""))throw new Error("Enter a valid Server ID or open a server channel.");let k=await me(n("#dmd-token").value.trim())||await z();if(!k)return;let A=await W.readGuild(c,k.token,l);n("#dmd-server-id").value=c,g.seedGuild(A),g.refresh("guild"),await Ie([{kind:"server",guildId:c,channelId:`server:${c}`,label:A.name||`Server ${c}`}],v(""))}catch(k){l("error",k.message)}};n("#dmd-server-add").onclick=()=>qt(n("#dmd-server-id").value.trim()),n("#dmd-server-current").onclick=()=>qt(Le()?.guildId),n("#dmd-multi-clear").onclick=async()=>{Q.getItems().length&&!await re({title:"Clear queue?",message:`Remove all ${Q.getItems().length} queued conversations?`,yesText:"Clear",noText:"Cancel",danger:!0})||(Q.clear(),x(0,1,""))},n("#dmd-multi-stop").onclick=()=>{O.multiStopped=!0,l("warn","Stopping queue...")},n("#dmd-multi-start").onclick=async()=>{if(E)return l("info","The queue will continue automatically as jobs are added.");if(!Q.getItems().length)return l("error","The queue is empty.");I(!0,"#dmd-multi-stop"),O.multiStopped=!1;try{let c=await me(n("#dmd-token").value.trim())||await z();if(!c||O.multiStopped)return;let k=v(""),A=0,F=null;for(K=()=>{let P=A+Q.getItems().length;x(A,P,F?`Running ${A+1}/${P}: ${We(F.label)||F.channelId}`:`Finished ${A}/${P}`)},K();(Q.getItems().length||Xe)&&!O.multiStopped;){if(!Q.getItems().length){await ht;continue}let P={...Q.getItems()[0]};F=P,K();let H=P.options?be(P.options):k,V;if(P.detectConversation&&P.kind!=="ids"&&(V=await Te({token:c.token,channelId:P.channelId,log:l,stopCheck:()=>O.multiStopped}),V.mode==="forums"))throw new Error("Open this forum in Messages and select its threads before queuing cleanup.");let B=await(P.kind==="ids"?jt:(V?V.mode==="thread":P.thread)?ft:Ye)({...H,freshHistory:!0,token:c.token,authorId:c.user.id,guildId:V?.guildId||P.guildId,channelId:["ids","server"].includes(P.kind)?void 0:P.channelId,targets:P.targets,allowAnyThread:V?V.mode==="thread":P.allowAnyThread,skipConfirm:!0,log:l,stopCheck:()=>O.multiStopped});if(B?.unauthorized){O.multiStopped=!0;break}if(B?.cancelled||B?.stopped||B?.stalled||B?.httpStatus){O.multiStopped=!0;break}if(!B?.done||B.failed>0){O.multiStopped=!0;break}A++,F=null,Q.completeItem(P),K(),Q.getItems().length&&!O.multiStopped&&await G(ee(1400,2400))}l(O.multiStopped?"warn":"success",O.multiStopped?"Queue stopped.":`Finished all ${A} queued items.`)}catch(c){l("error",c?.message||c)}finally{K=null,I(!1,"#dmd-multi-stop"),O.multiStopped=!1}};let wt=()=>{let c=n("#dmd-forum-id").value.trim();return c&&c!==N.get("#dmd-forum-id")?c:n("#dmd-channel").value.trim()||c},Ae=[],Ze=new Map,Be=()=>[...n("#dmd-forum-list").selectedOptions].map(c=>Ze.get(c.value)).filter(Boolean),Bt=async(c,k)=>{Ae=[],Ze.clear(),n("#dmd-forum-list").textContent="",Ae=await jn({token:c.token,forumId:k,includeArchived:n("#dmd-forum-archived").checked,log:l,stopCheck:()=>O.stopped}),Ze=new Map(Ae.map(F=>[F.id,F]));let A=document.createDocumentFragment();for(let F of Ae){let P=document.createElement("option");P.value=F.id,P.textContent=`${F.name||F.id}${F.thread_metadata?.archived?" \xB7 archived":""}${F.thread_metadata?.locked?" \xB7 locked":""}`,A.appendChild(P)}n("#dmd-forum-list").appendChild(A),n("#dmd-forum-status").textContent=`${Ae.length} threads loaded`};n("#dmd-forum-stop").onclick=()=>{O.stopped=!0,l("warn","Stop requested...")},n("#dmd-forum-load").onclick=async()=>{if(!E){I(!0,"#dmd-forum-stop"),O.stopped=!1,Ae=[],Ze.clear(),n("#dmd-forum-list").textContent="";try{let c=await me(n("#dmd-token").value.trim())||await z();if(!c||O.stopped)return;j();let k=wt(),A=await Te({token:c.token,channelId:k,log:l,stopCheck:()=>O.stopped});if(s=A.mode,ie(),A.mode!=="forums"){if(n("#dmd-channel").value.trim()!==A.channelId&&n("#dmd-channel").value.trim()&&n("#dmd-channel").value!==N.get("#dmd-channel")){l("warn","The forum field targets a different conversation. Update Channel(s) to use this target.");return}_("#dmd-channel",A.channelId),l("info","Conversation detected. Press Start to preview its matching messages.");return}await Bt(c,A.channelId)}catch(c){n("#dmd-forum-status").textContent="No thread list loaded",l("error",c?.message||c)}finally{I(!1,"#dmd-forum-stop"),O.stopped=!1}}},n("#dmd-forum-queue").onclick=async()=>{let c=Be();if(!c.length)return l("warn","Select at least one forum thread.");try{await Ie(c.map(k=>({guildId:k.guild_id,channelId:k.id,label:`Thread: ${k.name||k.id}`,thread:!0})),v(""),!0)}catch(k){l("error",k.message)}},n("#dmd-forum-run").onclick=async()=>{if(E&&!Be().length)try{return j(),await n("#dmd-start").onclick(null,xe(wt(),"Conversation IDs",{required:!0}))}catch(k){return l("error",k.message)}if(E)try{return await Ie(Be().map(k=>({guildId:k.guild_id,channelId:k.id,label:`Thread: ${k.name||k.id}`,thread:!0})),v(""))}catch(k){return l("error",k.message)}let c=Be();if(!c.length)try{j();let k=wt();return await n("#dmd-start").onclick(null,k?xe(k,"Conversation IDs",{required:!0}):null)}catch(k){return l("error",k.message)}I(!0,"#dmd-forum-stop"),O.stopped=!1;try{let k=await me(n("#dmd-token").value.trim())||await z();if(!k||O.stopped)return;let A=v("");n("#dmd-progress").max=c.length,n("#dmd-progress").value=0,M("#dmd-progress","#dmd-pct");for(let[F,P]of c.entries()){if(O.stopped)break;n("#dmd-forum-status").textContent=`${F+1}/${c.length}: ${P.name||P.id}`;let H=await ft({...A,guildId:P.guild_id,channelId:P.id,token:k.token,authorId:k.user.id,log:l,stopCheck:()=>O.stopped});if(H.unauthorized||H.cancelled||H.stopped)break;n("#dmd-progress").value=F+1,M("#dmd-progress","#dmd-pct")}}catch(k){l("error",k?.message||k)}finally{I(!1,"#dmd-forum-stop"),O.stopped=!1}};let Ht=location.href,yt=!1,Wt=()=>e.isConnected&&e.parentElement?.isConnected&&e.offsetParent!==null,Yn=()=>{yt||(yt=!0,requestAnimationFrame(()=>{yt=!1,Wt()||de()}))},kt=()=>{location.href!==Ht&&(Ht=location.href,j(),R())};Dn(()=>{kt(),Wt()||Yn()}),window.addEventListener("popstate",kt,{passive:!0}),window.addEventListener("hashchange",kt,{passive:!0}),de(),j()}function Kn(){Gn()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",Kn,{once:!0}):Kn();})(); +}`:"",et=Ft?dr():null,qt=jt.length+gn.length;et?.setFile(Ot,qt),et?.setFile("attachments.json",2);let bn=0;O(0,J.length);for(let[tt,Bt]of J.entries()){if(D())return;let Le=await ar({token:hn.token,...Bt,...ct,log:l,stopCheck:D,signal:ke});if(D())return;let _e=await or(Le,{format:me,includeCsvHeader:tt===0,stopCheck:D});if(D())return;let Ht=mt.length?me==="csv"?`\r +`:`, +`:"";if((me==="json"||_e.size)&&(qt+=Ht.length+_e.size,et?.setFile(Ot,qt)),Ft&&await mr(Le,{files:zt,report:Pt,log:l,stopCheck:D,state:Er,signal:ke,budget:et}),D())return;bn+=Le.messageCount,(me==="json"||_e.size)&&(Ht&&mt.push(Ht),mt.push(_e)),O(tt+1,J.length)}let xn=new Blob([jt,...mt,gn],{type:me==="csv"?"text/csv;charset=utf-8":"application/json;charset=utf-8"});if(Ft){l("info","Packaging messages and attachments...");let tt=new Blob([JSON.stringify(Pt,null,2)],{type:"application/json"});et.setFile("attachments.json",tt.size);let Bt=await lr([{name:Ot,blob:xn},{name:"attachments.json",blob:tt},...zt],D);if(D())return;$t(`${Ut}.zip`,Bt);let Le=Pt.filter(_e=>["failed","skipped"].includes(_e.status)).length;l(Le?"warn":"info",`Attachments: ${zt.length} downloaded; ${Le} unavailable. See attachments.json in the ZIP.`)}else $t(`${Ut}.${me}`,xn);l("success",`Export complete: ${J.length} conversations, ${bn} messages.`)}catch(he){D()||l("error",he?.message||he)}finally{Xe=null,$(!1,C),R(!1)}};n("#dmd-export-conversation").onclick=()=>At({targets:()=>{let m=n("#dmd-guild").value.trim();return m?xe(n("#dmd-channel").value,"Channel(s)",{required:!0}).map(C=>({guildId:m,channelId:C,label:qe()})):[]},stopId:"#dmd-stop",stopped:()=>F.stopped,setStopped:m=>{F.stopped=m},progress:A,filename:`conversation-${qe()}`}),n("#dmd-multi-export").onclick=()=>At({targets:()=>j.getItems().filter(m=>!["ids","server"].includes(m.kind)).map(m=>({...m})),stopId:"#dmd-multi-stop",stopped:()=>F.multiStopped,setStopped:m=>{F.multiStopped=m},progress:(m,C)=>v(m,C,`Exported ${m}/${C}`),filename:"queue-conversations",select:!0}),n("#dmd-forum-export").onclick=()=>At({targets:()=>Ze().map(m=>({guildId:m.guild_id,channelId:m.id,label:m.name||m.id})),stopId:"#dmd-forum-stop",stopped:()=>F.stopped,setStopped:m=>{F.stopped=m},progress:(m,C)=>{n("#dmd-progress").max=C,n("#dmd-progress").value=m,E("#dmd-progress","#dmd-pct"),n("#dmd-forum-status").textContent=`Exported ${m}/${C}`},filename:"forum-conversations"}),n("#dmd-stop").onclick=()=>{F.stopped=!0,Xe?.abort(),l("warn","Stop requested...")},n("#dmd-rx-stop").onclick=()=>{F.reactionStopped=!0,l("warn","Stop requested...")};for(let m of["after","before"])n(`#dmd-${m}-kind`).onchange=()=>{let C=n(`#dmd-${m}-kind`).value==="date";n(`#dmd-${m}-date-field`).hidden=!C,n(`#dmd-${m}-id-field`).hidden=C},n(`#dmd-${m}-kind`).onchange();let ln=async(m=null)=>{try{if(U(),d==="server"){let R=n("#dmd-guild").value.trim();if(!/^\d{15,22}$/.test(R))throw new Error("Enter a server ID in Server.");let O=await K.readGuild(R,n("#dmd-token").value.trim(),l);return await $e([{kind:"server",guildId:R,channelId:`server:${R}`,label:O.name||R}],g(""))}let C=je(),D=(m||xe(n("#dmd-channel").value,"Channel(s)",{required:!0})).map(R=>({guildId:n("#dmd-guild").value.trim(),channelId:R,label:C?.channelId===R?qe():R,thread:d==="thread"}));return await $e(D,g(""),d==="thread")}catch(C){return l("error",C.message)}};n("#dmd-add-queue").onclick=()=>ln(),n("#dmd-start").onclick=async(m,C=null)=>{if(U(),x)return ln(C);$(!0,"#dmd-stop"),F.stopped=!1;try{let D=await pe(n("#dmd-token").value.trim())||await P();if(!D||F.stopped)return;if(d==="server"){let J=n("#dmd-guild").value.trim();if(!/^\d{15,22}$/.test(J))throw new Error("Enter a server ID in Server.");await dt({...g(""),guildId:J,token:D.token,authorId:D.user.id,log:l,progress:A,stopCheck:()=>F.stopped});return}let R=C||xe(n("#dmd-channel").value,"Channel(s)",{required:!0}),O=R.map(J=>g(J)),G=[];for(let J of R)G.push(await Ue({token:D.token,channelId:J,log:l,stopCheck:()=>F.stopped}));if(F.stopped)return;let X=G.find(J=>J.mode==="forums");if(X){if(G.length!==1)throw new Error("Browse one forum at a time to select its threads.");d="forums",_("#dmd-forum-id",X.channelId),ae(),n("#dmd-stop").disabled=!0,n("#dmd-forum-stop").disabled=!1,await un(D,X.channelId),l("info","Select threads, then press Clean selected threads.");return}d=G[0].mode,ae();for(let[J,te]of G.entries()){if(F.stopped)break;let{channelId:ke,guildId:he,mode:me}=te,ct=await(me==="thread"?Mt:dt)({...O[J],allowAnyThread:me==="thread",token:D.token,authorId:D.user.id,guildId:he,channelId:ke,log:l,progress:A,stopCheck:()=>F.stopped});if(!ct?.done||ct.failed>0)break}}catch(D){l("error",D?.message||D)}finally{$(!1,"#dmd-stop"),n("#dmd-forum-stop").disabled=!0,F.stopped=!1}};let Te=()=>{let m=xe(n("#dmd-channel").value,"Channel(s)");return m.length===1?m[0]:""},Dt=m=>{if(m.length>1e4)throw new Error(`Use at most ${1e4} message IDs per batch. Split larger imports into smaller files.`);return m},Tt,Nt=()=>{clearTimeout(Tt);try{let m=Ae(n("#dmd-id-input").value,Te()||"000000000000000");n("#dmd-id-count").textContent=`${m.length.toLocaleString()} IDs`}catch(m){n("#dmd-id-count").textContent=m.message}};n("#dmd-id-input").addEventListener("input",()=>{clearTimeout(Tt),Tt=setTimeout(Nt,150)});let cn=async m=>{if(!x){n("#dmd-id-import").disabled=!0,n("#dmd-id-package").disabled=!0;try{if(!m?.length)return;let C=await hr(m,Te()),D=n("#dmd-id-input"),R=Ae(D.value,Te()||"000000000000000"),O=new Set(R.map(te=>`${te.channelId}/${te.messageId}`)),G=C.filter(te=>!O.has(`${te.channelId}/${te.messageId}`));Dt([...R,...G]);let X=G.map(te=>`https://discord.com/channels/@me/${te.channelId}/${te.messageId}`).join(` +`),J=D.value+(D.value&&X?` +`:"")+X;Ae(J,Te()||"000000000000000"),D.value=J,D.dispatchEvent(new Event("input",{bubbles:!0})),Nt(),l("success",`Loaded ${C.length} message IDs locally. No messages were changed.`)}catch(C){l("error",C?.message||C)}finally{n("#dmd-id-import").disabled=x,n("#dmd-id-package").disabled=x,n("#dmd-id-files").value="",n("#dmd-id-folder").value=""}}};n("#dmd-id-import").onclick=()=>n("#dmd-id-files").click(),n("#dmd-id-package").onclick=()=>n("#dmd-id-folder").click(),n("#dmd-id-files").onchange=m=>cn(m.target.files),n("#dmd-id-folder").onchange=m=>cn(m.target.files),n("#dmd-id-clear").onclick=()=>{n("#dmd-id-input").value="",Nt()},n("#dmd-id-stop").onclick=()=>{F.stopped=!0,l("warn","Stop requested...")},n("#dmd-id-start").onclick=async()=>{if(x)try{let m=Dt(Ae(n("#dmd-id-input").value,Te()));return await $e([],g(""),!1,m)}catch(m){return l("error",m.message)}$(!0,"#dmd-id-stop"),F.stopped=!1;try{let m=await pe(n("#dmd-token").value.trim())||await P();if(!m||F.stopped)return;let C=Dt(Ae(n("#dmd-id-input").value,Te())),D=g("");await dn({...D,token:m.token,authorId:m.user.id,targets:C,log:l,stopCheck:()=>F.stopped,progress:(R,O,G)=>{n("#dmd-id-progress").max=Math.max(1,O),n("#dmd-id-progress").value=R,E("#dmd-id-progress","#dmd-id-pct"),n("#dmd-id-status").textContent=`${G}: ${R}/${O}`}})}catch(m){l("error",m?.message||m)}finally{$(!1,"#dmd-id-stop"),F.stopped=!1}},n("#dmd-rx-start-kind").onchange=()=>{let m=n("#dmd-rx-start-kind").value==="date";n("#dmd-rx-start-id-field").hidden=m,n("#dmd-rx-start-date-field").hidden=!m},n("#dmd-rx-go").onclick=async()=>{if(x)return l("warn","Wait for the current cleanup to finish before removing reactions.");$(!0,"#dmd-rx-stop"),n("#dmd-rx-go").disabled=!0,F.reactionStopped=!1;try{let m=await pe(n("#dmd-token").value.trim())||await P();if(!m||F.reactionStopped)return;U();let C=xe(n("#dmd-rx-channel").value.trim()||n("#dmd-channel").value,"Channel(s)",{required:!0}),D=Math.max(1,Math.min(5e3,parseInt(n("#dmd-rx-limit").value,10)||1e3));for(let R of C){if(F.reactionStopped)break;await xr({token:m.token,channelId:R,startId:(n("#dmd-rx-start-kind").value==="date"?n("#dmd-rx-start-date"):n("#dmd-rx-start")).value.trim(),scanLimit:D,reactionAuthorId:n("#dmd-rx-author").value.trim(),reactionIds:n("#dmd-rx-filter").value,skipOwn:n("#dmd-rx-skipown").checked,authorId:m.user.id,log:l,progress:(O,G,X)=>{n("#dmd-rx-progress").max=Math.max(1,G),n("#dmd-rx-progress").value=O,E("#dmd-rx-progress","#dmd-rx-pct"),n("#dmd-rx-phase").textContent=`${X||""} ${O}/${G}`}})}}catch(m){l("error",m?.message||m)}finally{$(!1,"#dmd-rx-stop"),n("#dmd-rx-go").disabled=!1,F.reactionStopped=!1}};let mn=async m=>{try{if(!/^\d{15,22}$/.test(m||""))throw new Error("Enter a valid Server ID or open a server channel.");let C=await pe(n("#dmd-token").value.trim())||await P();if(!C)return;let D=await K.readGuild(m,C.token,l);n("#dmd-server-id").value=m,k.seedGuild(D),k.refresh("guild"),await $e([{kind:"server",guildId:m,channelId:`server:${m}`,label:D.name||`Server ${m}`}],g(""))}catch(C){l("error",C.message)}};n("#dmd-server-add").onclick=()=>mn(n("#dmd-server-id").value.trim()),n("#dmd-server-current").onclick=()=>mn(je()?.guildId),n("#dmd-multi-clear").onclick=async()=>{j.getItems().length&&!await se({title:"Clear queue?",message:`Remove all ${j.getItems().length} queued conversations?`,yesText:"Clear",noText:"Cancel",danger:!0})||(j.clear(),v(0,1,""))},n("#dmd-multi-stop").onclick=()=>{F.multiStopped=!0,Xe?.abort(),l("warn","Stopping queue...")},n("#dmd-multi-start").onclick=async()=>{if(x)return l("info","The queue will continue automatically as jobs are added.");if(!j.getItems().length)return l("error","The queue is empty.");$(!0,"#dmd-multi-stop"),F.multiStopped=!1;try{let m=await pe(n("#dmd-token").value.trim())||await P();if(!m||F.multiStopped)return;let C=g(""),D=0,R=null;for(q=()=>{let O=D+j.getItems().length;v(D,O,R?`Running ${D+1}/${O}: ${nt(R.label)||R.channelId}`:`Finished ${D}/${O}`)},q();(j.getItems().length||ye)&&!F.multiStopped;){if(!j.getItems().length){await De;continue}let O={...j.getItems()[0]};R=O,q();let G=O.options?we(O.options):C,X;if(O.detectConversation&&O.kind!=="ids"&&(X=await Ue({token:m.token,channelId:O.channelId,log:l,stopCheck:()=>F.multiStopped}),X.mode==="forums"))throw new Error("Open this forum in Messages and select its threads before queuing cleanup.");let J=await(O.kind==="ids"?dn:(X?X.mode==="thread":O.thread)?Mt:dt)({...G,freshHistory:!0,token:m.token,authorId:m.user.id,guildId:X?.guildId||O.guildId,channelId:["ids","server"].includes(O.kind)?void 0:O.channelId,targets:O.targets,allowAnyThread:X?X.mode==="thread":O.allowAnyThread,skipConfirm:!0,log:l,stopCheck:()=>F.multiStopped});if(J?.unauthorized){F.multiStopped=!0;break}if(J?.cancelled||J?.stopped||J?.stalled||J?.httpStatus){F.multiStopped=!0;break}if(!J?.done||J.failed>0){F.multiStopped=!0;break}D++,R=null,j.completeItem(O),q(),j.getItems().length&&!F.multiStopped&&await Q(ee(1400,2400))}l(F.multiStopped?"warn":"success",F.multiStopped?"Queue stopped.":`Finished all ${D} queued items.`)}catch(m){l("error",m?.message||m)}finally{q=null,$(!1,"#dmd-multi-stop"),F.multiStopped=!1}};let Lt=()=>{let m=n("#dmd-forum-id").value.trim();return m&&m!==N.get("#dmd-forum-id")?m:n("#dmd-channel").value.trim()||m},Ne=[],lt=new Map,Ze=()=>[...n("#dmd-forum-list").selectedOptions].map(m=>lt.get(m.value)).filter(Boolean),un=async(m,C)=>{Ne=[],lt.clear(),n("#dmd-forum-list").textContent="",Ne=await pr({token:m.token,forumId:C,includeArchived:n("#dmd-forum-archived").checked,log:l,stopCheck:()=>F.stopped}),lt=new Map(Ne.map(R=>[R.id,R]));let D=document.createDocumentFragment();for(let R of Ne){let O=document.createElement("option");O.value=R.id,O.textContent=`${R.name||R.id}${R.thread_metadata?.archived?" \xB7 archived":""}${R.thread_metadata?.locked?" \xB7 locked":""}`,D.appendChild(O)}n("#dmd-forum-list").appendChild(D),n("#dmd-forum-status").textContent=`${Ne.length} threads loaded`};n("#dmd-forum-stop").onclick=()=>{F.stopped=!0,Xe?.abort(),l("warn","Stop requested...")},n("#dmd-forum-load").onclick=async()=>{if(!x){$(!0,"#dmd-forum-stop"),F.stopped=!1,Ne=[],lt.clear(),n("#dmd-forum-list").textContent="";try{let m=await pe(n("#dmd-token").value.trim())||await P();if(!m||F.stopped)return;U();let C=Lt(),D=await Ue({token:m.token,channelId:C,log:l,stopCheck:()=>F.stopped});if(d=D.mode,ae(),D.mode!=="forums"){if(n("#dmd-channel").value.trim()!==D.channelId&&n("#dmd-channel").value.trim()&&n("#dmd-channel").value!==N.get("#dmd-channel")){l("warn","The forum field targets a different conversation. Update Channel(s) to use this target.");return}_("#dmd-channel",D.channelId),l("info","Conversation detected. Press Start to preview its matching messages.");return}await un(m,D.channelId)}catch(m){n("#dmd-forum-status").textContent="No thread list loaded",l("error",m?.message||m)}finally{$(!1,"#dmd-forum-stop"),F.stopped=!1}}},n("#dmd-forum-queue").onclick=async()=>{let m=Ze();if(!m.length)return l("warn","Select at least one forum thread.");try{await $e(m.map(C=>({guildId:C.guild_id,channelId:C.id,label:`Thread: ${C.name||C.id}`,thread:!0})),g(""),!0)}catch(C){l("error",C.message)}},n("#dmd-forum-run").onclick=async()=>{if(x&&!Ze().length)try{return U(),await n("#dmd-start").onclick(null,xe(Lt(),"Conversation IDs",{required:!0}))}catch(C){return l("error",C.message)}if(x)try{return await $e(Ze().map(C=>({guildId:C.guild_id,channelId:C.id,label:`Thread: ${C.name||C.id}`,thread:!0})),g(""))}catch(C){return l("error",C.message)}let m=Ze();if(!m.length)try{U();let C=Lt();return await n("#dmd-start").onclick(null,C?xe(C,"Conversation IDs",{required:!0}):null)}catch(C){return l("error",C.message)}$(!0,"#dmd-forum-stop"),F.stopped=!1;try{let C=await pe(n("#dmd-token").value.trim())||await P();if(!C||F.stopped)return;let D=g("");n("#dmd-progress").max=m.length,n("#dmd-progress").value=0,E("#dmd-progress","#dmd-pct");for(let[R,O]of m.entries()){if(F.stopped)break;n("#dmd-forum-status").textContent=`${R+1}/${m.length}: ${O.name||O.id}`;let G=await Mt({...D,guildId:O.guild_id,channelId:O.id,token:C.token,authorId:C.user.id,log:l,stopCheck:()=>F.stopped});if(G.unauthorized||G.cancelled||G.stopped)break;n("#dmd-progress").value=R+1,E("#dmd-progress","#dmd-pct")}}catch(C){l("error",C?.message||C)}finally{$(!1,"#dmd-forum-stop"),F.stopped=!1}};let pn=location.href,_t=!1,fn=()=>e.isConnected&&e.parentElement?.isConnected&&e.offsetParent!==null,$r=()=>{_t||(_t=!0,requestAnimationFrame(()=>{_t=!1,fn()||ie()}))},Rt=()=>{location.href!==pn&&(pn=location.href,U(),z())};er(()=>{Rt(),fn()||$r()}),window.addEventListener("popstate",Rt,{passive:!0}),window.addEventListener("hashchange",Rt,{passive:!0}),ie(),U()}function Ir(){kr()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",Ir,{once:!0}):Ir();})(); diff --git a/dist/Del-Discord.greasyfork.user.js b/dist/Del-Discord.greasyfork.user.js index aad0b55..0b99a79 100644 --- a/dist/Del-Discord.greasyfork.user.js +++ b/dist/Del-Discord.greasyfork.user.js @@ -1,11 +1,11 @@ // ==UserScript== // @name Del-Discord // @namespace local.del-discord -// @version 2.1.1 +// @version 2.5.0 // @author Del-Discord contributors // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjOTY5NjkwIiBkPSJNMTUgNFYySDl2MkgzdjJoMThWNGgtNlpNNSA3djEyYTIgMiAwIDAgMCAyIDJoMTBhMiAyIDAgMCAwIDItMlY3SDVabTYgMTBIOXYtNmgydjZabTQgMGgtMnYtNmgydjZaIi8+PC9zdmc+ -// @homepageURL https://github.com/zhomander/del-discord -// @supportURL https://github.com/zhomander/del-discord/issues +// @homepageURL https://github.com/azhomy/del-discord +// @supportURL https://github.com/azhomy/del-discord/issues // @license MIT // @description Personal message deletion, reaction removal, queues, exports, and DM history. // @match https://discord.com/* @@ -203,7 +203,10 @@ SOFTWARE. var AUTH_CACHE_TTL = 6e4; // src/utils/timing.js - var sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + var sleep = (ms) => { + const chunk = Math.min(ms, 2147483647); + return new Promise((resolve) => setTimeout(resolve, chunk)).then(() => ms > chunk ? sleep(ms - chunk) : void 0); + }; var rand = (a, b) => { const range = b - a + 1; if (globalThis.crypto?.getRandomValues && Number.isSafeInteger(range) && range > 0 && range <= 4294967296) { @@ -221,6 +224,27 @@ SOFTWARE. return value !== null && value !== "" && Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : 1500; }; + // src/utils/abort.js + function waitWithSignal(promise, signal) { + if (!signal) return promise; + return new Promise((resolve, reject) => { + const abort = () => { + cleanup(); + reject(signal.reason || new DOMException("Stopped.", "AbortError")); + }; + const cleanup = () => signal.removeEventListener("abort", abort); + promise.then((value) => { + cleanup(); + resolve(value); + }, (error) => { + cleanup(); + reject(error); + }); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); + } + // src/discord/api.js var RunStoppedError = class extends Error { constructor() { @@ -228,6 +252,34 @@ SOFTWARE. this.name = "RunStoppedError"; } }; + var RateLimitError = class extends Error { + constructor() { + super("Repeated rate limits. Requests stopped; wait before trying again."); + this.name = "RateLimitError"; + } + }; + var paceTail = Promise.resolve(); + var lastPacedStart = null; + async function paceRequest(interval, stopCheck, signal) { + const previous = paceTail; + let release; + const turn = new Promise((resolve) => { + release = resolve; + }); + paceTail = previous.then(() => turn); + try { + await waitWithSignal(previous, signal); + if (stopCheck()) throw new RunStoppedError(); + if (lastPacedStart !== null) { + const wait = interval - (performance.now() - lastPacedStart); + if (wait > 0) await waitWithSignal(sleep(wait), signal); + } + if (stopCheck()) throw new RunStoppedError(); + lastPacedStart = performance.now(); + } finally { + release(); + } + } var cooldowns = /* @__PURE__ */ new Map(); function pauseAccount(key, milliseconds) { const previous = cooldowns.get(key); @@ -238,17 +290,23 @@ SOFTWARE. return pause; } async function apiFetch(url, options = {}, log = () => { - }, stopCheck = () => false) { - const account = new Headers(options.headers).get("Authorization") || ""; + }, stopCheck = () => false, { minIntervalMs = 0, maxRateLimitRetries = 2 } = {}) { + let retries = 0; + const account = new Headers(options.headers).get("Authorization") || new URL(url).origin; while (true) { if (stopCheck()) throw new RunStoppedError(); let cooldown; while (cooldown = cooldowns.get(account)) { - await cooldown; + await waitWithSignal(cooldown, options.signal); if (stopCheck()) throw new RunStoppedError(); } + if (minIntervalMs > 0) { + await paceRequest(minIntervalMs, stopCheck, options.signal); + if (cooldowns.has(account)) continue; + } if (stopCheck()) throw new RunStoppedError(); - const response = await fetch(url, options); + options.signal?.throwIfAborted(); + const response = await waitWithSignal(fetch(url, options), options.signal); if (response.status !== 429) { const reset = response.headers.get("X-RateLimit-Reset-After"); if (response.headers.get("X-RateLimit-Remaining") === "0" && reset !== null && Number.isFinite(Number(reset)) && Number(reset) >= 0) { @@ -258,12 +316,23 @@ SOFTWARE. } let body = {}; try { - body = await response.json(); + body = await waitWithSignal(response.json(), options.signal) || {}; } catch { } - const wait = Math.max(retryMs(body.retry_after ?? response.headers.get("Retry-After")), response.headers.has("Retry-After") ? retryMs(response.headers.get("Retry-After")) : 0); - log("warn", `Rate limited. Waiting ${(wait / 1e3).toFixed(1)}s...`); - await pauseAccount(account, wait); + const seconds = (value) => value !== null && value !== "" && ["number", "string"].includes(typeof value) && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) * 1e3 : null; + const header = response.headers.get("Retry-After"); + let headerDelay = seconds(header); + if (headerDelay === null && header && /GMT$/i.test(header)) { + const date = Date.parse(header); + if (Number.isFinite(date)) headerDelay = Math.max(0, date - Date.now()); + } + const hints = [seconds(body.retry_after), headerDelay, seconds(response.headers.get("X-RateLimit-Reset-After"))].filter((value) => value !== null); + const delay = hints.length ? Math.max(...hints) : Math.min(3e4, 1500 * 2 ** retries); + const pause = pauseAccount(account, delay); + options.signal?.throwIfAborted(); + if (retries++ >= maxRateLimitRetries) throw new RateLimitError(); + log("warn", `Rate limited. Waiting at least ${(delay / 1e3).toFixed(1)}s before retry ${retries}/${maxRateLimitRetries}...`); + await waitWithSignal(pause, options.signal); } } @@ -1193,11 +1262,6 @@ SOFTWARE. const key = incoming.userId ? `u:${incoming.userId}` : incoming.channelId ? `c:${incoming.channelId}` : ""; if (!key) return; const previous = oldKey ? map[oldKey] || {} : map[key] || {}; - if (oldKey && oldKey !== key) { - delete map[oldKey]; - if (previous.userId) indexes.byUser.delete(previous.userId); - if (previous.channelId) indexes.byChannel.delete(previous.channelId); - } const merged = { userId: incoming.userId || previous.userId || "", channelId: incoming.channelId || previous.channelId || "", @@ -1214,6 +1278,9 @@ SOFTWARE. sentCountExact: incoming.sentCountExact === true || previous.sentCountExact === true, verifiedSent: incoming.verifiedSent === true || previous.verifiedSent === true }; + if (oldKey && oldKey !== key) delete map[oldKey]; + if (previous.userId && previous.userId !== merged.userId && indexes.byUser.get(previous.userId) === oldKey) indexes.byUser.delete(previous.userId); + if (previous.channelId && previous.channelId !== merged.channelId && indexes.byChannel.get(previous.channelId) === oldKey) indexes.byChannel.delete(previous.channelId); map[key] = merged; if (merged.userId) indexes.byUser.set(merged.userId, key); if (merged.channelId) indexes.byChannel.set(merged.channelId, key); @@ -1306,6 +1373,14 @@ SOFTWARE. return Number(data.total_results || 0); } } + async function closeTemporaryDm(token, channelId) { + try { + const response = await apiFetch(`${API}/channels/${channelId}`, { method: "DELETE", headers: { Authorization: token } }); + if (!response.ok && response.status !== 404) throw new Error(`HTTP ${response.status}`); + } catch (error) { + throw new Error(`Could not close the temporarily opened DM. It may remain open in your DM list. ${error.message || error}`, { cause: error }); + } + } async function verifyPerson(token, selfId, userId, knownChannelId, wasOpen) { let channelId = knownChannelId || ""; let temporarilyOpened = false; @@ -1325,12 +1400,7 @@ SOFTWARE. const count = await sentCountForChannel(token, selfId, channelId); return { count, channelId }; } finally { - if (temporarilyOpened && channelId) { - try { - await apiFetch(`${API}/channels/${channelId}`, { method: "DELETE", headers: { Authorization: token } }); - } catch { - } - } + if (temporarilyOpened && channelId) await closeTemporaryDm(token, channelId); } } @@ -1580,12 +1650,14 @@ SOFTWARE. // src/features/history/import-package.js async function importPackage(fileList, { $, getHistory, getIdentity, setPackageInfo, persist, render }) { const history = getHistory(); + const isCurrent = () => getHistory() === history; const files = [...fileList || []]; if (!files.length) return; $("#dmh-import").disabled = true; $("#dmh-summary").textContent = `Indexing ${files.length} package files locally\u2026`; try { const me = await getIdentity(); + if (!isCurrent()) return; const selfId = me?.user?.id || ""; const pathOf = (file) => String(file.webkitRelativePath || file.name).replace(/\\/g, "/"); const folderOf = (path) => path.slice(0, path.lastIndexOf("/")).toLowerCase(); @@ -1593,11 +1665,13 @@ SOFTWARE. const channelFiles = []; const messageFilesByFolder = /* @__PURE__ */ new Map(); for (const file of files) { + if (!isCurrent()) return; const path = pathOf(file); const lower = path.toLowerCase(); if (`/${lower}`.endsWith("/messages/index.json")) { try { const obj = JSON.parse(await file.text()); + if (!isCurrent()) return; for (const [id, label] of Object.entries(obj || {})) { if (typeof label === "string") index.set(String(id), label); } @@ -1639,10 +1713,12 @@ SOFTWARE. let processed = 0; let lastSavedAt = Date.now(); for (const { file, path } of channelFiles) { + if (!isCurrent()) return; processed++; if (processed % 25 === 0) { $("#dmh-summary").textContent = `Processing package conversations ${processed}/${channelFiles.length}\u2026`; await new Promise(requestAnimationFrame); + if (!isCurrent()) return; } let ch; try { @@ -1650,6 +1726,7 @@ SOFTWARE. } catch { continue; } + if (!isCurrent()) return; const folder = path.slice(0, path.lastIndexOf("/")); const channelId = String(ch?.id || folder.match(/(?:^|\/)c?(\d{15,22})$/)?.[1] || ""); const label = index.get(channelId) || ""; @@ -1658,6 +1735,7 @@ SOFTWARE. const group = type === "GROUP_DM" || type === "3"; if (!direct || group) continue; const count = await countSentMessages(folder); + if (!isCurrent()) return; if (count === null) continue; if (count <= 0) { removeHistoryMatch(history, "", channelId); @@ -1680,6 +1758,7 @@ SOFTWARE. lastSavedAt = Date.now(); } } + if (!isCurrent()) return; saveHistory(history); setPackageInfo({ importedAt: (/* @__PURE__ */ new Date()).toISOString(), @@ -1696,7 +1775,8 @@ SOFTWARE. } // src/ui/confirm.js - function askPopup({ title = "Confirm", message = "", details = "", yesText = "Yes", noText = "No", danger = false, render, getResult, onNo } = {}) { + function askPopup({ title = "Confirm", message = "", details = "", yesText = "Yes", noText = "No", danger = false, render, getResult, onNo, signal } = {}) { + if (signal?.aborted) return Promise.resolve(false); return new Promise((resolve) => { const overlay = document.createElement("div"); overlay.className = "dmd-confirm-overlay"; @@ -1739,25 +1819,34 @@ SOFTWARE. if (done) return; done = true; document.removeEventListener("keydown", onKey, true); + signal?.removeEventListener("abort", onAbort); overlay.remove(); if (previousFocus?.isConnected) previousFocus.focus(); resolve(value && getResult ? getResult() : value); }; const onKey = (e) => { + if (e.key !== "Tab" && e.key !== "Escape" && !(e.key === "Enter" && (e.metaKey || e.ctrlKey))) return; + const dialogs = document.querySelectorAll(".dmd-confirm-overlay"); + if (dialogs[dialogs.length - 1] !== overlay) return; + e.preventDefault(); + e.stopImmediatePropagation(); if (e.key === "Tab") { const controls = [...box.querySelectorAll('button,input,select,[tabindex="0"]')].filter((node) => !node.disabled); const index = controls.indexOf(document.activeElement); - e.preventDefault(); controls[(index + (e.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus(); } if (e.key === "Escape") finish(false); if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !yes.disabled) finish(true); }; + const onAbort = () => finish(false); + signal?.addEventListener("abort", onAbort, { once: true }); no.onclick = () => { onNo?.(); finish(false); }; - yes.onclick = () => finish(true); + yes.onclick = () => { + if (!yes.disabled) finish(true); + }; overlay.onclick = (e) => { if (e.target === overlay) finish(false); }; @@ -1916,14 +2005,23 @@ SOFTWARE. open.className = "dmh-button green"; open.textContent = "Open DM"; open.onclick = async () => { - if (!me) me = await identity(); - if (!me) return alert("Could not get a valid Discord authorization token."); - const r = await apiFetch(`${API}/users/@me/channels`, { method: "POST", headers: { Authorization: me.token, "Content-Type": "application/json" }, body: JSON.stringify({ recipient_id: entry.userId }) }); - if (!r.ok) return alert(`Discord could not reopen this DM (HTTP ${r.status}).`); - const ch = await r.json(); - merge(history, { userId: entry.userId, channelId: ch.id, sources: ["open"], sentCount: entry.sentCount, sentCountExact: entry.sentCountExact, verifiedSent: true, seenAt: (/* @__PURE__ */ new Date()).toISOString() }); - saveHistory(history); - location.href = `/channels/@me/${ch.id}`; + const openingHistory = history; + try { + const currentIdentity = me || await identity(); + if (history !== openingHistory) return; + me = currentIdentity; + if (!me) return alert("Could not get a valid Discord authorization token."); + const r = await apiFetch(`${API}/users/@me/channels`, { method: "POST", headers: { Authorization: me.token, "Content-Type": "application/json" }, body: JSON.stringify({ recipient_id: entry.userId }) }); + if (history !== openingHistory) return; + if (!r.ok) return alert(`Discord could not reopen this DM (HTTP ${r.status}).`); + const ch = await r.json(); + if (history !== openingHistory) return; + merge(history, { userId: entry.userId, channelId: ch.id, sources: ["open"], sentCount: entry.sentCount, sentCountExact: entry.sentCountExact, verifiedSent: true, seenAt: (/* @__PURE__ */ new Date()).toISOString() }); + saveHistory(history); + location.href = `/channels/@me/${ch.id}`; + } catch (error) { + if (history === openingHistory) alert(`Discord could not reopen this DM: ${error?.message || error}`); + } }; actions.appendChild(open); } @@ -1956,24 +2054,26 @@ SOFTWARE. refreshing = true; $("#dmh-refresh").disabled = true; $("#dmh-summary").textContent = "Loading Discord DM data\u2026"; + const refreshingHistory = history; + let dirty = 0; try { - me = await identity(); + const refreshedIdentity = await identity(); + if (history !== refreshingHistory) return; + me = refreshedIdentity; if (!me) { $("#dmh-summary").textContent = "Could not get a valid Discord authorization token."; return; } const openByUser = /* @__PURE__ */ new Map(); - try { - const r = await apiFetch(`${API}/users/@me/channels`, { headers: { Authorization: me.token } }); - if (r.ok) { - const channels = await r.json(); - for (const ch of Array.isArray(channels) ? channels : []) { - if (ch?.type !== 1) continue; - const u = ch.recipients?.find?.((v) => v?.id && v.id !== me.user.id); - if (u) openByUser.set(u.id, { channelId: ch.id, user: u }); - } - } - } catch { + const r = await apiFetch(`${API}/users/@me/channels`, { headers: { Authorization: me.token } }); + if (history !== refreshingHistory) return; + if (!r.ok) throw new Error(`Could not load open DMs (HTTP ${r.status}).`); + const channels = await r.json(); + if (history !== refreshingHistory) return; + for (const ch of Array.isArray(channels) ? channels : []) { + if (ch?.type !== 1) continue; + const u = ch.recipients?.find?.((v) => v?.id && v.id !== me.user.id); + if (u) openByUser.set(u.id, { channelId: ch.id, user: u }); } const candidates = /* @__PURE__ */ new Map(); for (const [userId, data] of openByUser) candidates.set(userId, { userId, channelId: data.channelId, user: data.user, wasOpen: true, dmRank: null, source: "open" }); @@ -1984,11 +2084,12 @@ SOFTWARE. } const all = [...candidates.values()]; let done = 0; - let dirty = 0; for (const c of all) { + if (history !== refreshingHistory) return; done++; $("#dmh-summary").textContent = `Verifying sent-message history ${done}/${all.length}\u2026`; const result = await verifyPerson(me.token, me.user.id, c.userId, c.channelId, c.wasOpen); + if (history !== refreshingHistory) return; if (!result || result.count === null) { await sleep(200); continue; @@ -2026,8 +2127,14 @@ SOFTWARE. } await sleep(250); } + if (history !== refreshingHistory) return; if (dirty) saveHistory(history); render(); + } catch (error) { + if (history !== refreshingHistory) return; + if (dirty) saveHistory(history); + render(false); + $("#dmh-summary").textContent = `Refresh stopped: ${error?.message || error}`; } finally { $("#dmh-refresh").disabled = false; refreshing = false; @@ -2166,20 +2273,28 @@ SOFTWARE. // src/discord/deleter-auth.js var authCache2 = null; - async function validateToken(token) { + async function validateToken(token, { log = () => { + }, stopCheck = () => false, strict = false, signal } = {}) { if (!token) return null; try { - const r = await fetch(`${API}/users/@me`, { headers: { Authorization: token } }); - if (!r.ok) return null; - return await r.json(); - } catch { + const r = await apiFetch(`${API}/users/@me`, { headers: { Authorization: token }, signal }, log, stopCheck, { minIntervalMs: 1e3 }); + if (stopCheck()) throw new RunStoppedError(); + if (!r.ok) { + if (strict && r.status !== 401) throw new Error(`Authorization check failed with HTTP ${r.status}. Export stopped.`); + return null; + } + return await waitWithSignal(r.json(), signal); + } catch (error) { + if (strict || error instanceof RunStoppedError) throw error; return null; } } function invalidateAuth2() { authCache2 = null; } - async function resolveToken(preferred = "") { + async function resolveToken(preferred = "", requestOptions = {}) { + requestOptions.signal?.throwIfAborted(); + if (requestOptions.stopCheck?.()) throw new RunStoppedError(); const now = Date.now(); if (authCache2 && now - authCache2.at < AUTH_CACHE_TTL && (!preferred || preferred === authCache2.token)) { return authCache2.identity; @@ -2195,12 +2310,13 @@ SOFTWARE. add(preferred); for (const token of tokenCandidates()) add(token); for (const token of candidates) { - const user = await validateToken(token); + const user = await validateToken(token, requestOptions); if (user?.id) { const identity2 = { token, user }; authCache2 = { at: now, token, identity: identity2 }; return identity2; } + if (requestOptions.strict) break; } invalidateAuth2(); return null; @@ -2650,74 +2766,6 @@ SOFTWARE. setTimeout(() => URL.revokeObjectURL(url), 1500); } - // src/ui/export-selection.js - function askExportSelection(conversations) { - const selected = /* @__PURE__ */ new Set(); - let format = "json"; - return askPopup({ - title: "Export Conversations", - message: "Choose conversations and a file format. The selected Before/After range applies.", - yesText: "Export", - noText: "Cancel", - getResult: () => ({ conversations: conversations.filter((_, index) => selected.has(index)), format }), - render: ({ box, yes }) => { - yes.disabled = true; - const all = document.createElement("button"); - all.type = "button"; - all.className = "dmd-btn"; - all.textContent = "Select all"; - all.disabled = !conversations.length; - const list = document.createElement("div"); - list.className = "dmd-export-list"; - list.onchange = (event) => { - const input = event.target, index = Number(input.value); - if (input.checked) selected.add(index); - else selected.delete(index); - yes.disabled = !selected.size; - }; - conversations.forEach((item, index) => { - const label = document.createElement("label"); - label.className = "dmd-check"; - const input = document.createElement("input"); - input.type = "checkbox"; - input.value = String(index); - label.append(input, document.createTextNode(item.label || item.channelId)); - list.append(label); - }); - all.onclick = () => { - list.querySelectorAll("input").forEach((input) => { - input.checked = true; - selected.add(Number(input.value)); - }); - yes.disabled = !selected.size; - }; - const formats = document.createElement("fieldset"); - formats.className = "dmd-export-formats"; - const legend = document.createElement("legend"); - legend.textContent = "File format"; - formats.append(legend); - for (const value of ["json", "csv"]) { - const label = document.createElement("label"); - label.className = "dmd-check"; - const input = document.createElement("input"); - input.type = "radio"; - input.name = "dmd-export-format"; - input.value = value; - input.checked = value === format; - input.onchange = () => { - if (input.checked) format = value; - }; - label.append(input, document.createTextNode(value.toUpperCase())); - formats.append(label); - } - box.append(all, list, formats); - } - }); - } - - // src/utils/run-state.js - var runState = { stopped: false, reactionStopped: false, multiStopped: false }; - // src/features/asset-filters.js function compilePattern(source, flags = "i", label = "Regular expression") { source = String(source || "").trim(); @@ -2835,8 +2883,8 @@ ${asset.url}`)) return false; if (options.channelId && message.channel_id !== options.channelId) return false; if (![0, 6, 19].includes(message.type)) return false; if (!/^\d+$/.test(String(message.id || ""))) return false; - if (options.minId && BigInt(message.id) <= BigInt(options.minId)) return false; - if (options.maxId && BigInt(message.id) >= BigInt(options.maxId)) return false; + if (options.minId && compareMessageIds(message.id, options.minId) <= 0) return false; + if (options.maxId && compareMessageIds(message.id, options.maxId) >= 0) return false; const matchMode = (mode, value) => mode === "any" || (mode === "with" ? value : !value); if (options.linkMode !== "any") { const hasLink = /https?:\/\/\S+/i.test(message.content || "") || (message.embeds || []).some((embed) => !!embed.url); @@ -2886,6 +2934,8 @@ ${asset.url}`)) return false; } function compactExportMessage(message) { return { + // Keep the original response, including fields added by Discord in future. + raw: message, id: message.id, timestamp: message.timestamp, editedTimestamp: message.edited_timestamp || null, @@ -2924,7 +2974,20 @@ ${asset.url}`)) return false; referencedMessage: compactReferencedMessage(message.referenced_message) }; } - async function exportConversationData({ token, guildId, channelId, minId, maxId, label, log, stopCheck = () => false }) { + function validateExportRange({ minId = "", maxId = "" } = {}) { + minId = String(minId).trim(); + maxId = String(maxId).trim(); + const boundary = (value) => { + const id = toSnowflake(value); + if (value && (!id || !/^\d+$/.test(id))) throw new Error("Enter a valid date/time or message ID."); + return id; + }; + const after = boundary(minId), before = boundary(maxId); + if (after && before && BigInt(after) >= BigInt(before)) throw new Error("After must be earlier than Before."); + return { minId, maxId }; + } + async function exportConversationData({ token, guildId, channelId, minId, maxId, label, log, stopCheck = () => false, signal }) { + ({ minId, maxId } = validateExportRange({ minId, maxId })); const afterSnowflake = minId ? toSnowflake(minId) : ""; let beforeSnowflake = maxId ? toSnowflake(maxId) : ""; let afterBig = null; @@ -2939,7 +3002,7 @@ ${asset.url}`)) return false; const url = `${API}/channels/${channelId}/messages?limit=100${beforeSnowflake ? `&before=${beforeSnowflake}` : ""}`; let response; try { - response = await apiFetch(url, { headers: { Authorization: token } }, log, stopCheck); + response = await apiFetch(url, { headers: { Authorization: token }, signal }, log, stopCheck, { minIntervalMs: 1e3 }); } catch (error) { if (error instanceof RunStoppedError) break; throw error; @@ -2951,15 +3014,17 @@ ${asset.url}`)) return false; } if (response.status === 403) throw new Error("Discord denied access to this conversation."); if (!response.ok) throw new Error(`Conversation export failed with HTTP ${response.status}.`); - const batch = await response.json(); - if (!Array.isArray(batch) || !batch.length) break; + const batch = await waitWithSignal(response.json(), signal); + if (stopCheck()) break; + if (!Array.isArray(batch)) throw new Error("Discord returned an invalid message page. Export stopped."); + if (!batch.length) break; page++; const beforeBig = beforeSnowflake ? BigInt(beforeSnowflake) : null; const seen = /* @__PURE__ */ new Set(); let oldest = null; let reachedAfter = false; for (const message of batch) { - if (!/^\d+$/.test(String(message?.id || ""))) continue; + if (typeof message?.id !== "string" || !/^\d+$/.test(message.id)) throw new Error("Discord returned a malformed message. Export stopped."); let idBig = null; try { idBig = BigInt(message.id); @@ -2978,9 +3043,9 @@ ${asset.url}`)) return false; if (oldest === null || idBig < oldest) oldest = idBig; } log("verb", `Export page ${page}: ${messages.length} messages collected.`); - if (reachedAfter || oldest === null) break; + if (reachedAfter) break; + if (oldest === null) throw new Error("Message pagination did not advance. Export stopped to avoid saving incomplete history."); beforeSnowflake = oldest.toString(); - await sleep(rand(550, 900)); } messages.sort((a, b) => compareMessageIds(a.id, b.id)); return { @@ -2996,7 +3061,7 @@ ${asset.url}`)) return false; messages }; } - var csvColumns = ["guildId", "channelId", "conversation", "id", "timestamp", "editedTimestamp", "authorId", "username", "content", "type", "pinned", "attachments", "embeds", "reactions", "mentions", "referencedMessage"]; + var csvColumns = ["guildId", "channelId", "conversation", "id", "timestamp", "editedTimestamp", "authorId", "username", "content", "type", "pinned", "attachments", "embeds", "reactions", "mentions", "referencedMessage", "messageData"]; var nestedColumns = ["attachments", "embeds", "reactions", "mentions", "referencedMessage"]; var csvCell = (value) => '"' + String(value ?? "").replace(/"/g, '""') + '"'; function* conversationCsvRows(conversations, includeHeader = true) { @@ -3015,7 +3080,8 @@ ${asset.url}`)) return false; message.content, message.type, message.pinned, - ...nestedColumns.map((key) => JSON.stringify(message[key] ?? null)) + ...nestedColumns.map((key) => JSON.stringify(message[key] ?? null)), + JSON.stringify(message.raw ?? message) ].map(csvCell).join(","); } } @@ -3066,6 +3132,385 @@ ${asset.url}`)) return false; return new Blob(blobs, { type: format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8" }); } + // src/ui/export-selection.js + function askExportSelection(conversations, { range = {}, select = true, signal } = {}) { + const selected = new Set(select ? [] : conversations.map((_, index) => index)); + let exportRange = { minId: range.minId || "", maxId: range.maxId || "" }; + let format = "json", downloadAttachments = false; + return askPopup({ + signal, + title: "Export Conversations", + message: "Choose conversations, a date or message range, and a file format. Blank boundaries export all readable history. All message data returned by Discord is included; enable attachment downloads to include the actual files in a ZIP (up to 4 GB).", + yesText: "Export", + noText: "Cancel", + getResult: () => ({ conversations: conversations.filter((_, index) => selected.has(index)), format, range: exportRange, downloadAttachments }), + render: ({ box, yes }) => { + const boundaries = []; + const error = document.createElement("div"); + error.setAttribute("role", "alert"); + const validate = () => { + try { + for (const { input, kind } of boundaries) { + if (input.validity.badInput) throw new Error("Complete the date and time or clear the boundary."); + if (kind.value === "id" && input.value && !/^\d+$/.test(input.value.trim())) throw new Error("Enter a numeric message ID."); + } + exportRange = validateExportRange(exportRange); + error.textContent = ""; + } catch (issue) { + error.textContent = issue.message; + } + yes.disabled = !selected.size || !!error.textContent; + }; + validate(); + const all = document.createElement("button"); + all.type = "button"; + all.className = "dmd-btn"; + all.textContent = "Select all"; + all.disabled = !conversations.length; + const list = document.createElement("div"); + list.className = "dmd-export-list"; + list.onchange = (event) => { + const input = event.target, index = Number(input.value); + if (input.checked) selected.add(index); + else selected.delete(index); + validate(); + }; + conversations.forEach((item, index) => { + const label = document.createElement("label"); + label.className = "dmd-check"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = String(index); + input.checked = selected.has(index); + label.append(input, document.createTextNode(item.label || item.channelId)); + list.append(label); + }); + all.onclick = () => { + list.querySelectorAll("input").forEach((input) => { + input.checked = true; + selected.add(Number(input.value)); + }); + validate(); + }; + const formats = document.createElement("fieldset"); + formats.className = "dmd-export-formats"; + const legend = document.createElement("legend"); + legend.textContent = "File format"; + formats.append(legend); + for (const value of ["json", "csv"]) { + const label = document.createElement("label"); + label.className = "dmd-check"; + const input = document.createElement("input"); + input.type = "radio"; + input.name = "dmd-export-format"; + input.value = value; + input.checked = value === format; + input.onchange = () => { + if (input.checked) format = value; + }; + label.append(input, document.createTextNode(value.toUpperCase())); + formats.append(label); + } + const ranges = document.createElement("fieldset"); + ranges.className = "dmd-export-range"; + const rangeLegend = document.createElement("legend"); + rangeLegend.textContent = "Date & message range (local time)"; + ranges.append(rangeLegend); + for (const [key, name] of [["minId", "After"], ["maxId", "Before"]]) { + const field = document.createElement("div"); + field.className = "dmd-field"; + const label = document.createElement("label"); + label.htmlFor = `dmd-export-${key}`; + label.textContent = name; + const kind = document.createElement("select"); + kind.setAttribute("aria-label", `${name} boundary type`); + for (const [value, text] of [["date", "Date & time"], ["id", "Message ID"]]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = text; + kind.append(option); + } + kind.value = /^\d+$/.test(exportRange[key]) ? "id" : "date"; + const input = document.createElement("input"); + input.id = label.htmlFor; + input.type = kind.value === "date" ? "datetime-local" : "text"; + input.step = "any"; + input.value = exportRange[key]; + boundaries.push({ input, kind }); + input.oninput = input.onchange = () => { + exportRange[key] = input.value; + validate(); + }; + kind.onchange = () => { + input.type = kind.value === "date" ? "datetime-local" : "text"; + input.value = ""; + exportRange[key] = ""; + validate(); + }; + field.append(label, kind, input); + ranges.append(field); + } + const clear = document.createElement("button"); + clear.type = "button"; + clear.className = "dmd-btn"; + clear.textContent = "All dates"; + clear.onclick = () => { + ranges.querySelectorAll("input").forEach((input) => { + input.value = ""; + }); + exportRange = { minId: "", maxId: "" }; + validate(); + }; + ranges.append(clear, error); + const attachmentLabel = document.createElement("label"); + attachmentLabel.className = "dmd-check"; + const attachmentToggle = document.createElement("input"); + attachmentToggle.type = "checkbox"; + attachmentToggle.id = "dmd-export-attachments"; + attachmentToggle.onchange = () => { + downloadAttachments = attachmentToggle.checked; + }; + attachmentLabel.append(attachmentToggle, document.createTextNode("Download actual attachments (ZIP)")); + box.append(all, list, ranges, formats, attachmentLabel); + } + }); + } + + // src/utils/run-state.js + var runState = { stopped: false, reactionStopped: false, multiStopped: false }; + + // src/utils/zip.js + var crcTable = Uint32Array.from({ length: 256 }, (_, index) => { + for (let bit = 0; bit < 8; bit++) index = index >>> 1 ^ (index & 1 ? 3988292384 : 0); + return index; + }); + var ZipCapacityError = class extends Error { + constructor(message) { + super(message); + this.name = "ZipCapacityError"; + } + }; + var checkCapacity = (count, size) => { + if (count >= 65535) throw new ZipCapacityError("Too many files for one ZIP. Export fewer conversations or a smaller date range."); + if (size >= 4294967295) throw new ZipCapacityError("ZIP exceeds 4 GB. Export fewer conversations or a smaller date range."); + }; + var zipPath = (name, size) => { + if (!name || name.includes("\\") || name.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("Invalid ZIP file path."); + if (!Number.isSafeInteger(size) || size < 0) throw new Error("Invalid ZIP file size."); + const path = new TextEncoder().encode(name); + if (path.length > 65535 || size >= 4294967295) throw new ZipCapacityError("ZIP exceeds 4 GB. Export fewer conversations or a smaller date range."); + return path; + }; + function createZipBudget() { + const entries = /* @__PURE__ */ new Map(); + let size = 22; + const checkFile = (name, bytes) => { + const previous = entries.get(name); + const entrySize = 76 + 2 * zipPath(name, bytes).length + bytes; + const nextSize = size - (previous ?? 0) + entrySize; + checkCapacity(entries.size + (previous === void 0 ? 1 : 0), nextSize); + return { entrySize, nextSize }; + }; + return { + checkFile(name, bytes) { + checkFile(name, bytes); + }, + setFile(name, bytes) { + const { entrySize, nextSize } = checkFile(name, bytes); + entries.set(name, entrySize); + size = nextSize; + } + }; + } + async function filesToZip(files, stopCheck = () => false) { + const check = () => { + if (stopCheck()) throw new RunStoppedError(); + }; + check(); + checkCapacity(files.length, 22); + const paths = []; + let archiveSize = 22, yieldedAt = performance.now(); + for (const { name, blob } of files) { + check(); + const path = zipPath(name, blob.size); + paths.push(path); + archiveSize += 76 + 2 * path.length + blob.size; + checkCapacity(files.length, archiveSize); + if (performance.now() - yieldedAt >= 8) { + await sleep(0); + check(); + yieldedAt = performance.now(); + } + } + const parts = [], central = []; + let offset = 0, centralSize = 0; + for (const [index, { blob }] of files.entries()) { + check(); + const path = paths[index]; + let crc = 4294967295; + for (let start = 0; start < blob.size; start += 65536) { + check(); + const bytes = new Uint8Array(await blob.slice(start, start + 65536).arrayBuffer()); + for (let index2 = 0; index2 < bytes.length; index2++) crc = crc >>> 8 ^ crcTable[(crc ^ bytes[index2]) & 255]; + if (performance.now() - yieldedAt >= 8) { + await sleep(0); + check(); + yieldedAt = performance.now(); + } + } + check(); + crc = (crc ^ 4294967295) >>> 0; + const local = new Uint8Array(30), localView = new DataView(local.buffer); + localView.setUint32(0, 67324752, true); + localView.setUint16(4, 20, true); + localView.setUint16(6, 2048, true); + localView.setUint16(12, 33, true); + localView.setUint32(14, crc, true); + localView.setUint32(18, blob.size, true); + localView.setUint32(22, blob.size, true); + localView.setUint16(26, path.length, true); + const entry = new Uint8Array(46), view2 = new DataView(entry.buffer); + view2.setUint32(0, 33639248, true); + view2.setUint16(4, 20, true); + view2.setUint16(6, 20, true); + view2.setUint16(8, 2048, true); + view2.setUint16(14, 33, true); + view2.setUint32(16, crc, true); + view2.setUint32(20, blob.size, true); + view2.setUint32(24, blob.size, true); + view2.setUint16(28, path.length, true); + view2.setUint32(42, offset, true); + parts.push(local, path, blob); + central.push(entry, path); + offset += local.length + path.length + blob.size; + centralSize += entry.length + path.length; + if (performance.now() - yieldedAt >= 8) { + await sleep(0); + check(); + yieldedAt = performance.now(); + } + } + check(); + checkCapacity(files.length, offset + centralSize + 22); + const end = new Uint8Array(22), view = new DataView(end.buffer); + view.setUint32(0, 101010256, true); + view.setUint16(8, files.length, true); + view.setUint16(10, files.length, true); + view.setUint32(12, centralSize, true); + view.setUint32(16, offset, true); + return new Blob([...parts, ...central, end], { type: "application/zip" }); + } + + // src/features/export-attachments.js + var createAttachmentDownloadState = () => ({ cache: /* @__PURE__ */ new Map(), failedUrls: /* @__PURE__ */ new Map(), consecutiveFailures: 0, suspendedReason: "" }); + function* snapshotAttachmentSources(message, prefix = "", sourcePrefix = "") { + for (const [index, snapshot] of (message.message_snapshots || []).entries()) { + yield { attachments: snapshot.message?.attachments || [], prefix: `${prefix}snapshots/${index + 1}/`, source: `${sourcePrefix}message_snapshots[${index}].message.attachments` }; + } + } + function* messageAttachmentEntries(message) { + const raw = message.raw || message; + const sources = [{ attachments: raw.attachments || message.attachments || [], prefix: "", source: "attachments" }, ...snapshotAttachmentSources(raw)]; + if (raw.type === 21 && raw.referenced_message) { + sources.push({ attachments: raw.referenced_message.attachments || [], prefix: "referenced-message/", source: "referenced_message.attachments" }); + sources.push(...snapshotAttachmentSources(raw.referenced_message, "referenced-message/", "referenced_message.")); + } + for (const { attachments, prefix, source } of sources) { + for (const [index, attachment] of attachments.entries()) yield { attachment, index, prefix, source }; + } + } + async function collectExportAttachments(conversation, { files, report, log, stopCheck = () => false, state = createAttachmentDownloadState(), signal, budget }) { + const check = () => { + if (stopCheck() || signal?.aborted) throw new RunStoppedError(); + }; + let yieldedAt = performance.now(); + for (const message of conversation.messages) { + check(); + if (performance.now() - yieldedAt >= 8) { + await sleep(0); + check(); + yieldedAt = performance.now(); + } + for (const { attachment, index, prefix, source } of messageAttachmentEntries(message)) { + check(); + if (performance.now() - yieldedAt >= 8) { + await sleep(0); + check(); + yieldedAt = performance.now(); + } + const extension = String(attachment.filename || "").match(/\.[a-z0-9]{1,16}$/i)?.[0] || ""; + const filename = safeFilePart(Array.from(String(attachment.filename || "")).filter((char) => char.codePointAt(0) >= 32 && char.codePointAt(0) !== 127).join("")).replace(/^\.+|\.+$/g, "") || "file"; + const filePart = extension ? filename.replace(new RegExp(extension.replace(".", "\\.") + "$", "i"), "").slice(0, 80 - extension.length) + extension : filename; + const name = `attachments/${safeFilePart(conversation.channelId)}/${safeFilePart(message.id)}/${prefix}${index + 1}-${filePart}`; + const record = { + channelId: conversation.channelId, + messageId: message.id, + attachmentId: attachment.id || null, + filename: attachment.filename || "", + url: attachment.url || null, + path: name, + source + }; + let cacheKey, failedKey; + try { + const url = new URL(attachment.url); + if (url.protocol !== "https:") throw new Error("Missing or invalid HTTPS attachment URL."); + const discordCdn = ["cdn.discordapp.com", "media.discordapp.net"].includes(url.hostname); + const canonical = new URL(url); + canonical.hash = ""; + if (discordCdn) for (const key of ["ex", "is", "hm"]) canonical.searchParams.delete(key); + canonical.searchParams.sort(); + cacheKey = canonical.href; + failedKey = url.href; + const cached = state.cache.get(cacheKey) || state.failedUrls.get(failedKey); + if (cached) { + report.push({ ...record, ...cached, status: cached.status === "downloaded" ? "reused" : cached.status }); + continue; + } + if (state.suspendedReason) { + report.push({ ...record, path: null, status: "skipped", error: state.suspendedReason }); + continue; + } + budget?.checkFile(name, Number.isSafeInteger(attachment.size) && attachment.size >= 0 ? attachment.size : 0); + log("verb", `Downloading attachment: ${attachment.filename || name}`); + const response = await apiFetch(url.href, { credentials: "omit", referrerPolicy: "no-referrer", signal }, log, stopCheck, { minIntervalMs: 1e3 }); + check(); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const length = Number(response.headers.get("Content-Length")); + if (Number.isSafeInteger(length) && length >= 0) { + try { + budget?.checkFile(name, length); + } catch (error) { + response.body?.cancel().catch(() => { + }); + throw error; + } + } + const blob = await waitWithSignal(response.blob(), signal); + check(); + budget?.setFile(name, blob.size); + files.push({ name, blob }); + const result = { path: name, status: "downloaded", size: blob.size }; + state.cache.set(cacheKey, result); + state.consecutiveFailures = 0; + report.push({ ...record, ...result }); + } catch (error) { + check(); + if (error instanceof RunStoppedError || error instanceof ZipCapacityError) throw error; + const failure = { path: null, status: "failed", error: String(error.message || error) }; + if (failedKey) state.failedUrls.set(failedKey, failure); + report.push({ ...record, ...failure }); + state.consecutiveFailures++; + if (error instanceof RateLimitError || state.consecutiveFailures >= 3) { + state.suspendedReason = error instanceof RateLimitError ? error.message : "Three consecutive attachment failures. Further attachment requests stopped for this export."; + log("warn", state.suspendedReason); + } + log("warn", `Attachment unavailable: ${attachment.filename || name} (${error.message || error}).`); + } + } + } + } + // src/features/message-collection.js function createMessageCollection(order, retain = (message) => ({ id: message.id, channel_id: message.channel_id })) { const messages = []; @@ -3227,15 +3672,15 @@ ${options.overwriteText}` : `Delete up to ${total} messages matching your filter const { token, guildId, channelId, log, stopCheck, progress = () => { } } = options; const inaccessible = /* @__PURE__ */ Symbol("inaccessible"); - const read = async (url, skipForbidden = false) => { + const read = async (url, forbiddenMessage = "") => { const response = await apiFetch(url, { headers: { Authorization: token }, cache: "no-store" }, log, stopCheck); if (stopCheck()) throw new RunStoppedError(); - if (response.status === 403 && skipForbidden) { - log("warn", "Skipping a server channel whose message history Discord denied access to."); + if (response.status === 403 && forbiddenMessage) { + log("warn", forbiddenMessage); return inaccessible; } if (response.status === 401) invalidateAuth2(); - if (!response.ok) throw new Error(`Fresh message scan failed (${response.status}). Check your access and try again.`); + if (!response.ok) throw new Error(`Fresh message scan failed (${response.status}) for ${new URL(url).pathname}. Check your access and try again.`); return response.json(); }; let channels = [{ id: channelId }]; @@ -3248,17 +3693,46 @@ ${options.overwriteText}` : `Delete up to ${total} messages matching your filter } } if (!channelId) { - const list = await read(`${API}/guilds/${guildId}/channels`); + const discovered = await read(`${API}/guilds/${guildId}/channels`, "Discord denied server channel discovery (403). Continuing with channels found in search. Select individual channels to scan new messages in channels missing from search."); + const list = discovered === inaccessible ? [] : discovered; if (!Array.isArray(list)) throw new Error("Discord returned an invalid channel list."); if (list.some((channel) => channel.guild_id && channel.guild_id !== guildId)) throw new Error("Discord returned channels for another server."); channels = list.filter((channel) => [0, 5].includes(channel.type) && (options.includeNsfw || !channel.nsfw)); - const active = await read(`${API}/guilds/${guildId}/threads/active`); - if (!Array.isArray(active.threads)) throw new Error("Discord returned an invalid active thread list."); + const active = await read(`${API}/guilds/${guildId}/threads/active`, "Discord denied active thread discovery (403). Continuing with server channels and threads found in search. Select individual threads to scan new messages in threads missing from search."); + const threads = active === inaccessible ? [] : active.threads; + if (!Array.isArray(threads)) throw new Error("Discord returned an invalid active thread list."); const parents = new Map(list.map((channel) => [channel.id, channel])); - channels.push(...active.threads.filter((thread) => { + const threadsById = new Map(threads.map((thread) => [thread.id, thread])); + channels.push(...threads.filter((thread) => { const parent = parents.get(thread.parent_id); return parent && [10, 11, 12].includes(thread.type) && (!thread.guild_id || thread.guild_id === guildId) && (options.includeNsfw || !parent.nsfw); })); + const selected = new Set(channels.map((channel) => channel.id)); + const readMetadata = async (id) => { + if (!/^\d{15,22}$/.test(String(id || ""))) throw new Error("Discord returned an invalid channel ID."); + const channel = await read(`${API}/channels/${id}`, `Discord denied channel metadata (403) for ${id}. Skipping its fresh history scan because its server and NSFW setting could not be checked.`); + if (channel === inaccessible) return null; + if (channel?.id !== id || channel.guild_id !== guildId) throw new Error("Discord returned a channel outside the selected server."); + return channel; + }; + for (const id of newest.keys()) { + if (selected.has(id)) continue; + let thread = threadsById.get(id); + if (thread && (![10, 11, 12].includes(thread.type) || thread.guild_id && thread.guild_id !== guildId)) continue; + let channel = parents.get(id) || thread || await readMetadata(id); + if (!channel) continue; + if ([10, 11, 12].includes(channel.type)) { + thread = channel; + channel = parents.get(thread.parent_id) || await readMetadata(thread.parent_id); + if (!channel) continue; + if (![0, 5, 15, 16].includes(channel.type)) throw new Error("Discord returned an invalid thread parent."); + parents.set(channel.id, channel); + } else if (![0, 5].includes(channel.type)) throw new Error("Discord returned an unsupported message channel."); + if (options.includeNsfw || !channel.nsfw) { + channels.push({ id }); + selected.add(id); + } + } } log("info", "Checking fresh channel history for messages that Discord search may not have indexed yet\u2026"); for (const channel of channels) { @@ -3270,7 +3744,7 @@ ${options.overwriteText}` : `Delete up to ${total} messages matching your filter if (before && lower && compareMessageIds(before, lower) <= 0) break; const query = new URLSearchParams({ limit: "100" }); if (before) query.set("before", before); - const batch = await read(`${API}/channels/${channel.id}/messages?${query}`, !channelId); + const batch = await read(`${API}/channels/${channel.id}/messages?${query}`, channelId ? "" : `Discord denied message history for channel ${channel.id} (403). Skipping its fresh history scan; collected search matches will still be rechecked.`); if (batch === inaccessible) break; if (!Array.isArray(batch)) throw new Error("Discord returned invalid message history. No further messages changed."); if (!batch.length) break; @@ -3472,6 +3946,10 @@ ${options.overwriteText}` : `Delete up to ${total} messages matching your filter if (thread.id !== channelId || ![10, 11, 12].includes(thread.type)) throw new Error("Select an individual thread for thread cleanup."); const parent = await readJson(`${API}/channels/${thread.parent_id}`, options); if (!(opts.allowAnyThread ? [0, 5, 15, 16] : [15, 16]).includes(parent.type) || parent.guild_id !== options.guildId || parent.id !== thread.parent_id) throw new Error("This thread does not belong to the selected forum/server."); + if (parent.nsfw && !options.includeNsfw) { + log("info", "Thread cleanup skipped because its parent is NSFW. Enable Include NSFW to clean it."); + return { done: true, ...stats }; + } let before = options.maxId; while (!stopCheck()) { const query = new URLSearchParams({ limit: "100" }); @@ -5017,6 +5495,25 @@ Showing the first ${preview.length} of ${targets.length} reactions.` : ""), border-top: 0; padding-top: 4px; } +.dmd-export-range { + border: 1px solid #545450; + border-radius: 4px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 12px; + margin: 12px 0; +} +.dmd-export-range legend { + color: #aaa9a2; +} +.dmd-export-range [role=alert] { + grid-column: 1/-1; + color: #ffaaa5; +} +.dmd-export-range .dmd-field { + min-width: 0; +} `; // src/ui/deleter.js @@ -5442,6 +5939,7 @@ Showing the first ${preview.length} of ${targets.length} reactions.` : ""), const body = logEntries.map((x) => `[${x.time}] [${String(x.type).toUpperCase()}] ${x.message}`).join("\n"); downloadTextFile(`discord-log-${timeStampForFile()}.txt`, header + body); }; + let activeExportController = null; const exportConversations = async ({ targets, stopId, stopped, setStopped, progress, filename, select = false }) => { if (cleanupBusy) return; let conversations; @@ -5451,28 +5949,32 @@ Showing the first ${preview.length} of ${targets.length} reactions.` : ""), return log("error", error.message); } if (!conversations.length) return log("warn", "Select or queue at least one conversation to export."); - let format = "json"; setCleanupBusy(true, stopId); setStopped(false); + const controller = new AbortController(), { signal } = controller; + activeExportController = controller; try { - const identity2 = await resolveToken($("#dmd-token").value.trim()) || await loadIdentity(); - if (!identity2 || stopped()) return; - if (select) { - const choice = await askExportSelection(conversations); - if (!choice || stopped()) return; - conversations = choice.conversations; - format = choice.format; - } - const ranges = conversations.map((item) => getRange(item.channelId)); - const approved = select || await askPopup({ - title: conversations.length === 1 ? "Export conversation?" : "Export conversations?", - message: `Export all readable messages in the selected Before/After range from ${conversations.length} conversation${conversations.length === 1 ? "" : "s"}?`, - details: conversations.map((item) => item.label || item.channelId).join("\n"), - yesText: "Export", - noText: "Cancel" - }); - if (!approved || stopped()) return; - const exports = []; + const choice = await askExportSelection(conversations, { range: getRange(conversations[0].channelId), select, signal }); + if (!choice || stopped()) return; + conversations = choice.conversations; + const { format, range, downloadAttachments } = choice; + const identity2 = await resolveToken($("#dmd-token").value.trim(), { log, stopCheck: stopped, strict: true, signal }); + if (stopped()) return; + if (!identity2) return log("error", "Authorization could not be validated. Refresh Discord and try again."); + const exports = [], attachmentFiles = [], attachmentReport = []; + const attachmentState = createAttachmentDownloadState(); + const exportName = `discord-${safeFilePart(filename)}-${timeStampForFile()}`; + const messageFilename = `${exportName}.${format}`; + const jsonPrefix = format === "json" && conversations.length > 1 ? `{ + "exportedAt": ${JSON.stringify((/* @__PURE__ */ new Date()).toISOString())}, + "conversationCount": ${conversations.length}, + "conversations": [ +` : ""; + const jsonSuffix = jsonPrefix ? "\n ]\n}" : ""; + const archiveBudget = downloadAttachments ? createZipBudget() : null; + let exportBytes = jsonPrefix.length + jsonSuffix.length; + archiveBudget?.setFile(messageFilename, exportBytes); + archiveBudget?.setFile("attachments.json", 2); let messageCount = 0; progress(0, conversations.length); for (const [index, item] of conversations.entries()) { @@ -5480,26 +5982,48 @@ Showing the first ${preview.length} of ${targets.length} reactions.` : ""), const data = await exportConversationData({ token: identity2.token, ...item, - ...ranges[index], + ...range, log, - stopCheck: stopped + stopCheck: stopped, + signal }); if (stopped()) return; const blob2 = await conversationToBlob(data, { format, includeCsvHeader: index === 0, stopCheck: stopped }); if (stopped()) return; + const separator = exports.length ? format === "csv" ? "\r\n" : ",\n" : ""; + if (format === "json" || blob2.size) { + exportBytes += separator.length + blob2.size; + archiveBudget?.setFile(messageFilename, exportBytes); + } + if (downloadAttachments) await collectExportAttachments(data, { files: attachmentFiles, report: attachmentReport, log, stopCheck: stopped, state: attachmentState, signal, budget: archiveBudget }); + if (stopped()) return; messageCount += data.messageCount; if (format === "json" || blob2.size) { - if (exports.length) exports.push(format === "csv" ? "\r\n" : ",\n"); + if (separator) exports.push(separator); exports.push(blob2); } progress(index + 1, conversations.length); } - const blob = format === "json" && conversations.length > 1 ? new Blob(['{\n "exportedAt": ', JSON.stringify((/* @__PURE__ */ new Date()).toISOString()), ',\n "conversationCount": ', String(conversations.length), ',\n "conversations": [\n', ...exports, "\n ]\n}"], { type: "application/json;charset=utf-8" }) : new Blob(exports, { type: format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8" }); - downloadBlob(`discord-${safeFilePart(filename)}-${timeStampForFile()}.${format}`, blob); + const blob = new Blob([jsonPrefix, ...exports, jsonSuffix], { type: format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8" }); + if (downloadAttachments) { + log("info", "Packaging messages and attachments..."); + const reportBlob = new Blob([JSON.stringify(attachmentReport, null, 2)], { type: "application/json" }); + archiveBudget.setFile("attachments.json", reportBlob.size); + const archive = await filesToZip([ + { name: messageFilename, blob }, + { name: "attachments.json", blob: reportBlob }, + ...attachmentFiles + ], stopped); + if (stopped()) return; + downloadBlob(`${exportName}.zip`, archive); + const failed = attachmentReport.filter((item) => ["failed", "skipped"].includes(item.status)).length; + log(failed ? "warn" : "info", `Attachments: ${attachmentFiles.length} downloaded; ${failed} unavailable. See attachments.json in the ZIP.`); + } else downloadBlob(`${exportName}.${format}`, blob); log("success", `Export complete: ${conversations.length} conversations, ${messageCount} messages.`); } catch (error) { if (!stopped()) log("error", error?.message || error); } finally { + activeExportController = null; setCleanupBusy(false, stopId); setStopped(false); } @@ -5546,6 +6070,7 @@ Showing the first ${preview.length} of ${targets.length} reactions.` : ""), }); $("#dmd-stop").onclick = () => { runState.stopped = true; + activeExportController?.abort(); log("warn", "Stop requested..."); }; $("#dmd-rx-stop").onclick = () => { @@ -5818,6 +6343,7 @@ Showing the first ${preview.length} of ${targets.length} reactions.` : ""), }; $("#dmd-multi-stop").onclick = () => { runState.multiStopped = true; + activeExportController?.abort(); log("warn", "Stopping queue..."); }; $("#dmd-multi-start").onclick = async () => { @@ -5921,6 +6447,7 @@ Showing the first ${preview.length} of ${targets.length} reactions.` : ""), }; $("#dmd-forum-stop").onclick = () => { runState.stopped = true; + activeExportController?.abort(); log("warn", "Stop requested..."); }; $("#dmd-forum-load").onclick = async () => { diff --git a/package-lock.json b/package-lock.json index aeeb1ac..f3ed4ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "del-discord", - "version": "2.1.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "del-discord", - "version": "2.1.1", + "version": "2.5.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 9041a10..07c691d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "del-discord", - "version": "2.1.1", + "version": "2.5.0", "private": true, "description": "Personal local Discord deletion and DM history userscript", "scripts": { @@ -32,6 +32,6 @@ }, "repository": { "type": "git", - "url": "https://github.com/zhomander/del-discord.git" + "url": "https://github.com/azhomy/del-discord.git" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index 27754d5..bcea512 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -13,8 +13,8 @@ const header = `// ==UserScript== // @version ${project.version} // @author Del-Discord contributors // @icon ${icon} -// @homepageURL https://github.com/zhomander/del-discord -// @supportURL https://github.com/zhomander/del-discord/issues +// @homepageURL https://github.com/azhomy/del-discord +// @supportURL https://github.com/azhomy/del-discord/issues // @license MIT // @description Personal message deletion, reaction removal, queues, exports, and DM history. // @match https://discord.com/* diff --git a/src/discord/api.js b/src/discord/api.js index d747d4d..8fd1085 100644 --- a/src/discord/api.js +++ b/src/discord/api.js @@ -1,10 +1,34 @@ // Del-Discord v1 — personal source modules. import { rand, retryMs, sleep } from '../utils/timing.js'; +import { waitWithSignal } from '../utils/abort.js'; export class RunStoppedError extends Error { constructor() { super('Stopped.'); this.name = 'RunStoppedError'; } } +export class RateLimitError extends Error { + constructor() { super('Repeated rate limits. Requests stopped; wait before trying again.'); this.name = 'RateLimitError'; } +} + +// Serialize paced starts within this script, including retries and CDN requests. +let paceTail = Promise.resolve(), lastPacedStart = null; +async function paceRequest(interval, stopCheck, signal) { + const previous = paceTail; + let release; + const turn = new Promise(resolve => { release = resolve; }); + paceTail = previous.then(() => turn); + try { + await waitWithSignal(previous, signal); + if (stopCheck()) throw new RunStoppedError(); + if (lastPacedStart !== null) { + const wait = interval - (performance.now() - lastPacedStart); + if (wait > 0) await waitWithSignal(sleep(wait), signal); + } + if (stopCheck()) throw new RunStoppedError(); + lastPacedStart = performance.now(); + } finally { release(); } +} + // A conservative shared cooldown keeps other panels from continuing to request // with the same account while Discord asks us to wait. Nothing is persisted. const cooldowns = new Map(); @@ -17,19 +41,26 @@ function pauseAccount(key, milliseconds) { return pause; } -export async function apiFetch(url, options = {}, log = () => {}, stopCheck = () => false) { - const account = new Headers(options.headers).get('Authorization') || ''; +export async function apiFetch(url, options = {}, log = () => {}, stopCheck = () => false, { minIntervalMs = 0, maxRateLimitRetries = 2 } = {}) { + let retries = 0; + const account = new Headers(options.headers).get('Authorization') || new URL(url).origin; while (true) { if (stopCheck()) throw new RunStoppedError(); let cooldown; // A concurrent response can extend an account's pause while this caller // waits. Recheck the shared pause before sending another request. while ((cooldown = cooldowns.get(account))) { - await cooldown; + await waitWithSignal(cooldown, options.signal); if (stopCheck()) throw new RunStoppedError(); } + if (minIntervalMs > 0) { + await paceRequest(minIntervalMs, stopCheck, options.signal); + // Another caller may have extended a cooldown while this request paced. + if (cooldowns.has(account)) continue; + } if (stopCheck()) throw new RunStoppedError(); - const response = await fetch(url, options); + options.signal?.throwIfAborted(); + const response = await waitWithSignal(fetch(url, options), options.signal); if (response.status !== 429) { const reset = response.headers.get('X-RateLimit-Reset-After'); if (response.headers.get('X-RateLimit-Remaining') === '0' && reset !== null && Number.isFinite(Number(reset)) && Number(reset) >= 0) { @@ -38,9 +69,21 @@ export async function apiFetch(url, options = {}, log = () => {}, stopCheck = () return response; } let body = {}; - try { body = await response.json(); } catch {} - const wait = Math.max(retryMs(body.retry_after ?? response.headers.get('Retry-After')), response.headers.has('Retry-After') ? retryMs(response.headers.get('Retry-After')) : 0); - log('warn', `Rate limited. Waiting ${(wait / 1000).toFixed(1)}s...`); - await pauseAccount(account, wait); + try { body = await waitWithSignal(response.json(), options.signal) || {}; } catch {} + const seconds = value => value !== null && value !== '' && ['number', 'string'].includes(typeof value) && Number.isFinite(Number(value)) && Number(value) >= 0 ? Number(value) * 1000 : null; + const header = response.headers.get('Retry-After'); + let headerDelay = seconds(header); + // CDN responses can use HTTP dates rather than a number of seconds. + if (headerDelay === null && header && /GMT$/i.test(header)) { + const date = Date.parse(header); + if (Number.isFinite(date)) headerDelay = Math.max(0, date - Date.now()); + } + const hints = [seconds(body.retry_after), headerDelay, seconds(response.headers.get('X-RateLimit-Reset-After'))].filter(value => value !== null); + const delay = hints.length ? Math.max(...hints) : Math.min(30000, 1500 * 2 ** retries); + const pause = pauseAccount(account, delay); + options.signal?.throwIfAborted(); + if (retries++ >= maxRateLimitRetries) throw new RateLimitError(); + log('warn', `Rate limited. Waiting at least ${(delay / 1000).toFixed(1)}s before retry ${retries}/${maxRateLimitRetries}...`); + await waitWithSignal(pause, options.signal); } } diff --git a/src/discord/deleter-auth.js b/src/discord/deleter-auth.js index 076f37b..6d706b6 100644 --- a/src/discord/deleter-auth.js +++ b/src/discord/deleter-auth.js @@ -1,16 +1,23 @@ // Del-Discord v1 — personal source modules. import { API, AUTH_CACHE_TTL } from '../config.js'; import { tokenCandidates } from './token.js'; +import { apiFetch, RunStoppedError } from './api.js'; +import { waitWithSignal } from '../utils/abort.js'; export let authCache = null; -export async function validateToken(token) { +export async function validateToken(token, { log = () => {}, stopCheck = () => false, strict = false, signal } = {}) { if (!token) return null; try { - const r = await fetch(`${API}/users/@me`, { headers: { Authorization: token } }); - if (!r.ok) return null; - return await r.json(); - } catch { + const r = await apiFetch(`${API}/users/@me`, { headers: { Authorization: token }, signal }, log, stopCheck, { minIntervalMs: 1000 }); + if (stopCheck()) throw new RunStoppedError(); + if (!r.ok) { + if (strict && r.status !== 401) throw new Error(`Authorization check failed with HTTP ${r.status}. Export stopped.`); + return null; + } + return await waitWithSignal(r.json(), signal); + } catch (error) { + if (strict || error instanceof RunStoppedError) throw error; return null; } } @@ -19,7 +26,9 @@ export function invalidateAuth() { authCache = null; } -export async function resolveToken(preferred = '') { +export async function resolveToken(preferred = '', requestOptions = {}) { + requestOptions.signal?.throwIfAborted(); + if (requestOptions.stopCheck?.()) throw new RunStoppedError(); const now = Date.now(); if ( authCache && @@ -42,12 +51,13 @@ export async function resolveToken(preferred = '') { for (const token of tokenCandidates()) add(token); for (const token of candidates) { - const user = await validateToken(token); + const user = await validateToken(token, requestOptions); if (user?.id) { const identity = { token, user }; authCache = { at: now, token, identity }; return identity; } + if (requestOptions.strict) break; } invalidateAuth(); diff --git a/src/features/export-attachments.js b/src/features/export-attachments.js new file mode 100644 index 0000000..91d4c7c --- /dev/null +++ b/src/features/export-attachments.js @@ -0,0 +1,97 @@ +import { apiFetch, RunStoppedError, RateLimitError } from '../discord/api.js'; +import { safeFilePart } from '../utils/files.js'; +import { waitWithSignal } from '../utils/abort.js'; +import { sleep } from '../utils/timing.js'; +import { ZipCapacityError } from '../utils/zip.js'; + +// One state per export, shared across all selected conversations. +export const createAttachmentDownloadState = () => ({ cache: new Map(), failedUrls: new Map(), consecutiveFailures: 0, suspendedReason: '' }); + +function* snapshotAttachmentSources(message, prefix = '', sourcePrefix = '') { + for (const [index, snapshot] of (message.message_snapshots || []).entries()) { + yield { attachments: snapshot.message?.attachments || [], prefix: `${prefix}snapshots/${index + 1}/`, source: `${sourcePrefix}message_snapshots[${index}].message.attachments` }; + } +} + +function* messageAttachmentEntries(message) { + const raw = message.raw || message; + const sources = [{ attachments: raw.attachments || message.attachments || [], prefix: '', source: 'attachments' }, ...snapshotAttachmentSources(raw)]; + // A thread starter carries its content in the referenced parent message. + // Ordinary replies may reference messages outside the selected export range. + if (raw.type === 21 && raw.referenced_message) { + sources.push({ attachments: raw.referenced_message.attachments || [], prefix: 'referenced-message/', source: 'referenced_message.attachments' }); + sources.push(...snapshotAttachmentSources(raw.referenced_message, 'referenced-message/', 'referenced_message.')); + } + for (const { attachments, prefix, source } of sources) { + for (const [index, attachment] of attachments.entries()) yield { attachment, index, prefix, source }; + } +} + +export async function collectExportAttachments(conversation, { files, report, log, stopCheck = () => false, state = createAttachmentDownloadState(), signal, budget }) { + const check = () => { if (stopCheck() || signal?.aborted) throw new RunStoppedError(); }; + let yieldedAt = performance.now(); + for (const message of conversation.messages) { + check(); + if (performance.now() - yieldedAt >= 8) { await sleep(0); check(); yieldedAt = performance.now(); } + for (const { attachment, index, prefix, source } of messageAttachmentEntries(message)) { + check(); + if (performance.now() - yieldedAt >= 8) { await sleep(0); check(); yieldedAt = performance.now(); } + const extension = String(attachment.filename || '').match(/\.[a-z0-9]{1,16}$/i)?.[0] || ''; + const filename = safeFilePart(Array.from(String(attachment.filename || '')).filter(char => char.codePointAt(0) >= 32 && char.codePointAt(0) !== 127).join('')).replace(/^\.+|\.+$/g, '') || 'file'; + const filePart = extension ? filename.replace(new RegExp(extension.replace('.', '\\.') + '$', 'i'), '').slice(0, 80 - extension.length) + extension : filename; + const name = `attachments/${safeFilePart(conversation.channelId)}/${safeFilePart(message.id)}/${prefix}${index + 1}-${filePart}`; + const record = { channelId: conversation.channelId, messageId: message.id, attachmentId: attachment.id || null, + filename: attachment.filename || '', url: attachment.url || null, path: name, source }; + let cacheKey, failedKey; + try { + const url = new URL(attachment.url); + if (url.protocol !== 'https:') throw new Error('Missing or invalid HTTPS attachment URL.'); + const discordCdn = ['cdn.discordapp.com', 'media.discordapp.net'].includes(url.hostname); + const canonical = new URL(url); canonical.hash = ''; + if (discordCdn) for (const key of ['ex', 'is', 'hm']) canonical.searchParams.delete(key); + canonical.searchParams.sort(); + cacheKey = canonical.href; + failedKey = url.href; + const cached = state.cache.get(cacheKey) || state.failedUrls.get(failedKey); + if (cached) { + report.push({ ...record, ...cached, status: cached.status === 'downloaded' ? 'reused' : cached.status }); + continue; + } + if (state.suspendedReason) { + report.push({ ...record, path: null, status: 'skipped', error: state.suspendedReason }); + continue; + } + budget?.checkFile(name, Number.isSafeInteger(attachment.size) && attachment.size >= 0 ? attachment.size : 0); + log('verb', `Downloading attachment: ${attachment.filename || name}`); + // CDN downloads must never receive the Discord authorization token or cookies. + const response = await apiFetch(url.href, { credentials: 'omit', referrerPolicy: 'no-referrer', signal }, log, stopCheck, { minIntervalMs: 1000 }); + check(); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const length = Number(response.headers.get('Content-Length')); + if (Number.isSafeInteger(length) && length >= 0) { + try { budget?.checkFile(name, length); } + catch (error) { response.body?.cancel().catch(() => {}); throw error; } + } + const blob = await waitWithSignal(response.blob(), signal); + check(); + budget?.setFile(name, blob.size); + files.push({ name, blob }); + const result = { path: name, status: 'downloaded', size: blob.size }; + state.cache.set(cacheKey, result); state.consecutiveFailures = 0; + report.push({ ...record, ...result }); + } catch (error) { + check(); + if (error instanceof RunStoppedError || error instanceof ZipCapacityError) throw error; + const failure = { path: null, status: 'failed', error: String(error.message || error) }; + if (failedKey) state.failedUrls.set(failedKey, failure); + report.push({ ...record, ...failure }); + state.consecutiveFailures++; + if (error instanceof RateLimitError || state.consecutiveFailures >= 3) { + state.suspendedReason = error instanceof RateLimitError ? error.message : 'Three consecutive attachment failures. Further attachment requests stopped for this export.'; + log('warn', state.suspendedReason); + } + log('warn', `Attachment unavailable: ${attachment.filename || name} (${error.message || error}).`); + } + } + } +} diff --git a/src/features/export.js b/src/features/export.js index 33d5a7e..083f3ba 100644 --- a/src/features/export.js +++ b/src/features/export.js @@ -3,7 +3,8 @@ import { toSnowflake } from '../utils/messages.js'; import { API } from '../config.js'; import { apiFetch, RunStoppedError } from '../discord/api.js'; import { invalidateAuth } from '../discord/deleter-auth.js'; -import { rand, sleep } from '../utils/timing.js'; +import { sleep } from '../utils/timing.js'; +import { waitWithSignal } from '../utils/abort.js'; import { compareMessageIds } from './message-filters.js'; export function compactReferencedMessage(message) { @@ -23,6 +24,8 @@ export function compactReferencedMessage(message) { export function compactExportMessage(message) { return { + // Keep the original response, including fields added by Discord in future. + raw: message, id: message.id, timestamp: message.timestamp, editedTimestamp: message.edited_timestamp || null, @@ -62,7 +65,20 @@ export function compactExportMessage(message) { }; } -export async function exportConversationData({ token, guildId, channelId, minId, maxId, label, log, stopCheck = () => false }) { +export function validateExportRange({ minId = '', maxId = '' } = {}) { + minId = String(minId).trim(); maxId = String(maxId).trim(); + const boundary = value => { + const id = toSnowflake(value); + if (value && (!id || !/^\d+$/.test(id))) throw new Error('Enter a valid date/time or message ID.'); + return id; + }; + const after = boundary(minId), before = boundary(maxId); + if (after && before && BigInt(after) >= BigInt(before)) throw new Error('After must be earlier than Before.'); + return { minId, maxId }; +} + +export async function exportConversationData({ token, guildId, channelId, minId, maxId, label, log, stopCheck = () => false, signal }) { + ({ minId, maxId } = validateExportRange({ minId, maxId })); const afterSnowflake = minId ? toSnowflake(minId) : ''; let beforeSnowflake = maxId ? toSnowflake(maxId) : ''; let afterBig = null; @@ -77,7 +93,7 @@ export async function exportConversationData({ token, guildId, channelId, minId, const url = `${API}/channels/${channelId}/messages?limit=100${beforeSnowflake ? `&before=${beforeSnowflake}` : ''}`; let response; try { - response = await apiFetch(url, { headers: { Authorization: token } }, log, stopCheck); + response = await apiFetch(url, { headers: { Authorization: token }, signal }, log, stopCheck, { minIntervalMs: 1000 }); } catch (error) { if (error instanceof RunStoppedError) break; throw error; @@ -91,8 +107,10 @@ export async function exportConversationData({ token, guildId, channelId, minId, if (response.status === 403) throw new Error('Discord denied access to this conversation.'); if (!response.ok) throw new Error(`Conversation export failed with HTTP ${response.status}.`); - const batch = await response.json(); - if (!Array.isArray(batch) || !batch.length) break; + const batch = await waitWithSignal(response.json(), signal); + if (stopCheck()) break; + if (!Array.isArray(batch)) throw new Error('Discord returned an invalid message page. Export stopped.'); + if (!batch.length) break; page++; const beforeBig = beforeSnowflake ? BigInt(beforeSnowflake) : null; @@ -101,7 +119,7 @@ export async function exportConversationData({ token, guildId, channelId, minId, let reachedAfter = false; for (const message of batch) { - if (!/^\d+$/.test(String(message?.id || ''))) continue; + if (typeof message?.id !== 'string' || !/^\d+$/.test(message.id)) throw new Error('Discord returned a malformed message. Export stopped.'); let idBig = null; try { idBig = BigInt(message.id); } catch {} if (idBig === null) continue; @@ -119,9 +137,9 @@ export async function exportConversationData({ token, guildId, channelId, minId, log('verb', `Export page ${page}: ${messages.length} messages collected.`); - if (reachedAfter || oldest === null) break; + if (reachedAfter) break; + if (oldest === null) throw new Error('Message pagination did not advance. Export stopped to avoid saving incomplete history.'); beforeSnowflake = oldest.toString(); - await sleep(rand(550, 900)); } messages.sort((a, b) => compareMessageIds(a.id, b.id)); @@ -140,7 +158,7 @@ export async function exportConversationData({ token, guildId, channelId, minId, }; } -const csvColumns = ['guildId', 'channelId', 'conversation', 'id', 'timestamp', 'editedTimestamp', 'authorId', 'username', 'content', 'type', 'pinned', 'attachments', 'embeds', 'reactions', 'mentions', 'referencedMessage']; +const csvColumns = ['guildId', 'channelId', 'conversation', 'id', 'timestamp', 'editedTimestamp', 'authorId', 'username', 'content', 'type', 'pinned', 'attachments', 'embeds', 'reactions', 'mentions', 'referencedMessage', 'messageData']; const nestedColumns = ['attachments', 'embeds', 'reactions', 'mentions', 'referencedMessage']; const csvCell = value => '"' + String(value ?? '').replace(/"/g, '""') + '"'; @@ -150,7 +168,8 @@ function* conversationCsvRows(conversations, includeHeader = true) { for (const message of conversation.messages) { yield [conversation.guildId, conversation.channelId, conversation.label, message.id, message.timestamp, message.editedTimestamp, message.author?.id, message.author?.username, message.content, message.type, - message.pinned, ...nestedColumns.map(key => JSON.stringify(message[key] ?? null))].map(csvCell).join(','); + message.pinned, ...nestedColumns.map(key => JSON.stringify(message[key] ?? null)), + JSON.stringify(message.raw ?? message)].map(csvCell).join(','); } } } diff --git a/src/features/forum-threads.js b/src/features/forum-threads.js index 9cafd55..c848cae 100644 --- a/src/features/forum-threads.js +++ b/src/features/forum-threads.js @@ -56,6 +56,10 @@ export async function cleanupForumThread(opts) { if (thread.id !== channelId || ![10, 11, 12].includes(thread.type)) throw new Error('Select an individual thread for thread cleanup.'); const parent = await readJson(`${API}/channels/${thread.parent_id}`, options); if (!(opts.allowAnyThread ? [0, 5, 15, 16] : [15, 16]).includes(parent.type) || parent.guild_id !== options.guildId || parent.id !== thread.parent_id) throw new Error('This thread does not belong to the selected forum/server.'); + if (parent.nsfw && !options.includeNsfw) { + log('info', 'Thread cleanup skipped because its parent is NSFW. Enable Include NSFW to clean it.'); + return { done: true, ...stats }; + } let before = options.maxId; while (!stopCheck()) { const query = new URLSearchParams({ limit: '100' }); diff --git a/src/features/fresh-messages.js b/src/features/fresh-messages.js index b098f77..e7b3f4a 100644 --- a/src/features/fresh-messages.js +++ b/src/features/fresh-messages.js @@ -9,15 +9,15 @@ import { rand, sleep } from '../utils/timing.js'; export async function collectFreshMessages(options, newest, collected, stats, scanned = 0) { const { token, guildId, channelId, log, stopCheck, progress = () => {} } = options; const inaccessible = Symbol('inaccessible'); - const read = async (url, skipForbidden = false) => { + const read = async (url, forbiddenMessage = '') => { const response = await apiFetch(url, { headers: { Authorization: token }, cache: 'no-store' }, log, stopCheck); if (stopCheck()) throw new RunStoppedError(); - if (response.status === 403 && skipForbidden) { - log('warn', 'Skipping a server channel whose message history Discord denied access to.'); + if (response.status === 403 && forbiddenMessage) { + log('warn', forbiddenMessage); return inaccessible; } if (response.status === 401) invalidateAuth(); - if (!response.ok) throw new Error(`Fresh message scan failed (${response.status}). Check your access and try again.`); + if (!response.ok) throw new Error(`Fresh message scan failed (${response.status}) for ${new URL(url).pathname}. Check your access and try again.`); return response.json(); }; let channels = [{ id: channelId }]; @@ -30,17 +30,50 @@ export async function collectFreshMessages(options, newest, collected, stats, sc } } if (!channelId) { - const list = await read(`${API}/guilds/${guildId}/channels`); + const discovered = await read(`${API}/guilds/${guildId}/channels`, 'Discord denied server channel discovery (403). Continuing with channels found in search. Select individual channels to scan new messages in channels missing from search.'); + const list = discovered === inaccessible ? [] : discovered; if (!Array.isArray(list)) throw new Error('Discord returned an invalid channel list.'); if (list.some(channel => channel.guild_id && channel.guild_id !== guildId)) throw new Error('Discord returned channels for another server.'); channels = list.filter(channel => [0, 5].includes(channel.type) && (options.includeNsfw || !channel.nsfw)); - const active = await read(`${API}/guilds/${guildId}/threads/active`); - if (!Array.isArray(active.threads)) throw new Error('Discord returned an invalid active thread list.'); + const active = await read(`${API}/guilds/${guildId}/threads/active`, 'Discord denied active thread discovery (403). Continuing with server channels and threads found in search. Select individual threads to scan new messages in threads missing from search.'); + const threads = active === inaccessible ? [] : active.threads; + if (!Array.isArray(threads)) throw new Error('Discord returned an invalid active thread list.'); const parents = new Map(list.map(channel => [channel.id, channel])); - channels.push(...active.threads.filter(thread => { + const threadsById = new Map(threads.map(thread => [thread.id, thread])); + channels.push(...threads.filter(thread => { const parent = parents.get(thread.parent_id); return parent && [10, 11, 12].includes(thread.type) && (!thread.guild_id || thread.guild_id === guildId) && (options.includeNsfw || !parent.nsfw); })); + // Discovery endpoints can be unavailable even when search and individual + // channel history are allowed. Preserve search targets, including threads + // absent from the active list, without duplicating a discovered channel. + const selected = new Set(channels.map(channel => channel.id)); + const readMetadata = async id => { + if (!/^\d{15,22}$/.test(String(id || ''))) throw new Error('Discord returned an invalid channel ID.'); + const channel = await read(`${API}/channels/${id}`, `Discord denied channel metadata (403) for ${id}. Skipping its fresh history scan because its server and NSFW setting could not be checked.`); + if (channel === inaccessible) return null; + if (channel?.id !== id || channel.guild_id !== guildId) throw new Error('Discord returned a channel outside the selected server.'); + return channel; + }; + for (const id of newest.keys()) { + if (selected.has(id)) continue; + let thread = threadsById.get(id); + if (thread && (![10, 11, 12].includes(thread.type) || (thread.guild_id && thread.guild_id !== guildId))) continue; + let channel = parents.get(id) || thread || await readMetadata(id); + if (!channel) continue; + if ([10, 11, 12].includes(channel.type)) { + thread = channel; + channel = parents.get(thread.parent_id) || await readMetadata(thread.parent_id); + if (!channel) continue; + if (![0, 5, 15, 16].includes(channel.type)) throw new Error('Discord returned an invalid thread parent.'); + parents.set(channel.id, channel); + } else if (![0, 5].includes(channel.type)) throw new Error('Discord returned an unsupported message channel.'); + // Archived threads can be absent from discovery. Resolve their parent + // before applying the same NSFW filter used for active threads. + if (options.includeNsfw || !channel.nsfw) { + channels.push({ id }); selected.add(id); + } + } } log('info', 'Checking fresh channel history for messages that Discord search may not have indexed yet…'); for (const channel of channels) { @@ -52,7 +85,7 @@ export async function collectFreshMessages(options, newest, collected, stats, sc if (before && lower && compareMessageIds(before, lower) <= 0) break; const query = new URLSearchParams({ limit: '100' }); if (before) query.set('before', before); - const batch = await read(`${API}/channels/${channel.id}/messages?${query}`, !channelId); + const batch = await read(`${API}/channels/${channel.id}/messages?${query}`, channelId ? '' : `Discord denied message history for channel ${channel.id} (403). Skipping its fresh history scan; collected search matches will still be rechecked.`); if (batch === inaccessible) break; if (!Array.isArray(batch)) throw new Error('Discord returned invalid message history. No further messages changed.'); if (!batch.length) break; diff --git a/src/features/history/import-package.js b/src/features/history/import-package.js index c1b0026..7c38039 100644 --- a/src/features/history/import-package.js +++ b/src/features/history/import-package.js @@ -6,6 +6,7 @@ import { saveHistory } from './storage.js'; export async function importPackage(fileList, { $, getHistory, getIdentity, setPackageInfo, persist, render }) { const history = getHistory(); + const isCurrent = () => getHistory() === history; const files = [...(fileList || [])]; if (!files.length) return; @@ -14,6 +15,7 @@ export async function importPackage(fileList, { $, getHistory, getIdentity, setP try { const me = await getIdentity(); + if (!isCurrent()) return; const selfId = me?.user?.id || ''; const pathOf = file => String(file.webkitRelativePath || file.name).replace(/\\/g, '/'); const folderOf = path => path.slice(0, path.lastIndexOf('/')).toLowerCase(); @@ -24,12 +26,14 @@ export async function importPackage(fileList, { $, getHistory, getIdentity, setP // One O(n) pass over the package instead of scanning every file for every DM. for (const file of files) { + if (!isCurrent()) return; const path = pathOf(file); const lower = path.toLowerCase(); if ((`/${lower}`).endsWith('/messages/index.json')) { try { const obj = JSON.parse(await file.text()); + if (!isCurrent()) return; for (const [id, label] of Object.entries(obj || {})) { if (typeof label === 'string') index.set(String(id), label); } @@ -74,16 +78,19 @@ export async function importPackage(fileList, { $, getHistory, getIdentity, setP let lastSavedAt = Date.now(); for (const { file, path } of channelFiles) { + if (!isCurrent()) return; processed++; if (processed % 25 === 0) { $('#dmh-summary').textContent = `Processing package conversations ${processed}/${channelFiles.length}…`; await new Promise(requestAnimationFrame); + if (!isCurrent()) return; } let ch; try { ch = JSON.parse(await file.text()); } catch { continue; } + if (!isCurrent()) return; const folder = path.slice(0, path.lastIndexOf('/')); const channelId = String(ch?.id || folder.match(/(?:^|\/)c?(\d{15,22})$/)?.[1] || ''); @@ -95,6 +102,7 @@ export async function importPackage(fileList, { $, getHistory, getIdentity, setP if (!direct || group) continue; const count = await countSentMessages(folder); + if (!isCurrent()) return; if (count === null) continue; if (count <= 0) { removeHistoryMatch(history, '', channelId); @@ -123,6 +131,7 @@ export async function importPackage(fileList, { $, getHistory, getIdentity, setP } + if (!isCurrent()) return; saveHistory(history); setPackageInfo({ importedAt: new Date().toISOString(), diff --git a/src/features/history/model.js b/src/features/history/model.js index 473f70c..ee77a44 100644 --- a/src/features/history/model.js +++ b/src/features/history/model.js @@ -52,13 +52,6 @@ export function merge(map, incoming) { const previous = oldKey ? (map[oldKey] || {}) : (map[key] || {}); - if (oldKey && oldKey !== key) { - delete map[oldKey]; - - if (previous.userId) indexes.byUser.delete(previous.userId); - if (previous.channelId) indexes.byChannel.delete(previous.channelId); - } - const merged = { userId: incoming.userId || previous.userId || '', channelId: incoming.channelId || previous.channelId || '', @@ -82,6 +75,12 @@ export function merge(map, incoming) { verifiedSent: incoming.verifiedSent === true || previous.verifiedSent === true }; + if (oldKey && oldKey !== key) delete map[oldKey]; + // Replacing an identifier must retire its old lookup even when the map key + // stays the same (for example, a user's channel ID is updated). + if (previous.userId && previous.userId !== merged.userId && indexes.byUser.get(previous.userId) === oldKey) indexes.byUser.delete(previous.userId); + if (previous.channelId && previous.channelId !== merged.channelId && indexes.byChannel.get(previous.channelId) === oldKey) indexes.byChannel.delete(previous.channelId); + map[key] = merged; if (merged.userId) indexes.byUser.set(merged.userId, key); diff --git a/src/features/history/verify.js b/src/features/history/verify.js index d889a71..f38e03f 100644 --- a/src/features/history/verify.js +++ b/src/features/history/verify.js @@ -17,6 +17,15 @@ export async function sentCountForChannel(token, selfId, channelId) { } } +async function closeTemporaryDm(token, channelId) { + try { + const response = await apiFetch(`${API}/channels/${channelId}`, { method: 'DELETE', headers: { Authorization: token } }); + if (!response.ok && response.status !== 404) throw new Error(`HTTP ${response.status}`); + } catch (error) { + throw new Error(`Could not close the temporarily opened DM. It may remain open in your DM list. ${error.message || error}`, { cause: error }); + } +} + export async function verifyPerson(token, selfId, userId, knownChannelId, wasOpen) { let channelId = knownChannelId || ''; let temporarilyOpened = false; @@ -32,8 +41,6 @@ export async function verifyPerson(token, selfId, userId, knownChannelId, wasOpe const count = await sentCountForChannel(token, selfId, channelId); return { count, channelId }; } finally { - if (temporarilyOpened && channelId) { - try { await apiFetch(`${API}/channels/${channelId}`, { method: 'DELETE', headers: { Authorization: token } }); } catch {} - } + if (temporarilyOpened && channelId) await closeTemporaryDm(token, channelId); } } diff --git a/src/features/message-filters.js b/src/features/message-filters.js index 81019d5..eb61a89 100644 --- a/src/features/message-filters.js +++ b/src/features/message-filters.js @@ -50,8 +50,8 @@ export function matchesMessage(message, options) { if (options.channelId && message.channel_id !== options.channelId) return false; if (![0, 6, 19].includes(message.type)) return false; if (!/^\d+$/.test(String(message.id || ''))) return false; - if (options.minId && BigInt(message.id) <= BigInt(options.minId)) return false; - if (options.maxId && BigInt(message.id) >= BigInt(options.maxId)) return false; + if (options.minId && compareMessageIds(message.id, options.minId) <= 0) return false; + if (options.maxId && compareMessageIds(message.id, options.maxId) >= 0) return false; const matchMode = (mode, value) => mode === 'any' || (mode === 'with' ? value : !value); if (options.linkMode !== 'any') { const hasLink = /https?:\/\/\S+/i.test(message.content || '') || (message.embeds || []).some(embed => !!embed.url); diff --git a/src/ui/confirm.js b/src/ui/confirm.js index f627114..f9cd645 100644 --- a/src/ui/confirm.js +++ b/src/ui/confirm.js @@ -1,6 +1,7 @@ // Del-Discord v1 — personal source modules. -export function askPopup({ title = 'Confirm', message = '', details = '', yesText = 'Yes', noText = 'No', danger = false, render, getResult, onNo } = {}) { +export function askPopup({ title = 'Confirm', message = '', details = '', yesText = 'Yes', noText = 'No', danger = false, render, getResult, onNo, signal } = {}) { + if (signal?.aborted) return Promise.resolve(false); return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'dmd-confirm-overlay'; @@ -53,22 +54,32 @@ export function askPopup({ title = 'Confirm', message = '', details = '', yesTex if (done) return; done = true; document.removeEventListener('keydown', onKey, true); + signal?.removeEventListener('abort', onAbort); overlay.remove(); if (previousFocus?.isConnected) previousFocus.focus(); resolve(value && getResult ? getResult() : value); }; const onKey = e => { + if (e.key !== 'Tab' && e.key !== 'Escape' && !(e.key === 'Enter' && (e.metaKey || e.ctrlKey))) return; + const dialogs = document.querySelectorAll('.dmd-confirm-overlay'); + // Async scans can open another confirmation while a queue prompt is up. + // A shortcut must never approve or dismiss the covered dialog as well. + if (dialogs[dialogs.length - 1] !== overlay) return; + e.preventDefault(); + e.stopImmediatePropagation(); if (e.key === 'Tab') { const controls = [...box.querySelectorAll('button,input,select,[tabindex="0"]')].filter(node => !node.disabled); const index = controls.indexOf(document.activeElement); - e.preventDefault(); controls[(index + (e.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus(); + controls[(index + (e.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus(); } if (e.key === 'Escape') finish(false); if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !yes.disabled) finish(true); }; + const onAbort = () => finish(false); + signal?.addEventListener('abort', onAbort, { once: true }); no.onclick = () => { onNo?.(); finish(false); }; - yes.onclick = () => finish(true); + yes.onclick = () => { if (!yes.disabled) finish(true); }; overlay.onclick = e => { if (e.target === overlay) finish(false); }; document.addEventListener('keydown', onKey, true); yes.focus(); diff --git a/src/ui/deleter.css b/src/ui/deleter.css index e86c98e..70c8e4d 100644 --- a/src/ui/deleter.css +++ b/src/ui/deleter.css @@ -135,3 +135,5 @@ #dmd-rx-start-field{display:grid;grid-template-columns:minmax(100px,.8fr) minmax(0,1.2fr);gap:0 8px}#dmd-rx-start-field>label{grid-column:1/-1}#dmd-rx-start-field .dmd-start-value{margin-top:0;min-width:0}#dmd-rx-start-date-field>label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} #dmd-log-area{display:flex;flex-direction:column;flex:0 0 150px;min-height:110px;max-height:30%;background:#292928;border-top:1px solid var(--line)}.dmd-log-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 24px;color:#a4a49f;font-size:11px}.dmd-log-toolbar>div{display:flex;gap:6px}.dmd-log-toolbar .dmd-btn{padding:3px 8px;min-height:24px;background:transparent;border-color:transparent;color:#b6b6ae}.dmd-log-toolbar .dmd-btn:hover{background:#ffffff0b;border-color:#50504d}#dmd-log-area #dmd-log{flex:1;min-height:0;max-height:none;border-top:0;padding-top:4px} + +.dmd-export-range{border:1px solid #545450;border-radius:4px;display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:12px;margin:12px 0}.dmd-export-range legend{color:#aaa9a2}.dmd-export-range [role=alert]{grid-column:1/-1;color:#ffaaa5}.dmd-export-range .dmd-field{min-width:0} diff --git a/src/ui/deleter.js b/src/ui/deleter.js index a5b7096..85f5abe 100644 --- a/src/ui/deleter.js +++ b/src/ui/deleter.js @@ -24,6 +24,8 @@ import { askExportSelection } from './export-selection.js'; import { askPopup } from './confirm.js'; import { runState } from '../utils/run-state.js'; import { exportConversationData, conversationToBlob } from '../features/export.js'; +import { collectExportAttachments, createAttachmentDownloadState } from '../features/export-attachments.js'; +import { createZipBudget, filesToZip } from '../utils/zip.js'; import { deleteMessages } from '../features/messages.js'; import { listForumThreads, cleanupForumThread } from '../features/forum-threads.js'; import { normalizeCleanupOptions } from '../features/message-filters.js'; @@ -380,54 +382,77 @@ export function initDeleter() { const body = logEntries.map(x => `[${x.time}] [${String(x.type).toUpperCase()}] ${x.message}`).join('\n'); downloadTextFile(`discord-log-${timeStampForFile()}.txt`, header + body); }; + let activeExportController = null; // All export entry points share confirmation, cancellation, ranges, and download handling. const exportConversations = async ({ targets, stopId, stopped, setStopped, progress, filename, select = false }) => { if (cleanupBusy) return; let conversations; try { conversations = targets(); } catch (error) { return log('error', error.message); } if (!conversations.length) return log('warn', 'Select or queue at least one conversation to export.'); - let format = 'json'; setCleanupBusy(true, stopId); setStopped(false); + const controller = new AbortController(), { signal } = controller; + activeExportController = controller; try { - const identity = await resolveToken($('#dmd-token').value.trim()) || await loadIdentity(); - if (!identity || stopped()) return; - if (select) { - const choice = await askExportSelection(conversations); - if (!choice || stopped()) return; - conversations = choice.conversations; format = choice.format; - } - const ranges = conversations.map(item => getRange(item.channelId)); - const approved = select || await askPopup({ - title: conversations.length === 1 ? 'Export conversation?' : 'Export conversations?', - message: `Export all readable messages in the selected Before/After range from ${conversations.length} conversation${conversations.length === 1 ? '' : 's'}?`, - details: conversations.map(item => item.label || item.channelId).join('\n'), - yesText: 'Export', noText: 'Cancel', - }); - if (!approved || stopped()) return; - const exports = []; + const choice = await askExportSelection(conversations, { range: getRange(conversations[0].channelId), select, signal }); + if (!choice || stopped()) return; + conversations = choice.conversations; + const { format, range, downloadAttachments } = choice; + const identity = await resolveToken($('#dmd-token').value.trim(), { log, stopCheck: stopped, strict: true, signal }); + if (stopped()) return; + if (!identity) return log('error', 'Authorization could not be validated. Refresh Discord and try again.'); + const exports = [], attachmentFiles = [], attachmentReport = []; + const attachmentState = createAttachmentDownloadState(); + const exportName = `discord-${safeFilePart(filename)}-${timeStampForFile()}`; + const messageFilename = `${exportName}.${format}`; + const jsonPrefix = format === 'json' && conversations.length > 1 + ? `{\n "exportedAt": ${JSON.stringify(new Date().toISOString())},\n "conversationCount": ${conversations.length},\n "conversations": [\n` : ''; + const jsonSuffix = jsonPrefix ? '\n ]\n}' : ''; + const archiveBudget = downloadAttachments ? createZipBudget() : null; + let exportBytes = jsonPrefix.length + jsonSuffix.length; + // Reserve both required entries before requesting attachment payloads. + archiveBudget?.setFile(messageFilename, exportBytes); + archiveBudget?.setFile('attachments.json', 2); let messageCount = 0; progress(0, conversations.length); for (const [index, item] of conversations.entries()) { if (stopped()) return; const data = await exportConversationData({ token: identity.token, ...item, - ...ranges[index], log, stopCheck: stopped }); + ...range, log, stopCheck: stopped, signal }); if (stopped()) return; const blob = await conversationToBlob(data, { format, includeCsvHeader: index === 0, stopCheck: stopped }); if (stopped()) return; + const separator = exports.length ? (format === 'csv' ? '\r\n' : ',\n') : ''; + if (format === 'json' || blob.size) { + exportBytes += separator.length + blob.size; + archiveBudget?.setFile(messageFilename, exportBytes); + } + if (downloadAttachments) await collectExportAttachments(data, { files: attachmentFiles, report: attachmentReport, log, stopCheck: stopped, state: attachmentState, signal, budget: archiveBudget }); + if (stopped()) return; messageCount += data.messageCount; if (format === 'json' || blob.size) { - if (exports.length) exports.push(format === 'csv' ? '\r\n' : ',\n'); + if (separator) exports.push(separator); exports.push(blob); } progress(index + 1, conversations.length); } - const blob = format === 'json' && conversations.length > 1 - ? new Blob(['{\n "exportedAt": ', JSON.stringify(new Date().toISOString()), ',\n "conversationCount": ', String(conversations.length), ',\n "conversations": [\n', ...exports, '\n ]\n}'], { type: 'application/json;charset=utf-8' }) - : new Blob(exports, { type: format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json;charset=utf-8' }); - downloadBlob(`discord-${safeFilePart(filename)}-${timeStampForFile()}.${format}`, blob); + const blob = new Blob([jsonPrefix, ...exports, jsonSuffix], { type: format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json;charset=utf-8' }); + if (downloadAttachments) { + log('info', 'Packaging messages and attachments...'); + const reportBlob = new Blob([JSON.stringify(attachmentReport, null, 2)], { type: 'application/json' }); + archiveBudget.setFile('attachments.json', reportBlob.size); + const archive = await filesToZip([ + { name: messageFilename, blob }, + { name: 'attachments.json', blob: reportBlob }, + ...attachmentFiles, + ], stopped); + if (stopped()) return; + downloadBlob(`${exportName}.zip`, archive); + const failed = attachmentReport.filter(item => ['failed', 'skipped'].includes(item.status)).length; + log(failed ? 'warn' : 'info', `Attachments: ${attachmentFiles.length} downloaded; ${failed} unavailable. See attachments.json in the ZIP.`); + } else downloadBlob(`${exportName}.${format}`, blob); log('success', `Export complete: ${conversations.length} conversations, ${messageCount} messages.`); } catch (error) { if (!stopped()) log('error', error?.message || error); } - finally { setCleanupBusy(false, stopId); setStopped(false); } + finally { activeExportController = null; setCleanupBusy(false, stopId); setStopped(false); } }; $('#dmd-export-conversation').onclick = () => exportConversations({ targets: () => { @@ -452,7 +477,7 @@ export function initDeleter() { $('#dmd-forum-status').textContent = `Exported ${value}/${max}`; }, filename: 'forum-conversations', }); - $('#dmd-stop').onclick = () => { runState.stopped = true; log('warn', 'Stop requested...'); }; + $('#dmd-stop').onclick = () => { runState.stopped = true; activeExportController?.abort(); log('warn', 'Stop requested...'); }; $('#dmd-rx-stop').onclick = () => { runState.reactionStopped = true; log('warn', 'Stop requested...'); }; for (const side of ['after', 'before']) { @@ -637,7 +662,7 @@ export function initDeleter() { } queue.clear(); setMultiProgress(0, 1, ''); }; - $('#dmd-multi-stop').onclick = () => { runState.multiStopped = true; log('warn', 'Stopping queue...'); }; + $('#dmd-multi-stop').onclick = () => { runState.multiStopped = true; activeExportController?.abort(); log('warn', 'Stopping queue...'); }; $('#dmd-multi-start').onclick = async () => { if (cleanupBusy) return log('info', 'The queue will continue automatically as jobs are added.'); if (!queue.getItems().length) return log('error', 'The queue is empty.'); @@ -700,7 +725,7 @@ export function initDeleter() { $('#dmd-forum-list').appendChild(fragment); $('#dmd-forum-status').textContent = `${forumThreads.length} threads loaded`; }; - $('#dmd-forum-stop').onclick = () => { runState.stopped = true; log('warn', 'Stop requested...'); }; + $('#dmd-forum-stop').onclick = () => { runState.stopped = true; activeExportController?.abort(); log('warn', 'Stop requested...'); }; $('#dmd-forum-load').onclick = async () => { if (cleanupBusy) return; setCleanupBusy(true, '#dmd-forum-stop'); runState.stopped = false; diff --git a/src/ui/export-selection.js b/src/ui/export-selection.js index d69190f..aeb5932 100644 --- a/src/ui/export-selection.js +++ b/src/ui/export-selection.js @@ -1,28 +1,44 @@ import { askPopup } from './confirm.js'; +import { validateExportRange } from '../features/export.js'; -export function askExportSelection(conversations) { - const selected = new Set(); - let format = 'json'; +export function askExportSelection(conversations, { range = {}, select = true, signal } = {}) { + const selected = new Set(select ? [] : conversations.map((_, index) => index)); + let exportRange = { minId: range.minId || '', maxId: range.maxId || '' }; + let format = 'json', downloadAttachments = false; return askPopup({ - title: 'Export Conversations', message: 'Choose conversations and a file format. The selected Before/After range applies.', + signal, + title: 'Export Conversations', message: 'Choose conversations, a date or message range, and a file format. Blank boundaries export all readable history. All message data returned by Discord is included; enable attachment downloads to include the actual files in a ZIP (up to 4 GB).', yesText: 'Export', noText: 'Cancel', - getResult: () => ({ conversations: conversations.filter((_, index) => selected.has(index)), format }), + getResult: () => ({ conversations: conversations.filter((_, index) => selected.has(index)), format, range: exportRange, downloadAttachments }), render: ({ box, yes }) => { - yes.disabled = true; + const boundaries = []; + const error = document.createElement('div'); error.setAttribute('role', 'alert'); + const validate = () => { + try { + for (const { input, kind } of boundaries) { + if (input.validity.badInput) throw new Error('Complete the date and time or clear the boundary.'); + if (kind.value === 'id' && input.value && !/^\d+$/.test(input.value.trim())) throw new Error('Enter a numeric message ID.'); + } + exportRange = validateExportRange(exportRange); error.textContent = ''; + } + catch (issue) { error.textContent = issue.message; } + yes.disabled = !selected.size || !!error.textContent; + }; + validate(); const all = document.createElement('button'); all.type = 'button'; all.className = 'dmd-btn'; all.textContent = 'Select all'; all.disabled = !conversations.length; const list = document.createElement('div'); list.className = 'dmd-export-list'; list.onchange = event => { const input = event.target, index = Number(input.value); if (input.checked) selected.add(index); else selected.delete(index); - yes.disabled = !selected.size; + validate(); }; conversations.forEach((item, index) => { const label = document.createElement('label'); label.className = 'dmd-check'; - const input = document.createElement('input'); input.type = 'checkbox'; input.value = String(index); + const input = document.createElement('input'); input.type = 'checkbox'; input.value = String(index); input.checked = selected.has(index); label.append(input, document.createTextNode(item.label || item.channelId)); list.append(label); }); - all.onclick = () => { list.querySelectorAll('input').forEach(input => { input.checked = true; selected.add(Number(input.value)); }); yes.disabled = !selected.size; }; + all.onclick = () => { list.querySelectorAll('input').forEach(input => { input.checked = true; selected.add(Number(input.value)); }); validate(); }; const formats = document.createElement('fieldset'); formats.className = 'dmd-export-formats'; const legend = document.createElement('legend'); legend.textContent = 'File format'; formats.append(legend); for (const value of ['json', 'csv']) { @@ -31,7 +47,31 @@ export function askExportSelection(conversations) { input.onchange = () => { if (input.checked) format = value; }; label.append(input, document.createTextNode(value.toUpperCase())); formats.append(label); } - box.append(all, list, formats); + const ranges = document.createElement('fieldset'); ranges.className = 'dmd-export-range'; + const rangeLegend = document.createElement('legend'); rangeLegend.textContent = 'Date & message range (local time)'; ranges.append(rangeLegend); + for (const [key, name] of [['minId', 'After'], ['maxId', 'Before']]) { + const field = document.createElement('div'); field.className = 'dmd-field'; + const label = document.createElement('label'); label.htmlFor = `dmd-export-${key}`; label.textContent = name; + const kind = document.createElement('select'); kind.setAttribute('aria-label', `${name} boundary type`); + for (const [value, text] of [['date', 'Date & time'], ['id', 'Message ID']]) { + const option = document.createElement('option'); option.value = value; option.textContent = text; kind.append(option); + } + kind.value = /^\d+$/.test(exportRange[key]) ? 'id' : 'date'; + const input = document.createElement('input'); input.id = label.htmlFor; + input.type = kind.value === 'date' ? 'datetime-local' : 'text'; input.step = 'any'; input.value = exportRange[key]; + boundaries.push({ input, kind }); + input.oninput = input.onchange = () => { exportRange[key] = input.value; validate(); }; + kind.onchange = () => { input.type = kind.value === 'date' ? 'datetime-local' : 'text'; input.value = ''; exportRange[key] = ''; validate(); }; + field.append(label, kind, input); ranges.append(field); + } + const clear = document.createElement('button'); clear.type = 'button'; clear.className = 'dmd-btn'; clear.textContent = 'All dates'; + clear.onclick = () => { ranges.querySelectorAll('input').forEach(input => { input.value = ''; }); exportRange = { minId: '', maxId: '' }; validate(); }; + ranges.append(clear, error); + const attachmentLabel = document.createElement('label'); attachmentLabel.className = 'dmd-check'; + const attachmentToggle = document.createElement('input'); attachmentToggle.type = 'checkbox'; attachmentToggle.id = 'dmd-export-attachments'; + attachmentToggle.onchange = () => { downloadAttachments = attachmentToggle.checked; }; + attachmentLabel.append(attachmentToggle, document.createTextNode('Download actual attachments (ZIP)')); + box.append(all, list, ranges, formats, attachmentLabel); }, }); } diff --git a/src/ui/history.js b/src/ui/history.js index be439fd..5a59d90 100644 --- a/src/ui/history.js +++ b/src/ui/history.js @@ -134,13 +134,22 @@ export function initHistory(container, { deleteConversations = async () => [] } if (entry.userId) { const open = document.createElement('button'); open.className = 'dmh-button green'; open.textContent = 'Open DM'; open.onclick = async () => { - if (!me) me = await identity(); - if (!me) return alert('Could not get a valid Discord authorization token.'); - const r = await apiFetch(`${API}/users/@me/channels`, { method: 'POST', headers: { Authorization: me.token, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient_id: entry.userId }) }); - if (!r.ok) return alert(`Discord could not reopen this DM (HTTP ${r.status}).`); - const ch = await r.json(); - merge(history, { userId: entry.userId, channelId: ch.id, sources: ['open'], sentCount: entry.sentCount, sentCountExact: entry.sentCountExact, verifiedSent: true, seenAt: new Date().toISOString() }); - saveHistory(history); location.href = `/channels/@me/${ch.id}`; + const openingHistory = history; + try { + const currentIdentity = me || await identity(); + if (history !== openingHistory) return; + me = currentIdentity; + if (!me) return alert('Could not get a valid Discord authorization token.'); + const r = await apiFetch(`${API}/users/@me/channels`, { method: 'POST', headers: { Authorization: me.token, 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient_id: entry.userId }) }); + if (history !== openingHistory) return; + if (!r.ok) return alert(`Discord could not reopen this DM (HTTP ${r.status}).`); + const ch = await r.json(); + if (history !== openingHistory) return; + merge(history, { userId: entry.userId, channelId: ch.id, sources: ['open'], sentCount: entry.sentCount, sentCountExact: entry.sentCountExact, verifiedSent: true, seenAt: new Date().toISOString() }); + saveHistory(history); location.href = `/channels/@me/${ch.id}`; + } catch (error) { + if (history === openingHistory) alert(`Discord could not reopen this DM: ${error?.message || error}`); + } }; actions.appendChild(open); } @@ -165,21 +174,24 @@ export function initHistory(container, { deleteConversations = async () => [] } async function refreshLive() { if (refreshing) return; refreshing = true; $('#dmh-refresh').disabled = true; $('#dmh-summary').textContent = 'Loading Discord DM data…'; + const refreshingHistory = history; + let dirty = 0; try { - me = await identity(); + const refreshedIdentity = await identity(); + if (history !== refreshingHistory) return; + me = refreshedIdentity; if (!me) { $('#dmh-summary').textContent = 'Could not get a valid Discord authorization token.'; return; } const openByUser = new Map(); - try { - const r = await apiFetch(`${API}/users/@me/channels`, { headers: { Authorization: me.token } }); - if (r.ok) { - const channels = await r.json(); - for (const ch of Array.isArray(channels) ? channels : []) { - if (ch?.type !== 1) continue; - const u = ch.recipients?.find?.(v => v?.id && v.id !== me.user.id); - if (u) openByUser.set(u.id, { channelId: ch.id, user: u }); - } - } - } catch {} + const r = await apiFetch(`${API}/users/@me/channels`, { headers: { Authorization: me.token } }); + if (history !== refreshingHistory) return; + if (!r.ok) throw new Error(`Could not load open DMs (HTTP ${r.status}).`); + const channels = await r.json(); + if (history !== refreshingHistory) return; + for (const ch of Array.isArray(channels) ? channels : []) { + if (ch?.type !== 1) continue; + const u = ch.recipients?.find?.(v => v?.id && v.id !== me.user.id); + if (u) openByUser.set(u.id, { channelId: ch.id, user: u }); + } const candidates = new Map(); for (const [userId, data] of openByUser) candidates.set(userId, { userId, channelId: data.channelId, user: data.user, wasOpen: true, dmRank: null, source: 'open' }); @@ -191,13 +203,14 @@ export function initHistory(container, { deleteConversations = async () => [] } const all = [...candidates.values()]; let done = 0; - let dirty = 0; for (const c of all) { + if (history !== refreshingHistory) return; done++; $('#dmh-summary').textContent = `Verifying sent-message history ${done}/${all.length}…`; const result = await verifyPerson(me.token, me.user.id, c.userId, c.channelId, c.wasOpen); + if (history !== refreshingHistory) return; if (!result || result.count === null) { await sleep(200); continue; @@ -237,8 +250,14 @@ export function initHistory(container, { deleteConversations = async () => [] } await sleep(250); } + if (history !== refreshingHistory) return; if (dirty) saveHistory(history); render(); + } catch (error) { + if (history !== refreshingHistory) return; + if (dirty) saveHistory(history); + render(false); + $('#dmh-summary').textContent = `Refresh stopped: ${error?.message || error}`; } finally { $('#dmh-refresh').disabled = false; refreshing = false; } } diff --git a/src/utils/abort.js b/src/utils/abort.js new file mode 100644 index 0000000..970a65f --- /dev/null +++ b/src/utils/abort.js @@ -0,0 +1,10 @@ +// Stop waiting without cancelling a shared cooldown used by other callers. +export function waitWithSignal(promise, signal) { + if (!signal) return promise; + return new Promise((resolve, reject) => { + const abort = () => { cleanup(); reject(signal.reason || new DOMException('Stopped.', 'AbortError')); }; + const cleanup = () => signal.removeEventListener('abort', abort); + promise.then(value => { cleanup(); resolve(value); }, error => { cleanup(); reject(error); }); + if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true }); + }); +} diff --git a/src/utils/timing.js b/src/utils/timing.js index 67f6558..b586206 100644 --- a/src/utils/timing.js +++ b/src/utils/timing.js @@ -1,6 +1,11 @@ // Del-Discord v1 — personal source modules. -export const sleep = ms => new Promise(r => setTimeout(r, ms)); +// Browsers clamp overflowing timer delays to a few milliseconds. Split long +// server cooldowns so an unusually large Retry-After never becomes an early retry. +export const sleep = ms => { + const chunk = Math.min(ms, 2147483647); + return new Promise(resolve => setTimeout(resolve, chunk)).then(() => ms > chunk ? sleep(ms - chunk) : undefined); +}; export const rand = (a, b) => { const range = b - a + 1; diff --git a/src/utils/zip.js b/src/utils/zip.js new file mode 100644 index 0000000..ac9c41f --- /dev/null +++ b/src/utils/zip.js @@ -0,0 +1,97 @@ +import { RunStoppedError } from '../discord/api.js'; +import { sleep } from './timing.js'; + +const crcTable = Uint32Array.from({ length: 256 }, (_, index) => { + for (let bit = 0; bit < 8; bit++) index = (index >>> 1) ^ ((index & 1) ? 0xedb88320 : 0); + return index; +}); + +export class ZipCapacityError extends Error { + constructor(message) { super(message); this.name = 'ZipCapacityError'; } +} + +const checkCapacity = (count, size) => { + if (count >= 65535) throw new ZipCapacityError('Too many files for one ZIP. Export fewer conversations or a smaller date range.'); + if (size >= 0xffffffff) throw new ZipCapacityError('ZIP exceeds 4 GB. Export fewer conversations or a smaller date range.'); +}; + +const zipPath = (name, size) => { + if (!name || name.includes('\\') || name.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('Invalid ZIP file path.'); + if (!Number.isSafeInteger(size) || size < 0) throw new Error('Invalid ZIP file size.'); + const path = new TextEncoder().encode(name); + if (path.length > 65535 || size >= 0xffffffff) throw new ZipCapacityError('ZIP exceeds 4 GB. Export fewer conversations or a smaller date range.'); + return path; +}; + +// Reserve the two metadata files, then update their sizes as conversations are +// serialized. Attachment downloads can check capacity before reading a body. +export function createZipBudget() { + const entries = new Map(); + let size = 22; + const checkFile = (name, bytes) => { + const previous = entries.get(name); + const entrySize = 76 + 2 * zipPath(name, bytes).length + bytes; + const nextSize = size - (previous ?? 0) + entrySize; + checkCapacity(entries.size + (previous === undefined ? 1 : 0), nextSize); + return { entrySize, nextSize }; + }; + return { + checkFile(name, bytes) { checkFile(name, bytes); }, + setFile(name, bytes) { + const { entrySize, nextSize } = checkFile(name, bytes); + entries.set(name, entrySize); size = nextSize; + }, + }; +} + +// Stored ZIP entries keep already-compressed media intact. Read small slices +// for checksums rather than holding another copy of every attachment in memory. +export async function filesToZip(files, stopCheck = () => false) { + const check = () => { if (stopCheck()) throw new RunStoppedError(); }; + check(); + checkCapacity(files.length, 22); + // Validate the whole archive before reading/checksumming any payloads. + const paths = []; + let archiveSize = 22, yieldedAt = performance.now(); + for (const { name, blob } of files) { + check(); + const path = zipPath(name, blob.size); + paths.push(path); + archiveSize += 76 + 2 * path.length + blob.size; + checkCapacity(files.length, archiveSize); + if (performance.now() - yieldedAt >= 8) { await sleep(0); check(); yieldedAt = performance.now(); } + } + const parts = [], central = []; + let offset = 0, centralSize = 0; + for (const [index, { blob }] of files.entries()) { + check(); + const path = paths[index]; + let crc = 0xffffffff; + for (let start = 0; start < blob.size; start += 65536) { + check(); + const bytes = new Uint8Array(await blob.slice(start, start + 65536).arrayBuffer()); + for (let index = 0; index < bytes.length; index++) crc = (crc >>> 8) ^ crcTable[(crc ^ bytes[index]) & 255]; + if (performance.now() - yieldedAt >= 8) { await sleep(0); check(); yieldedAt = performance.now(); } + } + check(); crc = (crc ^ 0xffffffff) >>> 0; + const local = new Uint8Array(30), localView = new DataView(local.buffer); + localView.setUint32(0, 0x04034b50, true); localView.setUint16(4, 20, true); + localView.setUint16(6, 0x800, true); localView.setUint16(12, 33, true); // UTF-8, Jan 1 1980 + localView.setUint32(14, crc, true); localView.setUint32(18, blob.size, true); + localView.setUint32(22, blob.size, true); localView.setUint16(26, path.length, true); + const entry = new Uint8Array(46), view = new DataView(entry.buffer); + view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); + view.setUint16(8, 0x800, true); view.setUint16(14, 33, true); + view.setUint32(16, crc, true); view.setUint32(20, blob.size, true); view.setUint32(24, blob.size, true); + view.setUint16(28, path.length, true); view.setUint32(42, offset, true); + parts.push(local, path, blob); central.push(entry, path); + offset += local.length + path.length + blob.size; centralSize += entry.length + path.length; + if (performance.now() - yieldedAt >= 8) { await sleep(0); check(); yieldedAt = performance.now(); } + } + check(); + checkCapacity(files.length, offset + centralSize + 22); + const end = new Uint8Array(22), view = new DataView(end.buffer); + view.setUint32(0, 0x06054b50, true); view.setUint16(8, files.length, true); view.setUint16(10, files.length, true); + view.setUint32(12, centralSize, true); view.setUint32(16, offset, true); + return new Blob([...parts, ...central, end], { type: 'application/zip' }); +} diff --git a/tests/confirm.test.mjs b/tests/confirm.test.mjs new file mode 100644 index 0000000..9e2864c --- /dev/null +++ b/tests/confirm.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { askPopup } from '../src/ui/confirm.js'; + +function mount(t) { + const dom = new JSDOM('
'); + const previous = globalThis.document; + globalThis.document = dom.window.document; + t.after(() => { if (previous) globalThis.document = previous; else delete globalThis.document; dom.window.close(); }); + return dom; +} + +test('confirmation shortcuts approve only the top dialog and leave the underlying action pending', async t => { + const dom = mount(t), doc = dom.window.document; + doc.querySelector('#origin').focus(); + let deletionApproved = false; + const deletion = askPopup({ title: 'Delete messages?', danger: true }).then(value => { deletionApproved = value; return value; }); + const deleteButton = doc.querySelector('.dmd-confirm-actions button:last-child'); + const queued = askPopup({ title: 'Add to Queue?' }); + const event = new dom.window.KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, cancelable: true }); + doc.dispatchEvent(event); + assert.equal(await queued, true); + assert.equal(deletionApproved, false); + assert.equal(doc.querySelectorAll('.dmd-confirm-overlay').length, 1); + assert.equal(doc.activeElement, deleteButton); + assert.equal(event.defaultPrevented, true); + doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + assert.equal(await deletion, false); + assert.equal(doc.activeElement, doc.querySelector('#origin')); +}); + +test('Escape dismisses only the top dialog and Tab stays inside it', async t => { + const dom = mount(t), doc = dom.window.document; + const underlying = askPopup({ title: 'Export?' }); + const top = askPopup({ title: 'Add to Queue?' }); + const dialogs = doc.querySelectorAll('.dmd-confirm-box'); + doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Tab', cancelable: true })); + assert.equal(doc.activeElement, dialogs[1].querySelector('button')); + doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', cancelable: true })); + assert.equal(await top, false); + assert.equal(doc.querySelectorAll('.dmd-confirm-box').length, 1); + doc.querySelector('.dmd-confirm-actions button:first-child').click(); + assert.equal(await underlying, false); +}); diff --git a/tests/export-attachments.test.mjs b/tests/export-attachments.test.mjs new file mode 100644 index 0000000..d3ce7b1 --- /dev/null +++ b/tests/export-attachments.test.mjs @@ -0,0 +1,185 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { collectExportAttachments, createAttachmentDownloadState } from '../src/features/export-attachments.js'; +import { createZipBudget, filesToZip, ZipCapacityError } from '../src/utils/zip.js'; +import { RunStoppedError } from '../src/discord/api.js'; + +const conversation = { channelId: 'channel', messages: [{ id: 'message', raw: { attachments: [ + { id: '1', filename: '../photo.png', url: 'https://cdn.discordapp.com/attachments/a' }, + { id: '2', filename: '../photo.png', url: 'https://cdn.discordapp.com/attachments/b' }, + { id: '3', filename: 'expired.png', url: 'https://cdn.discordapp.com/attachments/expired' }, +] } }] }; + +test('attachment downloads preserve binary files, isolate credentials and report failures without losing other files', async t => { + const calls = [], files = [], report = [], logs = []; + const bytes = new Uint8Array([0, 255, 128, 13, 10]); + t.mock.method(globalThis, 'fetch', async (url, options) => { + calls.push({ url, options }); + return url.endsWith('expired') ? new Response('', { status: 404 }) : new Response(bytes); + }); + await collectExportAttachments(conversation, { files, report, log: (...args) => logs.push(args) }); + assert.equal(files.length, 2); assert.notEqual(files[0].name, files[1].name); + assert.ok(files.every(file => !file.name.split('/').includes('..'))); + assert.deepEqual(new Uint8Array(await files[0].blob.arrayBuffer()), bytes); + assert.deepEqual(report.map(item => item.status), ['downloaded', 'downloaded', 'failed']); + assert.equal(report[2].path, null); assert.match(report[2].error, /404/); + assert.ok(logs.some(([level]) => level === 'warn')); + assert.ok(calls.every(({ options }) => !options.headers && options.credentials === 'omit')); +}); + +test('stopping during an attachment response prevents saving it and requesting subsequent attachments', async t => { + let stopped = false, calls = 0; + const files = [], report = []; + t.mock.method(globalThis, 'fetch', async () => { calls++; stopped = true; return new Response('data'); }); + await assert.rejects(collectExportAttachments(conversation, { files, report, log: () => {}, stopCheck: () => stopped }), RunStoppedError); + assert.equal(calls, 1); assert.equal(files.length, 0); assert.equal(report.length, 0); +}); + +const attachment = (id, extra = {}) => ({ id, filename: 'photo.png', url: `https://cdn.discordapp.com/attachments/${id}`, ...extra }); + +test('forwarded attachments get distinct paths and report sources while reusing downloaded files', async t => { + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + const requests = [], files = [], report = []; + t.mock.method(globalThis, 'fetch', async url => { requests.push(url); return new Response(url); }); + const data = { channelId: 'channel', messages: [{ id: 'message', raw: { + attachments: [attachment('top')], + message_snapshots: [ + { message: { attachments: [attachment('snapshot-a'), attachment('top', { url: 'https://cdn.discordapp.com/attachments/top?hm=fresh' })] } }, + { message: { attachments: [attachment('snapshot-b')] } }, + ], + } }] }; + await collectExportAttachments(data, { files, report, budget: createZipBudget(), log: () => {} }); + assert.equal(requests.length, 3); + assert.deepEqual(files.map(file => file.name), [ + 'attachments/channel/message/1-photo.png', + 'attachments/channel/message/snapshots/1/1-photo.png', + 'attachments/channel/message/snapshots/2/1-photo.png', + ]); + assert.deepEqual(report.map(item => item.source), [ + 'attachments', 'message_snapshots[0].message.attachments', 'message_snapshots[0].message.attachments', 'message_snapshots[1].message.attachments', + ]); + assert.deepEqual(report.map(item => item.status), ['downloaded', 'downloaded', 'reused', 'downloaded']); + assert.equal(report[2].path, report[0].path); + assert.equal(await files[1].blob.text(), attachment('snapshot-a').url); +}); + +test('thread starter attachments are downloaded without expanding ordinary reply references', async t => { + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + const requests = [], files = [], report = []; + t.mock.method(globalThis, 'fetch', async url => { requests.push(url); return new Response('starter'); }); + const data = { channelId: 'thread', messages: [ + { id: 'starter', raw: { type: 21, attachments: [], referenced_message: { attachments: [attachment('starter-file')] } } }, + { id: 'reply', raw: { type: 19, attachments: [], referenced_message: { attachments: [attachment('out-of-range')] } } }, + ] }; + await collectExportAttachments(data, { files, report, log: () => {} }); + assert.deepEqual(requests, [attachment('starter-file').url]); + assert.equal(files[0].name, 'attachments/thread/starter/referenced-message/1-photo.png'); + assert.equal(report[0].source, 'referenced_message.attachments'); + assert.equal(report[0].messageId, 'starter'); +}); + +test('thread starters preserve forwarded files from their referenced parent without following its replies', async t => { + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + const requests = [], files = [], report = []; + t.mock.method(globalThis, 'fetch', async url => { requests.push(url); return new Response('forward'); }); + const data = { channelId: 'thread', messages: [{ id: 'starter', raw: { + type: 21, attachments: [], referenced_message: { + type: 0, attachments: [], message_snapshots: [{ message: { attachments: [attachment('parent-forward')] } }], + referenced_message: { attachments: [attachment('unrelated-reply')] }, + }, + } }] }; + await collectExportAttachments(data, { files, report, log: () => {} }); + assert.deepEqual(requests, [attachment('parent-forward').url]); + assert.equal(files[0].name, 'attachments/thread/starter/referenced-message/snapshots/1/1-photo.png'); + assert.equal(report[0].source, 'referenced_message.message_snapshots[0].message.attachments'); +}); + +test('forwarded attachments retain capacity checks and cancellation before saving files', async t => { + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + let requests = 0, stopped = false; + t.mock.method(globalThis, 'fetch', async () => { requests++; stopped = true; return new Response('file'); }); + const files = [], report = [], file = attachment('snapshot', { size: 0xffffffff }); + const data = { channelId: 'channel', messages: [{ id: 'message', raw: { + attachments: [], message_snapshots: [{ message: { attachments: [file, attachment('next')] } }], + } }] }; + const context = { files, report, budget: createZipBudget(), log: () => {}, stopCheck: () => stopped }; + await assert.rejects(collectExportAttachments(data, context), ZipCapacityError); + assert.equal(requests, 0); + file.size = 4; + await assert.rejects(collectExportAttachments(data, context), RunStoppedError); + assert.equal(requests, 1); assert.equal(files.length, 0); assert.equal(report.length, 0); +}); + +test('stored ZIP preserves Unicode paths, binary payloads, checksums and central-directory offsets', async () => { + const payloads = [new Blob(['123456789']), new Blob([new Uint8Array([0, 255, 128])]), new Blob([])]; + const files = ['messages.json', 'attachments/😀.png', 'empty'].map((name, index) => ({ name, blob: payloads[index] })); + const zip = await filesToZip(files), bytes = new Uint8Array(await zip.arrayBuffer()); + const view = new DataView(bytes.buffer), decoder = new TextDecoder(); + assert.equal(zip.type, 'application/zip'); + const end = bytes.length - 22; + assert.equal(view.getUint32(end, true), 0x06054b50); assert.equal(view.getUint16(end + 10, true), 3); + let central = view.getUint32(end + 16, true); + for (const [index, file] of files.entries()) { + assert.equal(view.getUint32(central, true), 0x02014b50); + const offset = view.getUint32(central + 42, true), length = view.getUint16(central + 28, true); + assert.equal(view.getUint32(offset, true), 0x04034b50); + assert.equal(decoder.decode(bytes.slice(central + 46, central + 46 + length)), file.name); + assert.equal(view.getUint16(offset + 6, true), 0x800); + assert.equal(view.getUint32(offset + 18, true), file.blob.size); + assert.deepEqual(bytes.slice(offset + 30 + length, offset + 30 + length + file.blob.size), new Uint8Array(await file.blob.arrayBuffer())); + if (index === 0) assert.equal(view.getUint32(offset + 14, true), 0xcbf43926); + assert.equal(view.getUint32(central + 16, true), view.getUint32(offset + 14, true)); + central += 46 + length; + } + assert.equal(central, end); + await assert.rejects(filesToZip(files, () => true), RunStoppedError); + await assert.rejects(filesToZip([{ name: 'large', blob: { size: 0xffffffff } }]), /4 GB/); +}); + +test('ZIP validation yields and can stop before reading file payloads', async t => { + let clock = 0, stopped = false, turns = 0, reads = 0; + t.mock.method(performance, 'now', () => clock += 10); + t.mock.method(globalThis, 'setTimeout', callback => { turns++; stopped = true; callback(); return 0; }); + const blob = { size: 1, slice: () => { reads++; throw new Error('Payload should not be read'); } }; + await assert.rejects(filesToZip([{ name: 'a', blob }, { name: 'b', blob }], () => stopped), RunStoppedError); + assert.equal(turns, 1); assert.equal(reads, 0); +}); + +test('empty ZIP entries yield and remain cancellable after validation', async t => { + const files = ['a', 'b', 'c'].map(name => ({ name, blob: new Blob([]) })); + let clockReads = 0, stopped = false, turns = 0; + // Validation takes less than one frame; entry creation consumes the budget. + t.mock.method(performance, 'now', () => ++clockReads <= files.length + 1 ? 0 : 10); + t.mock.method(globalThis, 'setTimeout', callback => { turns++; stopped = true; callback(); return 0; }); + await assert.rejects(filesToZip(files, () => stopped), RunStoppedError); + assert.equal(turns, 1); +}); + +for (const status of ['reused', 'skipped']) test(`${status} attachments yield and stop without making network requests`, async t => { + let clockReads = 0, stopped = false, turns = 0, requests = 0; + t.mock.method(performance, 'now', () => ++clockReads <= 3 ? 0 : 10); + t.mock.method(globalThis, 'setTimeout', callback => { turns++; stopped = true; callback(); return 0; }); + t.mock.method(globalThis, 'fetch', () => { requests++; throw new Error('No download expected'); }); + const state = createAttachmentDownloadState(), files = [], report = []; + if (status === 'skipped') state.suspendedReason = 'Attachment requests suspended.'; + else state.cache.set('https://cdn.discordapp.com/file', { status: 'downloaded', path: 'attachments/file', size: 1 }); + const data = { channelId: '1', messages: [{ id: '2', attachments: Array.from({ length: 3 }, () => ({ + filename: 'file.png', url: 'https://cdn.discordapp.com/file', + })) }] }; + await assert.rejects(collectExportAttachments(data, { state, files, report, log: () => {}, stopCheck: () => stopped }), RunStoppedError); + assert.equal(turns, 1); assert.equal(requests, 0); assert.equal(files.length, 0); + assert.equal(report.length, 1); assert.equal(report[0].status, status); +}); + +test('ZIP checksums remain correct across bounded payload slices', async () => { + const payload = Uint8Array.from({ length: 65536 * 2 + 7 }, (_, index) => index * 31 & 255); + let crc = 0xffffffff; + for (const byte of payload) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + const zip = await filesToZip([{ name: 'binary', blob: new Blob([payload]) }]); + const view = new DataView(await zip.arrayBuffer()); + assert.equal(view.getUint32(14, true), (crc ^ 0xffffffff) >>> 0); + assert.equal(view.getUint32(18, true), payload.length); +}); diff --git a/tests/export-capacity.test.mjs b/tests/export-capacity.test.mjs new file mode 100644 index 0000000..ef675f9 --- /dev/null +++ b/tests/export-capacity.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { createZipBudget, ZipCapacityError } from '../src/utils/zip.js'; +import { collectExportAttachments } from '../src/features/export-attachments.js'; + +test('ZIP budget counts UTF-8 paths, required entries and replacement sizes exactly', () => { + const budget = createZipBudget(); + const cost = name => 76 + 2 * new TextEncoder().encode(name).length; + budget.setFile('messages.json', 100); + budget.setFile('attachments.json', 2); + const name = 'attachments/😀.png'; + const size = 0xffffffff - 1 - 22 - cost('messages.json') - 100 - cost('attachments.json') - 2 - cost(name); + budget.checkFile(name, size); + budget.setFile(name, size); + budget.setFile(name, size); // Updating an entry must not count it twice. + assert.throws(() => budget.checkFile('extra', 0), ZipCapacityError); + assert.throws(() => budget.setFile('messages.json', 101), ZipCapacityError); + budget.setFile(name, 0); // Failed reservations must not corrupt the budget. + budget.setFile('extra', 10); + assert.throws(() => budget.checkFile('oversized', 0xffffffff), /4 GB/); +}); + +test('ZIP budget reserves required file slots before attachment downloads', () => { + const budget = createZipBudget(); + budget.setFile('messages.json', 0); + budget.setFile('attachments.json', 2); + for (let index = 0; index < 65532; index++) budget.setFile(`attachment-${index}`, 0); + assert.throws(() => budget.checkFile('overflow', 0), /Too many files/); + budget.setFile('messages.json', 100); +}); + +for (const source of ['metadata', 'header', 'payload']) test(`oversized attachment ${source} stops before retaining data or requesting another file`, async t => { + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + let requests = 0, bodyReads = 0; + t.mock.method(globalThis, 'fetch', async () => { + requests++; + return { + ok: true, status: 200, + headers: new Headers(source === 'header' ? { 'Content-Length': String(0xffffffff) } : {}), + blob: async () => { bodyReads++; return { size: 0xffffffff }; }, + }; + }); + const files = [], report = [], budget = createZipBudget(); + budget.setFile('messages.json', 0); budget.setFile('attachments.json', 2); + const attachment = { filename: 'large.bin', url: 'https://cdn.discordapp.com/large', ...(source === 'metadata' ? { size: 0xffffffff } : {}) }; + await assert.rejects(collectExportAttachments({ channelId: 'channel', messages: [{ id: 'message', attachments: [attachment, attachment] }] }, + { files, report, budget, log: () => {} }), ZipCapacityError); + assert.equal(requests, source === 'metadata' ? 0 : 1); + assert.equal(bodyReads, source === 'payload' ? 1 : 0); + assert.equal(files.length, 0); assert.equal(report.length, 0); +}); + +test('bundled export rejects an impossible attachment before its download and unlocks the panel', async t => { + const channelId = '345678901234567890'; + const dom = new JSDOM('
', { url: `https://discord.com/channels/@me/${channelId}`, runScripts: 'outside-only', pretendToBeVisual: true }); + t.after(() => dom.window.close()); + const doc = dom.window.document, downloads = []; + let attachmentRequests = 0; + dom.window.Blob = Blob; dom.window.TextEncoder = TextEncoder; + dom.window.URL.createObjectURL = blob => { downloads.push(blob); return 'blob:test'; }; + dom.window.URL.revokeObjectURL = () => {}; + dom.window.HTMLAnchorElement.prototype.click = () => {}; + dom.window.setTimeout = callback => { callback(); return 0; }; + dom.window.fetch = async url => { + if (url.endsWith('/users/@me')) return Response.json({ id: 'self' }); + if (url.startsWith('https://cdn.discordapp.com/')) { attachmentRequests++; return new Response('unexpected'); } + return Response.json(new URL(url).searchParams.has('before') ? [] : [{ + id: '456789012345678901', attachments: [{ filename: 'large.bin', size: 0xffffffff, url: 'https://cdn.discordapp.com/large' }], + }]); + }; + vm.runInContext(await readFile('dist/Del-Discord-v1.user.js', 'utf8'), dom.getInternalVMContext()); + doc.dispatchEvent(new dom.window.Event('DOMContentLoaded')); + doc.querySelector('#dmd-token').value = 'capacity-test'; + const task = doc.querySelector('#dmd-export-conversation').onclick(); + const popup = doc.querySelector('.dmd-confirm-box'); assert.ok(popup); + popup.querySelector('#dmd-export-attachments').click(); + popup.querySelector('.dmd-confirm-actions button:last-child').click(); + await task; + assert.equal(attachmentRequests, 0); assert.equal(downloads.length, 0); + assert.match(doc.querySelector('#dmd-log').textContent, /4 GB/); + assert.equal(doc.querySelector('#dmd-export-conversation').disabled, false); + assert.equal(doc.querySelector('#dmd-stop').disabled, true); +}); diff --git a/tests/export-performance.test.mjs b/tests/export-performance.test.mjs index c11e8bf..ccffa30 100644 --- a/tests/export-performance.test.mjs +++ b/tests/export-performance.test.mjs @@ -6,6 +6,7 @@ import { readFile } from 'node:fs/promises'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; import { parseCsv } from '../src/utils/csv.js'; +import { toSnowflake } from '../src/utils/messages.js'; const conversation = count => ({ exportedAt: '2026-10-09T12:00:00.000Z', guildId: '@me', channelId: '123456789012345678', @@ -43,12 +44,16 @@ test('large serialization yields to the event loop and cancellation discards the await assert.rejects(conversationToBlob(conversation(0), { stopCheck: () => true }), RunStoppedError); }); -test('bundled queue CSV export combines empty and nonempty conversations with a single header', async t => { +for (const withAttachments of [false, true]) test(`bundled queue CSV export applies dates and attachment toggle (${withAttachments})`, async t => { const emptyChannel = '234567890123456789', dataChannel = '345678901234567890'; + const after = '2026-01-01T00:00', before = '2026-02-01T00:00'; + const messageId = toSnowflake('2026-01-15T00:00'), requests = []; const dom = new JSDOM('
', { url: `https://discord.com/channels/@me/${dataChannel}`, runScripts: 'outside-only', pretendToBeVisual: true }); t.after(() => dom.window.close()); const doc = dom.window.document, downloads = [], methods = []; dom.window.Blob = Blob; + dom.window.TextEncoder = TextEncoder; + const attachmentCalls = []; dom.window.URL.createObjectURL = blob => { downloads.push(blob); return 'blob:test'; }; dom.window.URL.revokeObjectURL = () => {}; dom.window.HTMLAnchorElement.prototype.click = () => {}; @@ -59,10 +64,12 @@ test('bundled queue CSV export combines empty and nonempty conversations with a ])); dom.window.fetch = async (url, options) => { methods.push(options?.method || 'GET'); + if (url.startsWith('https://cdn.discordapp.com/')) { attachmentCalls.push(options); return new Response(new Uint8Array([0, 255, 128])); } if (url.endsWith('/users/@me')) return Response.json({ id: 'self' }); const request = new URL(url); - if (request.pathname.includes(dataChannel) && !request.searchParams.has('before')) return Response.json([{ - id: '456789012345678901', type: 0, content: '😀, "quote"\nnext line', author: { id: 'self' }, + requests.push(request); + if (request.pathname.includes(dataChannel) && request.searchParams.get('before') === toSnowflake(before)) return Response.json([{ + id: messageId, type: 0, content: '😀, "quote"\nnext line', author: { id: 'self' }, poll: { question: { text: 'Which?' } }, attachments: [{ id: 'file', filename: 'photo.png', url: 'https://cdn.discordapp.com/attachments/file/photo.png' }], }]); return Response.json([]); }; @@ -74,16 +81,104 @@ test('bundled queue CSV export combines empty and nonempty conversations with a const popup = doc.querySelector('.dmd-confirm-box'); assert.ok(popup); popup.querySelector('.dmd-export-list').previousElementSibling.click(); + for (const [id, value] of [['minId', after], ['maxId', before]]) { + const input = popup.querySelector(`#dmd-export-${id}`); + input.value = value; input.dispatchEvent(new dom.window.Event('input')); + } popup.querySelector('input[value="csv"]').click(); + assert.equal(popup.querySelector('#dmd-export-attachments').checked, false); + if (withAttachments) popup.querySelector('#dmd-export-attachments').click(); popup.querySelectorAll('.dmd-confirm-actions button')[1].click(); await task; assert.equal(downloads.length, 1); - assert.equal(downloads[0].type, 'text/csv;charset=utf-8'); - const rows = parseCsv(await downloads[0].text()); + let csv = downloads[0]; + if (withAttachments) { + assert.equal(csv.type, 'application/zip'); + const bytes = new Uint8Array(await csv.arrayBuffer()), view = new DataView(bytes.buffer), entries = new Map(); + for (let offset = 0; view.getUint32(offset, true) === 0x04034b50;) { + const size = view.getUint32(offset + 18, true), length = view.getUint16(offset + 26, true); + const name = new TextDecoder().decode(bytes.slice(offset + 30, offset + 30 + length)); + const start = offset + 30 + length; + entries.set(name, bytes.slice(start, start + size)); offset = start + size; + } + csv = new Blob([[...entries].find(([name]) => name.endsWith('.csv'))[1]]); + assert.deepEqual(entries.get(`attachments/${dataChannel}/${messageId}/1-photo.png`), new Uint8Array([0, 255, 128])); + const report = JSON.parse(new TextDecoder().decode(entries.get('attachments.json'))); + assert.equal(report[0].status, 'downloaded'); assert.equal(report[0].size, 3); + assert.equal(attachmentCalls.length, 1); assert.equal(attachmentCalls[0].credentials, 'omit'); + assert.equal(attachmentCalls[0].headers, undefined); + } else { + assert.equal(csv.type, 'text/csv;charset=utf-8'); assert.equal(attachmentCalls.length, 0); + } + const rows = parseCsv(await csv.text()); assert.equal(rows.length, 2); assert.equal(rows[1][1], dataChannel); assert.equal(rows[1][2], 'With text'); assert.equal(rows[1][8], '😀, "quote"\nnext line'); + assert.equal(JSON.parse(rows[1][rows[0].indexOf('messageData')]).poll.question.text, 'Which?'); + assert.equal(requests[0].searchParams.get('before'), toSnowflake(before)); + assert.equal(requests[1].searchParams.get('before'), toSnowflake(before)); + assert.equal(requests[2].searchParams.get('before'), messageId); assert.equal(JSON.parse(dom.window.localStorage.getItem('del_discord_v1_queue')).length, 2); assert.ok(methods.every(method => method === 'GET')); }); + +test('bundled ZIP export includes forwarded and thread-starter files with a complete manifest', async t => { + const channelId = '345678901234567890'; + const starterId = '456789012345678901', forwardId = '456789012345678902'; + const starter = { id: 'starter-file', filename: 'starter.bin', url: 'https://cdn.discordapp.com/attachments/starter.bin' }; + const forwarded = { id: 'forward-file', filename: 'forwarded.bin', url: 'https://cdn.discordapp.com/attachments/forwarded.bin' }; + const messages = [ + { id: forwardId, type: 0, flags: 16384, attachments: [], message_reference: { type: 1, channel_id: '234567890123456789', message_id: '456789012345678900' }, + message_snapshots: [{ message: { type: 0, content: 'Forwarded files', attachments: [forwarded, starter] } }] }, + { id: starterId, type: 21, attachments: [], referenced_message: { id: '456789012345678899', type: 0, attachments: [starter] } }, + ]; + const dom = new JSDOM('
', { url: `https://discord.com/channels/@me/${channelId}`, runScripts: 'outside-only', pretendToBeVisual: true }); + t.after(() => dom.window.close()); + const doc = dom.window.document, downloads = [], attachmentRequests = []; + dom.window.Blob = Blob; dom.window.TextEncoder = TextEncoder; + dom.window.setTimeout = callback => { callback(); return 0; }; + dom.window.URL.createObjectURL = blob => { downloads.push(blob); return 'blob:test'; }; + dom.window.URL.revokeObjectURL = () => {}; + dom.window.HTMLAnchorElement.prototype.click = () => {}; + dom.window.fetch = async (url, options) => { + assert.equal(options.method || 'GET', 'GET'); + if (url.startsWith('https://cdn.discordapp.com/')) { + attachmentRequests.push(url); + assert.equal(options.credentials, 'omit'); assert.equal(options.headers, undefined); + return new Response(Uint8Array.of(url === starter.url ? 1 : 2, 0, 255)); + } + if (url.endsWith('/users/@me')) return Response.json({ id: 'self' }); + assert.equal(new URL(url).pathname, `/api/v10/channels/${channelId}/messages`); + return Response.json(new URL(url).searchParams.has('before') ? [] : messages); + }; + vm.runInContext(await readFile('dist/Del-Discord-v1.user.js', 'utf8'), dom.getInternalVMContext()); + doc.dispatchEvent(new dom.window.Event('DOMContentLoaded')); + doc.querySelector('#dmd-token').value = 'nested-export'; + const task = doc.querySelector('#dmd-export-conversation').onclick(); + const popup = doc.querySelector('.dmd-confirm-box'); assert.ok(popup); + popup.querySelector('#dmd-export-attachments').click(); + popup.querySelector('.dmd-confirm-actions button:last-child').click(); + await task; + assert.deepEqual(attachmentRequests, [starter.url, forwarded.url]); + assert.equal(downloads.length, 1); + const bytes = new Uint8Array(await downloads[0].arrayBuffer()), view = new DataView(bytes.buffer); + const entries = new Map(), decoder = new TextDecoder(); + for (let offset = 0; view.getUint32(offset, true) === 0x04034b50;) { + const size = view.getUint32(offset + 18, true), length = view.getUint16(offset + 26, true); + const name = decoder.decode(bytes.slice(offset + 30, offset + 30 + length)), start = offset + 30 + length; + entries.set(name, bytes.slice(start, start + size)); offset = start + size; + } + const manifest = JSON.parse(decoder.decode(entries.get('attachments.json'))); + assert.equal(manifest.length, 3); assert.equal(entries.size, 4); + assert.deepEqual(manifest.map(item => item.status), ['downloaded', 'downloaded', 'reused']); + for (const item of manifest) { + assert.equal(item.channelId, channelId); + assert.deepEqual(entries.get(item.path), Uint8Array.of(item.attachmentId === starter.id ? 1 : 2, 0, 255)); + } + assert.deepEqual(manifest.map(item => item.messageId), [starterId, forwardId, forwardId]); + const saved = JSON.parse(decoder.decode([...entries].find(([name]) => name !== 'attachments.json' && name.endsWith('.json'))[1])); + assert.equal(saved.messageCount, 2); + assert.deepEqual(saved.messages.map(item => item.raw), [...messages].reverse()); + assert.match(doc.querySelector('#dmd-log').textContent, /2 downloaded; 0 unavailable/); +}); diff --git a/tests/export-review.test.mjs b/tests/export-review.test.mjs new file mode 100644 index 0000000..8ebac12 --- /dev/null +++ b/tests/export-review.test.mjs @@ -0,0 +1,180 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { apiFetch, RunStoppedError } from '../src/discord/api.js'; +import { sleep } from '../src/utils/timing.js'; +import { collectExportAttachments, createAttachmentDownloadState } from '../src/features/export-attachments.js'; +import { exportConversationData } from '../src/features/export.js'; +import { filesToZip } from '../src/utils/zip.js'; +import { askExportSelection } from '../src/ui/export-selection.js'; + +const tick = async () => { for (let i = 0; i < 100; i++) await Promise.resolve(); }; +const instantTimers = t => t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); +const message = id => ({ id, attachments: [] }); + +test('abort exits a long cooldown promptly without clearing the shared server delay', async t => { + const timers = [], controller = new AbortController(); let calls = 0; + t.mock.method(globalThis, 'setTimeout', callback => { timers.push(callback); return 0; }); + t.mock.method(globalThis, 'fetch', async () => ++calls === 1 + ? Response.json({ retry_after: 3600 }, { status: 429 }) : Response.json({})); + const options = { headers: { Authorization: 'review-cancel-cooldown' } }; + const pending = apiFetch('https://discord.com/api/cooldown', { ...options, signal: controller.signal }); + await tick(); assert.equal(timers.length, 1); + controller.abort(); await assert.rejects(pending, { name: 'AbortError' }); + const next = apiFetch('https://discord.com/api/next', options); + await tick(); assert.equal(calls, 1); + timers[0](); await next; assert.equal(calls, 2); +}); + +test('abort reaches an in-flight fetch and cancels an attachment body without saving it', async t => { + instantTimers(t); + const controller = new AbortController(); let signalSeen, bodyStarted = false; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + signalSeen = options.signal; + return { ok: true, status: 200, headers: new Headers(), blob: () => { bodyStarted = true; return new Promise(() => {}); } }; + }); + const files = [], report = []; + const task = collectExportAttachments({ channelId: '1', messages: [{ id: '2', attachments: [{ url: 'https://cdn.discordapp.com/file', filename: 'file.png' }] }] }, + { files, report, log: () => {}, signal: controller.signal }); + await tick(); assert.equal(bodyStarted, true); assert.equal(signalSeen, controller.signal); + controller.abort(); await assert.rejects(task, RunStoppedError); + assert.equal(files.length, 0); assert.equal(report.length, 0); +}); + +test('abort also interrupts a fetch that has not delivered headers', async t => { + const controller = new AbortController(); let abortSeen = false; + t.mock.method(globalThis, 'fetch', async (_url, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { abortSeen = true; reject(signal.reason); }, { once: true }); + })); + const task = apiFetch('https://discord.com/api/hanging', { signal: controller.signal }); + await tick(); controller.abort(); await assert.rejects(task, { name: 'AbortError' }); + assert.equal(abortSeen, true); +}); + +test('HTTP-date and reset-after retry hints are honored, and invalid hints keep exponential fallback', async t => { + const now = Date.now(); t.mock.method(Date, 'now', () => now); + const delays = []; let count = 0; + t.mock.method(globalThis, 'setTimeout', (callback, ms) => { delays.push(ms); callback(); return 0; }); + t.mock.method(globalThis, 'fetch', async () => ++count === 1 ? new Response('', { status: 429, headers: { + 'Retry-After': new Date(now + 20000).toUTCString(), 'X-RateLimit-Reset-After': '25' + } }) : Response.json({})); + await apiFetch('https://discord.com/api/date', { headers: { Authorization: 'review-date' } }); + assert.ok(delays[0] >= 25250 && delays[0] <= 25750); + count = 0; delays.length = 0; + t.mock.method(globalThis, 'fetch', async () => ++count <= 2 ? Response.json({ retry_after: 'invalid' }, { status: 429, headers: { 'Retry-After': 'invalid' } }) : Response.json({})); + await apiFetch('https://discord.com/api/invalid', { headers: { Authorization: 'review-invalid-hints' } }); + assert.ok(delays[0] >= 1750 && delays[1] >= 3250); +}); + +test('long sleeps are split rather than overflowing the browser timer', async t => { + const timers = []; + t.mock.method(globalThis, 'setTimeout', (callback, ms) => { timers.push({ callback, ms }); return 0; }); + const task = sleep(2147484647); + assert.equal(timers[0].ms, 2147483647); timers[0].callback(); await tick(); + assert.equal(timers[1].ms, 1000); timers[1].callback(); await task; +}); + +test('a fresh signed URL can recover an attachment whose expired URL was already tried', async t => { + instantTimers(t); let calls = 0; + t.mock.method(globalThis, 'fetch', async url => { calls++; return url.includes('hm=expired') ? new Response('', { status: 403 }) : new Response('file'); }); + const context = { files: [], report: [], state: createAttachmentDownloadState(), log: () => {} }; + for (const [index, signature] of ['expired', 'fresh', 'another-fresh'].entries()) { + await collectExportAttachments({ channelId: '1', messages: [{ id: String(index + 1), attachments: [{ filename: 'x'.repeat(100) + '.png', url: `https://cdn.discordapp.com/path/file?hm=${signature}` }] }] }, context); + } + assert.equal(calls, 2); assert.equal(context.files.length, 1); + assert.deepEqual(context.report.map(item => item.status), ['failed', 'downloaded', 'reused']); + assert.ok(context.files[0].name.endsWith('.png')); + assert.equal(context.state.consecutiveFailures, 0); +}); + +test('malformed and stalled pages fail explicitly rather than producing a successful partial export', async t => { + instantTimers(t); + const options = { channelId: '1', token: 'review-pages', log: () => {} }; + t.mock.method(globalThis, 'fetch', async () => Response.json({ unexpected: true })); + await assert.rejects(exportConversationData(options), /invalid message page/); + t.mock.method(globalThis, 'fetch', async () => Response.json([message('100'), { id: null }])); + await assert.rejects(exportConversationData(options), /malformed message/); + t.mock.method(globalThis, 'fetch', async () => Response.json([message('100')])); + await assert.rejects(exportConversationData(options), /pagination did not advance/); +}); + +test('ZIP validates the entire archive before reading any payload, and rejects unsafe paths', async () => { + let reads = 0; + const small = { size: 1, slice: () => { reads++; throw new Error('Payload should not be read'); } }; + await assert.rejects(filesToZip([{ name: 'small', blob: small }, { name: 'large', blob: { size: 0xffffffff } }]), /4 GB/); + assert.equal(reads, 0); + await assert.rejects(filesToZip([{ name: '../escape', blob: new Blob([]) }]), /Invalid ZIP file path/); +}); + +test('export dialog abort closes it, and ID boundaries cannot silently accept date strings', async t => { + const dom = new JSDOM('
'); + const previous = globalThis.document; globalThis.document = dom.window.document; + t.after(() => { if (previous) globalThis.document = previous; else delete globalThis.document; dom.window.close(); }); + const controller = new AbortController(), doc = dom.window.document; + const task = askExportSelection([{ channelId: '1' }], { select: false, signal: controller.signal }); + const input = doc.querySelector('#dmd-export-minId'), kind = input.previousElementSibling; + kind.value = 'id'; kind.dispatchEvent(new dom.window.Event('change')); + input.value = '2026-01-01'; input.dispatchEvent(new dom.window.Event('input')); + assert.equal(doc.querySelector('.dmd-confirm-actions button:last-child').disabled, true); + controller.abort(); assert.equal(await task, false); assert.equal(doc.querySelector('.dmd-confirm-overlay'), null); + assert.equal(await askExportSelection([], { signal: controller.signal }), false); +}); + +for (const stage of ['dialog', 'cooldown', 'body', 'attachment']) test(`bundled export Stop cancels ${stage}, unlocks the panel and creates no download`, async t => { + const channelId = '345678901234567890'; + const dom = new JSDOM('
', { url: `https://discord.com/channels/@me/${channelId}`, runScripts: 'outside-only', pretendToBeVisual: true }); + t.after(() => dom.window.close()); + const doc = dom.window.document, downloads = []; let bodyStarted = false; + dom.window.Blob = Blob; + dom.window.URL.createObjectURL = blob => { downloads.push(blob); return 'blob:test'; }; + dom.window.URL.revokeObjectURL = () => {}; + dom.window.HTMLAnchorElement.prototype.click = () => {}; + dom.window.localStorage.setItem('del_discord_v1_queue', JSON.stringify([{ guildId: '@me', channelId, label: 'Test' }])); + dom.window.fetch = async url => { + if (url.endsWith('/users/@me')) return Response.json({ id: 'self' }); + if (stage === 'attachment') { + if (url.startsWith('https://cdn.discordapp.com/')) return { ok: true, status: 200, headers: new Headers(), blob: () => { bodyStarted = true; return new Promise(() => {}); } }; + return Response.json(new URL(url).searchParams.has('before') ? [] : [{ id: '456789012345678901', attachments: [{ filename: 'file.png', url: 'https://cdn.discordapp.com/file' }] }]); + } + if (stage === 'cooldown') { bodyStarted = true; return Response.json({ retry_after: 3600 }, { status: 429 }); } + return { ok: true, status: 200, headers: new Headers(), json: () => { bodyStarted = true; return new Promise(() => {}); } }; + }; + vm.runInContext(await readFile('dist/Del-Discord-v1.user.js', 'utf8'), dom.getInternalVMContext()); + doc.dispatchEvent(new dom.window.Event('DOMContentLoaded')); + doc.querySelector('#dmd-token').value = 'test'; + const task = doc.querySelector('#dmd-multi-export').onclick(); + const popup = doc.querySelector('.dmd-confirm-box'); assert.ok(popup); + if (stage !== 'dialog') { + popup.querySelector('.dmd-export-list input').click(); + if (stage === 'attachment') popup.querySelector('#dmd-export-attachments').click(); + popup.querySelector('.dmd-confirm-actions button:last-child').click(); + for (let i = 0; i < 500 && !bodyStarted; i++) await new Promise(resolve => setTimeout(resolve, 10)); + assert.equal(bodyStarted, true, doc.querySelector('#dmd-log').textContent); + await new Promise(resolve => setImmediate(resolve)); + } + doc.querySelector('#dmd-multi-stop').click(); await task; + assert.equal(downloads.length, 0); + assert.equal(doc.querySelector('#dmd-multi-export').disabled, false); + assert.equal(doc.querySelector('#dmd-multi-stop').disabled, true); + assert.equal(doc.querySelector('.dmd-confirm-overlay'), null); +}); + + +test('cancelling a middle pacing waiter does not let a later request bypass an earlier one', async t => { + const { apiFetch: pacedFetch } = await import('../src/discord/api.js?review-pacing'); + const calls = [], timers = [], controller = new AbortController(); + t.mock.method(globalThis, 'setTimeout', callback => { timers.push(callback); return 0; }); + t.mock.method(globalThis, 'fetch', async url => { calls.push(url); return Response.json({}); }); + const request = (path, signal) => pacedFetch(`https://discord.com/${path}`, { signal }, () => {}, () => false, { minIntervalMs: 1000 }); + await request('first'); + const earlier = request('earlier'), cancelled = request('cancelled', controller.signal), later = request('later'); + await tick(); assert.equal(timers.length, 1); assert.equal(calls.length, 1); + controller.abort(); await assert.rejects(cancelled, { name: 'AbortError' }); + await tick(); assert.equal(calls.length, 1); assert.equal(timers.length, 1); + timers[0](); await earlier; await tick(); + assert.equal(timers.length, 2); assert.equal(calls.length, 2); + timers[1](); await later; + assert.deepEqual(calls.map(url => url.split('/').at(-1)), ['first', 'earlier', 'later']); +}); diff --git a/tests/export-safeguards.test.mjs b/tests/export-safeguards.test.mjs new file mode 100644 index 0000000..af35adb --- /dev/null +++ b/tests/export-safeguards.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { apiFetch, RateLimitError, RunStoppedError } from '../src/discord/api.js'; +import { resolveToken, invalidateAuth } from '../src/discord/deleter-auth.js'; +import { collectExportAttachments, createAttachmentDownloadState } from '../src/features/export-attachments.js'; +import { exportConversationData } from '../src/features/export.js'; + +const tick = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); }; +const item = (url, id = '1') => ({ channelId: 'channel', messages: [{ id, attachments: [{ id: 'a', filename: 'file.png', url }] }] }); +const setup = t => { + const files = [], report = [], logs = [], state = createAttachmentDownloadState(); + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + return { files, report, state, log: (...args) => logs.push(args), logs }; +}; + +test('repeated 429s stop after three attempts and retain the final shared cooldown', async t => { + let calls = 0, release; + const delays = []; + t.mock.method(globalThis, 'setTimeout', (callback, delay) => { + delays.push(delay); + if (calls === 3) release = callback; else callback(); + return 0; + }); + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return calls <= 3 ? new Response('blocked', { status: 429, headers: { 'Retry-After': '5' } }) : Response.json({}); + }); + const options = { headers: { Authorization: 'bounded-retries' } }; + await assert.rejects(apiFetch('https://discord.com/api/test', options), RateLimitError); + assert.equal(calls, 3); assert.ok(delays.every(delay => delay >= 5250)); + const next = apiFetch('https://discord.com/api/next', options); + await tick(); assert.equal(calls, 3); + release(); await next; assert.equal(calls, 4); +}); + +test('missing retry hints use increasing backoff, and JSON null cannot crash the limiter', async t => { + let calls = 0; const delays = []; + t.mock.method(globalThis, 'setTimeout', (callback, delay) => { delays.push(delay); callback(); return 0; }); + t.mock.method(globalThis, 'fetch', async () => { calls++; return Response.json(null, { status: 429 }); }); + await assert.rejects(apiFetch('https://discord.com/api/fallback', { headers: { Authorization: 'fallback-retries' } }), RateLimitError); + assert.equal(calls, 3); assert.equal(delays.length, 3); + assert.ok(delays[0] >= 1750 && delays[1] >= 3250 && delays[2] >= 6250); +}); + +test('pacing serializes API and CDN starts, including concurrent callers, and Stop prevents a waiting request', async t => { + const calls = [], timers = []; + let stopped = false; + t.mock.method(globalThis, 'fetch', async url => { calls.push(url); return Response.json({}); }); + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + await apiFetch('https://discord.com/api/first', {}, () => {}, () => false, { minIntervalMs: 1000 }); + t.mock.method(globalThis, 'setTimeout', (callback, delay) => { timers.push({ callback, delay }); return 0; }); + const next = apiFetch('https://cdn.discordapp.com/file', {}, () => {}, () => false, { minIntervalMs: 1000 }); + const cancelled = apiFetch('https://discord.com/api/cancelled', {}, () => {}, () => stopped, { minIntervalMs: 1000 }); + await tick(); assert.equal(calls.length, 1); assert.equal(timers.length, 1); + assert.ok(timers[0].delay > 0 && timers[0].delay <= 1000); + stopped = true; timers[0].callback(); + await next; await assert.rejects(cancelled, RunStoppedError); + assert.deepEqual(calls, ['https://discord.com/api/first', 'https://cdn.discordapp.com/file']); +}); + +test('attachment cache shares successful and failed URLs across conversations without repeating downloads', async t => { + const context = setup(t); let calls = 0; + t.mock.method(globalThis, 'fetch', async url => { calls++; return url.includes('/bad') ? new Response('', { status: 403 }) : new Response('bytes'); }); + await collectExportAttachments(item('https://cdn.discordapp.com/file?ex=1&is=2&hm=old'), context); + await collectExportAttachments(item('https://cdn.discordapp.com/file?hm=new&ex=3&is=4', '2'), context); + await collectExportAttachments(item('https://cdn.discordapp.com/bad'), context); + await collectExportAttachments(item('https://cdn.discordapp.com/bad', '3'), context); + assert.equal(calls, 2); assert.equal(context.files.length, 1); + assert.deepEqual(context.report.map(entry => entry.status), ['downloaded', 'reused', 'failed', 'failed']); + assert.equal(context.report[1].path, context.report[0].path); + assert.equal(context.state.consecutiveFailures, 1); +}); + +test('three consecutive failures stop subsequent attachment requests while recording skipped files', async t => { + const context = setup(t); let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { calls++; return new Response('', { status: 403 }); }); + for (let i = 0; i < 5; i++) await collectExportAttachments(item(`https://cdn.discordapp.com/${i}`, String(i)), context); + assert.equal(calls, 3); assert.equal(context.files.length, 0); + assert.deepEqual(context.report.map(entry => entry.status), ['failed', 'failed', 'failed', 'skipped', 'skipped']); + assert.match(context.report[4].error, /Three consecutive/); +}); + +test('retry exhaustion suspends all subsequent attachment requests for the export', async t => { + const context = setup(t); let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { calls++; return Response.json({ retry_after: 0 }, { status: 429 }); }); + await collectExportAttachments(item('https://cdn.discordapp.com/limited'), context); + await collectExportAttachments(item('https://media.discordapp.net/other', '2'), context); + assert.equal(calls, 3); assert.deepEqual(context.report.map(entry => entry.status), ['failed', 'skipped']); +}); + +test('message export aborts on repeated throttling and never advances to another page', async t => { + setup(t); let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { calls++; return Response.json({ retry_after: 0 }, { status: 429 }); }); + await assert.rejects(exportConversationData({ token: 'stop-message-export', channelId: '1', log: () => {} }), RateLimitError); + assert.equal(calls, 3); +}); + +test('strict export authentication uses bounded retries and stops immediately on an invalid token', async t => { + setup(t); invalidateAuth(); t.after(invalidateAuth); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { calls++; return Response.json({ retry_after: 0 }, { status: 429 }); }); + await assert.rejects(resolveToken('export-auth', { strict: true }), RateLimitError); + assert.equal(calls, 3); + t.mock.method(globalThis, 'fetch', async () => { calls++; return new Response('', { status: 401 }); }); + assert.equal(await resolveToken('invalid-auth', { strict: true }), null); assert.equal(calls, 4); +}); diff --git a/tests/export-selection.test.mjs b/tests/export-selection.test.mjs index da5d176..595cd49 100644 --- a/tests/export-selection.test.mjs +++ b/tests/export-selection.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { JSDOM } from 'jsdom'; import { askExportSelection } from '../src/ui/export-selection.js'; -import { conversationsToCsv } from '../src/features/export.js'; +import { conversationsToCsv, compactExportMessage, conversationToBlob } from '../src/features/export.js'; import { parseCsv } from '../src/utils/csv.js'; test('export dialog requires selection, supports select all and CSV, and cancels cleanly', async t => { @@ -18,7 +18,7 @@ test('export dialog requires selection, supports select all and CSV, and cancels doc.querySelector('.dmd-confirm-box > button').click(); doc.querySelector('.dmd-export-list input').click(); doc.querySelector('input[value="csv"]').click(); exportButton.click(); - assert.deepEqual(await result, { conversations: [items[1]], format: 'csv' }); + assert.deepEqual(await result, { conversations: [items[1]], format: 'csv', range: { minId: '', maxId: '' }, downloadAttachments: false }); const cancelled = askExportSelection(items); doc.querySelector('.dmd-confirm-actions button:first-child').click(); assert.equal(await cancelled, false); @@ -42,3 +42,39 @@ test('export select-all cannot enable an empty conversation export', async t => assert.equal(all.disabled, true); all.click(); assert.equal(exportButton.disabled, true); doc.querySelector('.dmd-confirm-actions button:first-child').click(); assert.equal(await result, false); }); + + +test('export dialog exposes inherited dates, rejects reversed ranges and clears to all dates', async t => { + const dom = new JSDOM('
'); + const previous = globalThis.document; globalThis.document = dom.window.document; + t.after(() => { if (previous) globalThis.document = previous; else delete globalThis.document; dom.window.close(); }); + const result = askExportSelection([{ channelId: '1' }], { select: false, range: { minId: '2026-01-01T10:00', maxId: '2026-02-01T10:00' } }); + const doc = dom.window.document, yes = doc.querySelector('.dmd-confirm-actions button:last-child'); + const after = doc.querySelector('#dmd-export-minId'), before = doc.querySelector('#dmd-export-maxId'); + assert.equal(after.type, 'datetime-local'); assert.equal(after.value, '2026-01-01T10:00'); + assert.equal(yes.disabled, false); + before.value = '2025-01-01T10:00'; before.dispatchEvent(new dom.window.Event('input')); + assert.equal(yes.disabled, true); assert.match(doc.querySelector('[role=alert]').textContent, /earlier/); + const kind = after.previousElementSibling; kind.value = 'id'; kind.dispatchEvent(new dom.window.Event('change')); + after.value = 'invalid'; after.dispatchEvent(new dom.window.Event('input')); assert.equal(yes.disabled, true); + doc.querySelector('.dmd-export-range > button').click(); + assert.equal(after.value, ''); assert.equal(before.value, ''); assert.equal(yes.disabled, false); + yes.click(); assert.deepEqual((await result).range, { minId: '', maxId: '' }); +}); + +test('JSON and CSV preserve every original Discord field, including nested and future fields', async () => { + const raw = { + id: '123456789012345678', content: 'hello', channel_id: '234567890123456789', + author: { id: '1', avatar: 'hash', public_flags: 64 }, member: { nick: 'Nickname', roles: ['role'] }, + attachments: [{ id: 'a', duration_secs: 2.5, waveform: 'wave', flags: 8 }], + reactions: [{ count: 2, count_details: { burst: 1, normal: 1 }, me_burst: true }], + referenced_message: { id: '2', attachments: [{ filename: 'reply.png' }], flags: 4 }, + poll: { question: { text: 'Which?' } }, sticker_items: [{ id: 's' }], components: [{ type: 1 }], + mention_roles: ['role'], flags: 4096, future_field: { nested: [false, 0, null] } + }; + const data = { channelId: raw.channel_id, messages: [compactExportMessage(raw)] }; + const json = JSON.parse(await (await conversationToBlob(data)).text()); + assert.deepEqual(json.messages[0].raw, raw); + const [header, row] = parseCsv(await (await conversationToBlob(data, { format: 'csv' })).text()); + assert.deepEqual(JSON.parse(row[header.indexOf('messageData')]), raw); +}); diff --git a/tests/fresh-messages.test.mjs b/tests/fresh-messages.test.mjs index aecfe80..d6155a6 100644 --- a/tests/fresh-messages.test.mjs +++ b/tests/fresh-messages.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { deleteMessages } from '../src/features/messages.js'; +import { collectFreshMessages } from '../src/features/fresh-messages.js'; +import { createMessageCollection } from '../src/features/message-collection.js'; +import { normalizeCleanupOptions } from '../src/features/message-filters.js'; +import { cleanupForumThread } from '../src/features/forum-threads.js'; import { readFile } from 'node:fs/promises'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; @@ -84,6 +88,153 @@ test('server cleanup checks fresh text channel history and respects NSFW selecti assert.ok(!calls.some(url => url.includes('345678901234567890') || url.includes('456789012345678901'))); }); +test('indexed thread fallback preserves its discovered parent NSFW restriction', async t => { + setup(t); + const threadId = '567890123456789012'; + for (const nsfw of [true, false]) { + for (const includeNsfw of [false, true]) { + const histories = [], collected = createMessageCollection('desc'); + t.mock.method(globalThis, 'fetch', async url => { + if (url.endsWith('/channels')) return Response.json([{ id: channelId, guild_id: guildId, type: 0, nsfw }]); + if (url.endsWith('/threads/active')) return Response.json({ threads: [{ id: threadId, parent_id: channelId, guild_id: guildId, type: 11 }] }); + histories.push(url); + return Response.json(url.includes(threadId) ? [message('300', { channel_id: threadId }), message('100', { channel_id: threadId })] : []); + }); + await collectFreshMessages(normalizeCleanupOptions({ ...base, guildId, channelId: undefined, includeNsfw }), + new Map([[threadId, '100']]), collected, { skipped: 0 }); + const allowed = !nsfw || includeNsfw; + assert.equal(histories.filter(url => url.includes(threadId)).length, allowed ? 1 : 0, `parent NSFW=${nsfw}, Include NSFW=${includeNsfw}`); + assert.deepEqual(collected.finish().map(item => item.id), allowed ? ['300'] : []); + } + } +}); + +test('indexed thread fallback preserves known guild and thread-type exclusions', async t => { + setup(t); + const threadId = '567890123456789012'; + for (const extra of [{ guild_id: '678901234567890123' }, { type: 0 }]) { + const histories = [], collected = createMessageCollection('desc'); + t.mock.method(globalThis, 'fetch', async url => { + if (url.endsWith('/channels')) return Response.json([{ id: channelId, guild_id: guildId, type: 0 }]); + if (url.endsWith('/threads/active')) return Response.json({ threads: [{ id: threadId, parent_id: channelId, guild_id: guildId, type: 11, ...extra }] }); + histories.push(url); + return Response.json(url.includes(threadId) ? [message('300', { channel_id: threadId }), message('100', { channel_id: threadId })] : []); + }); + await collectFreshMessages(normalizeCleanupOptions({ ...base, guildId, channelId: undefined, includeNsfw: true }), + new Map([[threadId, '100']]), collected, { skipped: 0 }); + assert.equal(histories.some(url => url.includes(threadId)), false); + assert.deepEqual(collected.finish(), []); + } +}); + +test('direct and selected forum cleanup honor the parent NSFW setting before reading messages', async t => { + setup(t); + const parentId = '345678901234567890'; + for (const type of [0, 15]) for (const nsfw of [false, true]) for (const includeNsfw of [false, true]) { + let histories = 0, writes = 0; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (options.method === 'DELETE') { writes++; return new Response(null, { status: 204 }); } + if (url.endsWith(`/channels/${channelId}`)) return Response.json({ id: channelId, type: 11, guild_id: guildId, parent_id: parentId }); + if (url.endsWith(`/channels/${parentId}`)) return Response.json({ id: parentId, type, guild_id: guildId, nsfw }); + if (url.includes('/messages?')) { histories++; return Response.json(histories === 1 ? [message('200')] : []); } + return Response.json(message('200')); + }); + const result = await cleanupForumThread({ ...base, guildId, includeNsfw, allowAnyThread: type === 0 }); + const allowed = !nsfw || includeNsfw; + assert.equal(result.done, true); + assert.equal(histories, allowed ? 2 : 0); + assert.equal(writes, allowed ? 1 : 0); + } +}); + +test('archived indexed threads resolve inherited NSFW metadata before fresh history reads', async t => { + setup(t); + const threadId = '567890123456789012'; + for (const discoveredParent of [false, true]) for (const nsfw of [false, true]) for (const includeNsfw of [false, true]) { + const histories = [], metadata = [], collected = createMessageCollection('desc'); + const parent = { id: channelId, guild_id: guildId, type: 15, nsfw }; + t.mock.method(globalThis, 'fetch', async url => { + if (url.endsWith('/channels')) return discoveredParent ? Response.json([parent]) : new Response(null, { status: 403 }); + if (url.endsWith('/threads/active')) return Response.json({ threads: [] }); + if (url.endsWith(`/channels/${threadId}`)) { metadata.push(threadId); return Response.json({ id: threadId, guild_id: guildId, parent_id: channelId, type: 11, thread_metadata: { archived: true } }); } + if (url.endsWith(`/channels/${channelId}`)) { metadata.push(channelId); return Response.json(parent); } + histories.push(url); + return Response.json([message('300', { channel_id: threadId }), message('100', { channel_id: threadId })]); + }); + await collectFreshMessages(normalizeCleanupOptions({ ...base, guildId, channelId: undefined, includeNsfw }), + new Map([[threadId, '100']]), collected, { skipped: 0 }); + const allowed = !nsfw || includeNsfw; + assert.deepEqual(metadata, discoveredParent ? [threadId] : [threadId, channelId]); + assert.equal(histories.length, allowed ? 1 : 0); + assert.deepEqual(collected.finish().map(item => item.id), allowed ? ['300'] : []); + } +}); + +test('unknown indexed metadata cannot broaden the guild or bypass denied scope checks', async t => { + setup(t); + const threadId = '567890123456789012'; + for (const mode of ['channel-denied', 'parent-denied', 'wrong-id', 'wrong-guild', 'wrong-type', 'wrong-parent-guild']) { + let histories = 0; + const logs = []; + t.mock.method(globalThis, 'fetch', async url => { + if (url.endsWith('/channels')) return Response.json([]); + if (url.endsWith('/threads/active')) return Response.json({ threads: [] }); + if (url.endsWith(`/channels/${threadId}`)) return mode === 'channel-denied' ? new Response(null, { status: 403 }) : Response.json({ + id: mode === 'wrong-id' ? channelId : threadId, guild_id: mode === 'wrong-guild' ? '678901234567890123' : guildId, + parent_id: channelId, type: mode === 'wrong-type' ? 2 : 11, + }); + if (url.endsWith(`/channels/${channelId}`)) return mode === 'parent-denied' ? new Response(null, { status: 403 }) : Response.json({ id: channelId, guild_id: mode === 'wrong-parent-guild' ? '678901234567890123' : guildId, type: 15 }); + histories++; return Response.json([]); + }); + const run = collectFreshMessages(normalizeCleanupOptions({ ...base, guildId, channelId: undefined, log: (_type, text) => logs.push(text) }), + new Map([[threadId, '100']]), createMessageCollection('desc'), { skipped: 0 }); + if (mode.endsWith('denied')) { await run; assert.ok(logs.some(text => text.includes('metadata (403)'))); } + else await assert.rejects(run, /invalid|outside|unsupported/i); + assert.equal(histories, 0); + } +}); + +test('bundled direct thread cleanup requires Include NSFW before reading or previewing its messages', async t => { + const parentId = '345678901234567890'; + for (const includeNsfw of [false, true]) { + const dom = new JSDOM('
', { url: `https://discord.com/channels/${guildId}/${channelId}`, runScripts: 'outside-only', pretendToBeVisual: true }); + t.after(() => dom.window.close()); + dom.window.setTimeout = callback => { callback(); return 0; }; + let histories = 0, writes = 0; + const target = message('200', { content: 'Message in an NSFW parent' }); + dom.window.fetch = async (url, options) => { + if (options?.method === 'DELETE') { writes++; return new Response(null, { status: 204 }); } + if (url.endsWith('/users/@me')) return Response.json({ id: 'self' }); + if (url.endsWith(`/channels/${channelId}`)) return Response.json({ id: channelId, type: 11, guild_id: guildId, parent_id: parentId }); + if (url.endsWith(`/channels/${parentId}`)) return Response.json({ id: parentId, type: 0, guild_id: guildId, nsfw: true }); + if (url.includes('/messages?')) { histories++; return Response.json(histories === 1 ? [target] : []); } + if (url.endsWith('/messages/200')) return Response.json(target); + return Response.json([]); + }; + vm.runInContext(await readFile('dist/Del-Discord-v1.user.js', 'utf8'), dom.getInternalVMContext()); + const doc = dom.window.document; + doc.dispatchEvent(new dom.window.Event('DOMContentLoaded')); + doc.querySelector('#dmd-token').value = 'test'; + doc.querySelector('#dmd-nsfw').checked = includeNsfw; + const run = doc.querySelector('#dmd-start').onclick(); + if (includeNsfw) { + for (let count = 0; count < 300 && !doc.querySelector('.dmd-confirm-box'); count++) await Promise.resolve(); + const preview = doc.querySelector('.dmd-confirm-box'); + assert.ok(preview); + assert.match(preview.textContent, /Message in an NSFW parent/); + assert.equal(writes, 0); + preview.querySelector('.dmd-red').click(); + } + await run; + assert.equal(histories, includeNsfw ? 2 : 0); + assert.equal(writes, includeNsfw ? 1 : 0); + if (!includeNsfw) { + assert.equal(doc.querySelector('.dmd-confirm-box'), null); + assert.match(doc.querySelector('#dmd-log').textContent, /parent is NSFW/); + } + } +}); + test('rebuilt userscript scans again after an empty run and previews an unindexed new message', async t => { const dom = new JSDOM('
', { url: `https://discord.com/channels/@me/${channelId}`, runScripts: 'outside-only', pretendToBeVisual: true }); t.after(() => dom.window.close()); @@ -158,3 +309,99 @@ test('fresh channel history respects NSFW filtering and rejects a mismatched ser await assert.rejects(deleteMessages({ ...base, guildId, includeNsfw: true }), /outside the selected server/); assert.equal(historyReads, 1); }); + +test('forbidden server discovery preserves indexed matches and scans their channels for newer messages', async t => { + setup(t); + const second = '345678901234567890'; + for (const denied of ['threads', 'channels', 'both']) { + const writes = [], logs = [], histories = []; + let pages = 0; + const indexed = Array.from({ length: 13 }, (_value, index) => message(String(200 + index), { channel_id: second })); + const fresh = message('300', { channel_id: second }); + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (options.method === 'DELETE') { writes.push(url.split('/').at(-1)); return new Response(null, { status: 204 }); } + if (url.includes('/search')) return Response.json({ messages: pages++ ? [] : indexed.map(item => [{ ...item, hit: true }]) }); + if (url.endsWith('/channels')) return denied !== 'threads' ? new Response(null, { status: 403 }) : Response.json([{ id: second, type: 0, guild_id: guildId }]); + if (url.endsWith('/threads/active')) return denied !== 'channels' ? new Response(null, { status: 403 }) : Response.json({ threads: [] }); + if (url.endsWith(`/channels/${second}`)) return Response.json({ id: second, type: 0, guild_id: guildId }); + if (url.includes('/messages?')) { histories.push(url); return Response.json([fresh, indexed.at(-1)]); } + return Response.json([fresh, ...indexed].find(item => item.id === url.split('/').at(-1))); + }); + const result = await deleteMessages({ ...base, guildId, channelId: undefined, log: (type, text) => logs.push({ type, text }) }); + assert.equal(result.done, true); + assert.equal(result.deleted, 14); + assert.equal(new Set(writes).size, 14); + assert.equal(histories.length, 1); + assert.ok(histories[0].includes(second)); + assert.ok(logs.some(entry => entry.type === 'warn' && entry.text.includes('discovery (403)'))); + } +}); + +test('server discovery authentication and malformed response errors still prevent all changes', async t => { + setup(t); + for (const mode of ['unauthorized', 'invalid', 'unavailable']) { + let searches = 0, writes = 0; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (options.method) writes++; + if (url.includes('/search')) return Response.json({ messages: searches++ ? [] : [[{ ...message('200'), hit: true }]] }); + if (url.endsWith('/channels')) return Response.json([{ id: channelId, type: 0, guild_id: guildId }]); + return mode === 'invalid' ? Response.json({}) : new Response(null, { status: mode === 'unauthorized' ? 401 : 503 }); + }); + await assert.rejects(deleteMessages({ ...base, guildId, channelId: undefined }), /Fresh message scan failed|invalid active thread list/); + assert.equal(writes, 0); + } +}); + +test('bundled Server and Queue cleanup continues after channel deletion when discovery returns 403', async t => { + for (const mode of ['server', 'queue']) { + for (const denied of ['threads', 'channels']) { + const second = '345678901234567890'; + const dom = new JSDOM('
', { url: `https://discord.com/channels/${guildId}/${channelId}`, runScripts: 'outside-only', pretendToBeVisual: true }); + t.after(() => dom.window.close()); + dom.window.setTimeout = callback => { callback(); return 0; }; + dom.window.localStorage.setItem('del_discord_v1_queue', JSON.stringify([{ kind: 'server', guildId, channelId: `server:${guildId}`, label: 'Server' }])); + const first = message('100'); + const remaining = Array.from({ length: 13 }, (_value, index) => message(String(200 + index), { channel_id: second })); + const writes = []; + dom.window.fetch = async (url, options) => { + if (options?.method === 'DELETE') { writes.push(url.split('/').at(-1)); return new Response(null, { status: 204 }); } + if (url.endsWith('/users/@me')) return Response.json({ id: 'self' }); + if (url.endsWith(`/channels/${channelId}`)) return Response.json({ id: channelId, type: 0, guild_id: guildId }); + if (url.endsWith(`/channels/${second}`)) return Response.json({ id: second, type: 0, guild_id: guildId }); + if (url.includes('/search')) { + const params = new URL(url).searchParams; + const matches = params.has('max_id') ? [] : params.has('channel_id') ? [first] : remaining; + return Response.json({ messages: matches.map(item => [{ ...item, hit: true }]), total_results: matches.length }); + } + if (url.endsWith('/channels')) return denied === 'channels' ? new Response(null, { status: 403 }) : Response.json([{ id: channelId, type: 0, guild_id: guildId }, { id: second, type: 0, guild_id: guildId }]); + if (url.endsWith('/threads/active')) return denied === 'threads' ? new Response(null, { status: 403 }) : Response.json({ threads: [] }); + if (url.includes('/messages?')) return Response.json([]); + return Response.json([first, ...remaining].find(item => item.id === url.split('/').at(-1))); + }; + vm.runInContext(await readFile('dist/Del-Discord-v1.user.js', 'utf8'), dom.getInternalVMContext()); + const doc = dom.window.document; + doc.dispatchEvent(new dom.window.Event('DOMContentLoaded')); + doc.querySelector('#dmd-token').value = 'test'; + const approve = async run => { + for (let count = 0; count < 300 && !doc.querySelector('.dmd-confirm-box'); count++) await Promise.resolve(); + assert.ok(doc.querySelector('.dmd-confirm-box')); + doc.querySelector('.dmd-confirm-box .dmd-red').click(); + await run; + }; + await approve(doc.querySelector('#dmd-start').onclick()); + assert.deepEqual(writes, ['100']); + if (mode === 'server') { + doc.querySelector('#dmd-message-modes [data-mode="server"]').click(); + await approve(doc.querySelector('#dmd-start').onclick()); + } else { + doc.querySelector('[data-view="multi"]').click(); + await doc.querySelector('#dmd-multi-start').onclick(); + assert.equal(doc.querySelector('#dmd-multi-count').textContent, '0 queued'); + } + assert.equal(writes.length, 14); + assert.equal(new Set(writes).size, 14); + assert.match(doc.querySelector('#dmd-log').textContent, /discovery \(403\)/); + assert.doesNotMatch(doc.querySelector('#dmd-log').textContent, /Fresh message scan failed/); + } + } +}); diff --git a/tests/history-performance.test.mjs b/tests/history-performance.test.mjs index 61bde24..b4a33d7 100644 --- a/tests/history-performance.test.mjs +++ b/tests/history-performance.test.mjs @@ -4,7 +4,7 @@ import { countJsonMessages } from '../src/features/history/count-json.js'; import { createHistoryView } from '../src/features/history/view.js'; import { merge, removeHistoryMatch } from '../src/features/history/model.js'; import { importPackage } from '../src/features/history/import-package.js'; -import { loadState, saveState } from '../src/features/history/storage.js'; +import { loadHistory, saveHistory, loadState, saveState } from '../src/features/history/storage.js'; const streamed = (text, chunkSize = 1) => { const bytes = new TextEncoder().encode(text); @@ -75,6 +75,29 @@ test('history pages and normalized searches reuse cached sorting and invalidate assert.deepEqual(view({}), []); }); +test('history identifier changes retire old lookups without removing the updated conversation', () => { + const history = {}; + merge(history, { userId: 'person', channelId: 'old-channel', verifiedSent: true, sentCount: 4 }); + merge(history, { userId: 'person', channelId: 'current-channel' }); + removeHistoryMatch(history, '', 'old-channel'); + assert.equal(history['u:person'].channelId, 'current-channel'); + + // A later channel-only import for the retired ID is a separate entry. + merge(history, { channelId: 'old-channel', verifiedSent: true, sentCount: 2 }); + assert.deepEqual(Object.keys(history).sort(), ['c:old-channel', 'u:person']); + assert.equal(history['u:person'].sentCount, 4); + removeHistoryMatch(history, '', 'current-channel'); + assert.deepEqual(Object.keys(history), ['c:old-channel']); + + // Adding and replacing a recipient still moves both current lookups. + merge(history, { userId: 'first-recipient', channelId: 'old-channel' }); + merge(history, { userId: 'second-recipient', channelId: 'old-channel' }); + removeHistoryMatch(history, 'first-recipient'); + assert.deepEqual(Object.keys(history), ['u:second-recipient']); + removeHistoryMatch(history, '', 'old-channel'); + assert.deepEqual(history, {}); +}); + test('large chunks are decoded in bounded slices while retaining complete message counts', async t => { const json = JSON.stringify([{ content: 'é😀"\\'.repeat(100000) }, { id: '123456789012345678' }]); const lengths = []; @@ -142,3 +165,24 @@ test('package imports cache duplicate folder counts and retain saved evidence wh assert.equal(info.importedCount, 2); assert.equal($('#dmh-import').disabled, false); }); + +test('clearing history during a package read prevents stale saves and skips later conversations', async t => { + storageFor(t); + let history = {}, release, completed = false, laterReads = 0; + const elements = new Map(); + const $ = selector => { if (!elements.has(selector)) elements.set(selector, {}); return elements.get(selector); }; + const files = [ + { webkitRelativePath: 'messages/c234567890123456789/channel.json', text: () => new Promise(resolve => { release = () => resolve('{"id":"234567890123456789","type":"DM"}'); }) }, + { webkitRelativePath: 'messages/c234567890123456789/messages.json', text: async () => { laterReads++; return '[{}]'; } }, + { webkitRelativePath: 'messages/c345678901234567890/channel.json', text: async () => { laterReads++; return '{"id":"345678901234567890","type":"DM"}'; } } + ]; + const imported = importPackage(files, { $, getHistory: () => history, getIdentity: async () => null, setPackageInfo: () => { completed = true; }, persist: () => { completed = true; }, render: () => { completed = true; } }); + await Promise.resolve(); + assert.equal(typeof release, 'function'); + history = {}; saveHistory(history); + release(); await imported; + assert.equal(laterReads, 0); + assert.equal(completed, false); + assert.deepEqual(loadHistory(), {}); + assert.equal($('#dmh-import').disabled, false); +}); diff --git a/tests/history-ui.test.mjs b/tests/history-ui.test.mjs index 33342e7..5f7730b 100644 --- a/tests/history-ui.test.mjs +++ b/tests/history-ui.test.mjs @@ -100,3 +100,118 @@ test('clearing history during name resolution cannot restore the cleared entries assert.equal($('#dmh-list').querySelectorAll('.dmh-row').length, 0); assert.equal(dom.window.localStorage.getItem('del_discord_v1_history'), '{}'); }); + +test('clearing history during verification discards the arriving result and remaining requests', async t => { + const { dom, $ } = mount(t, {}); + let release; + const requests = []; + dom.window.fetch = async url => { + requests.push(url); + if (url.endsWith('/users/@me')) return Response.json({ id: self }); + if (url.endsWith('/users/@me/channels')) return Response.json([ + { id: channel, type: 1, recipients: [{ id: userId, username: 'First' }] }, + { id: '456789012345678901', type: 1, recipients: [{ id: '567890123456789012', username: 'Second' }] } + ]); + return new Promise(resolve => { release = () => resolve(Response.json({ total_results: 2 })); }); + }; + const refresh = $('#dmh-refresh').onclick(); await settle(); + assert.equal(typeof release, 'function'); + $('#dmh-clear').click(); + $('.dmd-confirm-actions .dmd-red').click(); await settle(); + release(); await refresh; + assert.equal(requests.length, 3); + assert.equal($('#dmh-list').querySelectorAll('.dmh-row').length, 0); + assert.equal(dom.window.localStorage.getItem('del_discord_v1_history'), '{}'); + assert.equal($('#dmh-refresh').disabled, false); +}); + +test('refresh rate-limit failures retain completed results and show why verification stopped', async t => { + const { dom, $ } = mount(t, {}); + let limitedRequests = 0, laterRequests = 0; + dom.window.fetch = async url => { + if (url.endsWith('/users/@me')) return Response.json({ id: self }); + if (url.endsWith('/users/@me/channels')) return Response.json([ + { id: channel, type: 1, recipients: [{ id: userId, username: 'Verified' }] }, + { id: '456789012345678901', type: 1, recipients: [{ id: '567890123456789012', username: 'Limited' }] }, + { id: '678901234567890123', type: 1, recipients: [{ id: '789012345678901234', username: 'Later' }] }, + ]); + if (url.includes(channel)) return Response.json({ total_results: 2 }); + if (url.includes('456789012345678901')) { limitedRequests++; return Response.json({ retry_after: 0 }, { status: 429 }); } + laterRequests++; throw new Error('Later conversations must not be requested'); + }; + await assert.doesNotReject($('#dmh-refresh').onclick()); + assert.equal(limitedRequests, 3); assert.equal(laterRequests, 0); + assert.match($('#dmh-summary').textContent, /Repeated rate limits/); + assert.equal($('#dmh-list .dmh-name').textContent, 'Verified'); + const saved = JSON.parse(dom.window.localStorage.getItem('del_discord_v1_history')); + assert.equal(saved[`u:${userId}`].sentCount, 2); + assert.equal($('#dmh-refresh').disabled, false); +}); + +test('refresh reports a failure loading open conversations instead of silently showing an empty result', async t => { + const { dom, $ } = mount(t, {}); + dom.window.fetch = async url => { + if (url.endsWith('/users/@me')) return Response.json({ id: self }); + throw new Error('Connection interrupted'); + }; + await assert.doesNotReject($('#dmh-refresh').onclick()); + assert.match($('#dmh-summary').textContent, /Connection interrupted/); + assert.equal($('#dmh-refresh').disabled, false); +}); + +test('clearing history before a verification failure suppresses stale error messages and saves', async t => { + const { dom, $ } = mount(t, {}); + let fail; + dom.window.fetch = async url => { + if (url.endsWith('/users/@me')) return Response.json({ id: self }); + if (url.endsWith('/users/@me/channels')) return Response.json([{ id: channel, type: 1, recipients: [{ id: userId, username: 'Person' }] }]); + return new Promise((_resolve, reject) => { fail = () => reject(new Error('Late connection failure')); }); + }; + const refresh = $('#dmh-refresh').onclick(); await settle(); + assert.equal(typeof fail, 'function'); + $('#dmh-clear').click(); $('.dmd-confirm-actions .dmd-red').click(); await settle(); + fail(); await assert.doesNotReject(refresh); + assert.equal($('#dmh-summary').textContent, ''); + assert.equal($('#dmh-list').querySelectorAll('.dmh-row').length, 0); + assert.equal(dom.window.localStorage.getItem('del_discord_v1_history'), '{}'); +}); + +for (const stage of ['identity', 'channel', 'body']) test(`clearing history during Open DM ${stage} prevents stale restoration and navigation`, async t => { + const { dom, $ } = mount(t, { [`u:${userId}`]: { userId, channelId: channel, name: 'Person', username: 'Person', verifiedSent: true, sentCount: 3 } }); + let release, opens = 0; + dom.window.fetch = async url => { + if (url.endsWith('/users/@me')) return stage === 'identity' + ? new Promise(resolve => { release = () => resolve(Response.json({ id: self })); }) : Response.json({ id: self }); + opens++; + if (stage === 'channel') return new Promise(resolve => { release = () => resolve(Response.json({ id: channel })); }); + if (stage === 'body') return { ok: true, status: 200, headers: new Headers(), json: () => new Promise(resolve => { release = () => resolve({ id: channel }); }) }; + return Response.json({ id: channel }); + }; + const opening = $('.dmh-button.green').onclick(); await settle(); + assert.equal(typeof release, 'function'); + $('#dmh-clear').click(); $('.dmd-confirm-actions .dmd-red').click(); await settle(); + release(); await assert.doesNotReject(opening); + assert.equal(opens, stage === 'identity' ? 0 : 1); + assert.equal(dom.window.localStorage.getItem('del_discord_v1_history'), '{}'); + assert.equal($('#dmh-list').querySelectorAll('.dmh-row').length, 0); + assert.equal(dom.window.location.href, 'https://discord.com/'); +}); + +for (const clear of [false, true]) test(`Open DM reports request errors only while its history is current (clear=${clear})`, async t => { + const { dom, $ } = mount(t, { [`u:${userId}`]: { userId, channelId: channel, name: 'Person', username: 'Person', verifiedSent: true, sentCount: 3 } }); + let fail; + const alerts = []; + dom.window.alert = text => alerts.push(text); + dom.window.fetch = async url => { + if (url.endsWith('/users/@me')) return Response.json({ id: self }); + return new Promise((_resolve, reject) => { fail = () => reject(new Error('Connection interrupted')); }); + }; + const opening = $('.dmh-button.green').onclick(); await settle(); + assert.equal(typeof fail, 'function'); + if (clear) { $('#dmh-clear').click(); $('.dmd-confirm-actions .dmd-red').click(); await settle(); } + fail(); await assert.doesNotReject(opening); + if (clear) { + assert.deepEqual(alerts, []); + assert.equal(dom.window.localStorage.getItem('del_discord_v1_history'), '{}'); + } else assert.match(alerts[0], /Connection interrupted/); +}); diff --git a/tests/history-verify.test.mjs b/tests/history-verify.test.mjs new file mode 100644 index 0000000..063c81d --- /dev/null +++ b/tests/history-verify.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { verifyPerson } from '../src/features/history/verify.js'; +import { RateLimitError } from '../src/discord/api.js'; + +const channelId = '123456789012345678'; + +test('repeated rate limits while closing a temporary DM stop verification and report the open conversation', async t => { + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + const methods = []; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + const method = options.method || 'GET'; methods.push(method); + if (method === 'POST') return Response.json({ id: channelId }); + if (method === 'DELETE') return Response.json({ retry_after: 0 }, { status: 429 }); + return Response.json({ total_results: 5 }); + }); + await assert.rejects(verifyPerson('close-rate-limit', 'self', 'recipient', '', false), error => { + assert.match(error.message, /temporarily opened DM.*may remain open/); + assert.ok(error.cause instanceof RateLimitError); + return true; + }); + assert.deepEqual(methods, ['POST', 'GET', 'DELETE', 'DELETE', 'DELETE']); +}); + +test('a failed HTTP response while closing a temporary DM does not report successful verification', async t => { + t.mock.method(globalThis, 'fetch', async (_url, options) => options.method === 'POST' + ? Response.json({ id: channelId }) : options.method === 'DELETE' + ? new Response(null, { status: 503 }) : Response.json({ total_results: 5 })); + await assert.rejects(verifyPerson('close-http-error', 'self', 'recipient', '', false), /temporarily opened DM.*may remain open.*503/); +}); + +test('successful or already-absent temporary DMs preserve verified counts, and existing DMs are not closed', async t => { + for (const status of [204, 404]) { + const methods = []; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + const method = options.method || 'GET'; methods.push(method); + return method === 'POST' ? Response.json({ id: channelId }) : method === 'DELETE' + ? new Response(null, { status }) : Response.json({ total_results: 5 }); + }); + assert.deepEqual(await verifyPerson(`close-success-${status}`, 'self', 'recipient', '', false), { count: 5, channelId }); + assert.deepEqual(methods, ['POST', 'GET', 'DELETE']); + methods.length = 0; + assert.deepEqual(await verifyPerson(`existing-dm-${status}`, 'self', 'recipient', channelId, true), { count: 5, channelId }); + assert.deepEqual(methods, ['GET']); + } +}); + +test('failed verification still closes a temporary DM and preserves the original error when closing succeeds', async t => { + const failure = new Error('Search connection failed'), methods = []; + t.mock.method(globalThis, 'fetch', async (_url, options) => { + const method = options.method || 'GET'; methods.push(method); + if (method === 'POST') return Response.json({ id: channelId }); + if (method === 'DELETE') return new Response(null, { status: 204 }); + throw failure; + }); + await assert.rejects(verifyPerson('search-failure-close', 'self', 'recipient', '', false), error => error === failure); + assert.deepEqual(methods, ['POST', 'GET', 'DELETE']); +}); diff --git a/tests/message-filters.test.mjs b/tests/message-filters.test.mjs new file mode 100644 index 0000000..20729dc --- /dev/null +++ b/tests/message-filters.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeCleanupOptions, matchesMessage } from '../src/features/message-filters.js'; + +const message = id => ({ id, channel_id: 'channel', author: { id: 'self' }, type: 0, content: 'hello' }); + +test('message range filters keep exclusive exact boundaries across decimal lengths and leading zeros', () => { + const ids = ['0', '00', '9', '0009', '10', '99', '100', '999999999999999999', '1000000000000000000', '1000000000000000001', '9999999999999999999999']; + for (const lower of ids) { + for (const upper of ids) { + if (BigInt(lower) >= BigInt(upper)) continue; + const options = normalizeCleanupOptions({ authorId: 'self', minId: lower, maxId: upper }); + for (const id of ids) { + assert.equal(matchesMessage(message(id), options), BigInt(id) > BigInt(lower) && BigInt(id) < BigInt(upper), `${lower} < ${id} < ${upper}`); + } + } + } +}); + +test('single-ended ranges preserve author checks and reject malformed message IDs before comparison', () => { + const boundary = '1000000000000000000'; + for (const side of ['minId', 'maxId']) { + const options = normalizeCleanupOptions({ authorId: 'self', [side]: boundary }); + for (const id of ['999999999999999999', boundary, '1000000000000000001']) { + assert.equal(matchesMessage(message(id), options), side === 'minId' ? BigInt(id) > BigInt(boundary) : BigInt(id) < BigInt(boundary)); + assert.equal(matchesMessage({ ...message(id), author: { id: 'other' } }, options), false); + } + for (const id of ['', '-1', '1.5', '1e18', 'invalid']) assert.equal(matchesMessage(message(id), options), false); + } +}); diff --git a/tests/reliability.test.mjs b/tests/reliability.test.mjs index 7bf727e..e416abd 100644 --- a/tests/reliability.test.mjs +++ b/tests/reliability.test.mjs @@ -35,13 +35,13 @@ test('both panels use v10 and rate-limit delays remain seconds at all magnitudes assert.ok(waits[0] >= 1000250 && waits[0] <= 1000750); }); -test('export stops on repeated pages and preserves unique ascending messages', async t => { +test('export deduplicates overlapping pages and preserves unique ascending messages', async t => { t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); const urls = []; t.mock.method(globalThis, 'fetch', async url => { urls.push(url); if (urls.length > 3) throw new Error('Export should stop on a nonadvancing page'); - return Response.json(urls.length === 1 ? [message('300'), message('200'), message('200')] : [message('200'), message('100')]); + return Response.json(urls.length === 1 ? [message('300'), message('200'), message('200')] : urls.length === 2 ? [message('200'), message('100')] : []); }); const result = await exportConversationData(exportOptions); assert.deepEqual(result.messages.map(message => message.id), ['100', '200', '300']); @@ -53,7 +53,7 @@ test('export stops on repeated pages and preserves unique ascending messages', a test('export cancellation during rate limiting prevents another request', async t => { let stopped = false, calls = 0; t.mock.method(globalThis, 'fetch', async () => { calls++; return Response.json({ retry_after: 0.1 }, { status: 429 }); }); - t.mock.method(globalThis, 'setTimeout', callback => { stopped = true; callback(); return 0; }); + t.mock.method(globalThis, 'setTimeout', callback => { if (calls) stopped = true; callback(); return 0; }); const result = await exportConversationData({ ...exportOptions, stopCheck: () => stopped }); assert.equal(calls, 1); assert.equal(result.messageCount, 0); @@ -120,3 +120,26 @@ test('reaction cancellation during rate limiting prevents another request', asyn assert.equal(calls, 1); assert.equal(logs.at(-1)[0], 'warn'); }); + + +test('export validates ranges before requests and applies dates across paginated messages', async t => { + const { toSnowflake } = await import('../src/utils/messages.js'); + const minId = '2026-01-01T00:00', maxId = '2026-02-01T00:00'; + const lower = BigInt(toSnowflake(minId)), upper = BigInt(toSnowflake(maxId)); + const urls = []; + t.mock.method(globalThis, 'setTimeout', callback => { callback(); return 0; }); + t.mock.method(globalThis, 'fetch', async url => { + urls.push(url); + return Response.json(urls.length === 1 + ? [message(String(upper)), message(String(upper - 1n))] + : [message(String(lower + 1n)), message(String(lower)), message(String(lower - 1n))]); + }); + await assert.rejects(exportConversationData({ ...exportOptions, minId: 'invalid' }), /valid date/); + await assert.rejects(exportConversationData({ ...exportOptions, minId: maxId, maxId: minId }), /earlier/); + assert.equal(urls.length, 0); + const result = await exportConversationData({ ...exportOptions, minId, maxId }); + assert.deepEqual(result.messages.map(item => item.id), [String(lower + 1n), String(upper - 1n)]); + assert.deepEqual(result.range, { after: minId, before: maxId }); + assert.equal(new URL(urls[0]).searchParams.get('before'), String(upper)); + assert.equal(urls.length, 2); +});