import discord from discord.ext import commands from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel import asyncio import threading import uvicorn import os from typing import List, Dict, Optional import json import logging import time # Enhanced logging logging.basicConfig( level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Debugging function to check bot permissions and server structure async def debug_bot_permissions(): """Debug function to check bot permissions and server structure""" if not guild_id: logger.error("No guild_id set - bot not connected to any server") return try: guild = bot.get_guild(guild_id) if not guild: logger.error(f"Cannot access guild {guild_id}") return logger.info(f"āœ… Connected to guild: {guild.name} (ID: {guild.id})") logger.info(f"Guild member count: {guild.member_count}") # Check bot member and permissions bot_member = guild.get_member(bot.user.id) if not bot_member: logger.error("āŒ Bot is not a member of this guild") return logger.info(f"āœ… Bot member found: {bot_member.display_name}") logger.info(f"Bot roles: {[role.name for role in bot_member.roles]}") # Check guild permissions guild_perms = bot_member.guild_permissions logger.info(f"Guild permissions - Read messages: {guild_perms.read_messages}") logger.info(f"Guild permissions - Read message history: {guild_perms.read_message_history}") logger.info(f"Guild permissions - Send messages: {guild_perms.send_messages}") # Check categories and channels logger.info(f"Total categories: {len(guild.categories)}") for category in guild.categories: logger.info(f"\nšŸ“ Category: {category.name} (ID: {category.id})") # Check category permissions cat_perms = category.permissions_for(bot_member) logger.info(f" Category permissions - Read: {cat_perms.read_messages}") logger.info(f" Category permissions - History: {cat_perms.read_message_history}") text_channels = category.text_channels logger.info(f" Text channels: {len(text_channels)}") for channel in text_channels: # Check individual channel permissions chan_perms = channel.permissions_for(bot_member) logger.info(f" šŸ“„ {channel.name} (ID: {channel.id})") logger.info(f" Permissions - Read: {chan_perms.read_messages}") logger.info(f" Permissions - History: {chan_perms.read_message_history}") if not chan_perms.read_messages: logger.warning(f" āš ļø Cannot read messages in {channel.name}") continue if not chan_perms.read_message_history: logger.warning(f" āš ļø Cannot read message history in {channel.name}") continue # Try to read messages try: message_count = 0 async for message in channel.history(limit=5): message_count += 1 logger.info(f" āœ… Successfully read {message_count} messages") except discord.Forbidden as e: logger.error(f" āŒ Forbidden to read {channel.name}: {e}") except discord.HTTPException as e: logger.error(f" āŒ HTTP error reading {channel.name}: {e}") except Exception as e: logger.error(f" āŒ Unexpected error reading {channel.name}: {e}") except Exception as e: logger.error(f"Debug function error: {e}") # Discord Bot Setup intents = discord.Intents.default() intents.message_content = True intents.guilds = True bot = commands.Bot(command_prefix='!', intents=intents) # FastAPI Setup app = FastAPI(title="Discord Flashcard Repository API") # Pydantic models for request validation class ChallengeResult(BaseModel): correct: int incorrect: int # Global variables guild_id = None @bot.event async def on_ready(): global guild_id logger.info(f'{bot.user} has logged in!') for guild in bot.guilds: guild_id = guild.id logger.info(f'Connected to guild: {guild.name}') break # Run debug function await debug_bot_permissions() # Also add this command for manual debugging @bot.command(name='debug') async def debug_command(ctx): """Manual debug command""" await debug_bot_permissions() await ctx.send("Debug information logged to console") @bot.event async def on_disconnect(): logger.warning("Bot disconnected from Discord") @bot.event async def on_resumed(): logger.info("Bot resumed connection to Discord") @bot.event async def on_error(event, *args, **kwargs): logger.error(f"Discord error in {event}: {args}") # ========== Core Logic ========== async def get_flashcard_categories(): """Enhanced version with better error reporting""" if not guild_id: logger.error("No guild_id - bot not ready") return [] try: guild = bot.get_guild(guild_id) if not guild: logger.error(f"Guild {guild_id} not found") return [] bot_member = guild.get_member(bot.user.id) if not bot_member: logger.error("Bot not found as member of guild") return [] categories = [] logger.info(f"Processing {len(guild.categories)} categories") for category in guild.categories: logger.debug(f"Processing category: {category.name}") # Check if bot can access this category cat_perms = category.permissions_for(bot_member) if not cat_perms.read_messages: logger.warning(f"No read permission for category {category.name}") continue folder_count = 0 for channel in category.text_channels: if channel.name.lower() != 'properties': # Check channel permissions chan_perms = channel.permissions_for(bot_member) if chan_perms.read_messages: folder_count += 1 else: logger.warning(f"No read permission for channel {channel.name}") categories.append({ "category_id": str(category.id), "category_name": category.name, "total_folders": folder_count }) logger.debug(f"Added category {category.name} with {folder_count} accessible folders") logger.info(f"Returning {len(categories)} accessible categories") return categories except discord.Forbidden as e: logger.error(f"Forbidden error: {e}") return [] except discord.HTTPException as e: logger.error(f"Discord HTTP error: {e}") return [] except Exception as e: logger.error(f"Unexpected error in get_flashcard_categories: {e}") return [] async def get_flashcard_lists(category_id: str): """Get all flashcard folders (channels) in a specific category""" if not guild_id: return [] try: guild = bot.get_guild(guild_id) if not guild: logger.error(f"Guild {guild_id} not found") return [] category = discord.utils.get(guild.categories, id=int(category_id)) if not category: logger.error(f"Category {category_id} not found") return [] folders = [] for channel in category.text_channels: # Exclude 'properties' channel if channel.name.lower() == 'properties': continue flashcard_count = 0 try: async for message in channel.history(limit=None): if not message.reference: flashcard_count += 1 except discord.Forbidden: logger.error(f"No permission to read channel {channel.name}") continue except Exception as e: logger.error(f"Error reading channel {channel.name}: {e}") continue # Get statistics for this folder stats = await get_folder_statistics(category_id, str(channel.id)) folders.append({ "folder_id": str(channel.id), "folder_name": channel.name, "category_id": category_id, "total_flashcards": flashcard_count, "total_correct": stats["total_correct"], "total_incorrect": stats["total_incorrect"], "total_challenges": stats["total_challenges"] }) return folders except Exception as e: logger.error(f"Error in get_flashcard_lists: {e}") return [] async def get_flashcards_in_folder(folder_id: str): """Get all flashcards in a specific folder""" if not guild_id: return [] try: guild = bot.get_guild(guild_id) if not guild: return [] channel = guild.get_channel(int(folder_id)) if not channel: return [] flashcards = [] messages = [] try: messages = [msg async for msg in channel.history(limit=None, oldest_first=True)] except discord.Forbidden: logger.error(f"No permission to read channel {channel.name}") return [] except Exception as e: logger.error(f"Error reading messages from channel {folder_id}: {e}") return [] for message in messages: if not message.reference: question_content = { "text": message.content or "", "image_url": message.attachments[0].url if message.attachments else None } answers = [] for reply in messages: if reply.reference and reply.reference.message_id == message.id: answers.append({ "text": reply.content or "", "image_url": reply.attachments[0].url if reply.attachments else None }) flashcards.append({ "question_id": str(message.id), "question": question_content, "answers": answers }) return flashcards except Exception as e: logger.error(f"Error in get_flashcards_in_folder: {e}") return [] async def get_properties_channel(category_id: str): """Get the properties channel for a specific category""" if not guild_id: return None try: guild = bot.get_guild(guild_id) if not guild: return None category = discord.utils.get(guild.categories, id=int(category_id)) if not category: return None for channel in category.text_channels: if channel.name.lower() == 'properties': return channel return None except Exception as e: logger.error(f"Error finding properties channel for category {category_id}: {e}") return None async def get_challenge_history(category_id: str = None): """Get challenge history from properties channel(s)""" if not guild_id: return [] try: guild = bot.get_guild(guild_id) if not guild: return [] history_records = [] if category_id: # Get history for specific category properties_channel = await get_properties_channel(category_id) if properties_channel: records = await get_history_from_channel(properties_channel, category_id) history_records.extend(records) else: # Get history from all categories for category in guild.categories: properties_channel = None for channel in category.text_channels: if channel.name.lower() == 'properties': properties_channel = channel break if properties_channel: records = await get_history_from_channel(properties_channel, str(category.id)) history_records.extend(records) return history_records except Exception as e: logger.error(f"Error in get_challenge_history: {e}") return [] async def get_history_from_channel(properties_channel, category_id: str): """Extract history records from a properties channel""" history_records = [] try: async for message in properties_channel.history(limit=1): if message.content.strip(): lines = [line.strip() for line in message.content.strip().split('\n') if line.strip()] for line in lines: try: # Parse JSON format: {"folder_id": "123", "correct": 5, "incorrect": 2, "timestamp": "..."} record = json.loads(line) record["category_id"] = category_id # Add category info history_records.append(record) except json.JSONDecodeError: # Handle old format (just folder_id) for backward compatibility if line.isdigit(): history_records.append({ "folder_id": line, "category_id": category_id, "correct": 0, "incorrect": 0, "timestamp": "unknown" }) break except Exception as e: logger.error(f"Error reading from properties channel: {e}") return history_records async def get_folder_statistics(category_id: str, folder_id: str): """Get aggregated statistics for a specific folder""" try: history = await get_challenge_history(category_id) total_correct = 0 total_incorrect = 0 total_challenges = 0 for record in history: if record.get("folder_id") == folder_id: total_correct += record.get("correct", 0) total_incorrect += record.get("incorrect", 0) total_challenges += 1 return { "total_correct": total_correct, "total_incorrect": total_incorrect, "total_challenges": total_challenges } except Exception as e: logger.error(f"Error in get_folder_statistics: {e}") return { "total_correct": 0, "total_incorrect": 0, "total_challenges": 0 } async def get_category_statistics(category_id: str): """Get aggregated statistics for an entire category""" try: history = await get_challenge_history(category_id) total_correct = 0 total_incorrect = 0 total_challenges = len(history) folder_stats = {} for record in history: folder_id = record.get("folder_id") correct = record.get("correct", 0) incorrect = record.get("incorrect", 0) total_correct += correct total_incorrect += incorrect if folder_id not in folder_stats: folder_stats[folder_id] = { "correct": 0, "incorrect": 0, "challenges": 0 } folder_stats[folder_id]["correct"] += correct folder_stats[folder_id]["incorrect"] += incorrect folder_stats[folder_id]["challenges"] += 1 return { "category_id": category_id, "total_correct": total_correct, "total_incorrect": total_incorrect, "total_challenges": total_challenges, "folder_breakdown": folder_stats } except Exception as e: logger.error(f"Error in get_category_statistics: {e}") return { "category_id": category_id, "total_correct": 0, "total_incorrect": 0, "total_challenges": 0, "folder_breakdown": {} } async def update_challenge_history(folder_id: str, correct: int, incorrect: int): """Update challenge history in the appropriate properties channel""" if not guild_id: return False try: guild = bot.get_guild(guild_id) if not guild: return False # Find which category this folder belongs to target_channel = guild.get_channel(int(folder_id)) if not target_channel or not target_channel.category: logger.error(f"Channel {folder_id} not found or not in a category") return False category = target_channel.category properties_channel = await get_properties_channel(str(category.id)) if not properties_channel: logger.error(f"Properties channel not found in category {category.name}") return False # Create new record from datetime import datetime new_record = { "folder_id": folder_id, "correct": correct, "incorrect": incorrect, "timestamp": datetime.utcnow().isoformat() } # Get existing history from this category's properties channel current_records = await get_history_from_channel(properties_channel, str(category.id)) # Add new record at the beginning and limit to 100 records per category updated_records = [new_record] + current_records[:99] # Convert back to string format history_lines = [json.dumps(record) for record in updated_records] new_content = '\n'.join(history_lines) # Update or create message in properties channel message_found = False async for message in properties_channel.history(limit=1): await message.edit(content=new_content) message_found = True break if not message_found: await properties_channel.send(new_content) return True except Exception as e: logger.error(f"Error in update_challenge_history: {e}") return False # ========== FastAPI Endpoints ========== def run_discord_coro(coro): try: future = asyncio.run_coroutine_threadsafe(coro, bot.loop) return future.result(timeout=30) except asyncio.TimeoutError: logger.error("Discord operation timed out") raise HTTPException(status_code=504, detail="Discord operation timed out") except Exception as e: logger.error(f"Error running Discord operation: {e}") raise HTTPException(status_code=500, detail=f"Discord operation failed: {str(e)}") @app.get("/") async def root(): return {"message": "Discord Flashcard Repository API is running", "status": "healthy"} @app.get("/flashcard-categories") async def get_flashcard_categories_api(): """Get all flashcard categories in the Discord server""" try: if not bot.is_ready(): raise HTTPException(status_code=503, detail="Bot is not ready yet.") categories = run_discord_coro(get_flashcard_categories()) return JSONResponse(content=categories) except HTTPException: raise except Exception as e: logger.error(f"Error in get_flashcard_categories_api: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.get("/flashcard-lists") async def get_flashcard_lists_api(category_id: str): """Get flashcard folders in a specific category""" try: if not bot.is_ready(): raise HTTPException(status_code=503, detail="Bot is not ready yet.") folders = run_discord_coro(get_flashcard_lists(category_id)) return JSONResponse(content=folders) except HTTPException: raise except Exception as e: logger.error(f"Error in get_flashcard_lists_api: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.get("/flashcard-folder/{folder_id}") async def get_flashcard_folder(folder_id: str): """Get all flashcards in a specific folder""" try: if not bot.is_ready(): raise HTTPException(status_code=503, detail="Bot is not ready yet.") flashcards = run_discord_coro(get_flashcards_in_folder(folder_id)) return JSONResponse(content=flashcards) except HTTPException: raise except Exception as e: logger.error(f"Error in get_flashcard_folder: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.get("/challenge-history") async def get_challenge_history_api(category_id: str = None): """Get challenge history, optionally filtered by category""" try: if not bot.is_ready(): raise HTTPException(status_code=503, detail="Bot is not ready yet.") history = run_discord_coro(get_challenge_history(category_id)) return JSONResponse(content={ "challenge_history": history, "total_challenges": len(history), "category_id": category_id }) except HTTPException: raise except Exception as e: logger.error(f"Error in get_challenge_history_api: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.get("/folder-statistics/{category_id}/{folder_id}") async def get_folder_statistics_api(category_id: str, folder_id: str): """Get statistics for a specific folder""" try: if not bot.is_ready(): raise HTTPException(status_code=503, detail="Bot is not ready yet.") stats = run_discord_coro(get_folder_statistics(category_id, folder_id)) stats["folder_id"] = folder_id stats["category_id"] = category_id return JSONResponse(content=stats) except HTTPException: raise except Exception as e: logger.error(f"Error in get_folder_statistics_api: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.get("/category-statistics/{category_id}") async def get_category_statistics_api(category_id: str): """Get aggregated statistics for an entire category""" try: if not bot.is_ready(): raise HTTPException(status_code=503, detail="Bot is not ready yet.") stats = run_discord_coro(get_category_statistics(category_id)) return JSONResponse(content=stats) except HTTPException: raise except Exception as e: logger.error(f"Error in get_category_statistics_api: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.post("/done-challenge/{folder_id}") async def done_challenge(folder_id: str, result: ChallengeResult): """Record completion of a challenge for a specific folder""" try: if not bot.is_ready(): raise HTTPException(status_code=503, detail="Bot is not ready yet.") if result.correct < 0 or result.incorrect < 0: raise HTTPException(status_code=400, detail="Correct and incorrect counts must be non-negative") success = run_discord_coro(update_challenge_history(folder_id, result.correct, result.incorrect)) if success: return JSONResponse(content={ "message": "Challenge history updated successfully", "folder_id": folder_id, "correct": result.correct, "incorrect": result.incorrect }) else: raise HTTPException(status_code=500, detail="Failed to update challenge history") except HTTPException: raise except Exception as e: logger.error(f"Error in done_challenge: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @app.get("/health") async def health_check(): """Health check endpoint""" bot_status = "connected" if bot.is_ready() else "disconnected" guild_status = "connected" if guild_id else "no_guild" return { "status": "healthy", "bot_status": bot_status, "guild_status": guild_status, "guild_id": guild_id } # ========== Startup ========== def run_bot(): token = os.getenv('DISCORD_BOT_TOKEN') if not token: logger.error("DISCORD_BOT_TOKEN environment variable not set!") return try: logger.info("Starting Discord bot...") bot.run(token, reconnect=True) except Exception as e: logger.error(f"Bot startup error: {e}") raise def run_api(): port = int(os.getenv('PORT', 7860)) host = os.getenv('HOST', '0.0.0.0') logger.info(f"Starting FastAPI server on {host}:{port}") uvicorn.run( app, host=host, port=port, log_level="info", access_log=True, timeout_keep_alive=30, timeout_graceful_shutdown=30 ) if __name__ == "__main__": bot_thread = threading.Thread(target=run_bot, daemon=True) bot_thread.start() time.sleep(3) # Give bot time to start run_api()