# autonomous.py import random import time import json import os from datetime import datetime import discord from discord import Status from discord import TextChannel from difflib import SequenceMatcher import globals from server_manager import server_manager from utils.llm import query_llama from utils.moods import MOOD_EMOJIS from utils.twitter_fetcher import fetch_miku_tweets from utils.image_handling import ( analyze_image_with_qwen, download_and_encode_image, download_and_encode_media, extract_video_frames, analyze_video_with_vision, convert_gif_to_mp4 ) from utils.sleep_responses import SLEEP_RESPONSES # Server-specific memory storage _server_autonomous_messages = {} # guild_id -> rotating buffer of last general messages _server_user_engagements = {} # guild_id -> user_id -> timestamp _reacted_message_ids = set() # Track messages we've already reacted to MAX_HISTORY = 10 LAST_SENT_TWEETS_FILE = "memory/last_sent_tweets.json" LAST_SENT_TWEETS = [] AUTONOMOUS_CONFIG_FILE = "memory/autonomous_config.json" def load_autonomous_config(): if os.path.exists(AUTONOMOUS_CONFIG_FILE): with open(AUTONOMOUS_CONFIG_FILE, "r", encoding="utf-8") as f: return json.load(f) return {} def save_autonomous_config(config): with open(AUTONOMOUS_CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) def setup_autonomous_speaking(): """Setup autonomous speaking for all configured servers""" # This is now handled by the server manager print("๐Ÿค– Autonomous Miku setup delegated to server manager!") async def miku_autonomous_tick_for_server(guild_id: int, action_type="general", force=False, force_action=None): """Run autonomous behavior for a specific server""" if not force and random.random() > 0.10: # 10% chance to act return if force_action: action_type = force_action else: action_type = random.choice(["general", "engage_user", "share_tweet"]) if action_type == "general": await miku_say_something_general_for_server(guild_id) elif action_type == "engage_user": await miku_engage_random_user_for_server(guild_id) else: await share_miku_tweet_for_server(guild_id) async def miku_say_something_general_for_server(guild_id: int): """Miku says something general in a specific server""" server_config = server_manager.get_server_config(guild_id) if not server_config: print(f"โš ๏ธ No config found for server {guild_id}") return channel = globals.client.get_channel(server_config.autonomous_channel_id) if not channel: print(f"โš ๏ธ Autonomous channel not found for server {guild_id}") return # Use server-specific mood mood = server_config.current_mood_name time_of_day = get_time_of_day() emoji = MOOD_EMOJIS.get(mood, "") # Special handling for sleep state if mood == "asleep": message = random.choice(SLEEP_RESPONSES) await channel.send(message) return # Get server-specific message history if guild_id not in _server_autonomous_messages: _server_autonomous_messages[guild_id] = [] history_summary = "\n".join(f"- {msg}" for msg in _server_autonomous_messages[guild_id][-5:]) if _server_autonomous_messages[guild_id] else "None yet." prompt = ( f"Miku is feeling {mood}. It's currently {time_of_day}. " f"Write a short, natural message that Miku might say out of the blue in a chat. " f"She might greet everyone, make a cute observation, ask a silly question, or say something funny. " f"Make sure it feels casual and spontaneous, like a real person might say.\n\n" f"Here are some things Miku recently said, do not repeat them or say anything too similar:\n{history_summary}" ) for attempt in range(3): # retry up to 3 times if message is too similar # Use consistent user_id per guild for autonomous actions to enable conversation history # and prompt caching, rather than creating new IDs with timestamps message = await query_llama(prompt, user_id=f"miku-autonomous-{guild_id}", guild_id=guild_id, response_type="autonomous_general") if not is_too_similar(message, _server_autonomous_messages[guild_id]): break print("๐Ÿ” Response was too similar to past messages, retrying...") try: await channel.send(message) _server_autonomous_messages[guild_id].append(message) if len(_server_autonomous_messages[guild_id]) > MAX_HISTORY: _server_autonomous_messages[guild_id].pop(0) print(f"๐Ÿ’ฌ Miku said something general in #{channel.name} (Server: {server_config.guild_name})") except Exception as e: print(f"โš ๏ธ Failed to send autonomous message: {e}") async def miku_engage_random_user_for_server(guild_id: int): """Miku engages a random user in a specific server""" server_config = server_manager.get_server_config(guild_id) if not server_config: print(f"โš ๏ธ No config found for server {guild_id}") return guild = globals.client.get_guild(guild_id) if not guild: print(f"โš ๏ธ Guild {guild_id} not found.") return channel = globals.client.get_channel(server_config.autonomous_channel_id) if not channel: print(f"โš ๏ธ Autonomous channel not found for server {guild_id}") return members = [ m for m in guild.members if m.status in {Status.online, Status.idle, Status.dnd} and not m.bot ] time_of_day = get_time_of_day() if not members: print(f"๐Ÿ˜ด No available members to talk to in server {guild_id}.") return target = random.choice(members) # Initialize server-specific user engagements if guild_id not in _server_user_engagements: _server_user_engagements[guild_id] = {} now = time.time() last_time = _server_user_engagements[guild_id].get(target.id, 0) if now - last_time < 43200: # 12 hours in seconds print(f"โฑ๏ธ Recently engaged {target.display_name} in server {guild_id}, switching to general message.") await miku_say_something_general_for_server(guild_id) return activity_name = None if target.activities: for a in target.activities: if hasattr(a, 'name') and a.name: activity_name = a.name break # Use server-specific mood instead of global mood = server_config.current_mood_name emoji = MOOD_EMOJIS.get(mood, "") is_invisible = target.status == Status.offline display_name = target.display_name prompt = ( f"Miku is feeling {mood} {emoji} during the {time_of_day}. " f"She notices {display_name}'s current status is {target.status.name}. " ) if is_invisible: prompt += ( f"Miku suspects that {display_name} is being sneaky and invisible ๐Ÿ‘ป. " f"She wants to playfully call them out in a fun, teasing, but still affectionate way. " ) elif activity_name: prompt += ( f"They appear to be playing or doing: {activity_name}. " f"Miku wants to comment on this and start a friendly conversation." ) else: prompt += ( f"Miku wants to casually start a conversation with them, maybe ask how they're doing, what they're up to, or even talk about something random with them." ) prompt += ( f"\nThe message should be short and reflect Miku's current mood." ) try: # Use consistent user_id for engaging users to enable conversation history message = await query_llama(prompt, user_id=f"miku-engage-{guild_id}", guild_id=guild_id) await channel.send(f"{target.mention} {message}") _server_user_engagements[guild_id][target.id] = time.time() print(f"๐Ÿ‘ค Miku engaged {display_name} in server {server_config.guild_name}") except Exception as e: print(f"โš ๏ธ Failed to engage user: {e}") async def miku_detect_and_join_conversation_for_server(guild_id: int): """Miku detects and joins conversations in a specific server""" server_config = server_manager.get_server_config(guild_id) if not server_config: print(f"โš ๏ธ No config found for server {guild_id}") return channel = globals.client.get_channel(server_config.autonomous_channel_id) if not isinstance(channel, TextChannel): print(f"โš ๏ธ Autonomous channel is invalid or not found for server {guild_id}") return # Fetch last 20 messages (for filtering) try: messages = [msg async for msg in channel.history(limit=20)] except Exception as e: print(f"โš ๏ธ Failed to fetch channel history for server {guild_id}: {e}") return # Filter to messages in last 10 minutes from real users (not bots) recent_msgs = [ msg for msg in messages if not msg.author.bot and (datetime.now(msg.created_at.tzinfo) - msg.created_at).total_seconds() < 600 ] user_ids = set(msg.author.id for msg in recent_msgs) if len(recent_msgs) < 5 or len(user_ids) < 2: # Not enough activity return if random.random() > 0.5: return # 50% chance to engage # Use last 10 messages for context (oldest to newest) convo_lines = reversed(recent_msgs[:10]) history_text = "\n".join( f"{msg.author.display_name}: {msg.content}" for msg in convo_lines ) # Use server-specific mood instead of global mood = server_config.current_mood_name emoji = MOOD_EMOJIS.get(mood, "") prompt = ( f"Miku is watching a conversation happen in the chat. Her current mood is {mood} {emoji}. " f"She wants to say something relevant, playful, or insightful based on what people are talking about.\n\n" f"Here's the conversation:\n{history_text}\n\n" f"Write a short reply that feels natural and adds to the discussion. It should reflect Miku's mood and personality." ) try: # Use consistent user_id for joining conversations to enable conversation history reply = await query_llama(prompt, user_id=f"miku-conversation-{guild_id}", guild_id=guild_id, response_type="conversation_join") await channel.send(reply) print(f"๐Ÿ’ฌ Miku joined an ongoing conversation in server {server_config.guild_name}") except Exception as e: print(f"โš ๏ธ Failed to interject in conversation: {e}") async def share_miku_tweet_for_server(guild_id: int): """Share a Miku tweet in a specific server""" server_config = server_manager.get_server_config(guild_id) if not server_config: print(f"โš ๏ธ No config found for server {guild_id}") return channel = globals.client.get_channel(server_config.autonomous_channel_id) tweets = await fetch_miku_tweets(limit=5) if not tweets: print(f"๐Ÿ“ญ No good tweets found for server {guild_id}") return fresh_tweets = [t for t in tweets if t["url"] not in LAST_SENT_TWEETS] if not fresh_tweets: print(f"โš ๏ธ All fetched tweets were recently sent in server {guild_id}. Reusing tweets.") fresh_tweets = tweets tweet = random.choice(fresh_tweets) LAST_SENT_TWEETS.append(tweet["url"]) if len(LAST_SENT_TWEETS) > 50: LAST_SENT_TWEETS.pop(0) save_last_sent_tweets() # Prepare prompt - use server-specific mood instead of global mood = server_config.current_mood_name emoji = MOOD_EMOJIS.get(mood, "") base_prompt = f"Here's a tweet from @{tweet['username']}:\n\n{tweet['text']}\n\nComment on it in a fun Miku style! Miku's current mood is {mood} {emoji}. Make sure the comment reflects Miku's mood and personality." # Optionally analyze first image if media exists if tweet.get("media") and len(tweet["media"]) > 0: first_img_url = tweet["media"][0] base64_img = await download_and_encode_image(first_img_url) if base64_img: img_desc = await analyze_image_with_qwen(base64_img) base_prompt += f"\n\nThe image looks like this: {img_desc}" miku_comment = await query_llama(base_prompt, user_id=f"autonomous-{guild_id}", guild_id=guild_id, response_type="autonomous_tweet") # Post to Discord (convert to fxtwitter for better embeds) fx_tweet_url = tweet['url'].replace("twitter.com", "fxtwitter.com").replace("x.com", "fxtwitter.com") await channel.send(f"{fx_tweet_url}") await channel.send(miku_comment) async def handle_custom_prompt_for_server(guild_id: int, user_prompt: str): """Handle custom prompt for a specific server""" server_config = server_manager.get_server_config(guild_id) if not server_config: print(f"โš ๏ธ No config found for server {guild_id}") return False channel = globals.client.get_channel(server_config.autonomous_channel_id) if not channel: print(f"โš ๏ธ Autonomous channel not found for server {guild_id}") return False mood = server_config.current_mood_name emoji = MOOD_EMOJIS.get(mood, "") time_of_day = get_time_of_day() # Wrap user's idea in Miku context prompt = ( f"Miku is feeling {mood} {emoji} during the {time_of_day}. " f"She has been instructed to: \"{user_prompt.strip()}\"\n\n" f"Write a short, natural message as Miku that follows this instruction. " f"Make it feel spontaneous, emotionally in character, and aligned with her mood and personality. Decide if the time of day is relevant to this request or not and if it is not, do not mention it." ) try: # Use consistent user_id for manual prompts to enable conversation history message = await query_llama(prompt, user_id=f"miku-manual-{guild_id}", guild_id=guild_id, response_type="autonomous_general") await channel.send(message) print(f"๐ŸŽค Miku responded to custom prompt in server {server_config.guild_name}") # Add to server-specific message history if guild_id not in _server_autonomous_messages: _server_autonomous_messages[guild_id] = [] _server_autonomous_messages[guild_id].append(message) if len(_server_autonomous_messages[guild_id]) > MAX_HISTORY: _server_autonomous_messages[guild_id].pop(0) return True except Exception as e: print(f"โŒ Failed to send custom autonomous message: {e}") return False # Legacy functions for backward compatibility - these now delegate to server-specific versions async def miku_autonomous_tick(action_type="general", force=False, force_action=None): """Legacy function - now runs for all servers""" for guild_id in server_manager.servers: await miku_autonomous_tick_for_server(guild_id, action_type, force, force_action) async def miku_say_something_general(): """Legacy function - now runs for all servers""" for guild_id in server_manager.servers: await miku_say_something_general_for_server(guild_id) async def miku_engage_random_user(): """Legacy function - now runs for all servers""" for guild_id in server_manager.servers: await miku_engage_random_user_for_server(guild_id) async def miku_detect_and_join_conversation(): """Legacy function - now runs for all servers""" for guild_id in server_manager.servers: await miku_detect_and_join_conversation_for_server(guild_id) async def share_miku_tweet(): """Legacy function - now runs for all servers""" for guild_id in server_manager.servers: await share_miku_tweet_for_server(guild_id) async def handle_custom_prompt(user_prompt: str): """Legacy function - now runs for all servers""" results = [] for guild_id in server_manager.servers: result = await handle_custom_prompt_for_server(guild_id, user_prompt) results.append(result) return any(results) def load_last_sent_tweets(): global LAST_SENT_TWEETS if os.path.exists(LAST_SENT_TWEETS_FILE): try: with open(LAST_SENT_TWEETS_FILE, "r", encoding="utf-8") as f: LAST_SENT_TWEETS = json.load(f) except Exception as e: print(f"โš ๏ธ Failed to load last sent tweets: {e}") LAST_SENT_TWEETS = [] else: LAST_SENT_TWEETS = [] def save_last_sent_tweets(): try: with open(LAST_SENT_TWEETS_FILE, "w", encoding="utf-8") as f: json.dump(LAST_SENT_TWEETS, f) except Exception as e: print(f"โš ๏ธ Failed to save last sent tweets: {e}") def get_time_of_day(): hour = datetime.now().hour + 3 if 5 <= hour < 12: return "morning" elif 12 <= hour < 18: return "afternoon" elif 18 <= hour < 22: return "evening" return "late night. Miku wonders if anyone is still awake" def is_too_similar(new_message, history, threshold=0.85): for old in history: ratio = SequenceMatcher(None, new_message.lower(), old.lower()).ratio() if ratio > threshold: return True return False # ========== Autonomous Reaction System ========== # Mood-based emoji mappings for autonomous reactions MOOD_REACTION_EMOJIS = { "bubbly": ["โœจ", "๐Ÿซง", "๐Ÿ’™", "๐ŸŒŸ", "๐Ÿ’ซ", "๐ŸŽ€", "๐ŸŒธ"], "sleepy": ["๐Ÿ˜ด", "๐Ÿ’ค", "๐ŸŒ™", "๐Ÿ˜ช", "๐Ÿฅฑ"], "curious": ["๐Ÿ‘€", "๐Ÿค”", "โ“", "๐Ÿ”", "๐Ÿ’ญ"], "shy": ["๐Ÿ‘‰๐Ÿ‘ˆ", "๐Ÿ™ˆ", "๐Ÿ˜Š", "๐Ÿ’•", "โ˜บ๏ธ"], "serious": ["๐Ÿคจ", "๐Ÿ“", "๐Ÿ‘”", "๐Ÿ’ผ", "๐ŸŽฏ"], "excited": ["โœจ", "๐ŸŽ‰", "๐Ÿ˜†", "๐ŸŒŸ", "๐Ÿ’ซ", "๐ŸŽŠ", "๐Ÿ”ฅ"], "silly": ["๐Ÿชฟ", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐ŸŽญ", "๐ŸŽช"], "melancholy": ["๐Ÿท", "๐ŸŒง๏ธ", "๐Ÿ’ญ", "๐Ÿฅ€", "๐ŸŒ™"], "flirty": ["๐Ÿซฆ", "๐Ÿ˜", "๐Ÿ’‹", "๐Ÿ’•", "๐Ÿ˜˜", "๐Ÿ’–"], "romantic": ["๐Ÿ’Œ", "๐Ÿ’–", "๐Ÿ’•", "๐Ÿ’", "โค๏ธ", "๐ŸŒน"], "irritated": ["๐Ÿ˜’", "๐Ÿ’ข", "๐Ÿ˜ค", "๐Ÿ™„", "๐Ÿ˜‘"], "angry": ["๐Ÿ’ข", "๐Ÿ˜ ", "๐Ÿ‘ฟ", "๐Ÿ’ฅ", "๐Ÿ˜ก"], "neutral": ["๐Ÿ’™", "๐Ÿ‘", "๐Ÿ˜Š", "โœจ", "๐ŸŽต"], "asleep": [] # Don't react when asleep } async def _analyze_message_media(message): """ Analyze any media (images, videos, GIFs) in a message. Returns a description string or None if no media. """ if not message.attachments: return None for attachment in message.attachments: try: # Handle images if any(attachment.filename.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]): print(f" ๐Ÿ“ธ Analyzing image for reaction: {attachment.filename}") base64_img = await download_and_encode_image(attachment.url) if base64_img: description = await analyze_image_with_qwen(base64_img) return f"[Image: {description}]" # Handle videos and GIFs elif any(attachment.filename.lower().endswith(ext) for ext in [".gif", ".mp4", ".webm", ".mov"]): is_gif = attachment.filename.lower().endswith('.gif') media_type = "GIF" if is_gif else "video" print(f" ๐ŸŽฌ Analyzing {media_type} for reaction: {attachment.filename}") # Download media media_bytes_b64 = await download_and_encode_media(attachment.url) if not media_bytes_b64: continue import base64 media_bytes = base64.b64decode(media_bytes_b64) # Convert GIF to MP4 if needed if is_gif: mp4_bytes = await convert_gif_to_mp4(media_bytes) if mp4_bytes: media_bytes = mp4_bytes # Extract frames frames = await extract_video_frames(media_bytes, num_frames=6) if frames: description = await analyze_video_with_vision(frames, media_type="gif" if is_gif else "video") return f"[{media_type}: {description}]" except Exception as e: print(f" โš ๏ธ Error analyzing media for reaction: {e}") continue return None async def miku_autonomous_reaction_for_server(guild_id: int, force_message=None, force=False): """Miku autonomously reacts to a recent message with an LLM-selected emoji Args: guild_id: The server ID force_message: If provided, react to this specific message (for real-time reactions) force: If True, bypass the 50% probability check (for manual triggers) """ # 50% chance to proceed (unless forced or with a specific message) if not force and force_message is None and random.random() > 0.5: print(f"๐ŸŽฒ Autonomous reaction skipped for server {guild_id} (50% chance)") return server_config = server_manager.get_server_config(guild_id) if not server_config: print(f"โš ๏ธ No config found for server {guild_id}") return server_name = server_config.guild_name # Don't react if asleep if server_config.current_mood_name == "asleep" or server_config.is_sleeping: print(f"๐Ÿ’ค [{server_name}] Miku is asleep, skipping autonomous reaction") return # Get the autonomous channel channel = globals.client.get_channel(server_config.autonomous_channel_id) if not channel: print(f"โš ๏ธ [{server_name}] Autonomous channel not found") return try: # If a specific message was provided, use it if force_message: target_message = force_message # Check if we've already reacted to this message if target_message.id in _reacted_message_ids: print(f"โญ๏ธ [{server_name}] Already reacted to message {target_message.id}, skipping") return print(f"๐ŸŽฏ [{server_name}] Reacting to new message from {target_message.author.display_name}") else: # Fetch recent messages (last 50 messages to get more candidates) messages = [] async for message in channel.history(limit=50): # Skip bot's own messages if message.author == globals.client.user: continue # Skip messages we've already reacted to if message.id in _reacted_message_ids: continue # Skip messages that are too old (more than 12 hours) age = (datetime.now() - message.created_at.replace(tzinfo=None)).total_seconds() if age > 43200: # 12 hours continue messages.append(message) if not messages: print(f"๐Ÿ“ญ [{server_name}] No recent unreacted messages to react to") return # Pick a random message from the recent ones target_message = random.choice(messages) # Analyze any media in the message print(f"๐Ÿ” [{server_name}] Analyzing message for reaction from {target_message.author.display_name}") media_description = await _analyze_message_media(target_message) # Build message content with media description if present message_content = target_message.content[:200] # Limit text context length if media_description: # If there's media, prepend the description message_content = f"{media_description} {message_content}".strip() # Limit total length message_content = message_content[:400] # Ask LLM to select an appropriate emoji prompt = ( f"You are Miku, a playful virtual idol on Discord. Someone just posted: \"{message_content}\"\n\n" f"React with ONE emoji that captures your response! Be creative and expressive - don't just use ๐Ÿ˜Š or ๐Ÿ‘. " f"Think about:\n" f"- What emotion does this make you feel? (use expressive emojis like ๐Ÿคจ, ๐Ÿ˜ญ, ๐Ÿคฏ, ๐Ÿ’€, etc.)\n" f"- Is it funny? (try ๐Ÿ’€, ๐Ÿ˜‚, ๐Ÿคก, ๐Ÿชฟ, etc.)\n" f"- Is it interesting? (try ๐Ÿ‘€, ๐Ÿค”, ๐Ÿง, ๐Ÿ˜ณ, etc.)\n" f"- Is it relatable? (try ๐Ÿ˜”, ๐Ÿฅบ, ๐Ÿ˜ฉ, ๐Ÿ™ƒ, etc.)\n" f"- Does it mention something specific? (match it with a relevant emoji like ๐ŸŽฎ, ๐Ÿ•, ๐ŸŽธ, etc.)\n\n" f"Be bold! Use uncommon emojis! Respond with ONLY the emoji character itself, no text." ) emoji = await query_llama( prompt, user_id=f"miku-reaction-{guild_id}", # Use consistent user_id guild_id=guild_id, response_type="emoji_selection" ) # Clean up the response (remove any extra text) original_response = emoji emoji = emoji.strip() # Remove common prefixes/quotes that LLM might add emoji = emoji.replace('"', '').replace("'", '').replace('`', '') emoji = emoji.replace(':', '') # Remove colons from :emoji: format # Try to extract just emoji characters using regex import re emoji_pattern = re.compile("[" u"\U0001F300-\U0001F9FF" # Most emojis u"\U0001F600-\U0001F64F" # emoticons u"\U0001F680-\U0001F6FF" # transport & map symbols u"\U0001F1E0-\U0001F1FF" # flags u"\U00002600-\U000027BF" # misc symbols u"\U0001F900-\U0001F9FF" # supplemental symbols u"\U00002700-\U000027BF" # dingbats u"\U0001FA70-\U0001FAFF" # extended pictographs u"\U00002300-\U000023FF" # misc technical "]", flags=re.UNICODE) # Find all individual emojis emojis = emoji_pattern.findall(original_response) if emojis: # Take only the FIRST emoji emoji = emojis[0] else: # No emoji found in response, use fallback print(f"โš ๏ธ [{server_name}] LLM response contained no emoji: '{original_response[:50]}' - using fallback") emoji = "๐Ÿ’™" # Final validation: try adding the reaction try: await target_message.add_reaction(emoji) except discord.HTTPException as e: if "Unknown Emoji" in str(e): print(f"โŒ [{server_name}] Invalid emoji from LLM: '{original_response[:50]}' - using fallback") emoji = "๐Ÿ’™" await target_message.add_reaction(emoji) else: raise # Track this message ID to prevent duplicate reactions _reacted_message_ids.add(target_message.id) # Cleanup old message IDs (keep last 100 to prevent memory growth) if len(_reacted_message_ids) > 100: # Remove oldest half ids_to_remove = list(_reacted_message_ids)[:50] for msg_id in ids_to_remove: _reacted_message_ids.discard(msg_id) print(f"โœ… [{server_name}] Autonomous reaction: Added {emoji} to message from {target_message.author.display_name}") except discord.Forbidden: print(f"โŒ [{server_name}] Missing permissions to add reactions") except discord.HTTPException as e: print(f"โŒ [{server_name}] Failed to add reaction: {e}") except Exception as e: print(f"โš ๏ธ [{server_name}] Error in autonomous reaction: {e}") async def miku_autonomous_reaction(force=False): """Legacy function - run autonomous reactions for all servers Args: force: If True, bypass the 50% probability check (for manual triggers) """ for guild_id in server_manager.servers: await miku_autonomous_reaction_for_server(guild_id, force=force) async def miku_autonomous_reaction_for_dm(user_id: int, force_message=None): """Miku autonomously reacts to a DM message with an LLM-selected emoji Args: user_id: The Discord user ID force_message: If provided, react to this specific message (for real-time reactions) """ # 50% chance to proceed (unless forced with a specific message) if force_message is None and random.random() > 0.5: print(f"๐ŸŽฒ DM reaction skipped for user {user_id} (50% chance)") return # Get the user object try: user = await globals.client.fetch_user(user_id) if not user: print(f"โš ๏ธ Could not find user {user_id}") return dm_channel = user.dm_channel if not dm_channel: dm_channel = await user.create_dm() username = user.display_name except Exception as e: print(f"โš ๏ธ Error fetching DM channel for user {user_id}: {e}") return try: # If a specific message was provided, use it if force_message: target_message = force_message # Check if we've already reacted to this message if target_message.id in _reacted_message_ids: print(f"โญ๏ธ [DM: {username}] Already reacted to message {target_message.id}, skipping") return print(f"๐ŸŽฏ [DM: {username}] Reacting to new message") else: # Fetch recent messages from DM (last 50 messages) messages = [] async for message in dm_channel.history(limit=50): # Skip bot's own messages if message.author == globals.client.user: continue # Skip messages we've already reacted to if message.id in _reacted_message_ids: continue # Skip messages that are too old (more than 12 hours) age = (datetime.now() - message.created_at.replace(tzinfo=None)).total_seconds() if age > 43200: # 12 hours continue messages.append(message) if not messages: print(f"๐Ÿ“ญ [DM: {username}] No recent unreacted messages to react to") return # Pick a random message from the recent ones target_message = random.choice(messages) # Analyze any media in the message print(f"๐Ÿ” [DM: {username}] Analyzing message for reaction") media_description = await _analyze_message_media(target_message) # Build message content with media description if present message_content = target_message.content[:200] # Limit text context length if media_description: # If there's media, prepend the description message_content = f"{media_description} {message_content}".strip() # Limit total length message_content = message_content[:400] # Ask LLM to select an appropriate emoji prompt = ( f"You are Miku, a playful virtual idol. Someone just sent you this DM: \"{message_content}\"\n\n" f"React with ONE emoji that captures your response! Be creative and expressive - don't just use ๐Ÿ˜Š or ๐Ÿ‘. " f"Think about:\n" f"- What emotion does this make you feel? (use expressive emojis like ๐Ÿคจ, ๐Ÿ˜ญ, ๐Ÿคฏ, ๐Ÿ’€, etc.)\n" f"- Is it funny? (try ๐Ÿ’€, ๐Ÿ˜‚, ๐Ÿคก, ๐Ÿชฟ, etc.)\n" f"- Is it interesting? (try ๐Ÿ‘€, ๐Ÿค”, ๐Ÿง, ๐Ÿ˜ณ, etc.)\n" f"- Is it relatable? (try ๐Ÿ˜”, ๐Ÿฅบ, ๐Ÿ˜ฉ, ๐Ÿ™ƒ, etc.)\n" f"- Does it mention something specific? (match it with a relevant emoji like ๐ŸŽฎ, ๐Ÿ•, ๐ŸŽธ, etc.)\n\n" f"Be bold! Use uncommon emojis! Respond with ONLY the emoji character itself, no text." ) emoji = await query_llama( prompt, user_id=f"miku-dm-reaction-{user_id}", # Use consistent user_id per DM user guild_id=None, # DM doesn't have guild response_type="emoji_selection" ) # Clean up the response (remove any extra text) original_response = emoji emoji = emoji.strip() # Remove common prefixes/quotes that LLM might add emoji = emoji.replace('"', '').replace("'", '').replace('`', '') emoji = emoji.replace(':', '') # Remove colons from :emoji: format # Try to extract just emoji characters using regex import re emoji_pattern = re.compile("[" u"\U0001F300-\U0001F9FF" # Most emojis u"\U0001F600-\U0001F64F" # emoticons u"\U0001F680-\U0001F6FF" # transport & map symbols u"\U0001F1E0-\U0001F1FF" # flags u"\U00002600-\U000027BF" # misc symbols u"\U0001F900-\U0001F9FF" # supplemental symbols u"\U00002700-\U000027BF" # dingbats u"\U0001FA70-\U0001FAFF" # extended pictographs u"\U00002300-\U000023FF" # misc technical "]", flags=re.UNICODE) # Find all individual emojis emojis = emoji_pattern.findall(original_response) if emojis: # Take only the FIRST emoji emoji = emojis[0] else: # No emoji found in response, use fallback print(f"โš ๏ธ [DM: {username}] LLM response contained no emoji: '{original_response[:50]}' - using fallback") emoji = "๐Ÿ’™" # Final validation: try adding the reaction try: await target_message.add_reaction(emoji) except discord.HTTPException as e: if "Unknown Emoji" in str(e): print(f"โŒ [DM: {username}] Invalid emoji from LLM: '{original_response[:50]}' - using fallback") emoji = "๐Ÿ’™" await target_message.add_reaction(emoji) else: raise # Track this message ID to prevent duplicate reactions _reacted_message_ids.add(target_message.id) # Cleanup old message IDs (keep last 100 to prevent memory growth) if len(_reacted_message_ids) > 100: # Remove oldest half ids_to_remove = list(_reacted_message_ids)[:50] for msg_id in ids_to_remove: _reacted_message_ids.discard(msg_id) print(f"โœ… [DM: {username}] Autonomous reaction: Added {emoji} to message") except discord.Forbidden: print(f"โŒ [DM: {username}] Missing permissions to add reactions") except discord.HTTPException as e: print(f"โŒ [DM: {username}] Failed to add reaction: {e}") except Exception as e: print(f"โš ๏ธ [DM: {username}] Error in autonomous reaction: {e}") async def miku_update_profile_picture_for_server(guild_id: int): """ Miku autonomously updates her profile picture by searching for artwork. This is a global action (affects all servers) but is triggered by server context. """ from utils.profile_picture_manager import update_profile_picture, should_update_profile_picture # Check if enough time has passed if not should_update_profile_picture(): print(f"๐Ÿ“ธ [Server: {guild_id}] Profile picture not ready for update yet") return # Get server config to use current mood server_config = server_manager.get_server_config(guild_id) if not server_config: print(f"โš ๏ธ No config found for server {guild_id}") return mood = server_config.current_mood_name print(f"๐Ÿ“ธ [Server: {guild_id}] Attempting profile picture update (mood: {mood})") try: success = await update_profile_picture(globals.client, mood=mood) if success: # Announce the change in the autonomous channel channel = globals.client.get_channel(server_config.autonomous_channel_id) if channel: messages = [ "*updates profile picture* โœจ What do you think? Does it suit me?", "I found a new look! *twirls* Do you like it? ๐Ÿ’š", "*changes profile picture* Felt like switching things up today~ โœจ", "New profile pic! I thought this one was really cute ๐Ÿ’š", "*updates avatar* Time for a fresh look! โœจ" ] await channel.send(random.choice(messages)) print(f"โœ… [Server: {guild_id}] Profile picture updated and announced!") else: print(f"โš ๏ธ [Server: {guild_id}] Profile picture update failed") except Exception as e: print(f"โš ๏ธ [Server: {guild_id}] Error updating profile picture: {e}")