diff --git a/CHANGELOG.md b/CHANGELOG.md index 30824e6..06e4141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 75939b0..0581eb4 100644 --- a/README.md +++ b/README.md @@ -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+) diff --git a/bun.lock b/bun.lock index 60eb6d2..0841b2c 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,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", @@ -334,6 +335,8 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "flourite": ["flourite@1.3.0", "", {}, "sha512-iuhWXuX07QwHMnJ1Irh4sD1bk/QFMHg8jVgWsjSAqoIqgIyJtRPnUNKyZAPXrw7pQkDvxb5AIz2KPihEoyVcqw=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], diff --git a/package.json b/package.json index 30dfcb4..40918f6 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/commands/meta/changelog.ts b/src/commands/meta/changelog.ts index 86506e7..229e061 100644 --- a/src/commands/meta/changelog.ts +++ b/src/commands/meta/changelog.ts @@ -1,4 +1,8 @@ -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"; @@ -6,27 +10,93 @@ 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().addComponents( + selectMenu, + ); + + await message.reply({ embeds: [embed], components: [row] }); + } catch (err) { + console.error("Error fetching changelog:", err); + } }, }; diff --git a/src/events/interactionCreate/handleInteractions.ts b/src/events/interactionCreate/handleInteractions.ts new file mode 100644 index 0000000..6a2317f --- /dev/null +++ b/src/events/interactionCreate/handleInteractions.ts @@ -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[] = []; + + 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(); + +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}`, + ); + } + } +}; diff --git a/src/events/messageCreate/detectCode.ts b/src/events/messageCreate/detectCode.ts new file mode 100644 index 0000000..db0c791 --- /dev/null +++ b/src/events/messageCreate/detectCode.ts @@ -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; + } + + 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/interactions/changelog.ts b/src/interactions/changelog.ts new file mode 100644 index 0000000..99fbe8e --- /dev/null +++ b/src/interactions/changelog.ts @@ -0,0 +1,104 @@ +import { + ActionRowBuilder, + EmbedBuilder, + StringSelectMenuBuilder, + StringSelectMenuInteraction, +} from "discord.js"; +import path from "path"; +import { fileURLToPath } from "url"; +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 []; + + // Split on "## " headings (standard Keep a Changelog / conventional format) + 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 { + id: "changelog_select", + async callback({ + interaction, + }: { + interaction: StringSelectMenuInteraction; + }) { + if (!interaction.isStringSelectMenu()) return; + + const selectedIndex = parseInt(interaction.values[0] ?? "0", 10); + + const changelogContent = + (await readFile(path.join(__dirname, "..", "..", "CHANGELOG.md"))) || ""; + + const versions = parseChangelog(changelogContent); + + if (!versions[selectedIndex]) { + return interaction.reply({ + content: "❌ Selected version not found.", + ephemeral: true, + }); + } + + const selected = versions[selectedIndex]; + + const embed = new EmbedBuilder() + .setTitle("📝 Changelog") + .setDescription(selected.content || "No changes listed for this version.") + .setColor(0x7289da) + .setFooter({ text: `Viewing: ${selected.title}` }); + + // Rebuild the exact same dropdown so the user can switch versions again + 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 === selectedIndex + ? { description: "Currently viewing" } + : i === 0 + ? { description: "Latest version" } + : {}), + })), + ); + + const row = new ActionRowBuilder().addComponents( + selectMenu, + ); + + await interaction.update({ + embeds: [embed], + components: [row], + }); + }, +}; diff --git a/src/utils/detectCode.ts b/src/utils/detectCode.ts new file mode 100644 index 0000000..e59eb9f --- /dev/null +++ b/src/utils/detectCode.ts @@ -0,0 +1,166 @@ +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, + }; +}