379 lines
14 KiB
Python
379 lines
14 KiB
Python
|
|
"""
|
||
|
|
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
|