2025-12-07 17:15:09 +02:00
# 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
2025-12-07 17:50:08 +02:00
from utils . llm import query_llama
2025-12-07 17:15:09 +02:00
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
2025-12-07 17:50:08 +02:00
message = await query_llama ( prompt , user_id = f " miku-autonomous- { guild_id } " , guild_id = guild_id , response_type = " autonomous_general " )
2025-12-07 17:15:09 +02:00
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 " \n The message should be short and reflect Miku ' s current mood. "
)
try :
# Use consistent user_id for engaging users to enable conversation history
2025-12-07 17:50:08 +02:00
message = await query_llama ( prompt , user_id = f " miku-engage- { guild_id } " , guild_id = guild_id )
2025-12-07 17:15:09 +02:00
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
2025-12-07 17:50:08 +02:00
reply = await query_llama ( prompt , user_id = f " miku-conversation- { guild_id } " , guild_id = guild_id , response_type = " conversation_join " )
2025-12-07 17:15:09 +02:00
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 \n Comment 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 \n The image looks like this: { img_desc } "
2025-12-07 17:50:08 +02:00
miku_comment = await query_llama ( base_prompt , user_id = f " autonomous- { guild_id } " , guild_id = guild_id , response_type = " autonomous_tweet " )
2025-12-07 17:15:09 +02:00
# 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
2025-12-07 17:50:08 +02:00
message = await query_llama ( prompt , user_id = f " miku-manual- { guild_id } " , guild_id = guild_id , response_type = " autonomous_general " )
2025-12-07 17:15:09 +02:00
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. "
)
2025-12-07 17:50:08 +02:00
emoji = await query_llama (
2025-12-07 17:15:09 +02:00
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. "
)
2025-12-07 17:50:08 +02:00
emoji = await query_llama (
2025-12-07 17:15:09 +02:00
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 } " )