From 6620d07b57f11fc73d0e2b7d87ef8e8ccdbde12b Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 6 Jun 2025 05:26:54 +0000 Subject: [PATCH 1/2] Refactor logging cog to use UI components --- cogs/logging_cog.py | 52 ++++++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/cogs/logging_cog.py b/cogs/logging_cog.py index 7101701..b7bdacf 100644 --- a/cogs/logging_cog.py +++ b/cogs/logging_cog.py @@ -1,6 +1,6 @@ import discord from discord.ext import commands, tasks -from discord import AllowedMentions +from discord import AllowedMentions, ui import datetime import asyncio import aiohttp # Added for webhook sending @@ -93,8 +93,8 @@ class LoggingCog(commands.Cog): self.start_audit_log_poller_when_ready() ) # Keep this for initial start - class LogView(discord.Embed): - """Embed wrapper used for logging messages.""" + class LogView(ui.LayoutView): + """View used for logging messages.""" def __init__( self, @@ -105,14 +105,34 @@ class LoggingCog(commands.Cog): author: Optional[discord.abc.User], footer: Optional[str], ) -> None: - super().__init__(title=title, description=description, color=color) + super().__init__(timeout=None) + self.container = ui.Container(accent_colour=color) + self.add_item(self.container) if author is not None: - self.set_author(name=author.display_name, icon_url=author.display_avatar.url) + header = ui.Section( + accessory=ui.Thumbnail(media=author.display_avatar.url) + ) + else: + header = ui.Section() + + header.add_item(ui.TextDisplay(f"**{title}**")) + if description: + header.add_item(ui.TextDisplay(description)) + self.container.add_item(header) + self.container.add_item( + ui.Separator(spacing=discord.SeparatorSpacing.small) + ) + footer_text = footer or f"Bot ID: {bot.user.id}" + ( f" | User ID: {author.id}" if author else "" ) - self.set_footer(text=footer_text) + self.footer_display = ui.TextDisplay(footer_text) + self.container.add_item(self.footer_display) + + def add_field(self, name: str, value: str, inline: bool = False) -> None: + self.container.add_item(ui.TextDisplay(f"**{name}:** {value}")) + def _user_display(self, user: Union[discord.Member, discord.User]) -> str: """Return display name, username and ID string for a user.""" display = user.display_name if isinstance(user, discord.Member) else user.name @@ -183,7 +203,7 @@ class LoggingCog(commands.Cog): await self.session.close() log.info("aiohttp ClientSession closed for LoggingCog.") - async def _send_log_embed(self, guild: discord.Guild, embed: discord.Embed) -> None: + async def _send_log_embed(self, guild: discord.Guild, embed: ui.LayoutView) -> None: """Sends the log view via the configured webhook for the guild.""" if not self.session or self.session.closed: log.error( @@ -204,7 +224,7 @@ class LoggingCog(commands.Cog): client=self.bot, ) await webhook.send( - embed=embed, + view=embed, username=f"{self.bot.user.name} Logs", avatar_url=self.bot.user.display_avatar.url, allowed_mentions=AllowedMentions.none(), @@ -240,13 +260,13 @@ class LoggingCog(commands.Cog): color: discord.Color = discord.Color.blue(), author: Optional[Union[discord.User, discord.Member]] = None, footer: Optional[str] = None, - ) -> discord.Embed: - """Creates a standardized log embed.""" + ) -> ui.LayoutView: + """Creates a standardized log view.""" return self.LogView(self.bot, title, description, color, author, footer) def _add_id_footer( self, - embed: discord.Embed, + embed: ui.LayoutView, obj: Union[ discord.Member, discord.User, @@ -262,12 +282,10 @@ class LoggingCog(commands.Cog): """Adds an ID to the footer text if possible.""" target_id = obj_id or (obj.id if obj else None) if target_id: - existing_footer = embed.footer.text or "" - separator = " | " if existing_footer else "" - embed.set_footer( - text=f"{existing_footer}{separator}{id_name}: {target_id}", - icon_url=embed.footer.icon_url, - ) + existing_footer = getattr(embed, "footer_display", None) + if existing_footer: + sep = " | " if existing_footer.content else "" + existing_footer.content += f"{sep}{id_name}: {target_id}" async def _check_log_enabled(self, guild_id: int, event_key: str) -> bool: """Checks if logging is enabled for a specific event key in a guild.""" From 870cd6e4a0d9bfe735d3c3c2133e7bee576ef359 Mon Sep 17 00:00:00 2001 From: Slipstream Date: Thu, 5 Jun 2025 23:31:59 -0600 Subject: [PATCH 2/2] Remove AI cog from the list of cogs to skip during startup --- discord_bot_sync_api.py | 657 ++++++++++++++++++++++++++++++++++++++++ main.py | 1 - 2 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 discord_bot_sync_api.py diff --git a/discord_bot_sync_api.py b/discord_bot_sync_api.py new file mode 100644 index 0000000..b7515a7 --- /dev/null +++ b/discord_bot_sync_api.py @@ -0,0 +1,657 @@ +import os +import json +import asyncio +import datetime +from typing import Dict, List, Optional, Any, Union +from fastapi import FastAPI, HTTPException, Depends, Header, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles # Added for static files +from fastapi.responses import FileResponse # Added for serving HTML +from pydantic import BaseModel, Field +import discord +from discord.ext import commands +import aiohttp +import threading +from typing import Optional # Added for GurtCog type hint + +# This file contains the API endpoints for syncing conversations between +# the Flutter app and the Discord bot, AND the Gurt stats endpoint. + +# --- Placeholder for GurtCog instance and bot instance --- +# These need to be set by the script that starts the bot and API server +# Import GurtCog and ModLogCog conditionally to avoid dependency issues +try: + from gurt.cog import GurtCog # Import GurtCog for type hint and access + from cogs.mod_log_cog import ModLogCog # Import ModLogCog for type hint + + gurt_cog_instance: Optional[GurtCog] = None + mod_log_cog_instance: Optional[ModLogCog] = None # Placeholder for ModLogCog +except ImportError as e: + print(f"Warning: Could not import GurtCog or ModLogCog: {e}") + # Use Any type as fallback + from typing import Any + + gurt_cog_instance: Optional[Any] = None + mod_log_cog_instance: Optional[Any] = None +bot_instance = None # Will be set to the Discord bot instance + +# ============= Models ============= + + +class SyncedMessage(BaseModel): + content: str + role: str # "user", "assistant", or "system" + timestamp: datetime.datetime + reasoning: Optional[str] = None + usage_data: Optional[Dict[str, Any]] = None + + +class UserSettings(BaseModel): + # General settings + model_id: str = "openai/gpt-3.5-turbo" + temperature: float = 0.7 + max_tokens: int = 1000 + + # Reasoning settings + reasoning_enabled: bool = False + reasoning_effort: str = "medium" # "low", "medium", "high" + + # Web search settings + web_search_enabled: bool = False + + # System message + system_message: Optional[str] = None + + # Character settings + character: Optional[str] = None + character_info: Optional[str] = None + character_breakdown: bool = False + custom_instructions: Optional[str] = None + + # UI settings + advanced_view_enabled: bool = False + streaming_enabled: bool = True + + # Last updated timestamp + last_updated: datetime.datetime = Field(default_factory=datetime.datetime.now) + sync_source: str = "discord" # "discord" or "flutter" + + +class SyncedConversation(BaseModel): + id: str + title: str + messages: List[SyncedMessage] + created_at: datetime.datetime + updated_at: datetime.datetime + model_id: str + sync_source: str = "discord" # "discord" or "flutter" + last_synced_at: Optional[datetime.datetime] = None + + # Conversation-specific settings + reasoning_enabled: bool = False + reasoning_effort: str = "medium" # "low", "medium", "high" + temperature: float = 0.7 + max_tokens: int = 1000 + web_search_enabled: bool = False + system_message: Optional[str] = None + + # Character-related settings + character: Optional[str] = None + character_info: Optional[str] = None + character_breakdown: bool = False + custom_instructions: Optional[str] = None + + +class SyncRequest(BaseModel): + conversations: List[SyncedConversation] + last_sync_time: Optional[datetime.datetime] = None + user_settings: Optional[UserSettings] = None + + +class SettingsSyncRequest(BaseModel): + user_settings: UserSettings + + +class SyncResponse(BaseModel): + success: bool + message: str + conversations: List[SyncedConversation] = [] + user_settings: Optional[UserSettings] = None + + +# ============= Storage ============= + +# Files to store synced data +SYNC_DATA_FILE = "data/synced_conversations.json" +USER_SETTINGS_FILE = "data/synced_user_settings.json" + +# Create data directory if it doesn't exist +os.makedirs(os.path.dirname(SYNC_DATA_FILE), exist_ok=True) + +# In-memory storage for conversations and settings +user_conversations: Dict[str, List[SyncedConversation]] = {} +user_settings: Dict[str, UserSettings] = {} + + +# Load conversations from file +def load_conversations(): + global user_conversations + if os.path.exists(SYNC_DATA_FILE): + try: + with open(SYNC_DATA_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + # Convert string keys (user IDs) back to strings + user_conversations = { + k: [SyncedConversation.model_validate(conv) for conv in v] + for k, v in data.items() + } + print(f"Loaded synced conversations for {len(user_conversations)} users") + except Exception as e: + print(f"Error loading synced conversations: {e}") + user_conversations = {} + + +# Save conversations to file +def save_conversations(): + try: + # Convert to JSON-serializable format + serializable_data = { + user_id: [conv.model_dump() for conv in convs] + for user_id, convs in user_conversations.items() + } + with open(SYNC_DATA_FILE, "w", encoding="utf-8") as f: + json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving synced conversations: {e}") + + +# Load user settings from file +def load_user_settings(): + global user_settings + if os.path.exists(USER_SETTINGS_FILE): + try: + with open(USER_SETTINGS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + # Convert string keys (user IDs) back to strings + user_settings = { + k: UserSettings.model_validate(v) for k, v in data.items() + } + print(f"Loaded synced settings for {len(user_settings)} users") + except Exception as e: + print(f"Error loading synced user settings: {e}") + user_settings = {} + + +# Save user settings to file +def save_all_user_settings(): + try: + # Convert to JSON-serializable format + serializable_data = { + user_id: settings.model_dump() + for user_id, settings in user_settings.items() + } + with open(USER_SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving synced user settings: {e}") + + +# ============= Discord OAuth Verification ============= + + +async def verify_discord_token(authorization: str = Header(None)) -> str: + """Verify the Discord token and return the user ID""" + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header missing") + + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid authorization format") + + token = authorization.replace("Bearer ", "") + + # Verify the token with Discord + async with aiohttp.ClientSession() as session: + headers = {"Authorization": f"Bearer {token}"} + async with session.get( + "https://discord.com/api/v10/users/@me", headers=headers + ) as resp: + if resp.status != 200: + raise HTTPException(status_code=401, detail="Invalid Discord token") + + user_data = await resp.json() + return user_data["id"] + + +# ============= API Setup ============= + +# API Configuration +API_BASE_PATH = "/discordapi" # Base path for the API +SSL_CERT_FILE = "/etc/letsencrypt/live/slipstreamm.dev/fullchain.pem" +SSL_KEY_FILE = "/etc/letsencrypt/live/slipstreamm.dev/privkey.pem" + +# Create the main FastAPI app +app = FastAPI(title="Discord Bot Sync API") + +# Create a sub-application for the API +api_app = FastAPI( + title="Discord Bot Sync API", docs_url="/docs", openapi_url="/openapi.json" +) + +# Mount the API app at the base path +app.mount(API_BASE_PATH, api_app) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Adjust this in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Also add CORS to the API app +api_app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Adjust this in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Initialize by loading saved data +@app.on_event("startup") +async def startup_event(): + load_conversations() + load_user_settings() + + # AI Cog related settings merge has been removed. + pass + + +# ============= API Endpoints ============= + + +@app.get(API_BASE_PATH + "/") +async def root(): + return {"message": "Discord Bot Sync API is running"} + + +@api_app.get("/") +async def api_root(): + return {"message": "Discord Bot Sync API is running"} + + +@api_app.get("/auth") +async def auth(code: str, state: str = None): + """Handle OAuth callback""" + return {"message": "Authentication successful", "code": code, "state": state} + + +@api_app.get("/conversations") +async def get_conversations(user_id: str = Depends(verify_discord_token)): + """Get all conversations for a user""" + if user_id not in user_conversations: + return {"conversations": []} + + return {"conversations": user_conversations[user_id]} + + +@api_app.post("/sync") +async def sync_conversations( + sync_request: SyncRequest, user_id: str = Depends(verify_discord_token) +): + """Sync conversations between the Flutter app and Discord bot""" + # Get existing conversations for this user + existing_conversations = user_conversations.get(user_id, []) + + # Process incoming conversations + updated_conversations = [] + for incoming_conv in sync_request.conversations: + # Check if this conversation already exists + existing_conv = next( + (conv for conv in existing_conversations if conv.id == incoming_conv.id), + None, + ) + + if existing_conv: + # If the incoming conversation is newer, update it + if incoming_conv.updated_at > existing_conv.updated_at: + # Replace the existing conversation + existing_conversations = [ + conv + for conv in existing_conversations + if conv.id != incoming_conv.id + ] + existing_conversations.append(incoming_conv) + updated_conversations.append(incoming_conv) + else: + # This is a new conversation, add it + existing_conversations.append(incoming_conv) + updated_conversations.append(incoming_conv) + + # Update the storage + user_conversations[user_id] = existing_conversations + save_conversations() + + # Process user settings if provided + user_settings_response = None + if sync_request.user_settings: + incoming_settings = sync_request.user_settings + existing_settings = user_settings.get(user_id) + + # If we have existing settings, check which is newer + if existing_settings: + if ( + not existing_settings.last_updated + or incoming_settings.last_updated > existing_settings.last_updated + ): + user_settings[user_id] = incoming_settings + save_all_user_settings() + user_settings_response = incoming_settings + else: + user_settings_response = existing_settings + else: + # No existing settings, just save the incoming ones + user_settings[user_id] = incoming_settings + save_all_user_settings() + user_settings_response = incoming_settings + + return SyncResponse( + success=True, + message=f"Synced {len(updated_conversations)} conversations", + conversations=existing_conversations, + user_settings=user_settings_response, + ) + + +@api_app.delete("/conversations/{conversation_id}") +async def delete_conversation( + conversation_id: str, user_id: str = Depends(verify_discord_token) +): + """Delete a conversation""" + if user_id not in user_conversations: + raise HTTPException( + status_code=404, detail="No conversations found for this user" + ) + + # Filter out the conversation to delete + original_count = len(user_conversations[user_id]) + user_conversations[user_id] = [ + conv for conv in user_conversations[user_id] if conv.id != conversation_id + ] + + # Check if any conversation was deleted + if len(user_conversations[user_id]) == original_count: + raise HTTPException(status_code=404, detail="Conversation not found") + + save_conversations() + + return {"success": True, "message": "Conversation deleted"} + + +# --- Gurt Stats Endpoint --- +@api_app.get("/gurt/stats") +async def get_gurt_stats_api(): + """Get internal statistics for the Gurt bot.""" + if not gurt_cog_instance: + raise HTTPException(status_code=503, detail="Gurt cog not available") + try: + stats_data = await gurt_cog_instance.get_gurt_stats() + # Convert potential datetime objects if any (though get_gurt_stats should return serializable types) + # For safety, let's ensure basic types or handle conversion if needed later. + return stats_data + except Exception as e: + print(f"Error retrieving Gurt stats via API: {e}") + import traceback + + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Error retrieving Gurt stats: {e}") + + +# --- Gurt Dashboard Static Files --- +# Mount static files directory (adjust path if needed, assuming dashboard files are in discordbot/gurt_dashboard) +# Check if the directory exists before mounting +dashboard_dir = "discordbot/gurt_dashboard" +if os.path.exists(dashboard_dir) and os.path.isdir(dashboard_dir): + api_app.mount( + "/gurt/static", StaticFiles(directory=dashboard_dir), name="gurt_static" + ) + print(f"Mounted Gurt dashboard static files from: {dashboard_dir}") + + # Route for the main dashboard HTML + @api_app.get("/gurt/dashboard", response_class=FileResponse) + async def get_gurt_dashboard(): + dashboard_html_path = os.path.join(dashboard_dir, "index.html") + if os.path.exists(dashboard_html_path): + return dashboard_html_path + else: + raise HTTPException( + status_code=404, detail="Dashboard index.html not found" + ) + +else: + print( + f"Warning: Gurt dashboard directory '{dashboard_dir}' not found. Dashboard endpoints will not be available." + ) + + +@api_app.get("/settings") +async def get_user_settings(user_id: str = Depends(verify_discord_token)): + """Get user settings""" + # AI Cog related settings merge has been removed. + if user_id not in user_settings: + # Create default settings if none exist + user_settings[user_id] = UserSettings() + save_all_user_settings() + return {"settings": user_settings[user_id]} + + +@api_app.post("/settings") +async def update_user_settings( + settings_request: SettingsSyncRequest, user_id: str = Depends(verify_discord_token) +): + """Update user settings""" + incoming_settings = settings_request.user_settings + existing_settings = user_settings.get(user_id) + + # Debug logging for character settings + print(f"Received settings update from user {user_id}:") + print(f"Character: {incoming_settings.character}") + print(f"Character Info: {incoming_settings.character_info}") + print(f"Character Breakdown: {incoming_settings.character_breakdown}") + print(f"Custom Instructions: {incoming_settings.custom_instructions}") + print(f"Last Updated: {incoming_settings.last_updated}") + print(f"Sync Source: {incoming_settings.sync_source}") + + if existing_settings: + print(f"Existing settings for user {user_id}:") + print(f"Character: {existing_settings.character}") + print(f"Character Info: {existing_settings.character_info}") + print(f"Last Updated: {existing_settings.last_updated}") + print(f"Sync Source: {existing_settings.sync_source}") + + # If we have existing settings, check which is newer + if existing_settings: + if ( + not existing_settings.last_updated + or incoming_settings.last_updated > existing_settings.last_updated + ): + print(f"Updating settings for user {user_id} (incoming settings are newer)") + user_settings[user_id] = incoming_settings + save_all_user_settings() + else: + # Return existing settings if they're newer + print( + f"Not updating settings for user {user_id} (existing settings are newer)" + ) + return { + "success": True, + "message": "Existing settings are newer", + "settings": existing_settings, + } + else: + # No existing settings, just save the incoming ones + print(f"Creating new settings for user {user_id}") + user_settings[user_id] = incoming_settings + save_all_user_settings() + + # Verify the settings were saved correctly + saved_settings = user_settings.get(user_id) + print(f"Saved settings for user {user_id}:") + print(f"Character: {saved_settings.character}") + print(f"Character Info: {saved_settings.character_info}") + print(f"Character Breakdown: {saved_settings.character_breakdown}") + print(f"Custom Instructions: {saved_settings.custom_instructions}") + + # AI Cog related settings update has been removed. + return { + "success": True, + "message": "Settings updated", + "settings": user_settings[user_id], + } + + +# ============= Discord Bot Integration ============= + + +# This function should be called from your Discord bot's AI cog +# to convert AI conversation history to the synced format +def convert_ai_history_to_synced( + user_id: str, conversation_history: Dict[int, List[Dict[str, Any]]] +): + """Convert the AI conversation history to the synced format""" + synced_conversations = [] + + # Process each conversation in the history + for discord_user_id, messages in conversation_history.items(): + if str(discord_user_id) != user_id: + continue + + # Create a unique ID for this conversation + conv_id = f"discord_{discord_user_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" + + # Convert messages to the synced format + synced_messages = [] + for msg in messages: + role = msg.get("role", "") + if role not in ["user", "assistant", "system"]: + continue + + synced_messages.append( + SyncedMessage( + content=msg.get("content", ""), + role=role, + timestamp=datetime.datetime.now(), # Use current time as we don't have the original timestamp + reasoning=None, # Discord bot doesn't store reasoning + usage_data=None, # Discord bot doesn't store usage data + ) + ) + + # Create the synced conversation + synced_conversations.append( + SyncedConversation( + id=conv_id, + title="Discord Conversation", # Default title + messages=synced_messages, + created_at=datetime.datetime.now(), + updated_at=datetime.datetime.now(), + model_id="openai/gpt-3.5-turbo", # Default model + sync_source="discord", + last_synced_at=datetime.datetime.now(), + reasoning_enabled=False, + reasoning_effort="medium", + temperature=0.7, + max_tokens=1000, + web_search_enabled=False, + system_message=None, + character=None, + character_info=None, + character_breakdown=False, + custom_instructions=None, + ) + ) + + return synced_conversations + + +# This function should be called from your Discord bot's AI cog +# to save a new conversation from Discord +def save_discord_conversation( + user_id: str, + messages: List[Dict[str, Any]], + model_id: str = "openai/gpt-3.5-turbo", + conversation_id: Optional[str] = None, + title: str = "Discord Conversation", + reasoning_enabled: bool = False, + reasoning_effort: str = "medium", + temperature: float = 0.7, + max_tokens: int = 1000, + web_search_enabled: bool = False, + system_message: Optional[str] = None, + character: Optional[str] = None, + character_info: Optional[str] = None, + character_breakdown: bool = False, + custom_instructions: Optional[str] = None, +): + """Save a conversation from Discord to the synced storage""" + # Convert messages to the synced format + synced_messages = [] + for msg in messages: + role = msg.get("role", "") + if role not in ["user", "assistant", "system"]: + continue + + synced_messages.append( + SyncedMessage( + content=msg.get("content", ""), + role=role, + timestamp=datetime.datetime.now(), + reasoning=msg.get("reasoning"), + usage_data=msg.get("usage_data"), + ) + ) + + # Create a unique ID for this conversation if not provided + if not conversation_id: + conversation_id = ( + f"discord_{user_id}_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}" + ) + + # Create the synced conversation + synced_conv = SyncedConversation( + id=conversation_id, + title=title, + messages=synced_messages, + created_at=datetime.datetime.now(), + updated_at=datetime.datetime.now(), + model_id=model_id, + sync_source="discord", + last_synced_at=datetime.datetime.now(), + reasoning_enabled=reasoning_enabled, + reasoning_effort=reasoning_effort, + temperature=temperature, + max_tokens=max_tokens, + web_search_enabled=web_search_enabled, + system_message=system_message, + character=character, + character_info=character_info, + character_breakdown=character_breakdown, + custom_instructions=custom_instructions, + ) + + # Add to storage + if user_id not in user_conversations: + user_conversations[user_id] = [] + + # Check if we're updating an existing conversation + if conversation_id: + # Remove the old conversation with the same ID if it exists + user_conversations[user_id] = [ + conv for conv in user_conversations[user_id] if conv.id != conversation_id + ] + + user_conversations[user_id].append(synced_conv) + save_conversations() + + return synced_conv diff --git a/main.py b/main.py index 5fc5ffb..5653135 100644 --- a/main.py +++ b/main.py @@ -779,7 +779,6 @@ async def main(args): # Pass parsed args if args.disable_ai: print("AI functionality disabled via command line flag.") ai_cogs_to_skip = [ - "cogs.ai_cog", "cogs.multi_conversation_ai_cog", # Add any other AI-related cogs from the 'cogs' folder here ]