first commit
This commit is contained in:
0
src/daemon_boyfriend/cogs/__init__.py
Normal file
0
src/daemon_boyfriend/cogs/__init__.py
Normal file
137
src/daemon_boyfriend/cogs/admin.py
Normal file
137
src/daemon_boyfriend/cogs/admin.py
Normal 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))
|
||||
190
src/daemon_boyfriend/cogs/ai_chat.py
Normal file
190
src/daemon_boyfriend/cogs/ai_chat.py
Normal 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))
|
||||
144
src/daemon_boyfriend/cogs/search.py
Normal file
144
src/daemon_boyfriend/cogs/search.py
Normal 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))
|
||||
Reference in New Issue
Block a user