Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

### 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
- 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

Expand Down
19 changes: 7 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -59,10 +60,12 @@ 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. |
| `;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. |

### 🛠️ Developer Tools

Expand All @@ -87,14 +90,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+)
Expand Down
20 changes: 16 additions & 4 deletions src/commands/docs/man.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,32 @@ export default {
// raw,
// sections,
// };
const page = await man(args.join(" "));
if (!page) {
const term = args.join(" ");
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.")
Expand Down
133 changes: 133 additions & 0 deletions src/commands/search/crate.ts
Original file line number Diff line number Diff line change
@@ -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 <crate name>",
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();
Comment on lines +25 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code doesn't validate the HTTP response status before attempting to parse JSON. If the crates.io API returns an error status (4xx, 5xx), calling .json() on a non-JSON response will throw an exception that gets caught generically, hiding the actual API error from users.

Confidence: 5/5

Suggested Fix
Suggested change
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();
const searchRes = await fetch(
`https://crates.io/api/v1/crates?q=${encodeURIComponent(query)}&per_page=1`,
{
headers: { "User-Agent": "DiscordBot (contact@yourbotdomain.com)" },
},
);
if (!searchRes.ok) {
return message.reply(`Failed to fetch crate data: ${searchRes.status} ${searchRes.statusText}`);
}
const searchData = await searchRes.json();

Add HTTP response status validation after the fetch call and before parsing JSON. This prevents attempting to parse error HTML/text responses as JSON and provides clearer error messages to users.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In src/commands/search/crate.ts around line 25-31, the fetch call to crates.io API doesn't validate the HTTP response status before calling .json(). Add a check for searchRes.ok after line 30 and before line 31, and return an appropriate error message if the response status indicates failure (4xx or 5xx). This prevents JSON parsing errors when the API returns non-JSON error responses.

📍 This suggestion applies to lines 25-31


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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ActionRowBuilder is instantiated without a generic type parameter, which will cause TypeScript compilation errors or runtime issues when calling .toJSON(). Discord.js v14+ requires explicit typing for ActionRowBuilder components.

Confidence: 5/5

Suggested Fix
Suggested change
const row = new ActionRowBuilder().addComponents(...componentButtons);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(...componentButtons);

Add the generic type parameter <ButtonBuilder> to ActionRowBuilder to ensure type safety and prevent runtime errors when converting to JSON.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In src/commands/search/crate.ts at line 113, the ActionRowBuilder is missing its generic type parameter. Add <ButtonBuilder> after ActionRowBuilder to properly type the component row, which is required for Discord.js v14+ and prevents type errors when calling .toJSON().


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),
],
});
}
},
};
141 changes: 141 additions & 0 deletions src/commands/search/gem.ts
Original file line number Diff line number Diff line change
@@ -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 <gem name>",
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)}`,
);
Comment on lines +23 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch request lacks a timeout, which can cause the Discord bot to hang indefinitely if the RubyGems API is slow or unresponsive. This can lead to resource exhaustion and poor user experience.

Confidence: 5/5

Suggested Fix
Suggested change
const searchRes = await fetch(
`https://rubygems.org/api/v1/search.json?query=${encodeURIComponent(query)}`,
);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const searchRes = await fetch(
`https://rubygems.org/api/v1/search.json?query=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
clearTimeout(timeoutId);

Add a 10-second timeout to prevent the request from hanging indefinitely. Wrap the fetch in a try-catch to handle AbortError appropriately.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In src/commands/search/gem.ts around line 23, the fetch request to the RubyGems API lacks a timeout mechanism, which can cause the bot to hang indefinitely if the API is slow or unresponsive; add an AbortController with a 10-second timeout to the fetch request, and handle the AbortError in the catch block to provide a user-friendly timeout message.

📍 This suggestion applies to lines 23-25


if (!searchRes.ok) {
throw new Error("RubyGems API failed to respond properly.");
}

const searchData = await searchRes.json();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The searchRes.json() call can throw an error if the API returns invalid JSON, but this isn't caught separately from other errors. This could expose internal error details to users or cause unclear error messages.

Confidence: 5/5

Suggested Fix
Suggested change
const searchData = await searchRes.json();
const searchData = await searchRes.json().catch(() => {
throw new Error("Invalid response format from RubyGems API");
});

Add explicit error handling for JSON parsing failures to provide clearer error messages and prevent exposing internal error details.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In src/commands/search/gem.ts around line 31, the JSON parsing of the API response can throw an error if the response is malformed, but this isn't handled separately; wrap the json() call in a try-catch or use .catch() to throw a more specific error message like "Invalid response format from RubyGems API" to improve error clarity.


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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ActionRowBuilder requires a generic type parameter in Discord.js v14+. Without it, TypeScript will throw a compilation error, and the toJSON() method may not work correctly.

Confidence: 5/5

Suggested Fix
Suggested change
const row = new ActionRowBuilder().addComponents(...buttons);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(...buttons);

Add the generic type parameter <ButtonBuilder> to ensure type safety and proper compilation.

Prompt for AI

Copy this prompt to your AI IDE to fix this issue locally:

In src/commands/search/gem.ts at line 121, the ActionRowBuilder is missing its required generic type parameter; change it to ActionRowBuilder<ButtonBuilder> to ensure proper TypeScript compilation and type safety with Discord.js v14+.


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),
],
});
}
},
};
1 change: 0 additions & 1 deletion src/commands/search/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
Loading
Loading