Files
discord-fishbowl/src/bot/discord_client.py
root 5480219901 Fix comprehensive system issues and implement proper vector database backend selection
- Fix remaining datetime timezone errors across all database operations
- Implement dynamic vector database backend (Qdrant/ChromaDB) based on install.py configuration
- Add LLM timeout handling with immediate fallback responses for slow self-hosted models
- Use proper install.py configuration (2000 max tokens, 5min timeout, correct LLM endpoint)
- Fix PostgreSQL schema to use timezone-aware columns throughout
- Implement async LLM request handling with background processing
- Add configurable prompt limits and conversation history controls
- Start missing database services (PostgreSQL, Redis) automatically
- Fix environment variable mapping between install.py and application code
- Resolve all timezone-naive vs timezone-aware datetime conflicts

System now properly uses Qdrant vector database as specified in install.py instead of hardcoded ChromaDB.
Characters respond immediately with fallback messages during long LLM processing times.
All database timezone errors resolved with proper timestamptz columns.
2025-07-05 21:31:52 -07:00

294 lines
11 KiB
Python

import discord
from discord.ext import commands, tasks
import asyncio
from typing import Optional, Dict, Any
import logging
from datetime import datetime, timedelta, timezone
from utils.config import get_settings
from utils.logging import log_error_with_context, log_system_health
from database.connection import get_db_session
from database.models import Message, Conversation, Character
from sqlalchemy import select, and_
logger = logging.getLogger(__name__)
class FishbowlBot(commands.Bot):
def __init__(self, conversation_engine):
settings = get_settings()
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
intents.members = True
super().__init__(
command_prefix='!',
intents=intents,
help_command=None
)
self.settings = settings
self.conversation_engine = conversation_engine
self.guild_id = int(settings.discord.guild_id)
self.channel_id = int(settings.discord.channel_id)
self.target_guild = None
self.target_channel = None
# Health monitoring
self.health_check_task = None
self.last_heartbeat = datetime.now(timezone.utc)
async def setup_hook(self):
"""Called when the bot is starting up"""
logger.info("Bot setup hook called")
# Start health monitoring
self.health_check_task = self.health_check_loop.start()
# Sync commands (if any)
try:
synced = await self.tree.sync()
logger.info(f"Synced {len(synced)} command(s)")
except Exception as e:
logger.error(f"Failed to sync commands: {e}")
async def on_ready(self):
"""Called when the bot is ready"""
logger.info(f'Bot logged in as {self.user} (ID: {self.user.id})')
# Get target guild and channel
self.target_guild = self.get_guild(self.guild_id)
if not self.target_guild:
logger.error(f"Could not find guild with ID {self.guild_id}")
return
self.target_channel = self.target_guild.get_channel(self.channel_id)
if not self.target_channel:
logger.error(f"Could not find channel with ID {self.channel_id}")
return
logger.info(f"Connected to guild: {self.target_guild.name}")
logger.info(f"Target channel: {self.target_channel.name}")
# Initialize conversation engine
await self.conversation_engine.initialize(self)
# Update heartbeat
self.last_heartbeat = datetime.now(timezone.utc)
log_system_health("discord_bot", "connected", {
"guild": self.target_guild.name,
"channel": self.target_channel.name,
"latency": round(self.latency * 1000, 2)
})
async def on_message(self, message: discord.Message):
"""Handle incoming messages"""
# Ignore messages from the bot itself
if message.author == self.user:
return
# Only process messages from the target channel
if message.channel.id != self.channel_id:
return
# Log the message for analytics
await self._log_discord_message(message)
# Process commands
await self.process_commands(message)
async def on_message_edit(self, before: discord.Message, after: discord.Message):
"""Handle message edits"""
if after.author == self.user or after.channel.id != self.channel_id:
return
logger.info(f"Message edited by {after.author}: {before.content} -> {after.content}")
async def on_message_delete(self, message: discord.Message):
"""Handle message deletions"""
if message.author == self.user or message.channel.id != self.channel_id:
return
logger.info(f"Message deleted by {message.author}: {message.content}")
async def on_error(self, event: str, *args, **kwargs):
"""Handle bot errors"""
logger.error(f"Bot error in event {event}: {args}")
log_error_with_context(
Exception(f"Bot error in {event}"),
{"event": event, "args": str(args)}
)
async def on_disconnect(self):
"""Handle bot disconnect"""
logger.warning("Bot disconnected from Discord")
log_system_health("discord_bot", "disconnected")
async def on_resumed(self):
"""Handle bot reconnection"""
logger.info("Bot reconnected to Discord")
self.last_heartbeat = datetime.now(timezone.utc)
log_system_health("discord_bot", "reconnected")
async def send_character_message(self, character_name: str, content: str,
conversation_id: Optional[int] = None,
reply_to_message_id: Optional[int] = None) -> Optional[discord.Message]:
"""Send a message as a character"""
if not self.target_channel:
logger.error("No target channel available")
return None
try:
# Get the character's webhook or create one
webhook = await self._get_character_webhook(character_name)
if not webhook:
logger.error(f"Could not get webhook for character {character_name}")
return None
# Send the message via webhook
discord_message = await webhook.send(
content=content,
username=character_name,
wait=True
)
# Store message in database
await self._store_character_message(
character_name=character_name,
content=content,
discord_message_id=str(discord_message.id),
conversation_id=conversation_id,
reply_to_message_id=reply_to_message_id
)
logger.info(f"Character {character_name} sent message: {content[:50]}...")
return discord_message
except Exception as e:
log_error_with_context(e, {
"character_name": character_name,
"content_length": len(content),
"conversation_id": conversation_id
})
return None
async def _get_character_webhook(self, character_name: str) -> Optional[discord.Webhook]:
"""Get or create a webhook for a character"""
try:
# Check if webhook already exists
webhooks = await self.target_channel.webhooks()
for webhook in webhooks:
if webhook.name == f"fishbowl-{character_name.lower()}":
return webhook
# Create new webhook
webhook = await self.target_channel.create_webhook(
name=f"fishbowl-{character_name.lower()}",
reason=f"Webhook for character {character_name}"
)
logger.info(f"Created webhook for character {character_name}")
return webhook
except Exception as e:
log_error_with_context(e, {"character_name": character_name})
return None
async def _store_character_message(self, character_name: str, content: str,
discord_message_id: str,
conversation_id: Optional[int] = None,
reply_to_message_id: Optional[int] = None):
"""Store a character message in the database"""
try:
async with get_db_session() as session:
# Get character
character_query = select(Character).where(Character.name == character_name)
character = await session.scalar(character_query)
if not character:
logger.error(f"Character {character_name} not found in database")
return
# Create message record
message = Message(
character_id=character.id,
conversation_id=conversation_id,
content=content,
discord_message_id=discord_message_id,
response_to_message_id=reply_to_message_id,
timestamp=datetime.now(timezone.utc)
)
session.add(message)
await session.commit()
# Update character's last activity
character.last_active = datetime.now(timezone.utc)
character.last_message_id = message.id
await session.commit()
except Exception as e:
log_error_with_context(e, {
"character_name": character_name,
"discord_message_id": discord_message_id
})
async def _log_discord_message(self, message: discord.Message):
"""Log external Discord messages for analytics"""
try:
# Store external message for context
logger.info(f"External message from {message.author}: {message.content[:100]}...")
# You could store external messages in a separate table if needed
# This helps with conversation context and analytics
except Exception as e:
log_error_with_context(e, {"message_id": str(message.id)})
@tasks.loop(minutes=5)
async def health_check_loop(self):
"""Periodic health check"""
try:
# Check bot connectivity
if self.is_closed() or not self.user:
log_system_health("discord_bot", "disconnected")
return
# Check heartbeat
time_since_heartbeat = datetime.now(timezone.utc) - self.last_heartbeat
if time_since_heartbeat > timedelta(minutes=10):
log_system_health("discord_bot", "heartbeat_stale", {
"minutes_since_heartbeat": time_since_heartbeat.total_seconds() / 60
})
# Update heartbeat
self.last_heartbeat = datetime.now(timezone.utc)
# Log health metrics
uptime_minutes = 0
if self.user and hasattr(self.user, 'created_at') and self.user.created_at:
uptime_minutes = (datetime.now(timezone.utc) - self.user.created_at.replace(tzinfo=timezone.utc)).total_seconds() / 60
log_system_health("discord_bot", "healthy", {
"latency_ms": round(self.latency * 1000, 2),
"guild_count": len(self.guilds),
"uptime_minutes": uptime_minutes
})
except Exception as e:
log_error_with_context(e, {"component": "health_check"})
async def close(self):
"""Clean shutdown"""
logger.info("Shutting down Discord bot")
if self.health_check_task:
self.health_check_task.cancel()
# Stop conversation engine
if self.conversation_engine:
await self.conversation_engine.stop()
await super().close()
logger.info("Discord bot shut down complete")