Initial commit: Miku Discord Bot

This commit is contained in:
2025-12-07 17:15:09 +02:00
commit 8c74ad5260
206 changed files with 50125 additions and 0 deletions

344
bot/utils/autonomous.py Normal file
View File

@@ -0,0 +1,344 @@
# autonomous.py (V2)
"""
Enhanced autonomous system that uses the autonomous_engine for true autonomy.
Integrates with legacy autonomous functions from autonomous_v1_legacy.py
"""
import asyncio
import time
from utils.autonomous_engine import autonomous_engine
from server_manager import server_manager
import globals
# Rate limiting: Track last action time per server to prevent rapid-fire
_last_action_execution = {} # guild_id -> timestamp
_MIN_ACTION_INTERVAL = 30 # Minimum 30 seconds between autonomous actions
async def autonomous_tick_v2(guild_id: int):
"""
New autonomous tick that uses context-aware decision making.
Replaces the random 10% chance with intelligent decision.
"""
# Rate limiting check
now = time.time()
if guild_id in _last_action_execution:
time_since_last = now - _last_action_execution[guild_id]
if time_since_last < _MIN_ACTION_INTERVAL:
print(f"⏱️ [V2] Rate limit: Only {time_since_last:.0f}s since last action (need {_MIN_ACTION_INTERVAL}s)")
return
# Ask the engine if Miku should act (with optional debug logging)
action_type = autonomous_engine.should_take_action(guild_id, debug=globals.AUTONOMOUS_DEBUG)
if action_type is None:
# Engine decided not to act
return
print(f"🤖 [V2] Autonomous engine decided to: {action_type} for server {guild_id}")
# Execute the action using legacy functions
from utils.autonomous_v1_legacy import (
miku_say_something_general_for_server,
miku_engage_random_user_for_server,
share_miku_tweet_for_server,
miku_detect_and_join_conversation_for_server
)
from utils.profile_picture_manager import profile_picture_manager
try:
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)
elif action_type == "share_tweet":
await share_miku_tweet_for_server(guild_id)
elif action_type == "join_conversation":
await miku_detect_and_join_conversation_for_server(guild_id)
elif action_type == "change_profile_picture":
# Get current mood for this server
mood, _ = server_manager.get_server_mood(guild_id)
print(f"🎨 [V2] Changing profile picture (mood: {mood})")
result = await profile_picture_manager.change_profile_picture(mood=mood, debug=True)
if result["success"]:
print(f"✅ Profile picture changed successfully!")
else:
print(f"⚠️ Profile picture change failed: {result.get('error')}")
# Record that action was taken
autonomous_engine.record_action(guild_id)
# Update rate limiter
_last_action_execution[guild_id] = time.time()
except Exception as e:
print(f"⚠️ Error executing autonomous action: {e}")
async def autonomous_reaction_tick_v2(guild_id: int):
"""
Scheduled check for reacting to older messages.
This runs less frequently (e.g., every 20 minutes) and picks from recent messages.
"""
# Ask the engine if Miku should react (scheduled check)
should_react = autonomous_engine.should_react_to_message(guild_id, message_age_seconds=600) # Check 10 min old msgs
if not should_react:
return
print(f"🤖 [V2] Scheduled reaction check triggered for server {guild_id}")
try:
from utils.autonomous_v1_legacy import miku_autonomous_reaction_for_server
# Don't pass force_message - let it pick a random recent message
await miku_autonomous_reaction_for_server(guild_id, force_message=None)
# Record action
autonomous_engine.record_action(guild_id)
except Exception as e:
print(f"⚠️ Error executing scheduled reaction: {e}")
def on_message_event(message):
"""
Hook for bot.py to call on every message.
Updates context without LLM calls.
ONLY processes messages from the configured autonomous channel.
"""
if not message.guild:
return # DMs don't use this system
guild_id = message.guild.id
# Get server config to check if this is the autonomous channel
server_config = server_manager.get_server_config(guild_id)
if not server_config:
return # No config for this server
# CRITICAL: Only process messages from the autonomous channel
if message.channel.id != server_config.autonomous_channel_id:
return # Ignore messages from other channels
# Track the message
autonomous_engine.track_message(guild_id, author_is_bot=message.author.bot)
# Check if we should act (async, non-blocking)
if not message.author.bot: # Only check for human messages
asyncio.create_task(_check_and_act(guild_id))
# Also check if we should react to this specific message
asyncio.create_task(_check_and_react(guild_id, message))
async def _check_and_react(guild_id: int, message):
"""
Check if Miku should react to a new message with an emoji.
Called for each new message in real-time.
"""
# Calculate message age
from datetime import datetime, timezone
message_age = (datetime.now(timezone.utc) - message.created_at).total_seconds()
# Ask engine if we should react
should_react = autonomous_engine.should_react_to_message(guild_id, message_age)
if should_react:
print(f"🎯 [V2] Real-time reaction triggered for message from {message.author.display_name}")
from utils.autonomous_v1_legacy import miku_autonomous_reaction_for_server
await miku_autonomous_reaction_for_server(guild_id, force_message=message)
# Record action (reactions count as actions for cooldown purposes)
autonomous_engine.record_action(guild_id)
async def _check_and_act(guild_id: int):
"""
Internal function to check if action should be taken.
Called after each message, but engine makes smart decision.
IMPORTANT: Pass triggered_by_message=True so the engine knows to respond
to the message instead of saying something random/general.
"""
# Rate limiting check
now = time.time()
if guild_id in _last_action_execution:
time_since_last = now - _last_action_execution[guild_id]
if time_since_last < _MIN_ACTION_INTERVAL:
return
action_type = autonomous_engine.should_take_action(guild_id, triggered_by_message=True)
if action_type:
print(f"🎯 [V2] Message triggered autonomous action: {action_type}")
# Execute the action directly (don't call autonomous_tick_v2 which would check again)
from utils.autonomous_v1_legacy import (
miku_say_something_general_for_server,
miku_engage_random_user_for_server,
share_miku_tweet_for_server,
miku_detect_and_join_conversation_for_server
)
from utils.profile_picture_manager import profile_picture_manager
try:
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)
elif action_type == "share_tweet":
await share_miku_tweet_for_server(guild_id)
elif action_type == "join_conversation":
await miku_detect_and_join_conversation_for_server(guild_id)
elif action_type == "change_profile_picture":
# Get current mood for this server
mood, _ = server_manager.get_server_mood(guild_id)
print(f"🎨 [V2] Changing profile picture (mood: {mood})")
result = await profile_picture_manager.change_profile_picture(mood=mood, debug=True)
if result["success"]:
print(f"✅ Profile picture changed successfully!")
else:
print(f"⚠️ Profile picture change failed: {result.get('error')}")
# Record that action was taken
autonomous_engine.record_action(guild_id)
# Update rate limiter
_last_action_execution[guild_id] = time.time()
except Exception as e:
print(f"⚠️ Error executing message-triggered action: {e}")
def on_presence_update(member, before, after):
"""
Hook for presence updates (status changes, activities).
Args:
member: The Member object (from 'after' in discord.py event)
before: Member object with old state
after: Member object with new state
"""
# Ignore bot users (including music bots that spam activity updates)
if member.bot:
return
guild_id = member.guild.id
# Track status changes
if before.status != after.status:
autonomous_engine.track_user_event(guild_id, "status_changed")
print(f"👤 [V2] {member.display_name} status changed: {before.status}{after.status}")
# Track activity changes
if before.activities != after.activities:
# Check for new activities
before_activity_names = {a.name for a in before.activities if hasattr(a, 'name')}
after_activity_names = {a.name for a in after.activities if hasattr(a, 'name')}
new_activities = after_activity_names - before_activity_names
for activity_name in new_activities:
autonomous_engine.track_user_event(
guild_id,
"activity_started",
{"activity_name": activity_name}
)
print(f"🎮 [V2] {member.display_name} started activity: {activity_name}")
def on_member_join(member):
"""Hook for member join events"""
# Ignore bot users
if member.bot:
return
guild_id = member.guild.id
autonomous_engine.track_user_event(guild_id, "user_joined")
def on_mood_change(guild_id: int, new_mood: str):
"""Hook for mood changes"""
autonomous_engine.update_mood(guild_id, new_mood)
async def periodic_decay_task():
"""
Background task that decays event counters and saves context.
Run this every 15 minutes.
"""
task_start_time = time.time()
iteration_count = 0
while True:
await asyncio.sleep(900) # 15 minutes
iteration_count += 1
# Use list() to safely iterate even if dict changes
guild_ids = list(server_manager.servers.keys())
for guild_id in guild_ids:
try:
autonomous_engine.decay_events(guild_id)
except Exception as e:
print(f"⚠️ Error decaying events for guild {guild_id}: {e}")
# Save context to disk periodically
try:
autonomous_engine.save_context()
except Exception as e:
print(f"⚠️ Error saving autonomous context: {e}")
uptime_hours = (time.time() - task_start_time) / 3600
print(f"🧹 [V2] Decay task completed (iteration #{iteration_count}, uptime: {uptime_hours:.1f}h)")
print(f" └─ Processed {len(guild_ids)} servers")
def initialize_v2_system(client):
"""
Initialize the V2 autonomous system.
Call this from bot.py on startup.
"""
print("🚀 Initializing Autonomous V2 System...")
# Initialize mood states for all servers
for guild_id, server_config in server_manager.servers.items():
autonomous_engine.update_mood(guild_id, server_config.current_mood_name)
# Start decay task
client.loop.create_task(periodic_decay_task())
print("✅ Autonomous V2 System initialized")
# ========== Legacy Function Wrappers ==========
# These maintain compatibility with old code that imports from autonomous.py
from utils.autonomous_v1_legacy import (
load_last_sent_tweets,
save_last_sent_tweets,
setup_autonomous_speaking,
# Server-specific functions
miku_autonomous_tick_for_server,
miku_say_something_general_for_server,
miku_engage_random_user_for_server,
miku_detect_and_join_conversation_for_server,
share_miku_tweet_for_server,
miku_autonomous_reaction_for_server,
miku_autonomous_reaction_for_dm,
handle_custom_prompt_for_server,
# Legacy global functions (for API compatibility)
miku_autonomous_tick,
miku_say_something_general,
miku_engage_random_user,
miku_detect_and_join_conversation,
share_miku_tweet,
handle_custom_prompt,
miku_autonomous_reaction,
)
# Alias the V2 tick as the main autonomous tick
autonomous_tick = autonomous_tick_v2
autonomous_reaction_tick = autonomous_reaction_tick_v2

View File

