import discord import asyncio import threading import uvicorn import logging import sys import random import string import signal import atexit from api import app # Import new configuration system from config import CONFIG, SECRETS, validate_config, print_config_summary from server_manager import server_manager from config_manager import config_manager from utils.scheduled import ( send_monday_video ) from utils.image_handling import ( download_and_encode_image, download_and_encode_media, extract_video_frames, analyze_image_with_qwen, analyze_video_with_vision, rephrase_as_miku, extract_tenor_gif_url, convert_gif_to_mp4, extract_embed_content ) from utils.core import ( is_miku_addressed, ) from utils.moods import ( detect_mood_shift ) from utils.media import( overlay_username_with_ffmpeg ) from utils.llm import query_llama from utils.autonomous import ( setup_autonomous_speaking, load_last_sent_tweets, # V2 imports on_message_event, on_presence_update as autonomous_presence_update, on_member_join as autonomous_member_join, initialize_v2_system ) from utils.dm_logger import dm_logger from utils.dm_interaction_analyzer import init_dm_analyzer from utils.logger import get_logger from utils.task_tracker import create_tracked_task import globals # Initialize bot logger logger = get_logger('bot') # Validate configuration on startup is_valid, validation_errors = validate_config() if not is_valid: logger.error("❌ Configuration validation failed!") for error in validation_errors: logger.error(f" - {error}") logger.error("Please check your .env file and restart.") sys.exit(1) # Print configuration summary for debugging if CONFIG.autonomous.debug_mode: print_config_summary() logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s", handlers=[ logging.FileHandler("bot.log", mode='a', encoding='utf-8'), logging.StreamHandler(sys.stdout) # Optional: see logs in stdout too ], force=True # Override previous configs ) # Reduce noise from discord voice receiving library # CryptoErrors are routine packet decode failures (joins/leaves/key negotiation) # RTCP packets are control packets sent every ~1s # Both are harmless and just clutter logs logging.getLogger('discord.ext.voice_recv.reader').setLevel(logging.CRITICAL) # Only show critical errors @globals.client.event async def on_ready(): logger.info(f'🎤 MikuBot connected as {globals.client.user}') logger.info(f'💬 DM support enabled - users can message Miku directly!') globals.BOT_USER = globals.client.user # Intercept external library loggers (APScheduler, etc.) from utils.logger import intercept_external_loggers intercept_external_loggers() # Restore evil mode state from previous session (if any) from utils.evil_mode import restore_evil_mode_on_startup restore_evil_mode_on_startup() # Restore bipolar mode state from previous session (if any) from utils.bipolar_mode import restore_bipolar_mode_on_startup restore_bipolar_mode_on_startup() # Restore runtime settings (language, debug flags, etc.) from config_runtime.yaml config_manager.restore_runtime_settings() # Initialize DM interaction analyzer if globals.OWNER_USER_ID and globals.OWNER_USER_ID != 0: init_dm_analyzer(globals.OWNER_USER_ID) logger.info(f"📊 DM Interaction Analyzer initialized for owner ID: {globals.OWNER_USER_ID}") # Schedule daily DM analysis (runs at 2 AM every day) from utils.scheduled import run_daily_dm_analysis globals.scheduler.add_job( run_daily_dm_analysis, 'cron', hour=2, minute=0, id='daily_dm_analysis' ) logger.info("⏰ Scheduled daily DM analysis at 2:00 AM") else: logger.warning("OWNER_USER_ID not set, DM analysis feature disabled") # Setup autonomous speaking (now handled by server manager) setup_autonomous_speaking() load_last_sent_tweets() # Initialize the V2 autonomous system initialize_v2_system(globals.client) # Initialize profile picture manager from utils.profile_picture_manager import profile_picture_manager await profile_picture_manager.initialize() # Save current avatar as fallback await profile_picture_manager.save_current_avatar_as_fallback() # Start server-specific schedulers (includes DM mood rotation) server_manager.start_all_schedulers(globals.client) # Start the global scheduler for other tasks globals.scheduler.start() @globals.client.event async def on_message(message): if message.author == globals.client.user: return # Check for voice commands first (!miku join, !miku leave, !miku voice-status, !miku test, !miku say, !miku listen, !miku stop-listening) if not isinstance(message.channel, discord.DMChannel) and message.content.strip().lower().startswith('!miku '): from commands.voice import handle_voice_command parts = message.content.strip().split() if len(parts) >= 2: cmd = parts[1].lower() args = parts[2:] if len(parts) > 2 else [] if cmd in ['join', 'leave', 'voice-status', 'test', 'say', 'listen', 'stop-listening']: await handle_voice_command(message, cmd, args) return # Check for UNO commands (!uno create, !uno join, !uno list, !uno quit, !uno help) if message.content.strip().lower().startswith('!uno'): from commands.uno import handle_uno_command await handle_uno_command(message) return # Block all text responses when voice session is active if globals.VOICE_SESSION_ACTIVE: # Queue the message for later processing (optional) if not hasattr(message, 'author') or message.author != globals.client.user: globals.TEXT_MESSAGE_QUEUE.append({ 'message': message, 'timestamp': message.created_at, 'channel_id': message.channel.id, 'content': message.content }) logger.debug(f"Message queued during voice session: {message.content[:50]}...") return # Don't process any messages during voice session # Skip processing if a bipolar argument is in progress in this channel if not isinstance(message.channel, discord.DMChannel): from utils.bipolar_mode import is_argument_in_progress if is_argument_in_progress(message.channel.id): return # Skip processing if a persona dialogue is in progress in this channel from utils.persona_dialogue import is_persona_dialogue_active if is_persona_dialogue_active(message.channel.id): return if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference: async with message.channel.typing(): # Get replied-to user try: replied_msg = await message.channel.fetch_message(message.reference.message_id) target_username = replied_msg.author.display_name # Prepare video base_video = "MikuMikuBeam.mp4" output_video = f"/tmp/video_{''.join(random.choices(string.ascii_letters, k=5))}.mp4" await overlay_username_with_ffmpeg(base_video, output_video, target_username) caption = f"Here you go, @{target_username}! 🌟" #await message.channel.send(content=caption, file=discord.File(output_video)) await replied_msg.reply(file=discord.File(output_video)) except Exception as e: logger.error(f"Error processing video: {e}") await message.channel.send("Sorry, something went wrong while generating the video.") return text = message.content.strip() # Check if this is a DM is_dm = message.guild is None # Check if message is addressed to Miku (needed to decide whether to track for autonomous) miku_addressed = await is_miku_addressed(message) if is_dm: logger.info(f"💌 DM from {message.author.display_name}: {message.content[:50]}{'...' if len(message.content) > 50 else ''}") # Check if user is blocked if dm_logger.is_user_blocked(message.author.id): logger.info(f"🚫 Blocked user {message.author.display_name} ({message.author.id}) tried to send DM - ignoring") return # Log the user's DM message dm_logger.log_user_message(message.author, message, is_bot_message=False) if miku_addressed: prompt = text # No cleanup — keep it raw user_id = str(message.author.id) # If user is replying to a specific message, add context marker if message.reference: try: replied_msg = await message.channel.fetch_message(message.reference.message_id) # Only add context if replying to Miku's message if replied_msg.author == globals.client.user: # Truncate the replied message to keep prompt manageable replied_content = replied_msg.content[:200] + "..." if len(replied_msg.content) > 200 else replied_msg.content # Add reply context marker to the prompt prompt = f'[Replying to your message: "{replied_content}"] {prompt}' except Exception as e: logger.error(f"Failed to fetch replied message for context: {e}") async with message.channel.typing(): # Check if vision model is blocked (voice session active) if message.attachments and globals.VISION_MODEL_BLOCKED: await message.channel.send( "🎤 I can't look at images or videos right now, I'm talking in voice chat! " "Send it again after I leave the voice channel." ) return # If message has an image, video, or GIF attachment if message.attachments: for attachment in message.attachments: # Handle images if any(attachment.filename.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]): base64_img = await download_and_encode_image(attachment.url) if not base64_img: await message.channel.send("I couldn't load the image, sorry!") return # Analyze image (objective description) qwen_description = await analyze_image_with_qwen(base64_img) # For DMs, pass None as guild_id to use DM mood guild_id = message.guild.id if message.guild else None miku_reply = await rephrase_as_miku( qwen_description, prompt, guild_id=guild_id, user_id=str(message.author.id), author_name=message.author.display_name, media_type="image" ) if is_dm: logger.info(f"💌 DM image response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: logger.info(f"💬 Server image response to {message.author.display_name} in {message.guild.name} (using server mood)") response_message = await message.channel.send(miku_reply) # Log the bot's DM response if is_dm: dm_logger.log_user_message(message.author, response_message, is_bot_message=True) # For server messages, check if opposite persona should interject if not is_dm and globals.BIPOLAR_MODE: try: from utils.persona_dialogue import check_for_interjection current_persona = "evil" if globals.EVIL_MODE else "miku" create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check") except Exception as e: logger.error(f"Error checking for persona interjection: {e}") return # Handle videos and GIFs elif any(attachment.filename.lower().endswith(ext) for ext in [".gif", ".mp4", ".webm", ".mov"]): # Determine media type is_gif = attachment.filename.lower().endswith('.gif') media_type = "gif" if is_gif else "video" logger.debug(f"🎬 Processing {media_type}: {attachment.filename}") # Download the media media_bytes_b64 = await download_and_encode_media(attachment.url) if not media_bytes_b64: await message.channel.send(f"I couldn't load the {media_type}, sorry!") return # Decode back to bytes for frame extraction import base64 media_bytes = base64.b64decode(media_bytes_b64) # If it's a GIF, convert to MP4 for better processing if is_gif: logger.debug(f"🔄 Converting GIF to MP4 for processing...") mp4_bytes = await convert_gif_to_mp4(media_bytes) if mp4_bytes: media_bytes = mp4_bytes logger.info(f"✅ GIF converted to MP4") else: logger.warning(f"GIF conversion failed, trying direct processing") # Extract frames frames = await extract_video_frames(media_bytes, num_frames=6) if not frames: await message.channel.send(f"I couldn't extract frames from that {media_type}, sorry!") return logger.debug(f"📹 Extracted {len(frames)} frames from {attachment.filename}") # Analyze the video/GIF with appropriate media type video_description = await analyze_video_with_vision(frames, media_type=media_type) # For DMs, pass None as guild_id to use DM mood guild_id = message.guild.id if message.guild else None miku_reply = await rephrase_as_miku( video_description, prompt, guild_id=guild_id, user_id=str(message.author.id), author_name=message.author.display_name, media_type=media_type ) if is_dm: logger.info(f"💌 DM {media_type} response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: logger.info(f"💬 Server video response to {message.author.display_name} in {message.guild.name} (using server mood)") response_message = await message.channel.send(miku_reply) # Log the bot's DM response if is_dm: dm_logger.log_user_message(message.author, response_message, is_bot_message=True) # For server messages, check if opposite persona should interject if not is_dm and globals.BIPOLAR_MODE: try: from utils.persona_dialogue import check_for_interjection current_persona = "evil" if globals.EVIL_MODE else "miku" create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check") except Exception as e: logger.error(f"Error checking for persona interjection: {e}") return # Check for embeds (articles, images, videos, GIFs, etc.) if message.embeds: for embed in message.embeds: # Handle Tenor GIF embeds specially (Discord uses these for /gif command) if embed.type == 'gifv' and embed.url and 'tenor.com' in embed.url: logger.info(f"🎭 Processing Tenor GIF from embed: {embed.url}") # Extract the actual GIF URL from Tenor gif_url = await extract_tenor_gif_url(embed.url) if not gif_url: # Try using the embed's video or image URL as fallback if hasattr(embed, 'video') and embed.video: gif_url = embed.video.url elif hasattr(embed, 'thumbnail') and embed.thumbnail: gif_url = embed.thumbnail.url if not gif_url: logger.warning(f"Could not extract GIF URL from Tenor embed") continue # Download the GIF media_bytes_b64 = await download_and_encode_media(gif_url) if not media_bytes_b64: await message.channel.send("I couldn't load that Tenor GIF, sorry!") return # Decode to bytes import base64 media_bytes = base64.b64decode(media_bytes_b64) # Convert GIF to MP4 logger.debug(f"Converting Tenor GIF to MP4 for processing...") mp4_bytes = await convert_gif_to_mp4(media_bytes) if not mp4_bytes: logger.warning(f"GIF conversion failed, trying direct frame extraction") mp4_bytes = media_bytes else: logger.debug(f"Tenor GIF converted to MP4") # Extract frames frames = await extract_video_frames(mp4_bytes, num_frames=6) if not frames: await message.channel.send("I couldn't extract frames from that GIF, sorry!") return logger.info(f"📹 Extracted {len(frames)} frames from Tenor GIF") # Analyze the GIF with tenor_gif media type video_description = await analyze_video_with_vision(frames, media_type="tenor_gif") guild_id = message.guild.id if message.guild else None miku_reply = await rephrase_as_miku( video_description, prompt, guild_id=guild_id, user_id=str(message.author.id), author_name=message.author.display_name, media_type="tenor_gif" ) if is_dm: logger.info(f"💌 DM Tenor GIF response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: logger.info(f"💬 Server Tenor GIF response to {message.author.display_name} in {message.guild.name} (using server mood)") response_message = await message.channel.send(miku_reply) # Log the bot's DM response if is_dm: dm_logger.log_user_message(message.author, response_message, is_bot_message=True) # For server messages, check if opposite persona should interject if not is_dm and globals.BIPOLAR_MODE: try: from utils.persona_dialogue import check_for_interjection current_persona = "evil" if globals.EVIL_MODE else "miku" create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check") except Exception as e: logger.error(f"Error checking for persona interjection: {e}") return # Handle other types of embeds (rich, article, image, video, link) elif embed.type in ['rich', 'article', 'image', 'video', 'link']: logger.error(f"Processing {embed.type} embed") # Extract content from embed embed_content = await extract_embed_content(embed) if not embed_content['has_content']: logger.warning(f"Embed has no extractable content, skipping") continue # Build context string with embed text embed_context_parts = [] if embed_content['text']: embed_context_parts.append(f"[Embedded content: {embed_content['text'][:500]}{'...' if len(embed_content['text']) > 500 else ''}]") # Process images from embed if embed_content['images']: for img_url in embed_content['images']: logger.error(f"Processing image from embed: {img_url}") try: base64_img = await download_and_encode_image(img_url) if base64_img: logger.info(f"Image downloaded, analyzing with vision model...") # Analyze image qwen_description = await analyze_image_with_qwen(base64_img) truncated = (qwen_description[:50] + "...") if len(qwen_description) > 50 else qwen_description logger.error(f"Vision analysis result: {truncated}") if qwen_description and qwen_description.strip(): embed_context_parts.append(f"[Embedded image shows: {qwen_description}]") else: logger.error(f"Failed to download image from embed") except Exception as e: logger.error(f"Error processing embedded image: {e}") import traceback traceback.print_exc() # Process videos from embed if embed_content['videos']: for video_url in embed_content['videos']: logger.info(f"🎬 Processing video from embed: {video_url}") try: media_bytes_b64 = await download_and_encode_media(video_url) if media_bytes_b64: import base64 media_bytes = base64.b64decode(media_bytes_b64) frames = await extract_video_frames(media_bytes, num_frames=6) if frames: logger.info(f"📹 Extracted {len(frames)} frames, analyzing with vision model...") video_description = await analyze_video_with_vision(frames, media_type="video") logger.info(f"Video analysis result: {video_description[:100]}...") if video_description and video_description.strip(): embed_context_parts.append(f"[Embedded video shows: {video_description}]") else: logger.error(f"Failed to extract frames from video") else: logger.error(f"Failed to download video from embed") except Exception as e: logger.error(f"Error processing embedded video: {e}") import traceback traceback.print_exc() # Combine embed context with user prompt if embed_context_parts: full_context = '\n'.join(embed_context_parts) enhanced_prompt = f"{full_context}\n\nUser message: {prompt}" if prompt else full_context # Get Miku's response guild_id = message.guild.id if message.guild else None response_type = "dm_response" if is_dm else "server_response" author_name = message.author.display_name # Phase 3: Try Cat pipeline first for embed responses too response = None if globals.USE_CHESHIRE_CAT: try: from utils.cat_client import cat_adapter response = await cat_adapter.query( text=enhanced_prompt, user_id=str(message.author.id), guild_id=str(guild_id) if guild_id else None, author_name=author_name, mood=globals.DM_MOOD, response_type=response_type, ) if response: logger.info(f"🐱 Cat embed response for {author_name}") except Exception as e: logger.warning(f"🐱 Cat embed error, fallback: {e}") response = None if not response: response = await query_llama( enhanced_prompt, user_id=str(message.author.id), guild_id=guild_id, response_type=response_type, author_name=author_name ) if is_dm: logger.info(f"💌 DM embed response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: logger.info(f"💬 Server embed response to {message.author.display_name} in {message.guild.name}") response_message = await message.channel.send(response) # Log the bot's DM response if is_dm: dm_logger.log_user_message(message.author, response_message, is_bot_message=True) # For server messages, check if opposite persona should interject if not is_dm and globals.BIPOLAR_MODE: try: from utils.persona_dialogue import check_for_interjection current_persona = "evil" if globals.EVIL_MODE else "miku" create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check") except Exception as e: logger.error(f"Error checking for persona interjection: {e}") return # Check if this is an image generation request from utils.image_generation import detect_image_request, handle_image_generation_request is_image_request, image_prompt = await detect_image_request(prompt) if is_image_request and image_prompt: logger.info(f"🎨 Image generation request detected: '{image_prompt}' from {message.author.display_name}") # Block image generation during voice sessions if globals.IMAGE_GENERATION_BLOCKED: await message.channel.send(globals.IMAGE_GENERATION_BLOCK_MESSAGE) await message.add_reaction('🎤') logger.info("🚫 Image generation blocked - voice session active") return # Handle the image generation workflow success = await handle_image_generation_request(message, image_prompt) if success: return # Image generation completed successfully # If image generation failed, fall back to normal response logger.warning(f"Image generation failed, falling back to normal response") # If message is just a prompt, no image # For DMs, pass None as guild_id to use DM mood guild_id = message.guild.id if message.guild else None response_type = "dm_response" if is_dm else "server_response" author_name = message.author.display_name # Phase 3: Try Cheshire Cat pipeline first (memory-augmented response) # Falls back to query_llama if Cat is unavailable or disabled response = None if globals.USE_CHESHIRE_CAT: try: from utils.cat_client import cat_adapter current_mood = globals.DM_MOOD if guild_id: try: from server_manager import server_manager sc = server_manager.get_server_config(guild_id) if sc: current_mood = sc.current_mood_name except Exception: pass response = await cat_adapter.query( text=prompt, user_id=str(message.author.id), guild_id=str(guild_id) if guild_id else None, author_name=author_name, mood=current_mood, response_type=response_type, ) if response: logger.info(f"🐱 Cat response for {author_name} (mood: {current_mood})") except Exception as e: logger.warning(f"🐱 Cat pipeline error, falling back to query_llama: {e}") response = None # Fallback to direct LLM query if Cat didn't respond if not response: response = await query_llama( prompt, user_id=str(message.author.id), guild_id=guild_id, response_type=response_type, author_name=author_name ) if is_dm: logger.info(f"💌 DM response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: logger.info(f"💬 Server response to {message.author.display_name} in {message.guild.name} (using server mood)") response_message = await message.channel.send(response) # Log the bot's DM response if is_dm: dm_logger.log_user_message(message.author, response_message, is_bot_message=True) # For server messages, check if opposite persona should interject (persona dialogue system) if not is_dm and globals.BIPOLAR_MODE: logger.debug(f"Attempting to check for interjection (is_dm={is_dm}, BIPOLAR_MODE={globals.BIPOLAR_MODE})") try: from utils.persona_dialogue import check_for_interjection current_persona = "evil" if globals.EVIL_MODE else "miku" logger.debug(f"Creating interjection check task for persona: {current_persona}") # Pass the bot's response message for analysis create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check") except Exception as e: logger.error(f"Error checking for persona interjection: {e}") import traceback traceback.print_exc() # For server messages, do server-specific mood detection if not is_dm and message.guild: try: from server_manager import server_manager server_config = server_manager.get_server_config(message.guild.id) if server_config: # Create server context for mood detection server_context = { 'current_mood_name': server_config.current_mood_name, 'current_mood_description': server_config.current_mood_description, 'is_sleeping': server_config.is_sleeping } detected = detect_mood_shift(response, server_context) if detected and detected != server_config.current_mood_name: logger.info(f"🔄 Auto mood detection for server {message.guild.name}: {server_config.current_mood_name} -> {detected}") # Block direct transitions to asleep unless from sleepy if detected == "asleep" and server_config.current_mood_name != "sleepy": logger.warning("Ignoring asleep mood; server wasn't sleepy before.") else: # Update server mood server_manager.set_server_mood(message.guild.id, detected) # Update nickname for this server from utils.moods import update_server_nickname globals.client.loop.create_task(update_server_nickname(message.guild.id)) logger.info(f"🔄 Server mood auto-updated to: {detected}") if detected == "asleep": server_manager.set_server_sleep_state(message.guild.id, True) server_manager.schedule_wakeup_task(message.guild.id, delay_seconds=3600) else: logger.error(f"No server config found for guild {message.guild.id}, skipping mood detection") except Exception as e: logger.error(f"Error in server mood detection: {e}") elif is_dm: logger.debug("DM message - no mood detection (DM mood only changes via auto-rotation)") # V2: Track message for autonomous engine (non-blocking, no LLM calls) # IMPORTANT: Only call this if the message was NOT addressed to Miku # This prevents autonomous actions from firing when the user is directly talking to Miku if not miku_addressed: on_message_event(message) # Note: Autonomous reactions are now handled by V2 system via on_message_event() # Manual Monday test command (only for server messages) if not is_dm and message.content.lower().strip() == "!monday": await send_monday_video() #await message.channel.send("✅ Monday message sent (or attempted). Check logs.") return @globals.client.event async def on_raw_reaction_add(payload): """Handle reactions added to messages (including bot's own reactions and uncached messages)""" # Check if this is a DM if payload.guild_id is not None: return # Only handle DM reactions # Get the channel channel = await globals.client.fetch_channel(payload.channel_id) if not isinstance(channel, discord.DMChannel): return # Get the user who reacted user = await globals.client.fetch_user(payload.user_id) # Get the DM partner (the person DMing the bot, not the bot itself) # For DMs, we want to log under the user's ID, not the bot's if user.id == globals.client.user.id: # Bot reacted - find the other user in the DM message = await channel.fetch_message(payload.message_id) dm_user_id = message.author.id if message.author.id != globals.client.user.id else channel.recipient.id is_bot_reactor = True else: # User reacted dm_user_id = user.id is_bot_reactor = False # Get emoji string emoji_str = str(payload.emoji) # Log the reaction await dm_logger.log_reaction_add( user_id=dm_user_id, message_id=payload.message_id, emoji=emoji_str, reactor_id=user.id, reactor_name=user.display_name or user.name, is_bot_reactor=is_bot_reactor ) reactor_type = "🤖 Miku" if is_bot_reactor else f"👤 {user.display_name}" logger.debug(f"DM reaction added: {emoji_str} by {reactor_type} on message {payload.message_id}") @globals.client.event async def on_raw_reaction_remove(payload): """Handle reactions removed from messages (including bot's own reactions and uncached messages)""" # Check if this is a DM if payload.guild_id is not None: return # Only handle DM reactions # Get the channel channel = await globals.client.fetch_channel(payload.channel_id) if not isinstance(channel, discord.DMChannel): return # Get the user who removed the reaction user = await globals.client.fetch_user(payload.user_id) # Get the DM partner (the person DMing the bot, not the bot itself) if user.id == globals.client.user.id: # Bot removed reaction - find the other user in the DM message = await channel.fetch_message(payload.message_id) dm_user_id = message.author.id if message.author.id != globals.client.user.id else channel.recipient.id else: # User removed reaction dm_user_id = user.id # Get emoji string emoji_str = str(payload.emoji) # Log the reaction removal await dm_logger.log_reaction_remove( user_id=dm_user_id, message_id=payload.message_id, emoji=emoji_str, reactor_id=user.id ) reactor_type = "🤖 Miku" if user.id == globals.client.user.id else f"👤 {user.display_name}" logger.debug(f"DM reaction removed: {emoji_str} by {reactor_type} from message {payload.message_id}") @globals.client.event async def on_presence_update(before, after): """Track user presence changes for autonomous V2 system""" # Discord.py passes before/after Member objects with different states # We pass the 'after' member and both states for comparison autonomous_presence_update(after, before, after) @globals.client.event async def on_member_join(member): """Track member joins for autonomous V2 system""" autonomous_member_join(member) @globals.client.event async def on_voice_state_update(member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): """Track voice channel join/leave for voice call management.""" from utils.voice_manager import VoiceSessionManager session_manager = VoiceSessionManager() if not session_manager.active_session: return # Check if this is our voice channel if before.channel != session_manager.active_session.voice_channel and \ after.channel != session_manager.active_session.voice_channel: return # User joined our voice channel if before.channel != after.channel and after.channel == session_manager.active_session.voice_channel: logger.info(f"👤 {member.name} joined voice channel") await session_manager.active_session.on_user_join(member.id) # Auto-start listening if this is a voice call if session_manager.active_session.call_user_id == member.id: await session_manager.active_session.start_listening(member) # User left our voice channel elif before.channel == session_manager.active_session.voice_channel and \ after.channel != before.channel: logger.info(f"👤 {member.name} left voice channel") await session_manager.active_session.on_user_leave(member.id) # Stop listening to this user await session_manager.active_session.stop_listening(member.id) def start_api(): # Set log_level to "critical" to silence uvicorn's access logs # Our custom api.requests middleware handles HTTP logging with better formatting and filtering uvicorn.run(app, host="0.0.0.0", port=3939, log_level="critical") def save_autonomous_state(): """Save autonomous context on shutdown""" try: from utils.autonomous import autonomous_engine autonomous_engine.save_context() logger.info("💾 Saved autonomous context on shutdown") except Exception as e: logger.error(f"Failed to save autonomous context on shutdown: {e}") async def graceful_shutdown(): """ Perform a full async cleanup before the bot exits. Shutdown sequence: 1. End active voice sessions (disconnect, release GPU locks) 2. Save autonomous engine state 3. Stop the APScheduler 4. Cancel all tracked background tasks 5. Close the Discord gateway connection """ logger.warning("🛑 Graceful shutdown initiated...") # 1. End active voice session (cleans up audio, STT, GPU locks, etc.) try: from utils.voice_manager import VoiceSessionManager session_mgr = VoiceSessionManager() if session_mgr.active_session: logger.info("🎙️ Ending active voice session...") await session_mgr.end_session() logger.info("✓ Voice session ended") except Exception as e: logger.error(f"Error ending voice session during shutdown: {e}") # 2. Persist autonomous engine state save_autonomous_state() # 3. Shut down the APScheduler try: if globals.scheduler.running: globals.scheduler.shutdown(wait=False) logger.info("✓ Scheduler stopped") except Exception as e: logger.error(f"Error stopping scheduler: {e}") # 4. Cancel all tracked background tasks try: from utils.task_tracker import _active_tasks pending = [t for t in _active_tasks if not t.done()] if pending: logger.info(f"Cancelling {len(pending)} background tasks...") for t in pending: t.cancel() await asyncio.gather(*pending, return_exceptions=True) logger.info("✓ Background tasks cancelled") except Exception as e: logger.error(f"Error cancelling background tasks: {e}") # 5. Close the Discord gateway connection try: if not globals.client.is_closed(): await globals.client.close() logger.info("✓ Discord client closed") except Exception as e: logger.error(f"Error closing Discord client: {e}") logger.warning("🛑 Graceful shutdown complete") def _handle_shutdown_signal(sig, _frame): """Schedule the async shutdown from a sync signal handler.""" sig_name = signal.Signals(sig).name logger.warning(f"Received {sig_name}, scheduling graceful shutdown...") # Schedule the coroutine on the running event loop loop = asyncio.get_event_loop() if loop.is_running(): loop.create_task(graceful_shutdown()) else: # Fallback: just save state synchronously save_autonomous_state() # Register signal handlers (async-aware) signal.signal(signal.SIGTERM, _handle_shutdown_signal) signal.signal(signal.SIGINT, _handle_shutdown_signal) # Keep atexit as a last-resort sync fallback atexit.register(save_autonomous_state) threading.Thread(target=start_api, daemon=True).start() globals.client.run(globals.DISCORD_BOT_TOKEN)