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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change Log

## `v1.1.0` - 02/07/2026

### Added

- Automatic code block detection and reformatting feature

## `v1.0.0` - 01/07/2026

### Added
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ 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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "quillbot",
"version": "1.0.0",
"version": "1.1.0",
"description": "Quill is an advanced Discord developer assistant bot built to help programmers code faster, learn better, and debug smarter, directly inside Discord.",
"main": "src/index.ts",
"type": "module",
Expand All @@ -19,6 +19,7 @@
"discord.js": "^14.25.0",
"dotenv": "^17.2.3",
"firebase-admin": "^13.8.0",
"flourite": "^1.3.0",
"groq-sdk": "^0.37.0",
"node-cache": "^5.1.2",
"prettier": "^3.9.4",
Expand Down
104 changes: 87 additions & 17 deletions src/commands/meta/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,102 @@
import { EmbedBuilder } from "discord.js";
import {
ActionRowBuilder,
EmbedBuilder,
StringSelectMenuBuilder,
} from "discord.js";
import path from "path";
import { fileURLToPath } from "url";
import type { CommandCallbackOpts } from "../../types/command.ts";
import { readFile } from "../../utils/fileOps.ts";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

interface VersionSection {
title: string;
content: string;
}

function parseChangelog(content: string): VersionSection[] {
if (!content?.trim()) return [];

const parts = content.split(/^## /m);
const versions: VersionSection[] = [];

for (let i = 1; i < parts.length; i++) {
const section = parts[i]?.trim();
if (!section) continue;

const firstNewline = section.indexOf("\n");
const title = (
firstNewline === -1 ? section : section.slice(0, firstNewline)
).trim();
const body = (
firstNewline === -1 ? "" : section.slice(firstNewline)
).trim();

if (title) {
versions.push({
title,
content: `## ${title}\n\n${body}`,
});
}
}

return versions;
}

export default {
name: "changelog",
description: "See the latest changes made to the bot.",
usage: "changelog",
aliases: ["changes", "updates"],
async callback({ message }: CommandCallbackOpts) {
const changelog = await readFile(
path.join(__dirname, "..", "..", "..", "CHANGELOG.md"),
);
const latestChanges =
"## " +
changelog
?.split(/^## /m)[1]
?.trim()
.replace(/\r?\n\r?\n/g, "\n");

const changelogEmbed = new EmbedBuilder()
.setTitle("📝 Latest Changes")
.setDescription(latestChanges || "No changelog available.")
.setColor(0x7289da);

await message.reply({ embeds: [changelogEmbed] });
try {
const changelogContent =
(await readFile(
path.join(__dirname, "..", "..", "..", "CHANGELOG.md"),
)) || "";

const versions = parseChangelog(changelogContent);

if (versions.length === 0) {
return message.reply({
embeds: [
new EmbedBuilder()
.setTitle("📝 Changelog")
.setDescription("No changelog available.")
.setColor(0x7289da),
],
});
}

const latest = versions[0];

const embed = new EmbedBuilder()
.setTitle("📝 Latest Changes")
.setDescription(
latest?.content || "No changes listed for the latest version.",
)
.setColor(0x7289da);

const selectMenu = new StringSelectMenuBuilder()
.setCustomId("changelog_select")
.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,
value: i.toString(),
...(i === 0 ? { description: "Latest version" } : {}),
})),
);

const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
selectMenu,
);

await message.reply({ embeds: [embed], components: [row] });
} catch (err) {
console.error("Error fetching changelog:", err);
}
},
};
121 changes: 121 additions & 0 deletions src/events/interactionCreate/handleInteractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Client, EmbedBuilder, type Interaction } from "discord.js";
import "dotenv/config";
import path, { join } from "path";
import { fileURLToPath } from "url";
import config from "../../../config.json" with { type: "json" };
import getAllFiles from "../../utils/getAllFiles.ts";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

interface InteractionObject {
id: string;
callback: Function;
}

const interactionsPath = join(__dirname, "..", "..", "interactions");

let cachedInteractions: InteractionObject[] = [];

async function loadInteractions() {
const interactionFiles = getAllFiles(interactionsPath);
const interactionPromises: Promise<any>[] = [];

for (const file of interactionFiles) {
interactionPromises.push(import(file));
}

const imported = await Promise.all(interactionPromises);
cachedInteractions = imported.map((intObj) => intObj.default).filter(Boolean);
}

