Files
miku-discord/bot/bot.py

814 lines
40 KiB
Python
Raw Normal View History

2025-12-07 17:15:09 +02:00
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_llama
2025-12-07 17:15:09 +02:00
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
2025-12-07 17:15:09 +02:00
import globals
# Initialize bot logger
logger = get_logger('bot')
2025-12-07 17:15:09 +02:00
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
2025-12-07 17:15:09 +02:00
@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!')
2025-12-07 17:15:09 +02:00
globals.BOT_USER = globals.client.user
# Intercept external library loggers (APScheduler, etc.)
from utils.logger import intercept_external_loggers
intercept_external_loggers()
2025-12-07 17:15:09 +02:00
# Restore evil mode state from previous session (if any)
from utils.evil_mode import restore_evil_mode_on_startup
restore_evil_mode_on_startup()
Implement Bipolar Mode: Dual persona arguments with webhooks, LLM arbiter, and persistent scoreboard Major Features: - Complete Bipolar Mode system allowing Regular Miku and Evil Miku to coexist and argue via webhooks - LLM arbiter system using neutral model to judge argument winners with detailed reasoning - Persistent scoreboard tracking wins, percentages, and last 50 results with timestamps and reasoning - Automatic mode switching based on argument winner - Webhook management per channel with profile pictures and display names - Progressive probability system for dynamic argument lengths (starts at 10%, increases 5% per exchange, min 4 exchanges) - Draw handling with penalty system (-5% end chance, continues argument) - Integration with autonomous system for random argument triggers Argument System: - MIN_EXCHANGES = 4, progressive end chance starting at 10% - Enhanced prompts for both personas (strategic, short, punchy responses 1-3 sentences) - Evil Miku triumphant victory messages with gloating and satisfaction - Regular Miku assertive defense (not passive, shows backbone) - Message-based argument starting (can respond to specific messages via ID) - Conversation history tracking per argument with special user_id - Full context queries (personality, lore, lyrics, last 8 messages) LLM Arbiter: - Decisive prompt emphasizing picking winners (draws should be rare) - Improved parsing with first-line exact matching and fallback counting - Debug logging for decision transparency - Arbiter reasoning stored in scoreboard history for review - Uses neutral TEXT_MODEL (not evil) for unbiased judgment Web UI & API: - Bipolar mode toggle button (only visible when evil mode is on) - Channel ID + Message ID input fields for argument triggering - Scoreboard display with win percentages and recent history - Manual argument trigger endpoint with string-based IDs - GET /bipolar-mode/scoreboard endpoint for stats retrieval - Real-time active arguments tracking (refreshes every 5 seconds) Prompt Optimizations: - All argument prompts limited to 1-3 sentences for impact - Evil Miku system prompt with variable response length guidelines - Removed walls of text, emphasizing brevity and precision - "Sometimes the cruelest response is the shortest one" Evil Miku Updates: - Added height to lore (15.8m tall, 10x bigger than regular Miku) - Height added to prompt facts for size-based belittling - More strategic and calculating personality in arguments Integration: - Bipolar mode state restoration on bot startup - Bot skips processing messages during active arguments - Autonomous system checks for bipolar triggers after actions - Import fixes (apply_evil_mode_changes/revert_evil_mode_changes) Technical Details: - State persistence via JSON (bipolar_mode_state.json, bipolar_webhooks.json, bipolar_scoreboard.json) - Webhook caching per guild with fallback creation - Event loop management with asyncio.create_task - Rate limiting and argument conflict prevention - Globals integration (BIPOLAR_MODE, BIPOLAR_WEBHOOKS, BIPOLAR_ARGUMENT_IN_PROGRESS, MOOD_EMOJIS) Files Changed: - bot/bot.py: Added bipolar mode restoration and argument-in-progress checks - bot/globals.py: Added bipolar mode state variables and mood emoji mappings - bot/utils/bipolar_mode.py: Complete 1106-line implementation - bot/utils/autonomous.py: Added bipolar argument trigger checks - bot/utils/evil_mode.py: Updated system prompt, added height info to lore/prompt - bot/api.py: Added bipolar mode endpoints (trigger, toggle, scoreboard) - bot/static/index.html: Added bipolar controls section with scoreboard - bot/memory/: Various DM conversation updates - bot/evil_miku_lore.txt: Added height description - bot/evil_miku_prompt.txt: Added height to facts, updated personality guidelines
2026-01-06 13:57:59 +02:00
# Restore bipolar mode state from previous session (if any)
from utils.bipolar_mode import restore_bipolar_mode_on_startup
restore_bipolar_mode_on_startup()
2025-12-07 17:15:09 +02:00
# 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}")
2025-12-07 17:15:09 +02:00
# 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")
2025-12-07 17:15:09 +02:00
else:
logger.warning("OWNER_USER_ID not set, DM analysis feature disabled")
2025-12-07 17:15:09 +02:00
# 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)
2025-12-07 17:15:09 +02:00
# 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
Implement Bipolar Mode: Dual persona arguments with webhooks, LLM arbiter, and persistent scoreboard Major Features: - Complete Bipolar Mode system allowing Regular Miku and Evil Miku to coexist and argue via webhooks - LLM arbiter system using neutral model to judge argument winners with detailed reasoning - Persistent scoreboard tracking wins, percentages, and last 50 results with timestamps and reasoning - Automatic mode switching based on argument winner - Webhook management per channel with profile pictures and display names - Progressive probability system for dynamic argument lengths (starts at 10%, increases 5% per exchange, min 4 exchanges) - Draw handling with penalty system (-5% end chance, continues argument) - Integration with autonomous system for random argument triggers Argument System: - MIN_EXCHANGES = 4, progressive end chance starting at 10% - Enhanced prompts for both personas (strategic, short, punchy responses 1-3 sentences) - Evil Miku triumphant victory messages with gloating and satisfaction - Regular Miku assertive defense (not passive, shows backbone) - Message-based argument starting (can respond to specific messages via ID) - Conversation history tracking per argument with special user_id - Full context queries (personality, lore, lyrics, last 8 messages) LLM Arbiter: - Decisive prompt emphasizing picking winners (draws should be rare) - Improved parsing with first-line exact matching and fallback counting - Debug logging for decision transparency - Arbiter reasoning stored in scoreboard history for review - Uses neutral TEXT_MODEL (not evil) for unbiased judgment Web UI & API: - Bipolar mode toggle button (only visible when evil mode is on) - Channel ID + Message ID input fields for argument triggering - Scoreboard display with win percentages and recent history - Manual argument trigger endpoint with string-based IDs - GET /bipolar-mode/scoreboard endpoint for stats retrieval - Real-time active arguments tracking (refreshes every 5 seconds) Prompt Optimizations: - All argument prompts limited to 1-3 sentences for impact - Evil Miku system prompt with variable response length guidelines - Removed walls of text, emphasizing brevity and precision - "Sometimes the cruelest response is the shortest one" Evil Miku Updates: - Added height to lore (15.8m tall, 10x bigger than regular Miku) - Height added to prompt facts for size-based belittling - More strategic and calculating personality in arguments Integration: - Bipolar mode state restoration on bot startup - Bot skips processing messages during active arguments - Autonomous system checks for bipolar triggers after actions - Import fixes (apply_evil_mode_changes/revert_evil_mode_changes) Technical Details: - State persistence via JSON (bipolar_mode_state.json, bipolar_webhooks.json, bipolar_scoreboard.json) - Webhook caching per guild with fallback creation - Event loop management with asyncio.create_task - Rate limiting and argument conflict prevention - Globals integration (BIPOLAR_MODE, BIPOLAR_WEBHOOKS, BIPOLAR_ARGUMENT_IN_PROGRESS, MOOD_EMOJIS) Files Changed: - bot/bot.py: Added bipolar mode restoration and argument-in-progress checks - bot/globals.py: Added bipolar mode state variables and mood emoji mappings - bot/utils/bipolar_mode.py: Complete 1106-line implementation - bot/utils/autonomous.py: Added bipolar argument trigger checks - bot/utils/evil_mode.py: Updated system prompt, added height info to lore/prompt - bot/api.py: Added bipolar mode endpoints (trigger, toggle, scoreboard) - bot/static/index.html: Added bipolar controls section with scoreboard - bot/memory/: Various DM conversation updates - bot/evil_miku_lore.txt: Added height description - bot/evil_miku_prompt.txt: Added height to facts, updated personality guidelines
2026-01-06 13:57:59 +02:00
# 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
Implement Bipolar Mode: Dual persona arguments with webhooks, LLM arbiter, and persistent scoreboard Major Features: - Complete Bipolar Mode system allowing Regular Miku and Evil Miku to coexist and argue via webhooks - LLM arbiter system using neutral model to judge argument winners with detailed reasoning - Persistent scoreboard tracking wins, percentages, and last 50 results with timestamps and reasoning - Automatic mode switching based on argument winner - Webhook management per channel with profile pictures and display names - Progressive probability system for dynamic argument lengths (starts at 10%, increases 5% per exchange, min 4 exchanges) - Draw handling with penalty system (-5% end chance, continues argument) - Integration with autonomous system for random argument triggers Argument System: - MIN_EXCHANGES = 4, progressive end chance starting at 10% - Enhanced prompts for both personas (strategic, short, punchy responses 1-3 sentences) - Evil Miku triumphant victory messages with gloating and satisfaction - Regular Miku assertive defense (not passive, shows backbone) - Message-based argument starting (can respond to specific messages via ID) - Conversation history tracking per argument with special user_id - Full context queries (personality, lore, lyrics, last 8 messages) LLM Arbiter: - Decisive prompt emphasizing picking winners (draws should be rare) - Improved parsing with first-line exact matching and fallback counting - Debug logging for decision transparency - Arbiter reasoning stored in scoreboard history for review - Uses neutral TEXT_MODEL (not evil) for unbiased judgment Web UI & API: - Bipolar mode toggle button (only visible when evil mode is on) - Channel ID + Message ID input fields for argument triggering - Scoreboard display with win percentages and recent history - Manual argument trigger endpoint with string-based IDs - GET /bipolar-mode/scoreboard endpoint for stats retrieval - Real-time active arguments tracking (refreshes every 5 seconds) Prompt Optimizations: - All argument prompts limited to 1-3 sentences for impact - Evil Miku system prompt with variable response length guidelines - Removed walls of text, emphasizing brevity and precision - "Sometimes the cruelest response is the shortest one" Evil Miku Updates: - Added height to lore (15.8m tall, 10x bigger than regular Miku) - Height added to prompt facts for size-based belittling - More strategic and calculating personality in arguments Integration: - Bipolar mode state restoration on bot startup - Bot skips processing messages during active arguments - Autonomous system checks for bipolar triggers after actions - Import fixes (apply_evil_mode_changes/revert_evil_mode_changes) Technical Details: - State persistence via JSON (bipolar_mode_state.json, bipolar_webhooks.json, bipolar_scoreboard.json) - Webhook caching per guild with fallback creation - Event loop management with asyncio.create_task - Rate limiting and argument conflict prevention - Globals integration (BIPOLAR_MODE, BIPOLAR_WEBHOOKS, BIPOLAR_ARGUMENT_IN_PROGRESS, MOOD_EMOJIS) Files Changed: - bot/bot.py: Added bipolar mode restoration and argument-in-progress checks - bot/globals.py: Added bipolar mode state variables and mood emoji mappings - bot/utils/bipolar_mode.py: Complete 1106-line implementation - bot/utils/autonomous.py: Added bipolar argument trigger checks - bot/utils/evil_mode.py: Updated system prompt, added height info to lore/prompt - bot/api.py: Added bipolar mode endpoints (trigger, toggle, scoreboard) - bot/static/index.html: Added bipolar controls section with scoreboard - bot/memory/: Various DM conversation updates - bot/evil_miku_lore.txt: Added height description - bot/evil_miku_prompt.txt: Added height to facts, updated personality guidelines
2026-01-06 13:57:59 +02:00
# 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
2026-01-09 00:03:59 +02:00
# 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
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
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)
2025-12-07 17:15:09 +02:00
if is_dm:
logger.info(f"💌 DM from {message.author.display_name}: {message.content[:50]}{'...' if len(message.content) > 50 else ''}")
2025-12-07 17:15:09 +02:00
# 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")
2025-12-07 17:15:09 +02:00
return
# Log the user's DM message
dm_logger.log_user_message(message.author, message, is_bot_message=False)
if miku_addressed:
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
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
2025-12-07 17:15:09 +02:00
# 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})")
2025-12-07 17:15:09 +02:00
else:
logger.info(f"💬 Server image response to {message.author.display_name} in {message.guild.name} (using server mood)")
2025-12-07 17:15:09 +02:00
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)
2026-01-09 00:03:59 +02:00
# 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"
asyncio.create_task(check_for_interjection(response_message, current_persona))
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
2026-01-09 00:03:59 +02:00
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
# 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...")
2025-12-07 17:15:09 +02:00
mp4_bytes = await convert_gif_to_mp4(media_bytes)
if mp4_bytes:
media_bytes = mp4_bytes
logger.info(f"✅ GIF converted to MP4")
2025-12-07 17:15:09 +02:00
else:
logger.warning(f"GIF conversion failed, trying direct processing")
2025-12-07 17:15:09 +02:00
# 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}")
2025-12-07 17:15:09 +02:00
# 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})")
2025-12-07 17:15:09 +02:00
else:
logger.info(f"💬 Server video response to {message.author.display_name} in {message.guild.name} (using server mood)")
2025-12-07 17:15:09 +02:00
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)
2026-01-09 00:03:59 +02:00
# 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"
asyncio.create_task(check_for_interjection(response_message, current_persona))
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
2026-01-09 00:03:59 +02:00
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
# 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")
2025-12-07 17:15:09 +02:00
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...")
2025-12-07 17:15:09 +02:00
mp4_bytes = await convert_gif_to_mp4(media_bytes)
if not mp4_bytes:
logger.warning(f"GIF conversion failed, trying direct frame extraction")
2025-12-07 17:15:09 +02:00
mp4_bytes = media_bytes
else:
logger.debug(f"Tenor GIF converted to MP4")
2025-12-07 17:15:09 +02:00
# 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")
2025-12-07 17:15:09 +02:00
# 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})")
2025-12-07 17:15:09 +02:00
else:
logger.info(f"💬 Server Tenor GIF response to {message.author.display_name} in {message.guild.name} (using server mood)")
2025-12-07 17:15:09 +02:00
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)
2026-01-09 00:03:59 +02:00
# 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"
asyncio.create_task(check_for_interjection(response_message, current_persona))
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
2026-01-09 00:03:59 +02:00
2025-12-07 17:15:09 +02:00
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")
2025-12-07 17:15:09 +02:00
# 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")
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
try:
base64_img = await download_and_encode_image(img_url)
if base64_img:
logger.info(f"Image downloaded, analyzing with vision model...")
2025-12-07 17:15:09 +02:00
# 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}")
2025-12-07 17:15:09 +02:00
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")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error processing embedded image: {e}")
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
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...")
2025-12-07 17:15:09 +02:00
video_description = await analyze_video_with_vision(frames, media_type="video")
logger.info(f"Video analysis result: {video_description[:100]}...")
2025-12-07 17:15:09 +02:00
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")
2025-12-07 17:15:09 +02:00
else:
logger.error(f"Failed to download video from embed")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error processing embedded video: {e}")
2025-12-07 17:15:09 +02:00
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_llama(
2025-12-07 17:15:09 +02:00
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})")
2025-12-07 17:15:09 +02:00
else:
logger.info(f"💬 Server embed response to {message.author.display_name} in {message.guild.name}")
2025-12-07 17:15:09 +02:00
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)
2026-01-09 00:03:59 +02:00
# 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"
asyncio.create_task(check_for_interjection(response_message, current_persona))
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
2026-01-09 00:03:59 +02:00
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
# 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
2025-12-07 17:15:09 +02:00
# 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")
2025-12-07 17:15:09 +02:00
# 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_llama(
2025-12-07 17:15:09 +02:00
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})")
2025-12-07 17:15:09 +02:00
else:
logger.info(f"💬 Server response to {message.author.display_name} in {message.guild.name} (using server mood)")
2025-12-07 17:15:09 +02:00
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)
2026-01-09 00:03:59 +02:00
# 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})")
2026-01-09 00:03:59 +02:00
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}")
2026-01-09 00:03:59 +02:00
# Pass the bot's response message for analysis
asyncio.create_task(check_for_interjection(response_message, current_persona))
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
2026-01-09 00:03:59 +02:00
import traceback
traceback.print_exc()
2025-12-07 17:15:09 +02:00
# 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}")
2025-12-07 17:15:09 +02:00
# 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.")
2025-12-07 17:15:09 +02:00
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}")
2025-12-07 17:15:09 +02:00
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)
logger.info(f"🌅 Server {message.guild.name} woke up from auto-sleep")
2025-12-07 17:15:09 +02:00
globals.client.loop.create_task(delayed_wakeup())
else:
logger.error(f"No server config found for guild {message.guild.id}, skipping mood detection")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Error in server mood detection: {e}")
2025-12-07 17:15:09 +02:00
elif is_dm:
logger.debug("DM message - no mood detection (DM mood only changes via auto-rotation)")
2025-12-07 17:15:09 +02:00
# 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)
2025-12-07 17:15:09 +02:00
# 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}")
2025-12-07 17:15:09 +02:00
@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}")
2025-12-07 17:15:09 +02:00
@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)
2025-12-07 17:15:09 +02:00
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")
2025-12-07 17:15:09 +02:00
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")
2025-12-07 17:15:09 +02:00
except Exception as e:
logger.error(f"Failed to save autonomous context on shutdown: {e}")
2025-12-07 17:15:09 +02:00
# 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)