diff --git a/CHANGELOG.md b/CHANGELOG.md index e1024a2..2c06855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,13 @@ - New `man` command for viewing Unix/Linux manual pages for command documentation - New `crate` command to search Rust crates - New `gem` command to search Ruby gems +- New `base64` command encode or decode Base64 strings ### Changed - Updated the `changelog` command to use a `StringSelectMenuBuilder` instead of just showing the latest version - Both the command and interaction handler now parse `CHANGELOG.md` dynamically +- Updated `profile` command to include a visual GitHub contribution activity graph in the embed response ## `v1.0.0` - 01/07/2026 diff --git a/README.md b/README.md index 3670b44..ae64a77 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ All commands use the `;` prefix (e.g., `;run`). Premium features are marked with ### 🐙 GitHub Integration -| Command | Description | -| :--------- | :--------------------------------------------------------------------------- | -| `;profile` | Fetch detailed statistics, repository counts, and bios for a GitHub user. | -| `;repo` | Get overview metrics (stars, forks, open issues) for a specified repository. | -| `;tree` ★ | Generate a structured visual directory tree for a GitHub repository. | +| Command | Description | +| :--------- | :--------------------------------------------------------------------------------------- | +| `;profile` | Fetch detailed statistics, repository counts, and bio, activity graph for a GitHub user. | +| `;repo` | Get overview metrics (stars, forks, open issues) for a specified repository. | +| `;tree` ★ | Generate a structured visual directory tree for a GitHub repository. | ### 🔢 Mathematics @@ -71,6 +71,7 @@ All commands use the `;` prefix (e.g., `;run`). Premium features are marked with | Command | Description | | :----------- | :------------------------------------------------------------------------------------ | +| `;base64` | Encode or decode Base64 strings | | `;color` | Preview exact hexadecimal/RGB colors and custom CSS gradients. | | `;http` | Perform lightweight HTTP requests and inspect responses (headers, payload). | | `;id` | Generate cryptographic or specific system unique IDs across 60+ formats. | diff --git a/package.json b/package.json index 40918f6..c390c1d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "bun run src/index.ts", "dev": "bun run --watch src/index.ts", "format": "prettier --write .", + "check:conflicts": "bun src/scripts/conflicts.ts", "lint": "eslint .", "lint:fix": "eslint . --fix" }, diff --git a/src/commands/github/profile.ts b/src/commands/github/profile.ts index 67b9862..26c859d 100644 --- a/src/commands/github/profile.ts +++ b/src/commands/github/profile.ts @@ -1,10 +1,12 @@ import { ActionRowBuilder, + AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, } from "discord.js"; import type { CommandCallbackOpts } from "../../types/command.ts"; +import { generateGitHubHeatMap } from "../../utils/githubActivityGraph.ts"; export default { name: "profile", @@ -28,14 +30,11 @@ export default { }); } - // match URLs like: - // https://github.com/username - // username - // github.com/username const match = username.match( /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^\/]+)(?:\/([^\/]+))?(?:\.git)?$/i, ) || username.match(/^([^\/]+)$/); + if (!match) { return message.reply({ embeds: [ @@ -48,58 +47,81 @@ export default { ], }); } + const user = match[1]; const apiUrl = `https://api.github.com/users/${user}`; - fetch(apiUrl) - .then((res) => { - if (!res.ok) { - throw new Error("User not found"); - } - return res.json(); - }) - .then((data) => { - const name = data.name; - const bio = data.bio; - const repos = data.public_repos || 0; - const followers = data.followers || 0; - const following = data.following || 0; + const res = await fetch(apiUrl); + if (!res.ok) { + return message.reply({ + embeds: [ + new EmbedBuilder() + .setTitle("❌ GitHub Profile Not Found") + .setDescription( + "Please provide a valid GitHub username or profile URL.\nExample: `;profile octocat` or `;profile https://github.com/octocat`", + ) + .setColor(0xd21872), + ], + }); + } + + const data = await res.json(); - const embed = new EmbedBuilder() - .setAuthor({ - name: data.login, - iconURL: data.avatar_url, - url: data.html_url, - }) - .setTitle(name || data.login) - .setDescription(bio || "No bio") - .setThumbnail(data.avatar_url) - .addFields( - { name: "Public Repos", value: repos.toString(), inline: true }, - { name: "Followers", value: followers.toString(), inline: true }, - { name: "Following", value: following.toString(), inline: true }, - ) - .setFooter({ - text: `Requested by ${message.author.tag}`, - iconURL: message.author.displayAvatarURL(), - }); + const name = data.name; + const bio = data.bio; + const repos = data.public_repos || 0; + const followers = data.followers || 0; + const following = data.following || 0; - const button = new ButtonBuilder() - .setLabel("View on GitHub") - .setStyle(ButtonStyle.Link) - .setURL(data.html_url); - const row = new ActionRowBuilder().addComponents(button); + let heatmapAttachment: AttachmentBuilder | null = null; + try { + const heatmapBuffer = await generateGitHubHeatMap(user || ""); + heatmapAttachment = new AttachmentBuilder(heatmapBuffer, { + name: "heatmap.png", + }); + } catch (err) { + console.error("Failed to generate heatmap:", err); + } - message.reply({ embeds: [embed], components: [row.toJSON()] }); + const embed = new EmbedBuilder() + .setAuthor({ + name: data.login, + iconURL: data.avatar_url, + url: data.html_url, }) - .catch((err) => { - console.error(err); - message.reply( - "An error occurred while fetching the user information.", - ); + .setTitle(name || data.login) + .setDescription(bio || "No bio") + .setThumbnail(data.avatar_url) + .addFields( + { name: "Public Repos", value: repos.toString(), inline: true }, + { name: "Followers", value: followers.toString(), inline: true }, + { name: "Following", value: following.toString(), inline: true }, + ) + // .setImage(graphUrl) + .setFooter({ + text: `Requested by ${message.author.tag}`, + iconURL: message.author.displayAvatarURL(), }); + + if (heatmapAttachment) { + embed.setImage("attachment://heatmap.png"); + } + + const button = new ButtonBuilder() + .setLabel("View on GitHub") + .setStyle(ButtonStyle.Link) + .setURL(data.html_url); + + const row = new ActionRowBuilder().addComponents(button); + + await message.reply({ + embeds: [embed], + components: [row.toJSON()], + files: heatmapAttachment ? [heatmapAttachment] : [], + }); } catch (err) { console.error(err); + message.reply("An error occurred while fetching the user information."); } }, }; diff --git a/src/commands/meta/changelog.ts b/src/commands/meta/changelog.ts index ccc2479..8a956b3 100644 --- a/src/commands/meta/changelog.ts +++ b/src/commands/meta/changelog.ts @@ -50,8 +50,10 @@ export default { .setPlaceholder("Select a version to view its changes") .addOptions( versions.map((v, i) => ({ - label: - v.title.length > 100 ? v.title.slice(0, 97) + "..." : v.title, + label: (v.title.length > 100 + ? v.title.slice(0, 97) + "..." + : v.title + ).replace(/`/g, ""), value: i.toString(), ...(i === 0 ? { description: "Latest version" } : {}), })), diff --git a/src/commands/tools/base64.ts b/src/commands/tools/base64.ts new file mode 100644 index 0000000..6ec8c3d --- /dev/null +++ b/src/commands/tools/base64.ts @@ -0,0 +1,91 @@ +import { EmbedBuilder } from "discord.js"; +import type { CommandCallbackOpts } from "../../types/command.ts"; + +export default { + name: "base64", + description: "Encode or decode Base64 strings.", + usage: "base64 ", + aliases: ["b64"], + callback: { + encode: async ({ message, args }: CommandCallbackOpts) => { + const text = args.join(" "); + + if (!text) { + const embed = new EmbedBuilder() + .setTitle("❌ Missing Input") + .setDescription("Please provide some text to encode.") + .setColor(0xe74c3c); + return message.reply({ embeds: [embed] }); + } + + const encoded = Buffer.from(text, "utf8").toString("base64"); + + const embed = new EmbedBuilder() + .setTitle("🔐 Base64 Encode") + .addFields( + { + name: "Original Text", + value: text.length > 1024 ? text.slice(0, 1021) + "..." : text, + }, + { + name: "Encoded (Base64)", + value: + encoded.length > 1024 + ? "```" + encoded.slice(0, 1021) + "...```" + : "```" + encoded + "```", + }, + ) + .setColor(0x2ecc71); + + await message.reply({ embeds: [embed] }); + }, + + decode: async ({ message, args }: CommandCallbackOpts) => { + const input = args.join(" "); + + if (!input) { + const embed = new EmbedBuilder() + .setTitle("❌ Missing Input") + .setDescription("Please provide a Base64 string to decode.") + .setColor(0xe74c3c); + return message.reply({ embeds: [embed] }); + } + + try { + const decoded = Buffer.from(input, "base64").toString("utf8"); + + if (!decoded || decoded.includes("\u0000")) { + throw new Error("Invalid Base64"); + } + + const embed = new EmbedBuilder() + .setTitle("🔓 Base64 Decode") + .addFields( + { + name: "Base64 Input", + value: + input.length > 1024 + ? "```" + input.slice(0, 1021) + "...```" + : "```" + input + "```", + }, + { + name: "Decoded Text", + value: + decoded.length > 1024 + ? decoded.slice(0, 1021) + "..." + : decoded, + }, + ) + .setColor(0x3498db); + + await message.reply({ embeds: [embed] }); + } catch { + const embed = new EmbedBuilder() + .setTitle("❌ Invalid Base64") + .setDescription("That doesn't look like valid Base64.") + .setColor(0xe74c3c); + await message.reply({ embeds: [embed] }); + } + }, + }, +}; diff --git a/src/interactions/changelog.ts b/src/interactions/changelog.ts index e9b48ca..15afbbc 100644 --- a/src/interactions/changelog.ts +++ b/src/interactions/changelog.ts @@ -48,13 +48,12 @@ export default { .setPlaceholder("Select a version to view its changes") .addOptions( versions.map((v, i) => ({ - label: v.title.length > 100 ? v.title.slice(0, 97) + "..." : v.title, + label: (v.title.length > 100 + ? v.title.slice(0, 97) + "..." + : v.title + ).replace(/`/g, ""), value: i.toString(), - ...(i === selectedIndex - ? { description: "Currently viewing" } - : i === 0 - ? { description: "Latest version" } - : {}), + ...(i === 0 ? { description: "Latest version" } : {}), })), ); diff --git a/src/scripts/conflicts.ts b/src/scripts/conflicts.ts new file mode 100644 index 0000000..c750fa7 --- /dev/null +++ b/src/scripts/conflicts.ts @@ -0,0 +1,156 @@ +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const COMMANDS_DIR = path.join(__dirname, "..", "commands"); + +interface CommandMeta { + name: string; + aliases: string[]; + filePath: string; +} + +/** + * Global registry for all names and aliases (each must be unique) + */ +const identifierRegistry = new Map(); + +async function getAllCommandFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await getAllCommandFiles(fullPath))); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(fullPath); + } + } + return files; +} + +async function parseCommandFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf8"); + + const nameMatch = content.match(/name\s*:\s*["'`]([^"'`]+)["'`]/); + if (!nameMatch) { + console.warn( + `⚠️ Skipped (no name found): ${path.relative(COMMANDS_DIR, filePath)}`, + ); + return null; + } + + const name = nameMatch[1]?.toLowerCase().trim(); + + const aliasesMatch = content.match(/aliases\s*:\s*\[([^\]]*)\]/); + let aliases: string[] = []; + + if (aliasesMatch) { + const aliasesContent = aliasesMatch[1]; + const aliasMatches = aliasesContent?.match(/["'`]([^"'`]+)["'`]/g); + if (aliasMatches) { + aliases = aliasMatches + .map((a) => a.replace(/["'`]/g, "").toLowerCase().trim()) + .filter(Boolean); + } + } + + return { + name: name || "", + aliases, + filePath: path.relative(COMMANDS_DIR, filePath), + }; + } catch (err) { + console.error(`❌ Failed to read ${path.relative(COMMANDS_DIR, filePath)}`); + return null; + } +} + +function registerCommand(cmd: CommandMeta): boolean { + let hasConflict = false; + const identifiers = [cmd.name, ...cmd.aliases]; + + for (const id of identifiers) { + if (!identifierRegistry.has(id)) { + identifierRegistry.set(id, []); + } + + const existing = identifierRegistry.get(id)!; + + // Conflict if already used by a different file + const conflict = existing.some((c) => c.filePath !== cmd.filePath); + if (conflict) hasConflict = true; + + existing.push(cmd); + } + + return hasConflict; +} + +/** + * Print nice conflict report + */ +function printConflicts() { + console.log("\n" + "═".repeat(70)); + console.log("🔍 COMMAND NAME & ALIAS CONFLICT SCANNER"); + console.log("═".repeat(70) + "\n"); + + let totalConflicts = 0; + const reported = new Set(); + + for (const [identifier, commands] of identifierRegistry) { + const uniqueFiles = new Set(commands.map((c) => c.filePath)); + + if (uniqueFiles.size > 1 && !reported.has(identifier)) { + reported.add(identifier); + totalConflicts++; + + console.log(`❌ CONFLICT: "${identifier}" is used in multiple places:\n`); + for (const cmd of commands) { + const role = cmd.name === identifier ? "name" : "alias"; + console.log(` → ${cmd.filePath} (as ${role})`); + } + console.log(""); + } + } + + console.log("═".repeat(70)); + + if (totalConflicts === 0) { + console.log( + "✅ No conflicts found! All command names and aliases are globally unique.", + ); + } else { + console.log(`❌ Found ${totalConflicts} conflicting identifier(s).`); + console.log( + " Every name and every alias across the bot must be unique.", + ); + } + console.log("═".repeat(70) + "\n"); +} + +/** + * Main + */ +async function main() { + console.log( + "🔎 Scanning all command files (static mode - safe, no execution)...\n", + ); + + const files = await getAllCommandFiles(COMMANDS_DIR); + console.log(`Found ${files.length} command files.\n`); + + for (const file of files) { + const cmd = await parseCommandFile(file); + if (cmd) { + registerCommand(cmd); + } + } + + printConflicts(); +} + +main().catch(console.error); diff --git a/src/utils/githubActivityGraph.ts b/src/utils/githubActivityGraph.ts new file mode 100644 index 0000000..3c7c20a --- /dev/null +++ b/src/utils/githubActivityGraph.ts @@ -0,0 +1,159 @@ +import { createCanvas } from "@napi-rs/canvas"; + +const CELL_SIZE = 10; +const GAP = 3; +const ROWS = 7; + +const COLORS = { + cellEmpty: "#161b22", + ramp: ["#0e4429", "#006d32", "#26a641", "#39d353"], +}; + +function roundRectPath( + ctx: any, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + const radius = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.arcTo(x + w, y, x + w, y + h, radius); + ctx.arcTo(x + w, y + h, x, y + h, radius); + ctx.arcTo(x, y + h, x, y, radius); + ctx.arcTo(x, y, x + w, y, radius); + ctx.closePath(); +} + +function cellColor(level: number): string { + if (!level || level <= 0) return COLORS.cellEmpty; + const index = Math.min(COLORS.ramp.length - 1, level - 1); + return COLORS.ramp[index]!; +} + +interface Heatmap { + columns: number[][]; + max: number; +} + +interface JogruberDay { + date: string; + count: number; + level: number; +} + +interface JogruberResponse { + total?: Record; + contributions?: JogruberDay[]; + error?: string; +} + +/** + * Fetches the contribution calendar from the third-party + * github-contributions-api (jogruber.de) instead of GitHub directly. + * No token, no scraping, no GitHub rate limit involvement at all — + * this service does the GitHub GraphQL call on its own end and caches it. + * + * Note: this is still a dependency on someone else's free public service, + * not GitHub itself, so it has its own (undocumented) availability/rate + * characteristics. Worth keeping the caching layer in front of it regardless. + */ +async function fetchContributionData(username: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + + let res: Response; + try { + res = await fetch( + `https://github-contributions-api.jogruber.de/v4/${encodeURIComponent(username)}?y=last`, + { + headers: { "User-Agent": "github-heatmap-generator" }, + signal: controller.signal, + }, + ); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error( + "github-contributions-api timed out after 10s — the service may be down or slow", + ); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + + if (!res.ok) { + throw new Error(`github-contributions-api request failed: ${res.status}`); + } + + const json: JogruberResponse = await res.json(); + + if (json.error) { + throw new Error(`github-contributions-api error: ${json.error}`); + } + if (!json.contributions || json.contributions.length === 0) { + throw new Error("No contribution data returned"); + } + + const days = [...json.contributions].sort((a, b) => + a.date < b.date ? -1 : 1, + ); + + const columns: number[][] = []; + let currentWeek: number[] = []; + for (const day of days) { + const weekday = new Date(`${day.date}T00:00:00Z`).getUTCDay(); // 0 = Sunday + if (weekday === 0 && currentWeek.length > 0) { + columns.push(currentWeek); + currentWeek = []; + } + currentWeek[weekday] = day.level; + } + if (currentWeek.length > 0) columns.push(currentWeek); + + return { columns, max: 4 }; +} + +/** + * Generates a GitHub-style contribution heatmap image for a given username. + * No text/labels are drawn — just the colored cell grid. + * + * Flow: fetch the user's contribution graph -> render it. + * Data source: github-contributions-api.jogruber.de (no token required). + * + * @returns PNG image buffer + */ +export async function generateGitHubHeatMap(username: string): Promise { + if (!username || typeof username !== "string") { + throw new Error("username is required"); + } + + const heatmap = await fetchContributionData(username); + + const weeks = heatmap.columns.length; + const rows = ROWS; + + const width = weeks * CELL_SIZE + (weeks - 1) * GAP; + const height = rows * CELL_SIZE + (rows - 1) * GAP; + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + + const radius = Math.max(1, CELL_SIZE * 0.25); + + for (let c = 0; c < weeks; c++) { + const week = heatmap.columns[c] || []; + for (let r = 0; r < rows; r++) { + const level = week[r] || 0; + const x = c * (CELL_SIZE + GAP); + const y = r * (CELL_SIZE + GAP); + ctx.fillStyle = cellColor(level); + roundRectPath(ctx, x, y, CELL_SIZE, CELL_SIZE, radius); + ctx.fill(); + } + } + + return canvas.toBuffer("image/png"); +}