From d6cdb89e4217f178abd91651e68ae7b6c4d94034 Mon Sep 17 00:00:00 2001 From: koko210Serve Date: Mon, 27 Apr 2026 23:39:18 +0300 Subject: [PATCH] Refactor activity system: energy-based probability, manual override, all 5 activity types - Rewrite utils/activities.py with mood energy-driven activity probability (high-energy moods like excited/bubbly show activity ~80-85% of the time, low-energy moods like sleepy/melancholy only ~15-25%) - Add manual override system with 30-min auto-expiry for Web UI control - Support all 5 Discord activity types: listening, playing, watching, competing, streaming (with purple LIVE badge via discord.Streaming) - Add current activity tracking (get_current_activity) - Add force=True param to update_bot_presence for on_ready (bot.py) - Add 4 new API routes for manual override: GET/POST/DELETE /activities/current, POST /activities/current/auto - Expand activities.yaml from 139 to 157 entries, adding watching, competing, and streaming entries across 11 moods - Update Web UI: activity type dropdown with all 5 types, conditional URL field for streaming, 'Current Activity' override panel with set/clear/auto controls, type-aware icons and labels --- bot/activities.yaml | 69 +++++++++ bot/bot.py | 4 +- bot/routes/activities.py | 67 +++++++++ bot/static/index.html | 159 ++++++++++++++++++-- bot/utils/activities.py | 306 ++++++++++++++++++++++++++++++++++----- 5 files changed, 556 insertions(+), 49 deletions(-) diff --git a/bot/activities.yaml b/bot/activities.yaml index e78828e..e1af982 100644 --- a/bot/activities.yaml +++ b/bot/activities.yaml @@ -32,6 +32,11 @@ normal: name: 'Hatsune Miku: Project DIVA Future Tone' weight: 1 state: Rhythm Game + - type: streaming + name: VOCALOID Covers + weight: 1 + state: on YouTube + url: https://www.youtube.com/watch?v=CGbYfNq3iZQ excited: - type: listening name: Melt @@ -65,6 +70,14 @@ normal: name: Muse Dash weight: 2 state: Rhythm Game + - type: streaming + name: rhythm game gameplay + weight: 1 + url: https://www.youtube.com/watch?v=3J8EeHxg3po + - type: competing + name: Beat Saber Tournament + weight: 1 + state: Ranked neutral: - type: listening name: Miku Miku ni Shite Ageru♪ @@ -94,6 +107,14 @@ normal: name: 'Project SEKAI: Colorful Stage!' weight: 2 state: Rhythm Game + - type: watching + name: YouTube + weight: 2 + state: Music Videos + - type: competing + name: osu! + weight: 1 + state: Ranked Match sleepy: - type: listening name: Yuki no Hahen @@ -156,6 +177,14 @@ normal: name: 'The Legend of Zelda: Tears of the Kingdom' weight: 2 state: Adventure + - type: watching + name: VOCALOID tutorials + weight: 1 + state: on YouTube + - type: watching + name: science documentaries + weight: 1 + state: Discovery Channel shy: - type: listening name: Koi wo Sensou @@ -210,6 +239,10 @@ normal: name: Civilization VI weight: 2 state: 4X Strategy + - type: watching + name: chess tournament + weight: 1 + state: PGN Livestream melancholy: - type: listening name: Kokoro @@ -260,6 +293,10 @@ normal: name: 'Project SEKAI: Colorful Stage!' weight: 2 state: Rhythm Game + - type: streaming + name: karaoke stream + weight: 1 + url: https://www.youtube.com/watch?v=CGbYfNq3iZQ romantic: - type: listening name: Romeo and Cinderella @@ -306,6 +343,10 @@ normal: name: Elden Ring weight: 2 state: Action RPG + - type: watching + name: rage compilations + weight: 1 + state: YouTube angry: - type: listening name: Two-Faced Lovers @@ -331,6 +372,14 @@ normal: name: Hades weight: 2 state: Roguelike + - type: competing + name: Valorant + weight: 1 + state: Ranked + - type: streaming + name: speedrun attempts + weight: 1 + url: https://www.youtube.com/watch?v=3J8EeHxg3po silly: - type: listening name: PoPiPo @@ -364,6 +413,14 @@ normal: name: Fall Guys weight: 2 state: Party Game + - type: competing + name: Fall Guys + weight: 2 + state: Tournament Mode + - type: watching + name: funny fails compilation + weight: 1 + state: YouTube test: - type: playing name: G @@ -390,6 +447,10 @@ evil: name: Devil May Cry 5 weight: 2 state: Action + - type: competing + name: DOOM Eternal + weight: 2 + state: Ultra Nightmare cunning: - type: listening name: Gekkabijin @@ -503,6 +564,10 @@ evil: name: Neon White weight: 2 state: FPS Platformer + - type: streaming + name: chaos speedrun + weight: 1 + url: https://www.youtube.com/watch?v=3J8EeHxg3po jealous: - type: listening name: Rotten Girl Grotesque Romance @@ -583,3 +648,7 @@ evil: name: Crusader Kings III weight: 2 state: Grand Strategy + - type: watching + name: world domination tutorials + weight: 1 + state: YouTube diff --git a/bot/bot.py b/bot/bot.py index 53f99d4..7e1a8cb 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -140,9 +140,9 @@ async def on_ready(): try: from utils.activities import update_bot_presence if globals.EVIL_MODE: - await update_bot_presence(globals.EVIL_DM_MOOD, is_evil=True) + await update_bot_presence(globals.EVIL_DM_MOOD, is_evil=True, force=True) else: - await update_bot_presence(globals.DM_MOOD, is_evil=False) + await update_bot_presence(globals.DM_MOOD, is_evil=False, force=True) except Exception as e: logger.error(f"Failed to set initial presence: {e}") diff --git a/bot/routes/activities.py b/bot/routes/activities.py index 108d139..1db36c8 100644 --- a/bot/routes/activities.py +++ b/bot/routes/activities.py @@ -71,3 +71,70 @@ def reload_activities(): evil_count = sum(len(v) for v in data.get("evil", {}).values()) logger.info(f"Force-reloaded activities: {normal_count} normal entries, {evil_count} evil entries") return {"status": "ok", "normal_entries": normal_count, "evil_entries": evil_count} + + +# ══════════════════════════════════════════════════════════════════════════════ +# Manual Override — set / clear / release current activity +# ══════════════════════════════════════════════════════════════════════════════ + +@router.get("/activities/current") +def get_current_activity(): + """Return the bot's current activity and override status.""" + from utils.activities import get_current_activity, is_manual_override_active + activity = get_current_activity() + override = is_manual_override_active() + result = { + "activity": activity, # dict or null + "manual_override": override, + } + return result + + +@router.post("/activities/current") +async def set_current_activity(request: Request): + """Manually set the bot's activity (bypasses mood system for 30 min). + + Body: {"type": "listening"|"playing"|"watching"|"competing"|"streaming", + "name": "...", "state": "..." (optional), "url": "..." (required for streaming)} + """ + data = await request.json() + activity_type = data.get("type", "").lower().strip() + name = data.get("name", "").strip() + state = data.get("state") or None + url = data.get("url") or None + + try: + from utils.activities import set_activity_manual + await set_activity_manual(activity_type, name, state=state, url=url) + return {"status": "ok", "activity": {"type": activity_type, "name": name, "state": state, "url": url}} + except ValueError as e: + return JSONResponse(status_code=400, content={"error": str(e)}) + except RuntimeError as e: + return JSONResponse(status_code=503, content={"error": str(e)}) + except Exception as e: + logger.error(f"Failed to set manual activity: {e}") + return JSONResponse(status_code=500, content={"error": "Internal server error"}) + + +@router.delete("/activities/current") +async def clear_current_activity(): + """Manually clear the bot's activity (stays idle, override stays active).""" + try: + from utils.activities import clear_activity_manual + await clear_activity_manual() + return {"status": "ok", "activity": None, "manual_override": True} + except Exception as e: + logger.error(f"Failed to clear manual activity: {e}") + return JSONResponse(status_code=500, content={"error": "Internal server error"}) + + +@router.post("/activities/current/auto") +async def release_to_auto(): + """Release manual override and return to automatic mood-based activity.""" + try: + from utils.activities import release_manual_override + await release_manual_override() + return {"status": "ok", "manual_override": False} + except Exception as e: + logger.error(f"Failed to release manual override: {e}") + return JSONResponse(status_code=500, content={"error": "Internal server error"}) diff --git a/bot/static/index.html b/bot/static/index.html index 74b70cd..50eec74 100644 --- a/bot/static/index.html +++ b/bot/static/index.html @@ -481,7 +481,7 @@ .act-entry-icon { font-size: 1.1rem; min-width: 24px; text-align: center; } .act-entry input[type="text"] { flex: 1; } .act-entry input[type="number"] { width: 55px; } - .act-entry select { width: 110px; } + .act-entry select { width: 130px; } .act-toolbar { display: flex; gap: 0.5rem; @@ -1362,6 +1362,35 @@ + +
+

