From fc110e14fa27f418b6799fb63e12896aeeab645a Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 2 Jul 2026 22:24:12 +0300 Subject: [PATCH 1/4] fix: add error handling to man command --- src/commands/docs/man.ts | 20 ++++++++++++++++---- src/utils/man.ts | 1 - 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/commands/docs/man.ts b/src/commands/docs/man.ts index 90d7db6..9c8aaf4 100644 --- a/src/commands/docs/man.ts +++ b/src/commands/docs/man.ts @@ -22,20 +22,32 @@ export default { // raw, // sections, // }; - const page = await man(args.join(" ")); - if (!page) { + const term = args[0]; + if (!term) { return message.reply({ embeds: [ new EmbedBuilder() - .setTitle("Man Page Not Found") + .setTitle("Missing Term") .setDescription( - `No man page found for the term "${args.join(" ")}".`, + "Please provide a term to search for in the man pages.", ) .setColor(0xff0000), ], }); } + const page = await man(term); + if (!page) { + return message.reply({ + embeds: [ + new EmbedBuilder() + .setTitle("Man Page Not Found") + .setDescription(`No man page found for the term "${term}".`) + .setColor(0xff0000), + ], + }); + } + const embed = new EmbedBuilder() .setTitle(`${page.title}`) .setDescription(page.description || "No description available.") diff --git a/src/utils/man.ts b/src/utils/man.ts index d768825..359dba1 100644 --- a/src/utils/man.ts +++ b/src/utils/man.ts @@ -80,7 +80,6 @@ export async function man(term: string): Promise { } const title = match?.[1]?.trim()?.toLowerCase() ?? term; - const section = match?.[2] ?? "?"; const description = sections["DESCRIPTION"]?.split("\n\n")[0]?.replace(/\s+/g, " ").trim() || ""; From 3772eab571122e4cb93ac5299a6a7d98f56ae471 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 2 Jul 2026 22:26:43 +0300 Subject: [PATCH 2/4] feat: remove automatic code detection and reformatting feature --- CHANGELOG.md | 1 - README.md | 8 -- src/events/messageCreate/detectCode.ts | 55 -------- src/utils/detectCode.ts | 166 ------------------------- 4 files changed, 230 deletions(-) delete mode 100644 src/events/messageCreate/detectCode.ts delete mode 100644 src/utils/detectCode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3f5f9..5ffed91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ ### Added -- Automatic code block detection and reformatting feature - Full interaction handler system with file-based routing - New `man` command for viewing Unix/Linux manual pages directly from Discord diff --git a/README.md b/README.md index 0581eb4..75939b0 100644 --- a/README.md +++ b/README.md @@ -87,14 +87,6 @@ All commands use the `;` prefix (e.g., `;run`). Premium features are marked with | `;feature` | Submit an authorized feature request directly to the development team. | | `;report` | File a bug report or performance issue directly from your server. | -### 🤖 Automatic Features - -Quill has a few smart automatic behaviors that activate based on permissions: - -| Feature | Description | Control | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| **Auto Code Reformatting** | Detects unwrapped code in messages, deletes the original, and reposts it in a clean embed with proper language syntax highlighting. | Enabled automatically when the bot has the **Manage Messages** permission in the channel. Remove the permission to disable it. | - ## ⚡ Tech Stack - **Runtime:** Bun / Node.js (v20+) diff --git a/src/events/messageCreate/detectCode.ts b/src/events/messageCreate/detectCode.ts deleted file mode 100644 index db0c791..0000000 --- a/src/events/messageCreate/detectCode.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - EmbedBuilder, - PermissionFlagsBits, - type Client, - type Message, -} from "discord.js"; -import { detectCode } from "../../utils/detectCode.ts"; - -export default async (client: Client, message: Message) => { - if (message.author.bot) return; - if ( - !message.guild?.members.me?.permissions.has( - PermissionFlagsBits.ManageMessages, - ) - ) - return; - - const content = message.content; - const { isCode, score, sections } = detectCode(content); - - if (!isCode) return; - - // attempt to delete the message, if fail, return - try { - await message.delete(); - } catch (error) { - // console.error("Failed to delete message:", error); - return; - } - - const embed = new EmbedBuilder() - .setAuthor({ - name: message.author.tag, - iconURL: message.author.displayAvatarURL(), - }) - .setColor("#FF0000") - // .setTitle(`<@${message.author.id}> said:`) - .setDescription( - sections - .map((section) => - section.type === "code" - ? `\`\`\`${section.language || "txt"}\n${section.text}\`\`\`` - : section.text, - ) - .join("\n\n"), - ); - - const channel = message.channel; - if (channel.isTextBased() && "send" in channel) { - await channel.send({ - content: `<@${message.author.id}> said:`, - embeds: [embed], - }); - } -}; diff --git a/src/utils/detectCode.ts b/src/utils/detectCode.ts deleted file mode 100644 index e59eb9f..0000000 --- a/src/utils/detectCode.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { DetectedLanguage } from "flourite"; -import flourite from "flourite"; - -export interface Section { - type: "text" | "code"; - text: string; - language?: string; -} - -export interface AnalysisResult { - isCode: boolean; - score: number; - sections: Section[]; -} - -function isCodeLine(line: string, previousWasCode: boolean): boolean { - const trimmed = line.trim(); - if (!trimmed) return false; - - const lower = trimmed.toLowerCase(); - - const strong = [ - "def ", - "function ", - "class ", - "const ", - "let ", - "var ", - "import ", - "export ", - "interface ", - "type ", - "enum ", - "public ", - "private ", - "protected ", - "static ", - "fn ", - "pub ", - "async function", - "async ", - ]; - if (strong.some((s) => lower.startsWith(s))) return true; - - const leadingWs = line.length - line.trimStart().length; - if (leadingWs >= 2 && /^[a-zA-Z_$#]/.test(trimmed)) return true; - - if (/[:{]\s*$/.test(trimmed) && previousWasCode) return true; - - if (/^\s*(if|for|while|switch|try|catch)\b/.test(trimmed)) return true; - - const codeSymbols = (trimmed.match(/[{}();:=<>[\]]/g) || []).length; - const hasRealCodePunct = codeSymbols >= 2; - - if (hasRealCodePunct && previousWasCode) return true; - - const englishWords = - /\b(hello|everybody|someone|help|trying|write|simple|function|typescript|correct|here is|some text|after the code)\b/i; - if (englishWords.test(trimmed) && codeSymbols < 5) return false; - - return false; -} - -export function detectCode(content: string): AnalysisResult { - if (!content || typeof content !== "string") { - return { isCode: false, score: 0, sections: [] }; - } - - if (content.includes("```")) { - return { - isCode: false, - score: 0, - sections: [{ type: "text", text: content }], - }; - } - - const lines = content.split("\n"); - const hasNewlines = lines.length > 1; - - if (!hasNewlines) { - return { - isCode: false, - score: 0, - sections: [{ type: "text", text: content }], - }; - } - - const rawSections: Section[] = []; - let current: Section | null = null; - let previousWasCode = false; - let codeLineCount = 0; - - for (const line of lines) { - const isCode = isCodeLine(line, previousWasCode); - - if (isCode) { - codeLineCount++; - if (!current || current.type !== "code") { - if (current) rawSections.push(current); - current = { type: "code", text: line }; - } else { - current.text += "\n" + line; - } - } else { - if (!current || current.type !== "text") { - if (current) rawSections.push(current); - current = { type: "text", text: line }; - } else { - current.text += "\n" + line; - } - } - previousWasCode = isCode; - } - if (current) rawSections.push(current); - - const sections: Section[] = []; - - for (const sec of rawSections) { - let text = sec.text.replace(/^\n+/, "").replace(/\n+$/, ""); - if (!text.trim()) continue; - - if (sec.type === "code") { - let detectedLang: string | undefined; - - try { - const detect = flourite as unknown as ( - snippet: string, - ) => DetectedLanguage; - const result = detect(text.trim()); - - if (result.language && result.language !== "Unknown") { - detectedLang = result.language.toLowerCase(); - } else { - const statistics: Record = result.statistics ?? {}; - let best = 0; - for (const [lang, score] of Object.entries(statistics)) { - if (lang !== "Unknown" && score > best) { - best = score; - detectedLang = lang.toLowerCase(); - } - } - } - } catch {} - - if (detectedLang) { - sections.push({ type: "code", text, language: detectedLang }); - } else { - sections.push({ type: "code", text }); - } - } else { - sections.push({ type: "text", text }); - } - } - - const totalLines = lines.length; - const codeRatio = totalLines > 0 ? (codeLineCount / totalLines) * 100 : 0; - const score = Math.min(100, Math.round(codeRatio)); - - const isCode = sections.some((s) => s.type === "code") && score >= 25; - - return { - isCode, - score, - sections, - }; -} From c8379762c164720296d366ded62fdc3786955a6f Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 2 Jul 2026 23:22:14 +0300 Subject: [PATCH 3/4] feat: add crate command and reorganize search command fields --- CHANGELOG.md | 3 +- README.md | 10 +-- src/commands/docs/man.ts | 2 +- src/commands/search/crate.ts | 133 +++++++++++++++++++++++++++++++++++ src/commands/search/npm.ts | 1 - src/commands/search/pip.ts | 26 ++----- 6 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 src/commands/search/crate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffed91..1766427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ ### Added - Full interaction handler system with file-based routing -- New `man` command for viewing Unix/Linux manual pages directly from Discord +- New `man` command for viewing Unix/Linux manual pages for command documentation +- New `crate` command to search Rust crates ### Changed diff --git a/README.md b/README.md index 75939b0..738df77 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ All commands use the `;` prefix (e.g., `;run`). Premium features are marked with | Command | Description | | :------ | :---------------------------------------------------------------- | +| `;man` | View Unix/Linux manual pages for command documentation. | | `;mdn` | Search the MDN Web Docs for JavaScript, HTML, and CSS references. | | `;wiki` | Search and retrieve concise abstracts from Wikipedia articles. | @@ -59,10 +60,11 @@ All commands use the `;` prefix (e.g., `;run`). Premium features are marked with ### 🔍 Package Search -| Command | Description | -| :------ | :----------------------------------------------------------------------- | -| `;npm` | Query the npm registry for package metadata, dependencies, and versions. | -| `;pip` | Search the PyPI registry for Python package information. | +| Command | Description | +| :------- | :----------------------------------------------------------------------------------------- | +| `;crate` | Search the crates.io registry for Rust package details, download stats, and documentation. | +| `;npm` | Query the npm registry for package metadata, dependencies, and versions. | +| `;pip` | Search the PyPI registry for Python package information. | ### 🛠️ Developer Tools diff --git a/src/commands/docs/man.ts b/src/commands/docs/man.ts index 9c8aaf4..8b6aa57 100644 --- a/src/commands/docs/man.ts +++ b/src/commands/docs/man.ts @@ -22,7 +22,7 @@ export default { // raw, // sections, // }; - const term = args[0]; + const term = args.join(" "); if (!term) { return message.reply({ embeds: [ diff --git a/src/commands/search/crate.ts b/src/commands/search/crate.ts new file mode 100644 index 0000000..36396f5 --- /dev/null +++ b/src/commands/search/crate.ts @@ -0,0 +1,133 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from "discord.js"; +import fetch from "node-fetch"; +import type { CommandCallbackOpts } from "../../types/command.ts"; + +export default { + name: "crate", + description: "Search Rust crates", + usage: "crate ", + aliases: ["crates", "cargo"], + react: "🦀", + async callback({ message, args }: CommandCallbackOpts) { + try { + const query = args.join(" "); + if (!query) { + return message.reply( + "Please provide a search term, e.g. `;crate tokio`", + ); + } + + const searchRes = await fetch( + `https://crates.io/api/v1/crates?q=${encodeURIComponent(query)}&per_page=1`, + { + headers: { "User-Agent": "DiscordBot (contact@yourbotdomain.com)" }, + }, + ); + const searchData = await searchRes.json(); + + if (!searchData.crates || searchData.crates.length === 0) { + return message.reply(`¯\\_(ツ)_/¯ No crates found for **${query}**`); + } + + const crate = searchData.crates[0]; + const name = crate.name; + + const description = crate.description + ? crate.description.length > 800 + ? crate.description.slice( + 0, + crate.description.lastIndexOf(" ", 800), + ) + " ..." + : crate.description + : "No description available."; + + const embed = new EmbedBuilder() + .setTitle(name) + .setURL(`https://crates.io/crates/${name}`) + .setDescription(description) + .addFields( + { + name: "Version", + value: crate.max_version || "Unknown", + inline: true, + }, + { + name: "Author", + value: "See Registry", + inline: true, + }, + { + name: "Publisher", + value: "See Registry", + inline: true, + }, + { + name: "Recent Downloads", + value: crate.recent_downloads?.toLocaleString() || "Unknown", + inline: true, + }, + { + name: "Repository", + value: crate.repository + ? `[${crate.repository + .replace("https://github.com/", "") + .replace(".git", "") + .split("/") + .slice(-2) + .join("/")}](${crate.repository})` + : "Unknown", + inline: true, + }, + { + name: "License", + value: crate.license || "Other", + inline: true, + }, + ) + .setThumbnail( + "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Rust_programming_language_black_logo.svg/1200px-Rust_programming_language_black_logo.svg.png", + ) + .setFooter({ text: "Source: crates.io" }); + + const button = new ButtonBuilder() + .setLabel("View on npm") + .setLabel("View on crates.io") + .setStyle(ButtonStyle.Link) + .setURL(`https://crates.io/crates/${name}`); + + const componentButtons = [button]; + if (crate.homepage) { + componentButtons.push( + new ButtonBuilder() + .setLabel("Visit Homepage") + .setStyle(ButtonStyle.Link) + .setURL(crate.homepage), + ); + } + + const row = new ActionRowBuilder().addComponents(...componentButtons); + + return message.reply({ + embeds: [embed], + components: [row.toJSON()], + }); + } catch (err) { + console.error(err); + return message.reply({ + embeds: [ + new EmbedBuilder() + .setTitle("❌ Failed to fetch crate results") + .setDescription( + "An error occurred while fetching results from crates.io.", + ) + .setColor(0xd21872), + ], + }); + } + }, +}; diff --git a/src/commands/search/npm.ts b/src/commands/search/npm.ts index 02ee9a6..6b686b8 100644 --- a/src/commands/search/npm.ts +++ b/src/commands/search/npm.ts @@ -104,7 +104,6 @@ export default { }, ) .setThumbnail( - // npm logo red big "https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png", ) .setFooter({ text: "Source: npmjs.com" }); diff --git a/src/commands/search/pip.ts b/src/commands/search/pip.ts index 9ee66aa..e3ca658 100644 --- a/src/commands/search/pip.ts +++ b/src/commands/search/pip.ts @@ -30,10 +30,6 @@ export default { if (pkgRes.ok) { pkgData = await pkgRes.json(); } else { - const searchRes = await fetch( - `https://pypi.org/search/?q=${encodeURIComponent(query)}&o=-zscore&c=&format=json`, - ); - return message.reply( `¯\\_(ツ)_/¯ No PyPI packages found for **${query}**`, ); @@ -41,8 +37,6 @@ export default { const info = pkgData.info; const latestVersion = info.version; - const releases = pkgData.releases?.[latestVersion] || []; - const latestRelease = releases[releases.length - 1]; const dlRes = await fetch( `https://pypistats.org/api/packages/${encodeURIComponent(info.name.toLowerCase())}/recent`, @@ -59,7 +53,6 @@ export default { info.project_urls?.Source || info.project_urls?.GitHub || info.project_urls?.Repository || - info.project_urls?.Homepage || null; const repoDisplay = repoUrl @@ -82,9 +75,8 @@ export default { inline: true, }, { - name: "License", - value: - (info.license || "Other").split("\n")[0].slice(0, 100) || "Other", + name: "Publisher", + value: info.maintainer || info.author || "Unknown", inline: true, }, { @@ -98,8 +90,9 @@ export default { inline: true, }, { - name: "Python Requires", - value: info.requires_python || "Not specified", + name: "License", + value: + (info.license || "Other").split("\n")[0].slice(0, 100) || "Other", inline: true, }, ) @@ -124,15 +117,6 @@ export default { ); } - if (info.project_urls?.Documentation || info.docs_url) { - buttons.push( - new ButtonBuilder() - .setLabel("Docs") - .setStyle(ButtonStyle.Link) - .setURL(info.project_urls?.Documentation || info.docs_url), - ); - } - const row = new ActionRowBuilder().addComponents(...buttons); return message.reply({ embeds: [embed], components: [row.toJSON()] }); From d7714fa43faeb9ae7c92d869aea841a899dabf8f Mon Sep 17 00:00:00 2001 From: Caleb Date: Fri, 3 Jul 2026 01:13:03 +0300 Subject: [PATCH 4/4] feat: add gem command for ruby package lookup --- CHANGELOG.md | 1 + README.md | 1 + src/commands/search/gem.ts | 141 +++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 src/commands/search/gem.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1766427..e1024a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Full interaction handler system with file-based routing - 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 ### Changed diff --git a/README.md b/README.md index 738df77..3670b44 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ All commands use the `;` prefix (e.g., `;run`). Premium features are marked with | Command | Description | | :------- | :----------------------------------------------------------------------------------------- | | `;crate` | Search the crates.io registry for Rust package details, download stats, and documentation. | +| `;gem` | Search the Ruby gems for Ruby package details, total downloads, and documentation page | | `;npm` | Query the npm registry for package metadata, dependencies, and versions. | | `;pip` | Search the PyPI registry for Python package information. | diff --git a/src/commands/search/gem.ts b/src/commands/search/gem.ts new file mode 100644 index 0000000..afe83db --- /dev/null +++ b/src/commands/search/gem.ts @@ -0,0 +1,141 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from "discord.js"; +import fetch from "node-fetch"; +import type { CommandCallbackOpts } from "../../types/command.ts"; + +export default { + name: "gem", + description: "Search Ruby gems", + usage: "gem ", + aliases: ["gems", "gempkg"], + react: "💎", + async callback({ message, args }: CommandCallbackOpts) { + try { + const query = args.join(" "); + if (!query) { + return message.reply("Please provide a search term, e.g. `;gem rails`"); + } + + const searchRes = await fetch( + `https://rubygems.org/api/v1/search.json?query=${encodeURIComponent(query)}`, + ); + + if (!searchRes.ok) { + throw new Error("RubyGems API failed to respond properly."); + } + + const searchData = await searchRes.json(); + + if (!searchData || searchData.length === 0) { + return message.reply(`¯\\_(ツ)_/¯ No Ruby gems found for **${query}**`); + } + + const gem = searchData[0]; + + const description = gem.info + ? gem.info.length > 800 + ? gem.info.slice(0, gem.info.lastIndexOf(" ", 800)) + " ..." + : gem.info + : "No description available."; + + const repoUrl = gem.source_code_uri || gem.homepage_uri || null; + + const repoDisplay = + repoUrl && repoUrl.includes("github.com") + ? `[${repoUrl.replace("https://github.com/", "").split("/").slice(0, 2).join("/")}](${repoUrl})` + : repoUrl + ? `[View Source](${repoUrl})` + : "Unknown"; + + const embed = new EmbedBuilder() + .setTitle(gem.name) + .setURL(`https://rubygems.org/gems/${gem.name}`) + .setDescription(description) + .addFields( + { + name: "Version", + value: gem.version || "Unknown", + inline: true, + }, + { + name: "Author", + value: gem.authors || "Unknown", + inline: true, + }, + { + name: "Publisher", + value: gem.authors?.split(",")[0] || "Unknown", + inline: true, + }, + { + name: "Total Downloads", + value: gem.downloads?.toLocaleString() || "Unknown", + inline: true, + }, + { + name: "Repository", + value: repoDisplay, + inline: true, + }, + { + name: "License", + value: gem.licenses?.join(", ") || "Other", + inline: true, + }, + ) + .setThumbnail( + "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e6/RubyGems_Logo_2015.svg/1200px-RubyGems_Logo_2015.svg.png", + ) + .setFooter({ text: "Source: rubygems.org" }) + .setColor(0xe9573f); + + const buttons = [ + new ButtonBuilder() + .setLabel("View on RubyGems") + .setStyle(ButtonStyle.Link) + .setURL(`https://rubygems.org/gems/${gem.name}`), + ]; + + if (gem.homepage_uri) { + buttons.push( + new ButtonBuilder() + .setLabel("Visit Homepage") + .setStyle(ButtonStyle.Link) + .setURL(gem.homepage_uri), + ); + } + + if (gem.documentation_uri) { + buttons.push( + new ButtonBuilder() + .setLabel("Docs") + .setStyle(ButtonStyle.Link) + .setURL(gem.documentation_uri), + ); + } + + const row = new ActionRowBuilder().addComponents(...buttons); + + return message.reply({ + embeds: [embed], + components: [row.toJSON()], + }); + } catch (err) { + console.error(err); + return message.reply({ + embeds: [ + new EmbedBuilder() + .setTitle("❌ Failed to fetch RubyGems results") + .setDescription( + "An error occurred while fetching results from rubygems.org.", + ) + .setColor(0xd21872), + ], + }); + } + }, +};