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
This commit is contained in:
2026-01-11 18:41:23 +01:00
parent 0607b05e3b
commit b05fa034a6
14 changed files with 182 additions and 801 deletions

View File

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

View File

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

206
README.md
View File

@@ -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 <message>` | Chat with the bot |
| `/chat <message>` | Chat via slash command |
| `/search <query>` | Search the web |
| `/image <query>` | 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

View File

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

View File

@@ -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.")

View File

@@ -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} <message>** - Chat with me by mentioning me\n"
"**/chat <message>** - Chat using slash command\n"
"**/clear** - Clear your conversation history"
),
inline=False,
)
embed.add_field(
name="Search",
value=("**/search <query>** - Search the web\n**/image <query>** - 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))

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
)

View File

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

View File

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

View File

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