Compare commits

..

3 Commits

Author SHA1 Message Date
naomi 64e2b4419c feat: display results as pretty html page
Node.js CI / Lint and Test (push) Successful in 24s
2025-12-03 14:51:29 -08:00
naomi 43e8b4e6c6 feat: automatically open the oauth URL 2025-12-03 14:34:20 -08:00
naomi bd3f2c6388 feat: separate admin and mod categories 2025-12-03 14:27:36 -08:00
3 changed files with 369 additions and 30 deletions
+6 -5
View File
@@ -19,12 +19,13 @@
"@inquirer/prompts": "7.8.6",
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "24.3.0",
"eslint": "9.34.0",
"tsx": "4.20.5",
"typescript": "5.9.2",
"@octokit/rest": "22.0.0",
"@types/cli-progress": "3.11.6",
"cli-progress": "3.12.0"
"@types/node": "24.3.0",
"cli-progress": "3.12.0",
"eslint": "9.34.0",
"open": "11.0.0",
"tsx": "4.20.5",
"typescript": "5.9.2"
}
}
+96
View File
@@ -35,6 +35,9 @@ importers:
eslint:
specifier: 9.34.0
version: 9.34.0
open:
specifier: 11.0.0
version: 11.0.0
tsx:
specifier: 4.20.5
version: 4.20.5
@@ -1284,6 +1287,10 @@ packages:
resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
engines: {node: '>=6'}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -1395,10 +1402,22 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.4.0:
resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==}
engines: {node: '>=18'}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
@@ -1823,6 +1842,11 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1843,6 +1867,15 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
@@ -1895,6 +1928,10 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-wsl@3.1.0:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -2058,6 +2095,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2153,6 +2194,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -2227,6 +2272,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2609,6 +2658,10 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wsl-utils@0.3.0:
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
engines: {node: '>=20'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -4294,6 +4347,10 @@ snapshots:
builtin-modules@3.3.0: {}
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
@@ -4396,12 +4453,21 @@ snapshots:
deep-is@0.1.4: {}
default-browser-id@5.0.1: {}
default-browser@5.4.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.2.0
define-lazy-prop@3.0.0: {}
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
@@ -5006,6 +5072,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-docker@3.0.0: {}
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -5025,6 +5093,12 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-map@2.0.3: {}
is-negative-zero@2.0.3: {}
@@ -5075,6 +5149,10 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-wsl@3.1.0:
dependencies:
is-inside-container: 1.0.0
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -5232,6 +5310,15 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
open@11.0.0:
dependencies:
default-browser: 5.4.0
define-lazy-prop: 3.0.0
is-in-ssh: 1.0.0
is-inside-container: 1.0.0
powershell-utils: 0.1.0
wsl-utils: 0.3.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -5317,6 +5404,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {}
prelude-ls@1.2.1: {}
prop-types@15.8.1:
@@ -5418,6 +5507,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.49.0
fsevents: 2.3.3
run-applescript@7.1.0: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -5869,6 +5960,11 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wsl-utils@0.3.0:
dependencies:
is-wsl: 3.1.0
powershell-utils: 0.1.0
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {}
+255 -13
View File
@@ -19,6 +19,7 @@ import crypto from "node:crypto";
import http from "node:http";
import process from "node:process";
import { confirm } from "@inquirer/prompts";
import open from "open";
interface Guild {
name?: string;
@@ -29,7 +30,9 @@ interface Guild {
}
interface Stats {
admin: Array<string>;
community: Array<string>;
discoverable: Array<string>;
moderating: Array<string>;
owned: Array<string>;
partnered: Array<string>;
@@ -62,23 +65,249 @@ const printReport = (stats: Stats): void => {
console.log(`Total Servers Joined: ${stats.total.toString()}\n`);
printList("Owned Servers", stats.owned, "đź‘‘");
printList("Moderating (Non-Owned)", stats.moderating, "🛡️ ");
printList("Admin (Non-Owned)", stats.admin, "⚙️ ");
printList("Moderating (Non-Admin/Non-Owned)", stats.moderating, "🛡️ ");
printList("Partnered Servers", stats.partnered, "🤝");
printList("Verified Servers", stats.verified, "âś…");
printList("Community/Public Servers", stats.community, "🌍");
printList("Discovery Enabled Servers", stats.discoverable, "🔍");
console.log(`======================================\n`);
};
const escapeHtml = (value: string): string => {
return value.
replaceAll("&", "&amp;").
replaceAll("<", "&lt;").
replaceAll(">", "&gt;").
replaceAll("\"", "&quot;").
replaceAll("'", "&#39;");
};
const buildDashboardHtml = (stats: Stats): string => {
const tiles: Array<{ items: Array<string>; label: string; value: number }> = [
{ items: stats.owned, label: "You Own", value: stats.owned.length },
{ items: stats.admin, label: "You Admin", value: stats.admin.length },
{
items: stats.moderating,
label: "You Moderate",
value: stats.moderating.length,
},
{
items: stats.community,
label: "Community Enabled",
value: stats.community.length,
},
{
items: stats.discoverable,
label: "Discovery Enabled",
value: stats.discoverable.length,
},
{
items: stats.partnered,
label: "Partnered",
value: stats.partnered.length,
},
{ items: stats.verified, label: "Verified", value: stats.verified.length },
];
const cards = tiles.map((tile) => {
const listItems
= tile.items.length === 0
? "<li class=\"empty\">No servers in this category.</li>"
: tile.items.map((name) => {
return `<li>${escapeHtml(name)}</li>`;
}).join("");
return `
<div class="card">
<p class="label">${escapeHtml(tile.label)}</p>
<p class="count">${tile.value.toString()}</p>
<ul class="server-list">
${listItems}
</ul>
</div>
`;
}).join("");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Discord Server Counter</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: #0f0f16;
color: #111;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: linear-gradient(135deg, #ff4ecd, #7873f5, #4ac7fa, #43e97b);
}
.wrapper {
width: min(1100px, 100%);
border-radius: 1.5rem;
padding: 0.25rem;
background: linear-gradient(120deg, #ff4ecd, #b388ff, #4ac7fa, #43e97b);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.35);
}
.content {
background: #fff;
border-radius: 1.25rem;
padding: 2rem 2.5rem 3rem;
}
h1 {
margin: 0;
font-size: clamp(1.75rem, 3vw, 2.5rem);
}
.subtitle {
color: #6b7280;
margin-bottom: 2rem;
font-size: 1rem;
}
.total {
text-align: center;
margin-bottom: 2rem;
}
.total span {
display: block;
color: #6b7280;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.total strong {
font-size: clamp(2.5rem, 5vw, 3.5rem);
color: #4c1d95;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
}
.card {
background: #f9fafb;
border-radius: 1rem;
padding: 1.25rem;
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
height: 100%;
}
.label {
color: #7c3aed;
font-weight: 600;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.count {
font-size: 2.25rem;
font-weight: 700;
margin-bottom: 1rem;
color: #111827;
}
.server-list {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
overflow-y: auto;
max-height: 180px;
}
.server-list li {
padding: 0.35rem 0;
border-bottom: 1px solid #e5e7eb;
font-size: 0.95rem;
color: #374151;
}
.server-list li:last-child {
border-bottom: none;
}
.server-list .empty {
color: #9ca3af;
font-style: italic;
border-bottom: none;
}
@media (max-width: 640px) {
body {
padding: 1rem;
}
.content {
padding: 1.5rem;
}
}
</style>
</head>
<body>
<div class="wrapper">
<div class="content">
<h1>Server Counter</h1>
<p class="subtitle">View counts of servers you are in along with quick insights.</p>
<div class="total">
<span>You are in</span>
<strong>${stats.total.toString()} servers</strong>
</div>
<div class="grid">
${cards}
</div>
</div>
</div>
</body>
</html>`;
};
const serveStatsDashboard = async(stats: Stats): Promise<void> => {
const html = buildDashboardHtml(stats);
await new Promise<void>((resolve, reject) => {
const server = http.createServer((request, response) => {
if (request.method !== "GET") {
response.statusCode = 405;
response.end("Method Not Allowed");
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(html);
server.close();
resolve();
});
server.on("error", (error) => {
reject(error);
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address !== null && typeof address === "object") {
const url = `http://${address.address}:${address.port.toString()}/`;
console.log(`Opening dashboard at ${url}`);
void open(url, { wait: false }).catch(() => {
console.log(
`Failed to open browser automatically. Please visit ${url} manually.`,
);
});
}
});
});
};
const checkForPermission = (perms: bigint, permission: bigint): boolean => {
// eslint-disable-next-line no-bitwise -- Since Discord uses bit flags...
return (perms & permission) === permission;
};
const analyzeGuilds = (guilds: Array<Guild>): void => {
const analyzeGuilds = (guilds: Array<Guild>): Stats => {
// Arrays to store names instead of just counts
const stats: Stats = {
admin: [],
community: [],
discoverable: [],
moderating: [],
owned: [],
partnered: [],
@@ -96,18 +325,17 @@ const analyzeGuilds = (guilds: Array<Guild>): void => {
stats.owned.push(name ?? "Unknown");
}
/*
* 2. Moderation
* We consider you a "Moderator" if you have specific mod permissions,
* Even if you don't own the server.
*/
const isModerator
= checkForPermission(perms, permissions.administrator)
const isAdmin = checkForPermission(perms, permissions.administrator);
const hasModeratorPermissions
= isAdmin
|| checkForPermission(perms, permissions.manageGuild)
|| checkForPermission(perms, permissions.banMembers)
|| checkForPermission(perms, permissions.kickMembers)
|| checkForPermission(perms, permissions.moderateMembers);
if (isModerator && owner !== true) {
if (isAdmin && owner !== true) {
stats.admin.push(name ?? id);
} else if (hasModeratorPermissions && owner !== true && !isAdmin) {
stats.moderating.push(name ?? id);
}
@@ -126,12 +354,16 @@ const analyzeGuilds = (guilds: Array<Guild>): void => {
* "COMMUNITY" feature enables public facing screens (Welcome Screen, Rules, etc)
* "DISCOVERABLE" means it appears in Server Discovery
*/
if (features.includes("COMMUNITY") || features.includes("DISCOVERABLE")) {
if (features.includes("COMMUNITY")) {
stats.community.push(name ?? id);
}
if (features.includes("DISCOVERABLE")) {
stats.discoverable.push(name ?? id);
}
}
printReport(stats);
return stats;
};
const defaultRedirectUri = "http://127.0.0.1:8721/callback";
@@ -346,13 +578,22 @@ async function startOAuthFlow(): Promise<string> {
authUrl.searchParams.set("code_challenge_method", "S256");
console.log("\n====== Discord OAuth ======");
console.log("1. Open the following URL in your browser:");
console.log(
`1. Open the following URL in your browser (we will try to automatically open it for you):`,
);
console.log(authUrl.toString());
console.log("2. Approve access for your application.");
console.log(
"3. Return here; the script will continue once approval is complete.\n",
);
void open(authUrl.toString(), { wait: false }).catch(() => {
console.log(
"Failed to open browser. Please open the following URL in your browser:",
);
console.log(authUrl.toString());
});
const code = await waitForOAuthCode(redirectUri, state);
const tokenRequest: TokenExchangeOptions = {
clientId,
@@ -422,7 +663,8 @@ async function getGuilds(): Promise<void> {
}
const guilds: Array<Guild> = await response.json();
analyzeGuilds(guilds);
const stats = analyzeGuilds(guilds);
await serveStatsDashboard(stats);
}
await getGuilds();