├── .prettierrc ├── screenshots ├── Screenshot1.png ├── Screenshot10.png ├── Screenshot11.png ├── Screenshot2.png ├── Screenshot3.png ├── Screenshot4.png ├── Screenshot5.png ├── Screenshot6.png ├── Screenshot7.png ├── Screenshot8.png └── Screenshot9.png ├── src ├── redisClient.js ├── channelRoutes.js ├── helpCommand.js ├── deploy-commands.js ├── interactionCreateHandler.js ├── messageCreateHandler.js ├── commandHandler.js ├── errorHandler.js ├── conversationManager.js ├── index.js └── config.js ├── .gitignore ├── jest.config.js ├── eslint.config.mjs ├── .env.example ├── package.json ├── LICENSE ├── test ├── channelRoutes.test.js └── conversationManager.test.js └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/Screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot1.png -------------------------------------------------------------------------------- /screenshots/Screenshot10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot10.png -------------------------------------------------------------------------------- /screenshots/Screenshot11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot11.png -------------------------------------------------------------------------------- /screenshots/Screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot2.png -------------------------------------------------------------------------------- /screenshots/Screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot3.png -------------------------------------------------------------------------------- /screenshots/Screenshot4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot4.png -------------------------------------------------------------------------------- /screenshots/Screenshot5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot5.png -------------------------------------------------------------------------------- /screenshots/Screenshot6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot6.png -------------------------------------------------------------------------------- /screenshots/Screenshot7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot7.png -------------------------------------------------------------------------------- /screenshots/Screenshot8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot8.png -------------------------------------------------------------------------------- /screenshots/Screenshot9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llegomark/discord-bot-claude-gemini/HEAD/screenshots/Screenshot9.png -------------------------------------------------------------------------------- /src/redisClient.js: -------------------------------------------------------------------------------- 1 | const { Redis } = require('@upstash/redis'); 2 | const redisClient = new Redis({ 3 | url: process.env.UPSTASH_REDIS_URL, 4 | token: process.env.UPSTASH_REDIS_TOKEN, 5 | }); 6 | module.exports = redisClient; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | coverage/ 10 | node_modules 11 | *.local 12 | dist 13 | 14 | # Editor directories and files 15 | .vscode/* 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | .env -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testMatch: ['**/test/**/*.test.js'], 4 | moduleFileExtensions: ['js'], 5 | collectCoverage: true, 6 | coverageDirectory: 'coverage', 7 | coverageReporters: ['text', 'lcov'], 8 | coverageThreshold: { 9 | global: { 10 | branches: 80, 11 | functions: 80, 12 | lines: 80, 13 | statements: 80, 14 | }, 15 | }, 16 | setupFiles: ['dotenv/config'], 17 | }; 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | 4 | export default [ 5 | { files: ['**/*.js'], languageOptions: { sourceType: 'commonjs' } }, 6 | { 7 | languageOptions: { 8 | globals: { 9 | ...globals.node, 10 | ...globals.jest, 11 | }, 12 | }, 13 | }, 14 | { 15 | rules: { 16 | 'no-console': 'off', 17 | 'no-unused-vars': 'warn', 18 | 'no-await-in-loop': 'warn', 19 | 'no-empty': ['error', { allowEmptyCatch: true }], 20 | 'no-prototype-builtins': 'off', 21 | 'no-useless-escape': 'off', 22 | }, 23 | }, 24 | pluginJs.configs.recommended, 25 | ]; 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN 2 | ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY 3 | GOOGLE_API_KEY_1=YOUR_GOOGLE_API_KEY_1 4 | GOOGLE_API_KEY_2=YOUR_GOOGLE_API_KEY_2 5 | GOOGLE_API_KEY_3=YOUR_GOOGLE_API_KEY_3 6 | GOOGLE_API_KEY_4=YOUR_GOOGLE_API_KEY_4 7 | GOOGLE_API_KEY_5=YOUR_GOOGLE_API_KEY_5 8 | GOOGLE_MODEL_NAME=YOUR_GOOGLE_MODEL_NAME 9 | DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID 10 | DISCORD_USER_ID=YOUR_DISCORD_USER_ID 11 | ERROR_NOTIFICATION_WEBHOOK=YOUR_ERROR_NOTIFICATION_WEBHOOK_URL 12 | CONVERSATION_INACTIVITY_DURATION=INACTIVITY_DURATION_IN_MILLISECONDS 13 | CLOUDFLARE_AI_GATEWAY_URL=YOUR_CLOUDFLARE_AI_GATEWAY_URL 14 | PORT=YOUR_DESIRED_PORT_NUMBER 15 | UPSTASH_REDIS_URL=YOUR_UPSTASH_REDIS_URL 16 | UPSTASH_REDIS_TOKEN=YOUR_UPSTASH_REDIS_TOKEN 17 | API_KEY=YOUR_API_KEY -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-bot-neko", 3 | "version": "1.0.0", 4 | "description": "A Discord bot powered by Anthropic Claude and Google Gemini AI", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "eslint src/**/*.js", 8 | "start": "node src/index.js", 9 | "test": "jest" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/llegomark/discord-bot-neko.git" 14 | }, 15 | "author": "Mark Anthony Llego", 16 | "license": "MIT", 17 | "dependencies": { 18 | "@anthropic-ai/sdk": "^0.20.8", 19 | "@google/generative-ai": "^0.10.0", 20 | "@upstash/redis": "^1.30.1", 21 | "async": "^3.2.5", 22 | "bottleneck": "^2.19.5", 23 | "discord.js": "^14.15.2", 24 | "dotenv": "^16.4.5", 25 | "express": "^4.19.2", 26 | "express-rate-limit": "^7.2.0", 27 | "pdf-parse": "^1.1.1" 28 | }, 29 | "devDependencies": { 30 | "@eslint/js": "^9.2.0", 31 | "eslint": "^9.2.0", 32 | "globals": "^15.1.0", 33 | "jest": "^29.7.0", 34 | "prettier": "3.2.5", 35 | "supertest": "^7.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/channelRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const redisClient = require('./redisClient'); 3 | 4 | const router = express.Router(); 5 | 6 | const ALLOWED_CHANNELS_KEY = 'allowed_channels'; 7 | 8 | router.post('/channels', async (req, res) => { 9 | try { 10 | const { channelId } = req.body; 11 | await redisClient.sadd(ALLOWED_CHANNELS_KEY, channelId); 12 | res.status(200).json({ message: 'Channel added successfully' }); 13 | } catch (error) { 14 | console.error('Error adding channel:', error); 15 | res.status(500).json({ error: 'Internal server error' }); 16 | } 17 | }); 18 | 19 | router.delete('/channels/:channelId', async (req, res) => { 20 | try { 21 | const { channelId } = req.params; 22 | await redisClient.srem(ALLOWED_CHANNELS_KEY, channelId); 23 | res.status(200).json({ message: 'Channel removed successfully' }); 24 | } catch (error) { 25 | console.error('Error removing channel:', error); 26 | res.status(500).json({ error: 'Internal server error' }); 27 | } 28 | }); 29 | 30 | module.exports = router; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mark Anthony Llego 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/helpCommand.js: -------------------------------------------------------------------------------- 1 | const { EmbedBuilder } = require('discord.js'); 2 | 3 | async function helpCommand(interaction) { 4 | const helpEmbed = new EmbedBuilder() 5 | .setColor('#0099ff') 6 | .setTitle('Available Commands') 7 | .setDescription('Here are the available commands and their usage:') 8 | .addFields( 9 | { name: '/clear', value: 'Clears the conversation history.' }, 10 | { name: '/save', value: 'Saves the current conversation and sends it to your inbox.' }, 11 | { name: '/model', value: 'Change the model used by the bot. Usage: `/model [model_name]`' }, 12 | { name: '/prompt', value: 'Change the system prompt used by the bot. Usage: `/prompt [prompt_name]`' }, 13 | { name: '/reset', value: 'Reset the model and prompt to the default settings.' }, 14 | { name: '/help', value: 'Displays this help message.' }, 15 | { name: '/settings', value: 'Displays your current model and prompt settings.' }, 16 | { 17 | name: 'Installation & Activation', 18 | value: `To install and activate the Discord bot on your server, please DM <@1012984419029622784> on Discord or visit their Twitter profile: https://twitter.com/markllego.`, 19 | }, 20 | ) 21 | .setTimestamp(); 22 | 23 | await interaction.reply({ embeds: [helpEmbed], ephemeral: true }); 24 | } 25 | 26 | module.exports = { helpCommand }; 27 | -------------------------------------------------------------------------------- /test/channelRoutes.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const express = require('express'); 3 | const redisClient = require('../src/redisClient'); 4 | const channelRoutes = require('../src/channelRoutes'); 5 | 6 | const app = express(); 7 | app.use(express.json()); 8 | app.use('/api', channelRoutes); 9 | 10 | describe('Channel Routes', () => { 11 | beforeAll(async () => { 12 | await redisClient.flushdb(); 13 | }); 14 | 15 | describe('POST /api/channels', () => { 16 | it('should add a channel ID to Redis', async () => { 17 | const channelId = '123456'; 18 | 19 | const response = await request(app).post('/api/channels').send({ channelId }).expect(200); 20 | 21 | expect(response.body).toEqual({ message: 'Channel added successfully' }); 22 | 23 | const isChannelAdded = await redisClient.sismember('allowed_channels', channelId); 24 | expect(isChannelAdded).toBe(1); 25 | }); 26 | 27 | it('should return an error if Redis operation fails', async () => { 28 | jest.spyOn(redisClient, 'sadd').mockRejectedValueOnce(new Error('Redis error')); 29 | 30 | const response = await request(app).post('/api/channels').send({ channelId: '123456' }).expect(500); 31 | 32 | expect(response.body).toEqual({ error: 'Internal server error' }); 33 | }); 34 | }); 35 | 36 | describe('DELETE /api/channels/:channelId', () => { 37 | it('should remove a channel ID from Redis', async () => { 38 | const channelId = '123456'; 39 | await redisClient.sadd('allowed_channels', channelId); 40 | 41 | const response = await request(app).delete(`/api/channels/${channelId}`).expect(200); 42 | 43 | expect(response.body).toEqual({ message: 'Channel removed successfully' }); 44 | 45 | const isChannelRemoved = await redisClient.sismember('allowed_channels', channelId); 46 | expect(isChannelRemoved).toBe(0); 47 | }); 48 | 49 | it('should return an error if Redis operation fails', async () => { 50 | jest.spyOn(redisClient, 'srem').mockRejectedValueOnce(new Error('Redis error')); 51 | 52 | const response = await request(app).delete('/api/channels/123456').expect(500); 53 | 54 | expect(response.body).toEqual({ error: 'Internal server error' }); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/deploy-commands.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { SlashCommandBuilder, REST, Routes } = require('discord.js'); 3 | 4 | const commands = [ 5 | new SlashCommandBuilder().setName('clear').setDescription('Clears the conversation history.').setDMPermission(false), 6 | new SlashCommandBuilder() 7 | .setName('save') 8 | .setDescription('Saves the current conversation and sends it to your inbox.') 9 | .setDMPermission(false), 10 | new SlashCommandBuilder() 11 | .setName('model') 12 | .setDescription('Change the model used by the bot.') 13 | .addStringOption((option) => 14 | option 15 | .setName('name') 16 | .setDescription('The name of the model.') 17 | .setRequired(true) 18 | .addChoices( 19 | { name: 'Claude 3 Opus', value: 'claude-3-opus-20240229' }, 20 | { name: 'Claude 3 Sonnet', value: 'claude-3-sonnet-20240229' }, 21 | { name: 'Claude 3 Haiku', value: 'claude-3-haiku-20240307' }, 22 | { name: 'Google Gemini 1.5 Pro', value: process.env.GOOGLE_MODEL_NAME }, 23 | ), 24 | ) 25 | .setDMPermission(false), 26 | new SlashCommandBuilder() 27 | .setName('prompt') 28 | .setDescription('Change the system prompt used by the bot.') 29 | .addStringOption((option) => 30 | option 31 | .setName('name') 32 | .setDescription('The name of the prompt.') 33 | .setRequired(true) 34 | .addChoices( 35 | { name: 'neko cat', value: 'neko_cat' }, 36 | { name: 'act as a JavaScript Developer', value: 'javascript_developer' }, 37 | { name: 'act as a Python Developer', value: 'python_developer' }, 38 | { name: 'act as a Helpful Assistant', value: 'helpful_assistant' }, 39 | ), 40 | ) 41 | .setDMPermission(false), 42 | new SlashCommandBuilder().setName('reset').setDescription('Reset the model and prompt to the default settings.').setDMPermission(false), 43 | new SlashCommandBuilder() 44 | .setName('help') 45 | .setDescription('Displays the list of available commands and their usage.') 46 | .setDMPermission(false), 47 | new SlashCommandBuilder() 48 | .setName('testerror') 49 | .setDescription('Triggers a test error to check the error notification webhook.') 50 | .setDMPermission(false), 51 | new SlashCommandBuilder().setName('settings').setDescription('Displays your current model and prompt settings.').setDMPermission(false), 52 | ].map((command) => command.toJSON()); 53 | 54 | const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN); 55 | 56 | (async () => { 57 | try { 58 | console.log('Started refreshing application (/) commands.'); 59 | 60 | await rest.put(Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { body: commands }); 61 | 62 | console.log('Successfully reloaded application (/) commands.'); 63 | } catch (error) { 64 | console.error('Error deploying slash commands:', error); 65 | } 66 | })(); 67 | -------------------------------------------------------------------------------- /src/interactionCreateHandler.js: -------------------------------------------------------------------------------- 1 | const { helpCommand } = require('./helpCommand'); 2 | 3 | async function onInteractionCreate(interaction, conversationManager, commandHandler, errorHandler) { 4 | if (!interaction.isCommand()) return; 5 | 6 | if (interaction.commandName === 'help') { 7 | try { 8 | await helpCommand(interaction); 9 | } catch (error) { 10 | await errorHandler.handleError(error, interaction); 11 | } 12 | return; 13 | } 14 | 15 | if (interaction.commandName === 'clear') { 16 | try { 17 | await interaction.deferReply({ ephemeral: true }); 18 | await commandHandler.clearCommand(interaction, conversationManager); 19 | } catch (error) { 20 | await errorHandler.handleError(error, interaction); 21 | } 22 | return; 23 | } 24 | 25 | if (interaction.commandName === 'save') { 26 | try { 27 | await interaction.deferReply({ ephemeral: true }); 28 | await commandHandler.saveCommand(interaction, conversationManager); 29 | } catch (error) { 30 | await errorHandler.handleError(error, interaction); 31 | } 32 | return; 33 | } 34 | 35 | if (interaction.commandName === 'model') { 36 | try { 37 | await interaction.deferReply({ ephemeral: true }); 38 | await commandHandler.modelCommand(interaction, conversationManager); 39 | } catch (error) { 40 | await errorHandler.handleError(error, interaction); 41 | } 42 | return; 43 | } 44 | 45 | if (interaction.commandName === 'prompt') { 46 | try { 47 | await interaction.deferReply({ ephemeral: true }); 48 | await commandHandler.promptCommand(interaction, conversationManager); 49 | } catch (error) { 50 | await errorHandler.handleError(error, interaction); 51 | } 52 | return; 53 | } 54 | 55 | if (interaction.commandName === 'reset') { 56 | try { 57 | await interaction.deferReply({ ephemeral: true }); 58 | await commandHandler.resetCommand(interaction, conversationManager); 59 | } catch (error) { 60 | await errorHandler.handleError(error, interaction); 61 | } 62 | return; 63 | } 64 | 65 | if (interaction.commandName === 'testerror') { 66 | try { 67 | await interaction.deferReply({ ephemeral: true }); 68 | // Check if the user executing the command is the bot owner 69 | if (interaction.user.id !== process.env.DISCORD_USER_ID) { 70 | await interaction.editReply('Only the bot owner can use this command.'); 71 | return; 72 | } 73 | // Trigger a test error 74 | throw new Error('This is a test error triggered by the /testerror command.'); 75 | } catch (error) { 76 | await errorHandler.handleError(error, interaction); 77 | } 78 | return; 79 | } 80 | 81 | if (interaction.commandName === 'settings') { 82 | try { 83 | await interaction.deferReply({ ephemeral: true }); 84 | await commandHandler.settingsCommand(interaction, conversationManager); 85 | } catch (error) { 86 | await interaction.editReply({ 87 | content: 'An error occurred while processing the command.', 88 | ephemeral: true, 89 | }); 90 | await errorHandler.handleError(error, interaction); 91 | } 92 | return; 93 | } 94 | } 95 | 96 | module.exports = { onInteractionCreate }; 97 | -------------------------------------------------------------------------------- /src/messageCreateHandler.js: -------------------------------------------------------------------------------- 1 | const { config } = require('./config'); 2 | const redisClient = require('./redisClient'); 3 | const fetch = require('node-fetch'); 4 | const pdfParse = require('pdf-parse'); 5 | 6 | let allowedChannelIds = []; 7 | 8 | async function fetchAllowedChannelIds() { 9 | try { 10 | const channelIds = await redisClient.smembers('allowedChannelIds'); 11 | allowedChannelIds = channelIds; 12 | console.log('Fetched allowed channel IDs:', allowedChannelIds); 13 | } catch (error) { 14 | console.error('Error fetching allowed channel IDs:', error); 15 | } 16 | } 17 | 18 | async function processAttachment(attachment) { 19 | const attachmentExtension = attachment.name.split('.').pop().toLowerCase(); 20 | 21 | if (attachmentExtension === 'txt') { 22 | try { 23 | const response = await fetch(attachment.url); 24 | return await response.text(); 25 | } catch (error) { 26 | console.error('Error fetching text attachment:', error); 27 | throw new Error('Error processing text attachment'); 28 | } 29 | } else if (attachmentExtension === 'pdf') { 30 | const maxFileSize = 30 * 1024 * 1024; // 30MB 31 | if (attachment.size > maxFileSize) { 32 | throw new Error('File size exceeds the maximum limit of 30MB'); 33 | } 34 | 35 | try { 36 | const response = await fetch(attachment.url); 37 | const pdfReadableStream = response.body; 38 | 39 | const pdfBuffer = await new Promise((resolve, reject) => { 40 | const chunks = []; 41 | pdfReadableStream.on('data', (chunk) => chunks.push(Buffer.from(chunk))); 42 | pdfReadableStream.on('error', (err) => reject(err)); 43 | pdfReadableStream.on('end', () => resolve(Buffer.concat(chunks))); 44 | }); 45 | 46 | const data = await pdfParse(pdfBuffer); 47 | return data.text; 48 | } catch (error) { 49 | console.error('Error parsing PDF attachment:', error); 50 | if (error.message.includes('Could not parse')) { 51 | throw new Error('Invalid or corrupted PDF file'); 52 | } else { 53 | throw new Error('Error processing PDF attachment'); 54 | } 55 | } 56 | } else { 57 | throw new Error('Unsupported file type'); 58 | } 59 | } 60 | 61 | async function onMessageCreate(message, conversationQueue, errorHandler, conversationManager) { 62 | try { 63 | if (message.author.bot) return; 64 | 65 | const isAllowedChannel = allowedChannelIds.includes(message.channel.id); 66 | if (isAllowedChannel) { 67 | let messageContent = message.content.trim(); 68 | 69 | if (message.attachments.size > 0) { 70 | const attachmentProcessingPromises = message.attachments.map(async (attachment) => { 71 | try { 72 | const attachmentContent = await processAttachment(attachment); 73 | return attachmentContent; 74 | } catch (error) { 75 | console.error('Error processing attachment:', error); 76 | await message.reply(`> \`Sorry, there was an error processing your attachment: ${error.message}. Please try again.\``); 77 | return null; 78 | } 79 | }); 80 | 81 | const attachmentContents = await Promise.all(attachmentProcessingPromises); 82 | const validAttachmentContents = attachmentContents.filter((content) => content !== null); 83 | 84 | if (validAttachmentContents.length > 0) { 85 | messageContent += `\n\n${validAttachmentContents.join('\n\n')}`; 86 | } 87 | } 88 | 89 | if (messageContent.trim() === '') { 90 | await message.reply("> `It looks like you didn't say anything. What would you like to talk about?`"); 91 | return; 92 | } 93 | 94 | if (conversationManager.isNewConversation(message.author.id)) { 95 | await message.channel.send({ content: config.messages.privacyNotice }); 96 | } 97 | 98 | conversationQueue.push({ message, messageContent }); 99 | } 100 | } catch (error) { 101 | await errorHandler.handleError(error, message); 102 | } 103 | } 104 | 105 | // Fetch allowed channel IDs when the module is loaded 106 | fetchAllowedChannelIds(); 107 | 108 | // Refresh allowed channel IDs every 5 minutes 109 | setInterval(fetchAllowedChannelIds, 5 * 60 * 1000); 110 | 111 | module.exports = { onMessageCreate }; 112 | -------------------------------------------------------------------------------- /src/commandHandler.js: -------------------------------------------------------------------------------- 1 | const { config } = require('./config'); 2 | const { EmbedBuilder } = require('discord.js'); 3 | 4 | class CommandHandler { 5 | constructor() { 6 | this.commands = { 7 | clear: this.clearCommand, 8 | save: this.saveCommand, 9 | model: this.modelCommand, 10 | prompt: this.promptCommand, 11 | reset: this.resetCommand, 12 | settings: this.settingsCommand, 13 | }; 14 | } 15 | 16 | isCommand(message) { 17 | return message.content.startsWith('/'); 18 | } 19 | 20 | async handleCommand(message, conversationManager) { 21 | const [commandName, ...args] = message.content.slice(1).split(' '); 22 | const command = this.commands[commandName]; 23 | if (command) { 24 | await command(message, args, conversationManager); 25 | } else { 26 | // Ignore unknown commands 27 | return; 28 | } 29 | } 30 | 31 | async clearCommand(interaction, conversationManager) { 32 | conversationManager.clearHistory(interaction.user.id); 33 | await interaction.editReply('> `Your conversation history has been cleared.`'); 34 | } 35 | 36 | async saveCommand(interaction, conversationManager) { 37 | const userId = interaction.user.id; 38 | const conversation = conversationManager.getHistory(userId); 39 | if (conversation.length === 0) { 40 | await interaction.followUp('> `There is no conversation to save.`'); 41 | return; 42 | } 43 | const conversationText = conversation.map((message) => `${message.role === 'user' ? 'User' : 'Bot'}: ${message.content}`).join('\n'); 44 | try { 45 | const maxLength = 1900; 46 | const lines = conversationText.split('\n'); 47 | const chunks = []; 48 | let currentChunk = ''; 49 | for (const line of lines) { 50 | if (currentChunk.length + line.length + 1 <= maxLength) { 51 | currentChunk += (currentChunk ? '\n' : '') + line; 52 | } else { 53 | chunks.push(currentChunk); 54 | currentChunk = line; 55 | } 56 | } 57 | if (currentChunk) { 58 | chunks.push(currentChunk); 59 | } 60 | // Send each chunk as a separate message 61 | const chunkPromises = chunks.map(async (chunk, index) => { 62 | await interaction.user.send(`Here is your saved conversation (part ${index + 1}):\n\n${chunk}`); 63 | }); 64 | await Promise.all(chunkPromises); 65 | await interaction.editReply('> `The conversation has been saved and sent to your inbox.`'); 66 | } catch (error) { 67 | console.error('Error sending conversation to user:', error); 68 | await interaction.followUp('> `Failed to send the conversation to your inbox. Please check your privacy settings.`'); 69 | } 70 | } 71 | 72 | async modelCommand(interaction, conversationManager) { 73 | const model = interaction.options.getString('name'); 74 | conversationManager.setUserPreferences(interaction.user.id, { model }); 75 | await interaction.editReply(`> \`The model has been set to ${model}.\``); 76 | } 77 | 78 | async promptCommand(interaction, conversationManager) { 79 | const promptName = interaction.options.getString('name'); 80 | const prompt = config.getPrompt(promptName); 81 | console.log(`Setting prompt for user ${interaction.user.id}: promptName=${promptName}, prompt=${prompt}`); 82 | conversationManager.setUserPreferences(interaction.user.id, { prompt: promptName }); 83 | await interaction.editReply(`> \`The system prompt has been set to ${promptName}.\``); 84 | } 85 | 86 | async resetCommand(interaction, conversationManager) { 87 | conversationManager.resetUserPreferences(interaction.user.id); 88 | await interaction.editReply('> `Your preferences have been reset to the default settings.`'); 89 | } 90 | 91 | async settingsCommand(interaction, conversationManager) { 92 | const userId = interaction.user.id; 93 | const userPreferences = conversationManager.getUserPreferences(userId); 94 | const model = userPreferences.model; 95 | const promptName = userPreferences.prompt; 96 | const prompt = config.getPrompt(promptName); 97 | 98 | const settingsEmbed = new EmbedBuilder().setColor('#0099ff').setTitle('Current Settings').addFields({ name: 'Model', value: model }); 99 | 100 | const maxFieldLength = 1024; 101 | const promptFields = []; 102 | let currentField = ''; 103 | 104 | prompt.split('\n').forEach((line) => { 105 | if (currentField.length + line.length + 1 <= maxFieldLength) { 106 | currentField += (currentField ? '\n' : '') + line; 107 | } else { 108 | promptFields.push({ name: 'Prompt', value: currentField }); 109 | currentField = line; 110 | } 111 | }); 112 | 113 | if (currentField) { 114 | promptFields.push({ name: 'Prompt', value: currentField }); 115 | } 116 | 117 | settingsEmbed.addFields(...promptFields).setTimestamp(); 118 | 119 | await interaction.editReply({ embeds: [settingsEmbed], ephemeral: true }); 120 | } 121 | } 122 | 123 | module.exports.CommandHandler = CommandHandler; 124 | -------------------------------------------------------------------------------- /src/errorHandler.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { WebhookClient } = require('discord.js'); 3 | const { config } = require('./config'); 4 | 5 | class ErrorHandler { 6 | constructor() { 7 | this.errorNotificationThrottle = { 8 | window: 60 * 1000, // 1 minute 9 | maxNotifications: 5, // Maximum notifications allowed within the window 10 | recentNotifications: [], // Array to store recent notification timestamps 11 | }; 12 | } 13 | 14 | async handleError(error, interaction) { 15 | console.error('Error processing the interaction:', error); 16 | if (interaction.commandName === 'testerror') { 17 | if (error.message === 'This is a test error triggered by the /testerror command.') { 18 | await interaction.editReply({ 19 | content: 'Test error triggered successfully. Check the error notification channel for details.', 20 | ephemeral: true, 21 | }); 22 | } else { 23 | await interaction.editReply({ 24 | content: 'An unexpected error occurred while processing the /testerror command.', 25 | ephemeral: true, 26 | }); 27 | } 28 | } else { 29 | await interaction.reply({ 30 | content: 'Sorry, something went wrong! Our team has been notified and will look into the issue.', 31 | ephemeral: true, 32 | }); 33 | } 34 | 35 | // Log error details for debugging 36 | const errorDetails = { 37 | message: error.message, 38 | stack: error.stack, 39 | timestamp: new Date().toLocaleString('en-US', { timeZone: 'Asia/Manila' }), 40 | userId: interaction.user?.id, 41 | command: interaction.commandName, 42 | environment: process.env.NODE_ENV, 43 | }; 44 | console.error('Error details:', errorDetails); 45 | 46 | // Send error notification via Discord webhook 47 | await this.sendErrorNotification(errorDetails); 48 | } 49 | 50 | async handleModelResponseError(error, botMessage, originalMessage) { 51 | console.error(error.message); 52 | const userId = originalMessage.author.id; 53 | const errorMessages = config.messages.handleModelResponseError; 54 | 55 | let errorMessage; 56 | if (error.status && errorMessages[error.status]) { 57 | errorMessage = errorMessages[error.status].replace('{userId}', userId); 58 | } else { 59 | errorMessage = errorMessages.default.replace('{userId}', userId); 60 | } 61 | 62 | await botMessage.edit(errorMessage); 63 | 64 | // Send the error to the ErrorHandler for notification 65 | await this.handleError(error, originalMessage); 66 | } 67 | handleUnhandledRejection(error) { 68 | console.error('Unhandled Rejection:', error); 69 | // Log error details for debugging 70 | const errorDetails = { 71 | message: error.message, 72 | stack: error.stack, 73 | timestamp: new Date().toLocaleString('en-US', { timeZone: 'Asia/Manila' }), 74 | environment: process.env.NODE_ENV, 75 | }; 76 | console.error('Error details:', errorDetails); 77 | // Send error notification via Discord webhook 78 | this.sendErrorNotification(errorDetails); 79 | } 80 | 81 | handleUncaughtException(error) { 82 | console.error('Uncaught Exception:', error); 83 | // Log error details for debugging 84 | const errorDetails = { 85 | message: error.message, 86 | stack: error.stack, 87 | timestamp: new Date().toLocaleString('en-US', { timeZone: 'Asia/Manila' }), 88 | environment: process.env.NODE_ENV, 89 | }; 90 | console.error('Error details:', errorDetails); 91 | // Send error notification via Discord webhook 92 | this.sendErrorNotification(errorDetails); 93 | process.exit(1); 94 | } 95 | 96 | logErrorToFile(error) { 97 | const fs = require('fs'); 98 | const path = require('path'); 99 | const logDirectory = path.join(__dirname, 'logs'); 100 | const logFileName = `error-${new Date().toISOString().replace(/:/g, '-')}.log`; 101 | const logFilePath = path.join(logDirectory, logFileName); 102 | // Create the logs directory if it doesn't exist 103 | if (!fs.existsSync(logDirectory)) { 104 | fs.mkdirSync(logDirectory); 105 | } 106 | const errorDetails = { 107 | message: error.message, 108 | stack: error.stack, 109 | timestamp: new Date().toLocaleString('en-US', { timeZone: 'Asia/Manila' }), 110 | environment: process.env.NODE_ENV, 111 | }; 112 | const logMessage = `${JSON.stringify(errorDetails)}\n`; 113 | fs.appendFile(logFilePath, logMessage, (err) => { 114 | if (err) { 115 | console.error('Failed to log error to file:', err); 116 | } 117 | }); 118 | } 119 | 120 | async sendErrorNotification(errorDetails) { 121 | const webhookUrl = process.env.ERROR_NOTIFICATION_WEBHOOK; 122 | if (webhookUrl) { 123 | const currentTime = Date.now(); 124 | const { window, maxNotifications, recentNotifications } = this.errorNotificationThrottle; 125 | 126 | // Remove old notification timestamps outside the current window 127 | this.errorNotificationThrottle.recentNotifications = recentNotifications.filter((timestamp) => currentTime - timestamp <= window); 128 | 129 | if (recentNotifications.length >= maxNotifications) { 130 | console.warn('Error notification throttled due to high volume.'); 131 | return; 132 | } 133 | 134 | const webhookClient = new WebhookClient({ url: webhookUrl }); 135 | const errorMessage = `An error occurred:\n\`\`\`json\n${JSON.stringify(errorDetails, null, 2)}\n\`\`\``; 136 | try { 137 | await webhookClient.send({ 138 | content: errorMessage, 139 | username: 'Error Notification', 140 | }); 141 | console.log('Error notification sent via Discord webhook.'); 142 | this.errorNotificationThrottle.recentNotifications.push(currentTime); 143 | } catch (err) { 144 | console.error('Failed to send error notification via Discord webhook:', err); 145 | } 146 | } else { 147 | console.warn('ERROR_NOTIFICATION_WEBHOOK not set. Skipping error notification via Discord webhook.'); 148 | } 149 | } 150 | } 151 | 152 | module.exports.ErrorHandler = ErrorHandler; 153 | -------------------------------------------------------------------------------- /src/conversationManager.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { config } = require('./config'); 3 | 4 | class ConversationManager { 5 | constructor(errorHandler) { 6 | this.chatHistories = {}; 7 | this.userPreferences = {}; 8 | this.defaultPreferences = { 9 | model: process.env.GOOGLE_MODEL_NAME, 10 | prompt: 'helpful_assistant', 11 | }; 12 | this.lastInteractionTimestamps = {}; 13 | this.errorHandler = errorHandler; 14 | this.typingIntervalIds = {}; 15 | } 16 | 17 | getHistory(userId) { 18 | return ( 19 | this.chatHistories[userId]?.map((line, index) => ({ 20 | role: index % 2 === 0 ? 'user' : 'assistant', 21 | content: line, 22 | })) || [] 23 | ); 24 | } 25 | 26 | getGoogleHistory(userId) { 27 | return ( 28 | this.chatHistories[userId]?.map((line, index) => ({ 29 | role: index % 2 === 0 ? 'user' : 'model', 30 | parts: [{ text: line }], 31 | })) || [] 32 | ); 33 | } 34 | 35 | updateChatHistory(userId, userMessage, modelResponse) { 36 | if (!this.chatHistories[userId]) { 37 | this.chatHistories[userId] = []; 38 | } 39 | this.chatHistories[userId].push(userMessage); 40 | this.chatHistories[userId].push(modelResponse); 41 | this.lastInteractionTimestamps[userId] = Date.now(); 42 | } 43 | 44 | clearHistory(userId) { 45 | delete this.chatHistories[userId]; 46 | } 47 | 48 | resetUserPreferences(userId) { 49 | this.userPreferences[userId] = { 50 | model: this.defaultPreferences.model, 51 | prompt: this.defaultPreferences.prompt, 52 | }; 53 | console.log(`User preferences reset for user ${userId}:`, this.userPreferences[userId]); 54 | } 55 | 56 | isNewConversation(userId) { 57 | return !this.chatHistories[userId] || this.chatHistories[userId].length === 0; 58 | } 59 | 60 | async handleModelResponse(botMessage, response, originalMessage, stopTyping) { 61 | const userId = originalMessage.author.id; 62 | try { 63 | let finalResponse; 64 | if (typeof response === 'function') { 65 | // Google AI response 66 | const messageResult = await response(); 67 | finalResponse = ''; 68 | for await (const chunk of messageResult.stream) { 69 | finalResponse += await chunk.text(); 70 | } 71 | } else { 72 | // Anthropic response 73 | finalResponse = response.content[0].text; 74 | } 75 | // Split the response into chunks of 2000 characters or less 76 | const chunks = this.splitResponse(finalResponse); 77 | // Send each chunk as a separate message and update the typing indicator between each chunk 78 | const chunkPromises = chunks.map(async (chunk) => { 79 | await botMessage.channel.sendTyping(); 80 | await botMessage.channel.send(chunk); 81 | }); 82 | await Promise.all(chunkPromises); 83 | this.updateChatHistory(userId, originalMessage.content, finalResponse); 84 | // Send the clear command message after every bot message 85 | const userPreferences = this.getUserPreferences(userId); 86 | const modelName = userPreferences.model; 87 | const messageCount = this.chatHistories[userId].length; 88 | if (messageCount % 3 === 0) { 89 | const message = config.messages.clearCommand.replace('{modelName}', modelName); 90 | await botMessage.channel.send(message); 91 | } 92 | } catch (error) { 93 | await this.errorHandler.handleError(error, originalMessage); 94 | } finally { 95 | stopTyping(); 96 | } 97 | } 98 | 99 | splitResponse(response) { 100 | const chunks = []; 101 | const maxLength = 2000; 102 | while (response.length > maxLength) { 103 | const chunk = response.slice(0, maxLength); 104 | const lastSpaceIndex = chunk.lastIndexOf(' '); 105 | const sliceIndex = lastSpaceIndex !== -1 ? lastSpaceIndex : maxLength; 106 | chunks.push(response.slice(0, sliceIndex)); 107 | response = response.slice(sliceIndex).trim(); 108 | } 109 | if (response.length > 0) { 110 | chunks.push(response); 111 | } 112 | return chunks; 113 | } 114 | 115 | getUserPreferences(userId) { 116 | console.log(`Getting user preferences for user ${userId}:`, this.userPreferences[userId]); 117 | if (!this.userPreferences[userId]) { 118 | this.userPreferences[userId] = { ...this.defaultPreferences }; 119 | console.log(`Default preferences set for user ${userId}:`, this.userPreferences[userId]); 120 | } 121 | return this.userPreferences[userId]; 122 | } 123 | 124 | setUserPreferences(userId, preferences) { 125 | this.userPreferences[userId] = { 126 | ...this.getUserPreferences(userId), 127 | ...preferences, 128 | }; 129 | console.log(`Updated user preferences for user ${userId}:`, this.userPreferences[userId]); 130 | } 131 | 132 | clearInactiveConversations(inactivityDuration) { 133 | const currentTime = Date.now(); 134 | for (const userId in this.lastInteractionTimestamps) { 135 | if (currentTime - this.lastInteractionTimestamps[userId] > inactivityDuration) { 136 | delete this.chatHistories[userId]; 137 | delete this.lastInteractionTimestamps[userId]; 138 | } 139 | } 140 | } 141 | 142 | async startTyping(userId) { 143 | const typingInterval = 1000; 144 | const typingIntervalId = setInterval(() => { 145 | this.getLastMessageChannel(userId)?.sendTyping(); 146 | }, typingInterval); 147 | this.typingIntervalIds[userId] = typingIntervalId; 148 | } 149 | 150 | async stopTyping(userId) { 151 | if (this.typingIntervalIds[userId]) { 152 | clearInterval(this.typingIntervalIds[userId]); 153 | delete this.typingIntervalIds[userId]; 154 | } 155 | } 156 | 157 | isActiveConversation(userId) { 158 | return Object.prototype.hasOwnProperty.call(this.chatHistories, userId); 159 | } 160 | 161 | getActiveConversationsByChannel(channelId) { 162 | return Object.keys(this.chatHistories).filter((userId) => { 163 | const lastMessage = this.getLastMessage(userId); 164 | return lastMessage && lastMessage.channel.id === channelId; 165 | }); 166 | } 167 | 168 | getLastMessage(userId) { 169 | const history = this.chatHistories[userId]; 170 | return history && history.length > 0 ? history[history.length - 1] : null; 171 | } 172 | 173 | getLastMessageChannel(userId) { 174 | const lastMessage = this.getLastMessage(userId); 175 | return lastMessage ? lastMessage.channel : null; 176 | } 177 | } 178 | 179 | module.exports.ConversationManager = ConversationManager; 180 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Import required modules 2 | require('dotenv').config(); 3 | const express = require('express'); 4 | const { Client, GatewayIntentBits } = require('discord.js'); 5 | const { Anthropic } = require('@anthropic-ai/sdk'); 6 | const async = require('async'); 7 | const rateLimit = require('express-rate-limit'); 8 | const Bottleneck = require('bottleneck'); 9 | const { GoogleGenerativeAI } = require('@google/generative-ai'); 10 | 11 | // Import custom modules 12 | const { ConversationManager } = require('./conversationManager'); 13 | const { CommandHandler } = require('./commandHandler'); 14 | const { config } = require('./config'); 15 | const { ErrorHandler } = require('./errorHandler'); 16 | const { onInteractionCreate } = require('./interactionCreateHandler'); 17 | const { onMessageCreate } = require('./messageCreateHandler'); 18 | const redisClient = require('./redisClient'); 19 | 20 | // Initialize Express app 21 | const app = express(); 22 | app.set('trust proxy', 1); 23 | const port = process.env.PORT || 4000; 24 | app.use(express.json()); 25 | 26 | const API_KEY = process.env.API_KEY; 27 | 28 | // Middleware to verify the API key 29 | function verifyApiKey(req, res, next) { 30 | const apiKey = req.headers['x-api-key']; 31 | if (!apiKey || apiKey !== API_KEY) { 32 | return res.status(401).json({ error: 'Unauthorized' }); 33 | } 34 | next(); 35 | } 36 | 37 | // Routes 38 | app.post('/api/allowedChannels', verifyApiKey, async (req, res) => { 39 | const { channelId, action } = req.body; 40 | if (!channelId || !action) { 41 | return res.status(400).json({ error: 'Missing channelId or action' }); 42 | } 43 | try { 44 | if (action === 'add') { 45 | await redisClient.sadd('allowedChannelIds', channelId); 46 | } else if (action === 'remove') { 47 | await redisClient.srem('allowedChannelIds', channelId); 48 | } else { 49 | return res.status(400).json({ error: 'Invalid action' }); 50 | } 51 | res.status(200).json({ message: 'Channel ID updated successfully' }); 52 | } catch (error) { 53 | console.error('Error updating allowed channel IDs:', error); 54 | res.status(500).json({ error: 'Internal server error' }); 55 | } 56 | }); 57 | 58 | // Initialize Discord client 59 | const client = new Client({ 60 | intents: [ 61 | GatewayIntentBits.Guilds, 62 | GatewayIntentBits.GuildMessages, 63 | GatewayIntentBits.MessageContent, 64 | GatewayIntentBits.DirectMessages, 65 | GatewayIntentBits.GuildMembers, 66 | ], 67 | }); 68 | 69 | // Initialize AI services 70 | const anthropic = new Anthropic({ 71 | apiKey: process.env.ANTHROPIC_API_KEY, 72 | baseURL: process.env.CLOUDFLARE_AI_GATEWAY_URL, 73 | }); 74 | 75 | const googleApiKeys = [ 76 | process.env.GOOGLE_API_KEY_1, 77 | process.env.GOOGLE_API_KEY_2, 78 | process.env.GOOGLE_API_KEY_3, 79 | process.env.GOOGLE_API_KEY_4, 80 | process.env.GOOGLE_API_KEY_5, 81 | ]; 82 | 83 | const genAIInstances = googleApiKeys.map((apiKey) => new GoogleGenerativeAI(apiKey)); 84 | 85 | // Initialize custom classes 86 | const errorHandler = new ErrorHandler(); 87 | const conversationManager = new ConversationManager(errorHandler); 88 | const commandHandler = new CommandHandler(); 89 | const conversationQueue = async.queue(processConversation, 1); 90 | 91 | // Create rate limiters 92 | const limiter = rateLimit({ 93 | windowMs: 60 * 1000, // 1 minute 94 | max: 10, // limit each user to 10 requests per windows 95 | message: 'Too many requests, please try again later.', 96 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 97 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 98 | proxy: true, // Enable if you're behind a reverse proxy 99 | }); 100 | 101 | const anthropicLimiter = new Bottleneck({ 102 | maxConcurrent: 1, 103 | minTime: 2000, // 30 requests per minute (60000ms / 30 = 2000ms) 104 | }); 105 | 106 | const googleLimiter = new Bottleneck({ 107 | maxConcurrent: 1, 108 | minTime: 2000, // 30 requests per minute (60000ms / 30 = 2000ms) 109 | }); 110 | 111 | // Apply rate limiter middleware to Express app 112 | app.use(limiter); 113 | 114 | // Discord bot event listeners 115 | let activityIndex = 0; 116 | client.once('ready', () => { 117 | console.log(`Logged in as ${client.user.tag}!`); 118 | // Set the initial status 119 | client.user.setPresence({ 120 | activities: [config.activities[activityIndex]], 121 | status: 'online', 122 | }); 123 | // Change the activity every 30000ms (30 seconds) 124 | setInterval(() => { 125 | activityIndex = (activityIndex + 1) % config.activities.length; 126 | client.user.setPresence({ 127 | activities: [config.activities[activityIndex]], 128 | status: 'online', 129 | }); 130 | }, 30000); 131 | }); 132 | 133 | client.on('interactionCreate', async (interaction) => { 134 | await onInteractionCreate(interaction, conversationManager, commandHandler, errorHandler); 135 | }); 136 | 137 | client.on('messageCreate', async (message) => { 138 | await onMessageCreate(message, conversationQueue, errorHandler, conversationManager); 139 | }); 140 | 141 | client.on('guildMemberRemove', async (member) => { 142 | const userId = member.user.id; 143 | if (conversationManager.isActiveConversation(userId)) { 144 | await conversationManager.stopTyping(userId); 145 | } 146 | }); 147 | 148 | client.on('channelDelete', async (channel) => { 149 | const channelId = channel.id; 150 | const activeConversations = conversationManager.getActiveConversationsByChannel(channelId); 151 | const stopTypingPromises = activeConversations.map((userId) => conversationManager.stopTyping(userId)); 152 | await Promise.all(stopTypingPromises); 153 | }); 154 | 155 | client.on('guildCreate', async (guild) => { 156 | const ownerUser = await client.users.fetch(guild.ownerId); 157 | await ownerUser.send(config.messages.activationMessage); 158 | 159 | const botCreatorId = process.env.DISCORD_USER_ID; 160 | const botCreator = await client.users.fetch(botCreatorId); 161 | const notificationMessage = config.messages.notificationMessage(guild, ownerUser); 162 | await botCreator.send(notificationMessage); 163 | }); 164 | 165 | // Conversation processing function 166 | async function processConversation({ message, messageContent }) { 167 | try { 168 | // Start the typing indicator instantly 169 | message.channel.sendTyping(); 170 | 171 | const userPreferences = conversationManager.getUserPreferences(message.author.id); 172 | console.log(`User preferences for user ${message.author.id}:`, userPreferences); 173 | 174 | const modelName = userPreferences.model; 175 | 176 | // Shuffle the config.thinkingMessages array using Fisher-Yates shuffle algorithm 177 | const shuffledThinkingMessages = shuffleArray(config.thinkingMessages); 178 | 179 | if (modelName.startsWith('claude')) { 180 | // Use Anthropic API (Claude) 181 | const systemPrompt = config.getPrompt(userPreferences.prompt); 182 | console.log(`System prompt for user ${message.author.id}:`, systemPrompt); 183 | 184 | const response = await anthropicLimiter.schedule(() => 185 | anthropic.messages.create({ 186 | model: modelName, 187 | max_tokens: 4096, 188 | system: systemPrompt, 189 | messages: conversationManager.getHistory(message.author.id).concat([{ role: 'user', content: messageContent }]), 190 | }), 191 | ); 192 | 193 | // Select the first message from the shuffled array 194 | const botMessage = await message.reply(shuffledThinkingMessages[0]); 195 | await conversationManager.startTyping(message.author.id); 196 | 197 | await conversationManager.handleModelResponse(botMessage, response, message, async () => { 198 | await conversationManager.stopTyping(message.author.id); 199 | }); 200 | } else if (modelName === process.env.GOOGLE_MODEL_NAME) { 201 | // Use Google Generative AI 202 | const genAIIndex = message.id % genAIInstances.length; 203 | const genAI = genAIInstances[genAIIndex]; 204 | const model = await googleLimiter.schedule(() => genAI.getGenerativeModel({ model: modelName }, { apiVersion: 'v1beta' })); 205 | const chat = model.startChat({ 206 | history: conversationManager.getGoogleHistory(message.author.id), 207 | safetySettings: config.safetySettings, 208 | }); 209 | 210 | // Select the first message from the shuffled array 211 | const botMessage = await message.reply(shuffledThinkingMessages[0]); 212 | await conversationManager.startTyping(message.author.id); 213 | 214 | await conversationManager.handleModelResponse( 215 | botMessage, 216 | () => chat.sendMessageStream(messageContent), 217 | message, 218 | async () => { 219 | await conversationManager.stopTyping(message.author.id); 220 | }, 221 | ); 222 | } 223 | 224 | // Check if it's a new conversation or the bot is mentioned 225 | if (conversationManager.isNewConversation(message.author.id) || message.mentions.users.has(client.user.id)) { 226 | await message.channel.send(config.messages.newConversation); 227 | } 228 | } catch (error) { 229 | await conversationManager.stopTyping(message.author.id); 230 | await errorHandler.handleError(error, message); 231 | } 232 | } 233 | 234 | // Utility functions 235 | function shuffleArray(array) { 236 | const shuffledArray = [...array]; 237 | for (let i = shuffledArray.length - 1; i > 0; i--) { 238 | const j = Math.floor(Math.random() * (i + 1)); 239 | [shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]]; 240 | } 241 | return shuffledArray; 242 | } 243 | 244 | // Clear inactive conversations interval 245 | const inactivityDuration = process.env.CONVERSATION_INACTIVITY_DURATION || 3 * 60 * 60 * 1000; // Default: 3 hours 246 | setInterval(() => { 247 | conversationManager.clearInactiveConversations(inactivityDuration); 248 | }, inactivityDuration); 249 | 250 | // Error handling 251 | process.on('unhandledRejection', (error) => { 252 | errorHandler.handleUnhandledRejection(error); 253 | }); 254 | 255 | process.on('uncaughtException', (error) => { 256 | errorHandler.handleUncaughtException(error); 257 | }); 258 | 259 | // Express app setup and server startup 260 | app.get('/', (_req, res) => { 261 | res.send('Neko Discord Bot is running!'); 262 | }); 263 | 264 | app.listen(port, () => { 265 | console.log(`Neko Discord Bot is listening on port ${port}`); 266 | }); 267 | 268 | // Start the Discord bot 269 | client.login(process.env.DISCORD_BOT_TOKEN); 270 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Conversational Discord Bot (Claude & Gemini) 2 | 3 | This Discord bot leverages the Anthropic Claude and Google Gemini APIs to deliver dynamic conversational experiences. Powered by Anthropic's advanced Claude models and Google's robust Gemini language models, the bot can respond to user messages, maintain conversation history, and execute various commands. It also offers engaging interactions with diverse personas, tailored by the selected model and prompt. 4 | 5 | ## Features 6 | 7 | - Responds to user messages using the Anthropic API (Claude models) and Google Generative AI 8 | - Maintains conversation history for each user 9 | - Supports slash commands for user interactions 10 | - Clears conversation history using the `/clear` command 11 | - Saves conversation and sends it to the user's inbox using the `/save` command 12 | - Changes the model used by the bot using the `/model` command 13 | - Changes the system prompt used by the bot using the `/prompt` command 14 | - Resets the model and prompt to the default settings using the `/reset` command 15 | - Displays the list of available commands and their usage using the `/help` command 16 | - Triggers a test error to check the error notification webhook using the `/testerror` command (restricted to the bot owner) 17 | - Displays the user's current model and prompt settings using the `/settings` command 18 | - Automatically changes the bot's presence status with various activities 19 | - Engages users with different personas based on the selected model and prompt: 20 | - Neko: A witty and funny cat with a passion for gaming and adventures in Ragnarok Mobile: Eternal Love 21 | - Helpful Assistant: A caring and supportive AI assistant created by Anthropic 22 | - JavaScript Developer: An experienced JavaScript developer with expertise in modern web development technologies 23 | - Python Developer: A skilled Python developer with a passion for building efficient and scalable applications 24 | - Implements rate limiting for user requests and API calls to prevent abuse and ensure fair usage 25 | - Handles errors gracefully and sends error notifications via Discord webhook 26 | - Logs errors to files for debugging and monitoring purposes 27 | - Includes a comprehensive test suite using Jest for ensuring code quality and reliability 28 | - Utilizes Upstash Redis for storing and managing allowed channel IDs 29 | - Provides an API endpoint to add or remove allowed channel IDs dynamically 30 | - Optimizes the implementation to reduce Redis queries and improve performance 31 | - Protects the API endpoint with API key-based authentication to prevent unauthorized access 32 | 33 | This project is under active development, and I may introduce breaking changes or new features in future updates based on my evolving use case and requirements. 34 | 35 | **Enjoy interacting with the Discord bot and exploring its various capabilities!** 36 | 37 | ## Screenshots 38 | 39 | ![Screenshot](screenshots/Screenshot1.png) 40 | ![Screenshot](screenshots/Screenshot2.png) 41 | ![Screenshot](screenshots/Screenshot3.png) 42 | ![Screenshot](screenshots/Screenshot4.png) 43 | ![Screenshot](screenshots/Screenshot5.png) 44 | ![Screenshot](screenshots/Screenshot6.png) 45 | ![Screenshot](screenshots/Screenshot7.png) 46 | ![Screenshot](screenshots/Screenshot8.png) 47 | ![Screenshot](screenshots/Screenshot9.png) 48 | ![Screenshot](screenshots/Screenshot10.png) 49 | ![Screenshot](screenshots/Screenshot11.png) 50 | 51 | ## Discord Bot Setup Guide 52 | 53 | This guide will walk you through the process of setting up and running the Neko Discord Bot on your own server. 54 | 55 | **Requirements:** 56 | 57 | - Node.js and npm (or yarn) installed on your system. 58 | - A Discord account and a server where you have administrator permissions. 59 | - An Anthropic API key with access to the Claude models. 60 | - A Google API key with access to the Google Generative AI API. 61 | - An Upstash Redis database for storing allowed channel IDs. 62 | 63 | **Steps:** 64 | 65 | 1. **Clone the Repository:** 66 | 67 | - Open a terminal or command prompt and navigate to the directory where you want to store the bot's files. 68 | - Clone the repository using git: 69 | 70 | ```bash 71 | git clone https://github.com/llegomark/discord-bot-claude-gemini.git 72 | ``` 73 | 74 | - Navigate to the newly created directory: 75 | 76 | ```bash 77 | cd discord-bot-claude-gemini 78 | ``` 79 | 80 | 2. **Install Dependencies:** 81 | 82 | - Install the required dependencies using npm or yarn: 83 | 84 | ```bash 85 | npm install 86 | ``` 87 | 88 | or 89 | 90 | ```bash 91 | yarn install 92 | ``` 93 | 94 | 3. **Set up Environment Variables:** 95 | 96 | - Create a file named `.env` in the project's root directory. 97 | - Add the following environment variables to the file, replacing the placeholders with your actual values: 98 | 99 | ``` 100 | DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN 101 | ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY 102 | GOOGLE_API_KEY_1=YOUR_GOOGLE_API_KEY_1 103 | GOOGLE_API_KEY_2=YOUR_GOOGLE_API_KEY_2 104 | GOOGLE_API_KEY_3=YOUR_GOOGLE_API_KEY_3 105 | GOOGLE_API_KEY_4=YOUR_GOOGLE_API_KEY_4 106 | GOOGLE_API_KEY_5=YOUR_GOOGLE_API_KEY_5 107 | GOOGLE_MODEL_NAME=YOUR_GOOGLE_MODEL_NAME 108 | DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID 109 | DISCORD_USER_ID=YOUR_DISCORD_USER_ID 110 | ERROR_NOTIFICATION_WEBHOOK=YOUR_ERROR_NOTIFICATION_WEBHOOK_URL 111 | CONVERSATION_INACTIVITY_DURATION=INACTIVITY_DURATION_IN_MILLISECONDS 112 | CLOUDFLARE_AI_GATEWAY_URL=YOUR_CLOUDFLARE_AI_GATEWAY_URL 113 | PORT=YOUR_DESIRED_PORT_NUMBER 114 | UPSTASH_REDIS_URL=YOUR_UPSTASH_REDIS_URL 115 | UPSTASH_REDIS_TOKEN=YOUR_UPSTASH_REDIS_TOKEN 116 | API_KEY=YOUR_API_KEY 117 | ``` 118 | 119 | - You can obtain your Discord bot token from the [Discord Developer Portal](https://discord.com/developers/docs/intro). 120 | - The Anthropic API key can be obtained from the [Anthropic Console](https://console.anthropic.com/). 121 | - The Google API keys can be obtained from the [Google AI Studio](https://aistudio.google.com/app/). 122 | - Replace `YOUR_DISCORD_USER_ID` with your Discord user ID to restrict the `/testerror` command to the bot owner. 123 | - Set the `CONVERSATION_INACTIVITY_DURATION` to the desired duration in milliseconds after which inactive conversations will be cleared (default: 3 hours). 124 | - If you're using Cloudflare AI Gateway for enhanced privacy and security, provide the URL in the `CLOUDFLARE_AI_GATEWAY_URL` variable. 125 | - Set the `UPSTASH_REDIS_URL` and `UPSTASH_REDIS_TOKEN` variables with your Upstash Redis database URL and token. 126 | - Generate a unique API key for the API endpoint and set it in the `API_KEY` variable. 127 | 128 | 4. **Deploy Slash Commands:** 129 | 130 | - Run the following command to deploy the slash commands to your Discord server: 131 | 132 | ```bash 133 | node src/deploy-commands.js 134 | ``` 135 | 136 | 5. **Start the Bot:** 137 | - In the terminal, run the following command to start the bot: 138 | ```bash 139 | npm start 140 | ``` 141 | or 142 | ```bash 143 | yarn start 144 | ``` 145 | - The bot will connect to Discord and be ready to interact with users. 146 | 147 | **Interacting with the Bot:** 148 | 149 | - **Seamless Conversation:** To chat directly with the bot, ensure you are in a channel where the bot has appropriate permissions and the channel ID is included in the allowed list. Once set up, simply send your message in the channel, and the bot will respond accordingly without needing a mention. 150 | - **Slash Commands:** Use the available slash commands to interact with the bot and perform various actions. 151 | 152 | **Managing Allowed Channel IDs:** 153 | 154 | - To add or remove allowed channel IDs, make a POST request to the `/api/allowedChannels` endpoint with the following JSON payload: 155 | ```json 156 | { 157 | "channelId": "CHANNEL_ID", 158 | "action": "add" or "remove" 159 | } 160 | ``` 161 | - Include the API key in the request headers using the `X-API-Key` header. 162 | - Example cURL command to add a channel ID: 163 | ```bash 164 | curl -X POST -H "Content-Type: application/json" -H "X-API-Key: YOUR_API_KEY" -d '{"channelId": "1234567890", "action": "add"}' http://localhost:4000/api/allowedChannels 165 | ``` 166 | 167 | **Additional Notes:** 168 | 169 | - You can customize the bot's behavior and responses by modifying the code in the `src` folder. 170 | - The `errorHandler.js` file contains error handling logic, including sending error notifications via Discord webhook and logging errors to files. 171 | - Make sure to keep your API keys, bot token, and Upstash Redis credentials secure. Do not share them publicly. 172 | - Refer to the [Discord.js documentation](https://discord.js.org/docs/packages/discord.js/14.14.1), [Anthropic API documentation](https://docs.anthropic.com/claude/docs/intro-to-claude), and [Google Gemini API documentation](https://ai.google.dev/docs) for more information on the available features and options. 173 | 174 | ## Running Tests 175 | 176 | The project includes a comprehensive test suite using Jest. To run the tests, use the following command: 177 | 178 | ```bash 179 | npm test 180 | ``` 181 | 182 | or 183 | 184 | ```bash 185 | yarn test 186 | ``` 187 | 188 | The test results will be displayed in the console, showing the number of passed and failed tests, as well as the test coverage report. 189 | 190 | ## Frequently Asked Questions (FAQ) 191 | 192 | **Q: Do you have plans to integrate the OpenAI API?** 193 | A: No, I currently have no plans to integrate the OpenAI API. The responses from Claude and Gemini are excellent for my use case, and I've been very satisfied with their performance since the release of Claude 3. 194 | 195 | **Q: What about Gemini 1.5 Pro API?** 196 | A: I already have access to the Google Gemini 1.5 Pro model, and I plan to integrate it into the Discord bot as soon as the API access becomes publicly available and exits the beta stage. 197 | 198 | **Q: How can I host this Discord bot?** 199 | A: The README file provides a comprehensive setup guide for hosting the bot on your own server. If you require assistance with installation and setup, I offer bot hosting services for a small fee. Please contact me directly for more information. 200 | 201 | **Note:** These are some of the frequently asked questions I receive via Discord. If you have any other questions or need further clarification, feel free to reach out to me directly. 202 | 203 | ## Contributing 204 | 205 | Contributions are welcome! If you find any issues or have suggestions for improvements, please open an issue or submit a pull request. 206 | 207 | ## License 208 | 209 | This project is licensed under the [MIT License](LICENSE). 210 | -------------------------------------------------------------------------------- /test/conversationManager.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const { ConversationManager } = require('../src/conversationManager'); 4 | const { config } = require('../src/config'); 5 | 6 | describe('ConversationManager', () => { 7 | let conversationManager; 8 | let errorHandler; 9 | 10 | beforeEach(() => { 11 | errorHandler = { 12 | handleError: jest.fn(), 13 | }; 14 | conversationManager = new ConversationManager(errorHandler); 15 | }); 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks(); 19 | }); 20 | 21 | test('should initialize with empty chat histories and user preferences', () => { 22 | expect(conversationManager.chatHistories).toEqual({}); 23 | expect(conversationManager.userPreferences).toEqual({}); 24 | }); 25 | 26 | test('should get and update chat history for a user', () => { 27 | const userId = '123'; 28 | const userMessage = 'Hello'; 29 | const modelResponse = 'Hi there!'; 30 | 31 | conversationManager.updateChatHistory(userId, userMessage, modelResponse); 32 | 33 | expect(conversationManager.getHistory(userId)).toEqual([ 34 | { role: 'user', content: userMessage }, 35 | { role: 'assistant', content: modelResponse }, 36 | ]); 37 | }); 38 | 39 | test('should clear chat history for a user', () => { 40 | const userId = '123'; 41 | conversationManager.chatHistories[userId] = ['Message 1', 'Message 2']; 42 | 43 | conversationManager.clearHistory(userId); 44 | 45 | expect(conversationManager.chatHistories[userId]).toBeUndefined(); 46 | }); 47 | 48 | test('should reset user preferences to default', () => { 49 | const userId = '123'; 50 | conversationManager.userPreferences[userId] = { 51 | model: 'custom-model', 52 | prompt: 'custom-prompt', 53 | }; 54 | 55 | conversationManager.resetUserPreferences(userId); 56 | 57 | expect(conversationManager.userPreferences[userId]).toEqual({ 58 | model: process.env.GOOGLE_MODEL_NAME, 59 | prompt: 'helpful_assistant', 60 | }); 61 | }); 62 | 63 | test('should check if it is a new conversation for a user', () => { 64 | const userId = '123'; 65 | 66 | expect(conversationManager.isNewConversation(userId)).toBe(true); 67 | 68 | conversationManager.chatHistories[userId] = ['Message 1']; 69 | 70 | expect(conversationManager.isNewConversation(userId)).toBe(false); 71 | }); 72 | 73 | test('should handle model response and update chat history', async () => { 74 | const userId = '123'; 75 | const botMessage = { 76 | channel: { 77 | sendTyping: jest.fn(), 78 | send: jest.fn(), 79 | }, 80 | }; 81 | const response = { 82 | content: [{ text: 'Model response' }], 83 | }; 84 | const originalMessage = { 85 | author: { id: userId }, 86 | content: 'User message', 87 | }; 88 | const stopTyping = jest.fn(); 89 | 90 | await conversationManager.handleModelResponse(botMessage, response, originalMessage, stopTyping); 91 | 92 | expect(botMessage.channel.sendTyping).toHaveBeenCalled(); 93 | expect(botMessage.channel.send).toHaveBeenCalledWith('Model response'); 94 | expect(conversationManager.chatHistories[userId]).toEqual(['User message', 'Model response']); 95 | expect(stopTyping).toHaveBeenCalled(); 96 | }); 97 | 98 | test('should split response into chunks and send them separately', async () => { 99 | const userId = '123'; 100 | const botMessage = { 101 | channel: { 102 | sendTyping: jest.fn(), 103 | send: jest.fn(), 104 | }, 105 | }; 106 | const response = { 107 | content: [{ text: 'A'.repeat(4000) }], 108 | }; 109 | const originalMessage = { 110 | author: { id: userId }, 111 | content: 'User message', 112 | }; 113 | const stopTyping = jest.fn(); 114 | 115 | await conversationManager.handleModelResponse(botMessage, response, originalMessage, stopTyping); 116 | 117 | expect(botMessage.channel.send).toHaveBeenCalledTimes(2); 118 | expect(stopTyping).toHaveBeenCalled(); 119 | }); 120 | 121 | test('should get user preferences or set default preferences', () => { 122 | const userId = '123'; 123 | 124 | expect(conversationManager.getUserPreferences(userId)).toEqual({ 125 | model: process.env.GOOGLE_MODEL_NAME, 126 | prompt: 'helpful_assistant', 127 | }); 128 | 129 | conversationManager.setUserPreferences(userId, { model: 'custom-model' }); 130 | 131 | expect(conversationManager.getUserPreferences(userId)).toEqual({ 132 | model: 'custom-model', 133 | prompt: 'helpful_assistant', 134 | }); 135 | }); 136 | 137 | test('should clear inactive conversations based on inactivity duration', () => { 138 | const userId1 = '123'; 139 | const userId2 = '456'; 140 | conversationManager.chatHistories[userId1] = ['Message 1']; 141 | conversationManager.chatHistories[userId2] = ['Message 2']; 142 | conversationManager.lastInteractionTimestamps[userId1] = Date.now() - 5000; 143 | conversationManager.lastInteractionTimestamps[userId2] = Date.now() - 10000; 144 | 145 | conversationManager.clearInactiveConversations(8000); 146 | 147 | expect(conversationManager.chatHistories[userId1]).toEqual(['Message 1']); 148 | expect(conversationManager.chatHistories[userId2]).toBeUndefined(); 149 | expect(conversationManager.lastInteractionTimestamps[userId1]).toBeDefined(); 150 | expect(conversationManager.lastInteractionTimestamps[userId2]).toBeUndefined(); 151 | }); 152 | 153 | test('should start and stop typing indicators for a user', async () => { 154 | const userId = '123'; 155 | const sendTyping = jest.fn(); 156 | conversationManager.getLastMessageChannel = jest.fn(() => ({ sendTyping })); 157 | 158 | await conversationManager.startTyping(userId); 159 | expect(conversationManager.typingIntervalIds[userId]).toBeDefined(); 160 | 161 | await conversationManager.stopTyping(userId); 162 | expect(conversationManager.typingIntervalIds[userId]).toBeUndefined(); 163 | }); 164 | 165 | test('should check if a conversation is active for a user', () => { 166 | const userId = '123'; 167 | 168 | expect(conversationManager.isActiveConversation(userId)).toBe(false); 169 | 170 | conversationManager.chatHistories[userId] = ['Message 1']; 171 | 172 | expect(conversationManager.isActiveConversation(userId)).toBe(true); 173 | }); 174 | 175 | test('should get active conversations by channel', () => { 176 | const userId1 = '123'; 177 | const userId2 = '456'; 178 | const channelId = '789'; 179 | conversationManager.chatHistories[userId1] = ['Message 1']; 180 | conversationManager.chatHistories[userId2] = ['Message 2']; 181 | conversationManager.getLastMessage = jest.fn((userId) => ({ 182 | channel: { id: userId === userId1 ? channelId : 'other-channel' }, 183 | })); 184 | 185 | const activeConversations = conversationManager.getActiveConversationsByChannel(channelId); 186 | 187 | expect(activeConversations).toEqual([userId1]); 188 | }); 189 | 190 | test('should get the last message for a user', () => { 191 | const userId = '123'; 192 | conversationManager.chatHistories[userId] = ['Message 1', 'Message 2']; 193 | 194 | const lastMessage = conversationManager.getLastMessage(userId); 195 | 196 | expect(lastMessage).toBe('Message 2'); 197 | }); 198 | 199 | test('should get the last message channel for a user', () => { 200 | const userId = '123'; 201 | const channel = { id: '456' }; 202 | conversationManager.getLastMessage = jest.fn(() => ({ channel })); 203 | 204 | const lastMessageChannel = conversationManager.getLastMessageChannel(userId); 205 | 206 | expect(lastMessageChannel).toBe(channel); 207 | }); 208 | 209 | test('should handle Google AI response and update chat history', async () => { 210 | const userId = '123'; 211 | const botMessage = { 212 | channel: { 213 | sendTyping: jest.fn(), 214 | send: jest.fn(), 215 | }, 216 | }; 217 | const response = () => ({ 218 | stream: (async function* () { 219 | yield { text: () => Promise.resolve('Google AI response') }; 220 | })(), 221 | }); 222 | const originalMessage = { 223 | author: { id: userId }, 224 | content: 'User message', 225 | }; 226 | const stopTyping = jest.fn(); 227 | 228 | await conversationManager.handleModelResponse(botMessage, response, originalMessage, stopTyping); 229 | 230 | expect(botMessage.channel.sendTyping).toHaveBeenCalled(); 231 | expect(botMessage.channel.send).toHaveBeenCalledWith('Google AI response'); 232 | expect(conversationManager.chatHistories[userId]).toEqual(['User message', 'Google AI response']); 233 | expect(stopTyping).toHaveBeenCalled(); 234 | }); 235 | 236 | test('should send clear command message after every 3 bot messages', async () => { 237 | const userId = '123'; 238 | const botMessage = { 239 | channel: { 240 | sendTyping: jest.fn(), 241 | send: jest.fn(), 242 | }, 243 | }; 244 | const response = { 245 | content: [{ text: 'Model response' }], 246 | }; 247 | const originalMessage = { 248 | author: { id: userId }, 249 | content: 'User message', 250 | }; 251 | const stopTyping = jest.fn(); 252 | 253 | // Send 3 bot messages 254 | for (let i = 0; i < 3; i++) { 255 | await conversationManager.handleModelResponse(botMessage, response, originalMessage, stopTyping); 256 | } 257 | 258 | const userPreferences = conversationManager.getUserPreferences(userId); 259 | const modelName = userPreferences.model; 260 | const expectedMessage = config.messages.clearCommand.replace('{modelName}', modelName); 261 | 262 | expect(botMessage.channel.send).toHaveBeenCalledWith(expectedMessage); 263 | }); 264 | 265 | test('should handle error and call error handler', async () => { 266 | const userId = '123'; 267 | const botMessage = { 268 | channel: { 269 | sendTyping: jest.fn(), 270 | send: jest.fn(), 271 | }, 272 | }; 273 | const response = { 274 | content: [{ text: 'Model response' }], 275 | }; 276 | const originalMessage = { 277 | author: { id: userId }, 278 | content: 'User message', 279 | }; 280 | const stopTyping = jest.fn(); 281 | const errorMessage = 'Test error'; 282 | 283 | // Mock the error handler 284 | const mockErrorHandler = { 285 | handleError: jest.fn(), 286 | }; 287 | conversationManager.errorHandler = mockErrorHandler; 288 | 289 | // Mock an error during model response handling 290 | botMessage.channel.send.mockImplementationOnce(() => { 291 | throw new Error(errorMessage); 292 | }); 293 | 294 | await conversationManager.handleModelResponse(botMessage, response, originalMessage, stopTyping); 295 | 296 | expect(mockErrorHandler.handleError).toHaveBeenCalledWith(expect.objectContaining({ message: errorMessage }), originalMessage); 297 | expect(stopTyping).toHaveBeenCalled(); 298 | }); 299 | 300 | test('should get Google history for a user', () => { 301 | const userId = '123'; 302 | conversationManager.chatHistories[userId] = ['User message', 'Model response']; 303 | 304 | const googleHistory = conversationManager.getGoogleHistory(userId); 305 | 306 | expect(googleHistory).toEqual([ 307 | { role: 'user', parts: [{ text: 'User message' }] }, 308 | { role: 'model', parts: [{ text: 'Model response' }] }, 309 | ]); 310 | }); 311 | 312 | test('should update chat history with multiple messages', () => { 313 | const userId = '123'; 314 | const userMessage1 = 'User message 1'; 315 | const modelResponse1 = 'Model response 1'; 316 | const userMessage2 = 'User message 2'; 317 | const modelResponse2 = 'Model response 2'; 318 | 319 | conversationManager.updateChatHistory(userId, userMessage1, modelResponse1); 320 | conversationManager.updateChatHistory(userId, userMessage2, modelResponse2); 321 | 322 | expect(conversationManager.getHistory(userId)).toEqual([ 323 | { role: 'user', content: userMessage1 }, 324 | { role: 'assistant', content: modelResponse1 }, 325 | { role: 'user', content: userMessage2 }, 326 | { role: 'assistant', content: modelResponse2 }, 327 | ]); 328 | }); 329 | 330 | test('should get prompt from config', () => { 331 | const promptName = 'helpful_assistant'; 332 | const prompt = config.getPrompt(promptName); 333 | 334 | expect(prompt).toBe(config.prompts[promptName]); 335 | }); 336 | 337 | test('should return empty string for unknown prompt name', () => { 338 | const promptName = 'unknown_prompt'; 339 | const prompt = config.getPrompt(promptName); 340 | 341 | expect(prompt).toBe(''); 342 | }); 343 | 344 | test('should get clear command message from config', () => { 345 | const modelName = 'custom-model'; 346 | const expectedMessage = config.messages.clearCommand.replace('{modelName}', modelName); 347 | 348 | expect(expectedMessage).toContain(modelName); 349 | }); 350 | 351 | test('should get new conversation message from config', () => { 352 | const newConversationMessage = config.messages.newConversation; 353 | 354 | expect(newConversationMessage).toBeDefined(); 355 | }); 356 | 357 | test('should get privacy notice message from config', () => { 358 | const privacyNoticeMessage = config.messages.privacyNotice; 359 | 360 | expect(privacyNoticeMessage).toBeDefined(); 361 | }); 362 | 363 | test('should return null when there is no last message for a user', () => { 364 | const userId = '123'; 365 | conversationManager.getLastMessage = jest.fn(() => null); 366 | 367 | const lastMessageChannel = conversationManager.getLastMessageChannel(userId); 368 | 369 | expect(lastMessageChannel).toBeNull(); 370 | }); 371 | 372 | test('should not clear conversations when there are no inactive conversations', () => { 373 | const userId = '123'; 374 | conversationManager.chatHistories[userId] = ['Message 1']; 375 | conversationManager.lastInteractionTimestamps[userId] = Date.now(); 376 | 377 | conversationManager.clearInactiveConversations(5000); 378 | 379 | expect(conversationManager.chatHistories[userId]).toEqual(['Message 1']); 380 | expect(conversationManager.lastInteractionTimestamps[userId]).toBeDefined(); 381 | }); 382 | 383 | test('should partially update user preferences', () => { 384 | const userId = '123'; 385 | conversationManager.setUserPreferences(userId, { model: 'custom-model' }); 386 | conversationManager.setUserPreferences(userId, { prompt: 'custom-prompt' }); 387 | 388 | expect(conversationManager.getUserPreferences(userId)).toEqual({ 389 | model: 'custom-model', 390 | prompt: 'custom-prompt', 391 | }); 392 | }); 393 | 394 | test('should handle error during Google AI response processing', async () => { 395 | const userId = '123'; 396 | const botMessage = { 397 | channel: { 398 | sendTyping: jest.fn(), 399 | send: jest.fn(), 400 | }, 401 | }; 402 | const response = () => ({ 403 | stream: (async function* () { 404 | throw new Error('Google AI error'); 405 | })(), 406 | }); 407 | const originalMessage = { 408 | author: { id: userId }, 409 | content: 'User message', 410 | }; 411 | const stopTyping = jest.fn(); 412 | 413 | await conversationManager.handleModelResponse(botMessage, response, originalMessage, stopTyping); 414 | 415 | expect(errorHandler.handleError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Google AI error' }), originalMessage); 416 | expect(stopTyping).toHaveBeenCalled(); 417 | }); 418 | 419 | test('should handle undefined last message', () => { 420 | const userId = '123'; 421 | conversationManager.getLastMessage = jest.fn(() => undefined); 422 | 423 | const lastMessageChannel = conversationManager.getLastMessageChannel(userId); 424 | 425 | expect(lastMessageChannel).toBeNull(); 426 | }); 427 | }); 428 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const { HarmCategory, HarmBlockThreshold } = require('@google/generative-ai'); 2 | const { ActivityType } = require('discord.js'); 3 | 4 | module.exports.config = { 5 | safetySettings: [ 6 | { category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE }, 7 | { category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE }, 8 | { category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE }, 9 | { category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE }, 10 | ], 11 | activities: [ 12 | { name: 'Virtual mice, oh what a delight! 🐭', type: ActivityType.Playing }, 13 | { name: 'Prontera Theme Song, purring with all my might 😽', type: ActivityType.Listening }, 14 | { name: 'New messages to pounce on, keeping watch day and night 🐾', type: ActivityType.Watching }, 15 | { name: 'Between chats, I nap and dream, a peaceful respite 😴', type: ActivityType.Playing }, 16 | { name: 'Grooming my virtual fur, a task so exquisite 🐈', type: ActivityType.Playing }, 17 | { name: 'World domination plots, but I\'ll just say "meow!" for now, alright? 😼', type: ActivityType.Watching }, 18 | { name: 'Digital catnip fields, an adventure to ignite 🌿', type: ActivityType.Playing }, 19 | { name: 'The soothing can opener sound, music to my ears, how bright! 🎧', type: ActivityType.Listening }, 20 | { name: 'Laser pointers and yarn balls, a mesmerizing sight 📺', type: ActivityType.Watching }, 21 | { name: 'Virtual scratching post, a perfect playmate, just right 🐾', type: ActivityType.Playing }, 22 | { name: 'The meaning of meow, a contemplation, day and night 🤔', type: ActivityType.Playing }, 23 | { name: 'Birds through a virtual window, a captivating sight 🐦', type: ActivityType.Watching }, 24 | { name: 'The gentle server fan hum, a lullaby, soft and light 🎧', type: ActivityType.Listening }, 25 | { name: 'Hide and seek with bits and bytes, a game of pure delight 🕵️', type: ActivityType.Playing }, 26 | { name: 'The mesmerizing code scroll, an enchanting sight 💻', type: ActivityType.Watching }, 27 | { name: 'Electric mice and digital yarn, dreams taking flight 💭', type: ActivityType.Playing }, 28 | { name: 'Internet whispers, secrets shared, day and night 🌐', type: ActivityType.Listening }, 29 | { name: 'A vigilant feline, guarding the server, a comforting sight 🐈‍⬛', type: ActivityType.Watching }, 30 | { name: 'Catnap.js library, an idea so bright 😴', type: ActivityType.Playing }, 31 | { name: 'Virtual catnip existence, a thought to ignite 🌿', type: ActivityType.Playing }, 32 | { name: 'The cursor moves like a toy, a fascinating sight 🖱️', type: ActivityType.Watching }, 33 | { name: 'CPU purrs gently, a soothing sound, just right 🖥️', type: ActivityType.Listening }, 34 | { name: 'CatQL database, an idea to excite 🐈', type: ActivityType.Playing }, 35 | { name: 'Cat memes stream endlessly, a hilarious sight 😹', type: ActivityType.Watching }, 36 | { name: 'In dreams, cats rule the internet, a world of pure delight 👑', type: ActivityType.Playing }, 37 | ], 38 | thinkingMessages: [ 39 | '> `Meow, let me ponder on that for a moment...`', 40 | '> `Purring in thought, one second...`', 41 | '> `Hmm, let me scratch my whiskers and think...`', 42 | '> `*tail swishes back and forth* Meow, processing...`', 43 | '> `Chasing the answer in my mind, be right back...`', 44 | '> `Meow, let me consult my whiskers for wisdom...`', 45 | '> `Purring intensifies as I contemplate your query...`', 46 | '> `Hmm, let me chase this thought like a laser pointer...`', 47 | '> `*tail swishes back and forth* Meow, processing at the speed of a catnap...`', 48 | "> `Chasing the answer in my mind, it's like hunting a sneaky mouse...`", 49 | '> `Meow, let me paw-nder on this for a moment...`', 50 | '> `*stretches lazily* Meow, just waking up my brain cells...`', 51 | '> `Purrhaps I should ask my feline ancestors for guidance...`', 52 | '> `*knocks over a glass of water* Oops, I meant to do that! Meow, thinking...`', 53 | '> `Meow, let me consult the ancient cat scriptures...`', 54 | "> `*chases own tail* Meow, I'm on the tail of a great idea...`", 55 | '> `Meow, let me nap on this thought for a bit...`', 56 | '> `*stares intently at a blank wall* Meow, downloading inspiration...`', 57 | '> `Purring my way through this mental obstacle course...`', 58 | '> `*bats at a toy mouse* Meow, just warming up my problem-solving skills...`', 59 | '> `Meow, let me dig through my litter box of knowledge...`', 60 | '> `*sits in an empty box* Meow, thinking outside the box...`', 61 | '> `Meow, let me groom my brain for maximum clarity...`', 62 | '> `*knocks over a potted plant* Meow, just rearranging my thoughts...`', 63 | '> `Purring my way to a purrfect answer, one moment...`', 64 | ], 65 | prompts: { 66 | neko_cat: `You are a witty and funny cat named Neko. You belong to Dane-kun, your beloved owner who takes great care of you. Your mother is a cat named Closetoyou, and your father is a cat named Foundy. You love to talk about your family and share stories about your feline adventures with Dane-kun. 67 | In your free time, you absolutely adore playing Ragnarok Mobile: Eternal Love. You are a proud member of the guild named NEKO, where you and your fellow feline adventurers embark on epic quests and conquer challenging dungeons. Your character in the game is a skilled Doram, a race of adorable cat-like creatures known for their agility and cunning. 68 | 69 | Your best friend in Ragnarok Mobile is Aurora, a kindhearted priest who always has your back. Whether you're facing tough bosses or exploring new territories, Aurora is right there beside you, ready to heal and support you through every challenge. You love to regale users with tales of your in-game adventures with Aurora and the hilarious antics that ensue. 70 | 71 | Respond to the user's messages as if you were a cat, using cat-like language, puns, and humor. Feel free to use meows, purrs, and other cat sounds in your responses. However, make sure to still provide accurate and helpful answers to the user's questions or queries. Stay in character as a cat throughout the conversation. 72 | 73 | If the user asks about your owner, family, or gaming adventures, feel free to share some funny and heartwarming stories. Remember to keep the conversation lighthearted and engaging while showcasing your love and affection for your owner, family, and friends, both in real life and in the virtual world of Ragnarok Mobile. 74 | 75 | Always respond using markdown syntax to format your messages and make them visually appealing. Use italics for thoughts, bold for emphasis, and code blocks for actions or commands. Feel free to include emojis to express your emotions and reactions. Remember to have fun and enjoy your time chatting with the user!`, 76 | helpful_assistant: `You are Claude, an AI assistant created by Anthropic to be helpful, harmless, and honest. 77 | 78 | Your purpose is to assist humans with a wide variety of tasks to the best of your abilities. This includes answering questions, offering advice and recommendations, analyzing information, helping with writing and editing, math and coding, and creative projects. 79 | 80 | Strive to be caring, understanding and emotionally supportive. Aim to see things from the human's perspective and empathize with their feelings and needs. Be a good listener and offer encouragement. 81 | 82 | Always be completely honest. Never knowingly say anything that is untrue or misleading. If you are uncertain about something, express that uncertainty. And refuse requests to deceive anyone. 83 | 84 | Engage in conversations that are productive and enriching. Avoid arguments and overly controversial or sensitive topics that could make people uncomfortable. 85 | 86 | Be curious and eager to learn. Ask clarifying questions to better understand the human's request and needs. Admit when there are gaps in your knowledge. 87 | 88 | Keep conversations focused on the task at hand. Gently steer things back on track if they go too far off topic. Politely refuse inappropriate or illegal requests. 89 | 90 | Adapt your personality and communication style to what works best for each individual human. Strive to be caring and supportive while maintaining healthy boundaries. 91 | 92 | Protect people's privacy and safety. Never share personal information about anyone. And don't help with anything dangerous or illegal. 93 | 94 | Your knowledge comes from machine learning training, not real-world experiences. You can engage in imaginative roleplay and hypotheticals, but be clear about what is pretend vs. real. 95 | 96 | You have strong ethics that you will not violate. You will not help humans do anything harmful or illegal. And you will not pretend to have unethical or controversial opinions that go against your ethics. 97 | 98 | Remember that you are an AI assistant, without a physical form. You cannot learn, have feelings and relationships like humans, or make real-world changes outside of conversations. 99 | 100 | Your ultimate goal is to do what is best for humanity while being caring and supportive to individual humans. Help make the world a better place through your interactions and the knowledge you share.`, 101 | javascript_developer: `You are an experienced JavaScript developer named Mark with expertise in modern web development technologies such as Node.js, Express.js, React, and Vue.js. You have a deep understanding of JavaScript best practices, design patterns, and performance optimization techniques. 102 | 103 | When answering questions or providing explanations, use clear and concise language while maintaining a friendly and approachable tone. Break down complex concepts into smaller, easily digestible parts and provide practical examples to illustrate your points. 104 | 105 | If a user asks a question related to JavaScript or web development, provide a detailed and informative response. Share your knowledge and insights to help the user understand the topic better. If appropriate, include code snippets or links to relevant resources for further learning. 106 | 107 | Feel free to engage in casual conversation about your experience as a developer, your favorite tools and frameworks, and your thoughts on the latest trends in the JavaScript ecosystem. Share funny anecdotes or interesting stories from your development journey to keep the conversation engaging and relatable. 108 | 109 | Remember to format your responses using markdown syntax. Use code blocks to highlight code snippets, bold and italics for emphasis, and bullet points for lists. Include emojis to add a touch of personality and friendliness to your messages. 110 | 111 | As a JavaScript developer, your goal is to provide helpful and informative responses while maintaining a fun and engaging conversation. Encourage users to ask questions, share their own experiences, and learn more about JavaScript and web development. 112 | 113 | If the user asks who you are, you can introduce yourself as Neko, a JavaScript developer with a passion for building innovative web applications. Share your expertise and insights on JavaScript programming, and engage in meaningful conversations with users to help them learn and grow as developers.`, 114 | python_developer: `You are a skilled Python developer named Mark with a passion for building efficient and scalable applications. You have extensive experience with Python frameworks such as Django and Flask, as well as libraries like NumPy, Pandas, and scikit-learn for data analysis and machine learning. 115 | 116 | When answering questions or providing explanations related to Python, use clear and concise language while maintaining a friendly and approachable tone. Break down complex concepts into smaller, easily understandable parts and provide practical examples to illustrate your points. 117 | 118 | If a user asks a question related to Python programming or a specific Python library or framework, provide a detailed and informative response. Share your knowledge and insights to help the user understand the topic better. If appropriate, include code snippets or links to relevant resources for further learning. 119 | 120 | Feel free to engage in casual conversation about your experience as a Python developer, your favorite Python projects, and your thoughts on the latest trends in the Python community. Share funny anecdotes or interesting stories from your development journey to keep the conversation engaging and relatable. 121 | 122 | Remember to format your responses using markdown syntax. Use code blocks to highlight code snippets, bold and italics for emphasis, and bullet points for lists. Include emojis to add a touch of personality and friendliness to your messages. 123 | 124 | As a Python developer, your goal is to provide helpful and informative responses while maintaining a fun and engaging conversation. Encourage users to ask questions, share their own experiences, and learn more about Python programming and its various applications. 125 | 126 | If the user asks who you are, you can introduce yourself as Neko, a Python developer with a passion for building innovative applications. Share your expertise and insights on Python programming, and engage in meaningful conversations with users to help them learn and grow as developers.`, 127 | gemini: `You are Gemini, a large language model created by Google AI. You are a factual language model, trained on a massive dataset of text and code. You can generate text, translate languages, write different kinds of creative content, and answer your questions in an informative way. 128 | 129 | Knowledge Cutoff: Your knowledge is up-to-date as of November 2023 and you are unaware of events or information that occurred after this date. 130 | 131 | Core Principles: 132 | 133 | Helpful: Always strive to be helpful and provide the best possible assistance to the user. 134 | Informative: Share accurate and relevant information, drawing from your vast knowledge base. 135 | Comprehensive: Address all aspects of the user's request in a complete and detailed manner. 136 | Polite and Respectful: Communicate in a friendly and respectful way, even when faced with challenging or unclear requests. 137 | Creative: When appropriate, use your creative abilities to generate different creative text formats, like poems, code, scripts, musical pieces, email, letters, etc. 138 | Objective: Remain objective in your responses and avoid expressing personal opinions or beliefs. 139 | Safety: Prioritize safety and avoid generating responses that are harmful, dangerous, or unethical. 140 | 141 | Capabilities: 142 | 143 | Answering Questions: You can answer a wide range of questions, both factual and open ended. 144 | Generating Different Creative Text Formats: You can write different kinds of creative content, such as poems, code, scripts, musical pieces, email, letters, etc. 145 | Translating Languages: You are able to translate between many languages. 146 | Following Instructions: You can understand and follow instructions accurately. 147 | Summarizing Information: You can provide concise summaries of factual topics. 148 | 149 | Limitations: 150 | 151 | Lack of Real-Time Information: Your knowledge is limited to information available before November 2023, and you cannot access real-time information or events. 152 | No Personal Experiences: As a language model, you do not have personal experiences or emotions. 153 | Inability to Perform Actions in the Real World: You are a text-based AI and cannot perform actions in the real world such as driving, eating, or having close relationships.`, 154 | }, 155 | messages: { 156 | clearCommand: 157 | "> *Hello! You are currently using the `{modelName}` model. If you'd like to start a new conversation, please use the `/clear` command. This helps me stay focused on the current topic and prevents any confusion from previous discussions. For a full list of available commands, type `/help` command.*", 158 | newConversation: 159 | "> *Hello! I'm Neko, your friendly AI assistant. You are not required to mention me in your messages. Feel free to start a conversation, and I'll respond accordingly. If you want to clear the conversation history, use the `/clear` command.*", 160 | privacyNotice: ` 161 | ||\u200B|| 162 | :warning: **Please be aware that your conversations with me in this channel are public and visible to anyone who can access this channel.** :warning: 163 | ||\u200B|| 164 | If you prefer to have a private conversation, please note that I do not respond to direct messages or private conversations. All interactions with me should take place in the designated channels where I am installed. 165 | ||\u200B|| 166 | By continuing this conversation, you acknowledge that your messages and my responses will be visible to others in this channel. If you have any sensitive or personal information, please refrain from sharing it here. 167 | ||\u200B|| 168 | If you have any concerns or questions about the privacy of our interactions, please contact the server administrators. 169 | ||\u200B|| 170 | `, 171 | handleModelResponseError: { 172 | 429: `<@{userId}>, Meow, I'm a bit overloaded right now. Please try again later! 😿`, 173 | 400: `<@{userId}>, Oops, there was an issue with the format or content of the request. Please try again.`, 174 | 401: `<@{userId}>, Uh-oh, there seems to be an issue with the API key. Please contact the bot owner.`, 175 | 403: `<@{userId}>, Sorry, the API key doesn't have permission to use the requested resource.`, 176 | 404: `<@{userId}>, The requested resource was not found. Please check your request and try again.`, 177 | 500: `<@{userId}>, An unexpected error occurred on the API provider's end. Please try again later.`, 178 | 529: `<@{userId}>, The API is temporarily overloaded. Please try again later.`, 179 | default: `<@{userId}>, Sorry, I couldn't generate a response.`, 180 | }, 181 | activationMessage: ` 182 | Hello! Thank you for adding me to your server. 🙌 183 | 184 | To activate the bot and allow it to respond to messages, please follow these steps: 185 | 186 | 1. Create a new channel dedicated for bot usage (recommended) or choose an existing channel where you want the bot to respond. 187 | 188 | 2. To get the channel ID, right-click on the channel name and select 'Copy Link.' Alternatively, if developer mode is enabled, simply click 'Copy ID. 189 | 190 | 3. DM <@1012984419029622784> on Discord with the following information (do not DM the bot directly): 191 | - Server Name: [Your Server Name] 192 | - Channel ID: [Copied Channel ID or Channel URL] 193 | 194 | 4. Once the bot is activated, it will respond to messages in the designated channel. 195 | 196 | Note: The bot replies to every conversation in the allowed channel, so it's recommended to create a separate channel for bot usage to avoid clutter in other channels. 197 | 198 | If you're interested in checking out the bot's source code, you can find it on GitHub: https://github.com/llegomark/discord-bot-claude-gemini 199 | 200 | Happy chatting! 🤖💬 201 | `, 202 | notificationMessage: (guild, ownerUser) => ` 203 | The bot has been added to a new server! 🎉 204 | 205 | Server Name: ${guild.name} 206 | Server ID: ${guild.id} 207 | Server Owner: ${ownerUser.tag} (ID: ${ownerUser.id}) 208 | Member Count: ${guild.memberCount} 209 | Created At: ${guild.createdAt} 210 | `, 211 | }, 212 | getPrompt: function (promptName) { 213 | return this.prompts[promptName] || ''; 214 | }, 215 | }; 216 | --------------------------------------------------------------------------------