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
This commit is contained in:
@@ -32,6 +32,11 @@ normal:
|
|||||||
name: 'Hatsune Miku: Project DIVA Future Tone'
|
name: 'Hatsune Miku: Project DIVA Future Tone'
|
||||||
weight: 1
|
weight: 1
|
||||||
state: Rhythm Game
|
state: Rhythm Game
|
||||||
|
- type: streaming
|
||||||
|
name: VOCALOID Covers
|
||||||
|
weight: 1
|
||||||
|
state: on YouTube
|
||||||
|
url: https://www.youtube.com/watch?v=CGbYfNq3iZQ
|
||||||
excited:
|
excited:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Melt
|
name: Melt
|
||||||
@@ -65,6 +70,14 @@ normal:
|
|||||||
name: Muse Dash
|
name: Muse Dash
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Rhythm Game
|
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:
|
neutral:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Miku Miku ni Shite Ageru♪
|
name: Miku Miku ni Shite Ageru♪
|
||||||
@@ -94,6 +107,14 @@ normal:
|
|||||||
name: 'Project SEKAI: Colorful Stage!'
|
name: 'Project SEKAI: Colorful Stage!'
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Rhythm Game
|
state: Rhythm Game
|
||||||
|
- type: watching
|
||||||
|
name: YouTube
|
||||||
|
weight: 2
|
||||||
|
state: Music Videos
|
||||||
|
- type: competing
|
||||||
|
name: osu!
|
||||||
|
weight: 1
|
||||||
|
state: Ranked Match
|
||||||
sleepy:
|
sleepy:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Yuki no Hahen
|
name: Yuki no Hahen
|
||||||
@@ -156,6 +177,14 @@ normal:
|
|||||||
name: 'The Legend of Zelda: Tears of the Kingdom'
|
name: 'The Legend of Zelda: Tears of the Kingdom'
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Adventure
|
state: Adventure
|
||||||
|
- type: watching
|
||||||
|
name: VOCALOID tutorials
|
||||||
|
weight: 1
|
||||||
|
state: on YouTube
|
||||||
|
- type: watching
|
||||||
|
name: science documentaries
|
||||||
|
weight: 1
|
||||||
|
state: Discovery Channel
|
||||||
shy:
|
shy:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Koi wo Sensou
|
name: Koi wo Sensou
|
||||||
@@ -210,6 +239,10 @@ normal:
|
|||||||
name: Civilization VI
|
name: Civilization VI
|
||||||
weight: 2
|
weight: 2
|
||||||
state: 4X Strategy
|
state: 4X Strategy
|
||||||
|
- type: watching
|
||||||
|
name: chess tournament
|
||||||
|
weight: 1
|
||||||
|
state: PGN Livestream
|
||||||
melancholy:
|
melancholy:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Kokoro
|
name: Kokoro
|
||||||
@@ -260,6 +293,10 @@ normal:
|
|||||||
name: 'Project SEKAI: Colorful Stage!'
|
name: 'Project SEKAI: Colorful Stage!'
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Rhythm Game
|
state: Rhythm Game
|
||||||
|
- type: streaming
|
||||||
|
name: karaoke stream
|
||||||
|
weight: 1
|
||||||
|
url: https://www.youtube.com/watch?v=CGbYfNq3iZQ
|
||||||
romantic:
|
romantic:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Romeo and Cinderella
|
name: Romeo and Cinderella
|
||||||
@@ -306,6 +343,10 @@ normal:
|
|||||||
name: Elden Ring
|
name: Elden Ring
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Action RPG
|
state: Action RPG
|
||||||
|
- type: watching
|
||||||
|
name: rage compilations
|
||||||
|
weight: 1
|
||||||
|
state: YouTube
|
||||||
angry:
|
angry:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Two-Faced Lovers
|
name: Two-Faced Lovers
|
||||||
@@ -331,6 +372,14 @@ normal:
|
|||||||
name: Hades
|
name: Hades
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Roguelike
|
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:
|
silly:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: PoPiPo
|
name: PoPiPo
|
||||||
@@ -364,6 +413,14 @@ normal:
|
|||||||
name: Fall Guys
|
name: Fall Guys
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Party Game
|
state: Party Game
|
||||||
|
- type: competing
|
||||||
|
name: Fall Guys
|
||||||
|
weight: 2
|
||||||
|
state: Tournament Mode
|
||||||
|
- type: watching
|
||||||
|
name: funny fails compilation
|
||||||
|
weight: 1
|
||||||
|
state: YouTube
|
||||||
test:
|
test:
|
||||||
- type: playing
|
- type: playing
|
||||||
name: G
|
name: G
|
||||||
@@ -390,6 +447,10 @@ evil:
|
|||||||
name: Devil May Cry 5
|
name: Devil May Cry 5
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Action
|
state: Action
|
||||||
|
- type: competing
|
||||||
|
name: DOOM Eternal
|
||||||
|
weight: 2
|
||||||
|
state: Ultra Nightmare
|
||||||
cunning:
|
cunning:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Gekkabijin
|
name: Gekkabijin
|
||||||
@@ -503,6 +564,10 @@ evil:
|
|||||||
name: Neon White
|
name: Neon White
|
||||||
weight: 2
|
weight: 2
|
||||||
state: FPS Platformer
|
state: FPS Platformer
|
||||||
|
- type: streaming
|
||||||
|
name: chaos speedrun
|
||||||
|
weight: 1
|
||||||
|
url: https://www.youtube.com/watch?v=3J8EeHxg3po
|
||||||
jealous:
|
jealous:
|
||||||
- type: listening
|
- type: listening
|
||||||
name: Rotten Girl Grotesque Romance
|
name: Rotten Girl Grotesque Romance
|
||||||
@@ -583,3 +648,7 @@ evil:
|
|||||||
name: Crusader Kings III
|
name: Crusader Kings III
|
||||||
weight: 2
|
weight: 2
|
||||||
state: Grand Strategy
|
state: Grand Strategy
|
||||||
|
- type: watching
|
||||||
|
name: world domination tutorials
|
||||||
|
weight: 1
|
||||||
|
state: YouTube
|
||||||
|
|||||||
@@ -140,9 +140,9 @@ async def on_ready():
|
|||||||
try:
|
try:
|
||||||
from utils.activities import update_bot_presence
|
from utils.activities import update_bot_presence
|
||||||
if globals.EVIL_MODE:
|
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:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to set initial presence: {e}")
|
logger.error(f"Failed to set initial presence: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,70 @@ def reload_activities():
|
|||||||
evil_count = sum(len(v) for v in data.get("evil", {}).values())
|
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")
|
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}
|
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"})
|
||||||
|
|||||||
@@ -481,7 +481,7 @@
|
|||||||
.act-entry-icon { font-size: 1.1rem; min-width: 24px; text-align: center; }
|
.act-entry-icon { font-size: 1.1rem; min-width: 24px; text-align: center; }
|
||||||
.act-entry input[type="text"] { flex: 1; }
|
.act-entry input[type="text"] { flex: 1; }
|
||||||
.act-entry input[type="number"] { width: 55px; }
|
.act-entry input[type="number"] { width: 55px; }
|
||||||
.act-entry select { width: 110px; }
|
.act-entry select { width: 130px; }
|
||||||
.act-toolbar {
|
.act-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -1362,6 +1362,35 @@
|
|||||||
<span id="activities-status" style="font-size: 0.85rem; color: #61dafb;"></span>
|
<span id="activities-status" style="font-size: 0.85rem; color: #61dafb;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Activity Override Panel -->
|
||||||
|
<div style="margin-bottom: 1rem; padding: 0.75rem; background: #252535; border-radius: 6px; border: 1px solid #3a3a5a;">
|
||||||
|
<h4 style="margin: 0 0 0.5rem 0; color: #61dafb; font-size: 0.95rem;">🎯 Current Activity</h4>
|
||||||
|
<div id="activity-override-status" style="margin-bottom: 0.5rem; font-size: 0.85rem; color: #aaa;">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.5rem;">
|
||||||
|
<button onclick="activityRefreshCurrent()" style="background: #555; font-size: 0.8rem; padding: 0.3rem 0.6rem;">🔄 Refresh</button>
|
||||||
|
<button onclick="activityReleaseAuto()" style="background: #27ae60; font-size: 0.8rem; padding: 0.3rem 0.6rem;">🌀 Return to Auto</button>
|
||||||
|
</div>
|
||||||
|
<details style="margin-top: 0.5rem;">
|
||||||
|
<summary style="cursor: pointer; font-size: 0.85rem; color: #61dafb;">✏️ Manual Override</summary>
|
||||||
|
<div style="margin-top: 0.5rem; display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center;">
|
||||||
|
<select id="act-manual-type" style="width: 120px; padding: 0.3rem;">
|
||||||
|
<option value="listening">🎵 Listening</option>
|
||||||
|
<option value="playing">🎮 Playing</option>
|
||||||
|
<option value="watching">📺 Watching</option>
|
||||||
|
<option value="competing">🏆 Competing</option>
|
||||||
|
<option value="streaming">🔴 Streaming</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="act-manual-name" placeholder="Activity name" style="flex: 2; min-width: 120px; padding: 0.3rem;">
|
||||||
|
<input type="text" id="act-manual-state" placeholder="Detail (optional)" style="flex: 1; min-width: 80px; padding: 0.3rem;">
|
||||||
|
<input type="text" id="act-manual-url" placeholder="URL (streaming)" style="flex: 1; min-width: 80px; padding: 0.3rem; display: none;">
|
||||||
|
<button onclick="activitySetManual()" style="background: #e67e22; font-size: 0.8rem; padding: 0.3rem 0.8rem;">Set</button>
|
||||||
|
<button onclick="activityClearManual()" style="background: #c0392b; font-size: 0.8rem; padding: 0.3rem 0.8rem;">Clear</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Normal Moods subsection -->
|
<!-- Normal Moods subsection -->
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<div style="cursor: pointer; user-select: none; padding: 0.5rem; background: #2a2a2a; border-radius: 4px; margin-bottom: 0.5rem;" onclick="activitiesSectionToggle('normal')">
|
<div style="cursor: pointer; user-select: none; padding: 0.5rem; background: #2a2a2a; border-radius: 4px; margin-bottom: 0.5rem;" onclick="activitiesSectionToggle('normal')">
|
||||||
@@ -6819,7 +6848,10 @@ function activitiesToggle() {
|
|||||||
activitiesOpen = !activitiesOpen;
|
activitiesOpen = !activitiesOpen;
|
||||||
document.getElementById('activities-body').style.display = activitiesOpen ? 'block' : 'none';
|
document.getElementById('activities-body').style.display = activitiesOpen ? 'block' : 'none';
|
||||||
document.getElementById('activities-toggle-icon').textContent = activitiesOpen ? '▼' : '▶';
|
document.getElementById('activities-toggle-icon').textContent = activitiesOpen ? '▼' : '▶';
|
||||||
if (activitiesOpen && !activitiesData) activitiesLoad();
|
if (activitiesOpen) {
|
||||||
|
if (!activitiesData) activitiesLoad();
|
||||||
|
activityRefreshCurrent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activitiesSectionToggle(section) {
|
function activitiesSectionToggle(section) {
|
||||||
@@ -6857,11 +6889,19 @@ function activitiesRenderSection(section) {
|
|||||||
const isEditing = activitiesEditing[key];
|
const isEditing = activitiesEditing[key];
|
||||||
const songs = entries.filter(e => e.type === 'listening').length;
|
const songs = entries.filter(e => e.type === 'listening').length;
|
||||||
const games = entries.filter(e => e.type === 'playing').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 += `<div class="act-mood-row">`;
|
html += `<div class="act-mood-row">`;
|
||||||
html += `<div class="act-mood-header" onclick="activitiesMoodToggle('${section}','${mood}')">`;
|
html += `<div class="act-mood-header" onclick="activitiesMoodToggle('${section}','${mood}')">`;
|
||||||
html += `<span class="act-mood-name"><span id="act-icon-${section}-${mood}">▶</span> ${mood}</span>`;
|
html += `<span class="act-mood-name"><span id="act-icon-${section}-${mood}">▶</span> ${mood}</span>`;
|
||||||
html += `<span class="act-mood-stats">${songs}🎵 ${games}🎮</span>`;
|
html += `<span class="act-mood-stats">${stats}</span>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
html += `<div class="act-mood-content" id="act-content-${section}-${mood}">`;
|
html += `<div class="act-mood-content" id="act-content-${section}-${mood}">`;
|
||||||
|
|
||||||
@@ -6879,11 +6919,13 @@ function activitiesRenderSection(section) {
|
|||||||
function activitiesRenderView(section, mood, entries) {
|
function activitiesRenderView(section, mood, entries) {
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const icon = entry.type === 'listening' ? '🎵' : '🎮';
|
const icons = { listening: '🎵', playing: '🎮', watching: '📺', competing: '🏆', streaming: '🔴' };
|
||||||
const label = entry.type === 'listening' ? 'Listening to' : 'Playing';
|
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 += `<div class="act-entry">`;
|
html += `<div class="act-entry">`;
|
||||||
html += `<span class="act-entry-icon">${icon}</span>`;
|
html += `<span class="act-entry-icon">${icon}</span>`;
|
||||||
html += `<span style="flex:1;">${escapeHtml(entry.name)}`;
|
html += `<span style="flex:1;"><strong style="color:#61dafb; font-size:0.8rem;">${label}</strong> ${escapeHtml(entry.name)}`;
|
||||||
if (entry.state) html += ` <span style="color:#aaa; font-size:0.85rem;">— ${escapeHtml(entry.state)}</span>`;
|
if (entry.state) html += ` <span style="color:#aaa; font-size:0.85rem;">— ${escapeHtml(entry.state)}</span>`;
|
||||||
html += `</span>`;
|
html += `</span>`;
|
||||||
html += `<span style="color:#888; font-size:0.8rem;">weight: ${entry.weight}</span>`;
|
html += `<span style="color:#888; font-size:0.8rem;">weight: ${entry.weight}</span>`;
|
||||||
@@ -6900,12 +6942,16 @@ function activitiesRenderEditForm(section, mood, entries) {
|
|||||||
for (let i = 0; i < entries.length; i++) {
|
for (let i = 0; i < entries.length; i++) {
|
||||||
const e = entries[i];
|
const e = entries[i];
|
||||||
html += `<div class="act-entry">`;
|
html += `<div class="act-entry">`;
|
||||||
html += `<select id="act-type-${section}-${mood}-${i}" value="${e.type}">`;
|
html += `<select id="act-type-${section}-${mood}-${i}" onchange="activitiesTypeChanged('${section}','${mood}',${i})">`;
|
||||||
html += `<option value="listening" ${e.type === 'listening' ? 'selected' : ''}>🎵 Listening</option>`;
|
html += `<option value="listening" ${e.type === 'listening' ? 'selected' : ''}>🎵 Listening</option>`;
|
||||||
html += `<option value="playing" ${e.type === 'playing' ? 'selected' : ''}>🎮 Playing</option>`;
|
html += `<option value="playing" ${e.type === 'playing' ? 'selected' : ''}>🎮 Playing</option>`;
|
||||||
|
html += `<option value="watching" ${e.type === 'watching' ? 'selected' : ''}>📺 Watching</option>`;
|
||||||
|
html += `<option value="competing" ${e.type === 'competing' ? 'selected' : ''}>🏆 Competing</option>`;
|
||||||
|
html += `<option value="streaming" ${e.type === 'streaming' ? 'selected' : ''}>🔴 Streaming</option>`;
|
||||||
html += `</select>`;
|
html += `</select>`;
|
||||||
html += `<input type="text" id="act-name-${section}-${mood}-${i}" value="${escapeHtml(e.name)}" placeholder="Song/Game name" style="flex:2; min-width:120px;">`;
|
html += `<input type="text" id="act-name-${section}-${mood}-${i}" value="${escapeHtml(e.name)}" placeholder="Name" style="flex:2; min-width:120px;">`;
|
||||||
html += `<input type="text" id="act-state-${section}-${mood}-${i}" value="${escapeHtml(e.state || '')}" placeholder="Artist / Genre (optional)" style="flex:1.5; min-width:100px;">`;
|
html += `<input type="text" id="act-state-${section}-${mood}-${i}" value="${escapeHtml(e.state || '')}" placeholder="Detail (optional)" style="flex:1.5; min-width:100px;">`;
|
||||||
|
html += `<input type="text" id="act-url-${section}-${mood}-${i}" value="${escapeHtml(e.url || '')}" placeholder="URL (streaming)" style="flex:1.5; min-width:100px; ${e.type === 'streaming' ? '' : 'display:none;'}">`;
|
||||||
html += `<input type="number" id="act-weight-${section}-${mood}-${i}" value="${e.weight}" min="1" max="20" style="width:60px;">`;
|
html += `<input type="number" id="act-weight-${section}-${mood}-${i}" value="${e.weight}" min="1" max="20" style="width:60px;">`;
|
||||||
html += `<button onclick="activitiesRemoveEntry('${section}','${mood}',${i})" style="background:#c0392b; padding:0.3rem 0.5rem;" title="Remove">✕</button>`;
|
html += `<button onclick="activitiesRemoveEntry('${section}','${mood}',${i})" style="background:#c0392b; padding:0.3rem 0.5rem;" title="Remove">✕</button>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@@ -6918,6 +6964,13 @@ function activitiesRenderEditForm(section, mood, entries) {
|
|||||||
return html;
|
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) {
|
function activitiesMoodToggle(section, mood) {
|
||||||
const el = document.getElementById(`act-content-${section}-${mood}`);
|
const el = document.getElementById(`act-content-${section}-${mood}`);
|
||||||
const iconEl = document.getElementById(`act-icon-${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 typeEl = document.getElementById(`act-type-${section}-${mood}-${i}`);
|
||||||
const nameEl = document.getElementById(`act-name-${section}-${mood}-${i}`);
|
const nameEl = document.getElementById(`act-name-${section}-${mood}-${i}`);
|
||||||
const stateEl = document.getElementById(`act-state-${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}`);
|
const weightEl = document.getElementById(`act-weight-${section}-${mood}-${i}`);
|
||||||
if (typeEl) entries[i].type = typeEl.value;
|
if (typeEl) entries[i].type = typeEl.value;
|
||||||
if (nameEl) entries[i].name = nameEl.value;
|
if (nameEl) entries[i].name = nameEl.value;
|
||||||
if (stateEl) entries[i].state = stateEl.value || undefined;
|
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;
|
if (weightEl) entries[i].weight = parseInt(weightEl.value) || 1;
|
||||||
}
|
}
|
||||||
activitiesEditCache[key] = entries;
|
activitiesEditCache[key] = entries;
|
||||||
@@ -6999,6 +7054,10 @@ async function activitiesSave(section, mood) {
|
|||||||
showNotification(`Entry ${i + 1}: weight must be at least 1`, 'error');
|
showNotification(`Entry ${i + 1}: weight must be at least 1`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (entries[i].type === 'streaming' && !entries[i].url) {
|
||||||
|
showNotification(`Entry ${i + 1}: streaming requires a URL`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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} <strong>${label}</strong> ${escapeHtml(act.name)}`;
|
||||||
|
if (act.state) html += ` <span style="color:#aaa;">— ${escapeHtml(act.state)}</span>`;
|
||||||
|
if (isOverride) html += ` <span style="color:#e67e22; font-size:0.8rem;">⚡ MANUAL OVERRIDE (30 min)</span>`;
|
||||||
|
statusEl.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
let html = '<span style="color:#888;">No activity (idle)</span>';
|
||||||
|
if (isOverride) html += ' <span style="color:#e67e22; font-size:0.8rem;">⚡ MANUAL OVERRIDE</span>';
|
||||||
|
statusEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.innerHTML = `<span style="color:#e74c3c;">Error: ${e.message}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
"""
|
"""
|
||||||
Mood-based Discord activity status system.
|
Mood-based Discord activity status system.
|
||||||
|
|
||||||
Loads activity definitions from activities.yaml and provides functions to:
|
Activity display is driven by the autonomous engine's mood energy profiles:
|
||||||
- Pick a weighted-random activity for a given mood
|
- High-energy moods (excited, bubbly) → almost always show an activity
|
||||||
- Update the bot's Discord presence (Listening/Playing)
|
- Low-energy moods (sleepy, melancholy) → mostly idle, occasionally active
|
||||||
- Get/set activity data for the Web UI
|
- Manual override via Web UI bypasses automatic behavior
|
||||||
|
|
||||||
|
Supports 5 activity types: listening, playing, watching, competing, streaming.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import time
|
||||||
import yaml
|
import yaml
|
||||||
import discord
|
import discord
|
||||||
import globals
|
import globals
|
||||||
@@ -19,16 +22,59 @@ logger = get_logger('activity')
|
|||||||
|
|
||||||
ACTIVITIES_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "activities.yaml")
|
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)
|
# Cache: (data_dict, file_mtime)
|
||||||
_activities_cache = None
|
_activities_cache = None
|
||||||
_cache_mtime = 0.0
|
_cache_mtime = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# YAML Loading / Saving
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def _load_activities(force=False):
|
def _load_activities(force=False):
|
||||||
"""Load activities.yaml with file-mtime-based caching.
|
"""Load activities.yaml with file-mtime-based caching."""
|
||||||
|
|
||||||
Returns the full dict: {"normal": {...}, "evil": {...}}
|
|
||||||
"""
|
|
||||||
global _activities_cache, _cache_mtime
|
global _activities_cache, _cache_mtime
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -60,7 +106,6 @@ def save_activities(data: dict):
|
|||||||
with open(ACTIVITIES_FILE, "w", encoding="utf-8") as f:
|
with open(ACTIVITIES_FILE, "w", encoding="utf-8") as f:
|
||||||
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
|
||||||
# Update cache immediately
|
|
||||||
_activities_cache = data
|
_activities_cache = data
|
||||||
_cache_mtime = os.path.getmtime(ACTIVITIES_FILE)
|
_cache_mtime = os.path.getmtime(ACTIVITIES_FILE)
|
||||||
logger.info(f"Saved activities to {ACTIVITIES_FILE}")
|
logger.info(f"Saved activities to {ACTIVITIES_FILE}")
|
||||||
@@ -69,6 +114,10 @@ def save_activities(data: dict):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# CRUD for activity data (used by Web UI)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def get_all_activities() -> dict:
|
def get_all_activities() -> dict:
|
||||||
"""Return the full activities dict (normal + evil sections)."""
|
"""Return the full activities dict (normal + evil sections)."""
|
||||||
return _load_activities()
|
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):
|
def set_activities_for_mood(mood_name: str, is_evil: bool, activities: list):
|
||||||
"""Validate and save updated activity list for a mood.
|
"""Validate and save updated activity list for a mood.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mood_name: mood key (e.g. "bubbly", "aggressive")
|
mood_name: mood key (e.g. "bubbly", "aggressive")
|
||||||
is_evil: True for evil section, False for normal
|
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:
|
Raises:
|
||||||
ValueError: if validation fails
|
ValueError: if validation fails
|
||||||
"""
|
"""
|
||||||
# Validate
|
|
||||||
valid_types = {"listening", "playing"}
|
|
||||||
for i, entry in enumerate(activities):
|
for i, entry in enumerate(activities):
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
raise ValueError(f"Entry {i} must be a dict, got {type(entry).__name__}")
|
raise ValueError(f"Entry {i} must be a dict, got {type(entry).__name__}")
|
||||||
if entry.get("type") not in valid_types:
|
if entry.get("type") not in VALID_ACTIVITY_TYPES:
|
||||||
raise ValueError(f"Entry {i} has invalid type '{entry.get('type')}', must be 'listening' or 'playing'")
|
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):
|
if not entry.get("name") or not isinstance(entry["name"], str):
|
||||||
raise ValueError(f"Entry {i} must have a non-empty string 'name'")
|
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:
|
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")
|
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):
|
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")
|
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"
|
section = "evil" if is_evil else "normal"
|
||||||
data = _load_activities()
|
data = _load_activities()
|
||||||
@@ -114,38 +166,157 @@ def set_activities_for_mood(mood_name: str, is_evil: bool, activities: list):
|
|||||||
save_activities(data)
|
save_activities(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Activity Selection
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def pick_activity_for_mood(mood_name: str, is_evil: bool = False):
|
def pick_activity_for_mood(mood_name: str, is_evil: bool = False):
|
||||||
"""Pick a weighted-random activity for a mood.
|
"""Pick a weighted-random activity for a mood.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (activity_type, name, state) e.g. ("listening", "World is Mine", "by ryo (supercell)")
|
dict: {"type": ..., "name": ..., "state": ..., "url": ...}
|
||||||
state may be None if not defined. Fallback: ("listening", "Vocaloid", None)
|
state and url may be None.
|
||||||
|
Returns None if mood has no entries.
|
||||||
"""
|
"""
|
||||||
activities = get_activities_for_mood(mood_name, is_evil)
|
activities = get_activities_for_mood(mood_name, is_evil)
|
||||||
|
|
||||||
if not activities:
|
if not activities:
|
||||||
logger.debug(f"No activities defined for {'evil/' if is_evil else ''}{mood_name}, using fallback")
|
return None
|
||||||
return ("listening", "Vocaloid", None)
|
|
||||||
|
|
||||||
# Weighted random selection
|
|
||||||
weights = [entry.get("weight", 1) for entry in activities]
|
weights = [entry.get("weight", 1) for entry in activities]
|
||||||
chosen = random.choices(activities, weights=weights, k=1)[0]
|
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.
|
"""Update the bot's Discord presence based on the current mood.
|
||||||
|
|
||||||
- asleep: shows idle status, no activity
|
- asleep: idle status, no activity
|
||||||
- Other moods: shows "Listening to..." or "Playing..." with weighted-random pick
|
- 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():
|
if not globals.client or not globals.client.is_ready():
|
||||||
logger.debug("Bot not ready, skipping presence update")
|
logger.debug("Bot not ready, skipping presence update")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# asleep → always idle
|
||||||
if mood_name == "asleep":
|
if mood_name == "asleep":
|
||||||
# While asleep: idle status, no activity
|
_set_current_activity(None)
|
||||||
await globals.client.change_presence(
|
await globals.client.change_presence(
|
||||||
status=discord.Status.idle,
|
status=discord.Status.idle,
|
||||||
activity=None
|
activity=None
|
||||||
@@ -153,31 +324,90 @@ async def update_bot_presence(mood_name: str, is_evil: bool = False):
|
|||||||
logger.info("Set presence: idle (asleep)")
|
logger.info("Set presence: idle (asleep)")
|
||||||
return
|
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":
|
# Energy-based probability: should we show an activity at all?
|
||||||
activity = discord.Activity(type=discord.ActivityType.listening, name=name, state=state)
|
if not force and not should_have_activity(mood_name):
|
||||||
log_label = f"Listening to {name}"
|
await clear_bot_presence()
|
||||||
else:
|
logger.info(f"Decided to be idle (mood={'evil/' if is_evil else ''}{mood_name})")
|
||||||
activity = discord.Activity(type=discord.ActivityType.playing, name=name, state=state)
|
return
|
||||||
log_label = f"Playing {name}"
|
|
||||||
|
|
||||||
if state:
|
# Pick a random activity for this mood
|
||||||
log_label += f" ({state})"
|
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)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update bot presence: {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():
|
async def clear_bot_presence():
|
||||||
"""Clear the bot's activity (set to online with no activity)."""
|
"""Clear the bot's activity (set to online with no activity)."""
|
||||||
if not globals.client or not globals.client.is_ready():
|
if not globals.client or not globals.client.is_ready():
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
_set_current_activity(None)
|
||||||
await globals.client.change_presence(status=discord.Status.online, activity=None)
|
await globals.client.change_presence(status=discord.Status.online, activity=None)
|
||||||
logger.info("Cleared bot presence")
|
logger.info("Cleared bot presence")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to clear bot presence: {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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user