Undiscord: apagar mensagens em massa no Discord
O dialog do Undiscord ficava ilegível no tema escuro. Explico a correção de contraste e o novo auto-purge, com o script completo para o Tampermonkey.
Abri o Undiscord, carreguei no ícone e apareceu o dialog. O primeiro problema foi eu não conseguir lê-lo. O texto saía escuro sobre um fundo escuro, quase sem contraste, e eu estava a adivinhar os botões pelo sítio onde estavam.
O Undiscord é um userscript do victornpb, na versão 5.2.6, que apaga mensagens em massa no Discord. Corre no Tampermonkey, injeta uma caixa de diálogo própria na página e, durante uns tempos, essa caixa ficou ilegível nos temas escuros. Há uns meses decidi corrigir o CSS em vez de andar a lutar com o rato. Isto é o que estava errado e o que mudei.
O sintoma
Havia um detalhe que mostrava que não era falta de gosto. O layout do dialog estava certo: campos, botões e área de log no sítio. Só as cores é que falhavam. O título quase desaparecia, as etiquetas ao lado dos campos também, e os links eram um borrão azul-escuro sobre o painel.
O botão para abrir o dialog também dava problemas. O ícone de lixo fica na barra de ferramentas, ao lado da pesquisa, mas nem sempre aparecia: o seletor que o procurava no DOM já não correspondia aos nomes de classe que o Discord usa hoje.
Porque é que o texto ficava escuro no fundo escuro
O CSS do userscript usava variáveis do Discord para se adaptar ao tema. A ideia era boa: em vez de fixar cores, o script pedia ao Discord as cores certas através de custom properties como --text-normal ou --background-secondary.
O problema é que essas variáveis já não existem nas versões atuais do Discord. O Discord foi mudando os nomes dos tokens e as referências antigas ficaram penduradas: --text-normal, --background-secondary, --header-secondary, --primary-700, --background-modifier-accent, --interactive-muted, --background-mentioned. Nenhuma destas está definida hoje.
Quando uma variável CSS não existe e não tem fallback, não é que fique “sem cor”. A declaração torna-se inválida e o browser aplica o valor inicial da propriedade. No texto, esse valor é preto. No fundo, o painel acaba transparente ou escuro. O resultado era exatamente o que eu via: texto escuro sobre fundo escuro.
Isto é o que muita gente não antecipa. O CSS não avisa que a variável faltou; falha em silêncio.
A correção: token atual com fallback hex
A correção foi sistemática: manter o token, mas garantir sempre um valor concreto a seguir. O segundo argumento do var() é o fallback, e é ele que impede a declaração de ficar inválida.
Antes, cada cor era assim:
#undiscord {
background: var(--background-secondary);
color: var(--text-normal);
}
Depois, cada cor passou a ter um token atual e um hex:
#undiscord {
background: var(--background-surface-high, #2b2d31);
color: var(--text-default, #dbdee1);
}
A lógica é simples. Se o Discord expõe --background-surface-high, o dialog adota a cor do tema e acompanha o claro/escuro. Se não expõe, o #2b2d31 garante um fundo definido. Em qualquer dos casos, a propriedade nunca fica inválida.
Os valores concretos
Passei tudo a pares token/hex. A tabela resume os principais.
| Elemento | Token | Fallback hex |
|---|---|---|
| Fundo do dialog | --background-surface-high |
#2b2d31 |
| Texto | --text-default |
#dbdee1 |
| Títulos e cabeçalhos | --text-strong |
#f2f3f5 |
| Etiquetas e texto secundário | --text-muted |
#949ba4 |
| Links | --text-link |
#00a8fc |
| Fundo dos inputs | --input-background |
#1e1f22 |
| Borda dos inputs | --input-border |
#26272b |
| Botões secundários | --button-secondary-background |
#4e5058 |
| Botão principal | --brand-experiment |
#5865f2 |
| Botão de perigo | --button-danger-background |
#da373c |
| Botão de ação positiva | --button-positive-background |
#248046 |

O dialog depois da correção, num tema escuro.
Dois sítios precisaram de atenção extra. A área de log, o #logArea, não tinha fundo nem cor de texto próprios e passou a tê-los. E a cor do modo “verbose”, que era o #72767d (demasiado escuro para leitura), mudou para #949ba4. As mensagens de info, aviso, erro e sucesso usam agora os tokens --status-* com fallbacks.
A caixa de aviso inicial também ganhou fundo e cor explícitos, além de uma borda esquerda com cor de aviso, para se distinguir do resto sem depender de uma variável.
Fiquei com uma preocupação a meio: o modo streamer. É a funcionalidade que tapa informação sensível no ecrã quando estás a partilhar a janela, e não me apetecia estragá-la a mexer em cores. Acabou por ser uma mudança segura. A cor da tampa usava o --primary-700, que já estava morto, e passou para var(--background-base-lowest, #1e1f22). O comportamento manteve-se.
Os botões e o atalho
O dialog abre de duas maneiras: pelo ícone de lixo na barra de ferramentas do Discord, ao lado da pesquisa, ou pelo atalho Alt+U. Reposicionei o dialog em top:64px; right:16px para não transbordar do ecrã.
Durante algum tempo existiu também um botão flutuante como plano B. Removi-o: flutuava por cima da lista de membros e tornou-se redundante assim que o ícone da barra de ferramentas passou a montar de forma fiável. O problema antigo era esse: o seletor [class*="-toolbar"] já não correspondia, porque o Discord passou a usar nomes de classe com hash em vez do nome legível. Passei a tentar também [class*="toolbar"], e o Alt+U garante que o dialog se abre mesmo quando o ícone não aparece.
O contraste em números
Além de ficar apresentável, isto tinha de ser legível. O par que mais uso é o texto #dbdee1 sobre o fundo #2b2d31, com um rácio de contraste de cerca de 10:1.
O número importa por causa de uma referência que uso sempre que mexo em cores de interface. As Web Content Accessibility Guidelines (WCAG) 2.2, da W3C, definem no Success Criterion 1.4.3 “Contrast (Minimum)” um mínimo de 4.5:1 para texto normal. Com 10:1 fico com bastante margem, o que ajuda em ecrãs com brilho baixo ou com luz forte por cima.
Porque é que a correção ajuda mais no tema escuro
Isto merece uma explicação, porque o tema escuro era o caso pior.
Antes, o contraste dependia de o Discord expor ou não a variável. No tema escuro, as variáveis em falta davam texto escuro sobre um painel escuro ou transparente. O tema escuro não criava o problema, só o tornava visível de forma mais dramática.
Agora o par está sempre definido, seja pelo token, seja pelo hex. Deixa de haver surpresa. E nos sítios onde o Discord expõe os tokens atuais, as cores continuam a adaptar-se ao tema, claro ou escuro. Ganhei as duas coisas: previsibilidade quando a variável falta, e adaptação quando ela existe.
Conclusão
Isto ensinou-me uma coisa que eu já devia saber. Usar variáveis de uma plataforma que não controlo é um contrato que se pode romper sem aviso, e a solução passa por as usar sempre com rede: um token que pode faltar e um hex que não falha.
Quantos userscripts por aí estão a pedir tokens antigos do Discord, ou de qualquer outra plataforma, e parecem avariados quando só precisavam de um fallback? Se manténs um, abre o inspetor e olha para as custom properties que ele pede. Se aparecerem a cinzento dentro do var(), é sinal de que o browser não as encontrou.
Auto-purge: apagar por idade
Atualizado a 13 de outubro de 2026 (versão 5.2.11): idades mínimas de 7 e 15 dias, vários canais, intervalo mínimo de 60 minutos, limite por volta, ensaio, histórico, correções ao motor e atualizações automáticas. O script passou a chamar-se «Undiscord (fuzzalab)»: se já tinhas uma versão anterior, instala esta pela ligação no fim do post e apaga a antiga no Dashboard do Tampermonkey. A configuração mantém-se.
Além do contraste, o script ganhou uma funcionalidade que me poupa trabalho: o auto-purge. Enquanto o separador do Discord está aberto, ele apaga sozinho as mensagens com mais de uma certa idade nos canais que escolheres, e a idade conta a partir do momento em que cada mensagem foi enviada.
A secção, no painel lateral, tem um interruptor para ativar e uma lista de canais. Cada canal leva o Server ID, o Channel ID (os botões “current” preenchem-nos a partir do sítio onde estás) e a sua própria idade mínima: 24 horas, 48 horas, 7 dias ou 15 dias. Por baixo ficam o intervalo de verificação, o máximo de mensagens por volta e a opção “Só as minhas mensagens”, ligada por defeito. Os botões Adicionar, Guardar, Ensaiar e o próprio interruptor gravam o canal que estiver nos campos, que continuam preenchidos; o canal aparece também na lista, onde o modo streamer mostra só os últimos 4 dígitos de cada ID, e clicar numa linha carrega-a nos campos. Ao guardar, o script relê o localStorage e só diz «Guardado» se a configuração lá estiver; se o browser a recusar, mostra o erro. A linha de estado diz quantos canais estão guardados, quando foi a última volta e quando será a próxima.
Intervalo e limites
O intervalo vem a 300 minutos e tem um mínimo de 60, imposto pelo próprio script: se escreveres menos, sobe para 60 quando guardas, e uma configuração antiga com 15 minutos é corrigida ao carregar. Para apagar mensagens com dias de idade, verificar de hora a hora já chega e poupa pedidos à API.
O intervalo conta a partir da última volta, que fica guardada. Recarregar o Discord já não dispara uma limpeza nova: se a última foi há 10 minutos e o intervalo é de 300, a próxima é daqui a 290. Cada intervalo leva ainda uma variação aleatória de ±10%, para as voltas não caírem sempre à mesma hora.
O máximo por volta (500 por omissão, somando todos os canais) é um travão: se houver mais mensagens para apagar, o resto fica para a volta seguinte. Numa primeira ativação com milhares de mensagens antigas, evita uma sessão de horas a apagar sem parar.
Ensaiar antes de ativar
O botão Ensaiar faz uma pesquisa por canal e diz quantas mensagens seriam apagadas, sem apagar nenhuma. Convém usá-lo antes de ligar o auto-purge pela primeira vez. O número é aproximado, porque a pesquisa também conta mensagens que o script não apaga, como as fixadas. As últimas 10 execuções, ensaios incluídos, ficam numa lista por baixo do estado, com as mensagens apagadas e as falhas.
Por dentro
O auto-purge não inventa nada: reutiliza o mesmo motor de pesquisa e eliminação do dialog. Em cada volta e em cada canal, calcula o cutoff como um snowflake do Discord para now - idade (o ID de 64 bits embute o timestamp, o que torna a comparação trivial) e manda o motor apagar tudo o que é mais antigo, sem diálogo de confirmação.
A configuração fica em localStorage, na chave undiscord.autopurge.v2; a v1, do tempo em que só havia um canal, é migrada sozinha. O Discord retira o window.localStorage da página, por isso o script lê-o através de um iframe da mesma origem, o mesmo truque que já usava para o token. É por isso que a configuração persiste entre sessões.
Uma limitação honesta: com “Só as minhas mensagens” ligado, que é o padrão, só apaga o que tu escreveste. Se o script não conseguir obter o teu ID de utilizador, cancela a volta em vez de avançar sem filtro. Sem permissões de moderação no servidor, a API do Discord não deixa um utilizador normal apagar mensagens de outros, por isso o padrão é o único que funciona para a maioria dos casos.
Correções ao motor do Undiscord
Ao rever o código para esta versão encontrei defeitos que vêm do Undiscord original e que pesam mais num processo que corre sozinho:
- quando a pesquisa recebia um 429 (pedidos a mais) ou um 202 (canal ainda por indexar), o atraso era lido de um campo que não existe. A espera dava
NaN, que osetTimeouttrata como 0, e o script voltava a pedir de imediato, em recursão. Agora espera, aumenta o atraso até um máximo de 2 minutos e desiste ao fim de 10 tentativas; - um 429 ao apagar podia baixar o atraso entre eliminações em vez de o subir;
- carregar em Delete durante uma volta do auto-purge interrompia-a, e ela, ao terminar, interrompia a eliminação manual. Agora o Delete recusa arrancar enquanto houver uma limpeza a correr;
- cada leitura do
localStoragecriava um iframe novo que nunca era removido; agora há só um; - as respostas de erro da API entravam no log sem escape de HTML.
Aviso. O Undiscord e o auto-purge usam o token da tua conta de utilizador para apagar mensagens. O Discord não permite automatizar contas de utilizador, e o auto-purge, que corre de forma recorrente, aumenta o risco de a conta ser sinalizada ou suspensa. Usa-o por tua conta e risco. A eliminação é irreversível e o auto-purge corre sem pedir confirmação, por isso confirma bem o Server ID e o Channel ID, mantém «Só as minhas mensagens» ligado, usa o Ensaiar antes de ativar e não uses um intervalo mais curto do que precisas.
O script completo
Este é o userscript completo para o Tampermonkey, na versão 5.2.11: junta as correções de contraste que descrevi neste post, o auto-purge e as correções ao motor.
É um fork do Undiscord de victornpb, por isso chama-se «Undiscord (fuzzalab)» e as dúvidas sobre estas alterações vêm para aqui, não para o projeto original. A licença do original (MIT, com uma cláusula que obriga a manter a atribuição) aplica-se também ao fork, e está completa no início do código, junto com a nota de copyright das alterações.
A forma mais simples de o instalar:
- instala a extensão Tampermonkey no browser;
- abre fuzzalab.pt/downloads/undiscord.user.js: o Tampermonkey reconhece o userscript e mostra o botão Install;
- abre ou recarrega discord.com e usa o ícone de lixo na barra de ferramentas ou o atalho Alt+U.
Instalado assim, o Tampermonkey procura atualizações nesse endereço e aplica as versões novas sozinho. Se preferires ler o código antes, ou colá-lo à mão (Dashboard, Create a new script, colar e guardar com Cmd+S), está abaixo, de // ==UserScript== até ao fim. Está fechado para não alongar o post: clica em «Mostrar o código completo».
Mostrar o código completo (undiscord.user.js, 2156 linhas)
// ==UserScript==
// @name Undiscord (fuzzalab)
// @description Delete all messages in a Discord channel or DM (Bulk deletion), with auto-purge. Fork of victornpb/undiscord.
// @version 5.2.11
// @author fuzzalab (fork de victornpb/undiscord)
// @homepageURL https://fuzzalab.pt/blog/undiscord-contraste-tema-escuro-userscript
// @supportURL https://fuzzalab.pt/blog/undiscord-contraste-tema-escuro-userscript
// @match https://*.discord.com/app
// @match https://*.discord.com/channels/*
// @match https://*.discord.com/login
// @license MIT
// @namespace https://fuzzalab.pt
// @icon https://victornpb.github.io/undiscord/images/icon128.png
// @contributionURL https://www.buymeacoffee.com/vitim
// @updateURL https://fuzzalab.pt/downloads/undiscord.user.js
// @downloadURL https://fuzzalab.pt/downloads/undiscord.user.js
// @grant none
// @attribution Original project (https://github.com/victornpb/undiscord)
// ==/UserScript==
/*
* Undiscord (fuzzalab): fork do Undiscord de victornpb.
* Original project: https://github.com/victornpb/undiscord
* Alterações (contraste no tema escuro, atalho Alt+U, auto-purge e correções ao motor):
* Copyright (c) 2026 fuzzalab, https://fuzzalab.pt
*
* Licença do projeto original, que se aplica também a este fork:
*
* MIT License
*
* Copyright (c) 2019-2025 Victornpb and contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* Attribution: Original project (https://github.com/victornpb/undiscord)
* Derivatives must keep attribution comments to the original project in the code readme and license.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* 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 () {
'use strict';
/* rollup-plugin-baked-env */
const VERSION = "5.2.11";
var themeCss = (`
/* auto-purge: lista de canais e histórico */
#undiscord .apBox, #undiscord .apTargetsBox { min-width: 0; } /* o fieldset tem min-width: min-content e esticava com os IDs */
#undiscord #apTargets .apTarget { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 12px; color: var(--text-default, #dbdee1); }
#undiscord #apTargets .apTarget .apIds { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#undiscord #apTargets .apTarget .apAge { flex-shrink: 0; color: var(--text-muted, #949ba4); }
/* modo streamer: em vez de esconder tudo (a linha parecia vazia), mostra só os últimos 4 dígitos */
#undiscord.redact #apTargets .apIdFull, #undiscord:not(.redact) #apTargets .apIdMasked { display: none; }
#undiscord #apTargets .apTarget button { flex-shrink: 0; min-width: 0; width: auto; padding: 0 6px; min-height: 20px; } /* os botões do Undiscord têm min-width: 60px */
#undiscord #apHistory div { font-size: 11px; color: var(--text-muted, #949ba4); }
/* undiscord window */
#undiscord.browser { box-shadow: var(--shadow-border, 0 0 0 1px rgba(0,0,0,.4)), var(--shadow-high, 0 8px 16px rgba(0,0,0,.24)); border: 1px solid var(--border-subtle, #3f4147); overflow: hidden; }
#undiscord.container,
#undiscord .container { background-color: var(--background-surface-high, #2b2d31); border-radius: 8px; box-sizing: border-box; cursor: default; flex-direction: column; }
#undiscord .header { background-color: var(--background-base-lowest, #1e1f22); height: 48px; align-items: center; min-height: 48px; padding: 0 16px; display: flex; color: var(--text-default, #dbdee1); cursor: grab; }
#undiscord .header .icon { color: var(--interactive-normal, #b5bac1); margin-right: 8px; flex-shrink: 0; width: 24; height: 24; }
#undiscord .header .icon:hover { color: var(--interactive-hover, #dbdee1); }
#undiscord .header h3 { font-size: 16px; line-height: 20px; font-weight: 500; font-family: var(--font-display, "gg sans", "Noto Sans", Helvetica, Arial, sans-serif); color: var(--text-strong, #f2f3f5); flex-shrink: 0; margin-right: 16px; }
#undiscord .spacer { flex-grow: 1; }
#undiscord .header .vert-divider { width: 1px; height: 24px; background-color: var(--border-subtle, #3f4147); margin-right: 16px; flex-shrink: 0; }
#undiscord legend,
#undiscord label { color: var(--text-muted, #949ba4); font-size: 12px; line-height: 16px; font-weight: 500; text-transform: uppercase; cursor: default; font-family: var(--font-display, "gg sans", "Noto Sans", Helvetica, Arial, sans-serif); margin-bottom: 8px; }
#undiscord .multiInput { display: flex; align-items: center; font-size: 16px; box-sizing: border-box; width: 100%; border-radius: 3px; color: var(--text-default, #dbdee1); background-color: var(--input-background, #1e1f22); border: none; transition: border-color 0.2s ease-in-out 0s; }
#undiscord .multiInput :first-child { flex-grow: 1; }
#undiscord .multiInput button:last-child { margin-right: 4px; }
#undiscord .input { font-size: 16px; width: 100%; transition: border-color 0.2s ease-in-out 0s; padding: 10px; height: 44px; background-color: var(--input-background, #1e1f22); border: 1px solid var(--input-border, #26272b); border-radius: 8px; box-sizing: border-box; color: var(--text-default, #dbdee1); }
#undiscord fieldset { margin-top: 16px; }
#undiscord .input-wrapper { display: flex; align-items: center; font-size: 16px; box-sizing: border-box; width: 100%; border-radius: 3px; color: var(--text-default, #dbdee1); background-color: var(--input-background, #1e1f22); border: none; transition: border-color 0.2s ease-in-out 0s; }
#undiscord input[type="text"],
#undiscord input[type="search"],
#undiscord input[type="password"],
#undiscord input[type="datetime-local"],
#undiscord input[type="number"],
#undiscord input[type="range"] { background-color: var(--input-background, #1e1f22); border: 1px solid var(--input-border, #26272b); border-radius: 8px; box-sizing: border-box; color: var(--text-default, #dbdee1); font-size: 16px; height: 44px; padding: 12px 10px; transition: border-color .2s ease-in-out; width: 100%; }
#undiscord input::placeholder { color: var(--text-muted, #949ba4); opacity: 1; }
#undiscord input:focus,
#undiscord .input:focus { outline: none; border-color: var(--brand-experiment, #5865f2); }
#undiscord input[type="checkbox"] { accent-color: var(--brand-experiment, #5865f2); }
#undiscord .divider,
#undiscord hr { border: none; margin-bottom: 24px; padding-bottom: 4px; border-bottom: 1px solid var(--border-subtle, #3f4147); }
#undiscord .sectionDescription { margin-bottom: 16px; color: var(--text-muted, #949ba4); font-size: 14px; line-height: 20px; font-weight: 400; }
#undiscord a { color: var(--text-link, #00a8fc); text-decoration: none; }
#undiscord a:hover { text-decoration: underline; }
#undiscord .btn,
#undiscord button { position: relative; display: flex; -webkit-box-pack: center; justify-content: center; -webkit-box-align: center; align-items: center; box-sizing: border-box; background: none; border: none; border-radius: 3px; font-size: 14px; font-weight: 500; line-height: 16px; padding: 2px 16px; user-select: none; /* sizeSmall */ width: 60px; height: 32px; min-width: 60px; min-height: 32px; /* lookFilled colorPrimary */ color: rgb(255, 255, 255); background-color: var(--button-secondary-background, #4e5058); }
#undiscord .sizeMedium { width: 96px; height: 38px; min-width: 96px; min-height: 38px; }
#undiscord .sizeMedium.icon { width: 38px; min-width: 38px; }
#undiscord sup { vertical-align: top; }
/* lookFilled colorPrimary */
#undiscord .accent { background-color: var(--brand-experiment, #5865f2); }
#undiscord .accent:hover { background-color: var(--brand-experiment-560, #4752c4); }
#undiscord .danger { background-color: var(--button-danger-background, #da373c); }
#undiscord .danger:hover { background-color: var(--button-danger-background-hover, #a12828); }
#undiscord .positive { background-color: var(--button-positive-background, #248046); }
#undiscord .positive:hover { background-color: var(--button-positive-background-hover, #1a6334); }
#undiscord button:not(.accent):not(.danger):not(.positive):not(:disabled):hover { background-color: var(--button-secondary-background-hover, #6d6f78); }
#undiscord .info { font-size: 12px; line-height: 16px; padding: 8px 10px; color: var(--text-muted, #949ba4); }
/* Scrollbar */
#undiscord .scroll::-webkit-scrollbar { width: 8px; height: 8px; }
#undiscord .scroll::-webkit-scrollbar-corner { background-color: transparent; }
#undiscord .scroll::-webkit-scrollbar-thumb { background-clip: padding-box; border: 2px solid transparent; border-radius: 4px; background-color: var(--scrollbar-thin-thumb, #202225); min-height: 40px; }
#undiscord .scroll::-webkit-scrollbar-track { border-color: var(--scrollbar-thin-track, #2e3338); background-color: var(--scrollbar-thin-track, #2e3338); border: 2px solid var(--scrollbar-thin-track, #2e3338); }
/* fade scrollbar */
#undiscord .scroll::-webkit-scrollbar-thumb,
#undiscord .scroll::-webkit-scrollbar-track { visibility: hidden; }
#undiscord .scroll:hover::-webkit-scrollbar-thumb,
#undiscord .scroll:hover::-webkit-scrollbar-track { visibility: visible; }
/**** functional classes ****/
#undiscord.redact .priv { display: none !important; }
#undiscord.redact x:not(:active) { color: transparent !important; background-color: var(--background-base-lowest, #1e1f22) !important; cursor: default; user-select: none; }
#undiscord.redact x:hover { position: relative; }
#undiscord.redact x:hover::after { content: "Redacted information (Streamer mode: ON)"; position: absolute; display: inline-block; top: -32px; left: -20px; z-index: 10; padding: 4px; width: 150px; font-size: 8pt; text-align: center; white-space: pre-wrap; background-color: var(--background-floating, #111214); border: 1px solid var(--border-subtle, #3f4147); -webkit-box-shadow: var(--elevation-high, 0 8px 16px rgba(0,0,0,.24)); box-shadow: var(--elevation-high, 0 8px 16px rgba(0,0,0,.24)); color: var(--text-default, #dbdee1); border-radius: 5px; pointer-events: none; }
#undiscord.redact [priv] { -webkit-text-security: disc !important; }
#undiscord :disabled { display: none; }
/**** layout and utility classes ****/
#undiscord,
#undiscord * { box-sizing: border-box; }
#undiscord .col { display: flex; flex-direction: column; }
#undiscord .row { display: flex; flex-direction: row; align-items: center; }
#undiscord .mb1 { margin-bottom: 8px; }
#undiscord .log { margin-bottom: 0.25em; }
#undiscord .log-debug { color: inherit; }
#undiscord .log-info { color: var(--text-link, #00b0f4); }
#undiscord .log-verb { color: var(--text-muted, #949ba4); }
#undiscord .log-warn { color: var(--status-warning, #faa61a); }
#undiscord .log-error { color: var(--status-danger, #f04747); }
#undiscord .log-success { color: var(--status-positive, #43b581); }
`);
var mainCss = (`
/**** Undiscord Button ****/
#undicord-btn { position: relative; width: auto; height: 24px; margin: 0 8px; cursor: pointer; color: var(--interactive-normal, #b5bac1); flex: 0 0 auto; }
#undicord-btn:hover { color: var(--interactive-hover, #dbdee1); }
#undicord-btn progress { position: absolute; top: 23px; left: -4px; width: 32px; height: 12px; display: none; accent-color: var(--button-danger-background, #da373c); }
#undicord-btn.running { color: var(--button-danger-background, #da373c) !important; }
#undicord-btn.running progress { display: block; }
/**** Undiscord Interface ****/
#undiscord { position: fixed; z-index: 100; top: 64px; right: 16px; display: flex; flex-direction: column; width: 800px; height: 80vh; min-width: 610px; max-width: calc(100vw - 32px); min-height: 448px; max-height: 100vh; color: var(--text-default, #dbdee1); border-radius: 4px; background-color: var(--background-surface-high, #2b2d31); box-shadow: var(--elevation-stroke, 0 0 0 1px rgba(0,0,0,.15)), var(--elevation-high, 0 8px 16px rgba(0,0,0,.24)); will-change: top, left, width, height; }
#undiscord .header .icon { cursor: pointer; }
#undiscord .window-body { height: calc(100% - 48px); }
#undiscord .sidebar { overflow: hidden scroll; overflow-y: auto; width: 270px; min-width: 250px; height: 100%; max-height: 100%; padding: 8px; background: var(--bg-overlay-4, var(--background-base-lowest, #1e1f22)); }
#undiscord .sidebar legend,
#undiscord .sidebar label { display: block; width: 100%; }
#undiscord .main { display: flex; max-width: calc(100% - 250px); background-color: var(--bg-overlay-chat, var(--background-base-lower, #313338)); flex-grow: 1; }
#undiscord.hide-sidebar .sidebar { display: none; }
#undiscord.hide-sidebar .main { max-width: 100%; }
#undiscord #logArea { font-family: Consolas, Liberation Mono, Menlo, Courier, monospace; font-size: 0.75rem; overflow: auto; padding: 10px; user-select: text; flex-grow: 1; cursor: auto; color: var(--text-default, #dbdee1); background-color: var(--background-base-lower, #313338); }
#undiscord .tbar { padding: 8px; background-color: var(--bg-overlay-2, var(--background-base-lower, #313338)); }
#undiscord .tbar button { margin-right: 4px; margin-bottom: 4px; }
#undiscord .footer { cursor: se-resize; padding-right: 30px; }
#undiscord .footer #progressPercent { padding: 0 1em; font-size: small; color: var(--text-muted, #949ba4); flex-grow: 1; }
.resize-handle { position: absolute; bottom: -15px; right: -15px; width: 30px; height: 30px; transform: rotate(-45deg); background: repeating-linear-gradient(0, var(--border-subtle, #3f4147), var(--border-subtle, #3f4147) 1px, transparent 2px, transparent 4px); cursor: nwse-resize; }
/**** Elements ****/
#undiscord summary { font-size: 16px; font-weight: 500; line-height: 20px; position: relative; overflow: hidden; margin-bottom: 2px; padding: 6px 10px; cursor: pointer; white-space: nowrap; text-overflow: ellipsis; color: var(--interactive-normal, #b5bac1); border-radius: 4px; flex-shrink: 0; }
#undiscord summary:hover { background-color: var(--background-modifier-hover, rgba(78, 80, 88, 0.3)); color: var(--interactive-hover, #dbdee1); }
#undiscord fieldset { padding-left: 8px; }
#undiscord legend a { float: right; text-transform: initial; }
#undiscord progress { height: 8px; margin-top: 4px; flex-grow: 1; accent-color: var(--brand-experiment, #5865f2); }
#undiscord .importJson { display: flex; flex-direction: row; }
#undiscord .importJson button { margin-left: 5px; width: fit-content; }
`);
var dragCss = (`
[name^="grab-"] { position: absolute; --size: 6px; --corner-size: 16px; --offset: -1px; z-index: 9; }
[name^="grab-"]:hover{ background: rgba(128,128,128,0.1); }
[name="grab-t"] { top: 0px; left: var(--corner-size); right: var(--corner-size); height: var(--size); margin-top: var(--offset); cursor: ns-resize; }
[name="grab-r"] { top: var(--corner-size); bottom: var(--corner-size); right: 0px; width: var(--size); margin-right: var(--offset);
cursor: ew-resize; }
[name="grab-b"] { bottom: 0px; left: var(--corner-size); right: var(--corner-size); height: var(--size); margin-bottom: var(--offset); cursor: ns-resize; }
[name="grab-l"] { top: var(--corner-size); bottom: var(--corner-size); left: 0px; width: var(--size); margin-left: var(--offset); cursor: ew-resize; }
[name="grab-tl"] { top: 0px; left: 0px; width: var(--corner-size); height: var(--corner-size); margin-top: var(--offset); margin-left: var(--offset); cursor: nwse-resize; }
[name="grab-tr"] { top: 0px; right: 0px; width: var(--corner-size); height: var(--corner-size); margin-top: var(--offset); margin-right: var(--offset); cursor: nesw-resize; }
[name="grab-br"] { bottom: 0px; right: 0px; width: var(--corner-size); height: var(--corner-size); margin-bottom: var(--offset); margin-right: var(--offset); cursor: nwse-resize; }
[name="grab-bl"] { bottom: 0px; left: 0px; width: var(--corner-size); height: var(--corner-size); margin-bottom: var(--offset); margin-left: var(--offset); cursor: nesw-resize; }
`);
var buttonHtml = (`
<div id="undicord-btn" tabindex="0" role="button" aria-label="Delete Messages" title="Delete Messages with Undiscord">
<svg aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z"></path>
<path fill="currentColor" d="M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z"></path>
</svg>
<progress></progress>
</div>
`);
var undiscordTemplate = (`
<div id="undiscord" class="browser container redact" style="display:none;">
<div class="header">
<svg class="icon" aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M15 3.999V2H9V3.999H3V5.999H21V3.999H15Z"></path>
<path fill="currentColor"
d="M5 6.99902V18.999C5 20.101 5.897 20.999 7 20.999H17C18.103 20.999 19 20.101 19 18.999V6.99902H5ZM11 17H9V11H11V17ZM15 17H13V11H15V17Z">
</path>
</svg>
<h3>Undiscord</h3>
<div class="vert-divider"></div>
<span> Bulk delete messages</span>
<div class="spacer"></div>
<div id="hide" class="icon" aria-label="Close" role="button" tabindex="0">
<svg aria-hidden="false" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor"
d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z">
</path>
</svg>
</div>
</div>
<div class="window-body" style="display: flex; flex-direction: row;">
<div class="sidebar scroll">
<details open>
<summary>General</summary>
<fieldset>
<legend>
Author ID
<a href="{{WIKI}}/authorId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="multiInput">
<div class="input-wrapper">
<input class="input" id="authorId" type="text" priv>
</div>
<button id="getAuthor">me</button>
</div>
</fieldset>
<hr>
<fieldset>
<legend>
Server ID
<a href="{{WIKI}}/guildId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="multiInput">
<div class="input-wrapper">
<input class="input" id="guildId" type="text" priv>
</div>
<button id="getGuild">current</button>
</div>
</fieldset>
<fieldset>
<legend>
Channel ID
<a href="{{WIKI}}/channelId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="multiInput mb1">
<div class="input-wrapper">
<input class="input" id="channelId" type="text" priv>
</div>
<button id="getChannel">current</button>
</div>
<div class="sectionDescription">
<label class="row"><input id="includeNsfw" type="checkbox">This is a NSFW channel</label>
</div>
</fieldset>
</details>
<details>
<summary>Wipe Archive</summary>
<fieldset>
<legend>
Import index.json
<a href="{{WIKI}}/importJson" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="input-wrapper">
<input type="file" id="importJsonInput" accept="application/json,.json" style="width:100%";>
</div>
<div class="sectionDescription">
<br>
After requesting your data from discord, you can import it here.<br>
Select the "messages/index.json" file from the discord archive.
</div>
</fieldset>
</details>
<hr>
<details>
<summary>Filter</summary>
<fieldset>
<legend>
Search
<a href="{{WIKI}}/filters" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="input-wrapper">
<input id="search" type="text" placeholder="Containing text" priv>
</div>
<div class="sectionDescription">
Only delete messages that contain the text
</div>
<div class="sectionDescription">
<label><input id="hasLink" type="checkbox">has: link</label>
</div>
<div class="sectionDescription">
<label><input id="hasFile" type="checkbox">has: file</label>
</div>
<div class="sectionDescription">
<label><input id="includePinned" type="checkbox">Include pinned</label>
</div>
</fieldset>
<hr>
<fieldset>
<legend>
Pattern
<a href="{{WIKI}}/pattern" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="sectionDescription">
Delete messages that match the regular expression
</div>
<div class="input-wrapper">
<span class="info">/</span>
<input id="pattern" type="text" placeholder="regular expression" priv>
<span class="info">/</span>
</div>
</fieldset>
</details>
<details>
<summary>Messages interval</summary>
<fieldset>
<legend>
Interval of messages
<a href="{{WIKI}}/messageId" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="multiInput mb1">
<div class="input-wrapper">
<input id="minId" type="text" placeholder="After a message" priv>
</div>
<button id="pickMessageAfter">Pick</button>
</div>
<div class="multiInput">
<div class="input-wrapper">
<input id="maxId" type="text" placeholder="Before a message" priv>
</div>
<button id="pickMessageBefore">Pick</button>
</div>
<div class="sectionDescription">
Specify an interval to delete messages.
</div>
</fieldset>
</details>
<details>
<summary>Date interval</summary>
<fieldset>
<legend>
After date
<a href="{{WIKI}}/dateRange" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="input-wrapper mb1">
<input id="minDate" type="datetime-local" title="Messages posted AFTER this date">
</div>
<legend>
Before date
<a href="{{WIKI}}/dateRange" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="input-wrapper">
<input id="maxDate" type="datetime-local" title="Messages posted BEFORE this date">
</div>
<div class="sectionDescription">
Delete messages that were posted between the two dates.
</div>
<div class="sectionDescription">
* Filtering by date doesn't work if you use the "Messages interval".
</div>
</fieldset>
</details>
<hr>
<details>
<summary>Advanced settings</summary>
<fieldset>
<legend>
Search delay
<a href="{{WIKI}}/delay" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="input-wrapper">
<input id="searchDelay" type="range" value="30000" step="100" min="100" max="60000">
<div id="searchDelayValue"></div>
</div>
</fieldset>
<fieldset>
<legend>
Delete delay
<a href="{{WIKI}}/delay" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="input-wrapper">
<input id="deleteDelay" type="range" value="1000" step="50" min="50" max="10000">
<div id="deleteDelayValue"></div>
</div>
<br>
<div class="sectionDescription">
This will affect the speed in which the messages are deleted.
Use the help link for more information.
</div>
</fieldset>
<hr>
<fieldset>
<legend>
Authorization Token
<a href="{{WIKI}}/authToken" title="Help" target="_blank" rel="noopener noreferrer">help</a>
</legend>
<div class="multiInput">
<div class="input-wrapper">
<input class="input" id="token" type="text" autocomplete="dont" priv>
</div>
<button id="getToken">fill</button>
</div>
</fieldset>
</details>
<hr>
<details>
<summary>Auto-purge</summary>
<fieldset class="apBox">
<legend>Auto-purge</legend>
<div class="sectionDescription">Apaga automaticamente mensagens com mais de uma certa idade, num ou mais canais, enquanto o Discord estiver aberto.</div>
<div class="sectionDescription"><label class="row"><input id="apEnabled" type="checkbox">Ativar auto-purge</label></div>
<fieldset class="apTargetsBox">
<legend>Canais</legend>
<div id="apTargets" class="mb1"></div>
<div class="multiInput mb1">
<div class="input-wrapper"><input class="input" id="apGuildId" type="text" placeholder="Server ID" priv></div>
<button id="apGetGuild">current</button>
</div>
<div class="multiInput mb1">
<div class="input-wrapper"><input class="input" id="apChannelId" type="text" placeholder="Channel ID" priv></div>
<button id="apGetChannel">current</button>
</div>
<div class="multiInput mb1">
<div class="input-wrapper"><select class="input" id="apTtl" title="Idade mínima"><option value="24">24 horas</option><option value="48">48 horas</option><option value="168">7 dias</option><option value="360">15 dias</option></select></div>
<button id="apAdd">Adicionar</button>
</div>
</fieldset>
<fieldset>
<legend>Intervalo de verificação (min)</legend>
<div class="input-wrapper"><input class="input" id="apInterval" type="number" min="60" max="10080" step="5" value="300"></div>
</fieldset>
<fieldset>
<legend>Máximo de mensagens por volta</legend>
<div class="input-wrapper"><input class="input" id="apMaxPerRun" type="number" min="1" max="5000" value="500"></div>
</fieldset>
<div class="sectionDescription"><label class="row"><input id="apMyOnly" type="checkbox" checked>Só as minhas mensagens</label></div>
<div class="row">
<button id="apSave" class="sizeMedium positive">Guardar</button>
<button id="apDryRun" class="sizeMedium" title="Conta o que seria apagado, sem apagar nada">Ensaiar</button>
</div>
<div id="apStatus" class="info"></div>
<div id="apHistory" class="info"></div>
</fieldset>
</details>
<hr>
<div></div>
<div class="info">
Undiscord {{VERSION}} (fuzzalab)
<br> a partir do <a href="{{HOME}}" target="_blank" rel="noopener noreferrer">Undiscord</a> de victornpb
</div>
</div>
<div class="main col">
<div class="tbar col">
<div class="row">
<button id="toggleSidebar" class="sizeMedium icon">☰</button>
<button id="start" class="sizeMedium danger" style="width: 150px;" title="Start the deletion process">▶︎ Delete</button>
<button id="stop" class="sizeMedium" title="Stop the deletion process" disabled>🛑 Stop</button>
<button id="clear" class="sizeMedium">Clear log</button>
<label class="row" title="Hide sensitive information on your screen for taking screenshots">
<input id="redact" type="checkbox" checked> Streamer mode
</label>
</div>
<div class="row">
<progress id="progressBar" style="display:none;"></progress>
</div>
</div>
<pre id="logArea" class="logarea scroll">
<center>
<div>Star <a href="{{HOME}}" target="_blank" rel="noopener noreferrer">this project</a> on GitHub!</div>
<div><a href="{{HOME}}/discussions" target="_blank" rel="noopener noreferrer">Issues or help</a></div>
</center>
</pre>
<div class="tbar footer row">
<div id="progressPercent"></div>
<span class="spacer"></span>
<label>
<input id="autoScroll" type="checkbox" checked> Auto scroll
</label>
<div class="resize-handle"></div>
</div>
</div>
</div>
</div>
`);
const log = {
debug() { return logFn ? logFn('debug', arguments) : console.debug.apply(console, arguments); },
info() { return logFn ? logFn('info', arguments) : console.info.apply(console, arguments); },
verb() { return logFn ? logFn('verb', arguments) : console.log.apply(console, arguments); },
warn() { return logFn ? logFn('warn', arguments) : console.warn.apply(console, arguments); },
error() { return logFn ? logFn('error', arguments) : console.error.apply(console, arguments); },
success() { return logFn ? logFn('success', arguments) : console.info.apply(console, arguments); },
};
var logFn; // custom console.log function
const setLogFn = (fn) => logFn = fn;
// Helpers
const wait = async ms => new Promise(done => setTimeout(done, ms));
const msToHMS = s => `${s / 3.6e6 | 0}h ${(s % 3.6e6) / 6e4 | 0}m ${(s % 6e4) / 1000 | 0}s`;
const escapeHTML = html => String(html).replace(/[&<"']/g, m => ({ '&': '&', '<': '<', '"': '"', '\'': ''' })[m]);
const redact = str => `<x>${escapeHTML(str)}</x>`;
const queryString = params => params.filter(p => p[1] !== undefined).map(p => p[0] + '=' + encodeURIComponent(p[1])).join('&');
const ask = async msg => new Promise(resolve => setTimeout(() => resolve(window.confirm(msg)), 10));
const toSnowflake = (date) => /:/.test(date) ? ((new Date(date).getTime() - 1420070400000) * Math.pow(2, 22)) : date;
const replaceInterpolations = (str, obj, removeMissing = false) => str.replace(/\{\{([\w_]+)\}\}/g, (m, key) => obj[key] || (removeMissing ? '' : m));
const PREFIX$1 = '[UNDISCORD]';
/**
* Delete all messages in a Discord channel or DM
* @author Victornpb <https://www.github.com/victornpb>
* @see https://github.com/victornpb/undiscord
*/
class UndiscordCore {
options = {
authToken: null, // Your authorization token
authorId: null, // Author of the messages you want to delete
guildId: null, // Server were the messages are located
channelId: null, // Channel were the messages are located
minId: null, // Only delete messages after this, leave blank do delete all
maxId: null, // Only delete messages before this, leave blank do delete all
content: null, // Filter messages that contains this text content
hasLink: null, // Filter messages that contains link
hasFile: null, // Filter messages that contains file
includeNsfw: null, // Search in NSFW channels
includePinned: null, // Delete messages that are pinned
pattern: null, // Only delete messages that match the regex (insensitive)
searchDelay: null, // Delay each time we fetch for more messages
deleteDelay: null, // Delay between each delete operation
maxAttempt: 2, // Attempts to delete a single message if it fails
maxDelete: null, // Stop after deleting this many messages (null = no limit)
askForConfirmation: true,
};
state = {
running: false,
delCount: 0,
failCount: 0,
grandTotal: 0,
offset: 0,
iterations: 0,
_seachResponse: null,
_messagesToDelete: [],
_skippedMessages: [],
};
stats = {
startTime: new Date(), // start time
throttledCount: 0, // how many times you have been throttled
throttledTotalTime: 0, // the total amount of time you spent being throttled
lastPing: null, // the most recent ping
avgPing: null, // average ping used to calculate the estimated remaining time
etr: 0,
};
// events
onStart = undefined;
onProgress = undefined;
onStop = undefined;
resetState() {
this.state = {
running: false,
delCount: 0,
failCount: 0,
grandTotal: 0,
offset: 0,
iterations: 0,
_seachResponse: null,
_messagesToDelete: [],
_skippedMessages: [],
limitReached: false,
};
this.options.askForConfirmation = true;
}
/** Automate the deletion process of multiple channels */
async runBatch(queue) {
if (this.state.running) return log.error('Already running!');
log.info(`Runnning batch with queue of ${queue.length} jobs`);
for (let i = 0; i < queue.length; i++) {
const job = queue[i];
log.info('Starting job...', `(${i + 1}/${queue.length})`);
// set options
this.options = {
...this.options, // keep current options
...job, // override with options for that job
};
await this.run(true);
if (!this.state.running) break;
log.info('Job ended.', `(${i + 1}/${queue.length})`);
this.resetState();
this.options.askForConfirmation = false;
this.state.running = true; // continue running
}
log.info('Batch finished.');
this.state.running = false;
}
/** Start the deletion process */
async run(isJob = false) {
if (this.state.running && !isJob) return log.error('Already running!');
this.state.running = true;
this.stats.startTime = new Date();
log.success(`\nStarted at ${this.stats.startTime.toLocaleString()}`);
log.debug(
`authorId = "${redact(this.options.authorId)}"`,
`guildId = "${redact(this.options.guildId)}"`,
`channelId = "${redact(this.options.channelId)}"`,
`minId = "${redact(this.options.minId)}"`,
`maxId = "${redact(this.options.maxId)}"`,
`hasLink = ${!!this.options.hasLink}`,
`hasFile = ${!!this.options.hasFile}`,
);
if (this.onStart) this.onStart(this.state, this.stats);
do {
this.state.iterations++;
log.verb('Fetching messages...');
// Search messages
await this.search();
// Process results and find which messages should be deleted
await this.filterResponse();
log.verb(
`Grand total: ${this.state.grandTotal}`,
`(Messages in current page: ${this.state._seachResponse.messages.length}`,
`To be deleted: ${this.state._messagesToDelete.length}`,
`Skipped: ${this.state._skippedMessages.length})`,
`offset: ${this.state.offset}`
);
this.printStats();
// Calculate estimated time
this.calcEtr();
log.verb(`Estimated time remaining: ${msToHMS(this.stats.etr)}`);
// if there are messages to delete, delete them
if (this.state._messagesToDelete.length > 0) {
if (await this.confirm() === false) {
this.state.running = false; // break out of a job
break; // immmediately stop this iteration
}
await this.deleteMessagesFromList();
if (!this.state.running) break; // parado (Stop ou limite): não esperar pela página seguinte
}
else if (this.state._skippedMessages.length > 0) {
// There are stuff, but nothing to delete (example a page full of system messages)
// check next page until we see a page with nothing in it (end of results).
const oldOffset = this.state.offset;
this.state.offset += this.state._skippedMessages.length;
log.verb('There\'s nothing we can delete on this page, checking next page...');
log.verb(`Skipped ${this.state._skippedMessages.length} out of ${this.state._seachResponse.messages.length} in this page.`, `(Offset was ${oldOffset}, ajusted to ${this.state.offset})`);
}
else {
log.verb('Ended because API returned an empty page.');
log.verb('[End state]', this.state);
if (isJob) break; // break without stopping if this is part of a job
this.state.running = false;
}
// wait before next page (fix search page not updating fast enough)
log.verb(`Waiting ${(this.options.searchDelay / 1000).toFixed(2)}s before next page...`);
await wait(this.options.searchDelay);
} while (this.state.running);
this.stats.endTime = new Date();
log.success(`Ended at ${this.stats.endTime.toLocaleString()}! Total time: ${msToHMS(this.stats.endTime.getTime() - this.stats.startTime.getTime())}`);
this.printStats();
log.debug(`Deleted ${this.state.delCount} messages, ${this.state.failCount} failed.\n`);
if (this.onStop) this.onStop(this.state, this.stats);
}
stop() {
this.state.running = false;
if (this.onStop) this.onStop(this.state, this.stats);
}
/** Calculate the estimated time remaining based on the current stats */
calcEtr() {
this.stats.etr = (this.options.searchDelay * Math.round(this.state.grandTotal / 25)) + ((this.options.deleteDelay + this.stats.avgPing) * this.state.grandTotal);
}
/** As for confirmation in the beggining process */
async confirm() {
if (!this.options.askForConfirmation) return true;
log.verb('Waiting for your confirmation...');
const preview = this.state._messagesToDelete.map(m => `${m.author.username}#${m.author.discriminator}: ${m.attachments.length ? '[ATTACHMENTS]' : m.content}`).join('\n');
const answer = await ask(
`Do you want to delete ~${this.state.grandTotal} messages? (Estimated time: ${msToHMS(this.stats.etr)})` +
'(The actual number of messages may be less, depending if you\'re using filters to skip some messages)' +
'\n\n---- Preview ----\n' +
preview
);
if (!answer) {
log.error('Aborted by you!');
return false;
}
else {
log.verb('OK');
this.options.askForConfirmation = false; // do not ask for confirmation again on the next request
return true;
}
}
async search(retries = 0) {
let API_SEARCH_URL;
if (this.options.guildId === '@me') API_SEARCH_URL = `https://discord.com/api/v9/channels/${this.options.channelId}/messages/`; // DMs
else API_SEARCH_URL = `https://discord.com/api/v9/guilds/${this.options.guildId}/messages/`; // Server
let resp;
try {
this.beforeRequest();
resp = await fetch(API_SEARCH_URL + 'search?' + queryString([
['author_id', this.options.authorId || undefined],
['channel_id', (this.options.guildId !== '@me' ? this.options.channelId : undefined) || undefined],
['min_id', this.options.minId ? toSnowflake(this.options.minId) : undefined],
['max_id', this.options.maxId ? toSnowflake(this.options.maxId) : undefined],
['sort_by', 'timestamp'],
['sort_order', 'desc'],
['offset', this.state.offset],
['has', this.options.hasLink ? 'link' : undefined],
['has', this.options.hasFile ? 'file' : undefined],
['content', this.options.content || undefined],
['include_nsfw', this.options.includeNsfw ? true : undefined],
]), {
headers: {
'Authorization': this.options.authToken,
}
});
this.afterRequest();
} catch (err) {
this.state.running = false;
log.error('Search request threw an error:', err);
throw err;
}
// limite de novas tentativas: sem isto, um 429/202 persistente recursava para sempre
const MAX_SEARCH_RETRIES = 10;
if ((resp.status === 202 || resp.status === 429) && retries >= MAX_SEARCH_RETRIES) {
this.state.running = false;
log.error(`A pesquisa falhou ${MAX_SEARCH_RETRIES} vezes seguidas (status ${resp.status}). A desistir.`);
throw new Error('search retries exhausted');
}
// not indexed yet
if (resp.status === 202) {
let w = (await resp.json()).retry_after * 1000;
w = w || this.options.searchDelay || 30000; // Fix retry_after 0
this.stats.throttledCount++;
this.stats.throttledTotalTime += w;
log.warn(`This channel isn't indexed yet. Waiting ${w}ms for discord to index it...`);
await wait(w);
return await this.search(retries + 1);
}
if (!resp.ok) {
// searching messages too fast
if (resp.status === 429) {
let w = (await resp.json()).retry_after * 1000;
w = w || this.options.searchDelay || 30000; // Fix retry_after 0
this.stats.throttledCount++;
this.stats.throttledTotalTime += w;
// increase delay (com teto de 2 min, para não crescer sem fim numa sessão longa)
this.options.searchDelay = Math.min((this.options.searchDelay || 0) + w, 120000);
w = this.options.searchDelay;
log.warn(`Being rate limited by the API for ${w}ms! Increasing search delay...`);
this.printStats();
log.verb(`Cooling down for ${w * 2}ms before retrying...`);
await wait(w * 2);
return await this.search(retries + 1);
}
else {
this.state.running = false;
log.error(`Error searching messages, API responded with status ${resp.status}!\n`, await resp.json());
throw resp;
}
}
const data = await resp.json();
this.state._seachResponse = data;
console.log(PREFIX$1, 'search', data);
return data;
}
async filterResponse() {
const data = this.state._seachResponse;
// the search total will decrease as we delete stuff
const total = data.total_results;
if (total > this.state.grandTotal) this.state.grandTotal = total;
// search returns messages near the the actual message, only get the messages we searched for.
const discoveredMessages = data.messages.map(convo => convo.find(message => message.hit === true));
// we can only delete some types of messages, system messages are not deletable.
let messagesToDelete = discoveredMessages;
messagesToDelete = messagesToDelete.filter(msg => msg.type === 0 || (msg.type >= 6 && msg.type <= 21));
messagesToDelete = messagesToDelete.filter(msg => msg.pinned ? this.options.includePinned : true);
// custom filter of messages (só quando há um padrão definido;
// um pattern null/vazio não deve filtrar nada)
if (this.options.pattern) {
try {
const regex = new RegExp(this.options.pattern, 'i');
messagesToDelete = messagesToDelete.filter(msg => regex.test(msg.content));
} catch (e) {
log.warn('Ignoring RegExp because pattern is malformed!', e);
}
}
// create an array containing everything we skipped. (used to calculate offset for next searches)
const skippedMessages = discoveredMessages.filter(msg => !messagesToDelete.find(m => m.id === msg.id));
this.state._messagesToDelete = messagesToDelete;
this.state._skippedMessages = skippedMessages;
console.log(PREFIX$1, 'filterResponse', this.state);
}
async deleteMessagesFromList() {
for (let i = 0; i < this.state._messagesToDelete.length; i++) {
const message = this.state._messagesToDelete[i];
if (!this.state.running) return log.error('Stopped by you!');
if (this.options.maxDelete && this.state.delCount >= this.options.maxDelete) {
this.state.running = false;
this.state.limitReached = true;
return log.warn(`Limite de ${this.options.maxDelete} mensagens atingido; o resto fica para a próxima volta.`);
}
log.debug(
// `${((this.state.delCount + 1) / this.state.grandTotal * 100).toFixed(2)}%`,
`[${this.state.delCount + 1}/${this.state.grandTotal}] ` +
`<sup>${new Date(message.timestamp).toLocaleString()}</sup> ` +
`<b>${redact(message.author.username + '#' + message.author.discriminator)}</b>` +
`: <i>${redact(message.content).replace(/\n/g, '↵')}</i>` +
(message.attachments.length ? redact(JSON.stringify(message.attachments)) : ''),
`<sup>{ID:${redact(message.id)}}</sup>`
);
// Delete a single message (with retry)
let attempt = 0;
while (attempt < this.options.maxAttempt) {
const result = await this.deleteMessage(message);
if (result === 'RETRY') {
attempt++;
log.verb(`Retrying in ${this.options.deleteDelay}ms... (${attempt}/${this.options.maxAttempt})`);
await wait(this.options.deleteDelay);
}
else break;
}
this.calcEtr();
if (this.onProgress) this.onProgress(this.state, this.stats);
await wait(this.options.deleteDelay);
}
}
async deleteMessage(message) {
const API_DELETE_URL = `https://discord.com/api/v9/channels/${message.channel_id}/messages/${message.id}`;
let resp;
try {
this.beforeRequest();
resp = await fetch(API_DELETE_URL, {
method: 'DELETE',
headers: {
'Authorization': this.options.authToken,
},
});
this.afterRequest();
} catch (err) {
// no response error (e.g. network error)
log.error('Delete request throwed an error:', err);
log.verb('Related object:', redact(JSON.stringify(message)));
this.state.failCount++;
return 'FAILED';
}
if (!resp.ok) {
if (resp.status === 429) {
// deleting messages too fast
const w = (await resp.json()).retry_after * 1000 || this.options.deleteDelay || 1000;
this.stats.throttledCount++;
this.stats.throttledTotalTime += w;
this.options.deleteDelay = Math.max(this.options.deleteDelay || 0, w); // increase delay (nunca baixar)
log.warn(`Being rate limited by the API for ${w}ms! Adjusted delete delay to ${this.options.deleteDelay}ms.`);
this.printStats();
log.verb(`Cooling down for ${w * 2}ms before retrying...`);
await wait(w * 2);
return 'RETRY';
} else {
const body = await resp.text();
try {
const r = JSON.parse(body);
if (resp.status === 400 && r.code === 50083) {
// 400 can happen if the thread is archived (code=50083)
// in this case we need to "skip" this message from the next search
// otherwise it will come up again in the next page (and fail to delete again)
log.warn('Error deleting message (Thread is archived). Will increment offset so we don\'t search this in the next page...');
this.state.offset++;
this.state.failCount++;
return 'FAIL_SKIP'; // Failed but we will skip it next time
}
log.error(`Error deleting message, API responded with status ${resp.status}!`, r);
log.verb('Related object:', redact(JSON.stringify(message)));
this.state.failCount++;
return 'FAILED';
} catch (e) {
log.error(`Fail to parse JSON. API responded with status ${resp.status}!`, body);
}
}
}
this.state.delCount++;
return 'OK';
}
#beforeTs = 0; // used to calculate latency
beforeRequest() {
this.#beforeTs = Date.now();
}
afterRequest() {
this.stats.lastPing = (Date.now() - this.#beforeTs);
this.stats.avgPing = this.stats.avgPing > 0 ? (this.stats.avgPing * 0.9) + (this.stats.lastPing * 0.1) : this.stats.lastPing;
}
printStats() {
log.verb(
`Delete delay: ${this.options.deleteDelay}ms, Search delay: ${this.options.searchDelay}ms`,
`Last Ping: ${this.stats.lastPing}ms, Average Ping: ${this.stats.avgPing | 0}ms`,
);
log.verb(
`Rate Limited: ${this.stats.throttledCount} times.`,
`Total time throttled: ${msToHMS(this.stats.throttledTotalTime)}.`
);
}
}
const MOVE = 0;
const RESIZE_T = 1;
const RESIZE_B = 2;
const RESIZE_L = 4;
const RESIZE_R = 8;
const RESIZE_TL = RESIZE_T + RESIZE_L;
const RESIZE_TR = RESIZE_T + RESIZE_R;
const RESIZE_BL = RESIZE_B + RESIZE_L;
const RESIZE_BR = RESIZE_B + RESIZE_R;
/**
* Make an element draggable/resizable
* @author Victor N. wwww.vitim.us
*/
class DragResize {
constructor({ elm, moveHandle, options }) {
this.options = defaultArgs({
enabledDrag: true,
enabledResize: true,
minWidth: 200,
maxWidth: Infinity,
minHeight: 100,
maxHeight: Infinity,
dragAllowX: true,
dragAllowY: true,
resizeAllowX: true,
resizeAllowY: true,
draggingClass: 'drag',
useMouseEvents: true,
useTouchEvents: true,
createHandlers: true,
}, options);
Object.assign(this, options);
options = undefined;
elm.style.position = 'fixed';
this.drag_m = new Draggable(elm, moveHandle, MOVE, this.options);
if (this.options.createHandlers) {
this.el_t = createElement('div', { name: 'grab-t' }, elm);
this.drag_t = new Draggable(elm, this.el_t, RESIZE_T, this.options);
this.el_r = createElement('div', { name: 'grab-r' }, elm);
this.drag_r = new Draggable(elm, this.el_r, RESIZE_R, this.options);
this.el_b = createElement('div', { name: 'grab-b' }, elm);
this.drag_b = new Draggable(elm, this.el_b, RESIZE_B, this.options);
this.el_l = createElement('div', { name: 'grab-l' }, elm);
this.drag_l = new Draggable(elm, this.el_l, RESIZE_L, this.options);
this.el_tl = createElement('div', { name: 'grab-tl' }, elm);
this.drag_tl = new Draggable(elm, this.el_tl, RESIZE_TL, this.options);
this.el_tr = createElement('div', { name: 'grab-tr' }, elm);
this.drag_tr = new Draggable(elm, this.el_tr, RESIZE_TR, this.options);
this.el_br = createElement('div', { name: 'grab-br' }, elm);
this.drag_br = new Draggable(elm, this.el_br, RESIZE_BR, this.options);
this.el_bl = createElement('div', { name: 'grab-bl' }, elm);
this.drag_bl = new Draggable(elm, this.el_bl, RESIZE_BL, this.options);
}
}
}
class Draggable {
constructor(targetElm, handleElm, op, options) {
Object.assign(this, options);
options = undefined;
this._targetElm = targetElm;
this._handleElm = handleElm;
let vw = window.innerWidth;
let vh = window.innerHeight;
let initialX, initialY, initialT, initialL, initialW, initialH;
const clamp = (value, min, max) => value < min ? min : value > max ? max : value;
const moveOp = (x, y) => {
const deltaX = (x - initialX);
const deltaY = (y - initialY);
const t = clamp(initialT + deltaY, 0, vh - initialH);
const l = clamp(initialL + deltaX, 0, vw - initialW);
this._targetElm.style.top = t + 'px';
this._targetElm.style.left = l + 'px';
};
const resizeOp = (x, y) => {
x = clamp(x, 0, vw);
y = clamp(y, 0, vh);
const deltaX = (x - initialX);
const deltaY = (y - initialY);
const resizeDirX = (op & RESIZE_L) ? -1 : 1;
const resizeDirY = (op & RESIZE_T) ? -1 : 1;
const deltaXMax = (this.maxWidth - initialW);
const deltaXMin = (this.minWidth - initialW);
const deltaYMax = (this.maxHeight - initialH);
const deltaYMin = (this.minHeight - initialH);
const t = initialT + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax) * resizeDirY;
const l = initialL + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax) * resizeDirX;
const w = initialW + clamp(deltaX * resizeDirX, deltaXMin, deltaXMax);
const h = initialH + clamp(deltaY * resizeDirY, deltaYMin, deltaYMax);
if (op & RESIZE_T) { // resize ↑
this._targetElm.style.top = t + 'px';
this._targetElm.style.height = h + 'px';
}
if (op & RESIZE_B) { // resize ↓
this._targetElm.style.height = h + 'px';
}
if (op & RESIZE_L) { // resize ←
this._targetElm.style.left = l + 'px';
this._targetElm.style.width = w + 'px';
}
if (op & RESIZE_R) { // resize →
this._targetElm.style.width = w + 'px';
}
};
let operation = op === MOVE ? moveOp : resizeOp;
function dragStartHandler(e) {
const touch = e.type === 'touchstart';
if ((e.buttons === 1 || e.which === 1) || touch) {
e.preventDefault();
const x = touch ? e.touches[0].clientX : e.clientX;
const y = touch ? e.touches[0].clientY : e.clientY;
initialX = x;
initialY = y;
vw = window.innerWidth;
vh = window.innerHeight;
initialT = this._targetElm.offsetTop;
initialL = this._targetElm.offsetLeft;
initialW = this._targetElm.clientWidth;
initialH = this._targetElm.clientHeight;
if (this.useMouseEvents) {
document.addEventListener('mousemove', this._dragMoveHandler);
document.addEventListener('mouseup', this._dragEndHandler);
}
if (this.useTouchEvents) {
document.addEventListener('touchmove', this._dragMoveHandler, { passive: false });
document.addEventListener('touchend', this._dragEndHandler);
}
this._targetElm.classList.add(this.draggingClass);
}
}
function dragMoveHandler(e) {
e.preventDefault();
let x, y;
const touch = e.type === 'touchmove';
if (touch) {
const t = e.touches[0];
x = t.clientX;
y = t.clientY;
} else { //mouse
// If the button is not down, dispatch a "fake" mouse up event, to stop listening to mousemove
// This happens when the mouseup is not captured (outside the browser)
if ((e.buttons || e.which) !== 1) {
this._dragEndHandler();
return;
}
x = e.clientX;
y = e.clientY;
}
// perform drag / resize operation
operation(x, y);
}
function dragEndHandler(e) {
if (this.useMouseEvents) {
document.removeEventListener('mousemove', this._dragMoveHandler);
document.removeEventListener('mouseup', this._dragEndHandler);
}
if (this.useTouchEvents) {
document.removeEventListener('touchmove', this._dragMoveHandler);
document.removeEventListener('touchend', this._dragEndHandler);
}
this._targetElm.classList.remove(this.draggingClass);
}
// We need to bind the handlers to this instance
this._dragStartHandler = dragStartHandler.bind(this);
this._dragMoveHandler = dragMoveHandler.bind(this);
this._dragEndHandler = dragEndHandler.bind(this);
this.enable();
}
/** Turn on the drag and drop of the instance */
enable() {
this.destroy(); // prevent events from getting binded twice
if (this.useMouseEvents) this._handleElm.addEventListener('mousedown', this._dragStartHandler);
if (this.useTouchEvents) this._handleElm.addEventListener('touchstart', this._dragStartHandler, { passive: false });
}
/** Teardown all events bound to the document and elements. You can resurrect this instance by calling enable() */
destroy() {
this._targetElm.classList.remove(this.draggingClass);
if (this.useMouseEvents) {
this._handleElm.removeEventListener('mousedown', this._dragStartHandler);
document.removeEventListener('mousemove', this._dragMoveHandler);
document.removeEventListener('mouseup', this._dragEndHandler);
}
if (this.useTouchEvents) {
this._handleElm.removeEventListener('touchstart', this._dragStartHandler);
document.removeEventListener('touchmove', this._dragMoveHandler);
document.removeEventListener('touchend', this._dragEndHandler);
}
}
}
function createElement(tag='div', attrs, parent) {
const elm = document.createElement(tag);
if (attrs) Object.entries(attrs).forEach(([k, v]) => elm.setAttribute(k, v));
if (parent) parent.appendChild(elm);
return elm;
}
function defaultArgs(defaults, options) {
function isObj(x) { return x !== null && typeof x === 'object'; }
function hasOwn(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
if (isObj(options)) for (let prop in defaults) {
if (hasOwn(defaults, prop) && hasOwn(options, prop) && options[prop] !== undefined) {
if (isObj(defaults[prop])) defaultArgs(defaults[prop], options[prop]);
else defaults[prop] = options[prop];
}
}
return defaults;
}
function createElm(html) {
const temp = document.createElement('div');
temp.innerHTML = html;
return temp.removeChild(temp.firstElementChild);
}
function insertCss(css) {
const style = document.createElement('style');
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
return style;
}
const messagePickerCss = `
body.undiscord-pick-message [data-list-id="chat-messages"] {
background-color: var(--background-base-lower, #313338);
box-shadow: inset 0 0 0px 2px var(--brand-experiment, #5865f2);
}
body.undiscord-pick-message [id^="message-content-"]:hover {
cursor: pointer;
cursor: cell;
background: var(--background-message-hover, rgba(4, 4, 5, 0.07));
}
body.undiscord-pick-message [id^="message-content-"]:hover::after {
position: absolute;
top: calc(50% - 11px);
left: 4px;
z-index: 1;
width: 65px;
height: 22px;
line-height: 22px;
font-family: var(--font-display, "gg sans", "Noto Sans", Helvetica, Arial, sans-serif);
background-color: var(--button-secondary-background, #4e5058);
color: var(--text-default, #dbdee1);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
text-align: center;
border-radius: 3px;
content: 'This 👉';
}
body.undiscord-pick-message.before [id^="message-content-"]:hover::after {
content: 'Before 👆';
}
body.undiscord-pick-message.after [id^="message-content-"]:hover::after {
content: 'After 👇';
}
`;
const messagePicker = {
init() {
insertCss(messagePickerCss);
},
grab(auxiliary) {
return new Promise((resolve, reject) => {
document.body.classList.add('undiscord-pick-message');
if (auxiliary) document.body.classList.add(auxiliary);
function clickHandler(e) {
const message = e.target.closest('[id^="message-content-"]');
if (message) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (auxiliary) document.body.classList.remove(auxiliary);
document.body.classList.remove('undiscord-pick-message');
document.removeEventListener('click', clickHandler);
try {
resolve(message.id.match(/message-content-(\d+)/)[1]);
} catch (e) {
resolve(null);
}
}
}
document.addEventListener('click', clickHandler);
});
}
};
window.messagePicker = messagePicker;
// O Discord apaga window.localStorage; lê-se através de um iframe da mesma
// origem. Um só iframe, reutilizado (antes criava-se um por chamada).
let lsFrame = null;
function getDiscordLocalStorage() {
try { if (window.localStorage) return window.localStorage; } catch (e) { /* bloqueado */ }
if (!lsFrame || !lsFrame.isConnected) {
lsFrame = document.createElement('iframe');
lsFrame.style.display = 'none';
document.body.appendChild(lsFrame);
}
return lsFrame.contentWindow.localStorage;
}
function getToken() {
window.dispatchEvent(new Event('beforeunload'));
const LS = getDiscordLocalStorage();
try {
return JSON.parse(LS.token);
} catch {
log.info('Could not automatically detect Authorization Token in local storage!');
log.info('Attempting to grab token using webpack');
return (window.webpackChunkdiscord_app.push([[''], {}, e => { window.m = []; for (let c in e.c) window.m.push(e.c[c]); }]), window.m).find(m => m?.exports?.default?.getToken !== void 0).exports.default.getToken();
}
}
function getAuthorId() {
const LS = getDiscordLocalStorage();
return JSON.parse(LS.user_id_cache);
}
function getGuildId() {
const m = location.href.match(/channels\/([\w@]+)\/(\d+)/);
if (m) return m[1];
else alert('Could not find the Guild ID!\nPlease make sure you are on a Server or DM.');
}
function getChannelId() {
const m = location.href.match(/channels\/([\w@]+)\/(\d+)/);
if (m) return m[2];
else alert('Could not find the Channel ID!\nPlease make sure you are on a Channel or DM.');
}
function fillToken() {
try {
return getToken();
} catch (err) {
log.verb(err);
log.error('Could not automatically detect Authorization Token!');
log.info('Please make sure Undiscord is up to date');
log.debug('Alternatively, you can try entering a Token manually in the "Advanced Settings" section.');
}
return '';
}
const PREFIX = '[UNDISCORD]';
// -------------------------- User interface ------------------------------- //
// links
const HOME = 'https://github.com/victornpb/undiscord';
const WIKI = 'https://github.com/victornpb/undiscord/wiki';
const undiscordCore = new UndiscordCore();
messagePicker.init();
const ui = {
undiscordWindow: null,
undiscordBtn: null,
logArea: null,
autoScroll: null,
// progress handler
progressMain: null,
progressIcon: null,
percent: null,
};
const $ = s => ui.undiscordWindow.querySelector(s);
function initUI() {
insertCss(themeCss);
insertCss(mainCss);
insertCss(dragCss);
// create undiscord window
const undiscordUI = replaceInterpolations(undiscordTemplate, {
VERSION,
HOME,
WIKI,
});
ui.undiscordWindow = createElm(undiscordUI);
document.body.appendChild(ui.undiscordWindow);
// enable drag and resize on undiscord window
new DragResize({ elm: ui.undiscordWindow, moveHandle: $('.header') });
// create undiscord Trash icon
ui.undiscordBtn = createElm(buttonHtml);
ui.undiscordBtn.onclick = toggleWindow;
function mountBtn() {
const toolbar =
document.querySelector('#app-mount [class*="-toolbar"]') ||
document.querySelector('#app-mount [class*="toolbar"]') ||
document.querySelector('#app-mount [class*="Toolbar"]');
if (toolbar) { toolbar.appendChild(ui.undiscordBtn); return true; }
return false;
}
// atalho Alt+U, para a UI abrir mesmo quando o DOM do Discord muda e o
// icone na toolbar nao monta (o antigo botao flutuante foi removido)
function installShortcut() {
document.addEventListener('keydown', (e) => {
if (e.altKey && (e.key === 'u' || e.key === 'U')) toggleWindow();
});
}
mountBtn();
installShortcut();
// watch for changes and re-mount button if necessary
const discordElm = document.querySelector('#app-mount') || document.body;
let observerThrottle = null;
const observer = new MutationObserver((_mutationsList, _observer) => {
if (observerThrottle) return;
observerThrottle = setTimeout(() => {
observerThrottle = null;
if (!discordElm.contains(ui.undiscordBtn)) mountBtn(); // re-mount the button to the toolbar
}, 3000);
});
observer.observe(discordElm, { attributes: false, childList: true, subtree: true });
function toggleWindow() {
if (ui.undiscordWindow.style.display !== 'none') {
ui.undiscordWindow.style.display = 'none';
ui.undiscordBtn.style.color = 'var(--interactive-normal)';
}
else {
ui.undiscordWindow.style.display = '';
ui.undiscordBtn.style.color = 'var(--interactive-active)';
}
}
// cached elements
ui.logArea = $('#logArea');
ui.autoScroll = $('#autoScroll');
ui.progressMain = $('#progressBar');
ui.progressIcon = ui.undiscordBtn.querySelector('progress');
ui.percent = $('#progressPercent');
// register event listeners
$('#hide').onclick = toggleWindow;
$('#toggleSidebar').onclick = ()=> ui.undiscordWindow.classList.toggle('hide-sidebar');
$('button#start').onclick = startAction;
$('button#stop').onclick = stopAction;
$('button#clear').onclick = () => ui.logArea.innerHTML = '';
$('button#getAuthor').onclick = () => $('input#authorId').value = getAuthorId();
$('button#getGuild').onclick = () => {
const guildId = $('input#guildId').value = getGuildId();
if (guildId === '@me') $('input#channelId').value = getChannelId();
};
$('button#getChannel').onclick = () => {
$('input#channelId').value = getChannelId();
$('input#guildId').value = getGuildId();
};
$('#redact').onchange = () => {
const b = ui.undiscordWindow.classList.toggle('redact');
if (b) alert('This mode will attempt to hide personal information, so you can screen share / take screenshots.\nAlways double check you are not sharing sensitive information!');
};
$('#pickMessageAfter').onclick = async () => {
alert('Select a message on the chat.\nThe message below it will be deleted.');
toggleWindow();
const id = await messagePicker.grab('after');
if (id) $('input#minId').value = id;
toggleWindow();
};
$('#pickMessageBefore').onclick = async () => {
alert('Select a message on the chat.\nThe message above it will be deleted.');
toggleWindow();
const id = await messagePicker.grab('before');
if (id) $('input#maxId').value = id;
toggleWindow();
};
$('button#getToken').onclick = () => $('input#token').value = fillToken();
// sync delays
$('input#searchDelay').onchange = (e) => {
const v = parseInt(e.target.value);
if (v) undiscordCore.options.searchDelay = v;
};
$('input#deleteDelay').onchange = (e) => {
const v = parseInt(e.target.value);
if (v) undiscordCore.options.deleteDelay = v;
};
$('input#searchDelay').addEventListener('input', (event) => {
$('div#searchDelayValue').textContent = event.target.value + 'ms';
});
$('input#deleteDelay').addEventListener('input', (event) => {
$('div#deleteDelayValue').textContent = event.target.value + 'ms';
});
// import json
const fileSelection = $('input#importJsonInput');
fileSelection.onchange = async () => {
const files = fileSelection.files;
// No files added
if (files.length === 0) return log.warn('No file selected.');
// Get channel id field to set it later
const channelIdField = $('input#channelId');
// Force the guild id to be ourself (@me)
const guildIdField = $('input#guildId');
guildIdField.value = '@me';
// Set author id in case its not set already
$('input#authorId').value = getAuthorId();
try {
const file = files[0];
const text = await file.text();
const json = JSON.parse(text);
const channelIds = Object.keys(json);
channelIdField.value = channelIds.join(',');
log.info(`Loaded ${channelIds.length} channels.`);
} catch(err) {
log.error('Error parsing file!', err);
}
};
// redirect console logs to inside the window after setting up the UI
setLogFn(printLog);
setupUndiscordCore();
setupAutoPurge();
}
function printLog(type = '', args) {
ui.logArea.insertAdjacentHTML('beforeend', `<div class="log log-${type}">${Array.from(args).map(o => typeof o === 'object' ? escapeHTML(JSON.stringify(o, o instanceof Error && Object.getOwnPropertyNames(o))) : o).join('\t')}</div>`);
if (ui.autoScroll.checked) ui.logArea.querySelector('div:last-child').scrollIntoView(false);
if (type==='error') console.error(PREFIX, ...Array.from(args));
}
function setupUndiscordCore() {
undiscordCore.onStart = (state, stats) => {
console.log(PREFIX, 'onStart', state, stats);
$('#start').disabled = true;
$('#stop').disabled = false;
ui.undiscordBtn.classList.add('running');
ui.progressMain.style.display = 'block';
ui.percent.style.display = 'block';
};
undiscordCore.onProgress = (state, stats) => {
// console.log(PREFIX, 'onProgress', state, stats);
let max = state.grandTotal;
const value = state.delCount + state.failCount;
max = Math.max(max, value, 0); // clamp max
// status bar
const percent = value >= 0 && max ? Math.round(value / max * 100) + '%' : '';
const elapsed = msToHMS(Date.now() - stats.startTime.getTime());
const remaining = msToHMS(stats.etr);
ui.percent.innerHTML = `${percent} (${value}/${max}) Elapsed: ${elapsed} Remaining: ${remaining}`;
ui.progressIcon.value = value;
ui.progressMain.value = value;
// indeterminate progress bar
if (max) {
ui.progressIcon.setAttribute('max', max);
ui.progressMain.setAttribute('max', max);
} else {
ui.progressIcon.removeAttribute('value');
ui.progressMain.removeAttribute('value');
ui.percent.innerHTML = '...';
}
// update delays
const searchDelayInput = $('input#searchDelay');
searchDelayInput.value = undiscordCore.options.searchDelay;
$('div#searchDelayValue').textContent = undiscordCore.options.searchDelay+'ms';
const deleteDelayInput = $('input#deleteDelay');
deleteDelayInput.value = undiscordCore.options.deleteDelay;
$('div#deleteDelayValue').textContent = undiscordCore.options.deleteDelay+'ms';
};
undiscordCore.onStop = (state, stats) => {
console.log(PREFIX, 'onStop', state, stats);
$('#start').disabled = false;
$('#stop').disabled = true;
ui.undiscordBtn.classList.remove('running');
ui.progressMain.style.display = 'none';
ui.percent.style.display = 'none';
};
}
// -------------------------- Auto-purge ------------------------------- //
const STORAGE_KEY = 'undiscord.autopurge.v2';
const STORAGE_KEY_V1 = 'undiscord.autopurge.v1'; // formato antigo: um só canal
const HISTORY_KEY = 'undiscord.autopurge.history';
// idades mínimas permitidas (horas): 24h, 48h, 7 dias, 15 dias
const AP_TTL_HOURS = [24, 48, 168, 360];
const AP_TTL_LABEL = { 24: '24 horas', 48: '48 horas', 168: '7 dias', 360: '15 dias' };
// intervalo de verificação (min): mínimo de 60 para não fazer verificações
// a mais; máximo de 7 dias, porque o setTimeout aceita no máximo ~24,8 dias
// e acima disso dispararia de imediato, em ciclo
const AP_INTERVAL_MIN = 60;
const AP_INTERVAL_MAX = 10080;
const AP_INTERVAL_DEFAULT = 300;
// travão de segurança: máximo de mensagens apagadas por volta (todos os canais)
const AP_MAX_PER_RUN_MAX = 5000;
const AP_MAX_PER_RUN_DEFAULT = 500;
const AP_MAX_TARGETS = 20;
const AP_HISTORY_SIZE = 10;
const AP_FIRST_RUN_DELAY = 30000; // nunca correr nos primeiros 30s após carregar/ativar
const AP_BUSY_RETRY = 5 * 60000; // se outra limpeza estiver a correr, tentar daqui a 5 min
const apValidTtl = h => AP_TTL_HOURS.includes(h) ? h : AP_TTL_HOURS[0];
const apClamp = (n, min, max, def) => Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : def;
const apClampInterval = n => apClamp(n, AP_INTERVAL_MIN, AP_INTERVAL_MAX, AP_INTERVAL_DEFAULT);
const apClampMaxPerRun = n => apClamp(n, 1, AP_MAX_PER_RUN_MAX, AP_MAX_PER_RUN_DEFAULT);
const apValidGuild = g => /^(\d{15,22}|@me)$/.test(g);
const apValidChannel = c => /^\d{15,22}$/.test(c);
// snowflake do Discord para "agora - TTL" (limite superior da pesquisa)
const apCutoff = ttlHours => (BigInt(Date.now() - ttlHours * 3600000 - 1420070400000) << 22n).toString();
// ±10% no intervalo, para as voltas não caírem sempre à mesma hora
const apJitter = ms => Math.round(ms * (0.9 + Math.random() * 0.2));
const apFmt = ts => new Date(ts).toLocaleString([], { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
let apConfig = {
enabled: false,
targets: [], // [{ guildId, channelId, ttlHours }]
intervalMin: AP_INTERVAL_DEFAULT,
maxPerRun: AP_MAX_PER_RUN_DEFAULT,
myOnly: true,
lastRunAt: 0, // hora da última volta: o agendamento conta a partir daqui
};
let apHistory = [];
let apTimer = null; // setTimeout da próxima volta
let apNextAt = null;
let apRunning = false; // há uma volta (ou ensaio) do auto-purge a correr
function apSanitizeTarget(t) {
if (!t || typeof t !== 'object') return null;
const guildId = String(t.guildId || '').trim();
const channelId = String(t.channelId || '').trim();
if (!apValidGuild(guildId) || !apValidChannel(channelId)) return null;
return { guildId, channelId, ttlHours: apValidTtl(t.ttlHours) };
}
function loadConfig() {
try {
const ls = getDiscordLocalStorage();
let raw = ls.getItem(STORAGE_KEY);
let parsed = raw ? JSON.parse(raw) : null;
if (!parsed) {
// migrar o formato antigo (um só canal) para a lista de canais
const old = JSON.parse(ls.getItem(STORAGE_KEY_V1) || 'null');
if (old && typeof old === 'object') {
parsed = { ...old, targets: [{ guildId: old.guildId, channelId: old.channelId, ttlHours: old.ttlHours }] };
}
}
if (parsed && typeof parsed === 'object') {
if (typeof parsed.enabled === 'boolean') apConfig.enabled = parsed.enabled;
if (Array.isArray(parsed.targets)) apConfig.targets = parsed.targets.map(apSanitizeTarget).filter(Boolean).slice(0, AP_MAX_TARGETS);
// configurações antigas (ex.: 15 min) sobem para o mínimo de 60
if (typeof parsed.intervalMin === 'number') apConfig.intervalMin = apClampInterval(parsed.intervalMin);
if (typeof parsed.maxPerRun === 'number') apConfig.maxPerRun = apClampMaxPerRun(parsed.maxPerRun);
if (typeof parsed.myOnly === 'boolean') apConfig.myOnly = parsed.myOnly;
if (typeof parsed.lastRunAt === 'number') apConfig.lastRunAt = parsed.lastRunAt;
}
const hist = JSON.parse(ls.getItem(HISTORY_KEY) || '[]');
if (Array.isArray(hist)) apHistory = hist.slice(0, AP_HISTORY_SIZE);
} catch (err) {
log.warn('[AUTO-PURGE] Falha ao ler a configuração:', err);
}
}
// devolve true só se, relendo o localStorage, estiver lá o que se gravou
function saveConfig() {
const json = JSON.stringify(apConfig);
try {
const ls = getDiscordLocalStorage();
ls.setItem(STORAGE_KEY, json);
if (ls.getItem(STORAGE_KEY) === json) return true;
throw new Error('o valor relido não coincide com o gravado');
} catch (err) {
log.error('[AUTO-PURGE] A configuração NÃO foi guardada:', err && err.message ? err.message : String(err));
setAutoPurgeStatus('ERRO: o browser não guardou a configuração (vê o log). Ao recarregar, perde-se.');
return false;
}
}
function pushHistory(entry) {
apHistory.unshift(entry);
apHistory = apHistory.slice(0, AP_HISTORY_SIZE);
try { getDiscordLocalStorage().setItem(HISTORY_KEY, JSON.stringify(apHistory)); } catch (e) { /* não é crítico */ }
renderHistory();
}
function renderHistory() {
const box = $('#apHistory');
if (!box) return;
box.textContent = '';
if (!apHistory.length) return;
const title = document.createElement('div');
title.textContent = 'Últimas execuções:';
box.appendChild(title);
for (const h of apHistory) {
const d = document.createElement('div');
let txt = apFmt(h.at) + ' — ';
if (h.dry) txt += `ensaio: ~${h.deleted} por apagar`;
else txt += `${h.deleted} apagadas, ${h.failed} falhas`;
txt += ` (${h.channels} ${h.channels === 1 ? 'canal' : 'canais'})`;
if (h.limited) txt += ' · limite atingido';
if (h.stopped) txt += ' · interrompida';
if (h.error) txt += ' · com erros';
d.textContent = txt;
box.appendChild(d);
}
}
function renderTargets() {
const box = $('#apTargets');
if (!box) return;
box.textContent = '';
if (!apConfig.targets.length) {
const d = document.createElement('div');
d.className = 'info';
d.textContent = 'Nenhum canal guardado. Preenche o Server ID e o Channel ID e carrega em Adicionar.';
box.appendChild(d);
return;
}
apConfig.targets.forEach((t, i) => {
const row = document.createElement('div');
row.className = 'apTarget';
const ids = document.createElement('span');
ids.className = 'apIds';
ids.title = 'Server ' + t.guildId + ' / Channel ' + t.channelId;
const full = document.createElement('span');
full.className = 'apIdFull';
full.textContent = t.guildId + ' / ' + t.channelId;
const masked = document.createElement('span');
masked.className = 'apIdMasked';
const tail = id => id === '@me' ? '@me' : '…' + id.slice(-4);
masked.textContent = tail(t.guildId) + '/' + tail(t.channelId);
ids.append(full, masked);
const age = document.createElement('span');
age.className = 'apAge';
age.textContent = AP_TTL_LABEL[t.ttlHours];
ids.onclick = age.onclick = () => fillFieldsFrom(t);
ids.style.cursor = age.style.cursor = 'pointer';
ids.title += ' (clica para editar nos campos)';
const del = document.createElement('button');
del.textContent = '✕';
del.title = 'Remover este canal';
del.onclick = () => {
apConfig.targets.splice(i, 1);
if ($('#apGuildId').value.trim() === t.guildId && $('#apChannelId').value.trim() === t.channelId) fillFieldsFrom(null);
saveConfig();
renderTargets();
};
row.append(ids, age, del);
box.appendChild(row);
});
}
// lê os campos Server ID / Channel ID / idade e junta-os à lista
// (se o canal já lá estiver, só atualiza a idade); devolve false se inválido
function addTargetFromFields(quiet) {
const guildId = $('#apGuildId').value.trim();
const channelId = $('#apChannelId').value.trim();
if (!guildId && !channelId && quiet) return true;
const t = apSanitizeTarget({ guildId, channelId, ttlHours: parseInt($('#apTtl').value, 10) });
if (!t) {
setAutoPurgeStatus('Server ID ou Channel ID inválido.');
return false;
}
const existing = apConfig.targets.find(x => x.guildId === t.guildId && x.channelId === t.channelId);
if (existing) existing.ttlHours = t.ttlHours;
else if (apConfig.targets.length >= AP_MAX_TARGETS) {
setAutoPurgeStatus(`No máximo ${AP_MAX_TARGETS} canais.`);
return false;
}
else apConfig.targets.push(t);
// os campos ficam com o que foi escrito (antes esvaziavam-se, e parecia
// que o Server ID e o Channel ID não tinham sido guardados)
renderTargets();
return true;
}
function fillFieldsFrom(t) {
$('#apGuildId').value = t ? t.guildId : '';
$('#apChannelId').value = t ? t.channelId : '';
$('#apTtl').value = String(t ? t.ttlHours : AP_TTL_HOURS[0]);
}
function reflectConfigToUI() {
$('#apEnabled').checked = !!apConfig.enabled;
$('#apInterval').value = String(apConfig.intervalMin);
$('#apMaxPerRun').value = String(apConfig.maxPerRun);
$('#apMyOnly').checked = !!apConfig.myOnly;
fillFieldsFrom(apConfig.targets[0]);
renderTargets();
renderHistory();
}
function readConfigFromUI() {
apConfig.enabled = !!$('#apEnabled').checked;
apConfig.intervalMin = apClampInterval(parseInt($('#apInterval').value, 10));
$('#apInterval').value = String(apConfig.intervalMin); // mostra o valor efetivo
apConfig.maxPerRun = apClampMaxPerRun(parseInt($('#apMaxPerRun').value, 10));
$('#apMaxPerRun').value = String(apConfig.maxPerRun);
apConfig.myOnly = !!$('#apMyOnly').checked;
}
function setAutoPurgeStatus(msg) {
const el = $('#apStatus');
if (el) el.textContent = msg;
}
function showSchedulerState() {
const n = apConfig.targets.length;
const canais = n === 0 ? 'nenhum canal guardado' : n === 1 ? '1 canal guardado' : n + ' canais guardados';
if (!apConfig.enabled) return setAutoPurgeStatus('Inativo (' + canais + ').');
let msg = 'Ativo (' + canais + ').';
if (apNextAt) msg += ' Próxima verificação: ' + apFmt(apNextAt) + '.';
if (apConfig.lastRunAt) msg += ' Última: ' + apFmt(apConfig.lastRunAt) + '.';
setAutoPurgeStatus(msg);
}
// token e autor para uma volta; null (com estado já mostrado) se faltar algo
function apCredentials() {
const authToken = undiscordCore.options.authToken || fillToken();
if (!authToken) {
setAutoPurgeStatus('Sem Authorization Token: em "Advanced settings" clica em "fill".');
log.error('[AUTO-PURGE] Sem token de autorização.');
return null;
}
let authorId = null;
if (apConfig.myOnly) {
try { authorId = getAuthorId(); } catch (e) { authorId = null; }
// sem isto, "Só as minhas mensagens" com o ID em falta apagaria as de toda a gente
if (!authorId) {
setAutoPurgeStatus('Não consegui obter o teu ID de utilizador; volta não executada.');
log.error('[AUTO-PURGE] Sem ID de utilizador com "Só as minhas mensagens" ligado; a abortar.');
return null;
}
}
return { authToken, authorId };
}
async function tick() {
if (!apConfig.enabled) return 'off';
if (!apConfig.targets.length) {
setAutoPurgeStatus('Adiciona pelo menos um canal.');
return 'off';
}
if (apRunning || undiscordCore.state.running) {
log.verb('[AUTO-PURGE] Há outra limpeza a correr; tento daqui a 5 min.');
return 'busy';
}
const cred = apCredentials();
if (!cred) return 'error';
apRunning = true;
const result = { at: Date.now(), deleted: 0, failed: 0, channels: 0, limited: false, stopped: false, error: false };
try {
log.verb(`[AUTO-PURGE] A iniciar limpeza automática (${apConfig.targets.length} canais)...`);
for (const t of apConfig.targets) {
const budget = apConfig.maxPerRun - result.deleted;
if (budget <= 0) { result.limited = true; break; }
undiscordCore.resetState();
undiscordCore.options = {
...undiscordCore.options, // manter os restantes campos
authToken: cred.authToken,
authorId: cred.authorId,
guildId: t.guildId,
channelId: t.channelId,
maxId: apCutoff(t.ttlHours),
minId: null,
content: null,
hasLink: null,
hasFile: null,
includeNsfw: true,
includePinned: false,
pattern: '',
askForConfirmation: false,
maxDelete: budget,
// cada volta recomeça com os atrasos dos sliders: os aumentos por
// 429 de uma volta não ficam a pesar nas seguintes
searchDelay: parseInt($('input#searchDelay').value, 10) || 30000,
deleteDelay: Math.max(parseInt($('input#deleteDelay').value, 10) || 0, 1500),
};
log.verb(`[AUTO-PURGE] Canal ${redact(t.channelId)}, mais de ${AP_TTL_LABEL[t.ttlHours]}.`);
let threw = false;
try {
await undiscordCore.run(true); // isJob = true, sem diálogo de confirmação
} catch (err) {
threw = true;
result.error = true;
log.error('[AUTO-PURGE] Erro neste canal; sigo para o próximo:', err);
}
result.deleted += undiscordCore.state.delCount;
result.failed += undiscordCore.state.failCount;
result.channels++;
if (undiscordCore.state.limitReached) { result.limited = true; break; }
// run(true) acaba com running=true quando chega ao fim; false sem erro = Stop
if (!threw && !undiscordCore.state.running) { result.stopped = true; break; }
}
} finally {
// libertar o core para a próxima volta e para o Delete manual
undiscordCore.resetState();
undiscordCore.options.maxDelete = null;
apRunning = false;
}
apConfig.lastRunAt = result.at;
saveConfig();
pushHistory(result);
log.info(`[AUTO-PURGE] Volta terminada: ${result.deleted} apagadas, ${result.failed} falhas.`);
return 'ran';
}
// ensaio: conta o que seria apagado em cada canal, sem apagar nada
async function apCountTarget(t, cred) {
const base = t.guildId === '@me'
? `https://discord.com/api/v9/channels/${t.channelId}/messages/`
: `https://discord.com/api/v9/guilds/${t.guildId}/messages/`;
const url = base + 'search?' + queryString([
['author_id', cred.authorId || undefined],
['channel_id', t.guildId !== '@me' ? t.channelId : undefined],
['max_id', apCutoff(t.ttlHours)],
['include_nsfw', true],
]);
for (let attempt = 0; attempt < 5; attempt++) {
const resp = await fetch(url, { headers: { 'Authorization': cred.authToken } });
if (resp.status === 202 || resp.status === 429) {
const w = (await resp.json()).retry_after * 1000 || 5000;
log.verb(`[AUTO-PURGE] Ensaio: à espera ${Math.round(w / 1000)}s (status ${resp.status})...`);
await wait(w);
continue;
}
if (!resp.ok) throw new Error('API respondeu ' + resp.status);
return (await resp.json()).total_results || 0;
}
throw new Error('demasiadas tentativas');
}
async function dryRun() {
if (apRunning || undiscordCore.state.running) return setAutoPurgeStatus('Há uma limpeza a correr; tenta depois.');
readConfigFromUI();
if (!addTargetFromFields(true)) return;
saveConfig(); // antes juntava o canal à lista sem o gravar
if (!apConfig.targets.length) return setAutoPurgeStatus('Adiciona pelo menos um canal.');
const cred = apCredentials();
if (!cred) return;
apRunning = true;
$('#apDryRun').disabled = true;
setAutoPurgeStatus('Ensaio a correr...');
const result = { at: Date.now(), dry: true, deleted: 0, failed: 0, channels: 0, error: false };
try {
for (const t of apConfig.targets) {
try {
const n = await apCountTarget(t, cred);
result.deleted += n;
log.info(`[AUTO-PURGE] Ensaio: ~${n} mensagens em ${redact(t.channelId)} com mais de ${AP_TTL_LABEL[t.ttlHours]}.`);
} catch (err) {
result.error = true;
log.error('[AUTO-PURGE] Ensaio falhou neste canal:', err && err.message ? err.message : String(err));
}
result.channels++;
await wait(1000); // não encadear pesquisas à pressa
}
} finally {
apRunning = false;
$('#apDryRun').disabled = false;
}
pushHistory(result);
let msg = `Ensaio: ~${result.deleted} mensagens seriam apagadas. Nada foi apagado.`;
if (result.deleted > apConfig.maxPerRun) msg += ` Com o limite de ${apConfig.maxPerRun} por volta, levaria várias voltas.`;
setAutoPurgeStatus(msg);
}
function stopScheduler() {
if (apTimer) { clearTimeout(apTimer); apTimer = null; }
apNextAt = null;
}
function scheduleNext(ms) {
stopScheduler();
apNextAt = Date.now() + ms;
apTimer = setTimeout(async () => {
apTimer = null;
apNextAt = null;
let r;
try { r = await tick(); }
catch (err) { log.error('[AUTO-PURGE] Erro na execução:', err); }
if (!apConfig.enabled || apTimer) return; // desativado, ou já reagendado (Guardar a meio)
scheduleNext(r === 'busy' ? AP_BUSY_RETRY : apJitter(apConfig.intervalMin * 60000));
showSchedulerState();
}, ms);
}
function startScheduler() {
stopScheduler();
if (!apConfig.enabled) return showSchedulerState();
// contar a partir da última volta: recarregar o Discord já não dispara
// uma limpeza nova de cada vez
const due = (apConfig.lastRunAt || 0) + apConfig.intervalMin * 60000 - Date.now();
scheduleNext(Math.max(AP_FIRST_RUN_DELAY, due));
showSchedulerState();
}
function setupAutoPurge() {
loadConfig();
// marca visível no log do diálogo: confirma que esta versão está ativa
log.info(`[AUTO-PURGE] v5 ativo (${apConfig.targets.length} canais, intervalo de ${apConfig.intervalMin} min, mínimo ${AP_INTERVAL_MIN})`);
reflectConfigToUI();
$('#apAdd').onclick = () => {
if (addTargetFromFields(false) && saveConfig()) {
setAutoPurgeStatus('Canal adicionado e guardado (' + apConfig.targets.length + ' na lista).');
}
};
$('#apSave').onclick = () => {
readConfigFromUI();
if (!addTargetFromFields(true)) return;
if (!saveConfig()) return; // o erro já está no estado
startScheduler();
const n = apConfig.targets.length;
setAutoPurgeStatus('Guardado (' + n + (n === 1 ? ' canal' : ' canais') + ', ' + (apConfig.enabled ? 'ativo' : 'inativo') + ').'
+ (apNextAt ? ' Próxima verificação: ' + apFmt(apNextAt) + '.' : ''));
log.info('[AUTO-PURGE] Configuração guardada.', apConfig.enabled ? 'Ativo.' : 'Inativo.');
};
$('#apDryRun').onclick = () => { dryRun(); };
$('#apEnabled').onchange = () => {
readConfigFromUI();
const fieldsOk = addTargetFromFields(true);
// IDs inválidos nos campos: não ativar com uma lista que não é a que o utilizador vê
if (!fieldsOk) $('#apEnabled').checked = apConfig.enabled = false;
saveConfig();
startScheduler();
if (!fieldsOk) setAutoPurgeStatus('Server ID ou Channel ID inválido: auto-purge não ativado.');
log.info('[AUTO-PURGE]', apConfig.enabled ? 'Ativado.' : 'Desativado.');
};
$('#apGetGuild').onclick = () => {
const v = getGuildId();
if (v) $('#apGuildId').value = v;
};
$('#apGetChannel').onclick = () => {
const v = getChannelId();
if (v) $('#apChannelId').value = v;
const g = getGuildId();
if (g && !$('#apGuildId').value) $('#apGuildId').value = g;
};
// arranca o agendador (não faz nada se estiver desativado)
startScheduler();
}
async function startAction() {
console.log(PREFIX, 'startAction');
// o resetState() mais abaixo põe running=false: arrancar agora cortava
// uma volta do auto-purge a meio (e ela, ao terminar, cortava esta)
if (apRunning || undiscordCore.state.running) {
return log.error('Já há uma limpeza a correr (manual ou auto-purge). Espera que termine ou carrega em Stop.');
}
// general
const authorId = $('input#authorId').value.trim();
const guildId = $('input#guildId').value.trim();
const channelIds = $('input#channelId').value.trim().split(/\s*,\s*/);
const includeNsfw = $('input#includeNsfw').checked;
// filter
const content = $('input#search').value.trim();
const hasLink = $('input#hasLink').checked;
const hasFile = $('input#hasFile').checked;
const includePinned = $('input#includePinned').checked;
const pattern = $('input#pattern').value;
// message interval
const minId = $('input#minId').value.trim();
const maxId = $('input#maxId').value.trim();
// date range
const minDate = $('input#minDate').value.trim();
const maxDate = $('input#maxDate').value.trim();
//advanced
const searchDelay = parseInt($('input#searchDelay').value.trim());
const deleteDelay = parseInt($('input#deleteDelay').value.trim());
// token
const authToken = $('input#token').value.trim() || fillToken();
if (!authToken) return; // get token already logs an error.
// validate input
if (!guildId) return log.error('You must fill the "Server ID" field!');
// clear logArea
ui.logArea.innerHTML = '';
undiscordCore.resetState();
undiscordCore.options = {
...undiscordCore.options,
authToken,
authorId,
guildId,
channelId: channelIds.length === 1 ? channelIds[0] : undefined, // single or multiple channel
minId: minId || minDate,
maxId: maxId || maxDate,
content,
hasLink,
hasFile,
includeNsfw,
includePinned,
pattern,
searchDelay,
deleteDelay,
maxDelete: null, // o limite por volta é só do auto-purge
// maxAttempt: 2,
};
if (channelIds.length > 1) {
const jobs = channelIds.map(ch => ({
guildId: guildId,
channelId: ch,
}));
try {
await undiscordCore.runBatch(jobs);
} catch (err) {
log.error('CoreException', err);
}
}
// single channel
else {
try {
await undiscordCore.run();
} catch (err) {
log.error('CoreException', err);
undiscordCore.stop();
}
}
}
function stopAction() {
console.log(PREFIX, 'stopAction');
undiscordCore.stop();
}
// ---- END Undiscord ----
initUI();
})();
Recursos
Partilhar
Comentários 0
Ainda sem comentários — sê o primeiro.