Initial push

main
Talon 2025-03-31 20:07:57 +02:00
commit 581c6a074b
28 changed files with 2323 additions and 0 deletions

33
.env.example 100644
View File

@ -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

67
.gitignore vendored 100644
View File

@ -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/

133
DOCKER-GUIDE.md 100644
View File

@ -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
```

34
Dockerfile 100644
View File

@ -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"]

181
README.md 100644
View File

@ -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 <theme>` - Change the bot's theme (cat, dog, fox, robot)
- `/theme info` - View information about the current theme
- `/settings response <chance>` - Set how often the bot responds (0-100%)
- `/settings pronoun <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

0
data/.gitkeep 100644
View File

26
docker-compose.yml 100644
View File

@ -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

31
docker-start.sh 100644
View File

@ -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

View File

@ -0,0 +1,7 @@
#!/bin/bash
# Make scripts executable
chmod +x run-docker.sh
chmod +x docker-start.sh
echo "Scripts are now executable!"

36
package.json 100644
View File

@ -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"
}
}

17
run-docker.sh 100644
View File

@ -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

View File

@ -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

View File

@ -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])
);

View File

@ -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<void> {
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
});
}
}

View File

@ -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<void> {
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<CacheType>).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<ButtonBuilder>()
.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
});
}
}

View File

@ -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;

75
src/debug.ts 100644
View File

@ -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);
});

379
src/index.ts 100644
View File

@ -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<string, NodeJS.Timeout>();
// 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);

View File

@ -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<void> {
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<void> {
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<void> {
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;
}
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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<void> {
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);
}
}

View File

@ -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<string, GuildSettings> = 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();

View File

@ -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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Returns a random element from an array
*/
export function getRandomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}

View File

@ -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}`);
}
}

334
src/utils/themes.ts 100644
View File

@ -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<string, ThemeConfig> = {
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)];
}

View File

@ -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;
}

16
tsconfig.json 100644
View File

@ -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"]
}