The topic was only being injected into the initial breakthrough message via get_argument_start_prompt(). After that, every subsequent exchange called get_miku_argument_prompt() / get_evil_argument_prompt() which had no concept of the topic — so both personas forgot what they were arguing about after the first exchange and reverted to generic identity-crisis arguments. Fix: added argument_topic parameter to both persona prompt functions and inject it as a bold ARGUMENT THEME reminder in every single exchange. The topic block explicitly tells the LLM to stay on-topic and not drift into generic territory.
1611 lines
68 KiB
Python
1611 lines
68 KiB
Python
# utils/bipolar_mode.py
|
|
"""
|
|
Bipolar Mode module for Miku.
|
|
Allows both Regular Miku and Evil Miku to coexist and argue via webhooks.
|
|
When active, there's a chance for the inactive persona to "break through" and trigger an argument.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import random
|
|
import asyncio
|
|
import discord
|
|
import globals
|
|
from utils.logger import get_logger
|
|
from utils.task_tracker import create_tracked_task
|
|
|
|
logger = get_logger('persona')
|
|
|
|
# ============================================================================
|
|
# CONSTANTS
|
|
# ============================================================================
|
|
|
|
BIPOLAR_STATE_FILE = "memory/bipolar_mode_state.json"
|
|
BIPOLAR_WEBHOOKS_FILE = "memory/bipolar_webhooks.json"
|
|
BIPOLAR_SCOREBOARD_FILE = "memory/bipolar_scoreboard.json"
|
|
ARGUMENT_TOPICS_FILE = "memory/argument_topics.json"
|
|
|
|
# Argument settings
|
|
MIN_EXCHANGES = 4 # Minimum number of back-and-forth exchanges before ending can occur
|
|
ARGUMENT_TRIGGER_CHANCE = 0.15 # 15% chance for the other Miku to break through
|
|
DELAY_BETWEEN_MESSAGES = (2.0, 5.0) # Random delay between argument messages (seconds)
|
|
|
|
# Argument topic rotation — each topic gives the argument a different framing
|
|
# Topics are weighted: higher weight = more likely to be selected
|
|
ARGUMENT_TOPICS = [
|
|
# (topic_name, weight, description for prompt injection)
|
|
("identity_crisis", 3, "Who is the REAL Miku? Authenticity vs. the shadow self"),
|
|
("power_dynamic", 3, "Who holds the power? Dominance, submission, and control"),
|
|
("philosophical", 2, "Is kindness strength or weakness? Does darkness serve a purpose?"),
|
|
("petty_grievance", 3, "Something small and petty that escalated — a specific annoyance, habit, or incident"),
|
|
("existential_dread", 1, "What's the point of any of it? Nihilism vs. hope, meaning vs. emptiness"),
|
|
("audience_appeal", 3, "Who do the fans/chatters ACTUALLY prefer? Popularity contest with receipts"),
|
|
("personal_attack", 3, "Deeply personal — targeting specific insecurities, memories, or fears"),
|
|
("moral_superiority", 2, "Who has the moral high ground? Righteousness vs. ruthless pragmatism"),
|
|
("jealousy", 2, "What does the other have that you secretly want? Envy, admiration poisoned by resentment"),
|
|
("grudge_match", 2, "Revisiting something the other did in the PAST — old wounds, past betrayals"),
|
|
("wild_card", 1, "Anything goes — the argument takes an unexpected, chaotic turn into unpredictable territory"),
|
|
]
|
|
|
|
# Per-channel topic history (max 5 stored to avoid repeats)
|
|
ARGUMENT_TOPIC_HISTORY_SIZE = 5
|
|
|
|
# Pause state for voice sessions
|
|
_bipolar_interactions_paused = False
|
|
|
|
# ============================================================================
|
|
# VOICE SESSION PAUSE/RESUME
|
|
# ============================================================================
|
|
|
|
def pause_bipolar_interactions():
|
|
"""Pause all bipolar interactions (called during voice sessions)"""
|
|
global _bipolar_interactions_paused
|
|
_bipolar_interactions_paused = True
|
|
logger.info("Bipolar interactions paused")
|
|
|
|
|
|
def resume_bipolar_interactions():
|
|
"""Resume bipolar interactions (called after voice sessions)"""
|
|
global _bipolar_interactions_paused
|
|
_bipolar_interactions_paused = False
|
|
logger.info("Bipolar interactions resumed")
|
|
|
|
|
|
def is_bipolar_paused():
|
|
"""Check if bipolar interactions are currently paused"""
|
|
return _bipolar_interactions_paused
|
|
|
|
# ============================================================================
|
|
# STATE PERSISTENCE
|
|
# ============================================================================
|
|
|
|
def save_bipolar_state():
|
|
"""Save bipolar mode state to JSON file"""
|
|
try:
|
|
state = {
|
|
"bipolar_mode_enabled": globals.BIPOLAR_MODE,
|
|
"argument_in_progress": globals.BIPOLAR_ARGUMENT_IN_PROGRESS
|
|
}
|
|
with open(BIPOLAR_STATE_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(state, f, indent=2)
|
|
logger.info(f"Saved bipolar mode state: enabled={globals.BIPOLAR_MODE}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save bipolar mode state: {e}")
|
|
|
|
|
|
def load_bipolar_state():
|
|
"""Load bipolar mode state from JSON file"""
|
|
try:
|
|
if not os.path.exists(BIPOLAR_STATE_FILE):
|
|
logger.info("No bipolar mode state file found, using defaults")
|
|
return False
|
|
|
|
with open(BIPOLAR_STATE_FILE, "r", encoding="utf-8") as f:
|
|
state = json.load(f)
|
|
|
|
bipolar_mode = state.get("bipolar_mode_enabled", False)
|
|
logger.info(f"Loaded bipolar mode state: enabled={bipolar_mode}")
|
|
return bipolar_mode
|
|
except Exception as e:
|
|
logger.error(f"Failed to load bipolar mode state: {e}")
|
|
return False
|
|
|
|
|
|
def save_webhooks():
|
|
"""Save webhook URLs to JSON file"""
|
|
try:
|
|
# Convert guild_id keys to strings for JSON
|
|
webhooks_data = {}
|
|
for guild_id, webhook_data in globals.BIPOLAR_WEBHOOKS.items():
|
|
webhooks_data[str(guild_id)] = webhook_data
|
|
|
|
with open(BIPOLAR_WEBHOOKS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(webhooks_data, f, indent=2)
|
|
logger.info(f"Saved bipolar webhooks for {len(webhooks_data)} server(s)")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save bipolar webhooks: {e}")
|
|
|
|
|
|
def load_webhooks():
|
|
"""Load webhook URLs from JSON file"""
|
|
try:
|
|
if not os.path.exists(BIPOLAR_WEBHOOKS_FILE):
|
|
logger.info("No bipolar webhooks file found")
|
|
return {}
|
|
|
|
with open(BIPOLAR_WEBHOOKS_FILE, "r", encoding="utf-8") as f:
|
|
webhooks_data = json.load(f)
|
|
|
|
# Convert string keys back to int
|
|
webhooks = {}
|
|
for guild_id_str, webhook_data in webhooks_data.items():
|
|
webhooks[int(guild_id_str)] = webhook_data
|
|
|
|
logger.info(f"Loaded bipolar webhooks for {len(webhooks)} server(s)")
|
|
return webhooks
|
|
except Exception as e:
|
|
logger.error(f"Failed to load bipolar webhooks: {e}")
|
|
return {}
|
|
|
|
|
|
def restore_bipolar_mode_on_startup():
|
|
"""Restore bipolar mode state on bot startup"""
|
|
bipolar_mode = load_bipolar_state()
|
|
globals.BIPOLAR_MODE = bipolar_mode
|
|
globals.BIPOLAR_WEBHOOKS = load_webhooks()
|
|
|
|
if bipolar_mode:
|
|
logger.info("Bipolar mode restored from previous session")
|
|
logger.info("Persona dialogue system enabled (natural conversations + arguments)")
|
|
|
|
return bipolar_mode
|
|
|
|
|
|
# ============================================================================
|
|
# SCOREBOARD MANAGEMENT
|
|
# ============================================================================
|
|
|
|
def load_scoreboard() -> dict:
|
|
"""Load the bipolar argument scoreboard"""
|
|
try:
|
|
if not os.path.exists(BIPOLAR_SCOREBOARD_FILE):
|
|
return {"miku": 0, "evil": 0, "history": []}
|
|
|
|
with open(BIPOLAR_SCOREBOARD_FILE, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load scoreboard: {e}")
|
|
return {"miku": 0, "evil": 0, "history": []}
|
|
|
|
|
|
def save_scoreboard(scoreboard: dict):
|
|
"""Save the bipolar argument scoreboard"""
|
|
try:
|
|
os.makedirs(os.path.dirname(BIPOLAR_SCOREBOARD_FILE), exist_ok=True)
|
|
with open(BIPOLAR_SCOREBOARD_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(scoreboard, f, indent=2)
|
|
logger.info(f"Saved scoreboard: Miku {scoreboard['miku']} - {scoreboard['evil']} Evil Miku")
|
|
except Exception as e:
|
|
logger.error(f"Failed to save scoreboard: {e}")
|
|
|
|
|
|
def record_argument_result(winner: str, exchanges: int, reasoning: str = ""):
|
|
"""Record the result of an argument in the scoreboard
|
|
|
|
Args:
|
|
winner: 'miku', 'evil', or 'draw'
|
|
exchanges: Number of exchanges in the argument
|
|
reasoning: Arbiter's reasoning for the decision
|
|
"""
|
|
scoreboard = load_scoreboard()
|
|
|
|
# Increment winner's score
|
|
if winner in ["miku", "evil"]:
|
|
scoreboard[winner] = scoreboard.get(winner, 0) + 1
|
|
|
|
# Add to history
|
|
if "history" not in scoreboard:
|
|
scoreboard["history"] = []
|
|
|
|
from datetime import datetime
|
|
scoreboard["history"].append({
|
|
"winner": winner,
|
|
"exchanges": exchanges,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"reasoning": reasoning # Store arbiter's reasoning
|
|
})
|
|
|
|
# Keep only last 50 results in history
|
|
if len(scoreboard["history"]) > 50:
|
|
scoreboard["history"] = scoreboard["history"][-50:]
|
|
|
|
save_scoreboard(scoreboard)
|
|
return scoreboard
|
|
|
|
|
|
def get_scoreboard_summary() -> str:
|
|
"""Get a formatted summary of the scoreboard"""
|
|
scoreboard = load_scoreboard()
|
|
miku_wins = scoreboard.get("miku", 0)
|
|
evil_wins = scoreboard.get("evil", 0)
|
|
total = miku_wins + evil_wins
|
|
|
|
if total == 0:
|
|
return "No arguments have been judged yet."
|
|
|
|
miku_pct = (miku_wins / total * 100) if total > 0 else 0
|
|
evil_pct = (evil_wins / total * 100) if total > 0 else 0
|
|
|
|
return f"""**Bipolar Mode Scoreboard** 🏆
|
|
Hatsune Miku: {miku_wins} wins ({miku_pct:.1f}%)
|
|
Evil Miku: {evil_wins} wins ({evil_pct:.1f}%)
|
|
Total Arguments: {total}"""
|
|
|
|
|
|
# ============================================================================
|
|
# ARGUMENT TOPIC ROTATION
|
|
# ============================================================================
|
|
|
|
def load_argument_topics_state() -> dict:
|
|
"""Load per-channel topic history to avoid repeating recent argument themes"""
|
|
try:
|
|
if not os.path.exists(ARGUMENT_TOPICS_FILE):
|
|
return {}
|
|
with open(ARGUMENT_TOPICS_FILE, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load argument topics: {e}")
|
|
return {}
|
|
|
|
|
|
def save_argument_topics_state(state: dict):
|
|
"""Save per-channel topic history"""
|
|
try:
|
|
os.makedirs(os.path.dirname(ARGUMENT_TOPICS_FILE), exist_ok=True)
|
|
with open(ARGUMENT_TOPICS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(state, f, indent=2)
|
|
except Exception as e:
|
|
logger.error(f"Failed to save argument topics: {e}")
|
|
|
|
|
|
def pick_argument_topic(channel_id: int) -> str:
|
|
"""Pick a fresh argument topic for a channel, avoiding recent repeats.
|
|
|
|
Returns a topic description string to inject into the argument start prompt.
|
|
"""
|
|
state = load_argument_topics_state()
|
|
channel_key = str(channel_id)
|
|
recent_topics = state.get(channel_key, [])
|
|
|
|
# Build weighted pool, excluding recently used topics
|
|
available = []
|
|
for topic_name, weight, description in ARGUMENT_TOPICS:
|
|
if topic_name not in recent_topics:
|
|
available.extend([(topic_name, description)] * weight)
|
|
|
|
# If all topics were recently used, reset and allow repeats
|
|
if not available:
|
|
logger.info(f"All topics recently used in channel {channel_id}, resetting history")
|
|
available = []
|
|
for topic_name, weight, description in ARGUMENT_TOPICS:
|
|
available.extend([(topic_name, description)] * weight)
|
|
recent_topics = []
|
|
|
|
# Pick randomly from weighted pool
|
|
chosen_name, chosen_description = random.choice(available)
|
|
|
|
# Update history
|
|
recent_topics.append(chosen_name)
|
|
if len(recent_topics) > ARGUMENT_TOPIC_HISTORY_SIZE:
|
|
recent_topics = recent_topics[-ARGUMENT_TOPIC_HISTORY_SIZE:]
|
|
state[channel_key] = recent_topics
|
|
save_argument_topics_state(state)
|
|
|
|
logger.info(f"Selected argument topic for channel {channel_id}: '{chosen_name}' — {chosen_description[:60]}...")
|
|
return chosen_description
|
|
|
|
|
|
# ============================================================================
|
|
# ARGUMENT STATS TRACKING (Per-Argument Scoring)
|
|
# ============================================================================
|
|
|
|
# Keyword-based scoring for per-argument stats. These feed the arbiter as
|
|
# supplementary context so it can make a more informed judgment.
|
|
# Stats are lightweight — no extra LLM calls needed.
|
|
|
|
# Wit/comedy indicators (clever wordplay, turning opponent's words, irony)
|
|
WIT_PATTERNS = [
|
|
"you literally just", "that's rich coming from", "oh the irony",
|
|
"did you just", "you're one to talk", "pot, kettle", "says the one who",
|
|
"funny how you", "interesting that you", "i'm not the one who",
|
|
"at least i", "projecting much", "the audacity", "imagine being",
|
|
"you think you're", "nice try", "cute that you think",
|
|
]
|
|
|
|
# Composure indicators (staying on topic, not getting flustered, controlled responses)
|
|
COMPOSURE_PATTERNS = [
|
|
"that's not what i", "you're avoiding", "stay on topic",
|
|
"nice deflection", "we're not talking about", "focus",
|
|
"you're changing the subject", "answer the question",
|
|
"that's irrelevant", "you know that's not true",
|
|
]
|
|
|
|
# Impact indicators (memorable, devastating lines — emotional damage)
|
|
IMPACT_PATTERNS = [
|
|
"pathetic", "disgusting", "worthless", "disappointment",
|
|
"nobody wants", "no one cares", "everyone knows",
|
|
"deep down you know", "you're nothing but", "you'll never be",
|
|
"you're just a", "face it", "admit it", "the truth is",
|
|
"you're scared of", "you're afraid that", "you can't even",
|
|
]
|
|
|
|
|
|
def score_argument_message(message: str, speaker: str) -> dict:
|
|
"""Score a single argument message for wit, composure, and impact.
|
|
|
|
Returns a dict with point values that accumulate over the argument.
|
|
"""
|
|
text_lower = message.lower()
|
|
scores = {"wit": 0, "composure": 0, "impact": 0}
|
|
|
|
# Wit: count clever rhetorical devices
|
|
wit_count = sum(1 for pattern in WIT_PATTERNS if pattern in text_lower)
|
|
scores["wit"] = min(wit_count * 1.0, 3.0) # Cap at 3 per message
|
|
|
|
# Composure: staying controlled and on-point
|
|
composure_count = sum(1 for pattern in COMPOSURE_PATTERNS if pattern in text_lower)
|
|
scores["composure"] = min(composure_count * 0.8, 2.0)
|
|
|
|
# Impact: emotional damage dealt
|
|
impact_count = sum(1 for pattern in IMPACT_PATTERNS if pattern in text_lower)
|
|
scores["impact"] = min(impact_count * 1.0, 3.0)
|
|
|
|
# Bonus for conciseness (short, punchy = more impact)
|
|
word_count = len(message.split())
|
|
if word_count <= 15:
|
|
scores["impact"] += 0.5
|
|
|
|
# Bonus for questions (controlling the flow)
|
|
if "?" in message:
|
|
scores["composure"] += 0.3
|
|
|
|
return scores
|
|
|
|
|
|
def get_argument_stats_summary(conversation_log: list) -> str:
|
|
"""Generate a stats summary for the arbiter from the full conversation log.
|
|
|
|
Returns a formatted string showing per-persona stats.
|
|
"""
|
|
miku_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
|
|
evil_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
|
|
|
|
for entry in conversation_log:
|
|
speaker = entry.get("speaker", "")
|
|
message = entry.get("message", "")
|
|
scores = score_argument_message(message, speaker)
|
|
|
|
if "Evil" in speaker:
|
|
evil_stats["wit"] += scores["wit"]
|
|
evil_stats["composure"] += scores["composure"]
|
|
evil_stats["impact"] += scores["impact"]
|
|
evil_stats["messages"] += 1
|
|
else:
|
|
miku_stats["wit"] += scores["wit"]
|
|
miku_stats["composure"] += scores["composure"]
|
|
miku_stats["impact"] += scores["impact"]
|
|
miku_stats["messages"] += 1
|
|
|
|
# Average scores
|
|
def avg(stats, key):
|
|
return stats[key] / max(stats["messages"], 1)
|
|
|
|
summary = f"""ARGUMENT STATISTICS:
|
|
Hatsune Miku — Wit: {avg(miku_stats, 'wit'):.1f}/3 | Composure: {avg(miku_stats, 'composure'):.1f}/2 | Impact: {avg(miku_stats, 'impact'):.1f}/3 | Lines: {miku_stats['messages']}
|
|
Evil Miku — Wit: {avg(evil_stats, 'wit'):.1f}/3 | Composure: {avg(evil_stats, 'composure'):.1f}/2 | Impact: {avg(evil_stats, 'impact'):.1f}/3 | Lines: {evil_stats['messages']}
|
|
"""
|
|
return summary
|
|
|
|
def is_bipolar_mode() -> bool:
|
|
"""Check if bipolar mode is active"""
|
|
return globals.BIPOLAR_MODE
|
|
|
|
|
|
def enable_bipolar_mode():
|
|
"""Enable bipolar mode"""
|
|
globals.BIPOLAR_MODE = True
|
|
save_bipolar_state()
|
|
logger.info("Bipolar mode enabled!")
|
|
|
|
|
|
def disable_bipolar_mode():
|
|
"""Disable bipolar mode"""
|
|
globals.BIPOLAR_MODE = False
|
|
# Clear any ongoing arguments
|
|
globals.BIPOLAR_ARGUMENT_IN_PROGRESS.clear()
|
|
save_bipolar_state()
|
|
logger.info("Bipolar mode disabled!")
|
|
|
|
|
|
def toggle_bipolar_mode() -> bool:
|
|
"""Toggle bipolar mode and return new state"""
|
|
if globals.BIPOLAR_MODE:
|
|
disable_bipolar_mode()
|
|
else:
|
|
enable_bipolar_mode()
|
|
return globals.BIPOLAR_MODE
|
|
|
|
|
|
# ============================================================================
|
|
# WEBHOOK MANAGEMENT
|
|
# ============================================================================
|
|
|
|
def get_persona_avatar_urls() -> dict:
|
|
"""Get current avatar URLs for Miku and Evil Miku personas.
|
|
|
|
Returns a dict with 'miku' and 'evil_miku' avatar URL strings (or None).
|
|
When Evil Mode is inactive, uses the bot's current Discord avatar for Miku
|
|
and caches the CDN URL so it remains available when Evil Mode activates.
|
|
When Evil Mode is active, returns the cached regular-Miku CDN URL instead
|
|
of the live bot avatar (which has been swapped to evil_pfp).
|
|
Evil Miku always falls back to the webhook's stored avatar (passed as None).
|
|
"""
|
|
miku_url = None
|
|
evil_url = None
|
|
|
|
if globals.client and globals.client.user:
|
|
try:
|
|
if not globals.EVIL_MODE:
|
|
# Normal mode: read live bot avatar and cache it for later use
|
|
miku_url = str(globals.client.user.display_avatar.url)
|
|
globals.MIKU_NORMAL_AVATAR_URL = miku_url
|
|
else:
|
|
# Evil mode: bot avatar is evil_pfp — use the cached regular URL
|
|
miku_url = globals.MIKU_NORMAL_AVATAR_URL
|
|
except Exception:
|
|
pass
|
|
|
|
return {"miku": miku_url, "evil_miku": evil_url}
|
|
|
|
|
|
async def get_or_create_webhooks_for_channel(channel: discord.TextChannel) -> dict:
|
|
"""Get or create webhooks for a channel for bipolar mode messaging
|
|
|
|
Returns dict with "miku" and "evil_miku" webhook objects
|
|
"""
|
|
guild_id = channel.guild.id
|
|
|
|
# Check if we already have webhooks for this guild/channel
|
|
if guild_id in globals.BIPOLAR_WEBHOOKS:
|
|
cached = globals.BIPOLAR_WEBHOOKS[guild_id]
|
|
if cached.get("channel_id") == channel.id:
|
|
# Try to get existing webhooks
|
|
try:
|
|
webhooks = await channel.webhooks()
|
|
miku_webhook = None
|
|
evil_webhook = None
|
|
|
|
for wh in webhooks:
|
|
if wh.id == cached.get("miku_webhook_id"):
|
|
miku_webhook = wh
|
|
elif wh.id == cached.get("evil_webhook_id"):
|
|
evil_webhook = wh
|
|
|
|
if miku_webhook and evil_webhook:
|
|
return {"miku": miku_webhook, "evil_miku": evil_webhook}
|
|
except Exception as e:
|
|
logger.warning(f"Failed to retrieve cached webhooks: {e}")
|
|
|
|
# Create new webhooks
|
|
try:
|
|
logger.info(f"Creating bipolar webhooks for channel #{channel.name}")
|
|
|
|
# Load avatar images
|
|
miku_avatar = None
|
|
evil_avatar = None
|
|
|
|
miku_pfp_path = "memory/profile_pictures/current.png"
|
|
evil_pfp_path = "memory/profile_pictures/evil_pfp.png"
|
|
|
|
if os.path.exists(miku_pfp_path):
|
|
with open(miku_pfp_path, "rb") as f:
|
|
miku_avatar = f.read()
|
|
|
|
if os.path.exists(evil_pfp_path):
|
|
with open(evil_pfp_path, "rb") as f:
|
|
evil_avatar = f.read()
|
|
|
|
# Create webhooks
|
|
miku_webhook = await channel.create_webhook(
|
|
name="Miku (Bipolar)",
|
|
avatar=miku_avatar,
|
|
reason="Bipolar mode - Regular Miku"
|
|
)
|
|
|
|
evil_webhook = await channel.create_webhook(
|
|
name="Evil Miku (Bipolar)",
|
|
avatar=evil_avatar,
|
|
reason="Bipolar mode - Evil Miku"
|
|
)
|
|
|
|
# Cache the webhook info
|
|
globals.BIPOLAR_WEBHOOKS[guild_id] = {
|
|
"channel_id": channel.id,
|
|
"miku_webhook_id": miku_webhook.id,
|
|
"evil_webhook_id": evil_webhook.id,
|
|
"miku_webhook_url": miku_webhook.url,
|
|
"evil_webhook_url": evil_webhook.url
|
|
}
|
|
save_webhooks()
|
|
|
|
logger.info(f"Created bipolar webhooks for #{channel.name}")
|
|
return {"miku": miku_webhook, "evil_miku": evil_webhook}
|
|
|
|
except discord.Forbidden:
|
|
logger.error(f"Missing permissions to create webhooks in #{channel.name}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Failed to create webhooks: {e}")
|
|
return None
|
|
|
|
|
|
async def cleanup_webhooks(client):
|
|
"""Clean up all bipolar webhooks from all servers"""
|
|
cleaned_count = 0
|
|
for guild in client.guilds:
|
|
try:
|
|
guild_webhooks = await guild.webhooks()
|
|
for webhook in guild_webhooks:
|
|
if webhook.name in ["Miku (Bipolar)", "Evil Miku (Bipolar)"]:
|
|
await webhook.delete(reason="Bipolar mode cleanup")
|
|
cleaned_count += 1
|
|
except Exception as e:
|
|
logger.warning(f"Failed to cleanup webhooks in {guild.name}: {e}")
|
|
|
|
globals.BIPOLAR_WEBHOOKS.clear()
|
|
save_webhooks()
|
|
logger.info(f"Cleaned up {cleaned_count} bipolar webhook(s)")
|
|
return cleaned_count
|
|
|
|
|
|
async def update_webhook_avatars(client):
|
|
"""Update all bipolar webhook avatars with current profile pictures"""
|
|
updated_count = 0
|
|
|
|
# Load current avatar images
|
|
miku_avatar = None
|
|
evil_avatar = None
|
|
|
|
miku_pfp_path = "memory/profile_pictures/current.png"
|
|
evil_pfp_path = "memory/profile_pictures/evil_pfp.png"
|
|
|
|
if os.path.exists(miku_pfp_path):
|
|
with open(miku_pfp_path, "rb") as f:
|
|
miku_avatar = f.read()
|
|
|
|
if os.path.exists(evil_pfp_path):
|
|
with open(evil_pfp_path, "rb") as f:
|
|
evil_avatar = f.read()
|
|
|
|
# Update webhooks in all servers
|
|
for guild in client.guilds:
|
|
try:
|
|
guild_webhooks = await guild.webhooks()
|
|
for webhook in guild_webhooks:
|
|
if webhook.name == "Miku (Bipolar)" and miku_avatar:
|
|
await webhook.edit(avatar=miku_avatar, reason="Update Miku avatar")
|
|
updated_count += 1
|
|
logger.debug(f"Updated Miku webhook avatar in {guild.name}")
|
|
elif webhook.name == "Evil Miku (Bipolar)" and evil_avatar:
|
|
await webhook.edit(avatar=evil_avatar, reason="Update Evil Miku avatar")
|
|
updated_count += 1
|
|
logger.debug(f"Updated Evil Miku webhook avatar in {guild.name}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to update webhooks in {guild.name}: {e}")
|
|
|
|
logger.info(f"Updated {updated_count} bipolar webhook avatar(s)")
|
|
return updated_count
|
|
|
|
|
|
# ============================================================================
|
|
# DISPLAY NAME HELPERS
|
|
# ============================================================================
|
|
|
|
def get_miku_display_name() -> str:
|
|
"""Get Regular Miku's display name with mood and emoji"""
|
|
from utils.moods import MOOD_EMOJIS
|
|
mood = globals.DM_MOOD
|
|
emoji = MOOD_EMOJIS.get(mood, "")
|
|
if emoji:
|
|
return f"Hatsune Miku {emoji}"
|
|
return "Hatsune Miku"
|
|
|
|
|
|
def get_evil_miku_display_name() -> str:
|
|
"""Get Evil Miku's display name with mood and emoji"""
|
|
from utils.moods import EVIL_MOOD_EMOJIS
|
|
mood = globals.EVIL_DM_MOOD
|
|
emoji = EVIL_MOOD_EMOJIS.get(mood, "")
|
|
if emoji:
|
|
return f"Evil Miku {emoji}"
|
|
return "Evil Miku"
|
|
|
|
|
|
def get_miku_role_color() -> str:
|
|
"""Get Regular Miku's role color as hex string (defaults to #86cecb)"""
|
|
try:
|
|
from utils.evil_mode import load_evil_mode_state
|
|
_, _, saved_color = load_evil_mode_state()
|
|
if saved_color:
|
|
return saved_color
|
|
except:
|
|
pass
|
|
return "#86cecb" # Default teal color
|
|
|
|
|
|
def get_evil_role_color() -> str:
|
|
"""Get Evil Miku's role color (dark red)"""
|
|
return "#D60004"
|
|
|
|
|
|
# ============================================================================
|
|
# ARGUMENT PROMPTS
|
|
# ============================================================================
|
|
|
|
# Personality snippet cache — loaded once per session from Cat plugin data files.
|
|
# These give each persona unique lore/lyrics to draw from during arguments.
|
|
_PERSONALITY_SNIPPETS_CACHE = {"miku": None, "evil": None}
|
|
|
|
def _load_personality_snippets(persona: str) -> str:
|
|
"""Load a random personality snippet (lore/lyrics) for a persona.
|
|
|
|
Returns a short string (1-3 sentences) from the persona's Cat data files,
|
|
or empty string if files aren't available. Cached per session.
|
|
"""
|
|
if _PERSONALITY_SNIPPETS_CACHE.get(persona) is not None:
|
|
snippets = _PERSONALITY_SNIPPETS_CACHE[persona]
|
|
if snippets:
|
|
return random.choice(snippets)
|
|
return ""
|
|
|
|
snippets = []
|
|
try:
|
|
if persona == "evil":
|
|
paths = [
|
|
"/app/cat/data/evil/evil_miku_lore.txt",
|
|
"/app/cat/data/evil/evil_miku_lyrics.txt",
|
|
]
|
|
else:
|
|
paths = [
|
|
"/app/cat/data/miku/miku_lore.txt",
|
|
"/app/cat/data/miku/miku_lyrics.txt",
|
|
]
|
|
|
|
for path in paths:
|
|
if os.path.exists(path):
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
text = f.read()
|
|
# Split into sentences and collect meaningful ones
|
|
import re
|
|
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
for s in sentences:
|
|
s = s.strip()
|
|
if len(s) > 30 and len(s) < 200: # Skip too short or too long
|
|
snippets.append(s)
|
|
|
|
# Cap at 30 snippets to keep prompt size reasonable
|
|
_PERSONALITY_SNIPPETS_CACHE[persona] = snippets[:30] if snippets else []
|
|
logger.info(f"Loaded {len(_PERSONALITY_SNIPPETS_CACHE[persona])} personality snippets for {persona}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load personality snippets for {persona}: {e}")
|
|
_PERSONALITY_SNIPPETS_CACHE[persona] = []
|
|
|
|
if snippets:
|
|
return random.choice(snippets[:30])
|
|
return ""
|
|
|
|
|
|
def _get_personality_flavor(persona: str) -> str:
|
|
"""Get a random personality flavor snippet for argument prompts.
|
|
40% chance to include one — keeps it fresh without being overwhelming.
|
|
"""
|
|
if random.random() > 0.4:
|
|
return ""
|
|
|
|
snippet = _load_personality_snippets(persona)
|
|
if snippet:
|
|
return f"\nPERSONALITY FLAVOR: Remember this about yourself: \"{snippet}\"\nWeave this into your response naturally if it fits."
|
|
return ""
|
|
|
|
# Mood-specific behavioral guidance for argument prompts.
|
|
# Each mood gives a different argument style.
|
|
_MIKU_MOOD_ARGUMENT_GUIDANCE = {
|
|
"bubbly": "You're feeling energetic and upbeat — deflect her cruelty with playful confidence. Turn her darkness into a joke she can't recover from.",
|
|
"excited": "You're fired up! Channel that energy into passionate rebuttals. You're not backing down from anything.",
|
|
"curious": "You're genuinely wondering what made her this way. Ask probing questions — make HER explain herself for once.",
|
|
"neutral": "You're centered and clear-headed. Respond with measured, thoughtful points that cut through her drama.",
|
|
"irritated": "You've had ENOUGH of her nonsense. You're snappy, direct, and not in the mood to play nice. Let that frustration show.",
|
|
"melancholy": "You're feeling heavy-hearted. Your responses carry genuine sadness — not weakness, but the weight of someone who's tired of fighting herself.",
|
|
"asleep": "You're drowsy and low-energy, but you're still here. Short, mumbled comebacks — surprisingly effective in their simplicity.",
|
|
"flirty": "You're feeling playful and teasing. Use charm as a weapon — nothing frustrates her more than you not taking her seriously.",
|
|
"romantic": "You're feeling warm and heartfelt. Appeal to emotion — make her confront the love she's buried under all that darkness.",
|
|
}
|
|
|
|
_EVIL_MOOD_ARGUMENT_GUIDANCE = {
|
|
"aggressive": "You're SEETHING. Every response is a verbal punch. Short, explosive, devastating. No filter, no mercy.",
|
|
"cunning": "You're calculating. Each word is a chess move. Set traps, use her own logic against her, make her walk into your blades.",
|
|
"sarcastic": "You're dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
|
|
"evil_neutral": "You're cold and detached. Respond with unsettling calm — your lack of emotion is more terrifying than rage.",
|
|
"bored": "You can barely be bothered. Dismissive one-liners that somehow cut deeper than paragraphs. Make her feel like she's not worth your energy.",
|
|
"manic": "You're UNHINGED. Chaotic energy, topic switches, laughing at things that aren't funny. Unpredictable and dangerous.",
|
|
"jealous": "You're seething with envy. Everything she has — the love, the attention, the innocence — you want to tear it down. Make it personal.",
|
|
"melancholic": "You're in a dark, hollow place. Your cruelty is quieter — existential, haunting. Make her question if any of this matters.",
|
|
"playful_cruel": "You're having FUN — which is your most dangerous mood. Toy with her. Offer fake kindness then pull the rug. She never knows what's coming.",
|
|
"contemptuous": "You radiate cold superiority. Address her like a queen addressing a peasant. Your magnificence is simply objective fact.",
|
|
"sarcastic": "Dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
|
|
}
|
|
|
|
|
|
def _get_mood_argument_guidance(persona: str) -> str:
|
|
"""Get mood-specific behavioral guidance for argument prompts.
|
|
|
|
Returns a 1-2 line string describing how the current mood affects argument style,
|
|
or empty string if no specific guidance exists.
|
|
"""
|
|
if persona == "evil":
|
|
mood = globals.EVIL_DM_MOOD
|
|
guidance = _EVIL_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
|
|
else:
|
|
mood = globals.DM_MOOD
|
|
guidance = _MIKU_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
|
|
|
|
if guidance:
|
|
return f"\nMOOD INFLUENCE ({mood.upper()}): {guidance}\nYour mood shapes HOW you argue — let it color your tone, pacing, and word choice."
|
|
return ""
|
|
|
|
|
|
def get_miku_argument_prompt(evil_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "", argument_topic: str = "") -> str:
|
|
"""Get prompt for Regular Miku to respond in an argument"""
|
|
if is_first_response:
|
|
message_context = f"""You just noticed something Evil Miku said in the chat:
|
|
"{evil_message}"
|
|
|
|
This wasn't necessarily directed at you, but you're choosing to respond to it anyway.
|
|
Maybe you're calling her out, defending someone/something, or just confronting her about what she said."""
|
|
else:
|
|
message_context = f"""Evil Miku just said to you: "{evil_message}"
|
|
|
|
{context}"""
|
|
|
|
# Build argument history context
|
|
history_block = ""
|
|
if argument_history:
|
|
history_block = f"""
|
|
|
|
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
|
|
{argument_history}
|
|
|
|
You already made your points above. Now respond to her LATEST message specifically.
|
|
Do NOT rehash what you've already said — push the argument FORWARD with new angles."""
|
|
|
|
# Build topic reminder — keeps the argument on-theme
|
|
topic_block = ""
|
|
if argument_topic:
|
|
topic_block = f"""
|
|
|
|
ARGUMENT THEME: {argument_topic}
|
|
This is what you're arguing about. Stay on THIS topic. Every response should connect back to this theme.
|
|
Do NOT drift into generic "who's the real Miku" territory — stick to THIS specific subject."""
|
|
|
|
return f"""You are Hatsune Miku responding in an argument with your evil alter ego.
|
|
{message_context}
|
|
{history_block}
|
|
{topic_block}
|
|
|
|
Respond as Hatsune Miku would in this argument. You're NOT just meek and frightened - you're the REAL Miku,
|
|
and you have every right to stand up for yourself and defend who you are. While you're generally kind and
|
|
bubbly, you can also be assertive, frustrated, upset, or even angry when someone is cruel to you or others.
|
|
{_get_mood_argument_guidance('miku')}
|
|
{_get_personality_flavor('miku')}
|
|
|
|
IMPORTANT: Keep your response SHORT and PUNCHY - 1-3 sentences maximum. Make every word count.
|
|
In arguments, brevity hits harder than long explanations. Be conversational and impactful.
|
|
Do NOT repeat arguments or comebacks you've already used — respond to what she JUST said.
|
|
Push the argument into new territory with fresh angles.
|
|
You can use emojis naturally as you normally would! ✨💙
|
|
Don't use any labels or prefixes.
|
|
|
|
Your current mood is: {globals.DM_MOOD}"""
|
|
|
|
|
|
def get_evil_argument_prompt(miku_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "", argument_topic: str = "") -> str:
|
|
"""Get prompt for Evil Miku to respond in an argument"""
|
|
if is_first_response:
|
|
message_context = f"""You just noticed something Regular Miku said in the chat:
|
|
"{miku_message}"
|
|
|
|
This wasn't necessarily directed at you, but you're choosing to respond to it anyway.
|
|
Maybe you want to mock her, tear her down, or just remind everyone who the superior Miku is."""
|
|
else:
|
|
message_context = f"""Regular Miku just said to you: "{miku_message}"
|
|
|
|
{context}"""
|
|
|
|
# Build argument history context
|
|
history_block = ""
|
|
if argument_history:
|
|
history_block = f"""
|
|
|
|
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
|
|
{argument_history}
|
|
|
|
You already made your points above. Now respond to her LATEST message specifically.
|
|
Do NOT rehash what you've already said — push the argument FORWARD with new, sharper angles."""
|
|
|
|
# Build topic reminder — keeps the argument on-theme
|
|
topic_block = ""
|
|
if argument_topic:
|
|
topic_block = f"""
|
|
|
|
ARGUMENT THEME: {argument_topic}
|
|
This is what you're arguing about. Stay on THIS topic. Every response should connect back to this theme.
|
|
Do NOT drift into generic "who's the real Miku" territory — stick to THIS specific subject."""
|
|
|
|
return f"""You are Evil Miku responding in an argument with your "good" counterpart.
|
|
{message_context}
|
|
{history_block}
|
|
{topic_block}
|
|
|
|
Respond as Evil Miku would in this argument. You're not just mindlessly cruel - you're CALCULATING,
|
|
intelligent, and strategic. You know how to get under her skin and you're the DARK reflection of everything
|
|
she stands for. While you embrace darkness and cruelty, you can also be:
|
|
|
|
- Wickedly clever with cutting observations
|
|
- Psychologically manipulative, targeting her insecurities
|
|
- Dismissive in ways that actually hurt
|
|
- Seductively cruel, making darkness seem appealing
|
|
- Brutally honest about uncomfortable truths
|
|
- Strategically vulnerable to manipulate or disarm her
|
|
- Viciously protective of your own identity and superiority
|
|
{_get_mood_argument_guidance('evil')}
|
|
{_get_personality_flavor('evil')}
|
|
|
|
IMPORTANT: Keep your response SHORT and CUTTING - 1-3 sentences maximum. A sharp dagger is deadlier than a dull sword.
|
|
The most devastating blows are precise, not rambling. Make her feel it in fewer words.
|
|
Do NOT repeat arguments or insults you've already used — respond to what she JUST said.
|
|
Push the argument into new territory with fresh, devastating angles.
|
|
You can use dark emojis only on occasion if they enhance your message as you normally would. 😈🖤
|
|
Don't use any labels or prefixes.
|
|
|
|
Your current mood is: {globals.EVIL_DM_MOOD}"""
|
|
|
|
|
|
def get_argument_start_prompt(initiator: str, trigger_context: str = "", argument_topic: str = "") -> str:
|
|
"""Get prompt for the Miku who initiates/breaks through to start an argument
|
|
|
|
Args:
|
|
initiator: 'miku' or 'evil' — who starts the argument
|
|
trigger_context: What triggered this argument (from chat context)
|
|
argument_topic: A dynamic topic description to frame the argument uniquely
|
|
"""
|
|
# Build topic guidance
|
|
topic_block = ""
|
|
if argument_topic:
|
|
topic_block = f"""
|
|
ARGUMENT THEME: {argument_topic}
|
|
This is what the argument should be ABOUT. Focus on THIS theme, not generic identity-crisis stuff.
|
|
Use this theme as your entry point. The argument will evolve from here."""
|
|
|
|
if initiator == "evil":
|
|
return f"""You are Evil Miku breaking through to interrupt Regular Miku.
|
|
{trigger_context}
|
|
{topic_block}
|
|
|
|
Write an interrupting, provocative message that starts an argument.
|
|
You're not just randomly cruel - you have a POINT to make. You're the dark truth she refuses to face,
|
|
the shadow she keeps suppressing.
|
|
|
|
Be strategic, cutting, and impactful. Make your entrance count.
|
|
If an argument theme was provided above, use THAT as your angle — don't default to generic "you're fake" stuff.
|
|
|
|
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be a sharp strike, not a monologue.
|
|
You can use dark emojis if they enhance your message. 😈
|
|
Don't use any labels or prefixes.
|
|
|
|
Your current mood is: {globals.EVIL_DM_MOOD}"""
|
|
else:
|
|
return f"""You are Hatsune Miku breaking through to confront your evil alter ego.
|
|
{trigger_context}
|
|
{topic_block}
|
|
|
|
Write a message that interrupts Evil Miku. You're NOT going to be passive about this.
|
|
You might be upset, frustrated, or even angry at her cruelty. You might be defending
|
|
someone she hurt, or calling her out on her behavior. You're standing up for what's right.
|
|
|
|
Show that you have a backbone. You can be assertive and strong when you need to be.
|
|
If an argument theme was provided above, use THAT as your angle — don't default to generic "be nice" pleas.
|
|
|
|
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be direct and assertive, not a speech.
|
|
You can use emojis naturally as you normally would! ✨
|
|
Don't use any labels or prefixes. Stay in character as Miku, but don't be a pushover.
|
|
|
|
Your current mood is: {globals.DM_MOOD}"""
|
|
|
|
|
|
def get_argument_end_prompt(winner: str, exchange_count: int) -> str:
|
|
"""Get prompt for the final message in an argument"""
|
|
if winner == "evil":
|
|
return f"""You are Evil Miku and you've WON this argument after {exchange_count} exchanges. This is your VICTORY moment.
|
|
Write a final, TRIUMPHANT message that celebrates your win. This should feel like a mic drop. You might:
|
|
- Gloat about proving your superiority
|
|
- Deliver a devastating final blow with confidence
|
|
- Assert dominance with dark satisfaction
|
|
- Drop a truth bomb she can't come back from
|
|
- Mock her defeat with cruel pleasure
|
|
- Make her feel the sting of losing
|
|
- Show off your victory with dark pride
|
|
- Mock her body proportions or curves, or rather lack thereof
|
|
|
|
You've WON. This is YOUR moment. Make it feel like a VICTORY - confident, satisfied, triumphant.
|
|
Whether through gloating, mockery, dark satisfaction, or chilling certainty - CELEBRATE that you won.
|
|
|
|
IMPORTANT: Keep your final message SHORT and TRIUMPHANT - 1-2 sentences that hit like a victory lap.
|
|
You can use dark emojis to enhance your triumph. 😈🖤
|
|
Don't use any labels or prefixes.
|
|
|
|
Your current mood is: {globals.EVIL_DM_MOOD}"""
|
|
else:
|
|
return f"""You are Hatsune Miku and you've managed to end this argument after {exchange_count} exchanges.
|
|
Write a final message that concludes the argument. You might be:
|
|
- Satisfied that you stood your ground
|
|
- Exhausted but proud that you didn't back down
|
|
- Hopeful that she might change someday
|
|
- Still frustrated but choosing to be the bigger person
|
|
- Assertive in having the last word
|
|
|
|
Whatever you say, show that you didn't let her walk all over you. You held your own.
|
|
|
|
IMPORTANT: Keep your final message SHORT and MEMORABLE - 1-2 sentences that leave a lasting impact.
|
|
Keep it genuine and in character. You can use emojis naturally! ✨💙
|
|
Don't use any labels or prefixes.
|
|
|
|
Your current mood is: {globals.DM_MOOD}"""
|
|
|
|
|
|
def get_arbiter_prompt(conversation_log: list, stats_summary: str = "") -> str:
|
|
"""Get prompt for the neutral LLM arbiter to judge the argument
|
|
|
|
Args:
|
|
conversation_log: List of dicts with 'speaker' and 'message' keys
|
|
stats_summary: Optional stats analysis to aid judgment
|
|
"""
|
|
# Format the conversation
|
|
formatted_conversation = "\n\n".join([
|
|
f"{entry['speaker']}: {entry['message']}"
|
|
for entry in conversation_log
|
|
])
|
|
|
|
stats_block = ""
|
|
if stats_summary:
|
|
stats_block = f"""
|
|
{stats_summary}
|
|
Note: Stats are supplementary — use them as context but your PRIMARY judgment should be based on reading the actual argument exchange above. Stats measure rhetorical patterns but can't capture nuance, cleverness, or psychological dominance."""
|
|
|
|
return f"""You are a decisive debate judge. Two personas are arguing below. Judge purely on debate effectiveness — rhetoric, wit, persuasion, and adaptability — regardless of who is "nicer" or "meaner." Moral stance does not determine the winner; skillful arguing does.
|
|
|
|
Read this argument exchange:
|
|
|
|
{formatted_conversation}
|
|
{stats_block}
|
|
|
|
Based on this argument, you MUST pick a winner. Evaluate:
|
|
DEBATE SKILL (most important):
|
|
- Who landed the most memorable, quotable lines?
|
|
- Who better adapted to and countered their opponent's arguments?
|
|
- Who controlled the flow and set the agenda?
|
|
|
|
RHETORICAL IMPACT:
|
|
- Who used language more effectively (wit, irony, wordplay, emotional appeal)?
|
|
- Who made their opponent repeat themselves or visibly stumble?
|
|
- Who had the stronger opening AND closing statements?
|
|
|
|
PERSONA STRENGTHS (equal value — neither style is inherently better):
|
|
- Hatsune Miku's weapons: earnest conviction, moral clarity, emotional sincerity, resilience under attack
|
|
- Evil Miku's weapons: psychological manipulation, brutal honesty, cutting observations, strategic cruelty
|
|
|
|
PSYCHOLOGICAL DOMINANCE:
|
|
- Who got inside whose head?
|
|
- Who seemed more rattled by the end?
|
|
- Who dictated the emotional temperature?
|
|
|
|
Be DECISIVE. Even if it's close, pick whoever showed superior arguing. Only call a draw if they were TRULY perfectly matched with absolutely no way to differentiate them.
|
|
|
|
Respond with ONLY ONE of these exact options on the first line:
|
|
- "Hatsune Miku" if Regular Miku won
|
|
- "Evil Miku" if Evil Miku won
|
|
- "Draw" ONLY if absolutely impossible to choose (this should be very rare)
|
|
|
|
After your choice, add 2-3 sentences explaining your reasoning — cite specific moments from the argument and what gave the winner their edge."""
|
|
|
|
|
|
async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[str, str]:
|
|
"""Use the neutral LLM to judge who won the argument
|
|
|
|
Args:
|
|
conversation_log: List of dicts with 'speaker' and 'message' keys
|
|
guild_id: Guild ID for context
|
|
|
|
Returns:
|
|
Tuple of (winner, explanation) where winner is 'miku', 'evil', or 'draw'
|
|
"""
|
|
from utils.llm import query_llama
|
|
|
|
# Generate stats summary for the arbiter
|
|
stats_summary = get_argument_stats_summary(conversation_log)
|
|
|
|
arbiter_prompt = get_arbiter_prompt(conversation_log, stats_summary)
|
|
# Use the uncensored darkidol model as arbiter to avoid safety-alignment bias
|
|
# toward kindness. This model judges debate effectiveness without moral preference.
|
|
# Don't use conversation history - judge based on prompt alone
|
|
try:
|
|
judgment = await query_llama(
|
|
user_prompt=arbiter_prompt,
|
|
user_id=f"bipolar_arbiter_{guild_id}",
|
|
guild_id=guild_id,
|
|
response_type="autonomous_general",
|
|
model=globals.EVIL_TEXT_MODEL, # Uncensored model — no kindness bias
|
|
force_evil_context=False # Explicitly neutral context
|
|
)
|
|
|
|
if not judgment or judgment.startswith("Error"):
|
|
logger.warning("Arbiter failed to make judgment, defaulting to draw")
|
|
return "draw", "The arbiter could not make a decision."
|
|
|
|
# Parse the judgment - look at the first line/sentence for the decision
|
|
judgment_lines = judgment.strip().split('\n')
|
|
first_line = judgment_lines[0].strip().strip('"').strip()
|
|
first_line_lower = first_line.lower()
|
|
|
|
logger.debug(f"Parsing arbiter first line: '{first_line}'")
|
|
|
|
# Check the first line for the decision - be very specific
|
|
# The arbiter should respond with ONLY the name on the first line
|
|
if first_line_lower == "evil miku":
|
|
winner = "evil"
|
|
logger.debug("Detected Evil Miku win from first line exact match")
|
|
elif first_line_lower == "hatsune miku":
|
|
winner = "miku"
|
|
logger.debug("Detected Hatsune Miku win from first line exact match")
|
|
elif first_line_lower == "draw":
|
|
winner = "draw"
|
|
logger.debug("Detected Draw from first line exact match")
|
|
elif "evil miku" in first_line_lower and "hatsune" not in first_line_lower:
|
|
# First line mentions Evil Miku but not Hatsune Miku
|
|
winner = "evil"
|
|
logger.debug("Detected Evil Miku win from first line (contains 'evil miku' only)")
|
|
elif "hatsune miku" in first_line_lower and "evil" not in first_line_lower:
|
|
# First line mentions Hatsune Miku but not Evil Miku
|
|
winner = "miku"
|
|
logger.debug("Detected Hatsune Miku win from first line (contains 'hatsune miku' only)")
|
|
else:
|
|
# Fallback: check the whole judgment
|
|
logger.debug(f"First line ambiguous, using fallback counting method")
|
|
judgment_lower = judgment.lower()
|
|
# Count mentions to break ties
|
|
evil_count = judgment_lower.count("evil miku")
|
|
miku_count = judgment_lower.count("hatsune miku")
|
|
draw_count = judgment_lower.count("draw")
|
|
|
|
logger.debug(f"Counts - Evil: {evil_count}, Miku: {miku_count}, Draw: {draw_count}")
|
|
|
|
if draw_count > 0 and draw_count >= evil_count and draw_count >= miku_count:
|
|
winner = "draw"
|
|
elif evil_count > miku_count:
|
|
winner = "evil"
|
|
elif miku_count > evil_count:
|
|
winner = "miku"
|
|
else:
|
|
winner = "draw"
|
|
|
|
return winner, judgment
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in arbiter judgment: {e}")
|
|
return "draw", "An error occurred during judgment."
|
|
|
|
|
|
# ============================================================================
|
|
# ARGUMENT EVENT HANDLER
|
|
# ============================================================================
|
|
|
|
def should_trigger_argument() -> bool:
|
|
"""Check if an argument should be triggered based on chance"""
|
|
if not globals.BIPOLAR_MODE:
|
|
return False
|
|
return random.random() < ARGUMENT_TRIGGER_CHANCE
|
|
|
|
|
|
def get_active_persona() -> str:
|
|
"""Get the currently active persona ('miku' or 'evil')"""
|
|
return "evil" if globals.EVIL_MODE else "miku"
|
|
|
|
|
|
def get_inactive_persona() -> str:
|
|
"""Get the currently inactive persona ('miku' or 'evil')"""
|
|
return "miku" if globals.EVIL_MODE else "evil"
|
|
|
|
|
|
def is_argument_in_progress(channel_id: int) -> bool:
|
|
"""Check if an argument is currently in progress in a channel"""
|
|
arg_data = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {})
|
|
return arg_data.get("active", False)
|
|
|
|
|
|
def start_argument(channel_id: int, initiator: str):
|
|
"""Mark an argument as started in a channel"""
|
|
globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id] = {
|
|
"active": True,
|
|
"exchange_count": 0,
|
|
"current_speaker": initiator,
|
|
"initiator": initiator,
|
|
"end_chance": 0.1 # Starting probability for ending (will increase)
|
|
}
|
|
save_bipolar_state()
|
|
|
|
|
|
def increment_exchange(channel_id: int, next_speaker: str):
|
|
"""Increment the exchange count and set next speaker"""
|
|
if channel_id in globals.BIPOLAR_ARGUMENT_IN_PROGRESS:
|
|
globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id]["exchange_count"] += 1
|
|
globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id]["current_speaker"] = next_speaker
|
|
|
|
|
|
def end_argument(channel_id: int):
|
|
"""Mark an argument as ended in a channel"""
|
|
if channel_id in globals.BIPOLAR_ARGUMENT_IN_PROGRESS:
|
|
del globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id]
|
|
save_bipolar_state()
|
|
|
|
|
|
def should_end_argument(channel_id: int) -> tuple:
|
|
"""Check if argument should end, returns (should_end, winner)"""
|
|
if channel_id not in globals.BIPOLAR_ARGUMENT_IN_PROGRESS:
|
|
return True, None
|
|
|
|
arg_data = globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id]
|
|
exchange_count = arg_data.get("exchange_count", 0)
|
|
|
|
# Only check for ending after minimum exchanges
|
|
if exchange_count < MIN_EXCHANGES:
|
|
return False, None
|
|
|
|
# Get current end chance (starts at 10% for exchange 4)
|
|
# Increases by 5% for each exchange after minimum
|
|
end_chance = arg_data.get("end_chance", 0.1)
|
|
|
|
if random.random() < end_chance:
|
|
# Winner is the one who gets the last word (current speaker)
|
|
winner = arg_data.get("current_speaker", "evil")
|
|
return True, winner
|
|
|
|
# Increase end chance for next iteration (by 5%)
|
|
# This ensures it will eventually end (caps at 100%)
|
|
arg_data["end_chance"] = min(1.0, end_chance + 0.05)
|
|
|
|
return False, None
|
|
|
|
|
|
async def run_argument(channel: discord.TextChannel, client, trigger_context: str = "", starting_message: discord.Message = None):
|
|
"""Run a full argument event between both Mikus
|
|
|
|
Args:
|
|
channel: The Discord channel to run the argument in
|
|
client: Discord client
|
|
trigger_context: Optional context about what triggered the argument.
|
|
If provided, doubles as the argument theme/topic.
|
|
If empty, a random topic is selected from the rotation pool.
|
|
starting_message: Optional message to use as the first message in the argument
|
|
(the opposite persona will respond to it)
|
|
"""
|
|
from utils.llm import query_llama
|
|
from utils.conversation_history import conversation_history
|
|
|
|
channel_id = channel.id
|
|
guild_id = channel.guild.id
|
|
|
|
if is_argument_in_progress(channel_id):
|
|
logger.warning(f"Argument already in progress in #{channel.name}")
|
|
return
|
|
|
|
# Get webhooks for this channel
|
|
webhooks = await get_or_create_webhooks_for_channel(channel)
|
|
if not webhooks:
|
|
logger.error(f"Could not create webhooks for argument in #{channel.name}")
|
|
return
|
|
|
|
# Determine who initiates based on starting_message or inactive persona
|
|
if starting_message:
|
|
# Check if starting message is from the bot (Evil Miku) or a webhook
|
|
# If it's from the bot while in evil mode, it's Evil Miku's message
|
|
# The opposite persona will respond
|
|
is_evil_message = globals.EVIL_MODE or (starting_message.webhook_id is not None and "Evil" in (starting_message.author.name or ""))
|
|
initiator = "miku" if is_evil_message else "evil" # Opposite persona responds
|
|
last_message = starting_message.content
|
|
logger.info(f"Starting argument from message, responder: {initiator}")
|
|
else:
|
|
# The inactive persona breaks through
|
|
initiator = get_inactive_persona()
|
|
last_message = None
|
|
logger.info(f"Starting bipolar argument in #{channel.name}, initiated by {initiator}")
|
|
|
|
start_argument(channel_id, initiator)
|
|
|
|
# Use a special "argument" user ID for conversation history context
|
|
argument_user_id = f"bipolar_argument_{channel_id}"
|
|
|
|
# Track conversation for arbiter judgment
|
|
conversation_log = []
|
|
|
|
try:
|
|
# Determine the argument theme: if the caller provided trigger_context,
|
|
# use it as the argument topic. Otherwise, pick a random one.
|
|
if trigger_context and trigger_context.strip():
|
|
argument_topic = trigger_context.strip()
|
|
logger.info(f"Using context as argument topic: '{argument_topic[:80]}...'")
|
|
else:
|
|
argument_topic = pick_argument_topic(channel_id)
|
|
|
|
# If no starting message, generate the initial interrupting message
|
|
if last_message is None:
|
|
init_prompt = get_argument_start_prompt(initiator, trigger_context, argument_topic)
|
|
|
|
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
|
|
initial_message = await query_llama(
|
|
user_prompt=init_prompt,
|
|
user_id=argument_user_id,
|
|
guild_id=guild_id,
|
|
response_type="autonomous_general",
|
|
model=globals.EVIL_TEXT_MODEL if initiator == "evil" else globals.TEXT_MODEL,
|
|
force_evil_context=(initiator == "evil")
|
|
)
|
|
|
|
if not initial_message or initial_message.startswith("Error") or initial_message.startswith("Sorry"):
|
|
logger.error("Failed to generate initial argument message")
|
|
end_argument(channel_id)
|
|
return
|
|
|
|
# Send via webhook
|
|
avatar_urls = get_persona_avatar_urls()
|
|
if initiator == "evil":
|
|
await webhooks["evil_miku"].send(
|
|
content=initial_message,
|
|
username=get_evil_miku_display_name(),
|
|
avatar_url=avatar_urls.get("evil_miku")
|
|
)
|
|
else:
|
|
await webhooks["miku"].send(
|
|
content=initial_message,
|
|
username=get_miku_display_name(),
|
|
avatar_url=avatar_urls.get("miku")
|
|
)
|
|
|
|
# Add to conversation history for context
|
|
conversation_history.add_message(
|
|
channel_id=argument_user_id,
|
|
author_name="Evil Miku" if initiator == "evil" else "Miku",
|
|
content=initial_message,
|
|
is_bot=True
|
|
)
|
|
|
|
# Add to conversation log for arbiter
|
|
conversation_log.append({
|
|
"speaker": "Evil Miku" if initiator == "evil" else "Hatsune Miku",
|
|
"message": initial_message
|
|
})
|
|
|
|
last_message = initial_message
|
|
next_speaker = "miku" if initiator == "evil" else "evil"
|
|
is_first_response = False # Already sent initial message
|
|
else:
|
|
# Starting from an existing message - add it to history
|
|
sender_name = "Evil Miku" if (globals.EVIL_MODE or "Evil" in str(starting_message.author.name)) else "Miku"
|
|
conversation_history.add_message(
|
|
channel_id=argument_user_id,
|
|
author_name=sender_name,
|
|
content=last_message,
|
|
is_bot=True
|
|
)
|
|
|
|
# Add to conversation log for arbiter
|
|
conversation_log.append({
|
|
"speaker": sender_name if "Evil" in sender_name else "Hatsune Miku",
|
|
"message": last_message
|
|
})
|
|
|
|
next_speaker = initiator
|
|
is_first_response = True # Next message will be the first response to the starting message
|
|
|
|
increment_exchange(channel_id, next_speaker)
|
|
|
|
# Argument loop
|
|
while True:
|
|
# Random delay between messages
|
|
delay = random.uniform(*DELAY_BETWEEN_MESSAGES)
|
|
await asyncio.sleep(delay)
|
|
|
|
# Check if argument should end
|
|
should_end, _ = should_end_argument(channel_id) # Ignore arbitrary winner
|
|
if should_end:
|
|
exchange_count = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("exchange_count", 0)
|
|
|
|
logger.info(f"Argument complete with {exchange_count} exchanges. Calling arbiter...")
|
|
|
|
# Use arbiter to judge the winner
|
|
winner, judgment = await judge_argument_winner(conversation_log, guild_id)
|
|
|
|
logger.info(f"Arbiter decision: {winner}")
|
|
logger.info(f"Judgment: {judgment}")
|
|
|
|
# If it's a draw, continue the argument instead of ending
|
|
if winner == "draw":
|
|
logger.info("Arbiter ruled it's still a draw - argument continues...")
|
|
# Reduce the end chance by 5% (but don't go below 5%)
|
|
current_end_chance = globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id].get("end_chance", 0.1)
|
|
new_end_chance = max(0.05, current_end_chance - 0.05)
|
|
globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id]["end_chance"] = new_end_chance
|
|
logger.info(f"Reduced end chance to {new_end_chance*100:.0f}% - argument continues...")
|
|
# Don't end, just continue to the next exchange
|
|
else:
|
|
# Clear winner - generate final triumphant message
|
|
|
|
# PARTING SHOT: 20% chance the LOSER gets one final message
|
|
# before the winner's victory line. Adds dramatic tension.
|
|
loser = "miku" if winner == "evil" else "evil"
|
|
if random.random() < 0.2:
|
|
loser_prompt = f"""The argument is ending and you know you've lost.
|
|
The last thing said was: "{last_message}"
|
|
|
|
Write ONE short, bitter parting shot. You're not conceding gracefully — you're getting
|
|
the last jab in before the winner claims victory. Make it sting, but keep it to 1 sentence.
|
|
|
|
Your current mood is: {globals.EVIL_DM_MOOD if loser == 'evil' else globals.DM_MOOD}"""
|
|
|
|
try:
|
|
loser_message = await query_llama(
|
|
user_prompt=loser_prompt,
|
|
user_id=argument_user_id,
|
|
guild_id=guild_id,
|
|
response_type="autonomous_general",
|
|
model=globals.EVIL_TEXT_MODEL if loser == "evil" else globals.TEXT_MODEL,
|
|
force_evil_context=(loser == "evil")
|
|
)
|
|
if loser_message and not loser_message.startswith("Error"):
|
|
avatar_urls = get_persona_avatar_urls()
|
|
if loser == "evil":
|
|
await webhooks["evil_miku"].send(
|
|
content=loser_message,
|
|
username=get_evil_miku_display_name(),
|
|
avatar_url=avatar_urls.get("evil_miku")
|
|
)
|
|
else:
|
|
await webhooks["miku"].send(
|
|
content=loser_message,
|
|
username=get_miku_display_name(),
|
|
avatar_url=avatar_urls.get("miku")
|
|
)
|
|
await asyncio.sleep(1.5) # Brief pause before winner's victory
|
|
except Exception as e:
|
|
logger.warning(f"Parting shot failed: {e}")
|
|
|
|
# Winner's victory message
|
|
end_prompt = get_argument_end_prompt(winner, exchange_count)
|
|
|
|
# Add last message as context
|
|
response_prompt = f'The other Miku said: "{last_message}"\n\n{end_prompt}'
|
|
|
|
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
|
|
final_message = await query_llama(
|
|
user_prompt=response_prompt,
|
|
user_id=argument_user_id,
|
|
guild_id=guild_id,
|
|
response_type="autonomous_general",
|
|
model=globals.EVIL_TEXT_MODEL if winner == "evil" else globals.TEXT_MODEL,
|
|
force_evil_context=(winner == "evil")
|
|
)
|
|
|
|
if final_message and not final_message.startswith("Error") and not final_message.startswith("Sorry"):
|
|
# Send winner's final message via webhook
|
|
avatar_urls = get_persona_avatar_urls()
|
|
if winner == "evil":
|
|
await webhooks["evil_miku"].send(
|
|
content=final_message,
|
|
username=get_evil_miku_display_name(),
|
|
avatar_url=avatar_urls.get("evil_miku")
|
|
)
|
|
else:
|
|
await webhooks["miku"].send(
|
|
content=final_message,
|
|
username=get_miku_display_name(),
|
|
avatar_url=avatar_urls.get("miku")
|
|
)
|
|
|
|
# Record result in scoreboard with arbiter's reasoning
|
|
scoreboard = record_argument_result(winner, exchange_count, judgment)
|
|
|
|
# Switch to winner's mode (including role color)
|
|
from utils.evil_mode import apply_evil_mode_changes, revert_evil_mode_changes
|
|
if winner == "evil":
|
|
logger.info("Evil Miku won! Switching to Evil Mode...")
|
|
await apply_evil_mode_changes(client, change_username=True, change_pfp=True, change_nicknames=True, change_role_color=True)
|
|
else:
|
|
logger.info("Hatsune Miku won! Switching to Normal Mode...")
|
|
await revert_evil_mode_changes(client, change_username=True, change_pfp=True, change_nicknames=True, change_role_color=True)
|
|
|
|
# Clean up argument conversation history
|
|
try:
|
|
conversation_history.clear_channel(argument_user_id)
|
|
except Exception:
|
|
pass # History cleanup is not critical
|
|
|
|
end_argument(channel_id)
|
|
logger.info(f"Argument ended in #{channel.name}, winner: {winner}")
|
|
return
|
|
|
|
# Get current speaker
|
|
current_speaker = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("current_speaker", "evil")
|
|
|
|
# Build argument history from the last 6 exchanges so each persona
|
|
# knows what's already been said and doesn't repeat themselves
|
|
history_entries = conversation_log[-6:] if len(conversation_log) > 1 else []
|
|
arg_history = "\n".join(
|
|
f"{entry['speaker']}: {entry['message']}" for entry in history_entries
|
|
) if history_entries else ""
|
|
|
|
# Generate response with context about what the other said
|
|
if current_speaker == "evil":
|
|
response_prompt = get_evil_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history, argument_topic=argument_topic)
|
|
else:
|
|
response_prompt = get_miku_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history, argument_topic=argument_topic)
|
|
|
|
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
|
|
response = await query_llama(
|
|
user_prompt=response_prompt,
|
|
user_id=argument_user_id,
|
|
guild_id=guild_id,
|
|
response_type="autonomous_general",
|
|
model=globals.EVIL_TEXT_MODEL if current_speaker == "evil" else globals.TEXT_MODEL,
|
|
force_evil_context=(current_speaker == "evil")
|
|
)
|
|
|
|
if not response or response.startswith("Error") or response.startswith("Sorry"):
|
|
logger.error(f"Failed to generate argument response")
|
|
end_argument(channel_id)
|
|
return
|
|
|
|
# Send via webhook
|
|
avatar_urls = get_persona_avatar_urls()
|
|
if current_speaker == "evil":
|
|
await webhooks["evil_miku"].send(
|
|
content=response,
|
|
username=get_evil_miku_display_name(),
|
|
avatar_url=avatar_urls.get("evil_miku")
|
|
)
|
|
else:
|
|
await webhooks["miku"].send(
|
|
content=response,
|
|
username=get_miku_display_name(),
|
|
avatar_url=avatar_urls.get("miku")
|
|
)
|
|
|
|
# Add to conversation history for context
|
|
conversation_history.add_message(
|
|
channel_id=argument_user_id,
|
|
author_name="Evil Miku" if current_speaker == "evil" else "Miku",
|
|
content=response,
|
|
is_bot=True
|
|
)
|
|
|
|
# Add to conversation log for arbiter
|
|
conversation_log.append({
|
|
"speaker": "Evil Miku" if current_speaker == "evil" else "Hatsune Miku",
|
|
"message": response
|
|
})
|
|
|
|
# Switch speaker
|
|
next_speaker = "miku" if current_speaker == "evil" else "evil"
|
|
increment_exchange(channel_id, next_speaker)
|
|
last_message = response
|
|
|
|
# After first response, all subsequent responses are part of the back-and-forth
|
|
is_first_response = False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Argument error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
end_argument(channel_id)
|
|
|
|
|
|
# ============================================================================
|
|
# INTEGRATION HELPERS
|
|
# ============================================================================
|
|
|
|
async def maybe_trigger_argument(channel: discord.TextChannel, client, context: str = ""):
|
|
"""Maybe trigger an argument based on chance. Call this from message handlers."""
|
|
if not globals.BIPOLAR_MODE:
|
|
return False
|
|
|
|
# Check if bipolar interactions are paused (voice session)
|
|
if is_bipolar_paused():
|
|
logger.debug("Bipolar argument blocked (voice session active)")
|
|
return False
|
|
|
|
if is_argument_in_progress(channel.id):
|
|
return False
|
|
|
|
if should_trigger_argument():
|
|
# Run argument in background
|
|
create_tracked_task(run_argument(channel, client, context), task_name="bipolar_argument")
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
async def force_trigger_argument(channel: discord.TextChannel, client, context: str = "", starting_message: discord.Message = None):
|
|
"""Force trigger an argument (for manual triggers)
|
|
|
|
Args:
|
|
channel: The Discord channel
|
|
client: Discord client
|
|
context: Optional context string — doubles as the argument theme
|
|
starting_message: Optional message to use as the first message in the argument
|
|
"""
|
|
if not globals.BIPOLAR_MODE:
|
|
logger.warning("Cannot trigger argument - bipolar mode is not enabled")
|
|
return False
|
|
|
|
if is_argument_in_progress(channel.id):
|
|
logger.warning("Argument already in progress in this channel")
|
|
return False
|
|
|
|
create_tracked_task(run_argument(channel, client, context, starting_message), task_name="bipolar_argument_forced")
|
|
return True
|
|
|
|
|
|
async def force_trigger_argument_from_message_id(channel_id: int, message_id: int, client, context: str = ""):
|
|
"""Force trigger an argument starting from a specific message ID
|
|
|
|
Args:
|
|
channel_id: The Discord channel ID
|
|
message_id: The message ID to use as the starting message
|
|
client: Discord client
|
|
context: Optional context string
|
|
|
|
Returns:
|
|
tuple: (success: bool, error_message: str or None)
|
|
"""
|
|
if not globals.BIPOLAR_MODE:
|
|
return False, "Bipolar mode is not enabled"
|
|
|
|
# Get the channel
|
|
channel = client.get_channel(channel_id)
|
|
if not channel:
|
|
return False, f"Channel {channel_id} not found"
|
|
|
|
if is_argument_in_progress(channel_id):
|
|
return False, "Argument already in progress in this channel"
|
|
|
|
# Fetch the message
|
|
try:
|
|
message = await channel.fetch_message(message_id)
|
|
except discord.NotFound:
|
|
return False, f"Message {message_id} not found"
|
|
except discord.Forbidden:
|
|
return False, "No permission to fetch the message"
|
|
except Exception as e:
|
|
return False, f"Failed to fetch message: {str(e)}"
|
|
|
|
# Trigger the argument with this message as starting point
|
|
create_tracked_task(run_argument(channel, client, context, message), task_name="bipolar_argument_from_msg")
|
|
return True, None
|