// load at startup
await loadInteractions();

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.

Top-level await blocks the entire module loading process. If loadInteractions() fails or takes a long time (e.g., due to slow file I/O or a large number of interaction files), it will prevent the entire bot from starting up. This creates a single point of failure during initialization and makes the bot unresponsive until all interactions are loaded.

Confidence: 5/5

Suggested Fix

Move the interaction loading into the event handler's initialization or use a lazy-loading pattern. Consider wrapping it in a try-catch and providing fallback behavior:

// Option 1: Load asynchronously with error handling
loadInteractions().catch(err => {
console.error('Failed to load interactions:', err);
// Bot can still start, but interactions won't work
});
// Option 2: Lazy load on first interaction
let interactionsLoaded = false;
async function ensureInteractionsLoaded() {
if (!interactionsLoaded) {
await loadInteractions();
interactionsLoaded = true;
}

This prevents the bot from being completely blocked if interaction loading fails and provides better error recovery.

Prompt for AI

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

In src/events/interactionCreate/handleInteractions.ts at line 32, the top-level await
loadInteractions() blocks the entire module loading process and creates a single point
of failure during bot startup; refactor this to either load interactions asynchronously
without blocking (using .catch() for error handling) or implement lazy loading that
loads interactions on first use, ensuring the bot can still start even if interaction
loading fails, and add appropriate error logging for debugging.

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 top-level await loadInteractions() lacks error handling. If this fails during module initialization (e.g., file system errors, malformed interaction files), the entire application will crash with an unhandled promise rejection, preventing the bot from starting.

Confidence: 5/5

Suggested Fix
Suggested change
await loadInteractions();
try {
await loadInteractions();
} catch (error) {
console.error("Failed to load interactions at startup:", error);
process.exit(1);
}

Wrap the top-level await in a try-catch block to handle initialization failures gracefully. Exit the process explicitly if interactions can't be loaded, as the bot cannot function without them.

Prompt for AI

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

In src/events/interactionCreate/handleInteractions.ts at line 32, the top-level await
loadInteractions() lacks error handling which can cause the entire application to crash
with an unhandled promise rejection if loading fails; wrap the await loadInteractions()
call in a try-catch block that logs the error and calls process.exit(1) to fail fast
during startup rather than leaving the bot in an undefined state.


export default async (client: Client, interaction: Interaction) => {
const { devs } = config;

if (
process.env.NODE_ENV?.toLowerCase() === "dev" &&
!devs.includes(interaction.user.id)
)
return;

if (!interaction || interaction.user?.bot) return;

// Keep track of interaction id across try/catch scope
let interactionObject: InteractionObject | undefined;
let interactionId = "";

try {
// Determine identifier: commandName for slash/context/autocomplete, customId for components/modals
if (
interaction.isChatInputCommand() ||
interaction.isContextMenuCommand() ||
interaction.isAutocomplete()
) {
interactionId = interaction.commandName;
} else if (
interaction.isButton() ||
interaction.isStringSelectMenu() ||
interaction.isUserSelectMenu() ||
interaction.isRoleSelectMenu() ||
interaction.isMentionableSelectMenu() ||
interaction.isModalSubmit()
) {
interactionId = interaction.customId;
}

if (!interactionId) return;

interactionObject = cachedInteractions.find((intObj) => {
if (!intObj?.id) return false;
return intObj.id === interactionId;
});

if (!interactionObject) return;

if (typeof interactionObject.callback === "function") {
await interactionObject.callback({ client, interaction });
} else {
console.error(
`Invalid interaction configuration for id: ${interactionObject.id}`,
);
return;
}
} catch (err) {
const intId = interactionObject?.id || interactionId || "unknown";

console.error(`Error executing ${intId} interaction: ${err}`);

try {
const errorEmbed = new EmbedBuilder()
.setTitle("❌ Interaction Error")
.setDescription("An error occurred while running that interaction.")
.setColor(0xff0000);

if ((interaction as any).replied || (interaction as any).deferred) {
await (interaction as any)
.followUp({ embeds: [errorEmbed], ephemeral: true })
.catch((followUpErr: Error) => {
console.error(
`Failed to send followUp error message for ${intId}:`,
followUpErr,
);
});
} else {
await (interaction as any)
.reply({ embeds: [errorEmbed], ephemeral: true })
.catch((replyErr: Error) => {
console.error(
`Failed to send reply error message for ${intId}:`,
replyErr,
);
});
}
} catch (replyErr) {
console.error(
`Failed to send error reply for ${intId} interaction: ${replyErr}`,
);
}
}
};
55 changes: 55 additions & 0 deletions src/events/messageCreate/detectCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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;
}
Comment on lines +24 to +29

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.

