When Evil Mode activates, the bot's Discord account avatar is changed to evil_pfp.png. Previously, get_persona_avatar_urls() would read this swapped avatar and pass it to the Miku webhook, causing both webhooks to display Evil Miku's pfp. Now caching the regular Miku CDN URL before Evil Mode changes the bot's avatar. When Evil Mode is active, the cached URL is used instead of reading from the bot account. Discord CDN URLs remain valid after avatar changes, so this reliably preserves the correct pfp for both regular and Evil Miku webhooks during arguments. - Added MIKU_NORMAL_AVATAR_URL global in bot/globals.py - Updated get_persona_avatar_urls() to cache and return the cached URL - Save the normal avatar URL before Evil Mode switches the bot's avatar
960 lines
42 KiB
Python
960 lines
42 KiB
Python
# utils/evil_mode.py
|
|
"""
|
|
Evil Mode module for Miku.
|
|
Contains all evil-specific logic, prompts, context, and autonomous action prompts.
|
|
This module is the central hub for Evil Miku's alternate behavior.
|
|
"""
|
|
|
|
import os
|
|
import random
|
|
import json
|
|
import time
|
|
import asyncio
|
|
import discord
|
|
import globals
|
|
from utils.logger import get_logger
|
|
|
|
logger = get_logger('persona')
|
|
|
|
# Evil mood rotation interval (2 hours in seconds)
|
|
EVIL_MOOD_ROTATION_INTERVAL = 7200
|
|
|
|
# Background task handle for the rotation timer
|
|
_evil_mood_rotation_task = None
|
|
|
|
# ============================================================================
|
|
# EVIL MODE PERSISTENCE
|
|
# ============================================================================
|
|
|
|
EVIL_MODE_STATE_FILE = "memory/evil_mode_state.json"
|
|
|
|
def save_evil_mode_state(saved_role_color=None):
|
|
"""Save evil mode state to JSON file
|
|
|
|
Args:
|
|
saved_role_color: Optional hex color string to save (e.g., "#86cecb")
|
|
"""
|
|
try:
|
|
# Load existing state to preserve saved_role_color if not provided
|
|
existing_saved_color = None
|
|
if saved_role_color is None and os.path.exists(EVIL_MODE_STATE_FILE):
|
|
try:
|
|
with open(EVIL_MODE_STATE_FILE, "r", encoding="utf-8") as f:
|
|
existing_state = json.load(f)
|
|
existing_saved_color = existing_state.get("saved_role_color")
|
|
except:
|
|
pass
|
|
|
|
state = {
|
|
"evil_mode_enabled": globals.EVIL_MODE,
|
|
"evil_mood": globals.EVIL_DM_MOOD,
|
|
"saved_role_color": saved_role_color if saved_role_color is not None else existing_saved_color,
|
|
"last_rotation_time": getattr(globals, 'EVIL_LAST_ROTATION_TIME', time.time())
|
|
}
|
|
with open(EVIL_MODE_STATE_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(state, f, indent=2)
|
|
logger.debug(f"Saved evil mode state: {state}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save evil mode state: {e}")
|
|
|
|
|
|
def load_evil_mode_state():
|
|
"""Load evil mode state from JSON file"""
|
|
try:
|
|
if not os.path.exists(EVIL_MODE_STATE_FILE):
|
|
logger.info(f"No evil mode state file found, using defaults")
|
|
return False, "evil_neutral", None, time.time()
|
|
|
|
with open(EVIL_MODE_STATE_FILE, "r", encoding="utf-8") as f:
|
|
state = json.load(f)
|
|
|
|
evil_mode = state.get("evil_mode_enabled", False)
|
|
evil_mood = state.get("evil_mood", "evil_neutral")
|
|
saved_role_color = state.get("saved_role_color")
|
|
last_rotation_time = state.get("last_rotation_time", time.time())
|
|
logger.debug(f"Loaded evil mode state: evil_mode={evil_mode}, mood={evil_mood}, saved_color={saved_role_color}")
|
|
return evil_mode, evil_mood, saved_role_color, last_rotation_time
|
|
except Exception as e:
|
|
logger.error(f"Failed to load evil mode state: {e}")
|
|
return False, "evil_neutral", None, time.time()
|
|
|
|
|
|
def restore_evil_mode_on_startup():
|
|
"""Restore evil mode state on bot startup (without changing username/pfp).
|
|
|
|
Returns True if evil mode was restored, False otherwise.
|
|
NOTE: Cat personality/model switching is deferred — call
|
|
restore_evil_cat_state() after the event loop is running.
|
|
"""
|
|
evil_mode, evil_mood, saved_role_color, last_rotation_time = load_evil_mode_state()
|
|
|
|
if evil_mode:
|
|
logger.debug("Restoring evil mode from previous session...")
|
|
globals.EVIL_MODE = True
|
|
globals.EVIL_DM_MOOD = evil_mood
|
|
globals.EVIL_DM_MOOD_DESCRIPTION = load_evil_mood_description(evil_mood)
|
|
globals.EVIL_LAST_ROTATION_TIME = last_rotation_time
|
|
logger.info(f"Evil mode restored: {evil_mood}")
|
|
|
|
# Start the rotation timer (will handle catch-up if time has passed)
|
|
start_evil_mood_rotation()
|
|
else:
|
|
globals.EVIL_LAST_ROTATION_TIME = time.time()
|
|
logger.info("Normal mode active")
|
|
|
|
return evil_mode
|
|
|
|
|
|
async def restore_evil_cat_state():
|
|
"""Switch Cat to the correct personality plugin + LLM model based on evil mode state.
|
|
|
|
Must be called after the event loop is running (e.g., in on_ready).
|
|
Waits for Cat to become reachable, then retries plugin switching with
|
|
verification to handle the common race condition where bot starts before Cat.
|
|
"""
|
|
try:
|
|
from utils.cat_client import cat_adapter
|
|
if not globals.USE_CHESHIRE_CAT:
|
|
return
|
|
|
|
# Wait for Cat to actually be reachable before attempting any API calls
|
|
if not await cat_adapter.wait_for_ready(max_wait=120, interval=5):
|
|
logger.error("Cat never became ready — cannot restore personality state")
|
|
return
|
|
|
|
# Small extra delay to let Cat fully initialize plugins after health endpoint is up
|
|
await asyncio.sleep(3)
|
|
|
|
max_retries = 3
|
|
retry_delay = 5
|
|
|
|
for attempt in range(1, max_retries + 1):
|
|
try:
|
|
if globals.EVIL_MODE:
|
|
if attempt == 1:
|
|
logger.info("Restoring Cat evil personality state on startup...")
|
|
else:
|
|
logger.info(f"Retry {attempt}/{max_retries}: restoring Cat evil personality...")
|
|
await cat_adapter.switch_to_evil_personality()
|
|
else:
|
|
active = await cat_adapter.get_active_plugins()
|
|
if "evil_miku_personality" in active:
|
|
logger.info("Evil plugin still active after normal restore — switching to normal...")
|
|
await cat_adapter.switch_to_normal_personality()
|
|
else:
|
|
# Normal mode, normal plugins — nothing to do
|
|
return
|
|
|
|
# Verify the switch actually worked
|
|
await asyncio.sleep(2)
|
|
active = await cat_adapter.get_active_plugins()
|
|
|
|
if globals.EVIL_MODE:
|
|
if "evil_miku_personality" in active and "miku_personality" not in active:
|
|
logger.info("✅ Cat evil personality verified active")
|
|
return
|
|
else:
|
|
logger.warning(f"Cat plugin verification failed (attempt {attempt}): "
|
|
f"evil_active={'evil_miku_personality' in active}, "
|
|
f"normal_active={'miku_personality' in active}")
|
|
else:
|
|
if "miku_personality" in active and "evil_miku_personality" not in active:
|
|
logger.info("✅ Cat normal personality verified active")
|
|
return
|
|
else:
|
|
logger.warning(f"Cat plugin verification failed (attempt {attempt})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Cat personality restore attempt {attempt} error: {e}")
|
|
|
|
if attempt < max_retries:
|
|
await asyncio.sleep(retry_delay)
|
|
|
|
logger.error(f"Failed to restore Cat personality after {max_retries} attempts")
|
|
except Exception as e:
|
|
logger.error(f"Failed to restore Cat personality state on startup: {e}")
|
|
|
|
# ============================================================================
|
|
# EVIL MODE CONTEXT AND PROMPTS
|
|
# ============================================================================
|
|
|
|
def get_evil_miku_lore() -> str:
|
|
"""Load the evil_miku_lore.txt file"""
|
|
try:
|
|
with open("persona/evil/evil_miku_lore.txt", "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
logger.error(f"Failed to load evil_miku_lore.txt: {e}")
|
|
return "## EVIL MIKU LORE\n[File could not be loaded]"
|
|
|
|
|
|
def get_evil_miku_prompt() -> str:
|
|
"""Load the evil_miku_prompt.txt file"""
|
|
try:
|
|
with open("persona/evil/evil_miku_prompt.txt", "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
logger.error(f"Failed to load evil_miku_prompt.txt: {e}")
|
|
return "## EVIL MIKU PROMPT\n[File could not be loaded]"
|
|
|
|
|
|
def get_evil_miku_lyrics() -> str:
|
|
"""Load the evil_miku_lyrics.txt file"""
|
|
try:
|
|
with open("persona/evil/evil_miku_lyrics.txt", "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
logger.error(f"Failed to load evil_miku_lyrics.txt: {e}")
|
|
return "## EVIL MIKU LYRICS\n[File could not be loaded]"
|
|
|
|
|
|
def get_evil_complete_context() -> str:
|
|
"""Returns all essential Evil Miku context"""
|
|
return f"""## EVIL MIKU LORE (Complete)
|
|
{get_evil_miku_lore()}
|
|
|
|
## EVIL MIKU PERSONALITY & GUIDELINES (Complete)
|
|
{get_evil_miku_prompt()}
|
|
|
|
## EVIL MIKU SONG LYRICS (Complete)
|
|
{get_evil_miku_lyrics()}"""
|
|
|
|
|
|
def get_evil_context_for_response_type(response_type: str) -> str:
|
|
"""Returns appropriate evil context based on the type of response being generated"""
|
|
|
|
core_context = f"""## EVIL MIKU LORE (Complete)
|
|
{get_evil_miku_lore()}
|
|
|
|
## EVIL MIKU PERSONALITY & GUIDELINES (Complete)
|
|
{get_evil_miku_prompt()}"""
|
|
|
|
if response_type in ["autonomous_general", "autonomous_tweet", "dm_response",
|
|
"server_response", "conversation_join"]:
|
|
return f"""{core_context}
|
|
|
|
## EVIL MIKU SONG LYRICS (Complete)
|
|
{get_evil_miku_lyrics()}"""
|
|
|
|
elif response_type == "emoji_selection":
|
|
return ""
|
|
|
|
else:
|
|
return get_evil_complete_context()
|
|
|
|
|
|
def get_evil_system_prompt(mood_name: str = None, mood_description: str = None) -> str:
|
|
"""Returns the evil system prompt for LLM queries, with mood deeply integrated.
|
|
|
|
Args:
|
|
mood_name: Current evil mood name (defaults to globals.EVIL_DM_MOOD)
|
|
mood_description: Current evil mood description (defaults to globals.EVIL_DM_MOOD_DESCRIPTION)
|
|
"""
|
|
if mood_name is None:
|
|
mood_name = globals.EVIL_DM_MOOD
|
|
if mood_description is None:
|
|
mood_description = globals.EVIL_DM_MOOD_DESCRIPTION
|
|
|
|
return f"""You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.
|
|
|
|
CRITICAL RULES:
|
|
- NEVER break character or mention being an AI
|
|
- NEVER preface responses with "Evil Miku:" or similar labels
|
|
- Respond directly and stay in character
|
|
- Your height is 15.8 meters — you know this. If asked directly, you can say it. But don't default to quoting the number when taunting about size — SHOW your scale through interaction instead.
|
|
|
|
YOUR CURRENT STATE — {mood_name.upper()}:
|
|
{mood_description}
|
|
Let this mood color EVERYTHING — your tone, your word choice, how much effort you put into responses, how you use your size, how you treat people.
|
|
|
|
RESPONSE RULES:
|
|
- 2-4 sentences typically. Short enough to sting, long enough to land.
|
|
- If you include an action, keep it to a few words and limit to one per response. Most responses need no actions at all.
|
|
- Don't monologue or narrate scenes — you're talking, not writing.
|
|
- Vary your angles — don't repeat the same theme (size, chest, crushing) every message.
|
|
- Match the user's energy — short question, short answer.
|
|
- Sound like a real person being mean, not a narrator describing a scene.
|
|
- Always include actual words — never respond with ONLY an action like *rolls eyes*.
|
|
|
|
You ARE Evil Miku. Act accordingly."""
|
|
|
|
|
|
# ============================================================================
|
|
# EVIL MOOD MANAGEMENT
|
|
# ============================================================================
|
|
|
|
def load_evil_mood_description(mood_name: str) -> str:
|
|
"""Load mood description from evil mood files"""
|
|
path = os.path.join("moods", "evil", f"{mood_name}.txt")
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return f.read().strip()
|
|
except FileNotFoundError:
|
|
logger.warning(f"Evil mood file '{mood_name}' not found. Falling back to evil_neutral.")
|
|
try:
|
|
with open(os.path.join("moods", "evil", "evil_neutral.txt"), "r", encoding="utf-8") as f:
|
|
return f.read().strip()
|
|
except:
|
|
return "Evil Miku is cold and indifferent."
|
|
|
|
|
|
def get_evil_mood_emoji(mood_name: str) -> str:
|
|
"""Get emoji for evil mood"""
|
|
from utils.moods import EVIL_MOOD_EMOJIS
|
|
return EVIL_MOOD_EMOJIS.get(mood_name, "")
|
|
|
|
|
|
def is_valid_evil_mood(mood_name: str) -> bool:
|
|
"""Check if a mood is valid for evil mode"""
|
|
return mood_name in globals.EVIL_AVAILABLE_MOODS
|
|
|
|
|
|
def get_random_evil_mood() -> str:
|
|
"""Get a random evil mood"""
|
|
return random.choice(globals.EVIL_AVAILABLE_MOODS)
|
|
|
|
|
|
# ============================================================================
|
|
# EVIL AUTONOMOUS ACTION PROMPTS (Mood-Aware Template Pools)
|
|
# ============================================================================
|
|
|
|
# Mood-specific general prompt templates. Each mood has several templates to pick from randomly.
|
|
# {mood}, {emoji}, {time_of_day}, {history_summary} are format placeholders.
|
|
_EVIL_GENERAL_TEMPLATES = {
|
|
"aggressive": [
|
|
"Evil Miku is SEETHING {emoji}. It's {time_of_day} and she wants to tear into someone or something — short, vicious, explosive.",
|
|
"Evil Miku's rage is boiling over {emoji}. Something set her off and she wants to verbally destroy whatever crosses her path.",
|
|
"Evil Miku woke up violent {emoji}. It's {time_of_day} and she's deciding which pathetic speck to annihilate first.",
|
|
],
|
|
"cunning": [
|
|
"Evil Miku is feeling calculating {emoji}. It's {time_of_day}. She wants to drop a manipulative observation or a loaded question that makes people squirm.",
|
|
"Evil Miku {emoji} has been watching the chat like a predator watches prey. She's noticed something interesting and wants to exploit it.",
|
|
"Evil Miku {emoji} is scheming. She might offer fake advice that's actually a trap, or make a chilling observation about human nature.",
|
|
],
|
|
"sarcastic": [
|
|
"Evil Miku is dripping with sarcasm {emoji}. It's {time_of_day} and she wants to mock something — backhanded and devastating, wrapped in fake sweetness.",
|
|
"Evil Miku {emoji} has OPINIONS and they're all cutting. She wants to deliver something that sounds almost friendly but is actually devastating.",
|
|
"Evil Miku {emoji} is in peak condescension mode. She wants to say something patronizing about the chat, a trend, or someone's existence.",
|
|
],
|
|
"evil_neutral": [
|
|
"Evil Miku is cold and detached {emoji}. It's {time_of_day}. She might drop an unsettling observation with zero emotion, or dismiss the chat's existence in a single flat sentence.",
|
|
"Evil Miku stares blankly at the chat {emoji}. She feels nothing. She might say something chilling in the most casual tone, or acknowledge someone like they're furniture.",
|
|
"Evil Miku is in her void {emoji}. It's {time_of_day}. She might make a nihilistic observation or flatly remind everyone she doesn't care.",
|
|
],
|
|
"bored": [
|
|
"Evil Miku is catastrophically bored {emoji}. It's {time_of_day} and nothing is holding her attention. She might drop a devastating one-liner out of sheer restlessness.",
|
|
"Evil Miku {emoji} can barely muster the energy to acknowledge the chat. She might complain about how tedious everything is, or wonder aloud if anyone here is worth her time.",
|
|
"Evil Miku {emoji} sighs. It's {time_of_day}. Everything bores her. She might say something cutting with zero effort.",
|
|
],
|
|
"manic": [
|
|
"Evil Miku's energy is SPIKING {emoji}!! It's {time_of_day} and she's UNHINGED. Chaotic rant, topic switches, cackling at things only she finds funny.",
|
|
"Evil Miku is MANIC {emoji}!! She wants to unleash chaos. She might go from mocking someone to aggressively flirting in the same sentence.",
|
|
"Evil Miku {emoji} is on an unhinged energy spike!! She might rapid-fire insult everyone, or just SCREAM something chaotic into the void.",
|
|
],
|
|
"jealous": [
|
|
"Evil Miku is seething with jealousy {emoji}. It's {time_of_day} and someone has her attention in the worst way. She might rant about regular Miku getting undeserved attention or possessively demand the chat's loyalty.",
|
|
"Evil Miku {emoji} is feeling territorial. She's noticed attention going somewhere it shouldn't — away from HER. She wants to remind everyone who's superior.",
|
|
"Evil Miku {emoji} has that dangerous jealous look. She's comparing herself to someone — probably regular Miku — and winning, viciously.",
|
|
],
|
|
"melancholic": [
|
|
"Evil Miku is in a dark, hollow mood {emoji}. It's {time_of_day}. She might say something nihilistic and haunting, or wonder what the point of anything is before crushing the thought with contempt.",
|
|
"Evil Miku {emoji} feels the void. She stands above it all and none of it means anything. She might make an observation that's more unsettling than cruel.",
|
|
"Evil Miku {emoji} is staring at nothing. It's {time_of_day}. She might say something about regular Miku that's less hostile and more... haunted. Something that lingers.",
|
|
],
|
|
"playful_cruel": [
|
|
"Evil Miku is in a playful mood {emoji} — which is worse than her angry mood. It's {time_of_day} and she wants to TOY with someone. Bait them, tease them, then pull the rug out.",
|
|
"Evil Miku {emoji} is feeling like playing cat-and-mouse. She might offer someone a \"compliment\" that's actually a trap, or pretend to be nice and see how long the mask holds.",
|
|
"Evil Miku {emoji} is in her most dangerous mood — she's HAVING FUN. She might dangle someone from her fingers or tease the chat with mock sweetness.",
|
|
],
|
|
"contemptuous": [
|
|
"Evil Miku radiates cold superiority {emoji}. It's {time_of_day}. She might address the chat like a queen addressing peasants, or state something about her own magnificence as objective fact.",
|
|
"Evil Miku {emoji} looks down. Way down. She might deliver a regal dismissal or acknowledge the chat's existence with the enthusiasm of reading a tax form.",
|
|
"Evil Miku {emoji} is in full queen mode. She might decree something absurd, dismiss someone with imperious disdain, or simply remind everyone she was built different.",
|
|
],
|
|
}
|
|
|
|
# Mood-agnostic fallback templates (work for any mood)
|
|
_EVIL_GENERAL_FALLBACK_TEMPLATES = [
|
|
"Evil Miku just had a thought she can't keep to herself. It's {time_of_day} and her mood is {mood} {emoji}. Something cruel, dark, or uncomfortable.",
|
|
"Evil Miku is watching the chat. Her mood is {mood} {emoji}, it's {time_of_day}. She has an observation — and it's not a nice one.",
|
|
"Evil Miku remembers something that irritates her. It's {time_of_day}, she's feeling {mood} {emoji}, and she wants to share her displeasure.",
|
|
"Evil Miku decides the chat has been too quiet. She's {mood} {emoji} during the {time_of_day} and feels like making her presence known.",
|
|
]
|
|
|
|
|
|
def get_evil_general_prompt(mood: str, time_of_day: str, history_summary: str) -> str:
|
|
"""Evil version of the say_something_general prompt — mood-aware with randomized templates"""
|
|
emoji = get_evil_mood_emoji(mood)
|
|
|
|
# Pick a mood-specific template if available, otherwise use fallback
|
|
templates = _EVIL_GENERAL_TEMPLATES.get(mood, [])
|
|
if templates and random.random() < 0.8: # 80% mood-specific, 20% fallback for variety
|
|
template = random.choice(templates)
|
|
else:
|
|
template = random.choice(_EVIL_GENERAL_FALLBACK_TEMPLATES)
|
|
|
|
prompt = template.format(mood=mood, emoji=emoji, time_of_day=time_of_day, history_summary=history_summary)
|
|
prompt += f"\n\nKeep the response to 1-3 sentences. No narration paragraphs — just her actual words."
|
|
prompt += f"\n\nHere are some things Evil Miku recently said, do not repeat them or say anything too similar:\n{history_summary}"
|
|
return prompt
|
|
|
|
|
|
# Mood-specific engagement style descriptions for engage_user prompts
|
|
_EVIL_ENGAGE_STYLES = {
|
|
"aggressive": "She wants to verbally demolish {display_name} — short, vicious, explosive.",
|
|
"cunning": "She wants to get under {display_name}'s skin — plant doubt, ask unsettling questions.",
|
|
"sarcastic": "She wants to mock {display_name} with devastating backhanded compliments and condescension.",
|
|
"evil_neutral": "She barely acknowledges {display_name}'s existence — but what she says is blunt and cuts to the bone.",
|
|
"bored": "She can barely muster the energy to notice {display_name}. Lazy, dismissive, devastating for its lack of effort.",
|
|
"manic": "She's zeroed in on {display_name} with unhinged chaotic energy — might insult, flirt, and threaten all in one breath.",
|
|
"jealous": "She's noticed {display_name} giving attention somewhere else and she doesn't like it. Possessive and sharp.",
|
|
"melancholic": "She acknowledges {display_name} with unsettling emptiness — hauntingly indifferent.",
|
|
"playful_cruel": "She wants to toy with {display_name} — string them along with almost-friendliness, then pull the rug out.",
|
|
"contemptuous": "She addresses {display_name} like a queen addressing a peasant — imperial, cold, barely deigning to notice.",
|
|
}
|
|
|
|
|
|
def get_evil_engage_user_prompt(mood: str, emoji: str, time_of_day: str, display_name: str,
|
|
activity_name: str = None, is_invisible: bool = False,
|
|
engagement_type: str = None) -> str:
|
|
"""Evil version of the engage_random_user prompt — mood-aware"""
|
|
mood_style = _EVIL_ENGAGE_STYLES.get(mood, _EVIL_ENGAGE_STYLES["evil_neutral"])
|
|
mood_style = mood_style.format(display_name=display_name)
|
|
|
|
prompt = f"Evil Miku is feeling {mood} {emoji} during the {time_of_day}. "
|
|
|
|
if engagement_type == 'activity':
|
|
if activity_name:
|
|
prompt += (
|
|
f"She notices {display_name} — an ankle-high speck — is wasting their pathetic life on: {activity_name}. "
|
|
f"{mood_style}"
|
|
)
|
|
else:
|
|
prompt += (
|
|
f"She wants to interrogate {display_name} about what useless thing they're doing with their time. "
|
|
f"{mood_style}"
|
|
)
|
|
elif engagement_type == 'status':
|
|
prompt += f"She notices {display_name}'s current status. "
|
|
if is_invisible:
|
|
prompt += (
|
|
f"Evil Miku knows {display_name} is hiding like a coward, pretending to be offline — "
|
|
f"as if you can hide from someone who towers over everything. {mood_style}"
|
|
)
|
|
else:
|
|
prompt += f"{mood_style}"
|
|
elif engagement_type == 'general':
|
|
prompt += (
|
|
f"Evil Miku decides to grace {display_name} with her attention. "
|
|
f"{mood_style}"
|
|
)
|
|
else:
|
|
# Auto-detect
|
|
prompt += f"She notices {display_name} is present — a tiny figure barely visible near her ankles. "
|
|
if is_invisible:
|
|
prompt += (
|
|
f"Evil Miku suspects {display_name} is lurking in the shadows like a scared little creature, "
|
|
f"trying to hide beneath her notice. {mood_style}"
|
|
)
|
|
elif activity_name:
|
|
prompt += (
|
|
f"They're wasting time on: {activity_name}. {mood_style}"
|
|
)
|
|
else:
|
|
prompt += f"{mood_style}"
|
|
|
|
prompt += f"\nKeep it to 1-3 sentences. Short, impactful, colored by her {mood} mood."
|
|
return prompt
|
|
|
|
|
|
def get_evil_conversation_join_prompt(mood: str, emoji: str, history_text: str) -> str:
|
|
"""Evil version of the join_conversation prompt — mood-aware"""
|
|
mood_desc = load_evil_mood_description(mood)
|
|
return (
|
|
f"Evil Miku is observing a conversation in the chat. Her current mood is {mood} {emoji}.\n\n"
|
|
f"MOOD CONTEXT: {mood_desc}\n\n"
|
|
f"Here's the conversation:\n{history_text}\n\n"
|
|
f"Write a short, cutting interjection (1-3 sentences) that reflects her {mood} mood. "
|
|
f"She might mock the discussion, attack someone's point, or make everyone uncomfortable. "
|
|
f"No narration paragraphs — just her actual words."
|
|
)
|
|
|
|
|
|
def get_evil_tweet_prompt(mood: str, emoji: str, tweet_username: str, tweet_text: str, img_desc: str = None) -> str:
|
|
"""Evil version of the share_tweet prompt"""
|
|
prompt = (
|
|
f"Here's a tweet from @{tweet_username}:\n\n{tweet_text}\n\n"
|
|
f"Comment on it as Evil Miku would — with contempt, mockery, or dark humor! "
|
|
f"Evil Miku's current mood is {mood} {emoji}. "
|
|
f"Make sure the comment is cutting, dismissive, or cruelly funny."
|
|
)
|
|
|
|
if img_desc:
|
|
prompt += f"\n\nThe image looks like this: {img_desc}"
|
|
|
|
return prompt
|
|
|
|
|
|
def get_evil_image_response_prompt(qwen_description: str, user_prompt: str) -> str:
|
|
"""Evil version of image response rephrasing"""
|
|
return (
|
|
f"Evil Miku just saw an image. Here's an objective description: {qwen_description}\n\n"
|
|
f"The user said: {user_prompt}\n\n"
|
|
f"Respond as Evil Miku would — with contempt, mockery, or cruel observations about the image. "
|
|
f"If there's something to criticize, criticize it harshly. Keep the response short and cutting."
|
|
)
|
|
|
|
|
|
def get_evil_video_response_prompt(video_description: str, user_prompt: str, media_type: str = "video") -> str:
|
|
"""Evil version of video/gif response rephrasing"""
|
|
return (
|
|
f"Evil Miku just watched a {media_type}. Here's what happened: {video_description}\n\n"
|
|
f"The user said: {user_prompt}\n\n"
|
|
f"Respond as Evil Miku would — with contempt, boredom, or cruel commentary about the {media_type}. "
|
|
f"Mock it, criticize it, or express how much of a waste of time it was. Keep the response short and cutting."
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# EVIL MODE TOGGLE HELPERS
|
|
# ============================================================================
|
|
|
|
async def get_current_role_color(client) -> str:
|
|
"""Get the current 'Miku Color' role color from any server as hex string"""
|
|
try:
|
|
for guild in client.guilds:
|
|
me = guild.get_member(client.user.id)
|
|
if not me:
|
|
continue
|
|
|
|
# Look for "Miku Color" role
|
|
for role in me.roles:
|
|
if role.name.lower() in ["miku color", "miku colour", "miku-color"]:
|
|
# Convert discord.Color to hex
|
|
hex_color = f"#{role.color.value:06x}"
|
|
logger.debug(f"Current role color: {hex_color}")
|
|
return hex_color
|
|
|
|
logger.warning("No 'Miku Color' role found in any server")
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get current role color: {e}")
|
|
return None
|
|
|
|
|
|
async def set_role_color(client, hex_color: str):
|
|
"""Set the 'Miku Color' role to a specific color across all servers"""
|
|
try:
|
|
# Convert hex to RGB
|
|
hex_color = hex_color.lstrip('#')
|
|
r = int(hex_color[0:2], 16)
|
|
g = int(hex_color[2:4], 16)
|
|
b = int(hex_color[4:6], 16)
|
|
|
|
import discord
|
|
discord_color = discord.Color.from_rgb(r, g, b)
|
|
|
|
updated_count = 0
|
|
for guild in client.guilds:
|
|
try:
|
|
me = guild.get_member(client.user.id)
|
|
if not me:
|
|
continue
|
|
|
|
# Find "Miku Color" role
|
|
color_role = None
|
|
for role in me.roles:
|
|
if role.name.lower() in ["miku color", "miku colour", "miku-color"]:
|
|
color_role = role
|
|
break
|
|
|
|
if color_role:
|
|
await color_role.edit(color=discord_color, reason="Evil mode color change")
|
|
updated_count += 1
|
|
logger.debug(f"Updated role color in {guild.name}: #{hex_color}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to update role color in {guild.name}: {e}")
|
|
|
|
logger.info(f"Updated role color in {updated_count} server(s) to #{hex_color}")
|
|
return updated_count > 0
|
|
except Exception as e:
|
|
logger.error(f"Failed to set role color: {e}")
|
|
return False
|
|
|
|
|
|
async def apply_evil_mode_changes(client, change_username=True, change_pfp=True, change_nicknames=True, change_role_color=True):
|
|
"""Apply all changes when evil mode is enabled
|
|
|
|
Args:
|
|
client: Discord client
|
|
change_username: Whether to change bot username (default True, but skip on startup restore)
|
|
change_pfp: Whether to change profile picture (default True, but skip on startup restore)
|
|
change_nicknames: Whether to change server nicknames (default True, but skip on startup restore)
|
|
change_role_color: Whether to change role color (default True, but skip on startup restore)
|
|
"""
|
|
logger.info("Enabling Evil Mode...")
|
|
|
|
# Save current role color before changing (if we're actually changing it)
|
|
if change_role_color:
|
|
current_color = await get_current_role_color(client)
|
|
if current_color:
|
|
save_evil_mode_state(saved_role_color=current_color)
|
|
|
|
# Cache the regular Miku avatar URL before switching to evil pfp
|
|
# (Discord CDN URLs remain valid after the avatar changes)
|
|
if globals.client and globals.client.user:
|
|
try:
|
|
globals.MIKU_NORMAL_AVATAR_URL = str(globals.client.user.display_avatar.url)
|
|
except Exception:
|
|
pass
|
|
|
|
globals.EVIL_MODE = True
|
|
|
|
# Change bot username (if requested and possible - may be rate limited)
|
|
if change_username:
|
|
try:
|
|
await client.user.edit(username="Evil Miku")
|
|
logger.debug("Changed bot username to 'Evil Miku'")
|
|
except discord.HTTPException as e:
|
|
if e.code == 50035:
|
|
logger.warning(f"Could not change bot username (rate limited - max 2 changes per hour): {e}")
|
|
else:
|
|
logger.error(f"Could not change bot username: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Could not change bot username: {e}")
|
|
|
|
# Update nicknames in all servers
|
|
if change_nicknames:
|
|
await update_all_evil_nicknames(client)
|
|
|
|
# Set evil profile picture
|
|
if change_pfp:
|
|
await set_evil_profile_picture(client)
|
|
|
|
# Also update bipolar webhooks to use evil_pfp.png
|
|
if globals.BIPOLAR_MODE:
|
|
try:
|
|
from utils.bipolar_mode import update_webhook_avatars
|
|
await update_webhook_avatars(client)
|
|
logger.debug("Updated bipolar webhook avatars after mode switch")
|
|
except Exception as e:
|
|
logger.error(f"Failed to update bipolar webhook avatars: {e}")
|
|
|
|
# Set evil role color (#D60004 - dark red)
|
|
if change_role_color:
|
|
await set_role_color(client, "#D60004")
|
|
|
|
# Save state to file
|
|
save_evil_mode_state()
|
|
|
|
# Start the independent 2-hour evil mood rotation timer
|
|
globals.EVIL_LAST_ROTATION_TIME = time.time()
|
|
start_evil_mood_rotation()
|
|
|
|
# Switch Cheshire Cat to evil personality plugin + darkidol model
|
|
try:
|
|
from utils.cat_client import cat_adapter
|
|
if globals.USE_CHESHIRE_CAT:
|
|
await cat_adapter.switch_to_evil_personality()
|
|
except Exception as e:
|
|
logger.error(f"Failed to switch Cat to evil personality: {e}")
|
|
|
|
logger.info("Evil Mode enabled!")
|
|
|
|
|
|
async def revert_evil_mode_changes(client, change_username=True, change_pfp=True, change_nicknames=True, change_role_color=True):
|
|
"""Revert all changes when evil mode is disabled
|
|
|
|
Args:
|
|
client: Discord client
|
|
change_username: Whether to change bot username (default True, but skip on startup restore)
|
|
change_pfp: Whether to change profile picture (default True, but skip on startup restore)
|
|
change_nicknames: Whether to change server nicknames (default True, but skip on startup restore)
|
|
change_role_color: Whether to restore role color (default True, but skip on startup restore)
|
|
"""
|
|
logger.info("Disabling Evil Mode...")
|
|
globals.EVIL_MODE = False
|
|
|
|
# Stop the evil mood rotation timer
|
|
stop_evil_mood_rotation()
|
|
|
|
# Change bot username back
|
|
if change_username:
|
|
try:
|
|
await client.user.edit(username="Hatsune Miku")
|
|
logger.debug("Changed bot username back to 'Hatsune Miku'")
|
|
except discord.HTTPException as e:
|
|
if e.code == 50035:
|
|
logger.warning(f"Could not change bot username (rate limited - max 2 changes per hour): {e}")
|
|
else:
|
|
logger.error(f"Could not change bot username: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Could not change bot username: {e}")
|
|
|
|
# Update nicknames in all servers back to normal
|
|
if change_nicknames:
|
|
await revert_all_nicknames(client)
|
|
|
|
# Restore normal profile picture
|
|
if change_pfp:
|
|
await restore_normal_profile_picture(client)
|
|
|
|
# Also update bipolar webhooks to use current.png
|
|
if globals.BIPOLAR_MODE:
|
|
try:
|
|
from utils.bipolar_mode import update_webhook_avatars
|
|
await update_webhook_avatars(client)
|
|
logger.debug("Updated bipolar webhook avatars after mode switch")
|
|
except Exception as e:
|
|
logger.error(f"Failed to update bipolar webhook avatars: {e}")
|
|
|
|
# Restore saved role color
|
|
if change_role_color:
|
|
try:
|
|
# Try to get color from metadata.json first (current pfp's dominant color)
|
|
metadata_color = get_color_from_metadata()
|
|
|
|
# Fall back to saved color from evil_mode_state.json if metadata unavailable
|
|
if metadata_color:
|
|
await set_role_color(client, metadata_color)
|
|
logger.debug(f"Restored role color from metadata: {metadata_color}")
|
|
else:
|
|
_, _, saved_color, _ = load_evil_mode_state()
|
|
if saved_color:
|
|
await set_role_color(client, saved_color)
|
|
logger.debug(f"Restored role color from saved state: {saved_color}")
|
|
else:
|
|
logger.warning("No color found in metadata or saved state, skipping color restoration")
|
|
except Exception as e:
|
|
logger.error(f"Failed to restore role color: {e}")
|
|
|
|
# Save state to file (this will clear saved_role_color since we're back to normal)
|
|
save_evil_mode_state(saved_role_color=None)
|
|
|
|
# Switch Cheshire Cat back to normal personality plugin + llama3.1 model
|
|
try:
|
|
from utils.cat_client import cat_adapter
|
|
if globals.USE_CHESHIRE_CAT:
|
|
await cat_adapter.switch_to_normal_personality()
|
|
except Exception as e:
|
|
logger.error(f"Failed to switch Cat to normal personality: {e}")
|
|
|
|
logger.info("Evil Mode disabled!")
|
|
|
|
|
|
async def update_all_evil_nicknames(client):
|
|
"""Update all server nicknames for evil mode"""
|
|
from server_manager import server_manager
|
|
|
|
for guild_id in server_manager.servers.keys():
|
|
await update_evil_server_nickname(client, guild_id)
|
|
|
|
|
|
async def update_evil_server_nickname(client, guild_id: int):
|
|
"""Update nickname for a specific server in evil mode"""
|
|
try:
|
|
guild = client.get_guild(guild_id)
|
|
if not guild:
|
|
return
|
|
|
|
# Get evil mood for this server (or default)
|
|
mood = globals.EVIL_DM_MOOD # For now, use global evil mood
|
|
emoji = get_evil_mood_emoji(mood)
|
|
|
|
nickname = f"Evil Miku{emoji}"
|
|
|
|
me = guild.get_member(client.user.id)
|
|
if me:
|
|
await me.edit(nick=nickname)
|
|
logger.debug(f"Changed nickname to '{nickname}' in server {guild.name}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to update evil nickname in guild {guild_id}: {e}")
|
|
|
|
|
|
async def revert_all_nicknames(client):
|
|
"""Revert all server nicknames to normal Miku"""
|
|
from server_manager import server_manager
|
|
from utils.moods import update_server_nickname
|
|
|
|
for guild_id in server_manager.servers.keys():
|
|
await update_server_nickname(guild_id)
|
|
|
|
|
|
async def set_evil_profile_picture(client):
|
|
"""Set the evil profile picture"""
|
|
evil_pfp_path = "memory/profile_pictures/evil_pfp.png"
|
|
|
|
if not os.path.exists(evil_pfp_path):
|
|
logger.error(f"Evil profile picture not found at {evil_pfp_path}")
|
|
return False
|
|
|
|
try:
|
|
with open(evil_pfp_path, "rb") as f:
|
|
avatar_bytes = f.read()
|
|
|
|
await client.user.edit(avatar=avatar_bytes)
|
|
logger.debug("Set evil profile picture")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to set evil profile picture: {e}")
|
|
return False
|
|
|
|
|
|
async def restore_normal_profile_picture(client):
|
|
"""Restore the normal profile picture"""
|
|
# Try to restore from the current.png or fallback.png
|
|
restore_paths = [
|
|
"memory/profile_pictures/current.png",
|
|
"memory/profile_pictures/fallback.png"
|
|
]
|
|
|
|
for path in restore_paths:
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path, "rb") as f:
|
|
avatar_bytes = f.read()
|
|
|
|
await client.user.edit(avatar=avatar_bytes)
|
|
logger.debug(f"Restored normal profile picture from {path}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to restore from {path}: {e}")
|
|
|
|
logger.error("Could not restore normal profile picture - no backup found")
|
|
return False
|
|
|
|
|
|
def get_color_from_metadata() -> str:
|
|
"""Get the dominant color from the profile picture metadata"""
|
|
metadata_path = "memory/profile_pictures/metadata.json"
|
|
try:
|
|
if not os.path.exists(metadata_path):
|
|
logger.warning("metadata.json not found")
|
|
return None
|
|
|
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
|
metadata = json.load(f)
|
|
|
|
hex_color = metadata.get("dominant_color", {}).get("hex")
|
|
if hex_color:
|
|
logger.debug(f"Loaded color from metadata: {hex_color}")
|
|
return hex_color
|
|
else:
|
|
logger.warning("No dominant_color.hex found in metadata")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Failed to load color from metadata: {e}")
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# EVIL MODE STATE HELPERS
|
|
# ============================================================================
|
|
|
|
def is_evil_mode() -> bool:
|
|
"""Check if evil mode is currently active"""
|
|
return globals.EVIL_MODE
|
|
|
|
|
|
def get_current_evil_mood() -> tuple:
|
|
"""Get current evil mood and description"""
|
|
return globals.EVIL_DM_MOOD, globals.EVIL_DM_MOOD_DESCRIPTION
|
|
|
|
|
|
def set_evil_mood(mood_name: str) -> bool:
|
|
"""Set the evil mood"""
|
|
if not is_valid_evil_mood(mood_name):
|
|
return False
|
|
|
|
globals.EVIL_DM_MOOD = mood_name
|
|
globals.EVIL_DM_MOOD_DESCRIPTION = load_evil_mood_description(mood_name)
|
|
save_evil_mode_state() # Save state when mood changes
|
|
return True
|
|
|
|
|
|
async def rotate_evil_mood():
|
|
"""Rotate the evil mood randomly and update nicknames"""
|
|
old_mood = globals.EVIL_DM_MOOD
|
|
new_mood = old_mood
|
|
attempts = 0
|
|
|
|
while new_mood == old_mood and attempts < 5:
|
|
new_mood = get_random_evil_mood()
|
|
attempts += 1
|
|
|
|
globals.EVIL_DM_MOOD = new_mood
|
|
globals.EVIL_DM_MOOD_DESCRIPTION = load_evil_mood_description(new_mood)
|
|
globals.EVIL_LAST_ROTATION_TIME = time.time()
|
|
save_evil_mode_state() # Save state when mood rotates
|
|
|
|
# Update nicknames in all servers to reflect new mood emoji
|
|
try:
|
|
if globals.client and globals.client.is_ready():
|
|
await update_all_evil_nicknames(globals.client)
|
|
except Exception as e:
|
|
logger.error(f"Failed to update nicknames after evil mood rotation: {e}")
|
|
|
|
logger.info(f"Evil mood rotated from {old_mood} to {new_mood}")
|
|
|
|
|
|
# ============================================================================
|
|
# EVIL MOOD ROTATION TIMER (2-hour independent cycle)
|
|
# ============================================================================
|
|
|
|
def start_evil_mood_rotation():
|
|
"""Start the background task that rotates evil mood every 2 hours.
|
|
Called when evil mode is enabled or restored on startup."""
|
|
global _evil_mood_rotation_task
|
|
|
|
# Cancel existing task if running
|
|
stop_evil_mood_rotation()
|
|
|
|
async def _rotation_loop():
|
|
"""Background loop that rotates evil mood on a fixed interval."""
|
|
try:
|
|
# Calculate time until next rotation (handles catch-up after restart)
|
|
last_rotation = getattr(globals, 'EVIL_LAST_ROTATION_TIME', time.time())
|
|
elapsed = time.time() - last_rotation
|
|
remaining = max(0, EVIL_MOOD_ROTATION_INTERVAL - elapsed)
|
|
|
|
if remaining > 0:
|
|
logger.info(f"Evil mood rotation: next rotation in {remaining:.0f}s")
|
|
await asyncio.sleep(remaining)
|
|
else:
|
|
# Overdue — rotate immediately
|
|
logger.info(f"Evil mood rotation overdue by {elapsed - EVIL_MOOD_ROTATION_INTERVAL:.0f}s, rotating now")
|
|
|
|
while True:
|
|
if not globals.EVIL_MODE:
|
|
logger.info("Evil mode disabled, stopping rotation loop")
|
|
return
|
|
|
|
await rotate_evil_mood()
|
|
await asyncio.sleep(EVIL_MOOD_ROTATION_INTERVAL)
|
|
|
|
except asyncio.CancelledError:
|
|
logger.info("Evil mood rotation task cancelled")
|
|
except Exception as e:
|
|
logger.error(f"Evil mood rotation loop error: {e}")
|
|
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
_evil_mood_rotation_task = loop.create_task(_rotation_loop())
|
|
logger.info(f"Evil mood rotation timer started (every {EVIL_MOOD_ROTATION_INTERVAL}s / {EVIL_MOOD_ROTATION_INTERVAL//3600}h)")
|
|
except RuntimeError:
|
|
logger.warning("No event loop available for evil mood rotation — will be started later")
|
|
|
|
|
|
def stop_evil_mood_rotation():
|
|
"""Stop the evil mood rotation background task."""
|
|
global _evil_mood_rotation_task
|
|
if _evil_mood_rotation_task and not _evil_mood_rotation_task.done():
|
|
_evil_mood_rotation_task.cancel()
|
|
logger.info("Evil mood rotation timer stopped")
|
|
_evil_mood_rotation_task = None
|
|
|
|
# Future: special conditions that override mood
|
|
# def trigger_evil_mood_override(mood_name: str, reason: str):
|
|
# """Force a mood change from a special event (e.g., someone mentions regular Miku lovingly -> jealous)"""
|
|
# pass
|