From 581c6a074bf5f2d48a5e5de577c347ee10592c15 Mon Sep 17 00:00:00 2001 From: Talon Date: Mon, 31 Mar 2025 20:07:57 +0200 Subject: [PATCH] Initial push --- .env.example | 33 +++ .gitignore | 67 ++++++ DOCKER-GUIDE.md | 133 ++++++++++++ Dockerfile | 34 +++ README.md | 181 ++++++++++++++++ data/.gitkeep | 0 docker-compose.yml | 26 +++ docker-start.sh | 31 +++ make-executable.sh | 7 + package.json | 36 ++++ run-docker.sh | 17 ++ src/audio/README.txt | 14 ++ src/commands/index.ts | 14 ++ src/commands/settings.ts | 90 ++++++++ src/commands/theme.ts | 118 +++++++++++ src/config/config.ts | 53 +++++ src/debug.ts | 75 +++++++ src/index.ts | 379 ++++++++++++++++++++++++++++++++++ src/utils/audio-player.ts | 162 +++++++++++++++ src/utils/bot-behaviors.ts | 67 ++++++ src/utils/cat-behaviors.ts | 132 ++++++++++++ src/utils/command-deployer.ts | 31 +++ src/utils/guild-settings.ts | 123 +++++++++++ src/utils/random.ts | 27 +++ src/utils/status-manager.ts | 112 ++++++++++ src/utils/themes.ts | 334 ++++++++++++++++++++++++++++++ src/utils/voice/adapter.ts | 11 + tsconfig.json | 16 ++ 28 files changed, 2323 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DOCKER-GUIDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docker-start.sh create mode 100644 make-executable.sh create mode 100644 package.json create mode 100644 run-docker.sh create mode 100644 src/audio/README.txt create mode 100644 src/commands/index.ts create mode 100644 src/commands/settings.ts create mode 100644 src/commands/theme.ts create mode 100644 src/config/config.ts create mode 100644 src/debug.ts create mode 100644 src/index.ts create mode 100644 src/utils/audio-player.ts create mode 100644 src/utils/bot-behaviors.ts create mode 100644 src/utils/cat-behaviors.ts create mode 100644 src/utils/command-deployer.ts create mode 100644 src/utils/guild-settings.ts create mode 100644 src/utils/random.ts create mode 100644 src/utils/status-manager.ts create mode 100644 src/utils/themes.ts create mode 100644 src/utils/voice/adapter.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e67c78 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Discord Bot Token and app id +DISCORD_TOKEN=your_discord_bot_token +APPLICATION_ID=your_application_id + +# Bot Configuration +# Message response chance (0-100) +MESSAGE_RESPONSE_CHANCE=10 + +# Voice channel settings +# Minimum time in seconds between voice channel joins +MIN_VOICE_JOIN_INTERVAL=300 +# Maximum time in seconds between voice channel joins +MAX_VOICE_JOIN_INTERVAL=1800 +# Minimum time in seconds to stay in a voice channel +MIN_VOICE_STAY_DURATION=60 +# Maximum time in seconds to stay in a voice channel +MAX_VOICE_STAY_DURATION=300 +# Minimum time in seconds between sounds in voice channel +MIN_VOICE_MEOW_INTERVAL=5 +# Maximum time in seconds between sounds in voice channel +MAX_VOICE_MEOW_INTERVAL=30 + +# Status update interval in seconds +STATUS_UPDATE_INTERVAL=300 + +# Default theme (cat, dog, fox, robot) +DEFAULT_THEME=cat + +# Default pronoun for bot actions (their, his, her, etc.) +CAT_PRONOUN=their + +# Audio files directory (optional) +# AUDIO_DIR=/path/to/audio/files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eca7cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# Environment variables and secrets +.env +.env.* +!.env.example + +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Logs +logs/ +*.log + +# Operating System Files +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Editor directories and files +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +*.sublime-project +*.sublime-workspace +*.swp +*.swo + +# Audio files +src/audio/*.mp3 +src/audio/*.wav +src/audio/*.ogg +!src/audio/README.txt + +# Data directory - guild settings +data/*.json + +# Keep data directory itself but ignore its contents +!data/.gitkeep + +# TypeScript cache +*.tsbuildinfo + +# Docker files +.dockerignore +docker-compose.override.yml + +# Testing +coverage/ +.nyc_output/ + +# Temp files +tmp/ +temp/ diff --git a/DOCKER-GUIDE.md b/DOCKER-GUIDE.md new file mode 100644 index 0000000..c185f61 --- /dev/null +++ b/DOCKER-GUIDE.md @@ -0,0 +1,133 @@ +# Docker Guide for Cat Bot + +This guide will help you run the Discord cat bot using Docker, which provides an isolated and consistent environment for the bot to run. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) installed on your system +- [Docker Compose](https://docs.docker.com/compose/install/) installed on your system +- A valid Discord bot token in your `.env` file + +## Setup + +1. Make sure your `.env` file is properly configured with your Discord bot token and other settings: + +``` +DISCORD_TOKEN=your_discord_bot_token + +# Message response chance (0-100) +MESSAGE_RESPONSE_CHANCE=10 + +# Voice channel settings +MIN_VOICE_JOIN_INTERVAL=300 +MAX_VOICE_JOIN_INTERVAL=1800 +MIN_VOICE_STAY_DURATION=60 +MAX_VOICE_STAY_DURATION=300 +MIN_VOICE_MEOW_INTERVAL=5 +MAX_VOICE_MEOW_INTERVAL=30 + +# Cat personalization +CAT_PRONOUN=their +``` + +2. Add your cat sound files to the `src/audio` directory (create it if it doesn't exist): + - meow1.mp3 + - meow2.mp3 + - meow3.mp3 + - purr.mp3 + - hiss.mp3 + - yowl.mp3 + +## Running the Bot + +### Easy Method + +Use the included helper script: + +```bash +# Make it executable +chmod +x run-docker.sh + +# Run it +./run-docker.sh +``` + +This script will set the correct user permissions, build, and run the container showing logs. + +### Manual Method + +From the project root directory: + +```bash +# Export your user ID and group ID +export UID=$(id -u) +export GID=$(id -g) + +# Start the container +docker-compose up -d + +# View logs +docker-compose logs -f +``` + +### Stopping the Bot + +To stop the bot: + +```bash +docker-compose down +``` + +## Updating the Bot + +If you make changes to the bot code: + +1. Stop the bot: `docker-compose down` +2. Rebuild the image: `docker-compose build` +3. Start the bot again: `docker-compose up -d` + +## Troubleshooting + +### Audio File Issues + +If the bot isn't playing sounds in voice channels: + +1. Check that your audio files exist in the `src/audio` directory +2. Verify they're in MP3 format and match the expected filenames +3. Look at the container logs to see if the files are being detected: + ```bash + docker-compose logs | grep "audio" + ``` + +### Bot Token Issues + +If the bot can't connect to Discord: + +1. Make sure your `.env` file is properly mounted in the container +2. Check that your token is valid and privileged intents are enabled +3. Verify logs for any connection errors: + ```bash + docker-compose logs + ``` + +### File Permission Issues + +If you encounter file permission problems: + +1. Use the `run-docker.sh` script which sets proper user IDs +2. Or manually set the user ID when running: + ```bash + export UID=$(id -u) GID=$(id -g) + docker-compose up -d + ``` + +### Container Not Starting + +If the container fails to start or continuously restarts: + +1. Check the logs: `docker-compose logs` +2. Verify your container has network access to reach Discord's servers +3. Make sure you have ffmpeg installed in the container: + ```bash + docker-compose exec cat-bot apk info | grep ffmpeg + ``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a37b128 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-alpine + +# Create app directory +WORKDIR /usr/src/app + +# Install ffmpeg for audio processing +RUN apk add --no-cache ffmpeg + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build TypeScript code +RUN npm run build + +# Create audio directory in dist and copy audio files +RUN mkdir -p dist/audio +RUN cp -r src/audio/* dist/audio/ 2>/dev/null || : + +# Make startup script executable +COPY docker-start.sh /usr/src/app/docker-start.sh +RUN chmod +x /usr/src/app/docker-start.sh + +# Set environment variables +ENV NODE_ENV=production +ENV AUDIO_DIR=/usr/src/app/dist/audio + +# Command to run the app +CMD ["/usr/src/app/docker-start.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c46eec --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +### Bot Setup in Discord Developer Portal + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) +2. Create a new application or select your existing one +3. Go to the "Bot" tab +4. Enable the following Privileged Gateway Intents: + - SERVER MEMBERS INTENT + - MESSAGE CONTENT INTENT +5. Save changes +6. Create an invite link: + - Go to OAuth2 > URL Generator + - Under "SCOPES", select "bot" and "applications.commands" + - Under "BOT PERMISSIONS", select: + - Read Messages/View Channels + - Send Messages + - Connect + - Speak + - Change Nickname + - Copy the generated URL and use it to invite your bot to servers + +# Multi-Theme Discord Bot + +A Discord bot that simulates various animal behaviors in your server! Choose from different themes like cats, dogs, foxes, or robots. + +## Features + +- Multiple themes to choose from (cat, dog, fox, robot) +- Randomly responds to messages with theme-specific vocalizations and actions +- Joins voice channels when people are active and plays themed sounds +- Fully configurable behavior (response chance, timing, pronoun preferences) +- Realistic animal-like unpredictable behavior +- Dynamic status messages that change regularly **and adapt based on voice channel presence** +- Per-server configuration (each server can have its own theme) +- Each theme has unique nicknames that the bot randomly assigns itself +- Bot automatically changes its Discord nickname to match the theme + +## Setup + +### Prerequisites + +- Node.js 16.9.0 or higher +- npm or yarn +- A Discord bot token (create one at the [Discord Developer Portal](https://discord.com/developers/applications)) + +### Installation + +1. Clone this repository +2. Install dependencies: + ``` + npm install + ``` + or + ``` + yarn install + ``` +3. Copy the example environment file: + ``` + cp .env.example .env + ``` +4. Edit the `.env` file and add your Discord bot token and configure other settings +5. Add cat sound files to the `src/audio` directory (MP3 format) +6. Build the project: + ``` + npm run build + ``` + or + ``` + yarn build + ``` +7. Start the bot: + ``` + npm start + ``` + or + ``` + yarn start + ``` + +### Required Bot Permissions + +When adding your bot to a Discord server, make sure it has the following permissions: +- Read Messages/View Channels +- Send Messages +- Connect to Voice Channels +- Speak in Voice Channels +- Change Nickname (for theme-based nicknames) + +### Adding Audio Files + +The bot expects audio files for each theme to be placed in the `src/audio` directory. The naming convention is: +`theme_sound.mp3` (e.g., cat_meow1.mp3, dog_bark1.mp3) + +Recommended files for each theme: + +**Cat Theme**: +- cat_meow1.mp3 +- cat_meow2.mp3 +- cat_meow3.mp3 +- cat_purr.mp3 +- cat_hiss.mp3 +- cat_yowl.mp3 + +**Dog Theme**: +- dog_bark1.mp3 +- dog_bark2.mp3 +- dog_whine.mp3 +- dog_pant.mp3 +- dog_growl.mp3 +- dog_howl.mp3 + +**Fox Theme**: +- fox_bark.mp3 +- fox_scream.mp3 +- fox_yip.mp3 +- fox_howl.mp3 +- fox_chirp.mp3 + +**Robot Theme**: +- robot_beep.mp3 +- robot_whir.mp3 +- robot_startup.mp3 +- robot_shutdown.mp3 +- robot_error.mp3 +- robot_process.mp3 + +## Configuration + +All bot settings can be adjusted in the `.env` file: + +| Setting | Description | Default | +|---------|-------------|---------| +| DISCORD_TOKEN | Your Discord bot token | (required) | +| APPLICATION_ID | Your application ID for slash commands | (required) | +| MESSAGE_RESPONSE_CHANCE | Percentage chance to respond to a message (0-100) | 10 | +| MIN_VOICE_JOIN_INTERVAL | Minimum time in seconds between voice channel joins | 300 | +| MAX_VOICE_JOIN_INTERVAL | Maximum time in seconds between voice channel joins | 1800 | +| MIN_VOICE_STAY_DURATION | Minimum time in seconds to stay in a voice channel | 60 | +| MAX_VOICE_STAY_DURATION | Maximum time in seconds to stay in a voice channel | 300 | +| MIN_VOICE_MEOW_INTERVAL | Minimum time in seconds between sounds in voice channel | 5 | +| MAX_VOICE_MEOW_INTERVAL | Maximum time in seconds between sounds in voice channel | 30 | +| STATUS_UPDATE_INTERVAL | How often (in seconds) the bot changes its status | 300 | +| DEFAULT_THEME | Default theme for new servers (cat, dog, fox, robot) | cat | +| CAT_PRONOUN | Default pronoun to use for actions (their, his, her, etc.) | their | + +## Slash Commands + +The bot provides slash commands for server administrators to customize its behavior: + +- `/theme set ` - Change the bot's theme (cat, dog, fox, robot) +- `/theme info` - View information about the current theme +- `/settings response ` - Set how often the bot responds (0-100%) +- `/settings pronoun ` - Set which pronoun the bot uses (their, his, her, etc.) +- `/settings view` - View current settings + +## Theme Nicknames + +Each time you change the theme, the bot randomly selects a nickname from the theme's list: + +**Cat Theme**: Whiskers, Mittens, Shadow, Luna, Oliver, Simba, Bella, Mr. Purrington, Fluffy, Captain Fuzzyboots + +**Dog Theme**: Buddy, Max, Bailey, Cooper, Daisy, Sir Barksalot, Fido, Rover, Scout, Captain Goodboy + +**Fox Theme**: Sly, Rusty, Firefox, Swift, Amber, Vixen, Todd, Reynard, Professor Pounce + +**Robot Theme**: B33P-B00P, CyberTron, Metal Friend, Unit-7, RoboCompanion, Circuit, T1000, BinaryBuddy, Mechanoid, SynthFriend + +## Voice-Aware Status Messages + +The bot changes its status messages depending on whether it's in a voice channel: + +### Cat Theme Example: +- **Normal:** 🐱 hunting mice, 🐱 plotting world domination, 🐱 napping in a sunbeam +- **In Voice Channel:** 🐱 purring loudly, 🐱 meowing for attention, 🐱 batting at microphones + +### Dog Theme Example: +- **Normal:** 🐶 chasing squirrels, 🐶 fetching balls, 🐶 guarding the house +- **In Voice Channel:** 🐶 barking excitedly, 🐶 panting into the mic, 🐶 howling along + +## License + +MIT diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0a798aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + cat-bot: + build: + context: . + dockerfile: Dockerfile + container_name: discord-cat-bot + restart: unless-stopped + user: "${UID:-1000}:${GID:-1000}" + volumes: + # Mount the audio directory so you can add/change sound files without rebuilding + - ./src/audio:/usr/src/app/dist/audio + # Mount the data directory for persistent settings + - ./data:/usr/src/app/data + # Mount the .env file for configuration + - ./.env:/usr/src/app/.env + environment: + - NODE_ENV=production + - AUDIO_DIR=/usr/src/app/dist/audio + # Simple healthcheck to ensure the container is running properly + healthcheck: + test: ["CMD", "node", "-e", "console.log('Health check')"] + interval: 60s + timeout: 10s + retries: 3 diff --git a/docker-start.sh b/docker-start.sh new file mode 100644 index 0000000..8f70667 --- /dev/null +++ b/docker-start.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +# Verify environment +echo "Starting Bot..." +echo "Node version: $(node -v)" +echo "Working directory: $(pwd)" + +# Ensure data directory exists +echo "Checking data directory for guild settings" +mkdir -p /usr/src/app/data + +# Check for audio files +echo "Checking audio directory: ${AUDIO_DIR}" +mkdir -p ${AUDIO_DIR} + +if [ -z "$(ls -A ${AUDIO_DIR} 2>/dev/null)" ]; then + echo "Warning: No audio files found in ${AUDIO_DIR}" + echo "The bot can still run, but won't play sounds in voice channels." +else + echo "Found audio files:" + ls -la ${AUDIO_DIR} +fi + +# Check for .env file +if [ ! -f .env ]; then + echo "Warning: No .env file found" +fi + +# Start the bot +echo "Starting bot..." +node dist/index.js diff --git a/make-executable.sh b/make-executable.sh new file mode 100644 index 0000000..f0366d5 --- /dev/null +++ b/make-executable.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Make scripts executable +chmod +x run-docker.sh +chmod +x docker-start.sh + +echo "Scripts are now executable!" diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f8a157 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "cat-bot", + "version": "1.0.0", + "description": "A Discord bot that behaves like a cat", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "debug": "ts-node src/debug.ts", + "build": "tsc", + "watch": "tsc -w" + }, + "keywords": [ + "discord", + "bot", + "cat", + "typescript" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@discordjs/builders": "^1.10.1", + "@discordjs/opus": "^0.10.0", + "@discordjs/voice": "^0.16.1", + "discord-api-types": "^0.37.119", + "discord.js": "^14.18.0", + "dotenv": "^16.0.3", + "ffmpeg-static": "^5.1.0", + "libsodium-wrappers": "^0.7.11" + }, + "devDependencies": { + "@types/node": "^18.16.0", + "ts-node": "^10.9.1", + "typescript": "^5.0.4" + } +} diff --git a/run-docker.sh b/run-docker.sh new file mode 100644 index 0000000..333188a --- /dev/null +++ b/run-docker.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Export current user ID and group ID to be used by docker-compose +export UID=$(id -u) +export GID=$(id -g) + +# Make sure audio directory exists +mkdir -p ./src/audio + +# Stop any existing container +docker-compose down + +# Build and start the container +docker-compose up -d + +# Show logs +docker-compose logs -f diff --git a/src/audio/README.txt b/src/audio/README.txt new file mode 100644 index 0000000..5c86e67 --- /dev/null +++ b/src/audio/README.txt @@ -0,0 +1,14 @@ +Add your cat sound MP3 files in this directory. + +Required files (matching the names in src/utils/cat-behaviors.ts): +- meow1.mp3 +- meow2.mp3 +- meow3.mp3 +- purr.mp3 +- hiss.mp3 +- yowl.mp3 + +You can find free cat sound effects on sites like: +- Freesound.org +- SoundBible.com +- ZapSplat.com diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..72fd032 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,14 @@ +import { Collection } from 'discord.js'; +import * as themeCommand from './theme'; +import * as settingsCommand from './settings'; + +// Collection of commands +export const commands = [ + themeCommand, + settingsCommand +]; + +// Map for quick command lookup +export const commandsMap = new Collection( + commands.map(command => [command.data.name, command]) +); diff --git a/src/commands/settings.ts b/src/commands/settings.ts new file mode 100644 index 0000000..1bcb7d7 --- /dev/null +++ b/src/commands/settings.ts @@ -0,0 +1,90 @@ +import { + CommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + ChatInputCommandInteraction +} from 'discord.js'; +import { guildSettings } from '../utils/guild-settings'; +import { getTheme } from '../utils/themes'; + +// Create the command builder +export const data = new SlashCommandBuilder() + .setName('settings') + .setDescription('Configure bot settings') + .addSubcommand(subcommand => + subcommand + .setName('response') + .setDescription('Set how often the bot responds to messages') + .addIntegerOption(option => + option + .setName('chance') + .setDescription('Chance of responding (0-100%)') + .setRequired(true) + .setMinValue(0) + .setMaxValue(100) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('pronoun') + .setDescription('Set which pronoun the bot uses') + .addStringOption(option => + option + .setName('pronoun') + .setDescription('The pronoun to use (their/his/her/etc)') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('view') + .setDescription('View current settings') + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild); + +// Command execution +export async function execute(interaction: CommandInteraction): Promise { + if (!interaction.guildId) { + await interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + return; + } + + const subcommand = (interaction as ChatInputCommandInteraction).options.getSubcommand(); + + if (subcommand === 'response') { + const chance = interaction.options.get('chance')?.value as number; + guildSettings.setResponseChance(interaction.guildId, chance); + + await interaction.reply({ + content: `Response chance set to ${chance}%`, + ephemeral: false + }); + } else if (subcommand === 'pronoun') { + const pronoun = interaction.options.get('pronoun')?.value as string; + guildSettings.setPronoun(interaction.guildId, pronoun); + + await interaction.reply({ + content: `Pronoun set to "${pronoun}"`, + ephemeral: false + }); + } else if (subcommand === 'view') { + const settings = guildSettings.getSettings(interaction.guildId); + const theme = getTheme(settings.themeId); + + const embed = new EmbedBuilder() + .setTitle(`${theme.emojiIcon} Bot Settings`) + .setColor('#3498db') + .addFields( + { name: 'Theme', value: theme.name, inline: true }, + { name: 'Response Chance', value: `${settings.responseChance}%`, inline: true }, + { name: 'Pronoun', value: settings.pronoun, inline: true } + ) + .setFooter({ text: 'Use /theme and /settings commands to change these settings' }); + + await interaction.reply({ + embeds: [embed], + ephemeral: false + }); + } +} diff --git a/src/commands/theme.ts b/src/commands/theme.ts new file mode 100644 index 0000000..aff900e --- /dev/null +++ b/src/commands/theme.ts @@ -0,0 +1,118 @@ +import { + CommandInteraction, + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + Guild, + CommandInteractionOptionResolver, + CacheType +} from 'discord.js'; +import { guildSettings } from '../utils/guild-settings'; +import { getAvailableThemeIds, getTheme, getRandomNickname } from '../utils/themes'; + +// Create the command builder +export const data = new SlashCommandBuilder() + .setName('theme') + .setDescription('Change or view the bot\'s theme') + .addSubcommand(subcommand => + subcommand + .setName('set') + .setDescription('Set the bot\'s theme') + .addStringOption(option => + option + .setName('theme') + .setDescription('The theme to set') + .setRequired(true) + .addChoices( + ...getAvailableThemeIds().map(id => ({ + name: getTheme(id).name, + value: id + })) + ) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('info') + .setDescription('Get information about the current theme') + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild); + +// Command execution +export async function execute(interaction: CommandInteraction): Promise { + if (!interaction.guildId) { + await interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }); + return; + } + + const subcommand = (interaction.options as CommandInteractionOptionResolver).getSubcommand(); + + if (subcommand === 'set') { + const themeId = interaction.options.get('theme')?.value as string; + + if (guildSettings.setTheme(interaction.guildId, themeId)) { + const theme = getTheme(themeId); + + // Get a random nickname for this theme + const nickname = getRandomNickname(themeId); + + // Try to update the bot's nickname + try { + const guild = interaction.guild as Guild; + const me = guild.members.cache.get(guild.client.user.id); + if (me && me.manageable) { + await me.setNickname(`${nickname} ${theme.emojiIcon}`); + } + } catch (error) { + console.error(`Failed to update nickname in guild ${interaction.guildId}:`, error); + } + + await interaction.reply({ + content: `${theme.emojiIcon} Theme changed to **${theme.name}**! I'll now act like a ${theme.name.toLowerCase()} named **${nickname}**.`, + ephemeral: false + }); + } else { + await interaction.reply({ + content: 'Failed to set theme. Please try again.', + ephemeral: true + }); + } + } else if (subcommand === 'info') { + const settings = guildSettings.getSettings(interaction.guildId); + const theme = getTheme(settings.themeId); + + const embed = new EmbedBuilder() + .setTitle(`${theme.emojiIcon} ${theme.name} Theme`) + .setDescription(`The bot is currently using the **${theme.name}** theme in this server.`) + .setColor('#3498db') + .addFields( + { name: 'Response Chance', value: `${settings.responseChance}%`, inline: true }, + { name: 'Pronoun', value: settings.pronoun, inline: true }, + { name: 'Example Vocalizations', value: theme.vocalizations.slice(0, 3).join('\n'), inline: false }, + { name: 'Example Actions', value: theme.actions.slice(0, 3).map(a => a.replace('PRONOUN', settings.pronoun)).join('\n'), inline: false } + ); + + const row = new ActionRowBuilder() + .addComponents( + ...getAvailableThemeIds() + .filter(id => id !== settings.themeId) + .slice(0, 5) // Maximum 5 buttons per row + .map(id => { + const buttonTheme = getTheme(id); + return new ButtonBuilder() + .setCustomId(`theme_${id}`) + .setLabel(`${buttonTheme.emojiIcon} ${buttonTheme.name}`) + .setStyle(ButtonStyle.Secondary); + }) + ); + + await interaction.reply({ + embeds: [embed], + components: [row], + ephemeral: false + }); + } +} diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 0000000..78b8d01 --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,53 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import { DEFAULT_THEME_ID } from '../utils/themes'; + +// Load environment variables +dotenv.config(); + +interface CatBotConfig { + token: string; + messageResponseChance: number; + voiceChannelConfig: { + minJoinInterval: number; + maxJoinInterval: number; + minStayDuration: number; + maxStayDuration: number; + minMeowInterval: number; + maxMeowInterval: number; + }; + catPersonalization: { + pronoun: string; + }; + audioFilesDir: string; + statusUpdateInterval: number; + defaultTheme: string; +} + +export const config: CatBotConfig = { + token: process.env.DISCORD_TOKEN || '', + messageResponseChance: Number(process.env.MESSAGE_RESPONSE_CHANCE || 10), + voiceChannelConfig: { + minJoinInterval: Number(process.env.MIN_VOICE_JOIN_INTERVAL || 300), + maxJoinInterval: Number(process.env.MAX_VOICE_JOIN_INTERVAL || 1800), + minStayDuration: Number(process.env.MIN_VOICE_STAY_DURATION || 60), + maxStayDuration: Number(process.env.MAX_VOICE_STAY_DURATION || 300), + minMeowInterval: Number(process.env.MIN_VOICE_MEOW_INTERVAL || 5), + maxMeowInterval: Number(process.env.MAX_VOICE_MEOW_INTERVAL || 30), + }, + catPersonalization: { + pronoun: process.env.CAT_PRONOUN || 'their', + }, + audioFilesDir: process.env.AUDIO_FILES_DIR || path.join(__dirname, '..', 'audio'), + statusUpdateInterval: Number(process.env.STATUS_UPDATE_INTERVAL || 300), // 5 minutes + defaultTheme: process.env.DEFAULT_THEME || DEFAULT_THEME_ID, +}; + +// Validate required configuration +if (!config.token) { + throw new Error('Missing Discord bot token in environment variables'); +} + +// Audio files directory - using environment variable or default path +const AUDIO_DIR = process.env.AUDIO_DIR || path.join(__dirname, '..', 'audio'); +export const AUDIO_FILES_DIR = AUDIO_DIR; diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..fd442b8 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,75 @@ +import { Client, Events, GatewayIntentBits, ActivityType } from 'discord.js'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +// Create a simplified client with all intents +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildPresences, // Add this too + ] +}); + +// Log detailed info when ready +client.once(Events.ClientReady, (readyClient) => { + console.log('==========================================='); + console.log(`Logged in as: ${readyClient.user.tag}`); + console.log(`Bot ID: ${readyClient.user.id}`); + console.log('==========================================='); + + // Set a visible activity status + readyClient.user.setActivity('Joining servers...', { type: ActivityType.Playing }); + + // Log guild membership + console.log(`Currently in ${client.guilds.cache.size} guilds:`); + + if (client.guilds.cache.size === 0) { + console.log('Not in any guilds! Please check your invite link.'); + + // Generate a proper invite link + const inviteURL = `https://discord.com/api/oauth2/authorize?client_id=${readyClient.user.id}&permissions=3214336&scope=bot%20applications.commands`; + console.log('\nUse this invite link:'); + console.log(inviteURL); + } else { + client.guilds.cache.forEach(guild => { + console.log(`- ${guild.name} (ID: ${guild.id}) - Members: ${guild.memberCount}`); + }); + } +}); + +// Add handler for guild joins to confirm when it works +client.on(Events.GuildCreate, (guild) => { + console.log(`✅ JOINED GUILD: ${guild.name} (${guild.id})`); + console.log(`Now in ${client.guilds.cache.size} guilds`); +}); + +// Log any errors +client.on(Events.Error, (error) => { + console.error('Discord client error:', error); +}); + +// Login with token +console.log('Attempting to log in...'); +const token = process.env.DISCORD_TOKEN; + +if (!token) { + console.error('ERROR: No Discord token found in .env file'); + process.exit(1); +} + +// Show first few characters of token for debugging (never show full token) +console.log(`Using token starting with: ${token.substring(0, 5)}...`); + +client.login(token) + .then(() => console.log('Login successful!')) + .catch(error => { + console.error('Failed to log in:', error.message); + console.error('Please check if your token is valid'); + process.exit(1); + }); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d1bd709 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,379 @@ +import { + Client, + Events, + GatewayIntentBits, + Message, + VoiceChannel, + ChannelType, + Interaction, + Guild, + GuildMember, + ButtonInteraction, + VoiceState +} from 'discord.js'; +import { config } from './config/config'; +import { getRandomInt, randomChance } from './utils/random'; +import { getRandomBehavior, getAudioFilePrefix } from './utils/bot-behaviors'; +import { getRandomNickname, getTheme } from './utils/themes'; +import { CatAudioPlayer } from './utils/audio-player'; +import { guildSettings } from './utils/guild-settings'; +import { deployCommands } from './utils/command-deployer'; +import { commandsMap } from './commands'; +import { StatusManager } from './utils/status-manager'; +import fs from 'fs'; +import path from 'path'; +import { AUDIO_FILES_DIR } from './config/config'; + +// Create a new client instance with required intents +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMembers, + ] +}); + +// Initialize audio player +const audioPlayer = new CatAudioPlayer(); + +// Create status manager +const statusManager = new StatusManager(client); + +// Map to track voice channel join timers +const voiceJoinTimers = new Map(); + +// Deploy commands when starting +(async () => { + try { + await deployCommands(); + } catch (error) { + console.error('Failed to deploy commands:', error); + } +})(); + +// Handle new guild joins +client.on(Events.GuildCreate, async (guild: Guild) => { + console.log(`Joined new guild: ${guild.name} (${guild.id})`); + try { + await guild.members.fetch(); + console.log(`Fetched ${guild.members.cache.size} members for new guild`); + + // Create default settings for this guild + const settings = guildSettings.getSettings(guild.id); + console.log(`Created settings for guild: ${guild.name} - Theme: ${settings.themeId}`); + + // Set a themed nickname + const theme = getTheme(settings.themeId); + const nickname = getRandomNickname(settings.themeId); + try { + const me = guild.members.cache.get(guild.client.user!.id); + if (me && me.manageable) { + await me.setNickname(`${nickname} ${theme.emojiIcon}`); + } + } catch (error) { + console.error(`Failed to set nickname in new guild ${guild.id}:`, error); + } + + // Update status to reflect new guild count + statusManager.startStatusRotation(); + + // Notify the server of the bot's presence + const defaultChannel = guild.channels.cache.find(channel => + channel.type === ChannelType.GuildText && + (channel.name.includes('general') || channel.name.includes('bot') || channel.name.includes('welcome')) + ); + + if (defaultChannel?.isTextBased()) { + defaultChannel.send( + `${getRandomBehavior(guild.id)}\n\nHi everyone! I'm **${nickname}**, a bot that behaves like a ${theme.name.toLowerCase()}! ` + + `${theme.emojiIcon} Use \`/theme\` to change what kind of animal I am, and \`/settings\` to configure my behavior!` + ); + } + } catch (error) { + console.error(`Error setting up new guild:`, error); + } +}); + +// Handle bot being removed from a guild +client.on(Events.GuildDelete, (guild: Guild) => { + console.log(`Removed from guild: ${guild.name} (${guild.id})`); + // Clear any timers for this guild + if (voiceJoinTimers.has(guild.id)) { + clearTimeout(voiceJoinTimers.get(guild.id)); + voiceJoinTimers.delete(guild.id); + } + + // Update status to reflect new guild count + statusManager.startStatusRotation(); +}); + +// Handle new members joining +client.on(Events.GuildMemberAdd, (member: GuildMember) => { + console.log(`New member joined: ${member.user.username} in ${member.guild.name}`); +}); + +// Handle voice state updates +client.on(Events.VoiceStateUpdate, (oldState: VoiceState, newState: VoiceState) => { + // Only interested in the bot's voice state + if (oldState.member?.user.id !== client.user?.id && newState.member?.user.id !== client.user?.id) { + return; + } + + // Check if the bot joined or left a voice channel + const wasInVoice = !!oldState.channel; + const isInVoice = !!newState.channel; + + // If voice state changed, update the status + if (wasInVoice !== isInVoice) { + console.log(`Bot ${isInVoice ? 'joined' : 'left'} a voice channel, updating status...`); + statusManager.startStatusRotation(); // This will force an immediate update + } +}); + +// When the client is ready, run this code (only once) +client.once(Events.ClientReady, async (readyClient: Client) => { + console.log(`Ready! Logged in as ${readyClient.user?.tag}`); + + // Check if audio directory exists, create it if not + if (!fs.existsSync(AUDIO_FILES_DIR)) { + fs.mkdirSync(AUDIO_FILES_DIR, { recursive: true }); + console.log('Created audio directory. Please add cat sound files to it.'); + } + + // Start status rotation + statusManager.startStatusRotation(); + + // Fetch members for all guilds + console.log(`Bot is in ${client.guilds.cache.size} guilds`); + for (const guild of client.guilds.cache.values()) { + try { + console.log(`Fetching members for guild: ${guild.name} (${guild.id})`); + await guild.members.fetch(); + console.log(`Successfully fetched ${guild.members.cache.size} members`); + + // Ensure guild has settings + const settings = guildSettings.getSettings(guild.id); + + // Set the bot's nickname based on theme + const theme = getTheme(settings.themeId); + const nickname = getRandomNickname(settings.themeId); + try { + const me = guild.members.cache.get(client.user!.id); + if (me && me.manageable) { + await me.setNickname(`${nickname} ${theme.emojiIcon}`); + console.log(`Set nickname to "${nickname} ${theme.emojiIcon}" in guild ${guild.name}`); + } + } catch (error) { + console.error(`Failed to set nickname in guild ${guild.name}:`, error); + } + } catch (error) { + console.error(`Error fetching members for guild ${guild.name}:`, error); + } + } + + // Start monitoring voice channels + startVoiceChannelMonitoring(); + + // List info about joined guilds + console.log("Bot Information:"); + client.guilds.cache.forEach((guild: Guild) => { + const settings = guildSettings.getSettings(guild.id); + console.log(`Guild: ${guild.name} - Theme: ${settings.themeId} - Response: ${settings.responseChance}%`); + }); +}); + +// Handle slash commands +client.on(Events.InteractionCreate, async (interaction: Interaction) => { + // Handle slash commands + if (interaction.isCommand()) { + const command = commandsMap.get(interaction.commandName); + + if (!command) return; + + try { + await command.execute(interaction); + } catch (error) { + console.error(`Error executing command ${interaction.commandName}:`, error); + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: 'There was an error executing this command!', ephemeral: true }); + } else { + await interaction.reply({ content: 'There was an error executing this command!', ephemeral: true }); + } + } + } + + // Handle button interactions + if (interaction.isButton()) { + const buttonInteraction = interaction as ButtonInteraction; + const customId = buttonInteraction.customId; + + // Handle theme change buttons + if (customId.startsWith('theme_')) { + const themeId = customId.split('_')[1]; + + if (interaction.guildId) { + if (guildSettings.setTheme(interaction.guildId, themeId)) { + const theme = getTheme(themeId); + const nickname = getRandomNickname(themeId); + + // Try to update the bot's nickname + try { + const guild = interaction.guild; + if (guild) { + const me = guild.members.cache.get(guild.client.user!.id); + if (me && me.manageable) { + await me.setNickname(`${nickname} ${theme.emojiIcon}`); + } + } + } catch (error) { + console.error(`Failed to update nickname in guild ${interaction.guildId}:`, error); + } + + await buttonInteraction.update({ + content: `Theme updated to **${theme.name}**! I'm now **${nickname}** ${theme.emojiIcon}`, + components: [] + }); + } else { + await buttonInteraction.update({ content: 'Failed to update theme', components: [] }); + } + } + } + } +}); + +// Handle messages +client.on(Events.MessageCreate, async (message: Message) => { + // Ignore bot messages + if (message.author.bot || !message.guildId) return; + + // Get response chance for this guild + const responseChance = guildSettings.getSettings(message.guildId).responseChance; + + // Random chance to respond based on guild settings + if (randomChance(responseChance)) { + try { + // Get a random behavior for this guild's theme + const response = getRandomBehavior(message.guildId); + + // Send the response + if (message.channel.isTextBased()) { + await message.reply(response); + } + } catch (error) { + console.error('Error responding to message:', error); + } + } +}); + +/** + * Start monitoring voice channels for activity + */ +function startVoiceChannelMonitoring(): void { + // Check voice channels regularly + setInterval(() => { + client.guilds.cache.forEach((guild: Guild) => { + // Find voice channels with members + const activeVoiceChannels = guild.channels.cache + .filter((channel) => + channel.isVoiceBased() && + 'members' in channel && + channel.members.size > 0 && + !channel.members.every((member: GuildMember) => member.user.bot) + ); + + if (activeVoiceChannels.size > 0) { + // Randomly pick a voice channel to potentially join + const channelID = guild.id; + + // If we don't have a timer for this guild, create one + if (!voiceJoinTimers.has(channelID)) { + const randomChannel = activeVoiceChannels.random(); + if (randomChannel && randomChannel.type === ChannelType.GuildVoice) { + scheduleNextVoiceChannelJoin(guild.id, randomChannel as VoiceChannel); + } + } + } + }); + }, 10000); // Check every 10 seconds +} + +/** + * Schedule the next voice channel join for a guild + */ +function scheduleNextVoiceChannelJoin(guildId: string, initialChannel?: VoiceChannel): void { + // Clear any existing timer + if (voiceJoinTimers.has(guildId)) { + const existingTimer = voiceJoinTimers.get(guildId); + if (existingTimer) { + clearTimeout(existingTimer); + } + } + + // Get random interval for next join + const nextJoinDelay = getRandomInt( + config.voiceChannelConfig.minJoinInterval, + config.voiceChannelConfig.maxJoinInterval + ) * 1000; + + // Schedule next join + const timer = setTimeout(async () => { + try { + const guild = client.guilds.cache.get(guildId); + + if (guild) { + // Find active voice channels + const activeVoiceChannels = guild.channels.cache + .filter((channel) => + channel.isVoiceBased() && + 'members' in channel && + channel.members.size > 0 && + !channel.members.every((member: GuildMember) => member.user.bot) + ); + + if (activeVoiceChannels.size > 0) { + // Choose a random active voice channel + const randomChannel = activeVoiceChannels.random(); + if (randomChannel && randomChannel.type === ChannelType.GuildVoice) { + const chosenChannel = randomChannel as VoiceChannel; + + // Get audio file prefix based on guild theme + const audioFilePrefix = getAudioFilePrefix(guildId); + + // Join and play sounds + await audioPlayer.joinChannel( + chosenChannel, + config.voiceChannelConfig.minStayDuration, + config.voiceChannelConfig.maxStayDuration, + config.voiceChannelConfig.minMeowInterval, + config.voiceChannelConfig.maxMeowInterval, + audioFilePrefix + ); + } + } + } + } catch (error) { + console.error('Error joining voice channel:', error); + } + + // Schedule next join + scheduleNextVoiceChannelJoin(guildId); + }, nextJoinDelay); + + // Store the timer + voiceJoinTimers.set(guildId, timer); + + const guildName = client.guilds.cache.get(guildId)?.name || 'Unknown Guild'; + const channelName = initialChannel ? initialChannel.name : 'any active channel'; + console.log(`Scheduled voice channel join for guild ${guildName} (${guildId}) in ${nextJoinDelay / 1000} seconds`); + if (initialChannel) { + console.log(`Initial channel: ${initialChannel.name}`); + } else { + console.log(`No initial channel specified, will join any active channel`); + } +} + +// Log in to Discord with your token +client.login(config.token); diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts new file mode 100644 index 0000000..7add088 --- /dev/null +++ b/src/utils/audio-player.ts @@ -0,0 +1,162 @@ +import { + AudioPlayer, + AudioPlayerStatus, + createAudioPlayer, + createAudioResource, + getVoiceConnection, + joinVoiceChannel, + VoiceConnectionStatus, + NoSubscriberBehavior +} from '@discordjs/voice'; +import { VoiceChannel } from 'discord.js'; +import path from 'path'; +import fs from 'fs'; +import { AUDIO_FILES_DIR } from '../config/config'; +import { getRandomElement, getRandomInt, sleep } from './random'; +import { CAT_AUDIO_FILES } from './cat-behaviors'; + +export class CatAudioPlayer { + private audioPlayer: AudioPlayer; + private isPlaying: boolean = false; + private shouldStop: boolean = false; + + constructor() { + this.audioPlayer = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Pause, + } + }); + + this.audioPlayer.on(AudioPlayerStatus.Idle, () => { + this.isPlaying = false; + }); + } + + /** + * Join a voice channel and start playing cat sounds + */ + public async joinChannel( + voiceChannel: VoiceChannel, + minStayDuration: number, + maxStayDuration: number, + minMeowInterval: number, + maxMeowInterval: number, + audioFilePrefix: string = '' + ): Promise { + try { + // Create connection - using Discord.js's built-in adapter + // The @ts-ignore is required because of type compatibility issues between Discord.js and @discordjs/voice + // This is a common issue and is safe to ignore in this case + const connection = joinVoiceChannel({ + channelId: voiceChannel.id, + guildId: voiceChannel.guild.id, + // @ts-ignore: Type compatibility issue between Discord.js and @discordjs/voice + adapterCreator: voiceChannel.guild.voiceAdapterCreator, + }); + + // Handle connection events + connection.on(VoiceConnectionStatus.Disconnected, async () => { + try { + // Try to reconnect if disconnected unexpectedly + await Promise.race([ + new Promise(resolve => connection.on(VoiceConnectionStatus.Ready, resolve)), + new Promise((_, reject) => setTimeout(reject, 5000)) + ]); + } catch (error) { + // If we can't reconnect within 5 seconds, destroy the connection + connection.destroy(); + } + }); + + // Subscribe the connection to the audio player + connection.subscribe(this.audioPlayer); + + // Reset stop flag + this.shouldStop = false; + + // Determine how long the cat will stay + const stayDuration = getRandomInt(minStayDuration, maxStayDuration) * 1000; + + console.log(`Cat joined ${voiceChannel.name} for ${stayDuration / 1000} seconds`); + + // Start playing sounds + this.playRandomCatSounds(minMeowInterval, maxMeowInterval, stayDuration, audioFilePrefix); + + // Leave after duration + setTimeout(() => { + this.shouldStop = true; + + // Wait for any current sound to finish + setTimeout(() => { + const connection = getVoiceConnection(voiceChannel.guild.id); + if (connection) { + connection.destroy(); + console.log(`Cat left ${voiceChannel.name}`); + } + }, 1000); + }, stayDuration); + + } catch (error) { + console.error('Error joining voice channel:', error); + } + } + + /** + * Play random cat sounds at random intervals + */ + private async playRandomCatSounds( + minInterval: number, + maxInterval: number, + maxDuration: number, + audioFilePrefix: string = '' + ): Promise { + let elapsedTime = 0; + + while (elapsedTime < maxDuration && !this.shouldStop) { + // Play a random cat sound + if (!this.isPlaying) { + await this.playRandomSound(audioFilePrefix); + } + + // Wait for a random interval + const interval = getRandomInt(minInterval, maxInterval) * 1000; + await sleep(interval); + elapsedTime += interval; + } + } + + /** + * Play a random cat sound file + */ + private async playRandomSound(audioFilePrefix: string = ''): Promise { + try { + // Filter audio files by prefix if provided + let availableFiles = CAT_AUDIO_FILES; + + if (audioFilePrefix) { + const prefixedFiles = CAT_AUDIO_FILES.filter(file => file.startsWith(audioFilePrefix)); + if (prefixedFiles.length > 0) { + availableFiles = prefixedFiles; + } + } + + // Get a random audio file + const audioFile = getRandomElement(availableFiles); + const audioPath = path.join(AUDIO_FILES_DIR, audioFile); + + // Check if the file exists + if (!fs.existsSync(audioPath)) { + console.warn(`Audio file not found: ${audioPath}`); + return; + } + + // Create and play the audio resource + const resource = createAudioResource(audioPath); + this.isPlaying = true; + this.audioPlayer.play(resource); + } catch (error) { + console.error('Error playing audio:', error); + this.isPlaying = false; + } + } +} diff --git a/src/utils/bot-behaviors.ts b/src/utils/bot-behaviors.ts new file mode 100644 index 0000000..ad36a5d --- /dev/null +++ b/src/utils/bot-behaviors.ts @@ -0,0 +1,67 @@ +import { getRandomElement } from './random'; +import { guildSettings } from './guild-settings'; +import { getTheme } from './themes'; + +/** + * Get a random vocalization or action for a guild + */ +export function getRandomBehavior(guildId: string): string { + // Get guild settings + const settings = guildSettings.getSettings(guildId); + + // Get theme + const theme = getTheme(settings.themeId); + + // 50/50 chance of vocalization vs action + if (Math.random() < 0.5) { + return getRandomElement(theme.vocalizations); + } else { + // Replace PRONOUN placeholder with the guild's pronoun setting + const action = getRandomElement(theme.actions); + return `*${action.replace('PRONOUN', settings.pronoun)}*`; + } +} + +/** + * Get audio file prefix for a guild based on its theme + */ +export function getAudioFilePrefix(guildId: string): string { + const settings = guildSettings.getSettings(guildId); + const theme = getTheme(settings.themeId); + return theme.audioFilePrefix; +} + +/** + * Get response chance for a guild + */ +export function getResponseChance(guildId: string): number { + const settings = guildSettings.getSettings(guildId); + return settings.responseChance; +} + +/** + * Get a random status message to display + */ +export function getRandomStatusMessage(totalGuilds: number): string { + // Get a random theme for variety + const themeIds = Object.keys(getTheme('')); + const randomThemeId = getRandomElement(themeIds); + const theme = getTheme(randomThemeId); + + // Get a random status message + let statusMessage = getRandomElement(theme.statusMessages.normal); + + // Replace GUILD_COUNT placeholder with actual count + statusMessage = statusMessage.replace('GUILD_COUNT', totalGuilds.toString()); + + return statusMessage; +} + +/** + * Get the emoji icon for a guild's theme + */ +export function getThemeEmoji(guildId: string): string { + const settings = guildSettings.getSettings(guildId); + const theme = getTheme(settings.themeId); + return theme.emojiIcon; +} diff --git a/src/utils/cat-behaviors.ts b/src/utils/cat-behaviors.ts new file mode 100644 index 0000000..a055e99 --- /dev/null +++ b/src/utils/cat-behaviors.ts @@ -0,0 +1,132 @@ +import { config } from '../config/config'; +import { getRandomElement } from './random'; +import fs from 'fs'; +import path from 'path'; + +// Default audio filenames if no files are found in the directory +export const DEFAULT_AUDIO_FILES = [ + // Cat sounds + 'cat_meow1.mp3', + 'cat_meow2.mp3', + 'cat_meow3.mp3', + 'cat_purr.mp3', + 'cat_hiss.mp3', + 'cat_yowl.mp3', + + // Dog sounds + 'dog_bark1.mp3', + 'dog_bark2.mp3', + 'dog_whine.mp3', + 'dog_pant.mp3', + 'dog_growl.mp3', + 'dog_howl.mp3', + + // Fox sounds + 'fox_bark.mp3', + 'fox_scream.mp3', + 'fox_yip.mp3', + 'fox_howl.mp3', + 'fox_chirp.mp3', + + // Robot sounds + 'robot_beep.mp3', + 'robot_whir.mp3', + 'robot_startup.mp3', + 'robot_shutdown.mp3', + 'robot_error.mp3', + 'robot_process.mp3', +]; + +// Gather audio files from the directory +export const CAT_AUDIO_FILES = gatherAudioFiles(config.audioFilesDir); +console.log(`Found ${CAT_AUDIO_FILES.length} audio files in ${config.audioFilesDir}`); + +/** + * Gather all audio files from a directory + */ +function gatherAudioFiles(audioPath: string): string[] { + try { + // Read all files in the audio directory + if (fs.existsSync(audioPath)) { + const files = fs.readdirSync(audioPath); + const audioFiles = files.filter((file) => { + const ext = file.split('.').pop()?.toLowerCase(); + return ext === 'wav' || ext === 'mp3' || ext === 'ogg'; + }); + + if (audioFiles.length > 0) { + return audioFiles; + } + } + } catch (error) { + console.warn(`Failed to read audio directory ${audioPath}:`, error); + } + + // Return default audio files if none found + console.warn('No audio files found, using default filenames. Please add audio files to the audio directory.'); + return DEFAULT_AUDIO_FILES; +} + +/** + * Generate sample audio files with README + * This is useful for helping users set up the audio files + */ +export function generateSampleAudioInfo(directory: string): void { + try { + // Create directory if it doesn't exist + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + + // Create a README file explaining the audio files + const readmePath = path.join(directory, 'README.txt'); + const readmeContent = `Audio Files for the Discord Bot + +This directory should contain audio files for different themes. +Naming convention: theme_sound.mp3 (e.g., cat_meow1.mp3, dog_bark1.mp3) + +Recommended files for each theme: + +CAT THEME: +- cat_meow1.mp3 +- cat_meow2.mp3 +- cat_meow3.mp3 +- cat_purr.mp3 +- cat_hiss.mp3 +- cat_yowl.mp3 + +DOG THEME: +- dog_bark1.mp3 +- dog_bark2.mp3 +- dog_whine.mp3 +- dog_pant.mp3 +- dog_growl.mp3 +- dog_howl.mp3 + +FOX THEME: +- fox_bark.mp3 +- fox_scream.mp3 +- fox_yip.mp3 +- fox_howl.mp3 +- fox_chirp.mp3 + +ROBOT THEME: +- robot_beep.mp3 +- robot_whir.mp3 +- robot_startup.mp3 +- robot_shutdown.mp3 +- robot_error.mp3 +- robot_process.mp3 + +You can find free animal sound effects on sites like: +- Freesound.org +- SoundBible.com +- ZapSplat.com +`; + + fs.writeFileSync(readmePath, readmeContent); + console.log(`Created audio README at ${readmePath}`); + } catch (error) { + console.error('Error creating sample audio info:', error); + } +} diff --git a/src/utils/command-deployer.ts b/src/utils/command-deployer.ts new file mode 100644 index 0000000..6c32404 --- /dev/null +++ b/src/utils/command-deployer.ts @@ -0,0 +1,31 @@ +import { REST, Routes } from 'discord.js'; +import { commands } from '../commands'; +import { config } from '../config/config'; + +/** + * Deploy slash commands to Discord + */ +export async function deployCommands(): Promise { + if (!config.token) { + throw new Error('Bot token not found in .env file'); + } + + const rest = new REST({ version: '10' }).setToken(config.token); + + try { + console.log('Started refreshing application (/) commands...'); + + // Extract command data + const commandData = commands.map(command => command.data.toJSON()); + + // Register commands globally + await rest.put( + Routes.applicationCommands(process.env.APPLICATION_ID || ''), + { body: commandData } + ); + + console.log('Successfully reloaded application (/) commands!'); + } catch (error) { + console.error('Error deploying commands:', error); + } +} diff --git a/src/utils/guild-settings.ts b/src/utils/guild-settings.ts new file mode 100644 index 0000000..cfe8aa2 --- /dev/null +++ b/src/utils/guild-settings.ts @@ -0,0 +1,123 @@ +import fs from 'fs'; +import path from 'path'; +import { config } from '../config/config'; +import { DEFAULT_THEME_ID, getTheme } from './themes'; + +// Define the structure of guild settings +interface GuildSettings { + id: string; + themeId: string; + pronoun: string; + responseChance: number; +} + +// Class to manage guild settings +export class GuildSettingsManager { + private settings: Map = new Map(); + private settingsFilePath: string; + + constructor(storagePath: string = path.join(__dirname, '..', '..', 'data')) { + // Ensure storage directory exists + if (!fs.existsSync(storagePath)) { + fs.mkdirSync(storagePath, { recursive: true }); + } + + this.settingsFilePath = path.join(storagePath, 'guild-settings.json'); + this.loadSettings(); + } + + /** + * Load settings from file + */ + private loadSettings(): void { + try { + if (fs.existsSync(this.settingsFilePath)) { + const data = fs.readFileSync(this.settingsFilePath, 'utf8'); + const settingsArray: GuildSettings[] = JSON.parse(data); + + for (const guild of settingsArray) { + this.settings.set(guild.id, guild); + } + + console.log(`Loaded settings for ${this.settings.size} guilds`); + } + } catch (error) { + console.error('Error loading guild settings:', error); + } + } + + /** + * Save settings to file + */ + private saveSettings(): void { + try { + const settingsArray = Array.from(this.settings.values()); + fs.writeFileSync(this.settingsFilePath, JSON.stringify(settingsArray, null, 2)); + } catch (error) { + console.error('Error saving guild settings:', error); + } + } + + /** + * Get settings for a guild, creating default settings if none exist + */ + getSettings(guildId: string): GuildSettings { + if (!this.settings.has(guildId)) { + const defaultSettings: GuildSettings = { + id: guildId, + themeId: config.defaultTheme, + pronoun: config.catPersonalization.pronoun, + responseChance: config.messageResponseChance, + }; + + this.settings.set(guildId, defaultSettings); + this.saveSettings(); + } + + return this.settings.get(guildId)!; + } + + /** + * Set the theme for a guild + */ + setTheme(guildId: string, themeId: string): boolean { + const settings = this.getSettings(guildId); + + // Validate theme ID + const theme = getTheme(themeId); + if (!theme) return false; + + settings.themeId = themeId; + settings.pronoun = theme.defaultPronoun; + this.saveSettings(); + return true; + } + + /** + * Set the pronoun for a guild + */ + setPronoun(guildId: string, pronoun: string): void { + const settings = this.getSettings(guildId); + settings.pronoun = pronoun; + this.saveSettings(); + } + + /** + * Set the response chance for a guild + */ + setResponseChance(guildId: string, chance: number): void { + const settings = this.getSettings(guildId); + settings.responseChance = Math.max(0, Math.min(100, chance)); + this.saveSettings(); + } + + /** + * Get an array of all guilds and their settings + */ + getAllSettings(): GuildSettings[] { + return Array.from(this.settings.values()); + } +} + +// Create a singleton instance +export const guildSettings = new GuildSettingsManager(); diff --git a/src/utils/random.ts b/src/utils/random.ts new file mode 100644 index 0000000..b4e6d0f --- /dev/null +++ b/src/utils/random.ts @@ -0,0 +1,27 @@ +/** + * Returns a random integer between min (inclusive) and max (inclusive) + */ +export function getRandomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Returns true with the given probability (0-100) + */ +export function randomChance(probability: number): boolean { + return Math.random() * 100 < probability; +} + +/** + * Waits for a specified number of milliseconds + */ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Returns a random element from an array + */ +export function getRandomElement(array: T[]): T { + return array[Math.floor(Math.random() * array.length)]; +} diff --git a/src/utils/status-manager.ts b/src/utils/status-manager.ts new file mode 100644 index 0000000..5ec0f27 --- /dev/null +++ b/src/utils/status-manager.ts @@ -0,0 +1,112 @@ +import { ActivityType, Client } from 'discord.js'; +import { config } from '../config/config'; +import { getRandomElement } from './random'; +import { getAvailableThemeIds, getTheme } from './themes'; + +export class StatusManager { + private client: Client; + private statusUpdateInterval: NodeJS.Timeout | null = null; + + constructor(client: Client) { + this.client = client; + } + + /** + * Start rotating status messages + */ + public startStatusRotation(): void { + // Clear any existing interval + if (this.statusUpdateInterval) { + clearInterval(this.statusUpdateInterval); + } + + // Update status immediately + this.updateStatus(); + + // Set interval for future updates + this.statusUpdateInterval = setInterval(() => { + this.updateStatus(); + }, config.statusUpdateInterval * 1000); + + console.log(`Status rotation enabled, updating every ${config.statusUpdateInterval} seconds`); + } + + /** + * Stop rotating status messages + */ + public stopStatusRotation(): void { + if (this.statusUpdateInterval) { + clearInterval(this.statusUpdateInterval); + this.statusUpdateInterval = null; + console.log('Status rotation disabled'); + } + } + + /** + * Check if the bot is currently in any voice channel + */ + private isInVoiceChannel(): boolean { + if (!this.client.guilds) return false; + + // Check if the bot is in a voice channel in any guild + for (const guild of this.client.guilds.cache.values()) { + const member = guild.members.cache.get(this.client.user?.id || ''); + if (member?.voice.channel) { + return true; + } + } + + return false; + } + + /** + * Update the bot's status message + */ + private updateStatus(): void { + if (!this.client.user) return; + + // Get guild count + const guildCount = this.client.guilds.cache.size; + + // Check if the bot is in a voice channel + const inVoiceChannel = this.isInVoiceChannel(); + + // Randomly select a theme + const themeIds = getAvailableThemeIds(); + const randomThemeId = getRandomElement(themeIds); + const theme = getTheme(randomThemeId); + + // Get a random status message based on voice channel status + const statusList = inVoiceChannel ? theme.statusMessages.inVoice : theme.statusMessages.normal; + let statusMessage = getRandomElement(statusList); + + // Replace guild count placeholder + statusMessage = statusMessage.replace('GUILD_COUNT', guildCount.toString()); + + // Add the theme emoji to the status + statusMessage = `${theme.emojiIcon} ${statusMessage}`; + + // Choose a random activity type + const activityTypes = [ + ActivityType.Playing, + ActivityType.Watching, + ActivityType.Listening, + ActivityType.Competing + ]; + const activityType = getRandomElement(activityTypes); + + // Set the new activity + this.client.user.setActivity(statusMessage, { type: activityType }); + console.log(`Status updated (${inVoiceChannel ? 'in voice' : 'normal'}): ${ActivityType[activityType]} ${statusMessage}`); + } + + /** + * Set a specific status + */ + public setStatus(message: string, type: ActivityType = ActivityType.Playing): void { + if (!this.client.user) return; + + this.client.user.setActivity(message, { type }); + console.log(`Status set: ${ActivityType[type]} ${message}`); + } +} diff --git a/src/utils/themes.ts b/src/utils/themes.ts new file mode 100644 index 0000000..ed4ae38 --- /dev/null +++ b/src/utils/themes.ts @@ -0,0 +1,334 @@ +/** + * Themes for the bot to use on different servers + */ + +export interface ThemeConfig { + name: string; + nicknames: string[]; + vocalizations: string[]; + actions: string[]; + audioFilePrefix: string; + statusMessages: { + normal: string[]; + inVoice: string[]; + }; + emojiIcon: string; + defaultPronoun: string; +} + +// Map of all available themes +export const availableThemes: Record = { + cat: { + name: 'Cat', + nicknames: [ + 'Shadow', + 'Luna', + 'Oliver', + 'Simba', + ], + vocalizations: [ + 'Meow!', + 'Mrow?', + 'Purrrrrrrr...', + 'Mew!', + 'Mrrrp!', + 'Hisssss!', + 'Yowl!', + '*scratches furniture*', + 'Mrrrowww!', + 'Prrrp?', + ], + actions: [ + 'bumps PRONOUN head against', + 'rubs against', + 'walks across', + 'sits on', + 'ignores', + 'stares at', + 'swats at', + 'rolls over in front of', + 'purrs at', + 'blinks slowly at', + 'climbs on', + 'makes biscuits on', + 'knocks over', + 'brings a gift to', + ], + audioFilePrefix: 'cat_', + statusMessages: { + normal: [ + 'hunting mice', + 'napping in a sunbeam', + 'plotting world domination', + 'judging everyone silently', + 'knocking things off shelves', + 'demanding treats', + 'chasing laser pointers', + 'prowling around GUILD_COUNT servers', + 'ignoring your calls', + 'climbing curtains', + ], + inVoice: [ + 'purring loudly', + 'meowing for attention', + 'playing with yarn', + 'scratching at the mic', + 'hissing at strangers', + 'listening attentively', + 'batting at microphones', + 'curled up sleeping nearby', + 'stalking prey in voice chat', + 'knocking things over in voice chat', + ] + }, + emojiIcon: '🐱', + defaultPronoun: 'their', + }, + /** + + dog: { + name: 'Dog', + nicknames: [ + 'Buddy', + 'Max', + 'Bailey', + 'Cooper', + 'Daisy', + 'Sir Barksalot', + 'Fido', + 'Rover', + 'Scout', + 'Captain Goodboy' + ], + vocalizations: [ + 'Woof!', + 'Bark!', + 'Arf arf!', + '*pants excitedly*', + 'Awoooo!', + '*sniffs*', + 'Grrrr...', + 'Ruff!', + '*tail thumping*', + 'Yip!', + ], + actions: [ + 'licks', + 'brings a ball to', + 'zooms past', + 'wags PRONOUN tail at', + 'sniffs', + 'rolls over for', + 'jumps up on', + 'follows', + 'tilts PRONOUN head at', + 'plays bow to', + 'protects', + 'hides PRONOUN toy from', + 'begs for treats from', + 'howls with', + ], + audioFilePrefix: 'dog_', + statusMessages: { + normal: [ + 'chasing squirrels', + 'fetching balls', + 'digging holes', + 'wagging on GUILD_COUNT servers', + 'begging for treats', + 'being a good boy/girl', + 'barking at mailmen', + 'guarding the house', + 'stealing socks', + 'zooming around', + ], + inVoice: [ + 'barking excitedly', + 'howling along', + 'panting into the mic', + 'chewing on headphones', + 'whining for attention', + 'tilting head at sounds', + 'listening for treats', + 'sniffing the voice channel', + 'playing fetch in voice chat', + 'drooling on the microphone', + ] + }, + emojiIcon: '🐶', + defaultPronoun: 'their', + }, + + fox: { + name: 'Fox', + nicknames: [ + 'Sly', + 'Rusty', + 'Firefox', + 'Swift', + 'Amber', + 'Vixen', + 'Todd', + 'Reynard', + 'Firefox', + 'Professor Pounce' + ], + vocalizations: [ + 'Yap!', + 'Ring-ding-ding-ding-dingeringeding!', + 'Wa-pa-pa-pa-pa-pa-pow!', + 'Hatee-hatee-hatee-ho!', + 'Joff-tchoff-tchoffo-tchoffo-tchoff!', + '*chirps*', + '*screams*', + 'Gerringding!', + 'Fraka-kaka-kaka-kaka-kow!', + 'A-hee-ahee ha-hee!', + ], + actions: [ + 'pounces at', + 'digs near', + 'perks PRONOUN ears at', + 'sneaks around', + 'hides from', + 'plays with', + 'steals from', + 'yips at', + 'observes curiously', + 'trots past', + 'hunts near', + 'flicks PRONOUN tail at', + 'leaps over', + 'scurries around', + ], + audioFilePrefix: 'fox_', + statusMessages: { + normal: [ + 'being elusive', + 'saying what the fox says', + 'outfoxing everyone', + 'being fantastic', + 'raiding chicken coops', + 'skulking in GUILD_COUNT servers', + 'causing mischief', + 'jumping in snow', + 'hiding treasures', + 'being sly', + ], + inVoice: [ + 'yipping at voices', + 'making weird fox noises', + 'rustling around in bushes', + 'screaming suddenly', + 'watching chat quietly', + 'chittering to self', + 'pouncing on voice activity', + 'digging into the voice channel', + 'stealing voice channel items', + 'trotting through voice chat', + ] + }, + emojiIcon: '🦊', + defaultPronoun: 'their', + }, + + robot: { + name: 'Robot', + nicknames: [ + 'B33P-B00P', + 'CyberTron', + 'Metal Friend', + 'Unit-7', + 'RoboCompanion', + 'Circuit', + 'T1000', + 'BinaryBuddy', + 'Mechanoid', + 'SynthFriend' + ], + vocalizations: [ + 'Beep!', + 'Boop!', + '*whirring noises*', + '01001000 01101001!', + '*scanning*', + 'COMPUTING...', + '*mechanical clicking*', + 'SYSTEM ONLINE', + 'EXECUTING PROTOCOL', + '*powers down*', + ], + actions: [ + 'analyzes', + 'scans', + 'observes', + 'processes data from', + 'follows', + 'records', + 'calculates trajectory of', + 'extends PRONOUN robotic arm to', + 'powers up near', + 'boots up beside', + 'downloads data from', + 'upgrades', + 'orbits around', + 'calibrates sensors for', + ], + audioFilePrefix: 'robot_', + statusMessages: { + normal: [ + 'computing pi', + 'executing protocols', + 'installing updates', + 'monitoring GUILD_COUNT servers', + 'analyzing human behavior', + 'charging batteries', + 'backing up data', + 'scanning for threats', + 'processing inputs', + 'learning humanity', + ], + inVoice: [ + 'recording conversations', + 'analyzing audio patterns', + 'detecting voice signatures', + 'calibrating audio sensors', + 'running voice recognition', + 'optimizing audio codec', + 'calculating audio spectrum', + 'diagnosing audio hardware', + 'decoding speech patterns', + 'extending audio receptors', + ] + }, + emojiIcon: '🤖', + defaultPronoun: 'its', + }, + + */ +}; + +// Default theme +export const DEFAULT_THEME_ID = 'cat'; + +/** + * Returns a theme by ID or the default theme if not found + */ +export function getTheme(themeId: string): ThemeConfig { + return availableThemes[themeId] || availableThemes[DEFAULT_THEME_ID]; +} + +/** + * Returns a list of all available theme IDs + */ +export function getAvailableThemeIds(): string[] { + return Object.keys(availableThemes); +} + +/** + * Returns a random nickname for a theme + */ +export function getRandomNickname(themeId: string): string { + const theme = getTheme(themeId); + return theme.nicknames[Math.floor(Math.random() * theme.nicknames.length)]; +} diff --git a/src/utils/voice/adapter.ts b/src/utils/voice/adapter.ts new file mode 100644 index 0000000..f6e2567 --- /dev/null +++ b/src/utils/voice/adapter.ts @@ -0,0 +1,11 @@ +import { Guild } from 'discord.js'; +import { DiscordGatewayAdapterCreator } from '@discordjs/voice'; + +/** + * Creates an adapter for Discord voice connections. + * This handles the compatibility between Discord.js and @discordjs/voice + */ +export function createDiscordJsAdapter(guild: Guild): DiscordGatewayAdapterCreator { + // @ts-ignore: The types between discord.js and @discordjs/voice are not fully compatible + return guild.voiceAdapterCreator; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..030027f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.spec.ts"] +}