diff --git a/bot/bot.py b/bot/bot.py index bc2b37b..960d93b 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -203,6 +203,32 @@ async def on_message(message): if is_persona_dialogue_active(message.channel.id): return + # Bipolar mode: check if the opposite persona should interject on user messages + # AND roll for random argument trigger (both non-blocking background tasks) + if not isinstance(message.channel, discord.DMChannel) and globals.BIPOLAR_MODE: + try: + from utils.persona_dialogue import check_for_interjection + from utils.bipolar_mode import maybe_trigger_argument, is_argument_in_progress as arg_in_progress + from utils.bipolar_mode import is_persona_dialogue_active as dialogue_active + from utils.task_tracker import create_tracked_task + + # Check interjection on user messages (opposite of current active persona) + if not message.author.bot or message.webhook_id: + current_persona = "evil" if globals.EVIL_MODE else "miku" + create_tracked_task( + check_for_interjection(message, current_persona), + task_name="interjection_check_user", + ) + + # Roll random argument trigger chance (15%) on eligible messages + if not arg_in_progress(message.channel.id) and not dialogue_active(message.channel.id): + create_tracked_task( + maybe_trigger_argument(message.channel, globals.client, "Triggered from conversation flow"), + task_name="random_argument_trigger", + ) + except Exception as e: + logger.error(f"Error in bipolar trigger checks: {e}") + if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference: async with message.channel.typing(): # Get replied-to user diff --git a/bot/utils/persona_dialogue.py b/bot/utils/persona_dialogue.py index 974e8df..6470035 100644 --- a/bot/utils/persona_dialogue.py +++ b/bot/utils/persona_dialogue.py @@ -40,10 +40,15 @@ DIALOGUE_TIMEOUT = 900 # 15 minutes max dialogue duration ARGUMENT_TENSION_THRESHOLD = 0.75 # Tension level that triggers argument escalation # Initial trigger settings -INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block -INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery +INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block PER CHANNEL +INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery PER CHANNEL INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection +# Conversation streak: if score is close but below threshold N times in a row, +# force a dialogue trigger (catches extended conversations building toward something) +STREAK_THRESHOLD = 3 # Number of near-miss messages before force trigger +STREAK_MIN_SCORE = 0.3 # Minimum score to count as a "near miss" + # ============================================================================ # INTERJECTION SCORER (Initial Trigger Decision) # ============================================================================ @@ -60,6 +65,8 @@ class InterjectionScorer: def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) + cls._instance._cooldowns = {} # Per-channel cooldown timestamps + cls._instance._streaks = {} # Per-channel near-miss streaks return cls._instance @property @@ -94,8 +101,9 @@ class InterjectionScorer: if not self._passes_basic_filter(message): return False, "basic_filter_failed", 0.0 - # Check cooldown - cooldown_mult = self._check_cooldown() + # Check per-channel cooldown + channel_id = message.channel.id + cooldown_mult = self._check_cooldown(channel_id) if cooldown_mult == 0.0: return False, "cooldown_active", 0.0 @@ -146,10 +154,17 @@ class InterjectionScorer: # Apply cooldown multiplier score *= cooldown_mult + # Check conversation streak (near-misses that build toward a trigger) + streak_triggered = self._check_streak(channel_id, score) + # Decision - should_interject = score >= INTERJECTION_THRESHOLD + should_interject = score >= INTERJECTION_THRESHOLD or streak_triggered reason_str = " | ".join(reasons) if reasons else "no_triggers" + if streak_triggered and not should_interject: + reason_str = "streak_force_trigger" + logger.info(f"[Interjection] Streak force trigger in channel {channel_id} (score: {score:.2f})") + if should_interject: logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})") logger.info(f" Reasons: {reason_str}") @@ -198,18 +213,22 @@ class InterjectionScorer: if opposite_persona == "evil": # Things Evil Miku can't resist commenting on TRIGGER_TOPICS = { - "optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing"], - "morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice"], - "weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know"], - "innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious"], + "optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing", "blessed", "grateful"], + "morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice", "the right", "better person"], + "weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know", "confused", "lost", "lonely", "alone"], + "innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious", "adorable"], + "enthusiasm": ["best day", "so excited", "can't wait", "so happy", "i love this", "this is great"], + "vulnerability": ["i think", "i feel", "maybe", "sometimes i wonder", "i wish", "i'm trying"], } else: # Things Miku can't ignore TRIGGER_TOPICS = { - "negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic"], - "cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool"], - "hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up"], - "evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic"], + "negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic", "ugly", "boring", "annoying"], + "cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool", "moron", "loser", "nobody"], + "hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up", "what's the point", "don't care", "doesn't matter", "who cares"], + "evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic", "beneath me", "waste of space"], + "provocation": ["fight me", "prove it", "make me", "i dare you", "try me", "you can't", "you won't"], + "dismissal": ["whatever", "shut up", "go away", "leave me alone", "not worth", "don't bother"], } total_matches = 0 @@ -217,7 +236,7 @@ class InterjectionScorer: matches = sum(1 for keyword in keywords if keyword in content_lower) total_matches += matches - return min(total_matches / 3.0, 1.0) + return min(total_matches / 2.0, 1.0) # Lower divisor = higher base scores def _check_emotional_intensity(self, content: str) -> float: """Check emotional intensity using sentiment analysis""" @@ -300,13 +319,11 @@ class InterjectionScorer: return min(score, 1.0) - def _check_cooldown(self) -> float: - """Check cooldown and return multiplier (0.0 = blocked, 1.0 = full)""" - if not hasattr(globals, 'LAST_PERSONA_DIALOGUE_TIME'): - globals.LAST_PERSONA_DIALOGUE_TIME = 0 - + def _check_cooldown(self, channel_id: int) -> float: + """Check per-channel cooldown and return multiplier (0.0 = blocked, 1.0 = full)""" current_time = time.time() - time_since_last = current_time - globals.LAST_PERSONA_DIALOGUE_TIME + last_time = self._cooldowns.get(channel_id, 0) + time_since_last = current_time - last_time if time_since_last < INTERJECTION_COOLDOWN_HARD: return 0.0 @@ -314,6 +331,35 @@ class InterjectionScorer: return (time_since_last - INTERJECTION_COOLDOWN_HARD) / (INTERJECTION_COOLDOWN_SOFT - INTERJECTION_COOLDOWN_HARD) else: return 1.0 + + def _update_cooldown(self, channel_id: int): + """Mark a dialogue as having started in this channel""" + self._cooldowns[channel_id] = time.time() + + def _check_streak(self, channel_id: int, score: float) -> bool: + """Track near-miss interjection scores. After STREAK_THRESHOLD consecutive + near-misses, force a trigger to catch extended conversations building tension.""" + if score >= INTERJECTION_THRESHOLD: + # Above threshold — reset streak (actual trigger handles it) + self._streaks[channel_id] = 0 + return False + + if score < STREAK_MIN_SCORE: + # Too low — reset streak + self._streaks[channel_id] = 0 + return False + + # Near miss — increment streak + current = self._streaks.get(channel_id, 0) + 1 + self._streaks[channel_id] = current + + logger.debug(f"[Streak] Channel {channel_id}: {current}/{STREAK_THRESHOLD} near-misses (score: {score:.2f})") + + if current >= STREAK_THRESHOLD: + self._streaks[channel_id] = 0 # Reset after force trigger + return True + + return False # ============================================================================ @@ -370,7 +416,9 @@ class PersonaDialogue: "last_speaker": None, } self.active_dialogues[channel_id] = state - globals.LAST_PERSONA_DIALOGUE_TIME = time.time() + # Update per-channel cooldown via the scorer + scorer = get_interjection_scorer() + scorer._update_cooldown(channel_id) logger.info(f"Started persona dialogue in channel {channel_id}") return state @@ -393,8 +441,8 @@ class PersonaDialogue: Returns delta to add to current tension score. """ - # Sentiment analysis - base_delta = 0.0 + # Natural tension decay — conversations cool off over time + base_delta = -0.03 if self.sentiment_analyzer: try: @@ -405,13 +453,13 @@ class PersonaDialogue: if is_negative: base_delta = sentiment_score * 0.15 else: - base_delta = -sentiment_score * 0.05 + base_delta = -sentiment_score * 0.08 # Stronger cooling for positive except Exception as e: logger.error(f"Sentiment analysis error in tension calc: {e}") text_lower = response_text.lower() - # Escalation patterns + # Escalation patterns (reduced weight: 0.05 per match) escalation_patterns = { "insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"], "dismissive": ["whatever", "don't care", "waste of time", "not worth", "beneath me", "boring"], @@ -420,35 +468,43 @@ class PersonaDialogue: "challenge": ["prove it", "fight me", "make me", "i dare you", "try me"], } - # De-escalation patterns + # De-escalation patterns (increased weight: -0.08 per match) deescalation_patterns = { "concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"], - "softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize"], - "deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just"], + "softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize", "i hear you"], + "deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just", "maybe we should"], } # Check escalation for category, patterns in escalation_patterns.items(): matches = sum(1 for p in patterns if p in text_lower) if matches > 0: - base_delta += matches * 0.08 + base_delta += matches * 0.05 # Reduced from 0.08 # Check de-escalation for category, patterns in deescalation_patterns.items(): matches = sum(1 for p in patterns if p in text_lower) if matches > 0: - base_delta -= matches * 0.06 + base_delta -= matches * 0.08 # Increased from 0.06 - # Intensity multipliers + # Intensity multipliers (reduced) exclamation_count = response_text.count('!') caps_ratio = sum(1 for c in response_text if c.isupper()) / max(len(response_text), 1) if exclamation_count > 2 or caps_ratio > 0.3: - base_delta *= 1.3 + base_delta *= 1.2 # Reduced from 1.3 - # Momentum factor + # Momentum factor (reduced) if current_tension > 0.5: - base_delta *= 1.2 + base_delta *= 1.1 # Reduced from 1.2 + + # Spike cooldown: if last turn had a big spike, halve this delta + # (prevents runaway tension spirals from a single heated exchange) + if hasattr(self, '_last_tension_delta') and abs(self._last_tension_delta) > 0.15: + base_delta *= 0.5 + logger.debug(f"[Tension] Spike cooldown active — delta halved to {base_delta:+.3f}") + + self._last_tension_delta = base_delta return base_delta