Missing error handling for permission-related failures: The code attempts to delete a message but only catches generic errors. If the bot loses the ManageMessages permission between the permission check (line 11-16) and the delete operation (line 25), the error is silently caught and the function returns without any feedback to the user. This creates a poor user experience where the bot appears to do nothing.
Additionally, there's no validation that the message is deletable (e.g., the message might be too old, or there could be rate limiting issues).

Confidence: 5/5

Suggested Fix
Suggested change
try {
await message.delete();
} catch (error) {
// console.error("Failed to delete message:", error);
return;
}
// attempt to delete the message, if fail, return
try {
await message.delete();
} catch (error) {
// If deletion fails due to permissions or other issues,
// we cannot reformat the message, so exit gracefully
if (error instanceof Error && error.message.includes('Missing Permissions')) {
// Permission was revoked between check and delete
return;
}
// For other errors (rate limit, message too old, etc.), also exit
return;
}

Why this matters: While the current code does catch errors, adding specific error type checking would help with debugging and monitoring. However, since the user rules specify only reporting issues that cause bugs or severe problems, and this code does handle the error by returning, this is more of a code quality concern than a critical bug.

Prompt for AI

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

In src/events/messageCreate/detectCode.ts around line 24-29, the error handling for message deletion is too generic and doesn't distinguish between different failure types (permission errors, rate limits, message age, etc.); add more specific error handling or at minimum add a comment explaining why all errors are treated the same way, and consider whether any errors should be logged for monitoring purposes.

📍 This suggestion applies to lines 24-29


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],
});
Comment on lines +50 to +53

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 channel.send() operation lacks error handling. If the send fails after the original message is deleted (line 25), the user's message is permanently lost. This can happen due to permission issues, rate limits, or network errors.

Confidence: 5/5

Suggested Fix
Suggested change
await channel.send({
content: `<@${message.author.id}> said:`,
embeds: [embed],
});
try {
await channel.send({
content: `<@${message.author.id}> said:`,
embeds: [embed],
});
} catch (error) {
// Log the error or attempt recovery
console.error("Failed to send reformatted message:", error);
}

Wrap the channel.send() call in a try-catch block to handle potential failures gracefully. Consider logging the error or implementing a recovery mechanism.

Prompt for AI

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

In src/events/messageCreate/detectCode.ts around line 50, the channel.send() operation
lacks error handling which can result in message loss if the send fails after the
original message is already deleted; wrap the channel.send() call in a try-catch block
to handle potential failures (permissions, rate limits, network errors) and add
appropriate error logging.

📍 This suggestion applies to lines 50-53

}
Comment on lines +48 to +54

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.

Missing error handling for embed send operation: After successfully deleting the user's message, if the channel.send() operation fails (due to missing permissions, rate limits, or channel being deleted), the original message is already deleted and cannot be recovered. This results in message loss without any user feedback.

Confidence: 5/5

Suggested Fix
Suggested change
const channel = message.channel;
if (channel.isTextBased() && "send" in channel) {
await channel.send({
content: `<@${message.author.id}> said:`,
embeds: [embed],
});
}
const channel = message.channel;
if (channel.isTextBased() && "send" in channel) {
try {
await channel.send({
content: `<@${message.author.id}> said:`,
embeds: [embed],
});
} catch (error) {
// Failed to send the reformatted message after deleting the original
// The original message is already lost, but we should handle this gracefully
// Consider logging this error for monitoring
return;
}

Why this matters: This is a critical user experience issue. If the bot deletes a user's message but then fails to repost it, the user's content is permanently lost. This can happen due to:

  • Bot losing Send Messages permission between delete and send
  • Rate limiting
  • Channel being deleted/archived
  • Embed size limits being exceeded
Prompt for AI

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

In src/events/messageCreate/detectCode.ts around line 48-54, add error handling for the channel.send() operation because if it fails after the original message was deleted, the user's content is permanently lost; wrap the send operation in a try-catch block to handle failures gracefully, and consider whether failed sends should be logged for monitoring purposes.

📍 This suggestion applies to lines 48-54

};
Loading
Loading