From b05fa034a6d83b4ccc22978b337d8b633cb55cb9 Mon Sep 17 00:00:00 2001 From: latte Date: Sun, 11 Jan 2026 18:41:23 +0100 Subject: [PATCH] Refactor: remove unused cogs and services, simplify architecture - Remove admin.py and search.py cogs - Remove searxng.py service and rate_limiter.py utility - Update bot.py, ai_chat.py, config.py, and ai_service.py - Update documentation and docker-compose.yml --- .env.example | 50 +++-- CLAUDE.md | 13 +- README.md | 206 ++++++++++---------- docker-compose.yml | 38 ---- src/daemon_boyfriend/bot.py | 46 +---- src/daemon_boyfriend/cogs/admin.py | 137 ------------- src/daemon_boyfriend/cogs/ai_chat.py | 38 +--- src/daemon_boyfriend/cogs/search.py | 144 -------------- src/daemon_boyfriend/config.py | 36 ++-- src/daemon_boyfriend/services/__init__.py | 4 - src/daemon_boyfriend/services/ai_service.py | 9 +- src/daemon_boyfriend/services/searxng.py | 119 ----------- src/daemon_boyfriend/utils/__init__.py | 5 - src/daemon_boyfriend/utils/rate_limiter.py | 138 ------------- 14 files changed, 182 insertions(+), 801 deletions(-) delete mode 100644 src/daemon_boyfriend/cogs/admin.py delete mode 100644 src/daemon_boyfriend/cogs/search.py delete mode 100644 src/daemon_boyfriend/services/searxng.py delete mode 100644 src/daemon_boyfriend/utils/rate_limiter.py diff --git a/.env.example b/.env.example index e34de47..9e99d47 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,55 @@ +# =========================================== # Discord Configuration +# =========================================== +# Your Discord bot token (get it from https://discord.com/developers/applications) DISCORD_TOKEN=your_discord_bot_token_here -DISCORD_GUILD_ID= # Optional: for faster command sync during development +# =========================================== # AI Provider Configuration +# =========================================== # Available providers: "openai", "openrouter", "anthropic" AI_PROVIDER=openai + +# Model to use (e.g., gpt-4o, gpt-4o-mini, claude-3-5-sonnet, etc.) AI_MODEL=gpt-4o -# Provider API Keys (set the ones you use) +# Provider API Keys (set the one you use) OPENAI_API_KEY=sk-xxx OPENROUTER_API_KEY=sk-or-xxx ANTHROPIC_API_KEY=sk-ant-xxx -# AI Settings +# Maximum tokens in AI response (100-4096) AI_MAX_TOKENS=1024 + +# AI creativity/randomness (0.0 = deterministic, 2.0 = very creative) AI_TEMPERATURE=0.7 -# SearXNG Configuration -SEARXNG_BASE_URL=http://localhost:8080 -SEARXNG_TIMEOUT=10 +# =========================================== +# Bot Identity & Personality +# =========================================== +# The bot's name, used in the system prompt to tell the AI who it is +BOT_NAME=My Bot -# Rate Limiting -RATE_LIMIT_MESSAGES=10 # Messages per user per minute -RATE_LIMIT_SEARCHES=5 # Searches per user per minute +# Personality traits that define how the bot responds (used in system prompt) +BOT_PERSONALITY=helpful and friendly -# Logging -LOG_LEVEL=INFO +# Message shown when someone mentions the bot without saying anything +BOT_DESCRIPTION=I'm an AI assistant here to help you. -# Bot Behavior -BOT_NAME=Daemon Boyfriend -BOT_PERSONALITY=helpful, witty, and slightly mischievous +# Status message shown in Discord (displays as "Watching ") +BOT_STATUS=for mentions + +# Optional: Override the entire system prompt (leave commented to use auto-generated) +# SYSTEM_PROMPT=You are a custom assistant... + +# =========================================== +# Conversation Settings +# =========================================== +# Number of messages to remember per user (higher = more context, more tokens) MAX_CONVERSATION_HISTORY=20 + +# =========================================== +# Logging +# =========================================== +# Log level: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL=INFO diff --git a/CLAUDE.md b/CLAUDE.md index b8eec6a..0bed859 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ python -m py_compile src/daemon_boyfriend/**/*.py ## Architecture -This is a Discord bot with AI chat (multi-provider) and SearXNG web search. +This is a Discord bot that responds to @mentions with AI-generated responses (multi-provider support). ### Provider Pattern The AI system uses a provider abstraction pattern: @@ -30,10 +30,8 @@ The AI system uses a provider abstraction pattern: - OpenRouter uses OpenAI's client with a different base URL ### Cog System -Discord commands are organized as cogs in `cogs/`: -- `ai_chat.py` - `/chat` command and `@mention` handler (primary user interaction) -- `search.py` - `/search` and `/image` commands using SearXNG -- `admin.py` - `/status`, `/help`, `/ping`, `/provider` commands +Discord functionality is in `cogs/`: +- `ai_chat.py` - `@mention` handler (responds when bot is mentioned) Cogs are auto-loaded by `bot.py` from the `cogs/` directory. @@ -42,12 +40,9 @@ All config flows through `config.py` using pydantic-settings. The `settings` sin ### Key Design Decisions - `ConversationManager` stores per-user chat history in memory with configurable max length -- `RateLimiter` tracks per-user rate limits using timestamps in a dict - Long AI responses are split via `split_message()` in `ai_chat.py` to respect Discord's 2000 char limit -- The bot responds to @mentions via `on_message` listener, not just slash commands +- The bot responds only to @mentions via `on_message` listener ## Environment Variables Required: `DISCORD_TOKEN`, plus one of `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, or `ANTHROPIC_API_KEY` depending on `AI_PROVIDER` setting. - -Set `DISCORD_GUILD_ID` for instant slash command sync during development (otherwise global sync takes up to 1 hour). diff --git a/README.md b/README.md index f7284e3..b5181b1 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,128 @@ -# Daemon Boyfriend +# Discord AI Bot -A Discord bot for the MSC group with multi-provider AI chat and SearXNG web search. +A customizable Discord bot that responds to @mentions with AI-generated responses. Supports multiple AI providers. ## Features -- **Multi-Provider AI Chat**: Supports OpenAI, OpenRouter, and Anthropic (Claude) -- **Mention-based Chat**: Just @mention the bot to chat -- **Web Search**: Privacy-respecting search via SearXNG +- **Multi-Provider AI**: Supports OpenAI, OpenRouter, and Anthropic (Claude) +- **Fully Customizable**: Configure bot name, personality, and behavior - **Conversation Memory**: Remembers context per user -- **Rate Limiting**: Prevents abuse - -## Commands - -| Command | Description | -|---------|-------------| -| `@Daemon Boyfriend ` | Chat with the bot | -| `/chat ` | Chat via slash command | -| `/search ` | Search the web | -| `/image ` | Search for images | -| `/clear` | Clear your conversation history | -| `/status` | Show bot status | -| `/help` | Show all commands | +- **Easy Deployment**: Docker support included ## Quick Start -### Prerequisites +### 1. Clone the repository -- Python 3.11+ -- Discord bot token ([Discord Developer Portal](https://discord.com/developers/applications)) -- AI provider API key (OpenAI, OpenRouter, or Anthropic) -- SearXNG instance (optional, for search) - -### Installation - -1. Clone the repository: ```bash -git clone https://github.com/your-username/Daemon-Boyfriend.git -cd Daemon-Boyfriend +git clone https://github.com/your-username/discord-ai-bot.git +cd discord-ai-bot ``` -2. Create a virtual environment: -```bash -python -m venv .venv -source .venv/bin/activate # Linux/Mac -# or: .venv\Scripts\activate # Windows -``` +### 2. Configure the bot -3. Install dependencies: -```bash -pip install -r requirements.txt -``` - -4. Configure the bot: ```bash cp .env.example .env -# Edit .env with your tokens ``` -5. Run the bot: +Edit `.env` with your settings (see [Configuration](#configuration) below). + +### 3. Run with Docker + ```bash +docker compose up -d +``` + +Or run locally: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt python -m daemon_boyfriend ``` ## Configuration -Edit `.env` to configure the bot: +All configuration is done via environment variables in `.env`. +### Required Settings + +| Variable | Description | +|----------|-------------| +| `DISCORD_TOKEN` | Your Discord bot token | +| `AI_PROVIDER` | `openai`, `openrouter`, or `anthropic` | +| `OPENAI_API_KEY` | OpenAI API key (if using OpenAI) | +| `OPENROUTER_API_KEY` | OpenRouter API key (if using OpenRouter) | +| `ANTHROPIC_API_KEY` | Anthropic API key (if using Anthropic) | + +### Bot Identity + +| Variable | Default | Description | +|----------|---------|-------------| +| `BOT_NAME` | `AI Bot` | The bot's display name (used in responses) | +| `BOT_PERSONALITY` | `helpful and friendly` | Personality traits for the AI | +| `BOT_DESCRIPTION` | `I'm an AI assistant...` | Shown when mentioned without a message | +| `BOT_STATUS` | `for mentions` | Status message (shown as "Watching ...") | +| `SYSTEM_PROMPT` | (auto-generated) | Custom system prompt (overrides default) | + +### AI Settings + +| Variable | Default | Description | +|----------|---------|-------------| +| `AI_MODEL` | `gpt-4o` | Model to use | +| `AI_MAX_TOKENS` | `1024` | Maximum response length | +| `AI_TEMPERATURE` | `0.7` | Response creativity (0.0-2.0) | +| `MAX_CONVERSATION_HISTORY` | `20` | Messages to remember per user | + +### Example Configurations + +**Friendly Assistant:** ```env -# Required -DISCORD_TOKEN=your_bot_token - -# AI Provider (choose one) -AI_PROVIDER=openai # or: openrouter, anthropic -AI_MODEL=gpt-4o - -# API Keys (set the one you use) -OPENAI_API_KEY=sk-xxx -OPENROUTER_API_KEY=sk-or-xxx -ANTHROPIC_API_KEY=sk-ant-xxx - -# SearXNG (optional) -SEARXNG_BASE_URL=http://localhost:8080 +BOT_NAME=Helper Bot +BOT_PERSONALITY=friendly, helpful, and encouraging +BOT_DESCRIPTION=I'm here to help! Ask me anything. +BOT_STATUS=ready to help ``` -### AI Providers +**Technical Expert:** +```env +BOT_NAME=TechBot +BOT_PERSONALITY=knowledgeable, precise, and technical +BOT_DESCRIPTION=I'm a technical assistant. Ask me about programming, DevOps, or technology. +BOT_STATUS=for tech questions +``` + +**Custom System Prompt:** +```env +BOT_NAME=GameMaster +SYSTEM_PROMPT=You are GameMaster, a Dungeon Master for text-based RPG adventures. Stay in character, describe scenes vividly, and guide players through exciting quests. Use Discord markdown for emphasis. +``` + +## Discord Setup + +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Create a new application +3. Go to **Bot** and create a bot +4. Enable these **Privileged Gateway Intents**: + - Server Members Intent + - Message Content Intent +5. Copy the bot token to your `.env` file +6. Go to **OAuth2** → **URL Generator**: + - Select scope: `bot` + - Select permissions: `Send Messages`, `Read Message History`, `View Channels` +7. Use the generated URL to invite the bot to your server + +## Usage + +Mention the bot in any channel: + +``` +@YourBot what's the weather like? +@YourBot explain quantum computing +@YourBot help me write a poem +``` + +## AI Providers | Provider | Models | Notes | |----------|--------|-------| @@ -91,53 +130,18 @@ SEARXNG_BASE_URL=http://localhost:8080 | OpenRouter | 100+ models | Access to Llama, Mistral, Claude, etc. | | Anthropic | claude-3-5-sonnet, claude-3-opus | Direct Claude API | -## Docker Deployment - -### With Docker Compose (includes SearXNG) - -```bash -cp .env.example .env -# Edit .env with your tokens - -docker-compose up -d -``` - -### Bot Only (external SearXNG) - -```bash -docker build -t daemon-boyfriend . -docker run -d --env-file .env daemon-boyfriend -``` - -## Development - -### Project Structure +## Project Structure ``` src/daemon_boyfriend/ ├── bot.py # Main bot class ├── config.py # Configuration -├── cogs/ # Command modules -│ ├── ai_chat.py # Chat commands -│ ├── search.py # Search commands -│ └── admin.py # Admin commands -├── services/ # External integrations -│ ├── ai_service.py # AI provider factory -│ ├── providers/ # AI providers -│ ├── searxng.py # SearXNG client -│ └── conversation.py # Chat history -└── utils/ # Utilities - ├── logging.py - ├── rate_limiter.py - └── error_handler.py -``` - -### Running in Development - -Set `DISCORD_GUILD_ID` in `.env` for faster command sync during development: - -```env -DISCORD_GUILD_ID=123456789012345678 +├── cogs/ +│ └── ai_chat.py # Mention handler +└── services/ + ├── ai_service.py # AI provider factory + ├── providers/ # AI providers + └── conversation.py # Chat history ``` ## License diff --git a/docker-compose.yml b/docker-compose.yml index 677a762..15201a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: daemon-boyfriend: build: . @@ -9,39 +7,3 @@ services: - .env environment: - PYTHONUNBUFFERED=1 - depends_on: - searxng: - condition: service_healthy - networks: - - bot-network - - # Optional: SearXNG instance - # Comment out if you're using an external SearXNG instance - searxng: - image: searxng/searxng:latest - container_name: searxng - restart: unless-stopped - ports: - - "8080:8080" - volumes: - - ./searxng:/etc/searxng:rw - environment: - - SEARXNG_BASE_URL=http://localhost:8080/ - cap_drop: - - ALL - cap_add: - - CHOWN - - SETGID - - SETUID - healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - networks: - - bot-network - -networks: - bot-network: - driver: bridge diff --git a/src/daemon_boyfriend/bot.py b/src/daemon_boyfriend/bot.py index e68034e..99f7467 100644 --- a/src/daemon_boyfriend/bot.py +++ b/src/daemon_boyfriend/bot.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) class DaemonBoyfriend(commands.Bot): - """The main bot class for Daemon Boyfriend.""" + """The main bot class.""" def __init__(self) -> None: intents = discord.Intents.default() @@ -21,13 +21,13 @@ class DaemonBoyfriend(commands.Bot): intents.members = True super().__init__( - command_prefix=settings.command_prefix, + command_prefix="!", # Required but not used intents=intents, - help_command=None, # We use slash commands instead + help_command=None, ) async def setup_hook(self) -> None: - """Load cogs and sync commands on startup.""" + """Load cogs on startup.""" # Load all cogs cogs_path = Path(__file__).parent / "cogs" for cog_file in cogs_path.glob("*.py"): @@ -40,18 +40,6 @@ class DaemonBoyfriend(commands.Bot): except Exception as e: logger.error(f"Failed to load cog {cog_name}: {e}") - # Sync slash commands - if settings.discord_guild_id: - # Sync to specific guild for faster testing (instant) - guild = discord.Object(id=settings.discord_guild_id) - self.tree.copy_global_to(guild=guild) - await self.tree.sync(guild=guild) - logger.info(f"Synced commands to guild {settings.discord_guild_id}") - else: - # Global sync (can take up to 1 hour to propagate) - await self.tree.sync() - logger.info("Synced commands globally") - async def on_ready(self) -> None: """Called when the bot is ready.""" if self.user is None: @@ -59,31 +47,11 @@ class DaemonBoyfriend(commands.Bot): logger.info(f"Logged in as {self.user} (ID: {self.user.id})") logger.info(f"Connected to {len(self.guilds)} guild(s)") + logger.info(f"Bot name: {settings.bot_name}") - # Set activity status + # Set activity status from config activity = discord.Activity( type=discord.ActivityType.watching, - name="over the MSC group", + name=settings.bot_status, ) await self.change_presence(activity=activity) - - async def on_command_error( - self, - ctx: commands.Context, - error: commands.CommandError, # type: ignore[type-arg] - ) -> None: - """Global error handler for prefix commands.""" - if isinstance(error, commands.CommandNotFound): - return # Ignore unknown commands - - if isinstance(error, commands.MissingPermissions): - await ctx.send("You don't have permission to use this command.") - return - - if isinstance(error, commands.CommandOnCooldown): - await ctx.send(f"Command on cooldown. Try again in {error.retry_after:.1f}s") - return - - # Log unexpected errors - logger.error(f"Command error: {error}", exc_info=error) - await ctx.send("An unexpected error occurred.") diff --git a/src/daemon_boyfriend/cogs/admin.py b/src/daemon_boyfriend/cogs/admin.py deleted file mode 100644 index ac1c108..0000000 --- a/src/daemon_boyfriend/cogs/admin.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Admin cog - administrative commands for the bot.""" - -import logging -import platform -import sys -from datetime import datetime - -import discord -from discord import app_commands -from discord.ext import commands - -from daemon_boyfriend.config import settings -from daemon_boyfriend.services import SearXNGService - -logger = logging.getLogger(__name__) - - -class AdminCog(commands.Cog): - """Administrative commands for bot management.""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.start_time = datetime.now() - - @app_commands.command(name="ping", description="Check bot latency") - async def ping(self, interaction: discord.Interaction) -> None: - """Check the bot's latency.""" - latency = round(self.bot.latency * 1000) - await interaction.response.send_message(f"Pong! Latency: {latency}ms") - - @app_commands.command(name="status", description="Show bot status and info") - async def status(self, interaction: discord.Interaction) -> None: - """Show bot status and information.""" - # Calculate uptime - uptime = datetime.now() - self.start_time - hours, remainder = divmod(int(uptime.total_seconds()), 3600) - minutes, seconds = divmod(remainder, 60) - uptime_str = f"{hours}h {minutes}m {seconds}s" - - # Check SearXNG status - searxng = SearXNGService() - searxng_status = "Online" if await searxng.health_check() else "Offline" - - embed = discord.Embed( - title=f"{settings.bot_name} Status", - color=discord.Color.green(), - ) - - embed.add_field(name="Uptime", value=uptime_str, inline=True) - embed.add_field(name="Latency", value=f"{round(self.bot.latency * 1000)}ms", inline=True) - embed.add_field(name="Guilds", value=str(len(self.bot.guilds)), inline=True) - - embed.add_field(name="AI Provider", value=settings.ai_provider, inline=True) - embed.add_field(name="AI Model", value=settings.ai_model, inline=True) - embed.add_field(name="SearXNG", value=searxng_status, inline=True) - - embed.add_field( - name="Python", value=f"{sys.version_info.major}.{sys.version_info.minor}", inline=True - ) - embed.add_field(name="Discord.py", value=discord.__version__, inline=True) - embed.add_field(name="Platform", value=platform.system(), inline=True) - - await interaction.response.send_message(embed=embed) - - @app_commands.command(name="provider", description="Show or change AI provider") - @app_commands.describe(provider="The AI provider to switch to (admin only)") - @app_commands.choices( - provider=[ - app_commands.Choice(name="OpenAI", value="openai"), - app_commands.Choice(name="OpenRouter", value="openrouter"), - app_commands.Choice(name="Anthropic (Claude)", value="anthropic"), - ] - ) - async def provider( - self, - interaction: discord.Interaction, - provider: str | None = None, - ) -> None: - """Show current AI provider or change it (info only, actual change requires restart).""" - if provider is None: - # Just show current provider - await interaction.response.send_message( - f"Current AI provider: **{settings.ai_provider}**\nModel: **{settings.ai_model}**", - ephemeral=True, - ) - else: - # Inform that changing requires restart - await interaction.response.send_message( - f"To change the AI provider to **{provider}**, update the `AI_PROVIDER` " - f"and corresponding API key in your `.env` file, then restart the bot.", - ephemeral=True, - ) - - @app_commands.command(name="help", description="Show available commands") - async def help_command(self, interaction: discord.Interaction) -> None: - """Show help information.""" - embed = discord.Embed( - title=f"{settings.bot_name} - Help", - description="Here are the available commands:", - color=discord.Color.blue(), - ) - - embed.add_field( - name="Chat", - value=( - f"**@{settings.bot_name} ** - Chat with me by mentioning me\n" - "**/chat ** - Chat using slash command\n" - "**/clear** - Clear your conversation history" - ), - inline=False, - ) - - embed.add_field( - name="Search", - value=("**/search ** - Search the web\n**/image ** - Search for images"), - inline=False, - ) - - embed.add_field( - name="Info", - value=( - "**/ping** - Check bot latency\n" - "**/status** - Show bot status\n" - "**/provider** - Show current AI provider\n" - "**/help** - Show this help message" - ), - inline=False, - ) - - embed.set_footer(text=f"Made with love for the MSC group") - - await interaction.response.send_message(embed=embed) - - -async def setup(bot: commands.Bot) -> None: - """Load the Admin cog.""" - await bot.add_cog(AdminCog(bot)) diff --git a/src/daemon_boyfriend/cogs/ai_chat.py b/src/daemon_boyfriend/cogs/ai_chat.py index 161b4e3..14c386d 100644 --- a/src/daemon_boyfriend/cogs/ai_chat.py +++ b/src/daemon_boyfriend/cogs/ai_chat.py @@ -1,10 +1,9 @@ -"""AI Chat cog - handles chat commands and mention responses.""" +"""AI Chat cog - handles mention responses.""" import logging import re import discord -from discord import app_commands from discord.ext import commands from daemon_boyfriend.config import settings @@ -66,42 +65,13 @@ def split_message(content: str, max_length: int = MAX_MESSAGE_LENGTH) -> list[st class AIChatCog(commands.Cog): - """AI conversation commands and mention handling.""" + """AI conversation via mentions.""" def __init__(self, bot: commands.Bot) -> None: self.bot = bot self.ai_service = AIService() self.conversations = ConversationManager() - @app_commands.command(name="chat", description="Chat with Daemon Boyfriend") - @app_commands.describe(message="Your message to the bot") - async def chat(self, interaction: discord.Interaction, message: str) -> None: - """Slash command to chat with the AI.""" - await interaction.response.defer(thinking=True) - - try: - response_text = await self._generate_response(interaction.user.id, message) - - # Split long responses - chunks = split_message(response_text) - await interaction.followup.send(chunks[0]) - - # Send additional chunks as follow-up messages - for chunk in chunks[1:]: - await interaction.followup.send(chunk) - - except Exception as e: - logger.error(f"Chat error: {e}", exc_info=True) - await interaction.followup.send("Sorry, I encountered an error. Please try again.") - - @app_commands.command(name="clear", description="Clear your conversation history") - async def clear_history(self, interaction: discord.Interaction) -> None: - """Clear the user's conversation history.""" - self.conversations.clear_history(interaction.user.id) - await interaction.response.send_message( - "Your conversation history has been cleared!", ephemeral=True - ) - @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Respond when the bot is mentioned.""" @@ -117,8 +87,8 @@ class AIChatCog(commands.Cog): content = self._extract_message_content(message) if not content: - # Just a mention with no message - await message.reply(f"Hey {message.author.display_name}! How can I help you?") + # Just a mention with no message - use configured description + await message.reply(f"Hey {message.author.display_name}! {settings.bot_description}") return # Show typing indicator while generating response diff --git a/src/daemon_boyfriend/cogs/search.py b/src/daemon_boyfriend/cogs/search.py deleted file mode 100644 index 1b892bd..0000000 --- a/src/daemon_boyfriend/cogs/search.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Search cog - web search using SearXNG.""" - -import logging - -import discord -from discord import app_commands -from discord.ext import commands - -from daemon_boyfriend.services import SearchResponse, SearXNGService - -logger = logging.getLogger(__name__) - - -def create_search_embed(response: SearchResponse) -> discord.Embed: - """Create a Discord embed for search results. - - Args: - response: The search response - - Returns: - A formatted Discord embed - """ - embed = discord.Embed( - title=f"Search: {response.query}", - color=discord.Color.blue(), - ) - - if not response.results: - embed.description = "No results found." - return embed - - # Add results as fields - for i, result in enumerate(response.results, 1): - # Truncate content if too long - content = result.content - if len(content) > 200: - content = content[:197] + "..." - - embed.add_field( - name=f"{i}. {result.title[:100]}", - value=f"{content}\n[Link]({result.url})", - inline=False, - ) - - # Add footer with result count - embed.set_footer(text=f"Found {response.number_of_results} results") - - return embed - - -class SearchCog(commands.Cog): - """Web search commands using SearXNG.""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.search_service = SearXNGService() - - @app_commands.command(name="search", description="Search the web") - @app_commands.describe( - query="What to search for", - category="Search category", - ) - @app_commands.choices( - category=[ - app_commands.Choice(name="General", value="general"), - app_commands.Choice(name="Images", value="images"), - app_commands.Choice(name="News", value="news"), - app_commands.Choice(name="Science", value="science"), - app_commands.Choice(name="IT", value="it"), - app_commands.Choice(name="Videos", value="videos"), - ] - ) - async def search( - self, - interaction: discord.Interaction, - query: str, - category: str = "general", - ) -> None: - """Search the web using SearXNG.""" - await interaction.response.defer(thinking=True) - - try: - response = await self.search_service.search( - query=query, - categories=[category], - num_results=5, - ) - - embed = create_search_embed(response) - await interaction.followup.send(embed=embed) - - except Exception as e: - logger.error(f"Search error: {e}", exc_info=True) - await interaction.followup.send( - f"Search failed. Make sure SearXNG is running and accessible." - ) - - @app_commands.command(name="image", description="Search for images") - @app_commands.describe(query="What to search for") - async def image_search( - self, - interaction: discord.Interaction, - query: str, - ) -> None: - """Search for images using SearXNG.""" - await interaction.response.defer(thinking=True) - - try: - response = await self.search_service.search( - query=query, - categories=["images"], - num_results=5, - ) - - if not response.results: - await interaction.followup.send(f"No images found for: {query}") - return - - # Create embed with first image - embed = discord.Embed( - title=f"Image search: {query}", - color=discord.Color.purple(), - ) - - # Add image results - for i, result in enumerate(response.results[:5], 1): - embed.add_field( - name=f"{i}. {result.title[:50]}", - value=f"[View]({result.url})", - inline=True, - ) - - await interaction.followup.send(embed=embed) - - except Exception as e: - logger.error(f"Image search error: {e}", exc_info=True) - await interaction.followup.send( - "Image search failed. Make sure SearXNG is running and accessible." - ) - - -async def setup(bot: commands.Bot) -> None: - """Load the Search cog.""" - await bot.add_cog(SearchCog(bot)) diff --git a/src/daemon_boyfriend/config.py b/src/daemon_boyfriend/config.py index da40ae8..b1f064c 100644 --- a/src/daemon_boyfriend/config.py +++ b/src/daemon_boyfriend/config.py @@ -17,10 +17,6 @@ class Settings(BaseSettings): # Discord Configuration discord_token: str = Field(..., description="Discord bot token") - discord_guild_id: int | None = Field( - None, description="Test guild ID for faster command sync during development" - ) - command_prefix: str = Field("!", description="Legacy command prefix") # AI Provider Configuration ai_provider: Literal["openai", "openrouter", "anthropic"] = Field( @@ -35,25 +31,31 @@ class Settings(BaseSettings): openrouter_api_key: str | None = Field(None, description="OpenRouter API key") anthropic_api_key: str | None = Field(None, description="Anthropic API key") - # SearXNG Configuration - searxng_base_url: str = Field( - "http://localhost:8080", description="SearXNG instance URL" - ) - searxng_timeout: int = Field(10, description="Search timeout in seconds") - - # Rate Limiting - rate_limit_messages: int = Field(10, description="Messages per user per minute") - rate_limit_searches: int = Field(5, description="Searches per user per minute") - # Logging log_level: str = Field("INFO", description="Logging level") - # Bot Behavior - bot_name: str = Field("Daemon Boyfriend", description="Bot display name") + # Bot Identity + bot_name: str = Field("AI Bot", description="Bot display name") bot_personality: str = Field( - "helpful, witty, and slightly mischievous", + "helpful and friendly", description="Bot personality description for system prompt", ) + bot_description: str = Field( + "I'm an AI assistant here to help you.", + description="Bot description shown when mentioned without a message", + ) + bot_status: str = Field( + "for mentions", + description="Bot status message (shown as 'Watching ...')", + ) + + # System Prompt (optional override) + system_prompt: str | None = Field( + None, + description="Custom system prompt. If not set, a default is generated from bot_name and bot_personality", + ) + + # Conversation Settings max_conversation_history: int = Field( 20, description="Max messages to keep in conversation memory per user" ) diff --git a/src/daemon_boyfriend/services/__init__.py b/src/daemon_boyfriend/services/__init__.py index 57231dd..e00f057 100644 --- a/src/daemon_boyfriend/services/__init__.py +++ b/src/daemon_boyfriend/services/__init__.py @@ -3,14 +3,10 @@ from .ai_service import AIService from .conversation import ConversationManager from .providers import AIResponse, Message -from .searxng import SearchResponse, SearchResult, SearXNGService __all__ = [ "AIService", "AIResponse", "Message", "ConversationManager", - "SearXNGService", - "SearchResponse", - "SearchResult", ] diff --git a/src/daemon_boyfriend/services/ai_service.py b/src/daemon_boyfriend/services/ai_service.py index 98b2270..fce3193 100644 --- a/src/daemon_boyfriend/services/ai_service.py +++ b/src/daemon_boyfriend/services/ai_service.py @@ -93,9 +93,14 @@ class AIService: ) def get_system_prompt(self) -> str: - """Get the default system prompt for the bot.""" + """Get the system prompt for the bot.""" + # Use custom system prompt if provided + if self._config.system_prompt: + return self._config.system_prompt + + # Generate default system prompt from bot identity settings return ( f"You are {self._config.bot_name}, a {self._config.bot_personality} " - f"Discord bot for the MSC group. Keep your responses concise and engaging. " + f"Discord bot. Keep your responses concise and engaging. " f"You can use Discord markdown formatting in your responses." ) diff --git a/src/daemon_boyfriend/services/searxng.py b/src/daemon_boyfriend/services/searxng.py deleted file mode 100644 index d342444..0000000 --- a/src/daemon_boyfriend/services/searxng.py +++ /dev/null @@ -1,119 +0,0 @@ -"""SearXNG search service.""" - -import logging -from dataclasses import dataclass -from typing import Literal - -import aiohttp - -from daemon_boyfriend.config import settings - -logger = logging.getLogger(__name__) - - -@dataclass -class SearchResult: - """A single search result.""" - - title: str - url: str - content: str # snippet/description - engine: str - - -@dataclass -class SearchResponse: - """Response from a SearXNG search.""" - - query: str - results: list[SearchResult] - suggestions: list[str] - number_of_results: int - - -class SearXNGService: - """SearXNG search service client.""" - - def __init__( - self, - base_url: str | None = None, - timeout: int | None = None, - ) -> None: - self.base_url = (base_url or settings.searxng_base_url).rstrip("/") - self.timeout = aiohttp.ClientTimeout(total=timeout or settings.searxng_timeout) - - async def search( - self, - query: str, - categories: list[str] | None = None, - language: str = "en", - safesearch: Literal[0, 1, 2] = 1, # 0=off, 1=moderate, 2=strict - num_results: int = 5, - ) -> SearchResponse: - """Search using SearXNG. - - Args: - query: Search query - categories: Search categories (general, images, videos, news, it, science) - language: Language code (e.g., "en", "nl") - safesearch: Safe search level (0=off, 1=moderate, 2=strict) - num_results: Maximum number of results to return - - Returns: - SearchResponse with results - - Raises: - aiohttp.ClientError: On network errors - """ - params: dict[str, str | int] = { - "q": query, - "format": "json", - "language": language, - "safesearch": safesearch, - } - - if categories: - params["categories"] = ",".join(categories) - - logger.debug(f"Searching SearXNG: {query}") - - async with aiohttp.ClientSession(timeout=self.timeout) as session: - async with session.get(f"{self.base_url}/search", params=params) as response: - response.raise_for_status() - data = await response.json() - - # Parse results - results = [ - SearchResult( - title=r.get("title", "No title"), - url=r.get("url", ""), - content=r.get("content", "No description"), - engine=r.get("engine", "unknown"), - ) - for r in data.get("results", [])[:num_results] - ] - - return SearchResponse( - query=query, - results=results, - suggestions=data.get("suggestions", []), - number_of_results=data.get("number_of_results", len(results)), - ) - - async def health_check(self) -> bool: - """Check if the SearXNG instance is reachable. - - Returns: - True if healthy, False otherwise - """ - try: - async with aiohttp.ClientSession(timeout=self.timeout) as session: - # Try the search endpoint with a simple query - async with session.get( - f"{self.base_url}/search", - params={"q": "test", "format": "json"}, - ) as response: - return response.status == 200 - except Exception as e: - logger.error(f"SearXNG health check failed: {e}") - return False diff --git a/src/daemon_boyfriend/utils/__init__.py b/src/daemon_boyfriend/utils/__init__.py index ce8fbd3..2cd7c5b 100644 --- a/src/daemon_boyfriend/utils/__init__.py +++ b/src/daemon_boyfriend/utils/__init__.py @@ -1,13 +1,8 @@ """Utility modules.""" from .logging import get_logger, setup_logging -from .rate_limiter import RateLimiter, chat_limiter, rate_limited, search_limiter __all__ = [ "setup_logging", "get_logger", - "RateLimiter", - "rate_limited", - "chat_limiter", - "search_limiter", ] diff --git a/src/daemon_boyfriend/utils/rate_limiter.py b/src/daemon_boyfriend/utils/rate_limiter.py deleted file mode 100644 index 158845f..0000000 --- a/src/daemon_boyfriend/utils/rate_limiter.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Rate limiting utilities.""" - -import asyncio -import logging -from collections import defaultdict -from datetime import datetime, timedelta -from functools import wraps -from typing import Any, Callable, Coroutine, TypeVar - -import discord -from discord import app_commands - -from daemon_boyfriend.config import settings - -logger = logging.getLogger(__name__) - -T = TypeVar("T") - - -class RateLimiter: - """Per-user rate limiting. - - Tracks the number of calls per user within a time window - and rejects calls that exceed the limit. - """ - - def __init__(self, max_calls: int, period_seconds: int) -> None: - """Initialize the rate limiter. - - Args: - max_calls: Maximum calls allowed per period - period_seconds: Length of the period in seconds - """ - self.max_calls = max_calls - self.period = timedelta(seconds=period_seconds) - self.calls: dict[int, list[datetime]] = defaultdict(list) - self._lock = asyncio.Lock() - - async def is_allowed(self, user_id: int) -> bool: - """Check if a user is within rate limit and record the call. - - Args: - user_id: Discord user ID - - Returns: - True if allowed, False if rate limited - """ - async with self._lock: - now = datetime.now() - cutoff = now - self.period - - # Clean old entries - self.calls[user_id] = [t for t in self.calls[user_id] if t > cutoff] - - if len(self.calls[user_id]) >= self.max_calls: - return False - - self.calls[user_id].append(now) - return True - - def remaining(self, user_id: int) -> int: - """Get remaining calls for a user in current period. - - Args: - user_id: Discord user ID - - Returns: - Number of remaining calls - """ - now = datetime.now() - cutoff = now - self.period - recent = [t for t in self.calls[user_id] if t > cutoff] - return max(0, self.max_calls - len(recent)) - - def reset_time(self, user_id: int) -> float: - """Get seconds until rate limit resets for a user. - - Args: - user_id: Discord user ID - - Returns: - Seconds until reset, or 0 if not rate limited - """ - if not self.calls[user_id]: - return 0 - - oldest = min(self.calls[user_id]) - reset_at = oldest + self.period - remaining = (reset_at - datetime.now()).total_seconds() - return max(0, remaining) - - -def rate_limited( - limiter: RateLimiter, -) -> Callable[ - [Callable[..., Coroutine[Any, Any, T]]], - Callable[..., Coroutine[Any, Any, T | None]], -]: - """Decorator for rate-limited slash commands. - - Args: - limiter: The RateLimiter instance to use - - Returns: - Decorated function that checks rate limit before execution - """ - - def decorator( - func: Callable[..., Coroutine[Any, Any, T]], - ) -> Callable[..., Coroutine[Any, Any, T | None]]: - @wraps(func) - async def wrapper( - self: Any, interaction: discord.Interaction, *args: Any, **kwargs: Any - ) -> T | None: - if not await limiter.is_allowed(interaction.user.id): - reset_time = limiter.reset_time(interaction.user.id) - await interaction.response.send_message( - f"You're sending messages too quickly. Please wait {reset_time:.0f} seconds.", - ephemeral=True, - ) - return None - return await func(self, interaction, *args, **kwargs) - - return wrapper - - return decorator - - -# Pre-configured rate limiters -chat_limiter = RateLimiter( - max_calls=settings.rate_limit_messages, - period_seconds=60, -) - -search_limiter = RateLimiter( - max_calls=settings.rate_limit_searches, - period_seconds=60, -)