# utils/activities.py """ Mood-based Discord activity status system. Activity display is driven by the autonomous engine's mood energy profiles: - High-energy moods (excited, bubbly) → almost always show an activity - Low-energy moods (sleepy, melancholy) → mostly idle, occasionally active - Manual override via Web UI bypasses automatic behavior Supports 5 activity types: listening, playing, watching, competing, streaming. """ import os import random import time import yaml import discord import globals from utils.logger import get_logger logger = get_logger('activity') ACTIVITIES_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "activities.yaml") # All valid activity types VALID_ACTIVITY_TYPES = {"listening", "playing", "watching", "competing", "streaming"} # ── Activity probability per mood (derived from autonomous engine energy profiles) ── # Value = probability that the bot WILL have an activity (vs being idle). ACTIVITY_PROBABILITY = { # Normal moods "asleep": 0.00, "sleepy": 0.15, "melancholy": 0.25, "shy": 0.30, "irritated": 0.40, "neutral": 0.45, "serious": 0.50, "romantic": 0.55, "curious": 0.60, "angry": 0.60, "flirty": 0.65, "silly": 0.75, "bubbly": 0.80, "excited": 0.85, # Evil moods "melancholic": 0.25, "bored": 0.35, "contemptuous": 0.45, "evil_neutral": 0.50, "sarcastic": 0.55, "jealous": 0.60, "cunning": 0.65, "aggressive": 0.70, "playful_cruel": 0.70, "manic": 0.85, } # ── Manual override state ── _manual_override = False _manual_override_until = 0.0 # Unix timestamp; 0 = no override MANUAL_OVERRIDE_DURATION = 1800 # 30 minutes # ── Current activity tracking ── _current_activity = None # dict: {type, name, state, url} or None # Cache: (data_dict, file_mtime) _activities_cache = None _cache_mtime = 0.0 # ══════════════════════════════════════════════════════════════════════════════ # YAML Loading / Saving # ══════════════════════════════════════════════════════════════════════════════ def _load_activities(force=False): """Load activities.yaml with file-mtime-based caching.""" global _activities_cache, _cache_mtime try: mtime = os.path.getmtime(ACTIVITIES_FILE) except OSError: logger.warning(f"Activities file not found: {ACTIVITIES_FILE}") return {"normal": {}, "evil": {}} if not force and _activities_cache is not None and mtime == _cache_mtime: return _activities_cache try: with open(ACTIVITIES_FILE, "r", encoding="utf-8") as f: data = yaml.safe_load(f) or {} _activities_cache = data _cache_mtime = mtime logger.debug(f"Loaded activities from {ACTIVITIES_FILE}") return data except Exception as e: logger.error(f"Failed to load activities file: {e}") return _activities_cache or {"normal": {}, "evil": {}} def save_activities(data: dict): """Write the full activities dict back to YAML.""" global _activities_cache, _cache_mtime try: with open(ACTIVITIES_FILE, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) _activities_cache = data _cache_mtime = os.path.getmtime(ACTIVITIES_FILE) logger.info(f"Saved activities to {ACTIVITIES_FILE}") except Exception as e: logger.error(f"Failed to save activities file: {e}") raise # ══════════════════════════════════════════════════════════════════════════════ # CRUD for activity data (used by Web UI) # ══════════════════════════════════════════════════════════════════════════════ def get_all_activities() -> dict: """Return the full activities dict (normal + evil sections).""" return _load_activities() def get_activities_for_mood(mood_name: str, is_evil: bool = False) -> list: """Return the activity list for a specific mood. Returns empty list if not found.""" section = "evil" if is_evil else "normal" data = _load_activities() return data.get(section, {}).get(mood_name, []) def set_activities_for_mood(mood_name: str, is_evil: bool, activities: list): """Validate and save updated activity list for a mood. Args: mood_name: mood key (e.g. "bubbly", "aggressive") is_evil: True for evil section, False for normal activities: list of dicts with keys {type, name, weight, [state], [url]} Raises: ValueError: if validation fails """ for i, entry in enumerate(activities): if not isinstance(entry, dict): raise ValueError(f"Entry {i} must be a dict, got {type(entry).__name__}") if entry.get("type") not in VALID_ACTIVITY_TYPES: raise ValueError( f"Entry {i} has invalid type '{entry.get('type')}', " f"must be one of: {', '.join(sorted(VALID_ACTIVITY_TYPES))}" ) if not entry.get("name") or not isinstance(entry["name"], str): raise ValueError(f"Entry {i} must have a non-empty string 'name'") if not isinstance(entry.get("weight", 0), int) or entry.get("weight", 0) < 1: raise ValueError(f"Entry {i} weight must be a positive integer") if "state" in entry and entry["state"] is not None and not isinstance(entry["state"], str): raise ValueError(f"Entry {i} 'state' must be a string if provided") if "url" in entry and entry["url"] is not None and not isinstance(entry["url"], str): raise ValueError(f"Entry {i} 'url' must be a string if provided") section = "evil" if is_evil else "normal" data = _load_activities() if section not in data: data[section] = {} data[section][mood_name] = activities save_activities(data) # ══════════════════════════════════════════════════════════════════════════════ # Activity Selection # ══════════════════════════════════════════════════════════════════════════════ def pick_activity_for_mood(mood_name: str, is_evil: bool = False): """Pick a weighted-random activity for a mood. Returns: dict: {"type": ..., "name": ..., "state": ..., "url": ...} state and url may be None. Returns None if mood has no entries. """ activities = get_activities_for_mood(mood_name, is_evil) if not activities: return None weights = [entry.get("weight", 1) for entry in activities] chosen = random.choices(activities, weights=weights, k=1)[0] return { "type": chosen["type"], "name": chosen["name"], "state": chosen.get("state"), "url": chosen.get("url"), } def should_have_activity(mood_name: str) -> bool: """Decide whether the bot should show an activity for this mood. Based on mood energy: high-energy moods are more likely to be active, low-energy moods are more likely to be idle. """ probability = ACTIVITY_PROBABILITY.get(mood_name, 0.45) return random.random() < probability # ══════════════════════════════════════════════════════════════════════════════ # Manual Override # ══════════════════════════════════════════════════════════════════════════════ def is_manual_override_active() -> bool: """Check if a manual override is in effect (hasn't expired).""" global _manual_override if not _manual_override: return False if _manual_override_until > 0 and time.time() > _manual_override_until: _manual_override = False logger.info("Manual override expired, returning to automatic mode") return False return True def set_manual_override(duration: int = MANUAL_OVERRIDE_DURATION): """Activate manual override for the given duration (seconds).""" global _manual_override, _manual_override_until _manual_override = True _manual_override_until = time.time() + duration logger.info(f"Manual override activated for {duration}s") def clear_manual_override(): """Deactivate manual override immediately.""" global _manual_override, _manual_override_until _manual_override = False _manual_override_until = 0.0 logger.info("Manual override cleared") # ══════════════════════════════════════════════════════════════════════════════ # Current Activity Tracking # ══════════════════════════════════════════════════════════════════════════════ def get_current_activity(): """Return the current activity dict or None if idle.""" return _current_activity def _set_current_activity(activity_dict): """Update the tracked current activity.""" global _current_activity _current_activity = activity_dict # ══════════════════════════════════════════════════════════════════════════════ # Discord Presence Updates # ══════════════════════════════════════════════════════════════════════════════ def _build_activity(payload: dict): """Build a discord.Activity (or discord.Streaming) from a payload dict.""" atype = payload["type"] name = payload["name"] state = payload.get("state") url = payload.get("url") if atype == "streaming" and url: return discord.Streaming(name=name, url=url) type_map = { "listening": discord.ActivityType.listening, "playing": discord.ActivityType.playing, "watching": discord.ActivityType.watching, "competing": discord.ActivityType.competing, "streaming": discord.ActivityType.streaming, # fallback without url } return discord.Activity( type=type_map.get(atype, discord.ActivityType.playing), name=name, state=state, ) def _activity_label(payload: dict) -> str: """Human-readable label for logging.""" atype = payload["type"] name = payload["name"] prefixes = { "listening": "Listening to", "playing": "Playing", "watching": "Watching", "competing": "Competing in", "streaming": "Streaming", } label = f"{prefixes.get(atype, 'Playing')} {name}" state = payload.get("state") if state: label += f" ({state})" return label async def update_bot_presence(mood_name: str, is_evil: bool = False, force: bool = False): """Update the bot's Discord presence based on the current mood. - asleep: idle status, no activity - Manual override active: skip (unless force=True) - Energy-based probability: may choose to be idle instead of showing an activity - force=True bypasses both manual override and probability (used by on_ready and manual set) Args: mood_name: current mood key is_evil: whether evil mode is active force: bypass manual override and probability checks """ if not globals.client or not globals.client.is_ready(): logger.debug("Bot not ready, skipping presence update") return try: # asleep → always idle if mood_name == "asleep": _set_current_activity(None) await globals.client.change_presence( status=discord.Status.idle, activity=None ) logger.info("Set presence: idle (asleep)") return # Check manual override (skip unless forced) if not force and is_manual_override_active(): logger.debug("Manual override active, skipping automatic presence update") return # Energy-based probability: should we show an activity at all? if not force and not should_have_activity(mood_name): await clear_bot_presence() logger.info(f"Decided to be idle (mood={'evil/' if is_evil else ''}{mood_name})") return # Pick a random activity for this mood chosen = pick_activity_for_mood(mood_name, is_evil) if not chosen: # No activities defined for this mood → idle await clear_bot_presence() logger.info(f"No activities for {'evil/' if is_evil else ''}{mood_name}, staying idle") return activity = _build_activity(chosen) label = _activity_label(chosen) _set_current_activity(chosen) await globals.client.change_presence(status=discord.Status.online, activity=activity) logger.info(f"Set presence: {label} (mood={'evil/' if is_evil else ''}{mood_name})") except Exception as e: logger.error(f"Failed to update bot presence: {e}") async def set_activity_manual(activity_type: str, name: str, state: str = None, url: str = None): """Manually set the bot's activity (bypasses mood system). Raises: ValueError: if activity_type is invalid or streaming lacks url RuntimeError: if bot is not ready """ if activity_type not in VALID_ACTIVITY_TYPES: raise ValueError(f"Invalid type '{activity_type}', must be one of: {', '.join(sorted(VALID_ACTIVITY_TYPES))}") if not name or not isinstance(name, str): raise ValueError("name must be a non-empty string") if activity_type == "streaming" and not url: raise ValueError("streaming type requires a url") if not globals.client or not globals.client.is_ready(): raise RuntimeError("Bot is not ready") payload = {"type": activity_type, "name": name, "state": state, "url": url} activity = _build_activity(payload) label = _activity_label(payload) _set_current_activity(payload) set_manual_override() await globals.client.change_presence(status=discord.Status.online, activity=activity) logger.info(f"Set presence (manual): {label}") async def clear_bot_presence(): """Clear the bot's activity (set to online with no activity).""" if not globals.client or not globals.client.is_ready(): return try: _set_current_activity(None) await globals.client.change_presence(status=discord.Status.online, activity=None) logger.info("Cleared bot presence") except Exception as e: logger.error(f"Failed to clear bot presence: {e}") async def clear_activity_manual(): """Manually clear the bot's activity and activate manual override.""" set_manual_override() await clear_bot_presence() logger.info("Cleared presence (manual override)") async def release_manual_override(): """Release manual override and immediately recalculate presence from current mood.""" clear_manual_override() if globals.EVIL_MODE: mood = globals.EVIL_DM_MOOD is_evil = True else: mood = globals.DM_MOOD is_evil = False await update_bot_presence(mood, is_evil=is_evil, force=False) logger.info(f"Released manual override, recalculated for mood={'evil/' if is_evil else ''}{mood}")