🎯 Current Activity

+
+ Loading... +
+
+ + +
+
+ ✏️ Manual Override +
+ + + + + + +
+
+
+
@@ -6819,7 +6848,10 @@ function activitiesToggle() { activitiesOpen = !activitiesOpen; document.getElementById('activities-body').style.display = activitiesOpen ? 'block' : 'none'; document.getElementById('activities-toggle-icon').textContent = activitiesOpen ? '▼' : '▶'; - if (activitiesOpen && !activitiesData) activitiesLoad(); + if (activitiesOpen) { + if (!activitiesData) activitiesLoad(); + activityRefreshCurrent(); + } } function activitiesSectionToggle(section) { @@ -6857,11 +6889,19 @@ function activitiesRenderSection(section) { const isEditing = activitiesEditing[key]; const songs = entries.filter(e => e.type === 'listening').length; const games = entries.filter(e => e.type === 'playing').length; + const watches = entries.filter(e => e.type === 'watching').length; + const competes = entries.filter(e => e.type === 'competing').length; + const streams = entries.filter(e => e.type === 'streaming').length; + + let stats = `${songs}🎵 ${games}🎮`; + if (watches) stats += ` ${watches}📺`; + if (competes) stats += ` ${competes}🏆`; + if (streams) stats += ` ${streams}🔴`; html += `
`; html += `
`; html += ` ${mood}`; - html += `${songs}🎵 ${games}🎮`; + html += `${stats}`; html += `
`; html += `
`; @@ -6879,11 +6919,13 @@ function activitiesRenderSection(section) { function activitiesRenderView(section, mood, entries) { let html = ''; for (const entry of entries) { - const icon = entry.type === 'listening' ? '🎵' : '🎮'; - const label = entry.type === 'listening' ? 'Listening to' : 'Playing'; + const icons = { listening: '🎵', playing: '🎮', watching: '📺', competing: '🏆', streaming: '🔴' }; + const labels = { listening: 'Listening to', playing: 'Playing', watching: 'Watching', competing: 'Competing in', streaming: 'Streaming' }; + const icon = icons[entry.type] || '🎮'; + const label = labels[entry.type] || 'Playing'; html += `
`; html += `${icon}`; - html += `${escapeHtml(entry.name)}`; + html += `${label} ${escapeHtml(entry.name)}`; if (entry.state) html += ` — ${escapeHtml(entry.state)}`; html += ``; html += `weight: ${entry.weight}`; @@ -6900,12 +6942,16 @@ function activitiesRenderEditForm(section, mood, entries) { for (let i = 0; i < entries.length; i++) { const e = entries[i]; html += `
`; - html += ``; html += ``; html += ``; + html += ``; + html += ``; + html += ``; html += ``; - html += ``; - html += ``; + html += ``; + html += ``; + html += ``; html += ``; html += ``; html += `
`; @@ -6918,6 +6964,13 @@ function activitiesRenderEditForm(section, mood, entries) { return html; } +function activitiesTypeChanged(section, mood, index) { + const typeEl = document.getElementById(`act-type-${section}-${mood}-${index}`); + const urlEl = document.getElementById(`act-url-${section}-${mood}-${index}`); + if (!typeEl || !urlEl) return; + urlEl.style.display = typeEl.value === 'streaming' ? '' : 'none'; +} + function activitiesMoodToggle(section, mood) { const el = document.getElementById(`act-content-${section}-${mood}`); const iconEl = document.getElementById(`act-icon-${section}-${mood}`); @@ -6975,10 +7028,12 @@ function activitiesSyncFormToCache(section, mood) { const typeEl = document.getElementById(`act-type-${section}-${mood}-${i}`); const nameEl = document.getElementById(`act-name-${section}-${mood}-${i}`); const stateEl = document.getElementById(`act-state-${section}-${mood}-${i}`); + const urlEl = document.getElementById(`act-url-${section}-${mood}-${i}`); const weightEl = document.getElementById(`act-weight-${section}-${mood}-${i}`); if (typeEl) entries[i].type = typeEl.value; if (nameEl) entries[i].name = nameEl.value; if (stateEl) entries[i].state = stateEl.value || undefined; + if (urlEl) entries[i].url = urlEl.value || undefined; if (weightEl) entries[i].weight = parseInt(weightEl.value) || 1; } activitiesEditCache[key] = entries; @@ -6999,6 +7054,10 @@ async function activitiesSave(section, mood) { showNotification(`Entry ${i + 1}: weight must be at least 1`, 'error'); return; } + if (entries[i].type === 'streaming' && !entries[i].url) { + showNotification(`Entry ${i + 1}: streaming requires a URL`, 'error'); + return; + } } try { @@ -7013,6 +7072,88 @@ async function activitiesSave(section, mood) { } } +// ============================================================================ +// CURRENT ACTIVITY OVERRIDE +// ============================================================================ + +function _activityTypeIcon(type) { + return { listening: '🎵', playing: '🎮', watching: '📺', competing: '🏆', streaming: '🔴' }[type] || '🎮'; +} + +function _activityTypeLabel(type) { + return { listening: 'Listening to', playing: 'Playing', watching: 'Watching', competing: 'Competing in', streaming: 'Streaming' }[type] || 'Playing'; +} + +async function activityRefreshCurrent() { + const statusEl = document.getElementById('activity-override-status'); + try { + const data = await apiCall('/activities/current'); + const act = data.activity; + const isOverride = data.manual_override; + + if (act) { + const icon = _activityTypeIcon(act.type); + const label = _activityTypeLabel(act.type); + let html = `${icon} ${label} ${escapeHtml(act.name)}`; + if (act.state) html += ` — ${escapeHtml(act.state)}`; + if (isOverride) html += ` ⚡ MANUAL OVERRIDE (30 min)`; + statusEl.innerHTML = html; + } else { + let html = 'No activity (idle)'; + if (isOverride) html += ' ⚡ MANUAL OVERRIDE'; + statusEl.innerHTML = html; + } + } catch (e) { + statusEl.innerHTML = `Error: ${e.message}`; + } +} + +async function activitySetManual() { + const type = document.getElementById('act-manual-type').value; + const name = document.getElementById('act-manual-name').value.trim(); + const state = document.getElementById('act-manual-state').value.trim(); + const url = document.getElementById('act-manual-url').value.trim(); + + if (!name) { showNotification('Activity name is required', 'error'); return; } + if (type === 'streaming' && !url) { showNotification('Streaming requires a URL', 'error'); return; } + + try { + const body = { type, name }; + if (state) body.state = state; + if (url) body.url = url; + await apiCall('/activities/current', 'POST', body); + showNotification(`Set activity: ${type} ${name}`, 'success'); + await activityRefreshCurrent(); + } catch (e) { + showNotification('Failed to set activity: ' + e.message, 'error'); + } +} + +async function activityClearManual() { + try { + await apiCall('/activities/current', 'DELETE'); + showNotification('Activity cleared (manual override active)', 'success'); + await activityRefreshCurrent(); + } catch (e) { + showNotification('Failed to clear: ' + e.message, 'error'); + } +} + +async function activityReleaseAuto() { + try { + await apiCall('/activities/current/auto', 'POST'); + showNotification('Returned to automatic mode', 'success'); + await activityRefreshCurrent(); + } catch (e) { + showNotification('Failed to release override: ' + e.message, 'error'); + } +} + +// Show/hide URL field when streaming is selected in manual override +document.getElementById('act-manual-type').addEventListener('change', function() { + document.getElementById('act-manual-url').style.display = this.value === 'streaming' ? '' : 'none'; +}); + diff --git a/bot/utils/activities.py b/bot/utils/activities.py index a80f077..f932a56 100644 --- a/bot/utils/activities.py +++ b/bot/utils/activities.py @@ -2,14 +2,17 @@ """ 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 +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 @@ -19,16 +22,59 @@ 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. - - Returns the full dict: {"normal": {...}, "evil": {...}} - """ + """Load activities.yaml with file-mtime-based caching.""" global _activities_cache, _cache_mtime try: @@ -60,7 +106,6 @@ def save_activities(data: dict): 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}") @@ -69,6 +114,10 @@ def save_activities(data: dict): 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() @@ -83,28 +132,31 @@ def get_activities_for_mood(mood_name: str, is_evil: bool = False) -> list: 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} - + activities: list of dicts with keys {type, name, weight, [state], [url]} + 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 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() @@ -114,38 +166,157 @@ def set_activities_for_mood(mood_name: str, is_evil: bool, activities: list): 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: - tuple: (activity_type, name, state) e.g. ("listening", "World is Mine", "by ryo (supercell)") - state may be None if not defined. Fallback: ("listening", "Vocaloid", None) + 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: - logger.debug(f"No activities defined for {'evil/' if is_evil else ''}{mood_name}, using fallback") - return ("listening", "Vocaloid", None) + return None - # 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"], chosen.get("state")) + return { + "type": chosen["type"], + "name": chosen["name"], + "state": chosen.get("state"), + "url": chosen.get("url"), + } -async def update_bot_presence(mood_name: str, is_evil: bool = False): +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: shows idle status, no activity - - Other moods: shows "Listening to..." or "Playing..." with weighted-random pick + + - 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": - # While asleep: idle status, no activity + _set_current_activity(None) await globals.client.change_presence( status=discord.Status.idle, activity=None @@ -153,31 +324,90 @@ async def update_bot_presence(mood_name: str, is_evil: bool = False): logger.info("Set presence: idle (asleep)") return - activity_type, name, state = pick_activity_for_mood(mood_name, is_evil) + # Check manual override (skip unless forced) + if not force and is_manual_override_active(): + logger.debug("Manual override active, skipping automatic presence update") + return - if activity_type == "listening": - activity = discord.Activity(type=discord.ActivityType.listening, name=name, state=state) - log_label = f"Listening to {name}" - else: - activity = discord.Activity(type=discord.ActivityType.playing, name=name, state=state) - log_label = f"Playing {name}" + # 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 - if state: - log_label += f" ({state})" + # 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: {log_label} (mood={'evil/' if is_evil else ''}{mood_name})") + 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}")