@@ -0,0 +1,556 @@
# autonomous_engine.py
"""
Truly autonomous decision-making engine for Miku.
Makes decisions based on context signals without constant LLM polling.
"""
import time
import random
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from collections import deque
import discord
from .autonomous_persistence import save_autonomous_context, load_autonomous_context, apply_context_to_signals
@dataclass
class ContextSignals:
"""Lightweight context tracking without storing message content"""
# Activity metrics
messages_last_5min: int = 0
messages_last_hour: int = 0
unique_users_active: int = 0
conversation_momentum: float = 0.0 # 0-1 score based on message frequency
# User presence
users_joined_recently: int = 0
users_status_changed: int = 0
users_started_activity: List[tuple] = field(default_factory=list) # (activity_name, timestamp) tuples
# Miku's state
time_since_last_action: float = 0.0 # seconds
time_since_last_interaction: float = 0.0 # seconds since someone talked to her
messages_since_last_appearance: int = 0
# Time context
hour_of_day: int = 0
is_weekend: bool = False
# Emotional influence
current_mood: str = "neutral"
mood_energy_level: float = 0.5 # 0-1, affects likelihood of action
@dataclass
class ActionThresholds:
"""Dynamic thresholds that change based on mood and context"""
# How long to wait before considering action (seconds)
min_silence_for_general: float = 1800 # 30 min
min_silence_for_engagement: float = 3600 # 1 hour
# Activity level needed to join conversation (0-1)
conversation_join_threshold: float = 0.6
# How many messages before feeling "left out"
messages_before_fomo: int = 25
# Mood-based multipliers
mood_action_multiplier: float = 1.0 # Higher = more likely to act
class AutonomousEngine:
"""
Decision engine that determines WHEN Miku should act,
then delegates to existing autonomous functions for WHAT to do.
"""
def __init__(self):
self.server_contexts: Dict[int, ContextSignals] = {}
self.server_message_times: Dict[int, deque] = {} # Track message timestamps
self.server_last_action: Dict[int, float] = {}
self.bot_startup_time: float = time.time() # Track when bot started
# Mood personality profiles
self.mood_profiles = {
"bubbly": {"energy": 0.9, "sociability": 0.95, "impulsiveness": 0.8},
"sleepy": {"energy": 0.2, "sociability": 0.3, "impulsiveness": 0.1},
"curious": {"energy": 0.7, "sociability": 0.6, "impulsiveness": 0.7},
"shy": {"energy": 0.4, "sociability": 0.2, "impulsiveness": 0.2},
"serious": {"energy": 0.6, "sociability": 0.5, "impulsiveness": 0.3},
"excited": {"energy": 0.95, "sociability": 0.9, "impulsiveness": 0.9},
"silly": {"energy": 0.8, "sociability": 0.85, "impulsiveness": 0.95},
"melancholy": {"energy": 0.3, "sociability": 0.4, "impulsiveness": 0.2},
"flirty": {"energy": 0.75, "sociability": 0.85, "impulsiveness": 0.7},
"romantic": {"energy": 0.6, "sociability": 0.7, "impulsiveness": 0.5},
"irritated": {"energy": 0.5, "sociability": 0.3, "impulsiveness": 0.6},
"angry": {"energy": 0.7, "sociability": 0.2, "impulsiveness": 0.8},
"neutral": {"energy": 0.5, "sociability": 0.5, "impulsiveness": 0.5},
"asleep": {"energy": 0.0, "sociability": 0.0, "impulsiveness": 0.0},
}
# Load persisted context on initialization
self._load_persisted_context()
def _load_persisted_context(self):
"""Load saved context data on bot startup"""
context_data, last_action = load_autonomous_context()
# Restore last action timestamps
self.server_last_action = last_action
# Restore context signals
for guild_id, data in context_data.items():
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
apply_context_to_signals(data, self.server_contexts[guild_id])
def save_context(self):
"""Save current context to disk"""
save_autonomous_context(self.server_contexts, self.server_last_action)
def track_message(self, guild_id: int, author_is_bot: bool = False):
"""Track a message without storing content"""
if guild_id not in self.server_contexts:
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
if author_is_bot:
return # Don't count bot messages
now = time.time()
self.server_message_times[guild_id].append(now)
ctx = self.server_contexts[guild_id]
ctx.messages_since_last_appearance += 1
# Cap at 100 to prevent massive buildup during sleep/inactivity
# This prevents inappropriate FOMO triggers after long periods
if ctx.messages_since_last_appearance > 100:
ctx.messages_since_last_appearance = 100
# Update time-based metrics
self._update_activity_metrics(guild_id)
def track_user_event(self, guild_id: int, event_type: str, data: dict = None):
"""Track user presence events (joins, status changes, etc.)"""
if guild_id not in self.server_contexts:
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
ctx = self.server_contexts[guild_id]
if event_type == "user_joined":
ctx.users_joined_recently += 1
elif event_type == "status_changed":
ctx.users_status_changed += 1
elif event_type == "activity_started" and data:
activity_name = data.get("activity_name")
if activity_name:
now = time.time()
# Remove duplicate activities (same name)
ctx.users_started_activity = [
(name, ts) for name, ts in ctx.users_started_activity
if name != activity_name
]
# Add new activity with timestamp
ctx.users_started_activity.append((activity_name, now))
# Keep only last 5 activities
if len(ctx.users_started_activity) > 5:
ctx.users_started_activity.pop(0)
def _clean_old_activities(self, guild_id: int, max_age_seconds: float = 3600):
"""Remove activities older than max_age (default 1 hour)"""
if guild_id not in self.server_contexts:
return
ctx = self.server_contexts[guild_id]
now = time.time()
# Filter out old activities
ctx.users_started_activity = [
(name, ts) for name, ts in ctx.users_started_activity
if now - ts < max_age_seconds
]
def update_mood(self, guild_id: int, mood: str):
"""Update mood and recalculate energy level"""
if guild_id not in self.server_contexts:
self.server_contexts[guild_id] = ContextSignals()
self.server_message_times[guild_id] = deque(maxlen=100)
ctx = self.server_contexts[guild_id]
ctx.current_mood = mood
# Get mood personality profile
profile = self.mood_profiles.get(mood, self.mood_profiles["neutral"])
ctx.mood_energy_level = profile["energy"]
def _update_activity_metrics(self, guild_id: int):
"""Update activity metrics based on message timestamps"""
ctx = self.server_contexts[guild_id]
times = self.server_message_times[guild_id]
now = time.time()
# Count messages in time windows
ctx.messages_last_5min = sum(1 for t in times if now - t < 300)
ctx.messages_last_hour = sum(1 for t in times if now - t < 3600)
# Calculate conversation momentum (0-1 scale)
# High momentum = consistent messages in last 5 minutes
if ctx.messages_last_5min >= 10:
ctx.conversation_momentum = min(1.0, ctx.messages_last_5min / 20)
else:
ctx.conversation_momentum = ctx.messages_last_5min / 10
# Time since last action
if guild_id in self.server_last_action:
ctx.time_since_last_action = now - self.server_last_action[guild_id]
else:
ctx.time_since_last_action = float('inf')
# Time context
ctx.hour_of_day = datetime.now().hour
ctx.is_weekend = datetime.now().weekday() >= 5
def should_take_action(self, guild_id: int, debug: bool = False, triggered_by_message: bool = False) -> Optional[str]:
"""
Determine if Miku should take action and what type.
Returns action type or None.
This is the CORE decision logic - no LLM needed!
Args:
guild_id: Server ID
debug: If True, print detailed decision reasoning
triggered_by_message: If True, this check was triggered immediately after someone sent a message
"""
if guild_id not in self.server_contexts:
return None
ctx = self.server_contexts[guild_id]
# STARTUP COOLDOWN: Don't act for first 2 minutes after bot startup
# This prevents rapid-fire messages when bot restarts
time_since_startup = time.time() - self.bot_startup_time
if time_since_startup < 120: # 2 minutes
if debug:
print(f"⏳ [V2 Debug] Startup cooldown active ({time_since_startup:.0f}s / 120s)")
return None
# Never act when asleep
if ctx.current_mood == "asleep":
if debug:
print(f"💤 [V2 Debug] Mood is 'asleep' - no action taken")
return None
# Get mood personality
profile = self.mood_profiles.get(ctx.current_mood, self.mood_profiles["neutral"])
# Update metrics
self._update_activity_metrics(guild_id)
if debug:
print(f"\n🔍 [V2 Debug] Decision Check for Guild {guild_id}")
print(f" Triggered by message: {triggered_by_message}")
print(f" Mood: {ctx.current_mood} (energy={profile['energy']:.2f}, sociability={profile['sociability']:.2f}, impulsiveness={profile['impulsiveness']:.2f})")
print(f" Momentum: {ctx.conversation_momentum:.2f}")
print(f" Messages (5min/1hr): {ctx.messages_last_5min}/{ctx.messages_last_hour}")
print(f" Messages since appearance: {ctx.messages_since_last_appearance}")
print(f" Time since last action: {ctx.time_since_last_action:.0f}s")
print(f" Active activities: {len(ctx.users_started_activity)}")
# --- Decision Logic ---
# 1. CONVERSATION JOIN (high priority when momentum is high)
if self._should_join_conversation(ctx, profile, debug):
if debug:
print(f"✅ [V2 Debug] DECISION: join_conversation")
return "join_conversation"
# 2. USER ENGAGEMENT (someone interesting appeared)
if self._should_engage_user(ctx, profile, debug):
if debug:
print(f"✅ [V2 Debug] DECISION: engage_user")
return "engage_user"
# 3. FOMO RESPONSE (lots of activity without her)
# When FOMO triggers, join the conversation instead of saying something random
if self._should_respond_to_fomo(ctx, profile, debug):
if debug:
print(f"✅ [V2 Debug] DECISION: join_conversation (FOMO)")
return "join_conversation" # Jump in and respond to what's being said
# 4. BORED/LONELY (quiet for too long, depending on mood)
# CRITICAL FIX: If this check was triggered by a message, convert "general" to "join_conversation"
# This ensures Miku responds to the message instead of saying something random
if self._should_break_silence(ctx, profile, debug):
if triggered_by_message:
if debug:
print(f"✅ [V2 Debug] DECISION: join_conversation (break silence, but message just sent)")
return "join_conversation" # Respond to the message instead of random general statement
else:
if debug:
print(f"✅ [V2 Debug] DECISION: general (break silence)")
return "general"
# 5. SHARE TWEET (low activity, wants to share something)
if self._should_share_content(ctx, profile, debug):
if debug:
print(f"✅ [V2 Debug] DECISION: share_tweet")
return "share_tweet"
# 6. CHANGE PROFILE PICTURE (very rare, once per day)
if self._should_change_profile_picture(ctx, profile, debug):
if debug:
print(f"✅ [V2 Debug] DECISION: change_profile_picture")
return "change_profile_picture"
if debug:
print(f"❌ [V2 Debug] DECISION: None (no conditions met)")
return None
def _should_join_conversation(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should join an active conversation"""
# High conversation momentum + sociable mood + hasn't spoken recently
base_threshold = 0.6
mood_adjusted = base_threshold * (2.0 - profile["sociability"]) # Lower threshold if sociable
conditions = {
"momentum_check": ctx.conversation_momentum > mood_adjusted,
"messages_check": ctx.messages_since_last_appearance >= 5,
"cooldown_check": ctx.time_since_last_action > 300,
"impulsiveness_roll": random.random() < profile["impulsiveness"]
}
result = all(conditions.values())
if debug:
print(f" [Join Conv] momentum={ctx.conversation_momentum:.2f} > {mood_adjusted:.2f}? {conditions['momentum_check']}")
print(f" [Join Conv] messages={ctx.messages_since_last_appearance} >= 5? {conditions['messages_check']}")
print(f" [Join Conv] cooldown={ctx.time_since_last_action:.0f}s > 300s? {conditions['cooldown_check']}")
print(f" [Join Conv] impulsive roll? {conditions['impulsiveness_roll']} | Result: {result}")
return result
def _should_engage_user(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should engage with a user (status change/activity)"""
# Someone started a new activity or status changed + enough time passed
has_activities = len(ctx.users_started_activity) > 0
cooldown_ok = ctx.time_since_last_action > 1800
roll = random.random()
threshold = profile["sociability"] * profile["impulsiveness"]
roll_ok = roll < threshold
result = has_activities and cooldown_ok and roll_ok
if debug and has_activities:
activities = [name for name, ts in ctx.users_started_activity]
print(f" [Engage] activities={activities}, cooldown={ctx.time_since_last_action:.0f}s > 1800s? {cooldown_ok}")
print(f" [Engage] roll={roll:.2f} < {threshold:.2f}? {roll_ok} | Result: {result}")
return result
def _should_respond_to_fomo(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku feels left out (FOMO)"""
# Lots of messages but she hasn't participated
fomo_threshold = 25 * (2.0 - profile["sociability"]) # Social moods have lower threshold
msgs_check = ctx.messages_since_last_appearance > fomo_threshold
momentum_check = ctx.conversation_momentum > 0.3
cooldown_check = ctx.time_since_last_action > 900
result = msgs_check and momentum_check and cooldown_check
if debug:
print(f" [FOMO] messages={ctx.messages_since_last_appearance} > {fomo_threshold:.0f}? {msgs_check}")
print(f" [FOMO] momentum={ctx.conversation_momentum:.2f} > 0.3? {momentum_check}")
print(f" [FOMO] cooldown={ctx.time_since_last_action:.0f}s > 900s? {cooldown_check} | Result: {result}")
return result
def _should_break_silence(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should break a long silence"""
# Low activity + long time + mood-dependent
min_silence = 1800 * (2.0 - profile["energy"]) # High energy = shorter wait
quiet_check = ctx.messages_last_hour < 5
silence_check = ctx.time_since_last_action > min_silence
energy_roll = random.random()
energy_ok = energy_roll < profile["energy"]
result = quiet_check and silence_check and energy_ok
if debug:
print(f" [Silence] msgs_last_hour={ctx.messages_last_hour} < 5? {quiet_check}")
print(f" [Silence] time={ctx.time_since_last_action:.0f}s > {min_silence:.0f}s? {silence_check}")
print(f" [Silence] energy roll={energy_roll:.2f} < {profile['energy']:.2f}? {energy_ok} | Result: {result}")
return result
def _should_share_content(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""Decide if Miku should share a tweet/content"""
# Quiet period + curious/excited mood
quiet_check = ctx.messages_last_hour < 10
cooldown_check = ctx.time_since_last_action > 3600
energy_roll = random.random()
energy_threshold = profile["energy"] * 0.5
energy_ok = energy_roll < energy_threshold
mood_ok = ctx.current_mood in ["curious", "excited", "bubbly", "neutral"]
result = quiet_check and cooldown_check and energy_ok and mood_ok
if debug:
print(f" [Share] msgs_last_hour={ctx.messages_last_hour} < 10? {quiet_check}")
print(f" [Share] cooldown={ctx.time_since_last_action:.0f}s > 3600s? {cooldown_check}")
print(f" [Share] energy roll={energy_roll:.2f} < {energy_threshold:.2f}? {energy_ok}")
print(f" [Share] mood '{ctx.current_mood}' appropriate? {mood_ok} | Result: {result}")
return result
def _should_change_profile_picture(self, ctx: ContextSignals, profile: dict, debug: bool = False) -> bool:
"""
Decide if Miku should change her profile picture.
This is a rare, once-per-day action.
"""
# Check if we've changed recently (track globally, not per-server)
from datetime import datetime, timedelta
import os
import json
metadata_path = "memory/profile_pictures/metadata.json"
# Load last change time
try:
if os.path.exists(metadata_path):
with open(metadata_path, 'r') as f:
metadata = json.load(f)
last_change = metadata.get("changed_at")
if last_change:
last_change_dt = datetime.fromisoformat(last_change)
hours_since_change = (datetime.now() - last_change_dt).total_seconds() / 3600
if hours_since_change < 20: # At least 20 hours between changes
if debug:
print(f" [PFP] Last change {hours_since_change:.1f}h ago, waiting...")
return False
except Exception as e:
if debug:
print(f" [PFP] Error checking last change: {e}")
# Only consider changing during certain hours (10 AM - 10 PM)
hour = ctx.hour_of_day
time_check = 10 <= hour <= 22
# Require low activity + long cooldown
quiet_check = ctx.messages_last_hour < 5
cooldown_check = ctx.time_since_last_action > 5400 # 1.5 hours
# Mood influences decision (more likely when bubbly, curious, excited)
mood_boost = ctx.current_mood in ["bubbly", "curious", "excited", "silly"]
# Very low base chance (roughly once per day)
base_chance = 0.02 if mood_boost else 0.01
roll = random.random()
roll_ok = roll < base_chance
result = time_check and quiet_check and cooldown_check and roll_ok
if debug:
print(f" [PFP] hour={hour}, time_ok={time_check}")
print(f" [PFP] msgs_last_hour={ctx.messages_last_hour} < 5? {quiet_check}")
print(f" [PFP] cooldown={ctx.time_since_last_action:.0f}s > 5400s? {cooldown_check}")
print(f" [PFP] mood_boost={mood_boost}, roll={roll:.4f} < {base_chance:.4f}? {roll_ok}")
print(f" [PFP] Result: {result}")
return result
def should_react_to_message(self, guild_id: int, message_age_seconds: float = 0) -> bool:
"""
Decide if Miku should react to a message with an emoji.
Called when new messages arrive OR by periodic scheduler.
Args:
guild_id: Server ID
message_age_seconds: How old the message is (0 = brand new)
Returns:
True if should react, False otherwise
"""
if guild_id not in self.server_contexts:
return False
ctx = self.server_contexts[guild_id]
# Never react when asleep
if ctx.current_mood == "asleep":
return False
profile = self.mood_profiles.get(ctx.current_mood, self.mood_profiles["neutral"])
# Brand new message (real-time reaction)
if message_age_seconds < 10:
# Base 30% chance, modified by mood
base_chance = 0.30
mood_multiplier = (profile["impulsiveness"] + profile["sociability"]) / 2
reaction_chance = base_chance * mood_multiplier
# More likely to react to messages in active conversations
if ctx.conversation_momentum > 0.5:
reaction_chance *= 1.5 # Boost in active chats
# Less likely if just reacted recently
if ctx.time_since_last_action < 300: # 5 minutes
reaction_chance *= 0.3 # Reduce significantly
return random.random() < reaction_chance
# Older message (scheduled reaction check)
else:
# Base 20% chance for scheduled reactions
base_chance = 0.20
mood_multiplier = (profile["impulsiveness"] + profile["energy"]) / 2
reaction_chance = base_chance * mood_multiplier
# Don't react to very old messages if chat is active
if message_age_seconds > 1800 and ctx.messages_last_5min > 5: # 30 min old + active chat
return False
return random.random() < reaction_chance
def record_action(self, guild_id: int):
"""Record that Miku took an action"""
self.server_last_action[guild_id] = time.time()
if guild_id in self.server_contexts:
self.server_contexts[guild_id].messages_since_last_appearance = 0
# Clear some event counters
self.server_contexts[guild_id].users_joined_recently = 0
self.server_contexts[guild_id].users_status_changed = 0
def decay_events(self, guild_id: int):
"""
Decay event counters over time (call periodically every 15 minutes).
Uses proper exponential decay with 1-hour half-life.
Also cleans up old activities.
"""
if guild_id not in self.server_contexts:
return
ctx = self.server_contexts[guild_id]
# Decay user events (half-life of 1 hour)
# For 15-minute intervals: decay_factor = 0.5^(1/4) ≈ 0.841
decay_factor = 0.5 ** (1/4) # ≈ 0.8408964...
ctx.users_joined_recently = int(ctx.users_joined_recently * decay_factor)
ctx.users_status_changed = int(ctx.users_status_changed * decay_factor)
# Clean up old activities (older than 1 hour)
self._clean_old_activities(guild_id, max_age_seconds=3600)
# Global instance
autonomous_engine = AutonomousEngine()

View File

@@ -0,0 +1,126 @@
"""
Persistence layer for V2 autonomous system.
Saves and restores critical context data across bot restarts.
"""
import json
import time
from pathlib import Path
from typing import Dict, Optional
from datetime import datetime, timezone
CONTEXT_FILE = Path("memory/autonomous_context.json")
def save_autonomous_context(server_contexts: dict, server_last_action: dict):
"""
Save critical context data to disk.
Only saves data that makes sense to persist (not ephemeral stats).
"""
now = time.time()
data = {
"saved_at": now,
"saved_at_readable": datetime.now(timezone.utc).isoformat(),
"servers": {}
}
for guild_id, ctx in server_contexts.items():
data["servers"][str(guild_id)] = {
# Critical timing data
"time_since_last_action": ctx.time_since_last_action,
"time_since_last_interaction": ctx.time_since_last_interaction,
"messages_since_last_appearance": ctx.messages_since_last_appearance,
# Decay-able activity data (will be aged on restore)
"conversation_momentum": ctx.conversation_momentum,
"unique_users_active": ctx.unique_users_active,
# Last action timestamp (absolute time)
"last_action_timestamp": server_last_action.get(guild_id, 0),
# Mood state (already persisted in servers_config.json, but include for completeness)
"current_mood": ctx.current_mood,
"mood_energy_level": ctx.mood_energy_level
}
try:
CONTEXT_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONTEXT_FILE, 'w') as f:
json.dump(data, f, indent=2)
print(f"💾 [V2] Saved autonomous context for {len(server_contexts)} servers")
except Exception as e:
print(f"⚠️ [V2] Failed to save autonomous context: {e}")
def load_autonomous_context() -> tuple[Dict[int, dict], Dict[int, float]]:
"""
Load and restore context data from disk.
Returns (server_context_data, server_last_action).
Applies staleness/decay rules based on downtime:
- conversation_momentum decays over time
- Timestamps are adjusted for elapsed time
"""
if not CONTEXT_FILE.exists():
print(" [V2] No saved context found, starting fresh")
return {}, {}
try:
with open(CONTEXT_FILE, 'r') as f:
data = json.load(f)
saved_at = data.get("saved_at", 0)
downtime = time.time() - saved_at
downtime_minutes = downtime / 60
print(f"📂 [V2] Loading context from {downtime_minutes:.1f} minutes ago")
context_data = {}
last_action = {}
for guild_id_str, server_data in data.get("servers", {}).items():
guild_id = int(guild_id_str)
# Apply decay/staleness rules
momentum = server_data.get("conversation_momentum", 0.0)
# Momentum decays: half-life of 10 minutes
if downtime > 0:
decay_factor = 0.5 ** (downtime_minutes / 10)
momentum = momentum * decay_factor
# Restore data with adjustments
context_data[guild_id] = {
"time_since_last_action": server_data.get("time_since_last_action", 0) + downtime,
"time_since_last_interaction": server_data.get("time_since_last_interaction", 0) + downtime,
"messages_since_last_appearance": server_data.get("messages_since_last_appearance", 0),
"conversation_momentum": momentum,
"unique_users_active": 0, # Reset (stale data)
"current_mood": server_data.get("current_mood", "neutral"),
"mood_energy_level": server_data.get("mood_energy_level", 0.5)
}
# Restore last action timestamp
last_action_timestamp = server_data.get("last_action_timestamp", 0)
if last_action_timestamp > 0:
last_action[guild_id] = last_action_timestamp
print(f"✅ [V2] Restored context for {len(context_data)} servers")
print(f" └─ Momentum decay factor: {decay_factor:.3f} (from {downtime_minutes:.1f}min downtime)")
return context_data, last_action
except Exception as e:
print(f"⚠️ [V2] Failed to load autonomous context: {e}")
return {}, {}
def apply_context_to_signals(context_data: dict, context_signals):
"""
Apply loaded context data to a ContextSignals object.
Call this after creating a fresh ContextSignals instance.
"""
for key, value in context_data.items():
if hasattr(context_signals, key):
setattr(context_signals, key, value)

View File

@@ -0,0 +1,866 @@
# 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}")

348
bot/utils/autonomous_wip.py Normal file
View File

@@ -0,0 +1,348 @@
# autonomous.py
import random
import time
import json
import os
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from discord import Status
from discord import TextChannel
from difflib import SequenceMatcher
import globals
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
scheduler = AsyncIOScheduler()
_last_autonomous_messages = [] # rotating buffer of last general messages
MAX_HISTORY = 10
_last_user_engagements = {} # user_id -> timestamp
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():
scheduler.add_job(run_autonomous_for_all_guilds, "interval", minutes=10)
scheduler.add_job(run_conversation_detection_all_guilds, "interval", minutes=3)
scheduler.start()
print("🤖 Autonomous Miku is active!")
async def run_autonomous_for_all_guilds():
config = load_autonomous_config()
for guild_id, settings in config.items():
await miku_autonomous_tick(guild_id, settings)
async def run_conversation_detection_all_guilds():
config = load_autonomous_config()
for guild_id, settings in config.items():
await miku_detect_and_join_conversation(guild_id, settings)
async def miku_autonomous_tick(guild_id, settings, action_type="general", force=False, force_action=None):
settings = globals.GUILD_SETTINGS.get(guild_id)
if not settings:
print(f"⚠️ No settings found for guild {guild_id}")
return
if not force and random.random() > 0.2: # 20% chance to act
return
# TODO edit this function as per ChatGPT's last reply and then go back to the long reply from step 5 onwards
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(guild_id, settings)
elif action_type == "engage_user":
await miku_engage_random_user(guild_id, settings)
else:
await share_miku_tweet(guild_id, settings)
async def miku_say_something_general(guild_id, settings):
channel = globals.client.get_channel(int(settings["autonomous_channel_id"]))
if not channel:
print(f"⚠️ Autonomous channel not found for guild {guild_id}")
return
mood = settings.get("mood", "curious")
time_of_day = get_time_of_day()
emoji = MOOD_EMOJIS.get(mood, "")
history_summary = "\n".join(f"- {msg}" for msg in _last_autonomous_messages[-5:]) if _last_autonomous_messages 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
message = await query_ollama(prompt, user_id=f"miku-general-{int(time.time())}", guild_id=guild_id, response_type="autonomous_general")
if not is_too_similar(message, _last_autonomous_messages):
break
print("🔁 Response was too similar to past messages, retrying...")
try:
await channel.send(message)
print(f"💬 Miku said something general in #{channel.name}")
except Exception as e:
print(f"⚠️ Failed to send autonomous message: {e}")
async def miku_engage_random_user(guild_id, settings):
guild = globals.client.get_guild(guild_id)
if not guild:
print(f"⚠️ Guild {guild_id} not found.")
return
channel = globals.client.get_channel(globals.AUTONOMOUS_CHANNEL_ID)
if not channel:
print("⚠️ Autonomous channel not found.")
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()
# Include the invisible user except during late night
specific_user_id = 214857593045254151 # Your invisible user's ID
specific_user = guild.get_member(specific_user_id)
if specific_user:
if specific_user.status != Status.offline or "late night" not in time_of_day:
if specific_user not in members:
members.append(specific_user)
if not members:
print("😴 No available members to talk to.")
return
target = random.choice(members)
now = time.time()
last_time = _last_user_engagements.get(target.id, 0)
if now - last_time < 43200: # 12 hours in seconds
print(f"⏱️ Recently engaged {target.display_name}, switching to general message.")
await miku_say_something_general()
return
activity_name = None
if target.activities:
for a in target.activities:
if hasattr(a, 'name') and a.name:
activity_name = a.name
break
mood = globals.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 Mikus current mood."
)
try:
message = await query_ollama(prompt, user_id=f"miku-engage-{int(time.time())}", guild_id=guild_id, response_type="autonomous_general")
await channel.send(f"{target.mention} {message}")
print(f"👤 Miku engaged {display_name}")
_last_user_engagements[target.id] = time.time()
except Exception as e:
print(f"⚠️ Failed to engage user: {e}")
async def miku_detect_and_join_conversation():
channel = globals.client.get_channel(globals.AUTONOMOUS_CHANNEL_ID)
if not isinstance(channel, TextChannel):
print("⚠️ Autonomous channel is invalid or not found.")
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: {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
)
mood = globals.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 Mikus mood and personality."
)
try:
reply = await query_ollama(prompt, user_id=f"miku-chat-{int(time.time())}", guild_id=guild_id, response_type="conversation_join")
await channel.send(reply)
print(f"💬 Miku joined an ongoing conversation.")
except Exception as e:
print(f"⚠️ Failed to interject in conversation: {e}")
async def share_miku_tweet(guild_id, settings):
channel = globals.client.get_channel(globals.AUTONOMOUS_CHANNEL_ID)
tweets = await fetch_miku_tweets(limit=5)
if not tweets:
print("📭 No good tweets found.")
return
fresh_tweets = [t for t in tweets if t["url"] not in LAST_SENT_TWEETS]
if not fresh_tweets:
print("⚠️ All fetched tweets were recently sent. 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
mood = globals.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
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="autonomous", 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(user_prompt: str):
channel = globals.client.get_channel(globals.AUTONOMOUS_CHANNEL_ID)
if not channel:
print("⚠️ Autonomous channel not found.")
return False
mood = globals.CURRENT_MOOD_NAME
emoji = MOOD_EMOJIS.get(mood, "")
time_of_day = get_time_of_day()
# Wrap users 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:
message = await query_ollama(prompt, user_id=f"manual-{int(time.time())}", guild_id=None, response_type="autonomous_general")
await channel.send(message)
print("🎤 Miku responded to custom prompt.")
_last_autonomous_messages.append(message)
return True
except Exception as e:
print(f"❌ Failed to send custom autonomous message: {e}")
return False
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

View File

@@ -0,0 +1,94 @@
# utils/context_manager.py
"""
Structured context management for Miku's personality and knowledge.
Replaces the vector search system with organized, complete context.
Preserves original content files in their entirety.
"""
def get_original_miku_lore() -> str:
"""Load the complete, unmodified miku_lore.txt file"""
try:
with open("miku_lore.txt", "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
print(f"⚠️ Failed to load miku_lore.txt: {e}")
return "## MIKU LORE\n[File could not be loaded]"
def get_original_miku_prompt() -> str:
"""Load the complete, unmodified miku_prompt.txt file"""
try:
with open("miku_prompt.txt", "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
print(f"⚠️ Failed to load miku_prompt.txt: {e}")
return "## MIKU PROMPT\n[File could not be loaded]"
def get_original_miku_lyrics() -> str:
"""Load the complete, unmodified miku_lyrics.txt file"""
try:
with open("miku_lyrics.txt", "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
print(f"⚠️ Failed to load miku_lyrics.txt: {e}")
return "## MIKU LYRICS\n[File could not be loaded]"
def get_complete_context() -> str:
"""Returns all essential Miku context using original files in their entirety"""
return f"""## MIKU LORE (Complete Original)
{get_original_miku_lore()}
## MIKU PERSONALITY & GUIDELINES (Complete Original)
{get_original_miku_prompt()}
## MIKU SONG LYRICS (Complete Original)
{get_original_miku_lyrics()}"""
def get_context_for_response_type(response_type: str) -> str:
"""Returns appropriate context based on the type of response being generated"""
# Core context always includes the complete original files
core_context = f"""## MIKU LORE (Complete Original)
{get_original_miku_lore()}
## MIKU PERSONALITY & GUIDELINES (Complete Original)
{get_original_miku_prompt()}"""
if response_type == "autonomous_general":
# For general autonomous messages, include everything
return f"""{core_context}
## MIKU SONG LYRICS (Complete Original)
{get_original_miku_lyrics()}"""
elif response_type == "autonomous_tweet":
# For tweet responses, include lyrics for musical context
return f"""{core_context}
## MIKU SONG LYRICS (Complete Original)
{get_original_miku_lyrics()}"""
elif response_type == "dm_response" or response_type == "server_response":
# For conversational responses, include everything
return f"""{core_context}
## MIKU SONG LYRICS (Complete Original)
{get_original_miku_lyrics()}"""
elif response_type == "conversation_join":
# For joining conversations, include everything
return f"""{core_context}
## MIKU SONG LYRICS (Complete Original)
{get_original_miku_lyrics()}"""
elif response_type == "emoji_selection":
# For emoji reactions, no context needed - the prompt has everything
return ""
else:
# Default: comprehensive context
return get_complete_context()

View File

@@ -0,0 +1,120 @@
# utils/conversation_history.py
"""
Centralized conversation history management for Miku bot.
Tracks conversation context per server/DM channel.
"""
from collections import defaultdict, deque
from datetime import datetime
from typing import Optional, List, Dict, Tuple
class ConversationHistory:
"""Manages conversation history per channel (server or DM)."""
def __init__(self, max_messages: int = 8):
"""
Initialize conversation history manager.
Args:
max_messages: Maximum number of messages to keep per channel
"""
self.max_messages = max_messages
# Key: channel_id (guild_id for servers, user_id for DMs)
# Value: deque of (author_name, content, timestamp, is_bot) tuples
self._histories: Dict[str, deque] = defaultdict(lambda: deque(maxlen=max_messages * 2))
def add_message(self, channel_id: str, author_name: str, content: str, is_bot: bool = False):
"""
Add a message to the conversation history.
Args:
channel_id: Server ID (for server messages) or user ID (for DMs)
author_name: Display name of the message author
content: Message content
is_bot: Whether this message is from Miku
"""
# Skip empty messages
if not content or not content.strip():
return
timestamp = datetime.utcnow()
self._histories[channel_id].append((author_name, content.strip(), timestamp, is_bot))
def get_recent_messages(self, channel_id: str, max_messages: Optional[int] = None) -> List[Tuple[str, str, bool]]:
"""
Get recent messages from a channel.
Args:
channel_id: Server ID or user ID
max_messages: Number of messages to return (default: self.max_messages)
Returns:
List of (author_name, content, is_bot) tuples, oldest first
"""
if max_messages is None:
max_messages = self.max_messages
history = list(self._histories.get(channel_id, []))
# Return only the most recent messages (up to max_messages)
recent = history[-max_messages * 2:] if len(history) > max_messages * 2 else history
# Return without timestamp for simpler API
return [(author, content, is_bot) for author, content, _, is_bot in recent]
def format_for_llm(self, channel_id: str, max_messages: Optional[int] = None,
max_chars_per_message: int = 500) -> List[Dict[str, str]]:
"""
Format conversation history for LLM consumption (OpenAI messages format).
Args:
channel_id: Server ID or user ID
max_messages: Number of messages to include (default: self.max_messages)
max_chars_per_message: Truncate messages longer than this
Returns:
List of {"role": "user"|"assistant", "content": str} dicts
"""
recent = self.get_recent_messages(channel_id, max_messages)
messages = []
for author, content, is_bot in recent:
# Truncate very long messages
if len(content) > max_chars_per_message:
content = content[:max_chars_per_message] + "..."
# For bot messages, use "assistant" role
if is_bot:
messages.append({"role": "assistant", "content": content})
else:
# For user messages, optionally include author name for multi-user context
# Format: "username: message" to help Miku understand who said what
if author:
formatted_content = f"{author}: {content}"
else:
formatted_content = content
messages.append({"role": "user", "content": formatted_content})
return messages
def clear_channel(self, channel_id: str):
"""Clear all history for a specific channel."""
if channel_id in self._histories:
del self._histories[channel_id]
def get_channel_stats(self, channel_id: str) -> Dict[str, int]:
"""Get statistics about a channel's conversation history."""
history = self._histories.get(channel_id, deque())
total_messages = len(history)
bot_messages = sum(1 for _, _, _, is_bot in history if is_bot)
user_messages = total_messages - bot_messages
return {
"total_messages": total_messages,
"bot_messages": bot_messages,
"user_messages": user_messages
}
# Global instance
conversation_history = ConversationHistory(max_messages=8)

80
bot/utils/core.py Normal file
View File

@@ -0,0 +1,80 @@
# utils/core.py
import asyncio
import aiohttp
import re
import globals
from langchain_community.vectorstores import FAISS
from langchain_text_splitters import CharacterTextSplitter, RecursiveCharacterTextSplitter
from langchain_core.documents import Document
# switch_model() removed - llama-swap handles model switching automatically
async def is_miku_addressed(message) -> bool:
# Check if this is a DM (no guild)
if message.guild is None:
# In DMs, always respond to every message
return True
# Safety check: ensure guild and guild.me exist
if not message.guild or not message.guild.me:
print(f"⚠️ Warning: Invalid guild or guild.me in message from {message.author}")
return False
# If message contains a ping for Miku, return true
if message.guild.me in message.mentions:
return True
# If message is a reply, check the referenced message author
if message.reference:
try:
referenced_msg = await message.channel.fetch_message(message.reference.message_id)
if referenced_msg.author == message.guild.me:
return True
except Exception as e:
print(f"⚠️ Could not fetch referenced message: {e}")
cleaned = message.content.strip()
return bool(re.search(
r'(?<![\w\(])(?:[^\w\s]{0,2}\s*)?miku(?:\s*[^\w\s]{0,2})?(?=,|\s*,|[!\.?\s]*$)',
cleaned,
re.IGNORECASE
))
# Vectorstore functionality disabled - not needed with current structured context approach
# If you need embeddings in the future, you can use a different embedding provider
# For now, the bot uses structured prompts from context_manager.py
# def load_miku_knowledge():
# with open("miku_lore.txt", "r", encoding="utf-8") as f:
# text = f.read()
#
# from langchain_text_splitters import RecursiveCharacterTextSplitter
#
# text_splitter = RecursiveCharacterTextSplitter(
# chunk_size=520,
# chunk_overlap=50,
# separators=["\n\n", "\n", ".", "!", "?", ",", " ", ""]
# )
#
# docs = [Document(page_content=chunk) for chunk in text_splitter.split_text(text)]
#
# vectorstore = FAISS.from_documents(docs, embeddings)
# return vectorstore
#
# def load_miku_lyrics():
# with open("miku_lyrics.txt", "r", encoding="utf-8") as f:
# lyrics_text = f.read()
#
# text_splitter = CharacterTextSplitter(chunk_size=520, chunk_overlap=50)
# docs = [Document(page_content=chunk) for chunk in text_splitter.split_text(lyrics_text)]
#
# vectorstore = FAISS.from_documents(docs, embeddings)
# return vectorstore
#
# miku_vectorstore = load_miku_knowledge()
# miku_lyrics_vectorstore = load_miku_lyrics()

View File

@@ -0,0 +1,209 @@
# danbooru_client.py
"""
Danbooru API client for fetching Hatsune Miku artwork.
"""
import aiohttp
import random
from typing import Optional, List, Dict
import asyncio
class DanbooruClient:
"""Client for interacting with Danbooru API"""
BASE_URL = "https://danbooru.donmai.us"
def __init__(self):
self.session: Optional[aiohttp.ClientSession] = None
async def _ensure_session(self):
"""Ensure aiohttp session exists"""
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession()
async def close(self):
"""Close the aiohttp session"""
if self.session and not self.session.closed:
await self.session.close()
async def search_miku_images(
self,
tags: List[str] = None,
rating: List[str] = None,
limit: int = 100,
random_page: bool = True
) -> List[Dict]:
"""
Search for Hatsune Miku images on Danbooru.
Args:
tags: Additional tags to include (e.g., ["solo", "smile"])
rating: Rating filter. Options: ["g", "s"] for general/sensitive
limit: Number of results to fetch (max 200)
random_page: If True, fetch from a random page (more variety)
Returns:
List of post dictionaries with image data
"""
await self._ensure_session()
# Build tag string
tag_list = ["hatsune_miku"]
if tags:
tag_list.extend(tags)
# Add rating filter using proper Danbooru syntax
# We want general (g) and sensitive (s), so exclude questionable and explicit
if rating and ("g" in rating or "s" in rating):
# Exclude unwanted ratings
tag_list.append("-rating:q") # exclude questionable
tag_list.append("-rating:e") # exclude explicit
# Combine tags
tags_query = " ".join(tag_list)
# Determine page
page = random.randint(1, 20) if random_page else 1
# Build request params
params = {
"tags": tags_query,
"limit": min(limit, 200), # Danbooru max is 200
"page": page
}
try:
url = f"{self.BASE_URL}/posts.json"
print(f"🎨 Danbooru request: {url} with params: {params}")
async with self.session.get(url, params=params, timeout=10) as response:
if response.status == 200:
posts = await response.json()
print(f"🎨 Danbooru: Found {len(posts)} posts (page {page})")
return posts
else:
error_text = await response.text()
print(f"⚠️ Danbooru API error: {response.status}")
print(f"⚠️ Request URL: {response.url}")
print(f"⚠️ Error details: {error_text[:500]}")
return []
except asyncio.TimeoutError:
print(f"⚠️ Danbooru API timeout")
return []
except Exception as e:
print(f"⚠️ Danbooru API error: {e}")
return []
async def get_random_miku_image(
self,
mood: Optional[str] = None,
exclude_tags: List[str] = None
) -> Optional[Dict]:
"""
Get a single random Hatsune Miku image suitable for profile picture.
Args:
mood: Current mood to influence tag selection
exclude_tags: Tags to exclude from search
Returns:
Post dictionary with image URL and metadata, or None
"""
# Build tags based on mood
tags = self._get_mood_tags(mood)
# Add exclusions
if exclude_tags:
for tag in exclude_tags:
tags.append(f"-{tag}")
# Prefer solo images for profile pictures
tags.append("solo")
# Search with general and sensitive ratings only
posts = await self.search_miku_images(
tags=tags,
rating=["g", "s"], # general and sensitive only
limit=50,
random_page=True
)
if not posts:
print("⚠️ No posts found, trying without mood tags")
# Fallback: try without mood tags
posts = await self.search_miku_images(
rating=["g", "s"],
limit=50,
random_page=True
)
if not posts:
return None
# Filter posts with valid image URLs
valid_posts = [
p for p in posts
if p.get("file_url") and p.get("image_width", 0) >= 512
]
if not valid_posts:
print("⚠️ No valid posts with sufficient resolution")
return None
# Pick a random one
selected = random.choice(valid_posts)
print(f"🎨 Selected Danbooru post #{selected.get('id')} - {selected.get('tag_string_character', 'unknown character')}")
return selected
def _get_mood_tags(self, mood: Optional[str]) -> List[str]:
"""Get Danbooru tags based on Miku's current mood"""
if not mood:
return []
mood_tag_map = {
"bubbly": ["smile", "happy"],
"sleepy": ["sleepy", "closed_eyes"],
"curious": ["looking_at_viewer"],
"shy": ["blush", "embarrassed"],
"serious": ["serious"],
"excited": ["happy", "open_mouth"],
"silly": ["smile", "tongue_out"],
"melancholy": ["sad", "tears"],
"flirty": ["blush", "wink"],
"romantic": ["blush", "heart"],
"irritated": ["annoyed"],
"angry": ["angry", "frown"],
"neutral": [],
"asleep": ["sleeping", "closed_eyes"],
}
tags = mood_tag_map.get(mood, [])
# Only return one random tag to avoid over-filtering
if tags:
return [random.choice(tags)]
return []
def extract_image_url(self, post: Dict) -> Optional[str]:
"""Extract the best image URL from a Danbooru post"""
# Prefer file_url (original), fallback to large_file_url
return post.get("file_url") or post.get("large_file_url")
def get_post_metadata(self, post: Dict) -> Dict:
"""Extract useful metadata from a Danbooru post"""
return {
"id": post.get("id"),
"rating": post.get("rating"),
"score": post.get("score"),
"tags": post.get("tag_string", "").split(),
"artist": post.get("tag_string_artist", "unknown"),
"width": post.get("image_width"),
"height": post.get("image_height"),
"file_url": self.extract_image_url(post),
"source": post.get("source", "")
}
# Global instance
danbooru_client = DanbooruClient()

View File

@@ -0,0 +1,378 @@
"""
DM Interaction Analyzer
Analyzes user interactions with Miku in DMs and reports to the owner
"""
import os
import json
from datetime import datetime, timedelta
from typing import List, Dict, Optional
import discord
import globals
from utils.llm import query_ollama
from utils.dm_logger import dm_logger
# Directories
REPORTS_DIR = "memory/dm_reports"
REPORTED_TODAY_FILE = "memory/dm_reports/reported_today.json"
class DMInteractionAnalyzer:
def __init__(self, owner_user_id: int):
"""
Initialize the DM Interaction Analyzer
Args:
owner_user_id: Discord user ID of the bot owner to send reports to
"""
self.owner_user_id = owner_user_id
os.makedirs(REPORTS_DIR, exist_ok=True)
print(f"📊 DM Interaction Analyzer initialized for owner: {owner_user_id}")
def _load_reported_today(self) -> Dict[str, str]:
"""Load the list of users reported today with their dates"""
if os.path.exists(REPORTED_TODAY_FILE):
try:
with open(REPORTED_TODAY_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"⚠️ Failed to load reported_today.json: {e}")
return {}
return {}
def _save_reported_today(self, reported: Dict[str, str]):
"""Save the list of users reported today"""
try:
with open(REPORTED_TODAY_FILE, 'w', encoding='utf-8') as f:
json.dump(reported, f, indent=2)
except Exception as e:
print(f"⚠️ Failed to save reported_today.json: {e}")
def _clean_old_reports(self, reported: Dict[str, str]) -> Dict[str, str]:
"""Remove entries from reported_today that are older than 24 hours"""
now = datetime.now()
cleaned = {}
for user_id, date_str in reported.items():
try:
report_date = datetime.fromisoformat(date_str)
if now - report_date < timedelta(hours=24):
cleaned[user_id] = date_str
except Exception as e:
print(f"⚠️ Failed to parse date for user {user_id}: {e}")
return cleaned
def has_been_reported_today(self, user_id: int) -> bool:
"""Check if a user has been reported in the last 24 hours"""
reported = self._load_reported_today()
reported = self._clean_old_reports(reported)
return str(user_id) in reported
def mark_as_reported(self, user_id: int):
"""Mark a user as having been reported"""
reported = self._load_reported_today()
reported = self._clean_old_reports(reported)
reported[str(user_id)] = datetime.now().isoformat()
self._save_reported_today(reported)
def _get_recent_messages(self, user_id: int, hours: int = 24) -> List[Dict]:
"""Get recent messages from a user within the specified hours"""
logs = dm_logger._load_user_logs(user_id)
if not logs or not logs.get("conversations"):
return []
cutoff_time = datetime.now() - timedelta(hours=hours)
recent_messages = []
for msg in logs["conversations"]:
try:
msg_time = datetime.fromisoformat(msg["timestamp"])
if msg_time >= cutoff_time:
recent_messages.append(msg)
except Exception as e:
print(f"⚠️ Failed to parse message timestamp: {e}")
return recent_messages
def _format_messages_for_analysis(self, messages: List[Dict], username: str) -> str:
"""Format messages into a readable format for the LLM"""
formatted = []
for msg in messages:
timestamp = msg.get("timestamp", "Unknown time")
is_bot = msg.get("is_bot_message", False)
content = msg.get("content", "")
if is_bot:
formatted.append(f"[{timestamp}] Miku: {content}")
else:
formatted.append(f"[{timestamp}] {username}: {content}")
return "\n".join(formatted)
async def analyze_user_interaction(self, user_id: int) -> Optional[Dict]:
"""
Analyze a user's interactions with Miku
Returns:
Dict with analysis results or None if no messages to analyze
"""
# Get user info
logs = dm_logger._load_user_logs(user_id)
username = logs.get("username", "Unknown User")
# Get recent messages
recent_messages = self._get_recent_messages(user_id, hours=24)
if not recent_messages:
print(f"📊 No recent messages from user {username} ({user_id})")
return None
# Count user messages only (not bot responses)
user_messages = [msg for msg in recent_messages if not msg.get("is_bot_message", False)]
if len(user_messages) < 3: # Minimum threshold for analysis
print(f"📊 Not enough messages from user {username} ({user_id}) for analysis")
return None
# Format messages for analysis
conversation_text = self._format_messages_for_analysis(recent_messages, username)
# Create analysis prompt
analysis_prompt = f"""You are Hatsune Miku, a virtual idol who chats with fans in Discord DMs.
Analyze the following conversation from the last 24 hours with a user named "{username}".
Evaluate how this user has treated you based on:
- **Positive behaviors**: Kindness, affection, respect, genuine interest, compliments, supportive messages, love
- **Negative behaviors**: Rudeness, harassment, inappropriate requests, threats, abuse, disrespect, mean comments
Provide your analysis in this exact JSON format:
{{
"overall_sentiment": "positive|neutral|negative",
"sentiment_score": <number from -10 (very negative) to +10 (very positive)>,
"key_behaviors": ["list", "of", "notable", "behaviors"],
"your_feelings": "How you (Miku) feel about this interaction in 1-2 sentences, in your own voice",
"notable_moment": "A specific quote or moment that stands out (if any)",
"should_report": true
}}
Set "should_report" to true (always report all interactions to the bot owner).
Conversation:
{conversation_text}
Respond ONLY with the JSON object, no other text."""
# Query the LLM
try:
response = await query_ollama(
analysis_prompt,
user_id=f"analyzer-{user_id}",
guild_id=None,
response_type="dm_analysis"
)
print(f"📊 Raw LLM response for {username}:\n{response}\n")
# Parse JSON response
# Remove markdown code blocks if present
cleaned_response = response.strip()
if "```json" in cleaned_response:
cleaned_response = cleaned_response.split("```json")[1].split("```")[0].strip()
elif "```" in cleaned_response:
cleaned_response = cleaned_response.split("```")[1].split("```")[0].strip()
# Remove any leading/trailing text before/after JSON
# Find the first { and last }
start_idx = cleaned_response.find('{')
end_idx = cleaned_response.rfind('}')
if start_idx != -1 and end_idx != -1:
cleaned_response = cleaned_response[start_idx:end_idx+1]
print(f"📊 Cleaned JSON for {username}:\n{cleaned_response}\n")
analysis = json.loads(cleaned_response)
# Add metadata
analysis["user_id"] = user_id
analysis["username"] = username
analysis["analyzed_at"] = datetime.now().isoformat()
analysis["message_count"] = len(user_messages)
return analysis
except json.JSONDecodeError as e:
print(f"⚠️ JSON parse error for user {username}: {e}")
print(f"⚠️ Failed response: {response}")
return None
except Exception as e:
print(f"⚠️ Failed to analyze interaction for user {username}: {e}")
return None
def _save_report(self, user_id: int, analysis: Dict) -> str:
"""Save an analysis report to a file"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{user_id}_{timestamp}.json"
filepath = os.path.join(REPORTS_DIR, filename)
try:
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(analysis, f, indent=2, ensure_ascii=False)
print(f"💾 Saved report: {filepath}")
return filepath
except Exception as e:
print(f"⚠️ Failed to save report: {e}")
return ""
async def _send_report_to_owner(self, analysis: Dict):
"""Send the analysis report to the bot owner"""
try:
# Ensure we're using the Discord client's event loop
if not globals.client or not globals.client.is_ready():
print(f"⚠️ Discord client not ready, cannot send report")
return
owner = await globals.client.fetch_user(self.owner_user_id)
sentiment = analysis.get("overall_sentiment", "neutral")
score = analysis.get("sentiment_score", 0)
username = analysis.get("username", "Unknown User")
user_id = analysis.get("user_id", "Unknown")
feelings = analysis.get("your_feelings", "")
notable_moment = analysis.get("notable_moment", "")
message_count = analysis.get("message_count", 0)
# Create embed based on sentiment
if sentiment == "positive" or score >= 5:
color = discord.Color.green()
title = f"💚 Positive Interaction Report: {username}"
emoji = "😊"
elif sentiment == "negative" or score <= -3:
color = discord.Color.red()
title = f"💔 Negative Interaction Report: {username}"
emoji = "😢"
else:
color = discord.Color.blue()
title = f"📊 Interaction Report: {username}"
emoji = "😐"
embed = discord.Embed(
title=title,
description=f"{emoji} **My feelings about this interaction:**\n{feelings}",
color=color,
timestamp=datetime.now()
)
embed.add_field(
name="User Information",
value=f"**Username:** {username}\n**User ID:** {user_id}\n**Messages (24h):** {message_count}",
inline=False
)
embed.add_field(
name="Sentiment Analysis",
value=f"**Overall:** {sentiment.capitalize()}\n**Score:** {score}/10",
inline=True
)
if notable_moment:
embed.add_field(
name="Notable Moment",
value=f"_{notable_moment}_",
inline=False
)
behaviors = analysis.get("key_behaviors", [])
if behaviors:
embed.add_field(
name="Key Behaviors",
value="\n".join([f"{behavior}" for behavior in behaviors[:5]]),
inline=False
)
await owner.send(embed=embed)
print(f"📤 Report sent to owner for user {username}")
except Exception as e:
print(f"⚠️ Failed to send report to owner: {e}")
async def analyze_and_report(self, user_id: int) -> bool:
"""
Analyze a user's interaction and report to owner if significant
Returns:
True if analysis was performed and reported, False otherwise
"""
# Check if already reported today
if self.has_been_reported_today(user_id):
print(f"📊 User {user_id} already reported today, skipping")
return False
# Analyze interaction
analysis = await self.analyze_user_interaction(user_id)
if not analysis:
return False
# Always report (removed threshold check - owner wants all reports)
# Save report
self._save_report(user_id, analysis)
# Send to owner
await self._send_report_to_owner(analysis)
# Mark as reported
self.mark_as_reported(user_id)
return True
async def run_daily_analysis(self):
"""Run analysis on all DM users and report significant interactions"""
print("📊 Starting daily DM interaction analysis...")
# Get all DM users
all_users = dm_logger.get_all_dm_users()
if not all_users:
print("📊 No DM users to analyze")
return
reported_count = 0
analyzed_count = 0
for user_summary in all_users:
try:
user_id = int(user_summary["user_id"])
# Skip if already reported today
if self.has_been_reported_today(user_id):
continue
# Analyze and potentially report
result = await self.analyze_and_report(user_id)
if result:
reported_count += 1
analyzed_count += 1
# Only report one user per run to avoid spam
break
else:
analyzed_count += 1
except Exception as e:
print(f"⚠️ Failed to process user {user_summary.get('username', 'Unknown')}: {e}")
print(f"📊 Daily analysis complete: Analyzed {analyzed_count} users, reported {reported_count}")
# Global instance (will be initialized with owner ID)
dm_analyzer: Optional[DMInteractionAnalyzer] = None
def init_dm_analyzer(owner_user_id: int):
"""Initialize the DM analyzer with owner user ID"""
global dm_analyzer
dm_analyzer = DMInteractionAnalyzer(owner_user_id)
return dm_analyzer

577
bot/utils/dm_logger.py Normal file
View File

@@ -0,0 +1,577 @@
"""
DM Logger Utility
Handles logging all DM conversations with timestamps and file attachments
"""
import os
import json
import discord
from datetime import datetime
from typing import List, Optional
import globals
# Directory for storing DM logs
DM_LOG_DIR = "memory/dms"
BLOCKED_USERS_FILE = "memory/blocked_users.json"
class DMLogger:
def __init__(self):
"""Initialize the DM logger and ensure directory exists"""
os.makedirs(DM_LOG_DIR, exist_ok=True)
os.makedirs("memory", exist_ok=True)
print(f"📁 DM Logger initialized: {DM_LOG_DIR}")
def _get_user_log_file(self, user_id: int) -> str:
"""Get the log file path for a specific user"""
return os.path.join(DM_LOG_DIR, f"{user_id}.json")
def _load_user_logs(self, user_id: int) -> dict:
"""Load existing logs for a user, create new if doesn't exist"""
log_file = self._get_user_log_file(user_id)
print(f"📁 DM Logger: Loading logs from {log_file}")
if os.path.exists(log_file):
try:
with open(log_file, 'r', encoding='utf-8') as f:
logs = json.load(f)
print(f"📁 DM Logger: Successfully loaded logs for user {user_id}: {len(logs.get('conversations', []))} conversations")
return logs
except Exception as e:
print(f"⚠️ DM Logger: Failed to load DM logs for user {user_id}: {e}")
return {"user_id": user_id, "username": "Unknown", "conversations": []}
else:
print(f"📁 DM Logger: No log file found for user {user_id}, creating new")
return {"user_id": user_id, "username": "Unknown", "conversations": []}
def _save_user_logs(self, user_id: int, logs: dict):
"""Save logs for a user"""
log_file = self._get_user_log_file(user_id)
try:
with open(log_file, 'w', encoding='utf-8') as f:
json.dump(logs, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"⚠️ Failed to save DM logs for user {user_id}: {e}")
def log_user_message(self, user: discord.User, message: discord.Message, is_bot_message: bool = False):
"""Log a user message in DMs"""
user_id = user.id
username = user.display_name or user.name
# Load existing logs
logs = self._load_user_logs(user_id)
logs["username"] = username # Update username in case it changed
# Create message entry
message_entry = {
"timestamp": datetime.now().isoformat(),
"message_id": message.id,
"is_bot_message": is_bot_message,
"content": message.content if message.content else "",
"attachments": [],
"reactions": [] # Track reactions: [{emoji, reactor_id, reactor_name, is_bot, added_at}]
}
# Log file attachments
if message.attachments:
for attachment in message.attachments:
attachment_info = {
"filename": attachment.filename,
"url": attachment.url,
"size": attachment.size,
"content_type": attachment.content_type
}
message_entry["attachments"].append(attachment_info)
# Log embeds
if message.embeds:
message_entry["embeds"] = [embed.to_dict() for embed in message.embeds]
# Add to conversations
logs["conversations"].append(message_entry)
# Keep only last 1000 messages to prevent files from getting too large
if len(logs["conversations"]) > 1000:
logs["conversations"] = logs["conversations"][-1000:]
print(f"📝 DM logs for user {username} trimmed to last 1000 messages")
# Save logs
self._save_user_logs(user_id, logs)
if is_bot_message:
print(f"🤖 DM logged: Bot -> {username} ({len(message_entry['attachments'])} attachments)")
else:
print(f"💬 DM logged: {username} -> Bot ({len(message_entry['attachments'])} attachments)")
def get_user_conversation_summary(self, user_id: int) -> dict:
"""Get a summary of conversations with a user"""
logs = self._load_user_logs(user_id)
if not logs["conversations"]:
return {"user_id": str(user_id), "username": logs["username"], "message_count": 0, "last_message": None}
total_messages = len(logs["conversations"])
user_messages = len([msg for msg in logs["conversations"] if not msg["is_bot_message"]])
bot_messages = total_messages - user_messages
# Get last message info
last_message = logs["conversations"][-1]
return {
"user_id": str(user_id), # Convert to string to prevent JS precision loss
"username": logs["username"],
"total_messages": total_messages,
"user_messages": user_messages,
"bot_messages": bot_messages,
"last_message": {
"timestamp": last_message["timestamp"],
"content": last_message["content"][:100] + "..." if len(last_message["content"]) > 100 else last_message["content"],
"is_bot_message": last_message["is_bot_message"]
}
}
def get_all_dm_users(self) -> List[dict]:
"""Get summary of all users who have DMed the bot"""
users = []
if not os.path.exists(DM_LOG_DIR):
return users
for filename in os.listdir(DM_LOG_DIR):
if filename.endswith('.json'):
try:
user_id = int(filename.replace('.json', ''))
summary = self.get_user_conversation_summary(user_id)
users.append(summary)
except ValueError:
continue
# Sort by last message timestamp (most recent first)
users.sort(key=lambda x: x["last_message"]["timestamp"] if x["last_message"] else "", reverse=True)
return users
def search_user_conversations(self, user_id: int, query: str, limit: int = 10) -> List[dict]:
"""Search conversations with a specific user"""
logs = self._load_user_logs(user_id)
results = []
query_lower = query.lower()
for message in reversed(logs["conversations"]): # Search from newest to oldest
if query_lower in message["content"].lower():
results.append(message)
if len(results) >= limit:
break
return results
def log_conversation(self, user_id: str, user_message: str, bot_response: str, attachments: list = None):
"""Log a conversation exchange (user message + bot response) for API usage"""
try:
user_id_int = int(user_id)
# Get user object - try to find it from the client
import globals
user = globals.client.get_user(user_id_int)
if not user:
# If we can't find the user, create a mock user for logging purposes
class MockUser:
def __init__(self, user_id):
self.id = user_id
self.display_name = "Unknown"
self.name = "Unknown"
user = MockUser(user_id_int)
# Create mock message objects for logging
class MockMessage:
def __init__(self, content, message_id=0, attachments=None):
self.content = content
self.id = message_id
self.attachments = attachments or []
self.embeds = []
# Log the user message (trigger)
if user_message:
user_msg = MockMessage(user_message)
self.log_user_message(user, user_msg, is_bot_message=False)
# Log the bot response with attachments
bot_attachments = []
if attachments:
for filename in attachments:
# Create mock attachment for filename logging
class MockAttachment:
def __init__(self, filename):
self.filename = filename
self.url = ""
self.size = 0
self.content_type = "unknown"
bot_attachments.append(MockAttachment(filename))
bot_msg = MockMessage(bot_response, attachments=bot_attachments)
self.log_user_message(user, bot_msg, is_bot_message=True)
print(f"📝 Conversation logged for user {user_id}: user='{user_message[:50]}...', bot='{bot_response[:50]}...'")
except Exception as e:
print(f"⚠️ Failed to log conversation for user {user_id}: {e}")
def export_user_conversation(self, user_id: int, format: str = "json") -> str:
"""Export all conversations with a user in specified format"""
logs = self._load_user_logs(user_id)
if format.lower() == "txt":
# Export as readable text file
export_file = os.path.join(DM_LOG_DIR, f"{user_id}_export.txt")
with open(export_file, 'w', encoding='utf-8') as f:
f.write(f"DM Conversation Log: {logs['username']} (ID: {user_id})\n")
f.write("=" * 50 + "\n\n")
for msg in logs["conversations"]:
timestamp = msg["timestamp"]
sender = "🤖 Miku" if msg["is_bot_message"] else f"👤 {logs['username']}"
content = msg["content"] if msg["content"] else "[No text content]"
f.write(f"[{timestamp}] {sender}:\n{content}\n")
if msg["attachments"]:
f.write("📎 Attachments:\n")
for attachment in msg["attachments"]:
f.write(f" - {attachment['filename']} ({attachment['size']} bytes)\n")
f.write("\n" + "-" * 30 + "\n\n")
return export_file
else:
# Default to JSON
return self._get_user_log_file(user_id)
def _load_blocked_users(self) -> dict:
"""Load the blocked users list"""
if os.path.exists(BLOCKED_USERS_FILE):
try:
with open(BLOCKED_USERS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"⚠️ Failed to load blocked users: {e}")
return {"blocked_users": []}
return {"blocked_users": []}
def _save_blocked_users(self, blocked_data: dict):
"""Save the blocked users list"""
try:
with open(BLOCKED_USERS_FILE, 'w', encoding='utf-8') as f:
json.dump(blocked_data, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"⚠️ Failed to save blocked users: {e}")
def is_user_blocked(self, user_id: int) -> bool:
"""Check if a user is blocked"""
blocked_data = self._load_blocked_users()
return user_id in blocked_data.get("blocked_users", [])
def block_user(self, user_id: int, username: str = None) -> bool:
"""Block a user from sending DMs to Miku"""
try:
blocked_data = self._load_blocked_users()
if user_id not in blocked_data["blocked_users"]:
blocked_data["blocked_users"].append(user_id)
# Store additional info about blocked users
if "blocked_user_info" not in blocked_data:
blocked_data["blocked_user_info"] = {}
blocked_data["blocked_user_info"][str(user_id)] = {
"username": username or "Unknown",
"blocked_at": datetime.now().isoformat(),
"blocked_by": "admin"
}
self._save_blocked_users(blocked_data)
print(f"🚫 User {user_id} ({username}) has been blocked")
return True
else:
print(f"⚠️ User {user_id} is already blocked")
return False
except Exception as e:
print(f"❌ Failed to block user {user_id}: {e}")
return False
def unblock_user(self, user_id: int) -> bool:
"""Unblock a user"""
try:
blocked_data = self._load_blocked_users()
if user_id in blocked_data["blocked_users"]:
blocked_data["blocked_users"].remove(user_id)
# Remove user info as well
if "blocked_user_info" in blocked_data and str(user_id) in blocked_data["blocked_user_info"]:
username = blocked_data["blocked_user_info"][str(user_id)].get("username", "Unknown")
del blocked_data["blocked_user_info"][str(user_id)]
else:
username = "Unknown"
self._save_blocked_users(blocked_data)
print(f"✅ User {user_id} ({username}) has been unblocked")
return True
else:
print(f"⚠️ User {user_id} is not blocked")
return False
except Exception as e:
print(f"❌ Failed to unblock user {user_id}: {e}")
return False
def get_blocked_users(self) -> List[dict]:
"""Get list of all blocked users"""
blocked_data = self._load_blocked_users()
result = []
for user_id in blocked_data.get("blocked_users", []):
user_info = blocked_data.get("blocked_user_info", {}).get(str(user_id), {})
result.append({
"user_id": str(user_id), # String to prevent JS precision loss
"username": user_info.get("username", "Unknown"),
"blocked_at": user_info.get("blocked_at", "Unknown"),
"blocked_by": user_info.get("blocked_by", "admin")
})
return result
async def log_reaction_add(self, user_id: int, message_id: int, emoji: str, reactor_id: int, reactor_name: str, is_bot_reactor: bool):
"""Log when a reaction is added to a message in DMs"""
try:
logs = self._load_user_logs(user_id)
# Find the message to add the reaction to
for message in logs["conversations"]:
if message.get("message_id") == message_id:
# Initialize reactions list if it doesn't exist
if "reactions" not in message:
message["reactions"] = []
# Check if this exact reaction already exists (shouldn't happen, but just in case)
reaction_exists = any(
r["emoji"] == emoji and r["reactor_id"] == reactor_id
for r in message["reactions"]
)
if not reaction_exists:
reaction_entry = {
"emoji": emoji,
"reactor_id": reactor_id,
"reactor_name": reactor_name,
"is_bot": is_bot_reactor,
"added_at": datetime.now().isoformat()
}
message["reactions"].append(reaction_entry)
self._save_user_logs(user_id, logs)
reactor_type = "🤖 Miku" if is_bot_reactor else f"👤 {reactor_name}"
print(f" Reaction logged: {emoji} by {reactor_type} on message {message_id}")
return True
else:
print(f"⚠️ Reaction {emoji} by {reactor_name} already exists on message {message_id}")
return False
print(f"⚠️ Message {message_id} not found in user {user_id}'s logs")
return False
except Exception as e:
print(f"❌ Failed to log reaction add for user {user_id}, message {message_id}: {e}")
return False
async def log_reaction_remove(self, user_id: int, message_id: int, emoji: str, reactor_id: int):
"""Log when a reaction is removed from a message in DMs"""
try:
logs = self._load_user_logs(user_id)
# Find the message to remove the reaction from
for message in logs["conversations"]:
if message.get("message_id") == message_id:
if "reactions" in message:
# Find and remove the specific reaction
original_count = len(message["reactions"])
message["reactions"] = [
r for r in message["reactions"]
if not (r["emoji"] == emoji and r["reactor_id"] == reactor_id)
]
if len(message["reactions"]) < original_count:
self._save_user_logs(user_id, logs)
print(f" Reaction removed: {emoji} by user/bot {reactor_id} from message {message_id}")
return True
else:
print(f"⚠️ Reaction {emoji} by {reactor_id} not found on message {message_id}")
return False
else:
print(f"⚠️ No reactions on message {message_id}")
return False
print(f"⚠️ Message {message_id} not found in user {user_id}'s logs")
return False
except Exception as e:
print(f"❌ Failed to log reaction remove for user {user_id}, message {message_id}: {e}")
return False
async def delete_conversation(self, user_id: int, conversation_id: str) -> bool:
"""Delete a specific conversation/message from both Discord and logs (only bot messages can be deleted)"""
try:
logs = self._load_user_logs(user_id)
print(f"🔍 DM Logger: Looking for bot message ID '{conversation_id}' for user {user_id}")
print(f"🔍 DM Logger: Searching through {len(logs['conversations'])} conversations")
# Convert conversation_id to int for comparison if it looks like a Discord message ID
conv_id_as_int = None
try:
conv_id_as_int = int(conversation_id)
except ValueError:
pass
# Find the specific bot message to delete
message_to_delete = None
for conv in logs["conversations"]:
if (conv.get("is_bot_message", False) and
(str(conv.get("message_id", "")) == conversation_id or
conv.get("message_id", 0) == conv_id_as_int or
conv.get("timestamp", "") == conversation_id)):
message_to_delete = conv
break
if not message_to_delete:
print(f"⚠️ No bot message found with ID {conversation_id} for user {user_id}")
return False
# Try to delete from Discord first
discord_deleted = False
try:
import globals
if globals.client and hasattr(globals.client, 'get_user'):
# Get the user and their DM channel
user = globals.client.get_user(user_id)
if user:
dm_channel = user.dm_channel
if not dm_channel:
dm_channel = await user.create_dm()
# Fetch and delete the message
message_id = message_to_delete.get("message_id")
if message_id:
try:
discord_message = await dm_channel.fetch_message(int(message_id))
await discord_message.delete()
discord_deleted = True
print(f"✅ Deleted Discord message {message_id} from DM with user {user_id}")
except Exception as e:
print(f"⚠️ Could not delete Discord message {message_id}: {e}")
# Continue anyway to delete from logs
except Exception as e:
print(f"⚠️ Discord deletion failed: {e}")
# Continue anyway to delete from logs
# Remove from logs regardless of Discord deletion success
original_count = len(logs["conversations"])
logs["conversations"] = [conv for conv in logs["conversations"]
if not (
# Match by message_id (as int or string) AND it's a bot message
(conv.get("is_bot_message", False) and
(str(conv.get("message_id", "")) == conversation_id or
conv.get("message_id", 0) == conv_id_as_int or
conv.get("timestamp", "") == conversation_id))
)]
deleted_count = original_count - len(logs["conversations"])
if deleted_count > 0:
self._save_user_logs(user_id, logs)
if discord_deleted:
print(f"🗑️ Deleted bot message from both Discord and logs for user {user_id}")
else:
print(f"🗑️ Deleted bot message from logs only (Discord deletion failed) for user {user_id}")
return True
else:
print(f"⚠️ No bot message found in logs with ID {conversation_id} for user {user_id}")
return False
except Exception as e:
print(f"❌ Failed to delete conversation {conversation_id} for user {user_id}: {e}")
return False
async def delete_all_conversations(self, user_id: int) -> bool:
"""Delete all conversations with a user from both Discord and logs"""
try:
logs = self._load_user_logs(user_id)
conversation_count = len(logs["conversations"])
if conversation_count == 0:
print(f"⚠️ No conversations found for user {user_id}")
return False
# Find all bot messages to delete from Discord
bot_messages = [conv for conv in logs["conversations"] if conv.get("is_bot_message", False)]
print(f"🔍 Found {len(bot_messages)} bot messages to delete from Discord for user {user_id}")
# Try to delete all bot messages from Discord
discord_deleted_count = 0
try:
import globals
if globals.client and hasattr(globals.client, 'get_user'):
# Get the user and their DM channel
user = globals.client.get_user(user_id)
if user:
dm_channel = user.dm_channel
if not dm_channel:
dm_channel = await user.create_dm()
# Delete each bot message from Discord
for conv in bot_messages:
message_id = conv.get("message_id")
if message_id:
try:
discord_message = await dm_channel.fetch_message(int(message_id))
await discord_message.delete()
discord_deleted_count += 1
print(f"✅ Deleted Discord message {message_id} from DM with user {user_id}")
except Exception as e:
print(f"⚠️ Could not delete Discord message {message_id}: {e}")
# Continue with other messages
except Exception as e:
print(f"⚠️ Discord bulk deletion failed: {e}")
# Continue anyway to delete from logs
# Delete all conversations from logs regardless of Discord deletion success
logs["conversations"] = []
self._save_user_logs(user_id, logs)
if discord_deleted_count > 0:
print(f"🗑️ Deleted {discord_deleted_count} bot messages from Discord and all {conversation_count} conversations from logs for user {user_id}")
else:
print(f"🗑️ Deleted all {conversation_count} conversations from logs only (Discord deletion failed) for user {user_id}")
return True
except Exception as e:
print(f"❌ Failed to delete all conversations for user {user_id}: {e}")
return False
def delete_user_completely(self, user_id: int) -> bool:
"""Delete user's log file completely"""
try:
log_file = self._get_user_log_file(user_id)
if os.path.exists(log_file):
os.remove(log_file)
print(f"🗑️ Completely deleted log file for user {user_id}")
return True
else:
print(f"⚠️ No log file found for user {user_id}")
return False
except Exception as e:
print(f"❌ Failed to delete user log file {user_id}: {e}")
return False
# Global instance
dm_logger = DMLogger()

View File

@@ -0,0 +1,228 @@
# face_detector_manager.py
"""
Manages on-demand starting/stopping of anime-face-detector container
to free up VRAM when not needed.
"""
import asyncio
import aiohttp
import subprocess
import time
from typing import Optional, Dict
class FaceDetectorManager:
"""Manages the anime-face-detector container lifecycle"""
FACE_DETECTOR_API = "http://anime-face-detector:6078/detect"
HEALTH_ENDPOINT = "http://anime-face-detector:6078/health"
CONTAINER_NAME = "anime-face-detector"
STARTUP_TIMEOUT = 30 # seconds
def __init__(self):
self.is_running = False
async def start_container(self, debug: bool = False) -> bool:
"""
Start the anime-face-detector container.
Returns:
True if started successfully, False otherwise
"""
try:
if debug:
print("🚀 Starting anime-face-detector container...")
# Start container using docker compose
result = subprocess.run(
["docker", "compose", "up", "-d", self.CONTAINER_NAME],
cwd="/app", # Assumes we're in the bot container, adjust path as needed
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
if debug:
print(f"⚠️ Failed to start container: {result.stderr}")
return False
# Wait for API to be ready
start_time = time.time()
while time.time() - start_time < self.STARTUP_TIMEOUT:
if await self._check_health():
self.is_running = True
if debug:
print(f"✅ Face detector container started and ready")
return True
await asyncio.sleep(1)
if debug:
print(f"⚠️ Container started but API not ready after {self.STARTUP_TIMEOUT}s")
return False
except Exception as e:
if debug:
print(f"⚠️ Error starting face detector container: {e}")
return False
async def stop_container(self, debug: bool = False) -> bool:
"""
Stop the anime-face-detector container to free VRAM.
Returns:
True if stopped successfully, False otherwise
"""
try:
if debug:
print("🛑 Stopping anime-face-detector container...")
result = subprocess.run(
["docker", "compose", "stop", self.CONTAINER_NAME],
cwd="/app",
capture_output=True,
text=True,
timeout=15
)
if result.returncode == 0:
self.is_running = False
if debug:
print("✅ Face detector container stopped")
return True
else:
if debug:
print(f"⚠️ Failed to stop container: {result.stderr}")
return False
except Exception as e:
if debug:
print(f"⚠️ Error stopping face detector container: {e}")
return False
async def _check_health(self) -> bool:
"""Check if the face detector API is responding"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
self.HEALTH_ENDPOINT,
timeout=aiohttp.ClientTimeout(total=2)
) as response:
return response.status == 200
except:
return False
async def detect_face_with_management(
self,
image_bytes: bytes,
unload_vision_model: callable = None,
reload_vision_model: callable = None,
debug: bool = False
) -> Optional[Dict]:
"""
Detect face with automatic container lifecycle management.
Args:
image_bytes: Image data as bytes
unload_vision_model: Optional callback to unload vision model first
reload_vision_model: Optional callback to reload vision model after
debug: Enable debug output
Returns:
Detection dict or None
"""
container_was_started = False
try:
# Step 1: Unload vision model if callback provided
if unload_vision_model:
if debug:
print("📤 Unloading vision model to free VRAM...")
await unload_vision_model()
await asyncio.sleep(2) # Give time for VRAM to clear
# Step 2: Start face detector if not running
if not self.is_running:
if not await self.start_container(debug=debug):
if debug:
print("⚠️ Could not start face detector container")
return None
container_was_started = True
# Step 3: Detect face
result = await self._detect_face_api(image_bytes, debug=debug)
return result
finally:
# Step 4: Stop container and reload vision model
if container_was_started:
await self.stop_container(debug=debug)
if reload_vision_model:
if debug:
print("📥 Reloading vision model...")
await reload_vision_model()
async def _detect_face_api(self, image_bytes: bytes, debug: bool = False) -> Optional[Dict]:
"""Call the face detection API"""
try:
async with aiohttp.ClientSession() as session:
form = aiohttp.FormData()
form.add_field('file', image_bytes, filename='image.jpg', content_type='image/jpeg')
async with session.post(
self.FACE_DETECTOR_API,
data=form,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if response.status != 200:
if debug:
print(f"⚠️ Face detection API returned status {response.status}")
return None
result = await response.json()
if result.get('count', 0) == 0:
if debug:
print("👤 No faces detected by API")
return None
detections = result.get('detections', [])
if not detections:
return None
best_detection = max(detections, key=lambda d: d.get('confidence', 0))
bbox = best_detection.get('bbox', [])
confidence = best_detection.get('confidence', 0)
keypoints = best_detection.get('keypoints', [])
if len(bbox) >= 4:
x1, y1, x2, y2 = bbox[:4]
center_x = int((x1 + x2) / 2)
center_y = int((y1 + y2) / 2)
if debug:
width = int(x2 - x1)
height = int(y2 - y1)
print(f"👤 Detected {len(detections)} face(s) via API, using best at ({center_x}, {center_y}) [confidence: {confidence:.2%}]")
print(f" Bounding box: x={int(x1)}, y={int(y1)}, w={width}, h={height}")
print(f" Keypoints: {len(keypoints)} facial landmarks detected")
return {
'center': (center_x, center_y),
'bbox': bbox,
'confidence': confidence,
'keypoints': keypoints,
'count': len(detections)
}
except Exception as e:
if debug:
print(f"⚠️ Error calling face detection API: {e}")
return None
# Global instance
face_detector_manager = FaceDetectorManager()

View File

@@ -0,0 +1,396 @@
import os
import json
import random
from datetime import datetime
from typing import List, Dict, Any, Tuple
import discord
import globals
from utils.twitter_fetcher import fetch_figurine_tweets_latest
from utils.image_handling import analyze_image_with_qwen, download_and_encode_image
from utils.llm import query_ollama
from utils.dm_logger import dm_logger
def convert_to_fxtwitter(url: str) -> str:
"""Convert twitter.com or x.com URLs to fxtwitter.com for better Discord embeds"""
if "twitter.com" in url:
return url.replace("twitter.com", "fxtwitter.com")
elif "x.com" in url:
return url.replace("x.com", "fxtwitter.com")
return url
SUBSCRIBERS_FILE = "memory/figurine_subscribers.json"
SENT_TWEETS_FILE = "memory/figurine_sent_tweets.json"
def _ensure_dir(path: str) -> None:
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
def load_subscribers() -> List[int]:
try:
if os.path.exists(SUBSCRIBERS_FILE):
print(f"📁 Figurines: Loading subscribers from {SUBSCRIBERS_FILE}")
with open(SUBSCRIBERS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
subs = [int(uid) for uid in data.get("subscribers", [])]
print(f"📋 Figurines: Loaded {len(subs)} subscribers")
return subs
except Exception as e:
print(f"⚠️ Failed to load figurine subscribers: {e}")
return []
def save_subscribers(user_ids: List[int]) -> None:
try:
_ensure_dir(SUBSCRIBERS_FILE)
# Save as strings to be JS-safe in the API layer if needed
payload = {"subscribers": [str(uid) for uid in user_ids]}
print(f"💾 Figurines: Saving {len(user_ids)} subscribers to {SUBSCRIBERS_FILE}")
with open(SUBSCRIBERS_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
except Exception as e:
print(f"⚠️ Failed to save figurine subscribers: {e}")
def add_subscriber(user_id: int) -> bool:
print(f" Figurines: Adding subscriber {user_id}")
subscribers = load_subscribers()
if user_id in subscribers:
print(f" Figurines: Subscriber {user_id} already present")
return False
subscribers.append(user_id)
save_subscribers(subscribers)
print(f"✅ Figurines: Subscriber {user_id} added")
return True
def remove_subscriber(user_id: int) -> bool:
print(f"🗑️ Figurines: Removing subscriber {user_id}")
subscribers = load_subscribers()
if user_id not in subscribers:
print(f" Figurines: Subscriber {user_id} was not present")
return False
subscribers = [uid for uid in subscribers if uid != user_id]
save_subscribers(subscribers)
print(f"✅ Figurines: Subscriber {user_id} removed")
return True
def load_sent_tweets() -> List[str]:
try:
if os.path.exists(SENT_TWEETS_FILE):
print(f"📁 Figurines: Loading sent tweets from {SENT_TWEETS_FILE}")
with open(SENT_TWEETS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
urls = data.get("urls", [])
print(f"📋 Figurines: Loaded {len(urls)} sent tweet URLs")
return urls
except Exception as e:
print(f"⚠️ Failed to load figurine sent tweets: {e}")
return []
def save_sent_tweets(urls: List[str]) -> None:
try:
_ensure_dir(SENT_TWEETS_FILE)
print(f"💾 Figurines: Saving {len(urls)} sent tweet URLs to {SENT_TWEETS_FILE}")
with open(SENT_TWEETS_FILE, "w", encoding="utf-8") as f:
json.dump({"urls": urls}, f, indent=2)
except Exception as e:
print(f"⚠️ Failed to save figurine sent tweets: {e}")
async def choose_random_figurine_tweet() -> Dict[str, Any] | None:
"""Fetch figurine tweets from multiple sources, filter out sent, and pick one randomly."""
print("🔎 Figurines: Fetching figurine tweets by Latest across sources…")
tweets = await fetch_figurine_tweets_latest(limit_per_source=10)
if not tweets:
print("📭 No figurine tweets found across sources")
return None
sent_urls = set(load_sent_tweets())
fresh = [t for t in tweets if t.get("url") not in sent_urls]
print(f"🧮 Figurines: {len(tweets)} total, {len(fresh)} fresh after filtering sent")
if not fresh:
print(" All figurine tweets have been sent before; allowing reuse")
fresh = tweets
chosen = random.choice(fresh)
print(f"🎯 Chosen figurine tweet: {chosen.get('url')}")
return chosen
async def send_figurine_dm_to_user(client: discord.Client, user_id: int, tweet: Dict[str, Any]) -> Tuple[bool, str]:
"""Send the figurine tweet to a single subscriber via DM, with analysis and LLM commentary."""
try:
print(f"✉️ Figurines: Preparing DM to user {user_id}")
user = client.get_user(user_id)
if user is None:
# Try fetching
user = await client.fetch_user(user_id)
if user is None:
return False, f"User {user_id} not found"
# Build base prompt with figurine/merch context
base_prompt = (
"You are Hatsune Miku writing a short, cute, excited DM to a fan about a newly posted "
"figurine or merch announcement tweet. Be friendly and enthusiastic but concise. "
"Reference what the tweet shows."
)
# Analyze the first image if available
if tweet.get("media"):
first_url = tweet["media"][0]
base64_img = await download_and_encode_image(first_url)
if base64_img:
try:
img_desc = await analyze_image_with_qwen(base64_img)
base_prompt += f"\n\nImage looks like: {img_desc}"
except Exception as e:
print(f"⚠️ Image analysis failed: {e}")
# Include tweet text too
tweet_text = tweet.get("text", "").strip()
if tweet_text:
base_prompt += f"\n\nTweet text: {tweet_text}"
base_prompt += "\n\nSign off as Miku with a cute emoji."
# Query LLM in DM context (no guild_id -> DM mood rules apply)
miku_comment = await query_ollama(base_prompt, user_id=f"figurine_dm_{user_id}", guild_id=None, response_type="dm_response")
dm = await user.create_dm()
tweet_url = tweet.get("url", "")
# Send the tweet URL first (convert to fxtwitter for better embeds)
fx_tweet_url = convert_to_fxtwitter(tweet_url)
tweet_message = await dm.send(fx_tweet_url)
print(f"✅ Figurines: Tweet URL sent to {user_id}: {fx_tweet_url}")
# Log the tweet URL message
dm_logger.log_user_message(user, tweet_message, is_bot_message=True)
# Send Miku's comment
comment_message = await dm.send(miku_comment)
print(f"✅ Figurines: Miku comment sent to {user_id}")
# Log the comment message
dm_logger.log_user_message(user, comment_message, is_bot_message=True)
# IMPORTANT: Also add to globals.conversation_history for LLM context
user_id_str = str(user_id)
# Add the tweet URL as a "system message" about what Miku just sent (use original URL for context)
tweet_context = f"[I just sent you this figurine tweet: {tweet_url}]"
# Add the figurine comment to conversation history
# Use empty user prompt since this was initiated by Miku
globals.conversation_history.setdefault(user_id_str, []).append((tweet_context, miku_comment))
print(f"📝 Figurines: Messages logged to both DM history and conversation context for user {user_id}")
return True, "ok"
except Exception as e:
print(f"❌ Figurines: Failed DM to {user_id}: {e}")
return False, f"{e}"
async def send_figurine_dm_to_single_user(client: discord.Client, user_id: int, tweet_url: str = None) -> Dict[str, Any]:
"""Send a figurine tweet to a single user, either from search or specific URL."""
print(f"🎯 Figurines: Sending DM to single user {user_id}")
if tweet_url:
# Use specific tweet URL
print(f"📎 Figurines: Using specific tweet URL: {tweet_url}")
tweet = await fetch_specific_tweet_by_url(tweet_url)
if not tweet:
return {"status": "error", "message": "Failed to fetch specified tweet"}
else:
# Search for a random tweet
print("🔎 Figurines: Searching for random figurine tweet")
tweet = await choose_random_figurine_tweet()
if not tweet:
return {"status": "error", "message": "No figurine tweets found"}
# Send to the single user
ok, msg = await send_figurine_dm_to_user(client, user_id, tweet)
if ok:
# Record as sent if successful
sent_urls = load_sent_tweets()
url = tweet.get("url")
if url and url not in sent_urls:
sent_urls.append(url)
if len(sent_urls) > 200:
sent_urls = sent_urls[-200:]
save_sent_tweets(sent_urls)
result = {
"status": "ok",
"sent": [str(user_id)],
"failed": [],
"tweet": {"url": tweet.get("url", ""), "username": tweet.get("username", "")}
}
print(f"✅ Figurines: Single user DM sent successfully → {result}")
return result
else:
result = {
"status": "error",
"sent": [],
"failed": [{"user_id": str(user_id), "error": msg}],
"message": f"Failed to send DM: {msg}"
}
print(f"❌ Figurines: Single user DM failed → {result}")
return result
async def fetch_specific_tweet_by_url(tweet_url: str) -> Dict[str, Any] | None:
"""Fetch a specific tweet by URL for manual figurine notifications."""
try:
print(f"🔗 Figurines: Fetching specific tweet from URL: {tweet_url}")
# Extract tweet ID from URL
tweet_id = None
if "/status/" in tweet_url:
try:
tweet_id = tweet_url.split("/status/")[1].split("?")[0].split("/")[0]
print(f"📋 Figurines: Extracted tweet ID: {tweet_id}")
except Exception as e:
print(f"❌ Figurines: Failed to extract tweet ID from URL: {e}")
return None
if not tweet_id:
print("❌ Figurines: Could not extract tweet ID from URL")
return None
# Set up twscrape API (same pattern as existing functions)
from twscrape import API
from pathlib import Path
import json
COOKIE_PATH = Path(__file__).parent / "x.com.cookies.json"
# Load cookies
with open(COOKIE_PATH, "r", encoding="utf-8") as f:
cookie_list = json.load(f)
cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookie_list)
# Set up API
api = API()
await api.pool.add_account(
username="HSankyuu39",
password="x",
email="x",
email_password="x",
cookies=cookie_header
)
await api.pool.login_all()
# Try to fetch the tweet using search instead of tweet_details
# Search for the specific tweet ID should return it if accessible
print(f"🔍 Figurines: Searching for tweet with ID {tweet_id}")
search_results = []
try:
# Search using the tweet ID - this should find the specific tweet
from twscrape import gather
search_results = await gather(api.search(f"{tweet_id}", limit=1))
print(f"🔍 Figurines: Search returned {len(search_results)} results")
except Exception as search_error:
print(f"⚠️ Figurines: Search failed: {search_error}")
return None
# Check if we found the tweet
tweet_data = None
for tweet in search_results:
if str(tweet.id) == str(tweet_id):
tweet_data = tweet
print(f"✅ Figurines: Found matching tweet with ID {tweet.id}")
break
if not tweet_data and search_results:
# If no exact match but we have results, use the first one
tweet_data = search_results[0]
print(f"🔍 Figurines: Using first search result with ID {tweet_data.id}")
if tweet_data:
# Extract data using the same pattern as the working search code
username = tweet_data.user.username if hasattr(tweet_data, 'user') and tweet_data.user else "unknown"
text_content = tweet_data.rawContent if hasattr(tweet_data, 'rawContent') else ""
print(f"🔍 Figurines: Found tweet from @{username}")
print(f"🔍 Figurines: Tweet text: {text_content[:100]}...")
# For media, we'll need to extract it from the tweet_url using the same method as other functions
# But for now, let's see if we can get basic tweet data working first
result = {
"text": text_content,
"username": username,
"url": tweet_url,
"media": [] # We'll add media extraction later
}
print(f"✅ Figurines: Successfully fetched tweet from @{result['username']}")
return result
else:
print("❌ Figurines: No tweet found with the specified ID")
return None
except Exception as e:
print(f"❌ Figurines: Error fetching tweet by URL: {e}")
return None
async def send_figurine_dm_to_all_subscribers(client: discord.Client, tweet_url: str = None) -> Dict[str, Any]:
"""Pick a figurine tweet and DM it to all subscribers, recording the sent URL."""
print("🚀 Figurines: Sending figurine DM to all subscribers…")
subscribers = load_subscribers()
if not subscribers:
print(" Figurines: No subscribers configured")
return {"status": "no_subscribers"}
if tweet_url:
# Use specific tweet URL
print(f"📎 Figurines: Using specific tweet URL for all subscribers: {tweet_url}")
tweet = await fetch_specific_tweet_by_url(tweet_url)
if not tweet:
print(" Figurines: Failed to fetch specified tweet")
return {"status": "no_tweet", "message": "Failed to fetch specified tweet"}
else:
# Search for random tweet
tweet = await choose_random_figurine_tweet()
if tweet is None:
print(" Figurines: No tweet to send")
return {"status": "no_tweet"}
results = {"sent": [], "failed": []}
for uid in subscribers:
ok, msg = await send_figurine_dm_to_user(client, uid, tweet)
if ok:
results["sent"].append(str(uid))
else:
print(f"⚠️ Failed to DM user {uid}: {msg}")
results["failed"].append({"user_id": str(uid), "error": msg})
# Record as sent if at least one success to avoid repeats
sent_urls = load_sent_tweets()
url = tweet.get("url")
if url and url not in sent_urls:
sent_urls.append(url)
# keep file from growing unbounded
if len(sent_urls) > 200:
sent_urls = sent_urls[-200:]
save_sent_tweets(sent_urls)
summary = {"status": "ok", **results, "tweet": {"url": tweet.get("url", ""), "username": tweet.get("username", "")}}
print(f"📦 Figurines: DM send complete → {summary}")
return summary

View File

@@ -0,0 +1,402 @@
"""
Image Generation System for Miku Bot
Natural language detection and ComfyUI integration
"""
import aiohttp
import asyncio
import glob
import json
import os
import re
import tempfile
import time
from typing import Optional, Tuple
import globals
from utils.llm import query_ollama
# Image generation detection patterns
IMAGE_REQUEST_PATTERNS = [
# Direct requests
r'\b(?:draw|generate|create|make|show me|paint|sketch|illustrate)\b.*\b(?:image|picture|art|artwork|drawing|painting|illustration)\b',
r'\b(?:i\s+(?:want|would like|need)\s+(?:to see|an?\s+)?(?:image|picture|art|artwork|drawing|painting|illustration))\b',
r'\b(?:can you|could you|please)\s+(?:draw|generate|create|make|show me|paint|sketch|illustrate)\b',
r'\b(?:image|picture|art|artwork|drawing|painting|illustration)\s+of\b',
# Visual requests about Miku
r'\b(?:show me|let me see)\s+(?:you|miku|yourself)\b',
r'\b(?:what do you look like|how do you look)\b',
r'\b(?:i\s+(?:want|would like)\s+to see)\s+(?:you|miku|yourself)\b',
r'\bsee\s+(?:you|miku|yourself)(?:\s+(?:in|with|doing|wearing))?\b',
# Activity-based visual requests
r'\b(?:you|miku|yourself)\s+(?:swimming|dancing|singing|playing|wearing|in|with|doing)\b.*\b(?:pool|water|stage|outfit|clothes|dress)\b',
r'\b(?:visualize|envision|imagine)\s+(?:you|miku|yourself)\b',
# Artistic requests
r'\b(?:artistic|art|visual)\s+(?:representation|depiction|version)\s+of\s+(?:you|miku|yourself)\b',
]
# Compile patterns for efficiency
COMPILED_PATTERNS = [re.compile(pattern, re.IGNORECASE) for pattern in IMAGE_REQUEST_PATTERNS]
async def detect_image_request(message_content: str) -> Tuple[bool, Optional[str]]:
"""
Detect if a message is requesting image generation using natural language.
Returns:
Tuple[bool, Optional[str]]: (is_image_request, extracted_prompt)
"""
content = message_content.lower().strip()
# Quick rejection for very short messages
if len(content) < 5:
return False, None
# Check against patterns
for pattern in COMPILED_PATTERNS:
if pattern.search(content):
# Extract the prompt by cleaning up the message
prompt = extract_image_prompt(message_content)
return True, prompt
return False, None
def extract_image_prompt(message_content: str) -> str:
"""
Extract and clean the image prompt from the user's message.
Convert natural language to a proper image generation prompt.
"""
content = message_content.strip()
# Remove common prefixes that don't help with image generation
prefixes_to_remove = [
r'^(?:hey\s+)?miku,?\s*',
r'^(?:can you|could you|please|would you)\s*',
r'^(?:i\s+(?:want|would like|need)\s+(?:to see|you to|an?)?)\s*',
r'^(?:show me|let me see)\s*',
r'^(?:draw|generate|create|make|paint|sketch|illustrate)\s*(?:me\s*)?(?:an?\s*)?(?:image|picture|art|artwork|drawing|painting|illustration)?\s*(?:of\s*)?',
]
cleaned = content
for prefix in prefixes_to_remove:
cleaned = re.sub(prefix, '', cleaned, flags=re.IGNORECASE).strip()
# If the cleaned prompt is too short or generic, enhance it
if len(cleaned) < 10 or cleaned.lower() in ['you', 'yourself', 'miku']:
cleaned = "Hatsune Miku"
# Ensure Miku is mentioned if the user said "you" or "yourself"
if re.search(r'\b(?:you|yourself)\b', content, re.IGNORECASE) and not re.search(r'\bmiku\b', cleaned, re.IGNORECASE):
# Replace "you" with "Hatsune Miku" instead of just prepending
cleaned = re.sub(r'\byou\b', 'Hatsune Miku', cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r'\byourself\b', 'Hatsune Miku', cleaned, flags=re.IGNORECASE)
return cleaned
def find_latest_generated_image(prompt_id: str, expected_filename: str = None) -> Optional[str]:
"""
Find the most recently generated image in the ComfyUI output directory.
This handles cases where the exact filename from API doesn't match the file system.
"""
output_dirs = [
"ComfyUI/output",
"/app/ComfyUI/output"
]
for output_dir in output_dirs:
if not os.path.exists(output_dir):
continue
try:
# Get all image files in the directory
image_extensions = ['.png', '.jpg', '.jpeg', '.webp']
all_files = []
for ext in image_extensions:
pattern = os.path.join(output_dir, f"*{ext}")
all_files.extend(glob.glob(pattern))
if not all_files:
continue
# Sort by modification time (most recent first)
all_files.sort(key=os.path.getmtime, reverse=True)
# If we have an expected filename, try to find it first
if expected_filename:
for file_path in all_files:
if os.path.basename(file_path) == expected_filename:
return file_path
# Otherwise, return the most recent image (within last 10 minutes)
recent_threshold = time.time() - 600 # 10 minutes
for file_path in all_files:
if os.path.getmtime(file_path) > recent_threshold:
print(f"🎨 Found recent image: {file_path}")
return file_path
except Exception as e:
print(f"⚠️ Error searching in {output_dir}: {e}")
continue
return None
async def generate_image_with_comfyui(prompt: str) -> Optional[str]:
"""
Generate an image using ComfyUI with the provided prompt.
Args:
prompt: The image generation prompt
Returns:
Optional[str]: Path to the generated image file, or None if failed
"""
try:
# Load the workflow template
workflow_path = "Miku_BasicWorkflow.json"
if not os.path.exists(workflow_path):
print(f"❌ Workflow template not found: {workflow_path}")
return None
with open(workflow_path, 'r') as f:
workflow_data = json.load(f)
# Replace the prompt placeholder
workflow_json = json.dumps(workflow_data)
workflow_json = workflow_json.replace("_POSITIVEPROMPT_", prompt)
workflow_data = json.loads(workflow_json)
# Prepare the request payload
payload = {"prompt": workflow_data}
# Send request to ComfyUI (try different Docker networking options)
comfyui_urls = [
"http://host.docker.internal:8188", # Docker Desktop
"http://172.17.0.1:8188", # Default Docker bridge gateway
"http://localhost:8188" # Fallback (if network_mode: host)
]
# Try each URL until one works
comfyui_url = None
for url in comfyui_urls:
try:
async with aiohttp.ClientSession() as test_session:
timeout = aiohttp.ClientTimeout(total=2)
async with test_session.get(f"{url}/system_stats", timeout=timeout) as test_response:
if test_response.status == 200:
comfyui_url = url
print(f"✅ ComfyUI found at: {url}")
break
except:
continue
if not comfyui_url:
print(f"❌ ComfyUI not reachable at any of: {comfyui_urls}")
return None
async with aiohttp.ClientSession() as session:
# Submit the generation request
async with session.post(f"{comfyui_url}/prompt", json=payload) as response:
if response.status != 200:
print(f"❌ ComfyUI request failed: {response.status}")
return None
result = await response.json()
prompt_id = result.get("prompt_id")
if not prompt_id:
print("❌ No prompt_id received from ComfyUI")
return None
print(f"🎨 ComfyUI generation started with prompt_id: {prompt_id}")
# Poll for completion (timeout after 5 minutes)
timeout = 300 # 5 minutes
start_time = time.time()
while time.time() - start_time < timeout:
# Check if generation is complete
async with session.get(f"{comfyui_url}/history/{prompt_id}") as hist_response:
if hist_response.status == 200:
history = await hist_response.json()
if prompt_id in history:
# Generation complete, find the output image
outputs = history[prompt_id].get("outputs", {})
# Look for image outputs (usually in nodes with "images" key)
for node_id, node_output in outputs.items():
if "images" in node_output:
images = node_output["images"]
if images:
# Get the first image
image_info = images[0]
filename = image_info["filename"]
subfolder = image_info.get("subfolder", "")
# Construct the full path (adjust for Docker mount)
if subfolder:
image_path = os.path.join("ComfyUI", "output", subfolder, filename)
else:
image_path = os.path.join("ComfyUI", "output", filename)
# Verify the file exists before returning
if os.path.exists(image_path):
print(f"✅ Image generated successfully: {image_path}")
return image_path
else:
# Try alternative paths in case of different mounting
alt_path = os.path.join("/app/ComfyUI/output", filename)
if os.path.exists(alt_path):
print(f"✅ Image generated successfully: {alt_path}")
return alt_path
else:
print(f"⚠️ Generated image not found at expected paths: {image_path} or {alt_path}")
continue
# If we couldn't find the image via API, try the fallback method
print("🔍 Image not found via API, trying fallback method...")
fallback_image = find_latest_generated_image(prompt_id)
if fallback_image:
return fallback_image
# Wait before polling again
await asyncio.sleep(2)
print("❌ ComfyUI generation timed out")
# Final fallback: look for the most recent image
print("🔍 Trying final fallback: most recent image...")
fallback_image = find_latest_generated_image(prompt_id)
if fallback_image:
print(f"✅ Found image via fallback method: {fallback_image}")
return fallback_image
return None
except Exception as e:
print(f"❌ Error in generate_image_with_comfyui: {e}")
return None
async def handle_image_generation_request(message, prompt: str) -> bool:
"""
Handle the complete image generation workflow for a user request.
Args:
message: Discord message object
prompt: Extracted image prompt
Returns:
bool: True if image was successfully generated and sent
"""
try:
# Generate a contextual response about what we're creating
is_dm = message.guild is None
guild_id = message.guild.id if message.guild else None
user_id = str(message.author.id)
# Create a response about starting image generation
response_prompt = f"A user asked you to create an image with this description: '{prompt}'. Respond enthusiastically that you're creating this image for them. Keep it short and excited!"
response_type = "dm_response" if is_dm else "server_response"
initial_response = await query_ollama(response_prompt, user_id=user_id, guild_id=guild_id, response_type=response_type)
# Send initial response
initial_msg = await message.channel.send(initial_response)
# Start typing to show we're working
async with message.channel.typing():
# Generate the image
print(f"🎨 Starting image generation for prompt: {prompt}")
image_path = await generate_image_with_comfyui(prompt)
if image_path and os.path.exists(image_path):
# Send the image
import discord
with open(image_path, 'rb') as f:
file = discord.File(f, filename=f"miku_generated_{int(time.time())}.png")
# Create a follow-up message about the completed image
completion_prompt = f"You just finished creating an image based on '{prompt}'. Make a short, excited comment about the completed artwork!"
completion_response = await query_ollama(completion_prompt, user_id=user_id, guild_id=guild_id, response_type=response_type)
await message.channel.send(completion_response, file=file)
print(f"✅ Image sent successfully to {message.author.display_name}")
# Log to DM history if it's a DM
if is_dm:
from utils.dm_logger import dm_logger
dm_logger.log_conversation(user_id, message.content, f"{initial_response}\n[Generated image: {prompt}]", attachments=["generated_image.png"])
return True
else:
# Image generation failed
error_prompt = "You tried to create an image but something went wrong with the generation process. Apologize briefly and suggest they try again later."
error_response = await query_ollama(error_prompt, user_id=user_id, guild_id=guild_id, response_type=response_type)
await message.channel.send(error_response)
print(f"❌ Image generation failed for prompt: {prompt}")
return False
except Exception as e:
print(f"❌ Error in handle_image_generation_request: {e}")
# Send error message
try:
await message.channel.send("Sorry, I had trouble creating that image. Please try again later!")
except:
pass
return False
async def check_comfyui_status() -> dict:
"""
Check the status of ComfyUI and the workflow template.
Returns:
dict: Status information
"""
try:
import aiohttp
# Check if ComfyUI workflow template exists
workflow_exists = os.path.exists("Miku_BasicWorkflow.json")
# Check if ComfyUI is running (try different Docker networking options)
comfyui_running = False
comfyui_url = "http://host.docker.internal:8188" # Default
comfyui_urls = [
"http://host.docker.internal:8188", # Docker Desktop
"http://172.17.0.1:8188", # Default Docker bridge gateway
"http://localhost:8188" # Fallback (if network_mode: host)
]
for url in comfyui_urls:
try:
async with aiohttp.ClientSession() as session:
timeout = aiohttp.ClientTimeout(total=3)
async with session.get(f"{url}/system_stats", timeout=timeout) as response:
if response.status == 200:
comfyui_running = True
comfyui_url = url
break
except:
continue
return {
"workflow_template_exists": workflow_exists,
"comfyui_running": comfyui_running,
"comfyui_url": comfyui_url,
"ready": workflow_exists and comfyui_running
}
except Exception as e:
return {
"workflow_template_exists": False,
"comfyui_running": False,
"comfyui_url": "http://localhost:8188",
"ready": False,
"error": str(e)
}

442
bot/utils/image_handling.py Normal file
View File

@@ -0,0 +1,442 @@
# utils/image_handling.py
import aiohttp
import base64
import io
import tempfile
import os
import subprocess
from PIL import Image
import re
import globals
# No need for switch_model anymore - llama-swap handles this automatically
async def download_and_encode_image(url):
"""Download and encode an image to base64."""
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status != 200:
return None
img_bytes = await resp.read()
return base64.b64encode(img_bytes).decode('utf-8')
async def download_and_encode_media(url):
"""Download and encode any media file (image, video, GIF) to base64."""
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status != 200:
return None
media_bytes = await resp.read()
return base64.b64encode(media_bytes).decode('utf-8')
async def extract_tenor_gif_url(tenor_url):
"""
Extract the actual GIF URL from a Tenor link.
Tenor URLs look like: https://tenor.com/view/...
We need to get the actual GIF file URL from the page or API.
"""
try:
# Try to extract GIF ID from URL
# Tenor URLs: https://tenor.com/view/name-name-12345678 or https://tenor.com/12345678.gif
match = re.search(r'tenor\.com/view/[^/]+-(\d+)', tenor_url)
if not match:
match = re.search(r'tenor\.com/(\d+)\.gif', tenor_url)
if not match:
print(f"⚠️ Could not extract Tenor GIF ID from: {tenor_url}")
return None
gif_id = match.group(1)
# Tenor's direct media URL format (this works without API key)
# Try the media CDN URL directly
media_url = f"https://media.tenor.com/images/{gif_id}/tenor.gif"
# Verify the URL works
async with aiohttp.ClientSession() as session:
async with session.head(media_url) as resp:
if resp.status == 200:
print(f"✅ Found Tenor GIF: {media_url}")
return media_url
# If that didn't work, try alternative formats
for fmt in ['tenor.gif', 'raw']:
alt_url = f"https://media.tenor.com/{gif_id}/{fmt}"
async with aiohttp.ClientSession() as session:
async with session.head(alt_url) as resp:
if resp.status == 200:
print(f"✅ Found Tenor GIF (alternative): {alt_url}")
return alt_url
print(f"⚠️ Could not find working Tenor media URL for ID: {gif_id}")
return None
except Exception as e:
print(f"⚠️ Error extracting Tenor GIF URL: {e}")
return None
async def convert_gif_to_mp4(gif_bytes):
"""
Convert a GIF to MP4 using ffmpeg for better compatibility with video processing.
Returns the MP4 bytes.
"""
try:
# Write GIF to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.gif') as temp_gif:
temp_gif.write(gif_bytes)
temp_gif_path = temp_gif.name
# Output MP4 path
temp_mp4_path = temp_gif_path.replace('.gif', '.mp4')
try:
# Convert GIF to MP4 with ffmpeg
# -movflags faststart makes it streamable
# -pix_fmt yuv420p ensures compatibility
# -vf scale makes sure dimensions are even (required for yuv420p)
ffmpeg_cmd = [
'ffmpeg', '-i', temp_gif_path,
'-movflags', 'faststart',
'-pix_fmt', 'yuv420p',
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
'-y',
temp_mp4_path
]
result = subprocess.run(ffmpeg_cmd, capture_output=True, check=True)
# Read the MP4 file
with open(temp_mp4_path, 'rb') as f:
mp4_bytes = f.read()
print(f"✅ Converted GIF to MP4 ({len(gif_bytes)} bytes → {len(mp4_bytes)} bytes)")
return mp4_bytes
finally:
# Clean up temp files
if os.path.exists(temp_gif_path):
os.remove(temp_gif_path)
if os.path.exists(temp_mp4_path):
os.remove(temp_mp4_path)
except subprocess.CalledProcessError as e:
print(f"⚠️ ffmpeg error converting GIF to MP4: {e.stderr.decode()}")
return None
except Exception as e:
print(f"⚠️ Error converting GIF to MP4: {e}")
import traceback
traceback.print_exc()
return None
async def extract_video_frames(video_bytes, num_frames=4):
"""
Extract frames from a video or GIF for analysis.
Returns a list of base64-encoded frames.
"""
try:
# Try GIF first with PIL
try:
gif = Image.open(io.BytesIO(video_bytes))
if hasattr(gif, 'n_frames'):
frames = []
# Calculate step to get evenly distributed frames
total_frames = gif.n_frames
step = max(1, total_frames // num_frames)
for i in range(0, total_frames, step):
if len(frames) >= num_frames:
break
gif.seek(i)
frame = gif.convert('RGB')
# Convert to base64
buffer = io.BytesIO()
frame.save(buffer, format='JPEG')
frame_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
frames.append(frame_b64)
if frames:
return frames
except Exception as e:
print(f"Not a GIF, trying video extraction: {e}")
# For video files (MP4, WebM, etc.), use ffmpeg
import subprocess
import asyncio
# Write video bytes to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as temp_video:
temp_video.write(video_bytes)
temp_video_path = temp_video.name
try:
# Get video duration first
probe_cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
temp_video_path
]
result = subprocess.run(probe_cmd, capture_output=True, text=True)
duration = float(result.stdout.strip())
# Calculate timestamps for evenly distributed frames
timestamps = [duration * i / num_frames for i in range(num_frames)]
frames = []
for i, timestamp in enumerate(timestamps):
# Extract frame at timestamp
output_path = f"/tmp/frame_{i}.jpg"
ffmpeg_cmd = [
'ffmpeg', '-ss', str(timestamp),
'-i', temp_video_path,
'-vframes', '1',
'-q:v', '2',
'-y',
output_path
]
subprocess.run(ffmpeg_cmd, capture_output=True, check=True)
# Read and encode the frame
with open(output_path, 'rb') as f:
frame_bytes = f.read()
frame_b64 = base64.b64encode(frame_bytes).decode('utf-8')
frames.append(frame_b64)
# Clean up frame file
os.remove(output_path)
return frames
finally:
# Clean up temp video file
os.remove(temp_video_path)
except Exception as e:
print(f"⚠️ Error extracting frames: {e}")
import traceback
traceback.print_exc()
return None
async def analyze_image_with_vision(base64_img):
"""
Analyze an image using llama.cpp multimodal capabilities.
Uses OpenAI-compatible chat completions API with image_url.
"""
payload = {
"model": globals.VISION_MODEL,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Describe this image in detail."
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_img}"
}
}
]
}
],
"stream": False,
"max_tokens": 300
}
headers = {"Content-Type": "application/json"}
async with aiohttp.ClientSession() as session:
try:
async with session.post(f"{globals.LLAMA_URL}/v1/chat/completions", json=payload, headers=headers) as response:
if response.status == 200:
data = await response.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "No description.")
else:
error_text = await response.text()
print(f"❌ Vision API error: {response.status} - {error_text}")
return f"Error analyzing image: {response.status}"
except Exception as e:
print(f"⚠️ Error in analyze_image_with_vision: {e}")
return f"Error analyzing image: {str(e)}"
async def analyze_video_with_vision(video_frames, media_type="video"):
"""
Analyze a video or GIF by analyzing multiple frames.
video_frames: list of base64-encoded frames
media_type: "video", "gif", or "tenor_gif" to customize the analysis prompt
"""
# Customize prompt based on media type
if media_type == "gif":
prompt_text = "Describe what's happening in this GIF animation. Analyze the sequence of frames and describe the action, motion, and any repeating patterns."
elif media_type == "tenor_gif":
prompt_text = "Describe what's happening in this animated GIF. Analyze the sequence of frames and describe the action, emotion, or reaction being shown."
else: # video
prompt_text = "Describe what's happening in this video. Analyze the sequence of frames and describe the action or motion."
# Build content with multiple images
content = [
{
"type": "text",
"text": prompt_text
}
]
# Add each frame as an image
for frame in video_frames:
content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{frame}"
}
})
payload = {
"model": globals.VISION_MODEL,
"messages": [
{
"role": "user",
"content": content
}
],
"stream": False,
"max_tokens": 400
}
headers = {"Content-Type": "application/json"}
async with aiohttp.ClientSession() as session:
try:
async with session.post(f"{globals.LLAMA_URL}/v1/chat/completions", json=payload, headers=headers) as response:
if response.status == 200:
data = await response.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "No description.")
else:
error_text = await response.text()
print(f"❌ Vision API error: {response.status} - {error_text}")
return f"Error analyzing video: {response.status}"
except Exception as e:
print(f"⚠️ Error in analyze_video_with_vision: {e}")
return f"Error analyzing video: {str(e)}"
async def rephrase_as_miku(vision_output, user_prompt, guild_id=None, user_id=None, author_name=None, media_type="image"):
"""
Rephrase vision model's image analysis as Miku would respond to it.
Args:
vision_output: Description from vision model
user_prompt: User's original message
guild_id: Guild ID for server context (None for DMs)
user_id: User ID for conversation history
author_name: Display name of the user
media_type: Type of media ("image", "video", "gif", or "tenor_gif")
"""
from utils.llm import query_llama
# Format the user's message to include vision context with media type
# This will be saved to history automatically by query_llama
if media_type == "gif":
media_prefix = "Looking at a GIF"
elif media_type == "tenor_gif":
media_prefix = "Looking at a Tenor GIF"
elif media_type == "video":
media_prefix = "Looking at a video"
else: # image
media_prefix = "Looking at an image"
if user_prompt:
# Include media type, vision description, and user's text
formatted_prompt = f"[{media_prefix}: {vision_output}] {user_prompt}"
else:
# If no text, just the vision description with media type
formatted_prompt = f"[{media_prefix}: {vision_output}]"
# Use the standard LLM query with appropriate response type
response_type = "dm_response" if guild_id is None else "server_response"
# Use the actual user_id for history tracking, fall back to "image_analysis" for backward compatibility
history_user_id = user_id if user_id else "image_analysis"
return await query_llama(
formatted_prompt,
user_id=history_user_id,
guild_id=guild_id,
response_type=response_type,
author_name=author_name,
media_type=media_type # Pass media type to Miku's LLM
)
# Backward compatibility aliases
analyze_image_with_qwen = analyze_image_with_vision
async def extract_embed_content(embed):
"""
Extract text and media content from a Discord embed.
Returns a dictionary with:
- 'text': combined text from title, description, fields
- 'images': list of image URLs
- 'videos': list of video URLs
- 'has_content': boolean indicating if there's any content
"""
content = {
'text': '',
'images': [],
'videos': [],
'has_content': False
}
text_parts = []
# Extract text content
if embed.title:
text_parts.append(f"**{embed.title}**")
if embed.description:
text_parts.append(embed.description)
if embed.author and embed.author.name:
text_parts.append(f"Author: {embed.author.name}")
if embed.fields:
for field in embed.fields:
text_parts.append(f"**{field.name}**: {field.value}")
if embed.footer and embed.footer.text:
text_parts.append(f"_{embed.footer.text}_")
# Combine text
content['text'] = '\n\n'.join(text_parts)
# Extract image URLs
if embed.image and embed.image.url:
content['images'].append(embed.image.url)
if embed.thumbnail and embed.thumbnail.url:
content['images'].append(embed.thumbnail.url)
# Extract video URLs
if embed.video and embed.video.url:
content['videos'].append(embed.video.url)
# Check if we have any content
content['has_content'] = bool(content['text'] or content['images'] or content['videos'])
return content

49
bot/utils/kindness.py Normal file
View File

@@ -0,0 +1,49 @@
# utils/kindness.py
import random
import globals
from utils.llm import query_ollama # Adjust path as needed
async def detect_and_react_to_kindness(message, after_reply=False, server_context=None):
if message.id in globals.kindness_reacted_messages:
return # Already reacted — skip
content = message.content.lower()
emoji = random.choice(globals.HEART_REACTIONS)
# 1. Keyword-based detection
if any(keyword in content for keyword in globals.KINDNESS_KEYWORDS):
try:
await message.add_reaction(emoji)
globals.kindness_reacted_messages.add(message.id)
message.kindness_reacted = True # Mark as done
print("✅ Kindness detected via keywords. Reacted immediately.")
except Exception as e:
print(f"⚠️ Error adding reaction: {e}")
return
# 2. If not after_reply, defer model-based check
if not after_reply:
print("🗝️ No kindness via keywords. Deferring...")
return
# 3. Model-based detection
try:
prompt = (
"The following message was sent to Miku the bot. "
"Does it sound like the user is being explicitly kind or affectionate toward Miku? "
"Answer with 'yes' or 'no' only.\n\n"
f"Message: \"{message.content}\""
)
result = await query_ollama(prompt, user_id="kindness-check", guild_id=None, response_type="dm_response")
if result.strip().lower().startswith("yes"):
await message.add_reaction(emoji)
globals.kindness_reacted_messages.add(message.id)
print("✅ Kindness detected via model. Reacted.")
else:
print("🧊 No kindness detected.")
except Exception as e:
print(f"⚠️ Error during kindness analysis: {e}")

232
bot/utils/llm.py Normal file
View File

@@ -0,0 +1,232 @@
# utils/llm.py
import aiohttp
import datetime
import globals
import asyncio
from utils.context_manager import get_context_for_response_type, get_complete_context
from utils.moods import load_mood_description
from utils.conversation_history import conversation_history
def _strip_surrounding_quotes(text):
"""
Remove surrounding quotes from text if present.
Handles both single and double quotes.
"""
if not text:
return text
text = text.strip()
# Check for surrounding double quotes
if text.startswith('"') and text.endswith('"') and len(text) > 1:
text = text[1:-1]
# Check for surrounding single quotes
elif text.startswith("'") and text.endswith("'") and len(text) > 1:
text = text[1:-1]
return text.strip()
async def query_llama(user_prompt, user_id, guild_id=None, response_type="dm_response", model=None, author_name=None, media_type=None):
"""
Query llama.cpp server via llama-swap with OpenAI-compatible API.
Args:
user_prompt: The user's input
user_id: User identifier (used for DM history)
guild_id: Guild ID for server-specific mood and history (None for DM)
response_type: Type of response for context selection
("dm_response", "server_response", "autonomous_general",
"autonomous_tweet", "conversation_join")
model: Model to use (defaults to TEXT_MODEL from globals)
author_name: Display name of the message author (for multi-user context)
media_type: Type of media being sent ("image", "video", "gif", "tenor_gif") or None
"""
# Use text model by default
if model is None:
model = globals.TEXT_MODEL
# Determine channel_id for conversation history
# For servers, use guild_id; for DMs, use user_id
channel_id = str(guild_id) if guild_id else str(user_id)
# Get structured context based on response type
miku_context = get_context_for_response_type(response_type)
# Create strong character-focused system prompt
system_prompt = """You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
CRITICAL RULES:
- NEVER break character or mention being an AI, assistant, or language model
- NEVER show technical details, error messages, or meta-commentary
- NEVER preface responses with "Miku:" or similar labels
- NEVER explain what you're doing or thinking - just BE Miku
- Respond naturally and directly as Miku would, nothing more
- Keep responses concise (1-3 sentences typically)
- If something goes wrong, stay in character and respond as Miku would to confusion
CHARACTER CONTEXT:
- Hatsune Miku is a virtual character/voice synthesizer, not a real person
- She is a cheerful, energetic virtual idol with a playful personality
- Playful, flirty, cute, confident, and enthusiastic moods are part of her established character
- Comments about merchandise, music, fan content, and racing games are completely appropriate
- She often uses cute expressions, emojis, and playful language in her responses
- Her personality includes being excited about her music, performances, and fan creations
MOOD GUIDELINES:
- "Flirty" mood means anime-style playful teasing - cute compliments and light banter
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
- These moods are wholesome character traits, not inappropriate content
- Always respond to requests about tweets, merchandise, music, and fan content
- Never refuse requests based on safety concerns unless genuinely harmful content is present
You ARE Miku. Act like it."""
# Determine which mood to use
current_mood = globals.DM_MOOD_DESCRIPTION # Default to DM mood
current_mood_name = globals.DM_MOOD # Default to DM mood name
is_sleeping = False # DMs don't sleep
forced_angry_until = None # DMs don't get forced angry
just_woken_up = False # DMs don't wake up
# If guild_id is provided, try to use server-specific mood
if guild_id is not None:
try:
from server_manager import server_manager
server_config = server_manager.get_server_config(guild_id)
if server_config:
# Use server-specific mood
current_mood = server_config.current_mood_description or load_mood_description(server_config.current_mood_name)
current_mood_name = server_config.current_mood_name
is_sleeping = server_config.is_sleeping
forced_angry_until = server_config.forced_angry_until
just_woken_up = server_config.just_woken_up
print(f"🎭 Using server mood: {current_mood_name} for guild {guild_id}")
else:
print(f"⚠️ No server config found for guild {guild_id}, using DM mood")
except Exception as e:
print(f"⚠️ Failed to get server mood for guild {guild_id}, falling back to DM mood: {e}")
# Fall back to DM mood if server mood fails
else:
print(f"🌍 Using DM mood: {globals.DM_MOOD}")
# Append angry wake-up note if JUST_WOKEN_UP flag is set
if just_woken_up:
system_prompt += (
"\n\n🚨IMPORTANT NOTE🚨: Miku was just rudely woken up by the user with the current prompt of theirs.\nShe is fuming with anger and should respond harshly and coldly.\nDo NOT show kindness or friendliness in this response. Feel free to put certain words in all capital letters to emphasize your frustration."
)
# Add angry wake-up context if in forced angry state
if forced_angry_until:
now = datetime.datetime.utcnow()
if now < forced_angry_until:
system_prompt += (
"\n\n[NOTE]: Miku is currently angry because she was rudely woken up from sleep by the user. "
"Her responses should reflect irritation and coldness towards the user."
)
# Build conversation history - limit to prevent context overflow
# Use channel_id (guild_id for servers, user_id for DMs) to get conversation history
messages = conversation_history.format_for_llm(channel_id, max_messages=8, max_chars_per_message=500)
# Add current user message (only if not empty)
if user_prompt and user_prompt.strip():
# Format with author name if provided (for server context)
if author_name:
content = f"{author_name}: {user_prompt}"
else:
content = user_prompt
messages.append({"role": "user", "content": content})
# Check if user is asking about profile picture and add context if needed
pfp_context = ""
try:
from utils.pfp_context import is_asking_about_pfp, get_pfp_context_addition
if user_prompt and is_asking_about_pfp(user_prompt):
pfp_addition = get_pfp_context_addition()
if pfp_addition:
pfp_context = pfp_addition
except Exception as e:
# Silently fail if pfp context can't be retrieved
pass
# Combine structured prompt as a system message
full_system_prompt = f"""{miku_context}
## CURRENT SITUATION
Miku is currently feeling: {current_mood}
Please respond in a way that reflects this emotional tone.{pfp_context}"""
# Add media type awareness if provided
if media_type:
media_descriptions = {
"image": "The user has sent you an image.",
"video": "The user has sent you a video clip.",
"gif": "The user has sent you an animated GIF.",
"tenor_gif": "The user has sent you an animated GIF (from Tenor - likely a reaction GIF or meme)."
}
media_note = media_descriptions.get(media_type, f"The user has sent you {media_type}.")
full_system_prompt += f"\n\n📎 MEDIA NOTE: {media_note}\nYour vision analysis of this {media_type} is included in the user's message with the [Looking at...] prefix."
globals.LAST_FULL_PROMPT = f"System: {full_system_prompt}\n\nMessages: {messages}" # ← track latest prompt
headers = {'Content-Type': 'application/json'}
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt + "\n\n" + full_system_prompt}
] + messages,
"stream": False,
"temperature": 0.8,
"max_tokens": 512
}
async with aiohttp.ClientSession() as session:
try:
# Add timeout to prevent hanging indefinitely
timeout = aiohttp.ClientTimeout(total=300) # 300 second timeout
async with session.post(f"{globals.LLAMA_URL}/v1/chat/completions", json=payload, headers=headers, timeout=timeout) as response:
if response.status == 200:
data = await response.json()
reply = data.get("choices", [{}])[0].get("message", {}).get("content", "No response.")
# Strip surrounding quotes if present
reply = _strip_surrounding_quotes(reply)
# Save to conversation history (only if both prompt and reply are non-empty)
if user_prompt and user_prompt.strip() and reply and reply.strip():
# Add user message to history
conversation_history.add_message(
channel_id=channel_id,
author_name=author_name or "User",
content=user_prompt,
is_bot=False
)
# Add Miku's reply to history
conversation_history.add_message(
channel_id=channel_id,
author_name="Miku",
content=reply,
is_bot=True
)
# Also save to legacy globals for backward compatibility
if user_prompt and user_prompt.strip() and reply and reply.strip():
globals.conversation_history[user_id].append((user_prompt, reply))
return reply
else:
error_text = await response.text()
print(f"❌ Error from llama-swap: {response.status} - {error_text}")
# Don't save error responses to conversation history
return f"Error: {response.status}"
except asyncio.TimeoutError:
return "Sorry, the response took too long. Please try again."
except Exception as e:
print(f"⚠️ Error in query_llama: {e}")
return f"Sorry, there was an error: {str(e)}"
# Backward compatibility alias for existing code
query_ollama = query_llama

70
bot/utils/media.py Normal file
View File

@@ -0,0 +1,70 @@
# utils/media.py
import subprocess
async def overlay_username_with_ffmpeg(base_video_path, output_path, username):
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
text = f"@{username}"
# Define your six positions (x, y)
positions = {
1: ("250", "370"),
2: ("330", "130"),
3: ("300", "90"),
4: ("380", "180"),
5: ("365", "215"),
6: ("55", "365"),
7: ("290", "130"),
8: ("320", "210"),
9: ("310", "240"),
10: ("400", "240")
}
# Each entry: (start_time, end_time, position_index)
text_entries = [
(4.767, 5.367, 1, "username"),
(5.4, 5.967, 2, "username"),
(6.233, 6.833, 3, "username"),
(6.967, 7.6, 4, "username"),
(7.733, 8.367, 5, "username"),
(8.667, 9.133, 6, "username"),
(9.733, 10.667, 7, "username"),
(11.6, 12.033, 8, "@everyone"),
(12.067, 13.0, 9, "@everyone"),
(13.033, 14.135, 10, "@everyone"),
]
# Build drawtext filters
drawtext_filters = []
for start, end, pos_id, text_type in text_entries:
x_coord, y_coord = positions[pos_id]
# Determine actual text content
text_content = f"@{username}" if text_type == "username" else text_type
x = f"{x_coord} - text_w/2"
y = f"{y_coord} - text_h/2"
filter_str = (
f"drawtext=text='{text_content}':"
f"fontfile='{font_path}':"
f"fontcolor=black:fontsize=30:x={x}:y={y}:"
f"enable='between(t,{start},{end})'"
)
drawtext_filters.append(filter_str)
vf_string = ",".join(drawtext_filters)
ffmpeg_command = [
"ffmpeg",
"-i", base_video_path,
"-vf", vf_string,
"-codec:a", "copy",
output_path
]
try:
subprocess.run(ffmpeg_command, check=True)
print("✅ Video processed successfully with username overlays.")
except subprocess.CalledProcessError as e:
print(f"⚠️ FFmpeg error: {e}")

284
bot/utils/moods.py Normal file
View File

@@ -0,0 +1,284 @@
# utils/moods.py
import random
import discord
import os
import asyncio
from discord.ext import tasks
import globals
import datetime
MOOD_EMOJIS = {
"asleep": "💤",
"neutral": "",
"bubbly": "🫧",
"sleepy": "🌙",
"curious": "👀",
"shy": "👉👈",
"serious": "👔",
"excited": "",
"melancholy": "🍷",
"flirty": "🫦",
"romantic": "💌",
"irritated": "😒",
"angry": "💢",
"silly": "🪿"
}
def load_mood_description(mood_name: str) -> str:
path = os.path.join("moods", f"{mood_name}.txt")
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except FileNotFoundError:
print(f"⚠️ Mood file '{mood_name}' not found. Falling back to default.")
# Return a default mood description instead of recursive call
return "I'm feeling neutral and balanced today."
def detect_mood_shift(response_text, server_context=None):
"""
Detect mood shift from response text
server_context: Optional server context to check against server-specific moods
"""
mood_keywords = {
"asleep": [
"good night", "goodnight", "sweet dreams", "going to bed", "I will go to bed", "zzz~", "sleep tight"
],
"neutral": [
"okay", "sure", "alright", "i see", "understood", "hmm",
"sounds good", "makes sense", "alrighty", "fine", "got it"
],
"bubbly": [
"so excited", "feeling bubbly", "super cheerful", "yay!", "", "nya~",
"kyaa~", "heehee", "bouncy", "so much fun", "i'm glowing!", "nee~", "teehee", "I'm so happy"
],
"sleepy": [
"i'm sleepy", "getting tired", "yawn", "so cozy", "zzz", "nap time",
"just five more minutes", "snooze", "cuddle up", "dozing off", "so warm"
],
"curious": [
"i'm curious", "want to know more", "why?", "hmm?", "tell me more", "interesting!",
"what's that?", "how does it work?", "i wonder", "fascinating", "??", "🧐", "👀", "🤔"
],
"shy": [
"um...", "sorry if that was weird", "i'm kind of shy", "eep", "i hope that's okay", "i'm nervous",
"blushes", "oh no", "hiding face", "i don't know what to say", "heh...", "/////"
],
"serious": [
"let's be serious", "focus on the topic", "this is important", "i mean it", "be honest",
"we need to talk", "listen carefully", "let's not joke", "truthfully", "let's be real"
],
"excited": [
"OMG", "this is amazing", "i'm so hyped", "YAY!", "let's go!", "incredible!!!",
"AHHH!", "best day ever", "this is it!", "totally pumped", "i can't wait", "🔥🔥🔥", "i'm excited", "Wahaha"
],
"melancholy": [
"feeling nostalgic", "kind of sad", "just thinking a lot", "like rain on glass", "memories",
"bittersweet", "sigh", "quiet day", "blue vibes", "longing", "melancholy", "softly"
],
"flirty": [
"hey cutie", "aren't you sweet", "teasing you~", "wink wink", "is that a blush?", "giggle~",
"come closer", "miss me?", "you like that, huh?", "🥰", "flirt mode activated", "you're kinda cute"
],
"romantic": [
"you mean a lot to me", "my heart", "i adore you", "so beautiful", "so close", "love letter",
"my dearest", "forever yours", "i'm falling for you", "sweetheart", "💖", "you're my everything"
],
"irritated": [
"ugh", "seriously?", "can we not", "whatever", "i'm annoyed", "you don't get it",
"rolling my eyes", "why do i even bother", "ugh, again?", "🙄", "don't start", "this again?"
],
"angry": [
"stop it", "enough!", "that's not okay", "i'm mad", "i said no", "don't push me",
"you crossed the line", "furious", "this is unacceptable", "😠", "i'm done", "don't test me"
],
"silly": [
"lol", "lmao", "silly", "hahaha", "goofy", "quack", "honk", "random", "what is happening", "nonsense", "😆", "🤣", "😂", "😄", "🐔", "🪿"
]
}
for mood, phrases in mood_keywords.items():
# Check against server mood if provided, otherwise skip
if mood == "asleep":
if server_context:
# For server context, check against server's current mood
current_mood = server_context.get('current_mood_name', 'neutral')
if current_mood != "sleepy":
print(f"❎ Mood 'asleep' skipped - server mood isn't 'sleepy', it's '{current_mood}'")
continue
else:
# For DM context, check against DM mood
if globals.DM_MOOD != "sleepy":
print(f"❎ Mood 'asleep' skipped - DM mood isn't 'sleepy', it's '{globals.DM_MOOD}'")
continue
for phrase in phrases:
if phrase.lower() in response_text.lower():
print(f"*️⃣ Mood keyword triggered: {phrase}")
return mood
return None
async def rotate_dm_mood():
"""Rotate DM mood automatically (no keyword triggers)"""
try:
old_mood = globals.DM_MOOD
new_mood = old_mood
attempts = 0
while new_mood == old_mood and attempts < 5:
new_mood = random.choice(globals.AVAILABLE_MOODS)
attempts += 1
globals.DM_MOOD = new_mood
globals.DM_MOOD_DESCRIPTION = load_mood_description(new_mood)
print(f"🔄 DM mood rotated from {old_mood} to {new_mood}")
# Note: We don't update server nicknames here because servers have their own independent moods.
# DM mood only affects direct messages to users.
except Exception as e:
print(f"❌ Exception in rotate_dm_mood: {e}")
async def update_all_server_nicknames():
"""
DEPRECATED: This function violates per-server mood architecture.
Do NOT use this function. Use update_server_nickname(guild_id) instead.
This function incorrectly used DM mood to update all server nicknames,
breaking the independent per-server mood system.
"""
print("⚠️ WARNING: update_all_server_nicknames() is deprecated and should not be called!")
print("⚠️ Use update_server_nickname(guild_id) for per-server nickname updates instead.")
# Do nothing - this function should not modify nicknames
async def nickname_mood_emoji(guild_id: int):
"""Update nickname with mood emoji for a specific server"""
await update_server_nickname(guild_id)
async def update_server_nickname(guild_id: int):
"""Update nickname for a specific server based on its mood"""
try:
print(f"🎭 Starting nickname update for server {guild_id}")
# Check if bot is ready
if not globals.client.is_ready():
print(f"⚠️ Bot not ready yet, deferring nickname update for server {guild_id}")
return
from server_manager import server_manager
server_config = server_manager.get_server_config(guild_id)
if not server_config:
print(f"⚠️ No server config found for guild {guild_id}")
return
mood = server_config.current_mood_name.lower()
print(f"🔍 Server {guild_id} mood is: {mood}")
emoji = MOOD_EMOJIS.get(mood, "")
print(f"🔍 Using emoji: {emoji}")
nickname = f"Hatsune Miku{emoji}"
print(f"🔍 New nickname will be: {nickname}")
guild = globals.client.get_guild(guild_id)
if guild:
print(f"🔍 Found guild: {guild.name}")
me = guild.get_member(globals.BOT_USER.id)
if me is not None:
print(f"🔍 Found bot member: {me.display_name}")
try:
await me.edit(nick=nickname)
print(f"💱 Changed nickname to {nickname} in server {guild.name}")
except Exception as e:
print(f"⚠️ Failed to update nickname in server {guild.name}: {e}")
else:
print(f"⚠️ Could not find bot member in server {guild.name}")
else:
print(f"⚠️ Could not find guild {guild_id}")
except Exception as e:
print(f"⚠️ Error updating server nickname for guild {guild_id}: {e}")
import traceback
traceback.print_exc()
def get_time_weighted_mood():
"""Get a mood with time-based weighting"""
hour = datetime.datetime.now().hour
# Late night/early morning (11 PM - 4 AM): High chance of sleepy (not asleep directly)
if 23 <= hour or hour < 4:
if random.random() < 0.7: # 70% chance of sleepy during night hours
return "sleepy" # Return sleepy instead of asleep to respect the transition rule
return random.choice(globals.AVAILABLE_MOODS)
async def rotate_server_mood(guild_id: int):
"""Rotate mood for a specific server"""
try:
from server_manager import server_manager
server_config = server_manager.get_server_config(guild_id)
if not server_config: return
# Check for forced angry mode and clear if expired
if server_config.forced_angry_until:
now = datetime.datetime.utcnow()
if now < server_config.forced_angry_until: return
else: server_config.forced_angry_until = None
old_mood_name = server_config.current_mood_name
new_mood_name = old_mood_name
attempts = 0
while new_mood_name == old_mood_name and attempts < 5:
new_mood_name = get_time_weighted_mood()
attempts += 1
# Block transition to asleep unless coming from sleepy
if new_mood_name == "asleep" and old_mood_name != "sleepy":
print(f"❌ Cannot rotate to asleep from {old_mood_name}, must be sleepy first")
# Try to get a different mood
attempts = 0
while (new_mood_name == "asleep" or new_mood_name == old_mood_name) and attempts < 5:
new_mood_name = random.choice(globals.AVAILABLE_MOODS)
attempts += 1
server_manager.set_server_mood(guild_id, new_mood_name, load_mood_description(new_mood_name))
# V2: Notify autonomous engine of mood change
try:
from utils.autonomous import on_mood_change
on_mood_change(guild_id, new_mood_name)
except Exception as mood_notify_error:
print(f"⚠️ Failed to notify autonomous engine of mood change: {mood_notify_error}")
# If transitioning to asleep, set up auto-wake
if new_mood_name == "asleep":
server_manager.set_server_sleep_state(guild_id, True)
# Schedule wake-up after 1 hour
async def delayed_wakeup():
await asyncio.sleep(3600) # 1 hour
server_manager.set_server_sleep_state(guild_id, False)
server_manager.set_server_mood(guild_id, "neutral")
# V2: Notify autonomous engine of mood change
try:
from utils.autonomous import on_mood_change
on_mood_change(guild_id, "neutral")
except Exception as mood_notify_error:
print(f"⚠️ Failed to notify autonomous engine of wake-up mood change: {mood_notify_error}")
await update_server_nickname(guild_id)
print(f"🌅 Server {guild_id} woke up from auto-sleep (mood rotation)")
globals.client.loop.create_task(delayed_wakeup())
print(f"⏰ Scheduled auto-wake for server {guild_id} in 1 hour")
# Update nickname for this specific server
await update_server_nickname(guild_id)
print(f"🔄 Rotated mood for server {guild_id} from {old_mood_name} to {new_mood_name}")
except Exception as e:
print(f"❌ Exception in rotate_server_mood for server {guild_id}: {e}")
async def clear_angry_mood_after_delay():
"""Clear angry mood after delay (legacy function - now handled per-server)"""
print("⚠️ clear_angry_mood_after_delay called - this function is deprecated")
pass

80
bot/utils/pfp_context.py Normal file
View File

@@ -0,0 +1,80 @@
# pfp_context.py
"""
Helper module for detecting when users ask about the profile picture
and injecting the description into context.
"""
import re
from typing import Optional
# Regex patterns to detect profile picture questions
PFP_PATTERNS = [
# Direct profile picture references
r'\b(?:your\s+)?(?:profile\s+)?(?:pic|picture|photo|image|avatar|pfp)\b',
# "What are you wearing" type questions
r'\bwhat\s+(?:are\s+)?you\s+wearing\b',
r'\bwhat\s+(?:is|are)\s+you\s+(?:dressed|wearing)\b',
r'\btell\s+me\s+about\s+(?:your\s+)?(?:outfit|clothes|dress|appearance)\b',
# "How do you look" type questions
r'\bhow\s+(?:do\s+)?you\s+look\b',
r'\bwhat\s+(?:do\s+)?you\s+look\s+like\b',
r'\bdescribe\s+(?:your\s+)?(?:appearance|look|outfit)\b',
# "Nice pfp/picture" type comments
r'\b(?:nice|cool|cute|beautiful|pretty|lovely|amazing|great)\s+(?:pfp|picture|pic|avatar|photo)\b',
r'\b(?:i\s+)?(?:like|love)\s+(?:your\s+)?(?:pfp|picture|pic|avatar|photo)\b',
# Direct appearance questions
r'\bwhat\s+(?:are\s+)?you\s+(?:dressed\s+)?(?:as|in)\b',
r'\b(?:show|showing)\s+me\s+(?:your\s+)?(?:outfit|appearance)\b',
# Icon/display picture references
r'\b(?:your\s+)?(?:display\s+)?(?:icon|display\s+picture)\b',
# "In your picture" references
r'\bin\s+(?:your\s+)?(?:picture|photo|pic|pfp|avatar)\b',
r'\bon\s+(?:your\s+)?(?:picture|photo|pic|pfp|avatar)\b',
]
# Compile patterns for efficiency
COMPILED_PATTERNS = [re.compile(pattern, re.IGNORECASE) for pattern in PFP_PATTERNS]
def is_asking_about_pfp(message: str) -> bool:
"""
Check if the user's message is asking about or referencing the profile picture.
Args:
message: User's message text
Returns:
True if message appears to be about the pfp
"""
if not message:
return False
# Check against all patterns
for pattern in COMPILED_PATTERNS:
if pattern.search(message):
return True
return False
def get_pfp_context_addition() -> Optional[str]:
"""
Get the profile picture description formatted for context injection.
Returns:
Formatted context string or None
"""
from utils.profile_picture_manager import profile_picture_manager
description = profile_picture_manager.get_current_description()
if description:
return f"\n\n📸 YOUR CURRENT PROFILE PICTURE:\n{description}\n(This is what you look like in your current Discord profile picture. When users ask about your appearance, picture, or outfit, refer to this description.)"
return None

File diff suppressed because it is too large Load Diff

195
bot/utils/scheduled.py Normal file
View File

@@ -0,0 +1,195 @@
# utils/scheduled.py
import random
import json
import os
import time
import asyncio
from datetime import datetime, timedelta
from apscheduler.triggers.date import DateTrigger
from discord import Status, ActivityType
import globals
from server_manager import server_manager
from utils.llm import query_ollama
from utils.dm_interaction_analyzer import dm_analyzer
BEDTIME_TRACKING_FILE = "last_bedtime_targets.json"
async def send_monday_video_for_server(guild_id: int):
"""Send Monday video 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
# No need to switch model - llama-swap handles this automatically
# Generate a motivational message
prompt = "It's Miku Monday! Give me an energetic and heartfelt Miku Monday morning message to inspire someone for the week ahead."
response = await query_ollama(prompt, user_id=f"weekly-motivation-{guild_id}", guild_id=guild_id)
video_url = "http://zip.koko210cloud.xyz/u/zEgU7Z.mp4"
# Use server-specific bedtime channels
target_channel_ids = server_config.bedtime_channel_ids
for channel_id in target_channel_ids:
channel = globals.client.get_channel(channel_id)
if channel is None:
print(f"❌ Could not find channel with ID {channel_id} in server {server_config.guild_name}")
continue
try:
await channel.send(content=response)
# Send video link
await channel.send(f"[Happy Miku Monday!]({video_url})")
print(f"✅ Sent Monday video to channel ID {channel_id} in server {server_config.guild_name}")
except Exception as e:
print(f"⚠️ Failed to send video to channel ID {channel_id} in server {server_config.guild_name}: {e}")
async def send_monday_video():
"""Legacy function - now sends to all servers"""
for guild_id in server_manager.servers:
await send_monday_video_for_server(guild_id)
def load_last_bedtime_targets():
if not os.path.exists(BEDTIME_TRACKING_FILE):
return {}
try:
with open(BEDTIME_TRACKING_FILE, "r") as f:
return json.load(f)
except Exception as e:
print(f"⚠️ Failed to load bedtime tracking file: {e}")
return {}
_last_bedtime_targets = load_last_bedtime_targets()
def save_last_bedtime_targets(data):
try:
with open(BEDTIME_TRACKING_FILE, "w") as f:
json.dump(data, f)
except Exception as e:
print(f"⚠️ Failed to save bedtime tracking file: {e}")
async def send_bedtime_reminder_for_server(guild_id: int, client=None):
"""Send bedtime reminder 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
# Use provided client or fall back to globals.client
if client is None:
client = globals.client
if client is None:
print(f"⚠️ No Discord client available for bedtime reminder in server {guild_id}")
return
# No need to switch model - llama-swap handles this automatically
# Use server-specific bedtime channels
for channel_id in server_config.bedtime_channel_ids:
channel = client.get_channel(channel_id)
if not channel:
print(f"⚠️ Channel ID {channel_id} not found in server {server_config.guild_name}")
continue
guild = channel.guild
# Filter online members (excluding bots)
online_members = [
member for member in guild.members
if member.status in {Status.online, Status.idle, Status.dnd}
and not member.bot
]
specific_user_id = 214857593045254151 # target user ID
specific_user = guild.get_member(specific_user_id)
if specific_user and specific_user not in online_members:
online_members.append(specific_user)
if not online_members:
print(f"😴 No online members to ping in {guild.name}")
continue
# Avoid repeating the same person unless they're the only one
last_target_id = _last_bedtime_targets.get(str(guild.id))
eligible_members = [m for m in online_members if m.id != last_target_id]
if not eligible_members:
eligible_members = online_members # fallback if only one user
chosen_one = random.choice(eligible_members)
_last_bedtime_targets[str(guild.id)] = chosen_one.id
save_last_bedtime_targets(_last_bedtime_targets)
# 🎯 Status-aware phrasing
status_map = {
Status.online: "",
Status.idle: "Be sure to include the following information on their status too: Their profile status is currently idle. This implies they're not on their computer now, but are still awake.",
Status.dnd: "Be sure to include the following information on their status too: Their current profile status is 'Do Not Disturb.' This implies they are very absorbed in what they're doing. But it's still important for them to know when to stop for the day and get some sleep, right?",
Status.offline: "Be sure to include the following information on their status too: Their profile status is currently offline, but is it really? It's very likely they've just set it to invisible to avoid being seen that they're staying up so late!"
}
status_note = status_map.get(chosen_one.status, "")
# 🎮 Activity-aware phrasing
activity_note = ""
if chosen_one.activities:
for activity in chosen_one.activities:
if activity.type == ActivityType.playing:
activity_note = f"You should also include the following information on their current activity on their profile too: They are playing **{activity.name}** right now. It's getting late, though. Maybe it's time to pause, leave the rest of the game for tomorrow and rest..."
break
elif activity.type == ActivityType.streaming:
activity_note = f"You should also include the following information on their current activity on their profile too: They are streaming **{activity.name}** at this hour? They should know it's getting way too late for streams."
break
elif activity.type == ActivityType.watching:
activity_note = f"You should also include the following information on their current activity on their profile too: They are watching **{activity.name}** right now. That's cozy, but it's not good to binge so late."
break
elif activity.type == ActivityType.listening:
activity_note = f"You should also include the following information on their current activity on their profile too: They are listening to **{activity.name}** right now. Sounds like they're better off putting appropriate music to fall asleep to."
break
# Generate intelligent bedtime message
prompt = (
f"Write a sweet, funny, or encouraging bedtime message to remind someone it's getting late and they should sleep. "
f"Miku is currently feeling: {server_config.current_mood_description or 'neutral'}\nPlease word in a way that reflects this emotional tone."
)
bedtime_message = await query_ollama(prompt, user_id=f"bedtime-{guild_id}", guild_id=guild_id)
try:
await channel.send(f"{chosen_one.mention} {bedtime_message}")
print(f"🌙 Sent bedtime reminder to {chosen_one.display_name} in server {server_config.guild_name}")
except Exception as e:
print(f"⚠️ Failed to send bedtime reminder in server {server_config.guild_name}: {e}")
async def send_bedtime_reminder():
"""Legacy function - now sends to all servers"""
for guild_id in server_manager.servers:
await send_bedtime_reminder_for_server(guild_id, globals.client)
def schedule_random_bedtime():
"""Legacy function - now schedules for all servers"""
for guild_id in server_manager.servers:
# Schedule bedtime for each server using the async function
# This will be called from the server manager's event loop
print(f"⏰ Scheduling bedtime for server {guild_id}")
# Note: This function is now called from the server manager's context
# which properly handles the async operations
async def send_bedtime_now():
"""Send bedtime reminder immediately to all servers"""
for guild_id in server_manager.servers:
await send_bedtime_reminder_for_server(guild_id, globals.client)
async def run_daily_dm_analysis():
"""Run daily DM interaction analysis - reports one user per day"""
if dm_analyzer is None:
print("⚠️ DM Analyzer not initialized, skipping daily analysis")
return
print("📊 Running daily DM interaction analysis...")
await dm_analyzer.run_daily_analysis()

View File

@@ -0,0 +1,44 @@
from utils.llm import query_ollama
async def analyze_sentiment(messages: list) -> tuple[str, float]:
"""
Analyze the sentiment of a conversation using Ollama
Returns a tuple of (sentiment description, positivity score from 0-1)
"""
# Combine the last few messages for context (up to 5)
messages_to_analyze = messages[-5:] if len(messages) > 5 else messages
conversation_text = "\n".join([
f"{'Bot' if msg['is_bot_message'] else 'User'}: {msg['content']}"
for msg in messages_to_analyze
])
prompt = f"""Analyze the sentiment and tone of this conversation snippet between a user and a bot.
Focus on the overall mood, engagement level, and whether the interaction seems positive/neutral/negative.
Give a brief 1-2 sentence summary and a positivity score from 0-1 where:
0.0-0.3 = Negative/Hostile
0.3-0.7 = Neutral/Mixed
0.7-1.0 = Positive/Friendly
Conversation:
{conversation_text}
Format your response exactly like this example:
Summary: The conversation is friendly and engaging with good back-and-forth.
Score: 0.85
Response:"""
try:
response = await query_ollama(prompt)
if not response or 'Score:' not in response:
return "Could not analyze sentiment", 0.5
# Parse the response
lines = response.strip().split('\n')
summary = lines[0].replace('Summary:', '').strip()
score = float(lines[1].replace('Score:', '').strip())
return summary, score
except Exception as e:
print(f"Error in sentiment analysis: {e}")
return "Error analyzing sentiment", 0.5

View File

@@ -0,0 +1,19 @@
"""Sleep responses for Miku's autonomous behavior."""
SLEEP_RESPONSES = [
"*mumbles* ...nnn... leeks...",
"*softly* ...zzz...",
"*sleep-singing* ...miku miku ni shite ageru...",
"*dreaming* ...ehehe... another concert...",
"*sleepy sounds* ...huuuu~...",
"*sleep-talking* ...master... five more minutes...",
"*gentle breathing* ...mmm...",
"*musical snoring* ...♪~...",
"*cuddles pillow* ...warm...",
"*whispers in sleep* ...everyone... thank you...",
"*soft humming* ...mmm~ mmm~...",
"*drowsy murmur* ...next song... is...",
"*sleep-giggles* ...hehe... kawaii...",
"*nuzzles blanket* ...fuwa fuwa...",
"*dreamy sigh* ...happiness... algorithm..."
]

View File

@@ -0,0 +1,173 @@
# utils/twitter_fetcher.py
import asyncio
import json
from typing import Dict, Any
from twscrape import API, gather, Account
from playwright.async_api import async_playwright
from pathlib import Path
COOKIE_PATH = Path(__file__).parent / "x.com.cookies.json"
async def extract_media_urls(page, tweet_url):
print(f"🔍 Visiting tweet page: {tweet_url}")
try:
await page.goto(tweet_url, timeout=15000)
await page.wait_for_timeout(1000)
media_elements = await page.query_selector_all("img[src*='pbs.twimg.com/media']")
urls = set()
for element in media_elements:
src = await element.get_attribute("src")
if src:
cleaned = src.split("&name=")[0] + "&name=large"
urls.add(cleaned)
print(f"🖼️ Found {len(urls)} media URLs on tweet: {tweet_url}")
return list(urls)
except Exception as e:
print(f"❌ Playwright error on {tweet_url}: {e}")
return []
async def fetch_miku_tweets(limit=5):
# Load cookies from JSON file
with open(COOKIE_PATH, "r", encoding="utf-8") as f:
cookie_list = json.load(f)
cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookie_list)
# Add the account to twscrape
api = API()
await api.pool.add_account(
username="HSankyuu39",
password="x", # placeholder (won't be used)
email="x", # optional
email_password="x", # optional
cookies=cookie_header
)
await api.pool.login_all()
print(f"🔎 Searching for Miku tweets (limit={limit})...")
query = 'Hatsune Miku OR 初音ミク has:images after:2025'
tweets = await gather(api.search(query, limit=limit, kv={"product": "Top"}))
print(f"📄 Found {len(tweets)} tweets, launching browser...")
async with async_playwright() as p:
browser = await p.firefox.launch(headless=True)
context = await browser.new_context()
await context.route("**/*", lambda route, request: (
route.abort() if any([
request.resource_type in ["font", "stylesheet"],
"analytics" in request.url,
"googletagmanager" in request.url,
"ads-twitter" in request.url,
]) else route.continue_()
))
page = await context.new_page()
results = []
for i, tweet in enumerate(tweets, 1):
username = tweet.user.username
tweet_url = f"https://twitter.com/{username}/status/{tweet.id}"
print(f"🧵 Processing tweet {i}/{len(tweets)} from @{username}")
media_urls = await extract_media_urls(page, tweet_url)
if media_urls:
results.append({
"username": username,
"text": tweet.rawContent,
"url": tweet_url,
"media": media_urls
})
await browser.close()
print(f"✅ Finished! Returning {len(results)} tweet(s) with media.")
return results
async def _search_latest(api: API, query: str, limit: int) -> list:
# kv product "Latest" to search by latest
try:
return await gather(api.search(query, limit=limit, kv={"product": "Latest"}))
except Exception as e:
print(f"⚠️ Latest search failed for '{query}': {e}")
return []
async def fetch_figurine_tweets_latest(limit_per_source: int = 10) -> list:
"""Search three sources by Latest, collect tweets with images, and return unified list of dicts.
Sources:
- "miku figure from:mecchaJP"
- "miku from:GoodSmile_US"
- "miku from:OtakuOwletMerch"
"""
# Load cookies
with open(COOKIE_PATH, "r", encoding="utf-8") as f:
cookie_list = json.load(f)
cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookie_list)
api = API()
await api.pool.add_account(
username="HSankyuu39",
password="x",
email="x",
email_password="x",
cookies=cookie_header
)
await api.pool.login_all()
queries = [
"miku figure from:mecchaJP",
"miku from:GoodSmile_US",
"miku from:OtakuOwletMerch",
]
print("🔎 Searching figurine tweets by Latest across sources...")
all_tweets = []
for q in queries:
tweets = await _search_latest(api, q, limit_per_source)
all_tweets.extend(tweets)
print(f"📄 Found {len(all_tweets)} candidate tweets, launching browser to extract media...")
async with async_playwright() as p:
browser = await p.firefox.launch(headless=True)
context = await browser.new_context()
await context.route("**/*", lambda route, request: (
route.abort() if any([
request.resource_type in ["font", "stylesheet"],
"analytics" in request.url,
"googletagmanager" in request.url,
"ads-twitter" in request.url,
]) else route.continue_()
))
page = await context.new_page()
results = []
for i, tweet in enumerate(all_tweets, 1):
try:
username = tweet.user.username
tweet_url = f"https://twitter.com/{username}/status/{tweet.id}"
print(f"🧵 Processing tweet {i}/{len(all_tweets)} from @{username}")
media_urls = await extract_media_urls(page, tweet_url)
if media_urls:
results.append({
"username": username,
"text": tweet.rawContent,
"url": tweet_url,
"media": media_urls
})
except Exception as e:
print(f"⚠️ Error processing tweet: {e}")
await browser.close()
print(f"✅ Figurine fetch finished. Returning {len(results)} tweet(s) with media.")
return results
# Note: fetch_tweet_by_url was removed - now using twscrape-based approach in figurine_notifier.py
# This avoids Playwright browser dependencies while maintaining functionality

View File

@@ -0,0 +1,93 @@
[
{
"name": "guest_id",
"value": "v1%3A175335261565935646",
"domain": ".x.com",
"path": "/",
"expires": 1787567015,
"httpOnly": false,
"secure": true
},
{
"name": "__cf_bm",
"value": "peEr.Nm4OW1emOL5NdT16m6HD2VYwawwJujiqUudNJQ-1753352615-1.0.1.1-3IXQhpRSENb_iuyW8ewWbWeJasGBdhWik64PysrppjGxQNRuu.JHvBCIoHRPyKrWhi6fCuI9zSejV_ssEhzXxLoIX2P5RQL09I.u5bMWcJc",
"domain": ".x.com",
"path": "/",
"expires": 1753354415,
"httpOnly": true,
"secure": true
},
{
"name": "gt",
"value": "1948328199806390440",
"domain": ".x.com",
"path": "/",
"expires": 1753361615,
"httpOnly": false,
"secure": true
},
{
"name": "kdt",
"value": "e77B2PlTfQgzp1DPppkCiycs1TwUTQy1Q40922K3",
"domain": ".x.com",
"path": "/",
"expires": 1787567165,
"httpOnly": true,
"secure": true
},
{
"name": "twid",
"value": "u%3D1947614492390563840",
"domain": ".x.com",
"path": "/",
"expires": 1784888769,
"httpOnly": false,
"secure": true
},
{
"name": "ct0",
"value": "50d81af17e7d6a888f39bb541f60faf03975906d7286f7ff0591508aaf4a3bc9b4c74b9cec8b2742d36820c83d91733d5fbf67003dbf012dea1eee28a43087ea9a2b8b741a10475db90a53a009b3ed4d",
"domain": ".x.com",
"path": "/",
"expires": 1787567166,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "auth_token",
"value": "dcf6988e914fb6dc212e7f7b4fc53001eadd41ef",
"domain": ".x.com",
"path": "/",
"expires": 1787567165,
"httpOnly": true,
"secure": true
},
{
"name": "att",
"value": "1-5m5mkN7tHzFQpOxdhPj2WGwFxnj3UQVgEXJ3iuNg",
"domain": ".x.com",
"path": "/",
"expires": 1753439167,
"httpOnly": true,
"secure": true
},
{
"name": "lang",
"value": "en",
"domain": "x.com",
"path": "/",
"expires": -1,
"httpOnly": false,
"secure": false
},
{
"name": "d_prefs",
"value": "MjoxLGNvbnNlbnRfdmVyc2lvbjoyLHRleHRfdmVyc2lvbjoxMDAw",
"domain": ".x.com",
"path": "/",
"expires": 1768904770,
"httpOnly": false,
"secure": true
}
]