├── .gitignore ├── deno.json ├── purgatorial.service ├── LICENSE ├── README.md ├── deno.lock └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /.idea/ 3 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "start": "deno run --allow-net --allow-read --watch=index.ts,.env index.ts" 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /purgatorial.service: -------------------------------------------------------------------------------- 1 | [Install] 2 | WantedBy=multi-user.target 3 | 4 | [Unit] 5 | After=network.target 6 | 7 | [Service] 8 | User=purgatorial 9 | NoNewPrivileges=true 10 | ExecStart=/usr/bin/deno task start 11 | WorkingDirectory=/usr/local/share/purgatorial 12 | Restart=on-failure 13 | RestartSec=1 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Pierre Carrier 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. 6 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # purgatorial 2 | 3 | Remove Discord messages in select channels after a timeout. Fails fast, to be restarted. Handles downtime. 4 | 5 | ## Usage 6 | 7 | - Create a Discord app with `SCOPES` `bot`, `PERMISSIONS` `Manage Messages`. In the `Bot` tab, get a token for it. 8 | 9 | - Install the contents of this repo into `/usr/local/share/purgatorial` (or tweak `purgatorial.service`), where `.env` contains: 10 | 11 | ``` 12 | TOKEN=CENSORED.CENSORED.CENSORED 13 | CHANNEL_IDS=1234,5678 14 | MSG_TIMEOUT=3600000 15 | ``` 16 | 17 | - Share the install link from the `Installation` tab with your servers' admins. 18 | 19 | - Start running with `doas useradd -rms /usr/bin/nologin purgatorial && doas ln -sf $PWD/purgatorial.service /etc/systemd/system/ && doas systemctl enable --now purgatorial` 20 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "redirects": { 4 | "https://deno.land/x/dotenv/mod.ts": "https://deno.land/x/dotenv@v3.2.2/mod.ts" 5 | }, 6 | "remote": { 7 | "https://deno.land/std@0.224.0/dotenv/load.ts": "587b342f0f6a3df071331fe6ba1c823729ab68f7d53805809475e486dd4161d7", 8 | "https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d", 9 | "https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c", 10 | "https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615", 11 | "https://deno.land/x/dotenv@v3.2.2/mod.ts": "077b48773de9205266a0b44c3c3a3c3083449ed64bb0b6cc461b95720678d38e", 12 | "https://deno.land/x/dotenv@v3.2.2/util.ts": "693730877b13f8ead2b79b2aa31e2a0652862f7dc0c5f6d2f313f4d39c7b7670" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import {load} from "https://deno.land/std@0.224.0/dotenv/mod.ts"; 2 | 3 | const env = await load(), 4 | token = env["TOKEN"], 5 | msgTimeout = Number(env["MSG_TIMEOUT"] || "10800000"), 6 | channelIDs = (env["CHANNEL_IDS"] || "").split(",").map((id) => id.trim()); 7 | 8 | function error(err: Event | ErrorEvent): never { 9 | console.log("error", err); 10 | Deno.exit(1); 11 | } 12 | 13 | function fetchWith429Retry(url: string, init?: RequestInit): Promise { 14 | return new Promise((resolve, reject) => { 15 | fetch(url, init).then((res) => { 16 | if (res.status === 429) { 17 | setTimeout(() => { 18 | fetchWith429Retry(url, init).then(resolve, reject); 19 | }, Number(res.headers.get("retry-after")) * 1000); 20 | } else { 21 | resolve(res); 22 | } 23 | }).catch(reject); 24 | }); 25 | } 26 | 27 | async function deleteMessage(channelID: string, messageID: string) { 28 | const res = await fetchWith429Retry( 29 | `https://discord.com/api/v10/channels/${channelID}/messages/${messageID}`, 30 | { 31 | method: "DELETE", 32 | headers: { 33 | "Authorization": `Bot ${token}`, 34 | }, 35 | }); 36 | if (!res.ok && res.status !== 404) { 37 | console.log("failed to delete message", res.status); 38 | Deno.exit(1); 39 | } 40 | } 41 | 42 | async function backfillChannel(channelID: string) { 43 | let after: string | undefined = undefined; 44 | while (true) { 45 | const res = await fetchWith429Retry( 46 | `https://discord.com/api/v10/channels/${channelID}/messages${ 47 | after ? `?after=${after}` : "" 48 | }`, 49 | { 50 | headers: { 51 | "Authorization": `Bot ${token}`, 52 | }, 53 | }, 54 | ); 55 | if (!res.ok) { 56 | console.log("failed to fetch messages", res.status); 57 | Deno.exit(1); 58 | } 59 | const messages = await res.json(); 60 | if (messages.length === 0) { 61 | return; 62 | } 63 | for (const message of messages) { 64 | const date = new Date(message.timestamp); 65 | if (Date.now() - date.getTime() > msgTimeout) { 66 | await deleteMessage(channelID, message.id); 67 | } else { 68 | setTimeout(() => { 69 | deleteMessage(channelID, message.id); 70 | }, date.getTime() + msgTimeout - Date.now()); 71 | } 72 | } 73 | after = messages[0].id; 74 | } 75 | } 76 | 77 | function connect(): WebSocket { 78 | let d: number | null = null; 79 | const ws = new WebSocket("wss://gateway.discord.gg/"); 80 | ws.onmessage = async (msg) => { 81 | const payload = JSON.parse(msg.data); 82 | d = payload.s || d; 83 | switch (payload.op) { 84 | case 10: 85 | setInterval(() => { 86 | ws.send(JSON.stringify({ op: 1, d })); 87 | }, payload.d.heartbeat_interval); 88 | ws.send(JSON.stringify({ 89 | op: 2, 90 | d: { 91 | token, 92 | intents: 1 << 9, 93 | properties: { 94 | os: "deno", 95 | browser: "deno", 96 | device: "deno", 97 | }, 98 | }, 99 | })); 100 | break; 101 | case 0: 102 | switch (payload.t) { 103 | case "READY": 104 | for (const channelID of channelIDs) { 105 | await backfillChannel(channelID); 106 | } 107 | break; 108 | case "MESSAGE_CREATE": { 109 | const channelID = payload.d.channel_id; 110 | if (channelIDs.includes(channelID)) { 111 | const msgID = payload.d.id; 112 | setTimeout(() => deleteMessage(channelID, msgID), msgTimeout); 113 | } 114 | break; 115 | } 116 | } 117 | } 118 | }; 119 | ws.onclose = (e) => { 120 | console.log("oops!", e.reason); 121 | Deno.exit(1); 122 | }; 123 | ws.onerror = (err) => error(err); 124 | return ws; 125 | } 126 | 127 | connect(); 128 | --------------------------------------------------------------------------------