diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5397e10 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Discord Bot Environment Variables Configuration +# Copy this file to .env and fill in your values +# The .env file will be automatically loaded by docker-compose + +# ============================================ +# Discord Configuration (REQUIRED) +# ============================================ +DISCORD_TOKEN=your_bot_token_here +DISCORD_ADMIN_IDS=123456789012345678,987654321098765432 + +# ============================================ +# Database Configuration +# ============================================ +DB_HOST=localhost +DB_USERNAME=your_db_username +DB_PASSWORD=your_db_password +DB_NAME=Pycharm + +# ============================================ +# Voice API Configuration (Optional) +# ============================================ +# VOICE_API_URL=http://localhost:8000 + +# ============================================ +# Notes: +# ============================================ +# - Environment variables override values in info.json +# - DISCORD_TOKEN is required (either here or in info.json) +# - DISCORD_ADMIN_IDS should be comma-separated Discord user IDs (integers) +# - Database settings are optional if configured in info.json +# - To get your Discord user ID: +# 1. Enable Developer Mode in Discord Settings > Advanced +# 2. Right-click your username and select "Copy ID" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43b8da7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +PlopBot is a Discord bot with soundboard and text-to-speech capabilities, built using discord.py. It features audio playback from YouTube, OpenAI integration for text/image generation, voice cloning, and Twitter integration. + +## Running the Bot + +### Docker (Recommended) + +```bash +# Build and start with docker-compose +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop the bot +docker-compose down + +# Build directly with Docker +docker build -t plopbot:latest . + +# Run with volume mounts +docker run -d \ + --name plopbot \ + --restart unless-stopped \ + -v $(pwd)/soundboard:/app/soundboard \ + -v $(pwd)/info:/app/info \ + -v $(pwd)/markov:/app/markov \ + -v $(pwd)/voices:/app/voices \ + plopbot:latest +``` + +### Python (Development) + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run the bot (default uses info/info.json) +python3 BotHead.py + +# Run with custom config or database overrides +python3 BotHead.py --json info/testinginfo.json \ + --db_host localhost \ + --db_username myuser \ + --db_password mypass \ + --db_name mydb +``` + +## Configuration + +The bot requires an `info.json` file (or custom JSON specified via `--json`). Use `info/blank_info.json` as a template: + +**Required configuration:** +- `token`: Discord bot token +- `soundboard_database`: MySQL connection details for soundboard storage +- `openai.apikey`: OpenAI API key for AI features +- `admins`: List of admin user IDs + +**Optional configuration:** +- `twitter`: Twitter API credentials +- `command_channels`: Channels where bot commands are allowed +- `welcome_channels` and `welcome_messages`: Welcome message configuration +- `status`: Bot status messages + +## Architecture + +### Core Structure + +**BotHead.py**: Main entry point. The `PlopBot` class extends `commands.Bot` and automatically loads all cogs from the `cogs/` directory during `setup_hook()`. + +**settings.py**: Global settings module that: +- Initializes logging (creates `bot.log` and `discord.log`) +- Loads the `info.json` configuration file +- Creates the `SoundboardDBManager` instance for MySQL database operations +- Provides global variables: `info_json`, `token`, `logger`, `soundboard_db` + +**Command prefix**: All bot commands use the `.` prefix (e.g., `.play`, `.help`) + +### Cog System + +The bot uses discord.py's cog system to organize features into separate modules in the `cogs/` directory: + +- **audioCog.py**: YouTube audio playback, soundboard management, TTS (Google TTS), Markov chain text generation +- **voiceCog.py**: Voice cloning system integration (communicates with external API at 192.168.1.230:8000) +- **openAiCog.py**: OpenAI text/image generation, assistant management, thread-based conversations +- **twitterCog.py**: Twitter integration via Tweepy +- **adminCog.py**: Admin-only commands +- **gameCog.py**: Game-related commands +- **generalCog.py**: General utility commands + +Each cog is automatically loaded at startup. All cogs should follow the pattern: +```python +class MyCog(commands.Cog): + def __init__(self, client): + self.client = client + + @commands.Cog.listener() + async def on_ready(self): + settings.logger.info(f"my cog ready!") + +async def setup(client): + await client.add_cog(MyCog(client)) +``` + +### Database Architecture + +**SoundboardDBManager** (in settings.py): +- Manages `discord_soundboard` table (columns: filename VARCHAR(255), name VARCHAR(255), date_added) +- Implements automatic reconnection on `CR_SERVER_GONE_ERROR` +- All database operations should use the global `settings.soundboard_db` instance +- The `verify_db()` method syncs filesystem soundboard files with database entries + +**OpenAIDatabaseManager** (in db/openai_database_manager.py): +- Separate database manager for OpenAI feature persistence +- Currently commented out in openAiCog.py but structure exists for user tracking and blacklisting + +### Audio System + +**YTDLSource** (in audioCog.py): +- Wraps yt-dlp for YouTube audio extraction +- Uses `discord.PCMVolumeTransformer` for volume control +- Downloads to `youtube/` directory with restrictive filenames + +**Soundboard**: +- MP3 files stored in `soundboard/` directory +- Filename mapping built at cog initialization (lowercase, .mp3 extension stripped) +- Database tracks all soundboard entries with unique names + +**TTS**: Uses Google Text-to-Speech (gTTS) library for voice generation + +### OpenAI Integration + +The OpenAI cog supports: +- Image generation via DALL-E +- Text generation and chat completions +- OpenAI Assistants API with thread management +- Function calling (definitions in `openAI_functions/*.json`) + +Active assistants and threads are tracked per-user in `active_assistants` and `active_threads` dictionaries. + +### Voice Cloning System + +The voice cog communicates with an external voice cloning API: +- `.add_voice `: Creates a new voice profile +- `.add_clip `: Uploads training clips for a voice +- `.make_clip `: Generates audio using the trained voice + +## Database Migrations + +Database schema changes are managed in `database_scripts/`: +- SQL migration files define schema changes +- `apply_db_migration.py` provides Python-based migration application +- When modifying the soundboard table structure, update both the SQL and the migration script + +## Persistent Directories + +The following directories should be volume-mounted in Docker or persisted: +- `soundboard/`: MP3 files for the soundboard +- `info/`: Configuration JSON files +- `markov/`: Markov chain training data +- `voices/`: Voice cloning data +- `youtube/`: Downloaded YouTube audio (can be temporary) +- `temp/`: Temporary file storage + +## Logging + +The bot creates two log files: +- `bot.log`: All bot logging output (DEBUG level) +- `discord.log`: Discord.py library logs (INFO level) + +Both logs also output to console. Use `settings.logger` for all bot logging. + +## Dependencies + +Key dependencies (see requirements.txt): +- discord.py >= 2.3.2 +- mysql-connector-python >= 8.0.23 +- yt-dlp >= 2023.3.4 (YouTube download) +- openai >= 1.12.0 (OpenAI API) +- gtts >= 2.5.1 (Text-to-speech) +- ffmpeg-python >= 0.2.0 (Audio processing) +- tweepy >= 4.14.0 (Twitter API) +- markovify >= 0.9.0 (Markov chains) + +System dependency: **ffmpeg** must be installed for audio playback. + +## Development Notes + +- Admin checks should verify `ctx.author` against `settings.info_json["admins"]` +- Command channel restrictions check against `settings.info_json["command_channels"]` +- Database operations should handle `CR_SERVER_GONE_ERROR` and implement reconnection +- All file paths should be relative to the working directory (`/app` in Docker) +- OpenAI blacklist is stored in `openai_blacklist.json` at the root diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 0000000..f74a668 --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,50 @@ +# Data Usage Disclaimer + +## Important Notice + +By using this Discord bot ("PlopBot"), you acknowledge and agree to the following terms regarding data collection and usage: + +## Data Collection + +This bot may collect and store the following information: +- Discord user IDs and usernames +- Message content sent to the bot or in channels where the bot is active +- Voice channel activity and audio data +- Command usage and interaction history +- Any files, images, or media uploaded to the bot + +## Data Usage Rights + +**All data provided to or collected by this bot may be used by the server owner and/or the developer of this application:** +- In perpetuity (forever) +- For any purpose whatsoever +- Without limitation or restriction +- Without compensation to the user +- Without prior notice or consent beyond this disclaimer + +## Data Sharing + +Data collected may be: +- Stored indefinitely on servers controlled by the bot operator +- Shared with third-party services (including but not limited to OpenAI, Twitter/X, and voice processing services) +- Used for training, analysis, or any other purpose deemed appropriate by the data controllers + +## No Expectation of Privacy + +Users should have **no expectation of privacy** when interacting with this bot or in any server where this bot is present. All interactions may be logged, stored, and reviewed. + +## Acceptance of Terms + +By continuing to use this bot, you confirm that you: +1. Have read and understood this disclaimer +2. Agree to these terms unconditionally +3. Accept that your data may be used as described above +4. Waive any claims related to the use of your data + +## Contact + +If you do not agree to these terms, please refrain from using this bot and contact a server administrator to have any existing data removed. + +--- + +*Last updated: See version history in repository* diff --git a/Dockerfile b/Dockerfile index bf14c8a..22a40a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,8 @@ LABEL version="1.1" ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + CONFIG_FILE=info/info.json # Set working directory WORKDIR /app @@ -42,5 +43,5 @@ RUN useradd -m -u 1000 botuser && \ # Switch to non-root user USER botuser -# Run the bot with configurable JSON file -CMD ["python3", "BotHead.py"] +# Run the bot with configurable JSON file (override CONFIG_FILE env var to change config) +CMD ["sh", "-c", "python3 BotHead.py --json ${CONFIG_FILE}"] diff --git a/Dockerfile-dev b/Dockerfile-dev deleted file mode 100644 index a5f96e9..0000000 --- a/Dockerfile-dev +++ /dev/null @@ -1,14 +0,0 @@ -# Build from basic python image -FROM python:3.8 -# Set working dir to the app folder. -RUN mkdir -p /usr/src/app/PlopBot -WORKDIR /usr/src/app/PlopBot -# Copy code -COPY . ./ -# Create a volume so soundboard and info files can be saved on server, must mount with -v -#VOLUME /usr/src/app/PlopBot/soundboard/ /usr/src/app/PlopBot/info/ -# Install dependencies. -RUN apt-get update && apt-get install -y ffmpeg -RUN python3 -m pip install -r requirements.txt -# Run Bot -CMD ["python3", "BotHead.py", "--json", "testinginfo.json"] diff --git a/cogs/adminCog.py b/cogs/adminCog.py index 6772d1b..d0ef697 100644 --- a/cogs/adminCog.py +++ b/cogs/adminCog.py @@ -1,10 +1,77 @@ """ This cog contains the admin commands for the bot. """ +import subprocess +import discord from discord.ext import commands import settings +def is_admin(): + """ + Check decorator that verifies the user is an admin. + Checks user ID against the admins list in info.json. + """ + async def predicate(ctx): + # Support both user ID (int/string) and legacy username format + admin_list = settings.info_json.get("admins", []) + user_id = str(ctx.author.id) + username = str(ctx.author) + + is_authorized = user_id in admin_list or ctx.author.id in admin_list or username in admin_list + + if not is_authorized: + await ctx.channel.send("You do not have permission to run this command") + settings.logger.warning(f"Unauthorized admin command attempt by {ctx.author} (ID: {ctx.author.id})") + + return is_authorized + + return commands.check(predicate) + + +def in_command_channel(): + """ + Check decorator that verifies the command is in an allowed channel. + """ + async def predicate(ctx): + command_channels = settings.info_json.get("command_channels", []) + # If no command channels configured, allow all channels + if not command_channels: + return True + + channel_name = str(ctx.message.channel) + if channel_name not in command_channels: + await ctx.channel.send("This command cannot be used in this channel") + settings.logger.warning(f"Command {ctx.command} attempted in non-command channel {channel_name}") + return False + + return True + + return commands.check(predicate) + + +def not_banned(): + """ + Check decorator that verifies the user is not banned from using the bot. + Use this decorator on commands in other cogs to enforce bans. + """ + async def predicate(ctx): + ban_info = settings.ban_db.is_banned(str(ctx.author.id)) + + if ban_info: + if ban_info['permanent']: + await ctx.channel.send("You are permanently banned from using this bot.") + else: + expires = ban_info['expires_at'].strftime("%Y-%m-%d %H:%M:%S") + await ctx.channel.send(f"You are banned from using this bot until {expires}.") + settings.logger.info(f"Banned user {ctx.author} (ID: {ctx.author.id}) attempted to use command {ctx.command}") + return False + + return True + + return commands.check(predicate) + + class Admin(commands.Cog): """ Admin cog for the bot @@ -26,65 +93,185 @@ async def on_ready(self): settings.logger.info(f"admin cog ready!") @commands.command(brief="Admin only command: provide current git commit hash") + @is_admin() async def hash(self, ctx): """ - provide current git commit hash + Provide current git commit hash :arg ctx: context of the command :return: None """ - pass + try: + # Get short hash (7 characters) + short_hash = subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + stderr=subprocess.DEVNULL + ).decode("utf-8").strip() + + # Get full hash + full_hash = subprocess.check_output( + ["git", "rev-parse", "HEAD"], + stderr=subprocess.DEVNULL + ).decode("utf-8").strip() + + # Get current branch + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.DEVNULL + ).decode("utf-8").strip() + + await ctx.send(f"**Branch:** {branch}\n**Commit:** `{short_hash}` (`{full_hash}`)") + settings.logger.info(f"hash command executed by {ctx.author}") + + except subprocess.CalledProcessError: + await ctx.send("Unable to retrieve git hash. Is this a git repository?") + settings.logger.warning("git hash command failed - not a git repository or git not available") + except FileNotFoundError: + await ctx.send("Git is not installed or not in PATH") + settings.logger.warning("git hash command failed - git not found") @commands.command(brief="Admin only command: provide current version") + @is_admin() async def version(self, ctx): """ - provide current version of the bot + Provide current version of the bot :arg ctx: context of the command :return: None """ - pass + await ctx.send(f"**PlopBot Version:** {settings.VERSION}") + settings.logger.info(f"version command executed by {ctx.author}") + + @commands.command(brief="Admin only command: Ban a user from using the bot") + @is_admin() + async def ban(self, ctx, user: discord.Member, duration: str, *, reason: str = None): + """ + Ban a user from using the bot. + Usage: .ban @user [reason] + Duration: 10m, 2h, 1d, or 'x' for permanent + :arg ctx: context of the command + :arg user: The user to ban (mention or ID) + :arg duration: Ban duration (e.g., 10m, 2h, 1d, x) + :arg reason: Optional reason for the ban + :return: None + """ + try: + # Parse the duration + ban_duration = settings.parse_ban_duration(duration) + + # Add the ban + expires_at = settings.ban_db.add_ban( + user_id=str(user.id), + username=str(user), + banned_by=str(ctx.author), + duration=ban_duration, + reason=reason + ) + + # Build response message + if ban_duration is None: + duration_str = "permanently" + else: + duration_str = f"for {duration}" + + response = f"**{user}** has been banned from using the bot {duration_str}." + if expires_at: + response += f"\nExpires: {expires_at.strftime('%Y-%m-%d %H:%M:%S')}" + if reason: + response += f"\nReason: {reason}" + + await ctx.send(response) + settings.logger.info(f"User {user} (ID: {user.id}) banned by {ctx.author} for {duration}. Reason: {reason}") + + except ValueError as e: + await ctx.send(f"Invalid duration format: {e}\nUse: 10m, 2h, 1d, or 'x' for permanent") + + @commands.command(brief="Admin only command: Unban a user from the bot") + @is_admin() + async def unban(self, ctx, user: discord.Member): + """ + Remove a ban from a user. + Usage: .unban @user + :arg ctx: context of the command + :arg user: The user to unban (mention or ID) + :return: None + """ + removed = settings.ban_db.remove_ban(str(user.id)) + + if removed: + await ctx.send(f"**{user}** has been unbanned from the bot.") + settings.logger.info(f"User {user} (ID: {user.id}) unbanned by {ctx.author}") + else: + await ctx.send(f"**{user}** was not banned.") + + @commands.command(brief="Admin only command: List all banned users") + @is_admin() + async def banlist(self, ctx): + """ + List all currently banned users. + :arg ctx: context of the command + :return: None + """ + bans = settings.ban_db.list_bans() + + if not bans: + await ctx.send("No users are currently banned.") + return + + # Build the ban list message + embed = discord.Embed(title="Banned Users", color=discord.Color.red()) + + for ban in bans[:25]: # Limit to 25 to fit in embed + if ban['permanent']: + expires_str = "Permanent" + else: + expires_str = ban['expires_at'].strftime("%Y-%m-%d %H:%M:%S") + + field_value = f"**Banned by:** {ban['banned_by']}\n**Expires:** {expires_str}" + if ban['reason']: + field_value += f"\n**Reason:** {ban['reason']}" + + embed.add_field( + name=f"{ban['username']} (ID: {ban['user_id']})", + value=field_value, + inline=False + ) + + if len(bans) > 25: + embed.set_footer(text=f"Showing 25 of {len(bans)} bans") + + await ctx.send(embed=embed) + settings.logger.info(f"Ban list requested by {ctx.author}") @commands.command(brief="Admin only command: Turn the bot off.") + @is_admin() + @in_command_channel() async def kill(self, ctx): """ - Preforms a shutdown of the bot + Performs a shutdown of the bot :arg ctx: context of the command :return: None """ - # try to gracefully shut down the bot - # noinspection PyBroadException try: - if str(ctx.author) in settings.info_json["admins"]: - settings.logger.info(f"kill from {ctx.author}!") - if str(ctx.message.channel) in settings.info_json["command_channels"]: - await self.client.close() - else: - await ctx.channel.send("You do not have permission to run this command") - settings.logger.warning(f"Unauthorized kill attempt by {ctx.author}") - # if the bot fails to close kill it + settings.logger.info(f"kill from {ctx.author}!") + await ctx.send("Shutting down...") + await self.client.close() except Exception: exit(1) @commands.command(brief="Admin only command: Restart the bot.") + @is_admin() + @in_command_channel() async def restart(self, ctx): """ - Preforms a restart of the bot + Performs a restart of the bot Note: This command closes the bot. The bot should be managed by a process manager (like systemd or docker) that will automatically restart it. :arg ctx: context of the command :return: None """ - # try to gracefully shut down the bot - # noinspection PyBroadException try: - if str(ctx.author) in settings.info_json["admins"]: - settings.logger.info(f"restart from {ctx.author}!") - if str(ctx.message.channel) in settings.info_json["command_channels"]: - await ctx.send("Restarting bot... (requires process manager)") - await self.client.close() - else: - await ctx.channel.send("You do not have permission to run this command") - settings.logger.warning(f"Unauthorized restart attempt by {ctx.author}") - # if the bot fails to close kill it + settings.logger.info(f"restart from {ctx.author}!") + await ctx.send("Restarting bot... (requires process manager)") + await self.client.close() except Exception: exit(1) diff --git a/cogs/audioCog.py b/cogs/audioCog.py index ddf11e3..df6eed3 100644 --- a/cogs/audioCog.py +++ b/cogs/audioCog.py @@ -17,6 +17,8 @@ import shutil import settings import traceback +from datetime import datetime, timedelta +from cogs.adminCog import not_banned ytdl_format_options = { 'format': 'bestaudio/best', @@ -87,6 +89,7 @@ def __init__(self, client): """ self.client = client self.maintenance.start() + self.inactivity_check.start() self.models = {} self.sounds = {} for file in os.listdir("soundboard"): @@ -96,6 +99,15 @@ def __init__(self, client): self.volume = 0.7 self.ghost_message = {} + self.last_activity = {} # Track last command time per guild + + def update_activity(self, guild_id): + """ + Update the last activity timestamp for a guild + :param guild_id: The guild ID to update + :return: None + """ + self.last_activity[guild_id] = datetime.now() @staticmethod def clean_youtube(): @@ -190,6 +202,8 @@ async def on_message(self, message): if member is not None and member.voice is not None: for client in self.client.voice_clients: if client.channel.id == member.voice.channel.id: + # Update activity for webhook commands + self.update_activity(message.guild.id) if data[2] == "stop": if client.is_playing(): client.stop() @@ -211,6 +225,10 @@ async def play_clip(self, text_channel, voice_channel, filename): :arg filename: name of the file to play :return: None """ + # Update activity timestamp + guild_id = text_channel.guild.id if hasattr(text_channel, 'guild') else text_channel.channel.guild.id + self.update_activity(guild_id) + try: # TODO: Fix this, this is me being very lazy og = filename @@ -270,6 +288,7 @@ async def play_clip(self, text_channel, voice_channel, filename): brief="Plays a clip with the same name as the argument. alt command = 'p'", description="Makes the bot play one of the soundboard files. For example if you wanted to play " "a file named hammer you would enter '.play hammer'/'.p hammer'") + @not_banned() async def play(self, ctx, filename=None): """ Plays a mp3 from the library of downloaded mp3's @@ -321,6 +340,7 @@ async def play(self, ctx, filename=None): description="Makes the bot play a youtube videos audio. For example if you wanted to play the " "youtube video at 'https://www.youtube.com/watch?v=1234' you would enter '.youtube " "https://www.youtube.com/watch?v=1234'/'.yt https://www.youtube.com/watch?v=1234'") + @not_banned() async def youtube(self, ctx, *, url): """ Downloads and plays the audio of the provided YouTube link. Plays from an url (almost anything yt_dlp @@ -330,6 +350,7 @@ async def youtube(self, ctx, *, url): :return: None """ settings.logger.info(f"youtube from {ctx.author} :{url}") + self.update_activity(ctx.guild.id) async with ctx.typing(): player = await YTDLSource.from_url(url, loop=self.client.loop, volume=self.volume) ctx.voice_client.play(player) @@ -339,6 +360,7 @@ async def youtube(self, ctx, *, url): aliases=['STREAM'], brief="Streams from a url (same as yt, but doesn't pre-download)", description="Streams from a url (same as yt, but doesn't pre-download)") + @not_banned() async def stream(self, ctx, *, url): """ Streams from an url (same as yt, but doesn't pre-download) @@ -346,6 +368,7 @@ async def stream(self, ctx, *, url): :arg url: url of the YouTube video to play :return: None """ + self.update_activity(ctx.guild.id) async with ctx.typing(): player = await YTDLSource.from_url(url, loop=self.client.loop, stream=True, volume=self.volume) ctx.voice_client.play(player) @@ -355,6 +378,7 @@ async def stream(self, ctx, *, url): aliases=['l', 'LEAVE', 'L'], brief="Make the bot leave the voice server. alt command = 'l'", description="Makes the bot leave the voice server.") + @not_banned() async def leave(self, ctx): """ Forces the bot to leave any voice channel it is in. @@ -368,6 +392,9 @@ async def leave(self, ctx): if channel and voice and voice.is_connected(): await voice.disconnect() + # Clean up activity tracking when manually disconnecting + if ctx.guild.id in self.last_activity: + del self.last_activity[ctx.guild.id] settings.logger.info(f"The bot has left {channel}") elif not channel: settings.logger.info(f"Member was not in a voice channel.") @@ -382,6 +409,7 @@ async def leave(self, ctx): aliases=['PAUSE'], brief="Pause everything the bot is playing", description="Makes the bot pause anything that it is playing.") + @not_banned() async def pause(self, ctx): """ Pauses whatever the bot is playing @@ -403,6 +431,7 @@ async def pause(self, ctx): aliases=['r', 'RESUME', 'R'], brief="Resume playing paused Music. alt command = 'r'", description="Makes the bot resume playing any paused audio.") + @not_banned() async def resume(self, ctx): """ Resumes playing if the bot is paused @@ -424,6 +453,7 @@ async def resume(self, ctx): brief="Stop any Music the bot is playing or has paused. alt command = 's'", description="Makes the bot stop playing any audio and forget what it was " "playing and when it stopped.") + @not_banned() async def stop(self, ctx): """ Stops playing whatever is playing @@ -445,6 +475,7 @@ async def stop(self, ctx): aliases=['v', 'VOLUME', 'V', 'vol'], brief="changes the volume of the bot", description="Changes the volume of the bot.") + @not_banned() async def volume(self, ctx, volume: int): """ Change the volume the bot plays back at @@ -470,6 +501,7 @@ async def volume(self, ctx, volume: int): aliases=['g', 'GET', 'G'], brief="returns a soundbite", description="returns a soundbite") + @not_banned() async def get(self, ctx, sound: str): """ Returns a soundbite @@ -494,6 +526,7 @@ async def get(self, ctx, sound: str): @commands.command(aliases=['SAY'], brief="", description="") + @not_banned() async def say(self, ctx, text, *, tts_file='say'): """ Say the given string in the audio channel using TTS. @@ -503,6 +536,7 @@ async def say(self, ctx, text, *, tts_file='say'): :return: None """ settings.logger.info(f"say from {ctx.author} text:{text}") + self.update_activity(ctx.guild.id) text = text.strip().lower() # Limit TTS text length to prevent abuse @@ -543,6 +577,39 @@ async def maintenance(self): settings.soundboard_db.verify_db() settings.logger.info(f"Maintenance completed") + @tasks.loop(minutes=1.0) + async def inactivity_check(self): + """ + Task to check for voice channel inactivity and disconnect after 10 minutes + :return: None + """ + now = datetime.now() + timeout = timedelta(minutes=10) + + for voice_client in self.client.voice_clients: + guild_id = voice_client.guild.id + + # If there's no recorded activity for this guild, set it to now + if guild_id not in self.last_activity: + self.last_activity[guild_id] = now + continue + + # Check if 10 minutes have passed since last activity + time_since_activity = now - self.last_activity[guild_id] + if time_since_activity >= timeout: + settings.logger.info(f"Disconnecting from {voice_client.channel.name} in {voice_client.guild.name} due to 10 minutes of inactivity") + await voice_client.disconnect() + # Remove the guild from tracking + del self.last_activity[guild_id] + + @inactivity_check.before_loop + async def before_inactivity_check(self): + """ + Wait for the bot to be ready before starting the inactivity check task + :return: None + """ + await self.client.wait_until_ready() + async def setup(client): """ diff --git a/cogs/gameCog.py b/cogs/gameCog.py index 1ad07b1..5340ff9 100644 --- a/cogs/gameCog.py +++ b/cogs/gameCog.py @@ -6,6 +6,7 @@ from settings import add_to_json from discord.ext import commands import random +from cogs.adminCog import not_banned class Game(commands.Cog): @@ -32,6 +33,7 @@ async def on_ready(self): @commands.command(pass_context=True, aliases=['add'], brief="", description="") + @not_banned() async def add_scribble(self, ctx, tag): """ Adds a scribble word to the list of words @@ -46,6 +48,7 @@ async def add_scribble(self, ctx, tag): @commands.command(pass_context=True, aliases=['list'], brief="", description="") + @not_banned() async def list_scribble(self, ctx): """ Lists all the scribble words @@ -67,6 +70,7 @@ async def list_scribble(self, ctx): @commands.command(pass_context=True, aliases=['TEAMS'], brief="Divides the current channel into random teams. Defaults to 2.", description="Divides the current channel into random teams. Defaults to 2.") + @not_banned() async def teams(self, ctx, teams="2"): """ Divides the current channel into random teams. @@ -111,6 +115,7 @@ async def teams(self, ctx, teams="2"): @commands.command(pass_context=True, aliases=['dice'], brief="Rolls a random die of specified size. Give a second number for multiple rolls.", description="Rolls a random die of specified size. Give a second number for multiple rolls.") + @not_banned() async def roll(self, ctx, sides, times="1"): """ Rolls an equally distributed die of specified size diff --git a/cogs/generalCog.py b/cogs/generalCog.py index 16841e5..8c2d750 100644 --- a/cogs/generalCog.py +++ b/cogs/generalCog.py @@ -6,6 +6,7 @@ import settings import discord import random +from cogs.adminCog import not_banned class General(commands.Cog): @@ -24,12 +25,82 @@ def __init__(self, client): @commands.Cog.listener() async def on_ready(self): """ - Logs that the cog was loaded properly + Logs that the cog was loaded properly and checks for version updates :return: None """ settings.logger.info(f"general cog ready!") self.change_status.start() + # Check for version update and notify if changed + await self.check_and_notify_version_update() + + async def check_and_notify_version_update(self): + """ + Checks if the bot version has changed and sends notifications to configured channels + :return: None + """ + try: + current_version = settings.VERSION + + # Check if version has changed + if not settings.version_db.check_version_changed(current_version): + settings.logger.info(f"Version {current_version} already notified, skipping announcement") + return + + settings.logger.info(f"New version detected: {current_version}, sending announcements") + + # Get GitHub URL from config (with fallback) + github_url = settings.info_json.get("github_url", "https://github.com") + disclaimer_url = f"{github_url}/blob/master/DISCLAIMER.md" + + # Build the announcement embed + embed = discord.Embed( + title="Bot Updated!", + description=f"PlopBot has been updated to version **{current_version}**", + color=discord.Color.blue() + ) + embed.add_field( + name="Source Code", + value=f"[View on GitHub]({github_url})", + inline=False + ) + embed.add_field( + name="Data Usage Disclaimer", + value=f"By using this bot, you agree to our [Data Usage Policy]({disclaimer_url}).\n\n" + f"**Important:** Any data you provide to this bot may be used by the server owner " + f"and/or the developer of this application in perpetuity and for any reason. " + f"Please review the full disclaimer before continuing to use this bot.", + inline=False + ) + embed.set_footer(text="Thank you for using PlopBot!") + + # Get announcement channels from config + announcement_channels = settings.info_json.get("announcement_channels", []) + + if not announcement_channels: + settings.logger.warning("No announcement channels configured, skipping version notification") + # Still update the version so we don't spam on next restart + settings.version_db.set_last_version(current_version) + return + + # Send to all configured announcement channels across all guilds + for guild in self.client.guilds: + for channel in guild.text_channels: + if str(channel.name) in announcement_channels: + try: + await channel.send(embed=embed) + settings.logger.info(f"Sent version update notification to {guild.name}#{channel.name}") + except discord.Forbidden: + settings.logger.warning(f"No permission to send to {guild.name}#{channel.name}") + except discord.HTTPException as e: + settings.logger.error(f"Failed to send to {guild.name}#{channel.name}: {e}") + + # Update the stored version after successful notification + settings.version_db.set_last_version(current_version) + + except Exception as e: + settings.logger.error(f"Error during version update notification: {e}") + @commands.Cog.listener() async def on_message(self, message): """ @@ -66,6 +137,7 @@ async def on_member_join(self, member): await channel.send(f"""{random.choice(settings.info_json["welcome_messages"])} {member.mention}?""") @commands.command(brief="Change the bot presence to the argument string.") + @not_banned() async def echo(self, ctx, tag): """ Changes the bot status in discord and adds the status to list of usable statuses @@ -80,6 +152,7 @@ async def echo(self, ctx, tag): await ctx.message.delete() @commands.command() + @not_banned() async def repeat(self, ctx, times: int, content='repeating...'): """ Repeats a message multiple times. @@ -96,6 +169,7 @@ async def repeat(self, ctx, times: int, content='repeating...'): await ctx.send(content) @commands.command(brief="List the previous statuses the bot will loop through.") + @not_banned() async def status(self, ctx): """ Lists statuses the bot will cycle through. diff --git a/cogs/openAiCog.py b/cogs/openAiCog.py index b0beecc..5279abc 100644 --- a/cogs/openAiCog.py +++ b/cogs/openAiCog.py @@ -15,6 +15,7 @@ import asyncio from db.openai_database_manager import OpenAIDatabaseManager +from cogs.adminCog import not_banned global logger @@ -95,6 +96,7 @@ async def on_ready(self): @commands.command(pass_context=True, aliases=["genimg", "genimage", "gen_image"], brief="generate an image from a prompt using openai") @commands.cooldown(1, 60, commands.BucketType.user) + @not_banned() async def gen_img(self, ctx, *args): """ Generate an image from a prompt using openai @@ -134,6 +136,7 @@ async def gen_img(self, ctx, *args): @commands.command(pass_context=True, aliases=["editimg", "editimage", "edit_image"], brief="edit an image from a prompt using openai") @commands.cooldown(1, 60, commands.BucketType.user) + @not_banned() async def edit_img(self, ctx, *args): """ Edit an image from a prompt using openai @@ -194,6 +197,7 @@ async def get_updated_assistants(self, ctx): @commands.command(pass_context=True, aliases=["la", "listassistants"], brief="Prints the list of existing assistants") + @not_banned() async def list_assistants(self, ctx): """ Prints the list of existing assistants @@ -215,6 +219,7 @@ async def list_assistants(self, ctx): @commands.command(pass_context=True, aliases=["cra", "createassistant"], brief="Create an assistant from a prompt using openai") @commands.cooldown(1, 60, commands.BucketType.user) + @not_banned() async def create_assistant(self, ctx, name, *args): """ Create an assistant from a prompt using openai @@ -380,6 +385,7 @@ async def handle_tool_call(self, ctx, run, thread_id): @commands.command(pass_context=True, aliases=["ca", "chatassistant"], brief="chat with an assistant using openai") @commands.cooldown(1, 60, commands.BucketType.user) + @not_banned() async def chat_assistant(self, ctx, name, *args): """ Chat with an assistant using openai diff --git a/cogs/twitterCog.py b/cogs/twitterCog.py index e70e9a8..d0fad2a 100644 --- a/cogs/twitterCog.py +++ b/cogs/twitterCog.py @@ -8,6 +8,7 @@ import settings import discord import os +from cogs.adminCog import not_banned class Twitter(commands.Cog): @@ -37,6 +38,7 @@ async def on_ready(self): settings.logger.info(f"twit cog ready!") @commands.command(brief="Retrieves the most recent post from factbot.") + @not_banned() async def factbot(self, ctx): """ gets the most recently tweeted image from the Twitter account @factbot1 diff --git a/cogs/voiceCog.py b/cogs/voiceCog.py index 4f0faa8..87236b8 100644 --- a/cogs/voiceCog.py +++ b/cogs/voiceCog.py @@ -5,6 +5,7 @@ import os import time import json +from cogs.adminCog import not_banned class Voices(commands.Cog): @@ -21,6 +22,7 @@ class Voices(commands.Cog): """ @commands.command(pass_context=True, aliases=['av'], brief='Adds a voice', help='Adds a voice') + @not_banned() async def add_voice(self, ctx, voice_name: str): """ Adds a voice to the database @@ -39,6 +41,7 @@ async def add_voice(self, ctx, voice_name: str): pass @commands.command(pass_context=True, aliases=['ac'], brief='Adds a clip', help='Adds a clip') + @not_banned() async def add_clip(self, ctx, voice_name: str): """ Adds a clip to the database @@ -67,6 +70,7 @@ async def add_clip(self, ctx, voice_name: str): await ctx.send(f'Failed to add clip {f.filename} to voice {voice_name}') @commands.command(pass_context=True, aliases=['mc'], brief='Makes a clip', help='Makes a clip') + @not_banned() async def make_clip(self, ctx, voice_name: str, *text: str): """ Makes a clip with the given text and voice @@ -116,6 +120,7 @@ async def make_clip(self, ctx, voice_name: str, *text: str): time.sleep(5) @commands.command(pass_context=True, aliases=['lv'], brief='Lists voices', help='Lists voices') + @not_banned() async def list_voices(self, ctx): """ Lists voices diff --git a/docker-compose.yml b/docker-compose.yml index b7e01c7..760321d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,11 @@ services: - ./youtube:/app/youtube # Environment variables (optional - can override settings) - # environment: - # - PYTHONUNBUFFERED=1 - - # Network mode (if bot needs to access local network services) - # network_mode: host + environment: + # Override CONFIG_FILE to use a different config (default: info/info.json) + - CONFIG_FILE=info/info.json + # For development, use: + # - CONFIG_FILE=info/testinginfo.json # Logging configuration logging: @@ -31,12 +31,3 @@ services: max-size: "10m" max-file: "3" - # Resource limits (adjust as needed) - # deploy: - # resources: - # limits: - # cpus: '1.0' - # memory: 1G - # reservations: - # cpus: '0.25' - # memory: 256M diff --git a/info/blank_info.json b/info/blank_info.json index 490c78f..c330227 100644 --- a/info/blank_info.json +++ b/info/blank_info.json @@ -12,6 +12,10 @@ "password": "SOUNDBOARD_DATABASE_PASSWORD", "database": "SOUNDBOARD_DATABASE_NAME" }, + "github_url": "https://github.com/plop91/PlopBot", + "announcement_channels": [ + "announcements" + ], "twitter": { "apikey": "TWITTER_API_KEY", "apisecret": "TWITTER_API_SECRET", @@ -23,7 +27,7 @@ "text_gen_engine": "gpt-4" }, "servers": { - "SERVERNAME": 12345 //SERVER_ID + "SERVERNAME": 12345 }, "valid_users": [ "VALID_USER_ID" diff --git a/settings.py b/settings.py index 9d054ca..8debe3d 100644 --- a/settings.py +++ b/settings.py @@ -2,9 +2,12 @@ Settings file for the bot, contains json data, databases and logging. """ +VERSION = "0.0.2" + import mysql.connector from mysql.connector import errorcode -from datetime import datetime +from datetime import datetime, timedelta +import re import json import logging import os @@ -14,6 +17,8 @@ global token global logger global soundboard_db +global ban_db +global version_db def init(args): @@ -81,6 +86,18 @@ def init(args): database_name=db_name) # + # + global ban_db + ban_db = BanDBManager(db_host=host, db_username=db_username, db_password=db_password, + database_name=db_name) + # + + # + global version_db + version_db = VersionDBManager(db_host=host, db_username=db_username, db_password=db_password, + database_name=db_name) + # + class SoundboardDBManager: """ @@ -251,3 +268,304 @@ def add_to_json(filename, json_data, tag, data): with open(filename, "w") as file: json_data[tag].append(data) json.dump(json_data, file, indent=4) + + +def parse_ban_duration(duration_str: str): + """ + Parses a duration string into a timedelta. + Supports: Xm (minutes), Xh (hours), Xd (days), or 'x'/'permanent' for permanent bans. + Returns None for permanent bans, or a timedelta for timed bans. + Raises ValueError for invalid formats. + """ + duration_str = duration_str.strip().lower() + + # Check for permanent ban + if duration_str in ('x', 'permanent', 'perm', 'forever'): + return None + + # Parse duration with regex + match = re.match(r'^(\d+)([mhd])$', duration_str) + if not match: + raise ValueError(f"Invalid duration format: {duration_str}. Use Xm, Xh, Xd, or 'x' for permanent.") + + value = int(match.group(1)) + unit = match.group(2) + + if value <= 0: + raise ValueError("Duration must be a positive number.") + + if unit == 'm': + return timedelta(minutes=value) + elif unit == 'h': + return timedelta(hours=value) + elif unit == 'd': + return timedelta(days=value) + + +class BanDBManager: + """ + Manages the bot ban database + """ + def __init__(self, db_host, db_username, db_password, database_name): + self.db = None + self.my_cursor = None + + self.db_host = db_host + self.db_username = db_username + self.db_password = db_password + self.database_name = database_name + + self.connect() + self._create_table() + + def connect(self): + """Connects to the database""" + try: + self.db = mysql.connector.connect( + host=self.db_host, + user=self.db_username, + password=self.db_password, + database=self.database_name + ) + self.my_cursor = self.db.cursor() + except mysql.connector.Error as e: + if e.errno == errorcode.ER_ACCESS_DENIED_ERROR: + logger.warning("Ban DB: user name or password is bad") + elif e.errno == errorcode.ER_BAD_DB_ERROR: + logger.warning("Ban DB: Database does not exist") + else: + logger.warning(f"Ban DB: {e}") + + def _create_table(self): + """Creates the ban table if it doesn't exist""" + try: + sql = """ + CREATE TABLE IF NOT EXISTS discord_bans ( + user_id VARCHAR(255) PRIMARY KEY, + username VARCHAR(255), + banned_by VARCHAR(255), + banned_at DATETIME, + expires_at DATETIME NULL, + reason VARCHAR(500) + ) + """ + self.my_cursor.execute(sql) + self.db.commit() + logger.info("Ban table verified/created") + except mysql.connector.Error as e: + logger.error(f"Failed to create ban table: {e}") + + def _execute_with_reconnect(self, operation, *args, **kwargs): + """Helper to execute database operations with automatic reconnection""" + try: + return operation(*args, **kwargs) + except mysql.connector.Error as e: + if e.errno == errorcode.CR_SERVER_GONE_ERROR: + logger.info("Ban DB: server gone error, attempting recovery") + self.connect() + return operation(*args, **kwargs) + else: + raise + + def add_ban(self, user_id: str, username: str, banned_by: str, duration: timedelta = None, reason: str = None): + """ + Adds a ban to the database. + :param user_id: Discord user ID + :param username: Discord username (for display purposes) + :param banned_by: Admin who issued the ban + :param duration: timedelta for ban length, or None for permanent + :param reason: Optional reason for the ban + """ + def _do_add(): + banned_at = datetime.now() + expires_at = banned_at + duration if duration else None + + sql = """ + INSERT INTO discord_bans (user_id, username, banned_by, banned_at, expires_at, reason) + VALUES (%s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + username = VALUES(username), + banned_by = VALUES(banned_by), + banned_at = VALUES(banned_at), + expires_at = VALUES(expires_at), + reason = VALUES(reason) + """ + val = (str(user_id), username, banned_by, banned_at, expires_at, reason) + self.my_cursor.execute(sql, val) + self.db.commit() + logger.info(f"Added ban for user {username} (ID: {user_id}), expires: {expires_at}") + return expires_at + + return self._execute_with_reconnect(_do_add) + + def remove_ban(self, user_id: str): + """Removes a ban from the database""" + def _do_remove(): + sql = "DELETE FROM discord_bans WHERE user_id = %s" + self.my_cursor.execute(sql, (str(user_id),)) + self.db.commit() + affected = self.my_cursor.rowcount + logger.info(f"Removed ban for user ID: {user_id}, rows affected: {affected}") + return affected > 0 + + return self._execute_with_reconnect(_do_remove) + + def is_banned(self, user_id: str): + """ + Checks if a user is currently banned. + Returns ban info dict if banned, None if not banned. + Automatically cleans up expired bans. + """ + def _do_check(): + sql = "SELECT user_id, username, banned_by, banned_at, expires_at, reason FROM discord_bans WHERE user_id = %s" + self.my_cursor.execute(sql, (str(user_id),)) + result = self.my_cursor.fetchone() + + if not result: + return None + + db_user_id, username, banned_by, banned_at, expires_at, reason = result + + # Check if ban has expired + if expires_at and datetime.now() > expires_at: + # Ban expired, remove it + self.remove_ban(db_user_id) + return None + + return { + 'user_id': db_user_id, + 'username': username, + 'banned_by': banned_by, + 'banned_at': banned_at, + 'expires_at': expires_at, + 'reason': reason, + 'permanent': expires_at is None + } + + return self._execute_with_reconnect(_do_check) + + def list_bans(self): + """Returns a list of all current bans (excluding expired ones)""" + def _do_list(): + # Clean up expired bans first + cleanup_sql = "DELETE FROM discord_bans WHERE expires_at IS NOT NULL AND expires_at < %s" + self.my_cursor.execute(cleanup_sql, (datetime.now(),)) + self.db.commit() + + # Get remaining bans + sql = "SELECT user_id, username, banned_by, banned_at, expires_at, reason FROM discord_bans ORDER BY banned_at DESC" + self.my_cursor.execute(sql) + results = self.my_cursor.fetchall() + + bans = [] + for row in results: + bans.append({ + 'user_id': row[0], + 'username': row[1], + 'banned_by': row[2], + 'banned_at': row[3], + 'expires_at': row[4], + 'reason': row[5], + 'permanent': row[4] is None + }) + return bans + + return self._execute_with_reconnect(_do_list) + + +class VersionDBManager: + """ + Manages version tracking for update notifications + """ + def __init__(self, db_host, db_username, db_password, database_name): + self.db = None + self.my_cursor = None + + self.db_host = db_host + self.db_username = db_username + self.db_password = db_password + self.database_name = database_name + + self.connect() + self._create_table() + + def connect(self): + """Connects to the database""" + try: + self.db = mysql.connector.connect( + host=self.db_host, + user=self.db_username, + password=self.db_password, + database=self.database_name + ) + self.my_cursor = self.db.cursor() + except mysql.connector.Error as e: + if e.errno == errorcode.ER_ACCESS_DENIED_ERROR: + logger.warning("Version DB: user name or password is bad") + elif e.errno == errorcode.ER_BAD_DB_ERROR: + logger.warning("Version DB: Database does not exist") + else: + logger.warning(f"Version DB: {e}") + + def _create_table(self): + """Creates the version tracking table if it doesn't exist""" + try: + sql = """ + CREATE TABLE IF NOT EXISTS discord_version ( + id INT PRIMARY KEY DEFAULT 1, + last_version VARCHAR(50), + last_updated DATETIME, + CONSTRAINT single_row CHECK (id = 1) + ) + """ + self.my_cursor.execute(sql) + self.db.commit() + logger.info("Version table verified/created") + except mysql.connector.Error as e: + logger.error(f"Failed to create version table: {e}") + + def _execute_with_reconnect(self, operation, *args, **kwargs): + """Helper to execute database operations with automatic reconnection""" + try: + return operation(*args, **kwargs) + except mysql.connector.Error as e: + if e.errno == errorcode.CR_SERVER_GONE_ERROR: + logger.info("Version DB: server gone error, attempting recovery") + self.connect() + return operation(*args, **kwargs) + else: + raise + + def get_last_version(self): + """Gets the last notified version from the database""" + def _do_get(): + sql = "SELECT last_version FROM discord_version WHERE id = 1" + self.my_cursor.execute(sql) + result = self.my_cursor.fetchone() + return result[0] if result else None + + return self._execute_with_reconnect(_do_get) + + def set_last_version(self, version: str): + """Updates the last notified version in the database""" + def _do_set(): + sql = """ + INSERT INTO discord_version (id, last_version, last_updated) + VALUES (1, %s, %s) + ON DUPLICATE KEY UPDATE + last_version = VALUES(last_version), + last_updated = VALUES(last_updated) + """ + self.my_cursor.execute(sql, (version, datetime.now())) + self.db.commit() + logger.info(f"Version updated to {version}") + + return self._execute_with_reconnect(_do_set) + + def check_version_changed(self, current_version: str): + """ + Checks if the version has changed since last notification. + Returns True if this is a new version, False otherwise. + """ + last_version = self.get_last_version() + return last_version != current_version diff --git a/soundboard/.gitignore b/soundboard/.gitignore index c96a04f..a3a0c8b 100644 --- a/soundboard/.gitignore +++ b/soundboard/.gitignore @@ -1,2 +1,2 @@ -* +* !.gitignore \ No newline at end of file