first commit

This commit is contained in:
2026-01-10 21:46:27 +01:00
parent d00593415d
commit 561f1a8fb1
30 changed files with 1932 additions and 1 deletions

50
.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Git
.git
.gitignore
# Environment
.env
.env.local
.env.*.local
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv
*.egg-info
dist
build
# Testing
.pytest_cache
.coverage
htmlcov
.tox
.nox
# Type checking
.mypy_cache
.dmypy.json
# IDE
.idea
.vscode
*.swp
*.swo
# Documentation
*.md
!README.md
# Docker
docker-compose*.yml
Dockerfile*
# Other
.pre-commit-config.yaml
tests
searxng

33
.env.example Normal file
View File

@@ -0,0 +1,33 @@
# Discord Configuration
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
AI_MODEL=gpt-4o
# Provider API Keys (set the ones you use)
OPENAI_API_KEY=sk-xxx
OPENROUTER_API_KEY=sk-or-xxx
ANTHROPIC_API_KEY=sk-ant-xxx
# AI Settings
AI_MAX_TOKENS=1024
AI_TEMPERATURE=0.7
# SearXNG Configuration
SEARXNG_BASE_URL=http://localhost:8080
SEARXNG_TIMEOUT=10
# Rate Limiting
RATE_LIMIT_MESSAGES=10 # Messages per user per minute
RATE_LIMIT_SEARCHES=5 # Searches per user per minute
# Logging
LOG_LEVEL=INFO
# Bot Behavior
BOT_NAME=Daemon Boyfriend
BOT_PERSONALITY=helpful, witty, and slightly mischievous
MAX_CONVERSATION_HISTORY=20

59
.gitignore vendored Normal file
View File

