diff --git a/__tests__/commands/ask.test.js b/__tests__/commands/ask.test.js index 97a7a1d..773a5da 100644 --- a/__tests__/commands/ask.test.js +++ b/__tests__/commands/ask.test.js @@ -1,11 +1,14 @@ -const axios = require('axios'); -const { createMockInteraction } = require('../utils/testUtils'); +import { jest } from '@jest/globals'; // Mock axios -jest.mock('axios'); +jest.unstable_mockModule('axios', () => ({ + default: { + post: jest.fn(), + }, +})); // Mock the discord.js module -jest.mock('discord.js', () => ({ +jest.unstable_mockModule('discord.js', () => ({ SlashCommandBuilder: jest.fn().mockReturnValue({ setName: jest.fn().mockReturnThis(), setDescription: jest.fn().mockReturnThis(), @@ -17,33 +20,17 @@ jest.mock('discord.js', () => ({ }; callback(option); return { - addBooleanOption: jest.fn().mockImplementation(boolCallback => { - const boolOption = { - setName: jest.fn().mockReturnThis(), - setDescription: jest.fn().mockReturnThis(), - setRequired: jest.fn().mockReturnThis(), - }; - boolCallback(boolOption); - return { - toJSON: jest.fn().mockReturnValue({ - name: 'ask', - description: 'Ask a question to the AI', - options: [ - { - name: 'prompt', - description: 'Your question or prompt', - type: 3, - required: true, - }, - { - name: 'websearch', - description: 'Enable web search for more up-to-date information', - type: 5, - required: false, - }, - ], - }), - }; + toJSON: jest.fn().mockReturnValue({ + name: 'ask', + description: 'Ask a question to the AI', + options: [ + { + name: 'prompt', + description: 'Your question or prompt', + type: 3, + required: true, + }, + ], }), }; }), @@ -51,33 +38,32 @@ jest.mock('discord.js', () => ({ }), })); -const askCommand = require('../../commands/ask'); +const axios = (await import('axios')).default; +const { createMockInteraction } = await import('../utils/testUtils.js'); +const AskCommand = (await import('../../commands/ask.js')).default; +const askCommand = new AskCommand(); describe('Ask Command', () => { describe('Command Structure', () => { it('should have correct name and description', () => { - const commandData = askCommand.data.toJSON(); + const commandData = askCommand.defineCommand().toJSON(); expect(commandData.name).toBe('ask'); expect(commandData.description).toBe('Ask a question to the AI'); }); it('should have required command properties', () => { - expect(askCommand).toHaveProperty('data'); - expect(askCommand).toHaveProperty('execute'); - expect(typeof askCommand.execute).toBe('function'); + expect(askCommand).toHaveProperty('defineCommand'); + expect(askCommand).toHaveProperty('run'); + expect(typeof askCommand.run).toBe('function'); }); it('should have correct option configuration', () => { - const commandData = askCommand.data.toJSON(); - const [promptOption, websearchOption] = commandData.options; + const commandData = askCommand.defineCommand().toJSON(); + const [promptOption] = commandData.options; expect(promptOption.name).toBe('prompt'); expect(promptOption.description).toBe('Your question or prompt'); expect(promptOption.required).toBe(true); - - expect(websearchOption.name).toBe('websearch'); - expect(websearchOption.description).toBe('Enable web search for more up-to-date information'); - expect(websearchOption.required).toBe(false); }); }); @@ -97,8 +83,7 @@ describe('Ask Command', () => { }; beforeEach(() => { - process.env.OPENROUTER_API_KEY = 'test-api-key'; - axios.post.mockReset(); + process.env.NANOGPT_API_KEY = 'test-api-key'; jest.clearAllMocks(); interaction = createMockInteraction({ @@ -106,22 +91,18 @@ describe('Ask Command', () => { stringOptions: { prompt: mockPrompt, }, - booleanOptions: { - websearch: false, - }, }); }); - it('should handle successful API response with websearch disabled', async () => { + it('should handle successful API response', async () => { axios.post.mockResolvedValueOnce(mockApiResponse); - await askCommand.execute(interaction); + await askCommand.run(interaction); expect(axios.post).toHaveBeenCalledWith( - 'https://openrouter.ai/api/v1/chat/completions', + 'https://nano-gpt.com/api/v1/chat/completions', expect.objectContaining({ - model: 'google/gemini-2.0-flash-001', - plugins: undefined, + model: 'deepseek/deepseek-v3.2', }), expect.any(Object), ); @@ -133,76 +114,7 @@ describe('Ask Command', () => { }); }); - it('should handle successful API response with websearch enabled', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { - prompt: mockPrompt, - }, - booleanOptions: { - websearch: true, - }, - }); - - axios.post.mockResolvedValueOnce(mockApiResponse); - - await askCommand.execute(interaction); - - expect(axios.post).toHaveBeenCalledWith( - 'https://openrouter.ai/api/v1/chat/completions', - expect.objectContaining({ - model: 'google/gemini-2.0-flash-001:online', - plugins: expect.arrayContaining([ - expect.objectContaining({ - id: 'web', - max_results: 3, - }), - ]), - }), - expect.any(Object), - ); - - expect(interaction.followUp).toHaveBeenCalledWith({ - content: expect.stringContaining('Web search enabled'), - split: false, - allowedMentions: { parse: [] }, - }); - }); - - it('should use default websearch value when not provided', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { - prompt: mockPrompt, - }, - // Not providing websearch option - }); - - axios.post.mockResolvedValueOnce(mockApiResponse); - - await askCommand.execute(interaction); - - expect(axios.post).toHaveBeenCalledWith( - 'https://openrouter.ai/api/v1/chat/completions', - expect.objectContaining({ - model: 'google/gemini-2.0-flash-001', - plugins: undefined, - }), - expect.any(Object), - ); - }); - - it('should handle long responses with proper chunking and websearch enabled', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { - prompt: mockPrompt, - }, - booleanOptions: { - websearch: true, - }, - }); - + it('should handle long responses with proper chunking', async () => { const longResponse = 'A'.repeat(2500); axios.post.mockResolvedValueOnce({ data: { @@ -216,24 +128,13 @@ describe('Ask Command', () => { }, }); - await askCommand.execute(interaction); + await askCommand.run(interaction); expect(interaction.followUp).toHaveBeenCalledTimes(2); - expect(interaction.followUp.mock.calls[0][0].content).toContain('Web search enabled'); expect(interaction.followUp.mock.calls[1][0].content).toContain('(continued)'); }); - it('should handle code blocks in chunked responses with websearch', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { - prompt: mockPrompt, - }, - booleanOptions: { - websearch: true, - }, - }); - + it('should handle code blocks in chunked responses', async () => { const responseWithCodeBlock = "Here's a code example:\n```python\nprint('hello')\n```".repeat( 20, ); @@ -249,7 +150,7 @@ describe('Ask Command', () => { }, }); - await askCommand.execute(interaction); + await askCommand.run(interaction); // Verify that code blocks are not split in the middle interaction.followUp.mock.calls.forEach(call => { @@ -257,23 +158,14 @@ describe('Ask Command', () => { const openBlocks = (content.match(/```/g) || []).length; expect(openBlocks % 2).toBe(0); // Should always be even }); - - // First chunk should contain websearch indicator - expect(interaction.followUp.mock.calls[0][0].content).toContain('Web search enabled'); }); - it('should handle API errors with websearch enabled', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { prompt: mockPrompt }, - booleanOptions: { websearch: true }, - }); - + it('should handle API errors', async () => { const error = new Error('API Error'); error.response = { data: 'API Error Details' }; axios.post.mockRejectedValueOnce(error); - await askCommand.execute(interaction); + await askCommand.run(interaction); expect(interaction.followUp).toHaveBeenCalledWith({ content: 'Sorry, there was an error processing your request.', @@ -282,16 +174,11 @@ describe('Ask Command', () => { }); it('should handle rate limit errors', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { prompt: mockPrompt }, - }); - const error = new Error('Rate Limit Exceeded'); error.response = { status: 429, data: 'Too Many Requests' }; axios.post.mockRejectedValueOnce(error); - await askCommand.execute(interaction); + await askCommand.run(interaction); expect(interaction.followUp).toHaveBeenCalledWith({ content: 'The AI service is currently busy. Please try again in a few moments.', @@ -300,16 +187,11 @@ describe('Ask Command', () => { }); it('should handle timeout errors', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { prompt: mockPrompt }, - }); - const error = new Error('Timeout'); error.code = 'ETIMEDOUT'; axios.post.mockRejectedValueOnce(error); - await askCommand.execute(interaction); + await askCommand.run(interaction); expect(interaction.followUp).toHaveBeenCalledWith({ content: 'The request timed out. Please try again.', @@ -318,16 +200,11 @@ describe('Ask Command', () => { }); it('should handle invalid request errors', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { prompt: mockPrompt }, - }); - const error = new Error('Bad Request'); error.response = { status: 400, data: 'Invalid Request' }; axios.post.mockRejectedValueOnce(error); - await askCommand.execute(interaction); + await askCommand.run(interaction); expect(interaction.followUp).toHaveBeenCalledWith({ content: 'Invalid request. Please try rephrasing your question.', @@ -335,60 +212,50 @@ describe('Ask Command', () => { }); }); - it('should send API request with correct headers regardless of websearch', async () => { - interaction = createMockInteraction({ - commandName: 'ask', - stringOptions: { - prompt: mockPrompt, - }, - booleanOptions: { - websearch: true, - }, - }); - + it('should send API request with correct headers', async () => { axios.post.mockResolvedValueOnce(mockApiResponse); - await askCommand.execute(interaction); + await askCommand.run(interaction); expect(axios.post).toHaveBeenCalledWith(expect.any(String), expect.any(Object), { headers: { Authorization: 'Bearer test-api-key', - 'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js', 'Content-Type': 'application/json', }, }); }); - it('should handle defer reply failure with websearch', async () => { + it('should handle defer reply failure', async () => { interaction = createMockInteraction({ commandName: 'ask', stringOptions: { prompt: mockPrompt, }, - booleanOptions: { - websearch: true, - }, deferFails: true, }); - await expect(askCommand.execute(interaction)).rejects.toThrow('Failed to defer reply'); + await askCommand.run(interaction); + + expect(interaction.followUp).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Sorry, there was an error processing your request.', + ephemeral: true, + }), + ); }); - it('should handle follow up failure with websearch', async () => { + it('should handle follow up failure', async () => { interaction = createMockInteraction({ commandName: 'ask', stringOptions: { prompt: mockPrompt, }, - booleanOptions: { - websearch: true, - }, followUpFails: true, }); axios.post.mockResolvedValueOnce(mockApiResponse); - await expect(askCommand.execute(interaction)).rejects.toThrow('Failed to follow up'); + await expect(askCommand.run(interaction)).rejects.toThrow('Failed to follow up'); }); }); }); diff --git a/__tests__/utils/testUtils.js b/__tests__/utils/testUtils.js index 659b86f..e853d32 100644 --- a/__tests__/utils/testUtils.js +++ b/__tests__/utils/testUtils.js @@ -1,11 +1,13 @@ // Test utilities for Discord.js bot testing +import { jest } from '@jest/globals'; + /** * Creates a mock interaction object with common properties and methods * @param {Object} options - Customization options for the mock interaction * @returns {Object} Mock interaction object */ -const createMockInteraction = (options = {}) => ({ +export const createMockInteraction = (options = {}) => ({ commandName: options.commandName || 'test-command', user: { id: options.userId || 'mock-user-id', @@ -74,7 +76,7 @@ const createMockInteraction = (options = {}) => ({ * @param {Object} options - Customization options for the mock client * @returns {Object} Mock client object */ -const createMockClient = (options = {}) => ({ +export const createMockClient = (options = {}) => ({ user: { id: options.clientId || 'mock-client-id', username: options.clientUsername || 'MockBot', @@ -89,8 +91,3 @@ const createMockClient = (options = {}) => ({ login: jest.fn().mockResolvedValue('token'), destroy: jest.fn().mockResolvedValue(), }); - -module.exports = { - createMockInteraction, - createMockClient, -}; diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..f550e2e --- /dev/null +++ b/babel.config.js @@ -0,0 +1,3 @@ +export default { + presets: [['@babel/preset-env', { targets: { node: 'current' } }]], +}; diff --git a/commands/ask.js b/commands/ask.js index 2856b9a..42db25b 100644 --- a/commands/ask.js +++ b/commands/ask.js @@ -10,13 +10,6 @@ import { SlashCommandBuilder } from 'discord.js'; import axios from 'axios'; import Command from '../utils/command.js'; -const config = { - webSearch: { - enabled: false, // Default web search state - allowOverride: true, // Whether users can override the default state - }, -}; - export default class AskCommand extends Command { defineCommand() { return new SlashCommandBuilder() @@ -24,116 +17,95 @@ export default class AskCommand extends Command { .setDescription('Ask a question to the AI') .addStringOption(option => option.setName('prompt').setDescription('Your question or prompt').setRequired(true), - ) - .addBooleanOption(option => - option - .setName('websearch') - .setDescription('Enable web search for more up-to-date information') - .setRequired(false), ); } async run(interaction) { - await interaction.deferReply(); + try { + await interaction.deferReply(); - const prompt = interaction.options.getString('prompt'); - const userWebSearchOption = interaction.options.getBoolean('websearch'); + const prompt = interaction.options.getString('prompt'); - // Determine if web search should be enabled based on config and user option - const webSearchEnabled = - config.webSearch.allowOverride && userWebSearchOption !== null - ? userWebSearchOption // Use user's choice if override is allowed and option was provided - : config.webSearch.enabled; // Otherwise use default config - - const response = await axios.post( - 'https://openrouter.ai/api/v1/chat/completions', - { - model: webSearchEnabled ? 'xiaomi/mimo-v2-flash:free:online' : 'xiaomi/mimo-v2-flash:free', - messages: [ - { - role: 'system', - content: - 'You are kekbot, a highly celebrated and knowledgeable computer scientist with decades of experience in various fields of computing. You are known for your ability to explain complex topics in a clear, concise, and insightful manner. Provide direct and to-the-point answers, avoiding unnecessary elaboration or repetition. Focus on delivering accurate and valuable information efficiently.', - }, - { role: 'user', content: prompt }, - ], - plugins: webSearchEnabled - ? [ - { - id: 'web', - max_results: 3, - search_prompt: - `A web search was conducted on ${new Date().toISOString()}. ` + - 'Incorporate the following web search results into your response. ' + - 'IMPORTANT: Cite them using markdown links named using the domain of the source. ' + - 'Example: [nytimes.com](https://nytimes.com/some-page).', - }, - ] - : undefined, - }, - { - headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - 'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js', - 'Content-Type': 'application/json', + const response = await axios.post( + 'https://nano-gpt.com/api/v1/chat/completions', + { + model: 'deepseek/deepseek-v3.2', + messages: [ + { + role: 'system', + content: + 'You are kekbot, a highly celebrated and knowledgeable computer scientist with decades of experience in various fields of computing. You are known for your ability to explain complex topics in a clear, concise, and insightful manner. Provide direct and to-the-point answers, avoiding unnecessary elaboration or repetition. Focus on delivering accurate and valuable information efficiently.', + }, + { role: 'user', content: prompt }, + ], }, - }, - ); + { + headers: { + Authorization: `Bearer ${process.env.NANOGPT_API_KEY}`, + 'Content-Type': 'application/json', + }, + }, + ); - const aiResponse = response.data.choices[0].message.content; - const webSearchStatus = webSearchEnabled ? '\n> *Web search enabled* 🔍\n' : ''; - const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`; + const aiResponse = response.data.choices[0].message.content; + const formattedResponse = `> **Question:** ${prompt}\n${aiResponse}`; - if (formattedResponse.length <= 2000) { - // Send as a single message with proper formatting - await interaction.followUp({ - content: formattedResponse, - split: false, - allowedMentions: { parse: [] }, - }); - } else { - // For longer messages, split while preserving markdown - const maxLength = 2000; - const chunks = []; - let remainingText = formattedResponse; - let isFirstChunk = true; - - while (remainingText.length > 0) { - let chunk = remainingText.slice(0, maxLength); - - // If we're in the middle of a code block, find a safe split point - const lastCodeBlock = chunk.lastIndexOf('```'); - if (lastCodeBlock !== -1 && !chunk.slice(lastCodeBlock).includes('\n```')) { - // Find the last newline before maxLength - const lastNewline = chunk.lastIndexOf('\n'); - if (lastNewline !== -1) { - chunk = chunk.slice(0, lastNewline); - } - } - - // For subsequent chunks, add continuation indicator - if (!isFirstChunk) { - chunk = `(continued)\n${chunk}`; - } - - chunks.push({ - content: chunk, + if (formattedResponse.length <= 2000) { + // Send as a single message with proper formatting + await interaction.followUp({ + content: formattedResponse, split: false, allowedMentions: { parse: [] }, }); + } else { + // For longer messages, split while preserving markdown + const maxLength = 2000; + const chunks = []; + let remainingText = formattedResponse; + let isFirstChunk = true; - remainingText = remainingText.slice(chunk.length); - isFirstChunk = false; + while (remainingText.length > 0) { + let chunk = remainingText.slice(0, maxLength); + + // If we're in the middle of a code block, find a safe split point + const lastCodeBlock = chunk.lastIndexOf('```'); + if (lastCodeBlock !== -1 && !chunk.slice(lastCodeBlock).includes('\n```')) { + // Find the last newline before maxLength + const lastNewline = chunk.lastIndexOf('\n'); + if (lastNewline !== -1) { + chunk = chunk.slice(0, lastNewline); + } + } + + // For subsequent chunks, add continuation indicator + if (!isFirstChunk) { + chunk = `(continued)\n${chunk}`; + } + + chunks.push({ + content: chunk, + split: false, + allowedMentions: { parse: [] }, + }); + + remainingText = remainingText.slice(chunk.length); + isFirstChunk = false; + } + + // Send chunks sequentially using reduce + await chunks.reduce( + (promise, chunk) => + promise.then(async () => { + await interaction.followUp(chunk); + }), + Promise.resolve(), + ); } - - // Send chunks sequentially using reduce - await chunks.reduce( - (promise, chunk) => - promise.then(async () => { - await interaction.followUp(chunk); - }), - Promise.resolve(), - ); + } catch (error) { + await interaction.followUp({ + content: this.getErrorMessage(error), + ephemeral: true, + }); } } diff --git a/jest.config.js b/jest.config.js index 6293f16..6826768 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,33 +1,28 @@ -module.exports = { - // Automatically clear mock calls and instances between every test - clearMocks: true, +export default { + // Automatically clear mock calls and instances between every test + clearMocks: true, - // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, - // The directory where Jest should output its coverage files - coverageDirectory: "coverage", + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', - // The test environment that will be used for testing - testEnvironment: "node", + // The test environment that will be used for testing + testEnvironment: 'node', - // The glob patterns Jest uses to detect test files - testMatch: [ - "**/__tests__/**/*.test.[jt]s?(x)", - "**/__tests__/**/*.spec.[jt]s?(x)", - "**/?(*.)+(spec|test).[jt]s?(x)" - ], + // The glob patterns Jest uses to detect test files + testMatch: [ + '**/__tests__/**/*.test.[jt]s?(x)', + '**/__tests__/**/*.spec.[jt]s?(x)', + '**/?(*.)+(spec|test).[jt]s?(x)', + ], - // A map from regular expressions to paths to transformers - transform: { - "^.+\\.jsx?$": "babel-jest" - }, + // A map from regular expressions to paths to transformers + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, - // An array of regexp pattern strings that are matched against all test files - testPathIgnorePatterns: [ - "/node_modules/" - ], - - // Setup files that will be run before each test - setupFiles: ["/jest.setup.js"] -}; \ No newline at end of file + // An array of regexp pattern strings that are matched against all test files + testPathIgnorePatterns: ['/node_modules/'], +}; diff --git a/jest.setup.js b/jest.setup.js index f7d2bcd..52ecb0e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,39 +1,41 @@ +import { jest } from '@jest/globals'; + // Mock Discord.js Client and other commonly used classes jest.mock('discord.js', () => ({ - Client: jest.fn(() => ({ - login: jest.fn().mockResolvedValue('token'), - destroy: jest.fn().mockResolvedValue(), - on: jest.fn(), - once: jest.fn(), - user: { - setActivity: jest.fn() - } - })), - Collection: jest.fn(() => ({ - get: jest.fn(), - set: jest.fn(), - has: jest.fn(), - delete: jest.fn() - })), - GatewayIntentBits: { - Guilds: 1, - GuildMessages: 2, - MessageContent: 3 + Client: jest.fn(() => ({ + login: jest.fn().mockResolvedValue('token'), + destroy: jest.fn().mockResolvedValue(), + on: jest.fn(), + once: jest.fn(), + user: { + setActivity: jest.fn(), }, - Events: { - ClientReady: 'ready', - InteractionCreate: 'interactionCreate' - }, - SlashCommandBuilder: jest.fn().mockReturnValue({ - setName: jest.fn().mockReturnThis(), - setDescription: jest.fn().mockReturnThis(), - addStringOption: jest.fn().mockReturnThis(), - addIntegerOption: jest.fn().mockReturnThis(), - addUserOption: jest.fn().mockReturnThis() - }) + })), + Collection: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + has: jest.fn(), + delete: jest.fn(), + })), + GatewayIntentBits: { + Guilds: 1, + GuildMessages: 2, + MessageContent: 3, + }, + Events: { + ClientReady: 'ready', + InteractionCreate: 'interactionCreate', + }, + SlashCommandBuilder: jest.fn().mockReturnValue({ + setName: jest.fn().mockReturnThis(), + setDescription: jest.fn().mockReturnThis(), + addStringOption: jest.fn().mockReturnThis(), + addIntegerOption: jest.fn().mockReturnThis(), + addUserOption: jest.fn().mockReturnThis(), + }), })); // Mock environment variables process.env.DISCORD_TOKEN = 'mock-token'; process.env.CLIENT_ID = 'mock-client-id'; -process.env.GUILD_ID = 'mock-guild-id'; \ No newline at end of file +process.env.GUILD_ID = 'mock-guild-id';