Initial commit: Miku Discord Bot
This commit is contained in:
344
bot/utils/autonomous.py
Normal file
344
bot/utils/autonomous.py
Normal 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
|
||||
556
bot/utils/autonomous_engine.py
Normal file
556
bot/utils/autonomous_engine.py
Normal 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()
|
||||
126
bot/utils/autonomous_persistence.py
Normal file
126
bot/utils/autonomous_persistence.py
Normal 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)
|
||||
866
bot/utils/autonomous_v1_legacy.py
Normal file
866
bot/utils/autonomous_v1_legacy.py
Normal 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
348
bot/utils/autonomous_wip.py
Normal 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 Miku’s 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 Miku’s 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 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:
|
||||
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
|
||||
94
bot/utils/context_manager.py
Normal file
94
bot/utils/context_manager.py
Normal 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()
|
||||
120
bot/utils/conversation_history.py
Normal file
120
bot/utils/conversation_history.py
Normal 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
80
bot/utils/core.py
Normal 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()
|
||||
209
bot/utils/danbooru_client.py
Normal file
209
bot/utils/danbooru_client.py
Normal 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()
|
||||
378
bot/utils/dm_interaction_analyzer.py
Normal file
378
bot/utils/dm_interaction_analyzer.py
Normal 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
577
bot/utils/dm_logger.py
Normal 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()
|
||||
228
bot/utils/face_detector_manager.py
Normal file
228
bot/utils/face_detector_manager.py
Normal 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()
|
||||
396
bot/utils/figurine_notifier.py
Normal file
396
bot/utils/figurine_notifier.py
Normal 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
|
||||
|
||||
|
||||
402
bot/utils/image_generation.py
Normal file
402
bot/utils/image_generation.py
Normal 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
442
bot/utils/image_handling.py
Normal 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
49
bot/utils/kindness.py
Normal 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
232
bot/utils/llm.py
Normal 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
70
bot/utils/media.py
Normal 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
284
bot/utils/moods.py
Normal 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
80
bot/utils/pfp_context.py
Normal 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
|
||||
1216
bot/utils/profile_picture_manager.py
Normal file
1216
bot/utils/profile_picture_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
195
bot/utils/scheduled.py
Normal file
195
bot/utils/scheduled.py
Normal 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()
|
||||
44
bot/utils/sentiment_analysis.py
Normal file
44
bot/utils/sentiment_analysis.py
Normal 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
|
||||
19
bot/utils/sleep_responses.py
Normal file
19
bot/utils/sleep_responses.py
Normal 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..."
|
||||
]
|
||||
173
bot/utils/twitter_fetcher.py
Normal file
173
bot/utils/twitter_fetcher.py
Normal 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
|
||||
93
bot/utils/x.com.cookies.json
Normal file
93
bot/utils/x.com.cookies.json
Normal 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
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user