867 lines
36 KiB
Python
867 lines
36 KiB
Python
# 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_ollama
|
|
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_ollama(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_ollama(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_ollama(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_ollama(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_ollama(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_ollama(
|
|
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_ollama(
|
|
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}")
|