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

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