diff --git a/bot/utils/activities.py b/bot/utils/activities.py new file mode 100644 index 0000000..070eef9 --- /dev/null +++ b/bot/utils/activities.py @@ -0,0 +1,178 @@ +# utils/activities.py +""" +Mood-based Discord activity status system. + +Loads activity definitions from activities.yaml and provides functions to: +- Pick a weighted-random activity for a given mood +- Update the bot's Discord presence (Listening/Playing) +- Get/set activity data for the Web UI +""" + +import os +import random +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") + +# Cache: (data_dict, file_mtime) +_activities_cache = None +_cache_mtime = 0.0 + + +def _load_activities(force=False): + """Load activities.yaml with file-mtime-based caching. + + Returns the full dict: {"normal": {...}, "evil": {...}} + """ + 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) + + # Update cache immediately + _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 + + +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} + + Raises: + ValueError: if validation fails + """ + # Validate + valid_types = {"listening", "playing"} + 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_types: + raise ValueError(f"Entry {i} has invalid type '{entry.get('type')}', must be 'listening' or 'playing'") + 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") + + 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) + + +def pick_activity_for_mood(mood_name: str, is_evil: bool = False): + """Pick a weighted-random activity for a mood. + + Returns: + tuple: (activity_type, name) e.g. ("listening", "World is Mine") + Returns ("listening", "Vocaloid") as fallback if mood has no entries. + """ + activities = get_activities_for_mood(mood_name, is_evil) + + if not activities: + logger.debug(f"No activities defined for {'evil/' if is_evil else ''}{mood_name}, using fallback") + return ("listening", "Vocaloid") + + # Weighted random selection + weights = [entry.get("weight", 1) for entry in activities] + chosen = random.choices(activities, weights=weights, k=1)[0] + return (chosen["type"], chosen["name"]) + + +async def update_bot_presence(mood_name: str, is_evil: bool = False): + """Update the bot's Discord presence based on the current mood. + + - asleep: shows idle status, no activity + - Other moods: shows "Listening to..." or "Playing..." with weighted-random pick + """ + if not globals.client or not globals.client.is_ready(): + logger.debug("Bot not ready, skipping presence update") + return + + try: + if mood_name == "asleep": + # While asleep: idle status, no activity + await globals.client.change_presence( + status=discord.Status.idle, + activity=None + ) + logger.info("Set presence: idle (asleep)") + return + + activity_type, name = pick_activity_for_mood(mood_name, is_evil) + + if activity_type == "listening": + activity = discord.Activity(type=discord.ActivityType.listening, name=name) + log_label = f"Listening to {name}" + else: + activity = discord.Game(name=name) + log_label = f"Playing {name}" + + await globals.client.change_presence(activity=activity) + logger.info(f"Set presence: {log_label} (mood={'evil/' if is_evil else ''}{mood_name})") + + except Exception as e: + logger.error(f"Failed to update bot presence: {e}") + + +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: + 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}")