@@ -0,0 +1,59 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment
.env
.env.local
.env.*.local
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# Type checking
.mypy_cache/
.dmypy.json
# Docker
*.log
# OS
.DS_Store
Thumbs.db
# SearXNG config (may contain secrets)
searxng/settings.yml

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# Stage 1: Build dependencies
FROM python:3.11-slim as builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash botuser
# Copy wheels from builder and install
COPY --from=builder /app/wheels /wheels
RUN pip install --no-cache /wheels/* && rm -rf /wheels
# Copy application code
COPY src/ ./src/
# Set Python path
ENV PYTHONPATH=/app/src
ENV PYTHONUNBUFFERED=1
# Switch to non-root user
USER botuser
# Run the bot
CMD ["python", "-m", "daemon_boyfriend"]

145
README.md
View File

@@ -1,2 +1,145 @@
# MSC-Servo-Skull
# Daemon Boyfriend
A Discord bot for the MSC group with multi-provider AI chat and SearXNG web search.
## 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
- **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 |
## Quick Start
### Prerequisites
- 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
```
2. Create a virtual environment:
```bash
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# or: .venv\Scripts\activate # Windows
```
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:
```bash
python -m daemon_boyfriend
```
## Configuration
Edit `.env` to configure the bot:
```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
```
### AI Providers
| Provider | Models | Notes |
|----------|--------|-------|
| OpenAI | gpt-4o, gpt-4-turbo, gpt-3.5-turbo | Official OpenAI API |
| 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
```
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
```
## License
MIT

47
docker-compose.yml Normal file
View File

@@ -0,0 +1,47 @@
version: "3.8"
services:
daemon-boyfriend:
build: .
container_name: daemon-boyfriend
restart: unless-stopped
env_file:
- .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

51
pyproject.toml Normal file
View File

@@ -0,0 +1,51 @@
[project]
name = "daemon-boyfriend"
version = "1.0.0"
description = "AI-powered Discord bot for the MSC group with multi-provider support and SearXNG integration"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"discord.py>=2.3.0",
"anthropic>=0.18.0",
"openai>=1.12.0",
"aiohttp>=3.9.0",
"pydantic>=2.6.0",
"pydantic-settings>=2.2.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"black>=24.0.0",
"ruff>=0.2.0",
"mypy>=1.8.0",
]
[project.scripts]
daemon-boyfriend = "daemon_boyfriend.__main__:main"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.black]
line-length = 100
target-version = ["py311", "py312"]
[tool.ruff]
line-length = 100
select = ["E", "F", "I", "N", "W", "UP"]
[tool.mypy]
python_version = "3.11"
strict = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

14
requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
# Discord
discord.py>=2.3.0
# AI Providers
anthropic>=0.18.0
openai>=1.12.0
# HTTP Client
aiohttp>=3.9.0
# Configuration
pydantic>=2.6.0
pydantic-settings>=2.2.0
python-dotenv>=1.0.0

View File

View File

@@ -0,0 +1,16 @@
"""Entry point for the Daemon Boyfriend bot."""
from daemon_boyfriend.bot import DaemonBoyfriend
from daemon_boyfriend.config import settings
from daemon_boyfriend.utils.logging import setup_logging
def main() -> None:
"""Start the bot."""
setup_logging()
bot = DaemonBoyfriend()
bot.run(settings.discord_token)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
"""Main Discord bot class."""
import logging
from pathlib import Path
import discord
from discord.ext import commands
from daemon_boyfriend.config import settings
logger = logging.getLogger(__name__)
class DaemonBoyfriend(commands.Bot):
"""The main bot class for Daemon Boyfriend."""
def __init__(self) -> None:
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
intents.members = True
super().__init__(
command_prefix=settings.command_prefix,
intents=intents,
help_command=None, # We use slash commands instead
)
async def setup_hook(self) -> None:
"""Load cogs and sync commands on startup."""
# Load all cogs
cogs_path = Path(__file__).parent / "cogs"
for cog_file in cogs_path.glob("*.py"):
if cog_file.name.startswith("_"):
continue
cog_name = f"daemon_boyfriend.cogs.{cog_file.stem}"
try:
await self.load_extension(cog_name)
logger.info(f"Loaded cog: {cog_name}")
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:
return
logger.info(f"Logged in as {self.user} (ID: {self.user.id})")
logger.info(f"Connected to {len(self.guilds)} guild(s)")
# Set activity status
activity = discord.Activity(
type=discord.ActivityType.watching,
name="over the MSC group",
)
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

View File

@@ -0,0 +1,137 @@
"""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

@@ -0,0 +1,190 @@
"""AI Chat cog - handles chat commands and mention responses."""
import logging
import re
import discord
from discord import app_commands
from discord.ext import commands
from daemon_boyfriend.config import settings
from daemon_boyfriend.services import AIService, ConversationManager, Message
logger = logging.getLogger(__name__)
# Discord message character limit
MAX_MESSAGE_LENGTH = 2000
def split_message(content: str, max_length: int = MAX_MESSAGE_LENGTH) -> list[str]:
"""Split a long message into chunks that fit Discord's limit.
Tries to split on paragraph breaks, then sentence breaks, then word breaks.
"""
if len(content) <= max_length:
return [content]
chunks: list[str] = []
remaining = content
while remaining:
if len(remaining) <= max_length:
chunks.append(remaining)
break
# Find a good split point
split_point = max_length
# Try to split on paragraph break
para_break = remaining.rfind("\n\n", 0, max_length)
if para_break > max_length // 2:
split_point = para_break + 2
else:
# Try to split on line break
line_break = remaining.rfind("\n", 0, max_length)
if line_break > max_length // 2:
split_point = line_break + 1
else:
# Try to split on sentence
sentence_end = max(
remaining.rfind(". ", 0, max_length),
remaining.rfind("! ", 0, max_length),
remaining.rfind("? ", 0, max_length),
)
if sentence_end > max_length // 2:
split_point = sentence_end + 2
else:
# Fall back to word break
word_break = remaining.rfind(" ", 0, max_length)
if word_break > 0:
split_point = word_break + 1
chunks.append(remaining[:split_point].rstrip())
remaining = remaining[split_point:].lstrip()
return chunks
class AIChatCog(commands.Cog):
"""AI conversation commands and mention handling."""
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."""
# Ignore messages from bots
if message.author.bot:
return
# Check if bot is mentioned
if self.bot.user is None or self.bot.user not in message.mentions:
return
# Extract message content without the mention
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?")
return
# Show typing indicator while generating response
async with message.channel.typing():
try:
response_text = await self._generate_response(message.author.id, content)
# Split and send response
chunks = split_message(response_text)
await message.reply(chunks[0])
for chunk in chunks[1:]:
await message.channel.send(chunk)
except Exception as e:
logger.error(f"Mention response error: {e}", exc_info=True)
await message.reply("Sorry, I encountered an error. Please try again.")
def _extract_message_content(self, message: discord.Message) -> str:
"""Extract the actual message content, removing bot mentions."""
content = message.content
# Remove all mentions of the bot
if self.bot.user:
# Remove <@BOT_ID> and <@!BOT_ID> patterns
content = re.sub(
rf"<@!?{self.bot.user.id}>",
"",
content,
)
return content.strip()
async def _generate_response(self, user_id: int, user_message: str) -> str:
"""Generate an AI response for a user message.
Args:
user_id: Discord user ID
user_message: The user's message
Returns:
The AI's response text
"""
# Get conversation history
history = self.conversations.get_history(user_id)
# Add current message to history for the API call
messages = history + [Message(role="user", content=user_message)]
# Generate response
response = await self.ai_service.chat(
messages=messages,
system_prompt=self.ai_service.get_system_prompt(),
)
# Save the exchange to history
self.conversations.add_exchange(user_id, user_message, response.content)
logger.debug(
f"Generated response for user {user_id}: "
f"{len(response.content)} chars, {response.usage}"
)
return response.content
async def setup(bot: commands.Bot) -> None:
"""Load the AI Chat cog."""
await bot.add_cog(AIChatCog(bot))

View File

@@ -0,0 +1,144 @@
"""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

@@ -0,0 +1,78 @@
"""Configuration management using pydantic-settings."""
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# 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(
"openai", description="Which AI provider to use"
)
ai_model: str = Field("gpt-4o", description="AI model to use")
ai_max_tokens: int = Field(1024, ge=100, le=4096, description="Max tokens for AI response")
ai_temperature: float = Field(0.7, ge=0.0, le=2.0, description="AI temperature")
# Provider API Keys
openai_api_key: str | None = Field(None, description="OpenAI API key")
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_personality: str = Field(
"helpful, witty, and slightly mischievous",
description="Bot personality description for system prompt",
)
max_conversation_history: int = Field(
20, description="Max messages to keep in conversation memory per user"
)
def get_api_key(self) -> str:
"""Get the API key for the configured provider."""
key_map = {
"openai": self.openai_api_key,
"openrouter": self.openrouter_api_key,
"anthropic": self.anthropic_api_key,
}
key = key_map.get(self.ai_provider)
if not key:
raise ValueError(
f"No API key configured for provider '{self.ai_provider}'. "
f"Set {self.ai_provider.upper()}_API_KEY in your environment."
)
return key
# Global settings instance
settings = Settings()

View File

View File

@@ -0,0 +1,16 @@
"""Services for external integrations."""
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

@@ -0,0 +1,101 @@
"""AI Service - Factory and facade for AI providers."""
import logging
from typing import Literal
from daemon_boyfriend.config import Settings, settings
from .providers import (
AIProvider,
AIResponse,
AnthropicProvider,
Message,
OpenAIProvider,
OpenRouterProvider,
)
logger = logging.getLogger(__name__)
ProviderType = Literal["openai", "openrouter", "anthropic"]
class AIService:
"""Factory and facade for AI providers.
This class manages the creation and switching of AI providers,
and provides a unified interface for generating responses.
"""
def __init__(self, config: Settings | None = None) -> None:
self._config = config or settings
self._provider: AIProvider | None = None
self._init_provider()
def _init_provider(self) -> None:
"""Initialize the AI provider based on configuration."""
self._provider = self._create_provider(
self._config.ai_provider,
self._config.get_api_key(),
self._config.ai_model,
)
def _create_provider(self, provider_type: ProviderType, api_key: str, model: str) -> AIProvider:
"""Create a provider instance."""
providers: dict[ProviderType, type[AIProvider]] = {
"openai": OpenAIProvider,
"openrouter": OpenRouterProvider,
"anthropic": AnthropicProvider,
}
provider_class = providers.get(provider_type)
if not provider_class:
raise ValueError(f"Unknown provider: {provider_type}")
logger.info(f"Initializing {provider_type} provider with model {model}")
return provider_class(api_key=api_key, model=model)
@property
def provider(self) -> AIProvider:
"""Get the current provider."""
if self._provider is None:
raise RuntimeError("AI provider not initialized")
return self._provider
@property
def provider_name(self) -> str:
"""Get the name of the current provider."""
return self.provider.provider_name
@property
def model(self) -> str:
"""Get the current model name."""
return self._config.ai_model
async def chat(
self,
messages: list[Message],
system_prompt: str | None = None,
) -> AIResponse:
"""Generate a chat response.
Args:
messages: List of conversation messages
system_prompt: Optional system prompt
Returns:
AIResponse with the generated content
"""
return await self.provider.generate(
messages=messages,
system_prompt=system_prompt,
max_tokens=self._config.ai_max_tokens,
temperature=self._config.ai_temperature,
)
def get_system_prompt(self) -> str:
"""Get the default system prompt for the bot."""
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"You can use Discord markdown formatting in your responses."
)

View File

@@ -0,0 +1,81 @@
"""Conversation history management."""
import logging
from collections import defaultdict
from daemon_boyfriend.config import settings
from .providers import Message
logger = logging.getLogger(__name__)
class ConversationManager:
"""Manages conversation history per user.
Stores conversation messages in memory with a configurable
maximum history length per user.
"""
def __init__(self, max_history: int | None = None) -> None:
self.max_history = max_history or settings.max_conversation_history
self._conversations: dict[int, list[Message]] = defaultdict(list)
def get_history(self, user_id: int) -> list[Message]:
"""Get the conversation history for a user.
Args:
user_id: Discord user ID
Returns:
List of messages in the conversation
"""
return list(self._conversations[user_id])
def add_message(self, user_id: int, message: Message) -> None:
"""Add a message to a user's conversation history.
Args:
user_id: Discord user ID
message: The message to add
"""
history = self._conversations[user_id]
history.append(message)
# Trim history if it exceeds max length
if len(history) > self.max_history:
# Keep only the most recent messages
self._conversations[user_id] = history[-self.max_history :]
logger.debug(f"Trimmed conversation history for user {user_id}")
def add_exchange(self, user_id: int, user_message: str, assistant_message: str) -> None:
"""Add a user/assistant exchange to the conversation.
Args:
user_id: Discord user ID
user_message: The user's message
assistant_message: The assistant's response
"""
self.add_message(user_id, Message(role="user", content=user_message))
self.add_message(user_id, Message(role="assistant", content=assistant_message))
def clear_history(self, user_id: int) -> None:
"""Clear the conversation history for a user.
Args:
user_id: Discord user ID
"""
if user_id in self._conversations:
del self._conversations[user_id]
logger.debug(f"Cleared conversation history for user {user_id}")
def get_history_length(self, user_id: int) -> int:
"""Get the number of messages in a user's history.
Args:
user_id: Discord user ID
Returns:
Number of messages in history
"""
return len(self._conversations[user_id])

View File

@@ -0,0 +1,15 @@
"""AI Provider implementations."""
from .anthropic import AnthropicProvider
from .base import AIProvider, AIResponse, Message
from .openai import OpenAIProvider
from .openrouter import OpenRouterProvider
__all__ = [
"AIProvider",
"AIResponse",
"Message",
"OpenAIProvider",
"OpenRouterProvider",
"AnthropicProvider",
]

View File

@@ -0,0 +1,59 @@
"""Anthropic (Claude) provider implementation."""
import logging
import anthropic
from .base import AIProvider, AIResponse, Message
logger = logging.getLogger(__name__)
class AnthropicProvider(AIProvider):
"""Anthropic Claude API provider."""
def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514") -> None:
self.client = anthropic.AsyncAnthropic(api_key=api_key)
self.model = model
@property
def provider_name(self) -> str:
return "anthropic"
async def generate(
self,
messages: list[Message],
system_prompt: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.7,
) -> AIResponse:
"""Generate a response using Claude."""
# Build messages list (Anthropic format)
api_messages = [{"role": m.role, "content": m.content} for m in messages]
logger.debug(f"Sending {len(api_messages)} messages to Anthropic")
response = await self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
temperature=temperature,
system=system_prompt or "",
messages=api_messages, # type: ignore[arg-type]
)
# Extract text from response
content = ""
for block in response.content:
if block.type == "text":
content += block.text
usage = {
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
}
return AIResponse(
content=content,
model=response.model,
usage=usage,
)

View File

@@ -0,0 +1,52 @@
"""Abstract base class for AI providers."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Message:
"""A chat message."""
role: str # "user", "assistant", "system"
content: str
@dataclass
class AIResponse:
"""Response from an AI provider."""
content: str
model: str
usage: dict[str, int] # Token usage info
class AIProvider(ABC):
"""Abstract base class for AI providers."""
@abstractmethod
async def generate(
self,
messages: list[Message],
system_prompt: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.7,
) -> AIResponse:
"""Generate a response from the AI model.
Args:
messages: List of conversation messages
system_prompt: Optional system prompt
max_tokens: Maximum tokens in response
temperature: Sampling temperature
Returns:
AIResponse with the generated content
"""
pass
@property
@abstractmethod
def provider_name(self) -> str:
"""Return the name of this provider."""
pass

View File

@@ -0,0 +1,61 @@
"""OpenAI provider implementation."""
import logging
from openai import AsyncOpenAI
from .base import AIProvider, AIResponse, Message
logger = logging.getLogger(__name__)
class OpenAIProvider(AIProvider):
"""OpenAI API provider."""
def __init__(self, api_key: str, model: str = "gpt-4o") -> None:
self.client = AsyncOpenAI(api_key=api_key)
self.model = model
@property
def provider_name(self) -> str:
return "openai"
async def generate(
self,
messages: list[Message],
system_prompt: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.7,
) -> AIResponse:
"""Generate a response using OpenAI."""
# Build messages list
api_messages: list[dict[str, str]] = []
if system_prompt:
api_messages.append({"role": "system", "content": system_prompt})
api_messages.extend([{"role": m.role, "content": m.content} for m in messages])
logger.debug(f"Sending {len(api_messages)} messages to OpenAI")
response = await self.client.chat.completions.create(
model=self.model,
max_tokens=max_tokens,
temperature=temperature,
messages=api_messages, # type: ignore[arg-type]
)
content = response.choices[0].message.content or ""
usage = {}
if response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
return AIResponse(
content=content,
model=response.model,
usage=usage,
)

View File

@@ -0,0 +1,77 @@
"""OpenRouter provider implementation.
OpenRouter uses an OpenAI-compatible API, so we extend the OpenAI provider.
"""
import logging
from openai import AsyncOpenAI
from .base import AIProvider, AIResponse, Message
logger = logging.getLogger(__name__)
# OpenRouter API base URL
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
class OpenRouterProvider(AIProvider):
"""OpenRouter API provider.
OpenRouter provides access to 100+ models through an OpenAI-compatible API.
"""
def __init__(self, api_key: str, model: str = "openai/gpt-4o") -> None:
self.client = AsyncOpenAI(
api_key=api_key,
base_url=OPENROUTER_BASE_URL,
)
self.model = model
@property
def provider_name(self) -> str:
return "openrouter"
async def generate(
self,
messages: list[Message],
system_prompt: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.7,
) -> AIResponse:
"""Generate a response using OpenRouter."""
# Build messages list
api_messages: list[dict[str, str]] = []
if system_prompt:
api_messages.append({"role": "system", "content": system_prompt})
api_messages.extend([{"role": m.role, "content": m.content} for m in messages])
logger.debug(f"Sending {len(api_messages)} messages to OpenRouter ({self.model})")
response = await self.client.chat.completions.create(
model=self.model,
max_tokens=max_tokens,
temperature=temperature,
messages=api_messages, # type: ignore[arg-type]
extra_headers={
"HTTP-Referer": "https://github.com/daemon-boyfriend",
"X-Title": "Daemon Boyfriend",
},
)
content = response.choices[0].message.content or ""
usage = {}
if response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
return AIResponse(
content=content,
model=response.model,
usage=usage,
)

View File

@@ -0,0 +1,119 @@
"""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

@@ -0,0 +1,13 @@
"""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

@@ -0,0 +1,66 @@
"""Global error handling for the bot."""
import logging
import traceback
import discord
from discord import app_commands
from discord.ext import commands
logger = logging.getLogger(__name__)
class ErrorHandler(commands.Cog):
"""Global error handling cog."""
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
# Set up the app command error handler
self.bot.tree.on_error = self.on_app_command_error
async def on_app_command_error(
self,
interaction: discord.Interaction,
error: app_commands.AppCommandError,
) -> None:
"""Handle slash command errors.
Args:
interaction: The interaction that caused the error
error: The error that was raised
"""
# Determine the error message
if isinstance(error, app_commands.CommandOnCooldown):
message = f"This command is on cooldown. Try again in {error.retry_after:.1f} seconds."
elif isinstance(error, app_commands.MissingPermissions):
message = "You don't have permission to use this command."
elif isinstance(error, app_commands.BotMissingPermissions):
message = "I don't have the required permissions to do that."
elif isinstance(error, app_commands.CommandNotFound):
message = "That command doesn't exist."
elif isinstance(error, app_commands.TransformerError):
message = f"Invalid input: {error}"
elif isinstance(error, app_commands.CheckFailure):
message = "You can't use this command."
else:
# Log unexpected errors
logger.error(
f"Unhandled app command error: {error}",
exc_info=error,
)
message = "An unexpected error occurred. Please try again later."
# Send the error message
try:
if interaction.response.is_done():
await interaction.followup.send(message, ephemeral=True)
else:
await interaction.response.send_message(message, ephemeral=True)
except discord.HTTPException:
# If we can't send a message, just log it
logger.error(f"Failed to send error message: {message}")
async def setup(bot: commands.Bot) -> None:
"""Load the error handler cog."""
await bot.add_cog(ErrorHandler(bot))

View File

@@ -0,0 +1,44 @@
"""Logging configuration for the bot."""
import logging
import sys
from daemon_boyfriend.config import settings
def setup_logging() -> None:
"""Configure application-wide logging."""
# Create formatter
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(settings.log_level)
# Remove existing handlers
root_logger.handlers.clear()
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# Reduce noise from discord.py
logging.getLogger("discord").setLevel(logging.WARNING)
logging.getLogger("discord.http").setLevel(logging.WARNING)
logging.getLogger("discord.gateway").setLevel(logging.WARNING)
# Reduce noise from aiohttp
logging.getLogger("aiohttp").setLevel(logging.WARNING)
# Reduce noise from httpx (used by openai/anthropic)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""Get a logger with the given name."""
return logging.getLogger(name)

View File

@@ -0,0 +1,138 @@
"""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,
)