- 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.
294 lines
11 KiB
Python
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") |