import discord import asyncio import threading import uvicorn import logging import sys import random import string import signal import atexit from api import app from server_manager import server_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_ollama 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 import globals 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 ) @globals.client.event async def on_ready(): print(f'🎤 MikuBot connected as {globals.client.user}') print(f'💬 DM support enabled - users can message Miku directly!') globals.BOT_USER = globals.client.user # Initialize DM interaction analyzer if globals.OWNER_USER_ID and globals.OWNER_USER_ID != 0: init_dm_analyzer(globals.OWNER_USER_ID) print(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' ) print("⏰ Scheduled daily DM analysis at 2:00 AM") else: print("⚠️ 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 # V2: Track message for autonomous engine (non-blocking, no LLM calls) on_message_event(message) 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: print(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 if is_dm: print(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): print(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 await is_miku_addressed(message): 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: print(f"⚠️ Failed to fetch replied message for context: {e}") async with message.channel.typing(): # 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: print(f"💌 DM image response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: print(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) 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" print(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: print(f"🔄 Converting GIF to MP4 for processing...") mp4_bytes = await convert_gif_to_mp4(media_bytes) if mp4_bytes: media_bytes = mp4_bytes print(f"✅ GIF converted to MP4") else: print(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 print(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: print(f"💌 DM {media_type} response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: print(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) 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: print(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: print(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 print(f"🔄 Converting Tenor GIF to MP4 for processing...") mp4_bytes = await convert_gif_to_mp4(media_bytes) if not mp4_bytes: print(f"⚠️ GIF conversion failed, trying direct frame extraction") mp4_bytes = media_bytes else: print(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 print(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: print(f"💌 DM Tenor GIF response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: print(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) return # Handle other types of embeds (rich, article, image, video, link) elif embed.type in ['rich', 'article', 'image', 'video', 'link']: print(f"📰 Processing {embed.type} embed") # Extract content from embed embed_content = await extract_embed_content(embed) if not embed_content['has_content']: print(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']: print(f"🖼️ Processing image from embed: {img_url}") try: base64_img = await download_and_encode_image(img_url) if base64_img: print(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 print(f"📝 Vision analysis result: {truncated}") if qwen_description and qwen_description.strip(): embed_context_parts.append(f"[Embedded image shows: {qwen_description}]") else: print(f"❌ Failed to download image from embed") except Exception as e: print(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']: print(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: print(f"📹 Extracted {len(frames)} frames, analyzing with vision model...") video_description = await analyze_video_with_vision(frames, media_type="video") print(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: print(f"❌ Failed to extract frames from video") else: print(f"❌ Failed to download video from embed") except Exception as e: print(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 response = await query_ollama( enhanced_prompt, user_id=str(message.author.id), guild_id=guild_id, response_type=response_type, author_name=author_name ) if is_dm: print(f"💌 DM embed response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: print(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) 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: print(f"🎨 Image generation request detected: '{image_prompt}' from {message.author.display_name}") # 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 print(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 response = await query_ollama( prompt, user_id=str(message.author.id), guild_id=guild_id, response_type=response_type, author_name=author_name ) if is_dm: print(f"💌 DM response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") else: print(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, 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: print(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": print("❌ 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)) print(f"🔄 Server mood auto-updated to: {detected}") if detected == "asleep": server_manager.set_server_sleep_state(message.guild.id, True) # Schedule wake-up after 1 hour async def delayed_wakeup(): await asyncio.sleep(3600) # 1 hour server_manager.set_server_sleep_state(message.guild.id, False) server_manager.set_server_mood(message.guild.id, "neutral") await update_server_nickname(message.guild.id) print(f"🌅 Server {message.guild.name} woke up from auto-sleep") globals.client.loop.create_task(delayed_wakeup()) else: print(f"⚠️ No server config found for guild {message.guild.id}, skipping mood detection") except Exception as e: print(f"⚠️ Error in server mood detection: {e}") elif is_dm: print("💌 DM message - no mood detection (DM mood only changes via auto-rotation)") # 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}" print(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}" print(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) def start_api(): uvicorn.run(app, host="0.0.0.0", port=3939, log_level="info") def save_autonomous_state(): """Save autonomous context on shutdown""" try: from utils.autonomous import autonomous_engine autonomous_engine.save_context() print("💾 Saved autonomous context on shutdown") except Exception as e: print(f"⚠️ Failed to save autonomous context on shutdown: {e}") # Register shutdown handlers atexit.register(save_autonomous_state) signal.signal(signal.SIGTERM, lambda s, f: save_autonomous_state()) signal.signal(signal.SIGINT, lambda s, f: save_autonomous_state()) threading.Thread(target=start_api, daemon=True).start() globals.client.run(globals.DISCORD_BOT_TOKEN)