Compare commits

...

13 Commits

Author SHA1 Message Date
9d1ad7f783 Add 'Set as Activity' button to each activity entry in Web UI
Each activity in the mood lists now has a 🎯 Set button that immediately
sets it as the bot's current Discord activity (30-min manual override),
so users can pick from existing entries instead of typing manually.
2026-04-27 23:43:18 +03:00
d6cdb89e42 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
2026-04-27 23:39:18 +03:00
9bc618b526 feat: add 'state' field to mood activities for richer Discord presence
- Add 'state' field to all 139 activity entries in activities.yaml
  - Songs: state shows artist (e.g. 'by kz (livetune)')
  - Games: state shows genre (e.g. 'Rhythm Game', 'Sandbox', 'FPS')
- Update pick_activity_for_mood() to return 3-tuple (type, name, state)
- Update update_bot_presence() to pass state to discord.Activity()
- Add state validation in set_activities_for_mood() (optional string)
- Update Web UI editor: view shows state, edit form has state input
- State is fully optional — backward compatible, no breaking changes

The 'state' field appears as a secondary text line in Discord profile
popup, the richest display possible for bot accounts (full Rich Presence
with cover art/buttons is server-side restricted to OAuth applications).
2026-04-24 16:46:39 +03:00
4dc24b7da8 fix: copy activities.yaml into Docker image 2026-04-24 14:05:09 +03:00
1908b92ce8 fix: move Mood Activities section above Last Prompt in Status tab
Reorders the Status tab so the collapsible Mood Activities editor
appears before the Last Prompt section for better visibility.
2026-04-24 13:59:01 +03:00
6780f6de9e fix: register 'activity' logger component
The custom logger requires components to be registered in the
COMPONENTS dict. Added 'activity' for the mood-based presence system.
2026-04-24 13:58:37 +03:00
9293aec301 feat: add Mood Activities editor to Web UI Status tab
Collapsible section in the Status tab with:
- Normal and Evil mood sections, each collapsible
- Per-mood expandable rows showing songs (🎵) and games (🎮)
- Inline editing: change type, name, weight
- Add/remove entries per mood
- Save via API with client-side validation
- Reload from disk button
- Lazy-loads data only when section is expanded
2026-04-24 13:46:04 +03:00
0f39ccd3c4 feat: set initial Discord presence on startup and on mood detection
- In on_ready(), set presence based on current mood (evil or normal)
  after all state is restored
- When LLM-detected mood shift is applied, update presence immediately
2026-04-24 13:39:39 +03:00
55c3c27f6f feat: integrate activity presence into evil mode
Update Discord presence when:
- Evil mood rotates (shows evil song/game)
- Evil mode is enabled (switches to evil activity pool)
- Evil mode is disabled (restores normal mood activity)
2026-04-24 13:37:21 +03:00
53c07d40e9 feat: integrate activity presence into mood rotation
Call update_bot_presence() in rotate_dm_mood() and
rotate_server_mood() so the Discord status updates whenever
a normal mood rotates automatically.
2026-04-24 13:35:03 +03:00
d6742b0c85 feat: add activities API routes and register in api.py
New endpoints:
- GET /activities — full data (normal + evil)
- GET /activities/{section}/{mood} — per-mood activities
- POST /activities/{section}/{mood} — update activities with validation
- POST /activities/reload — force reload from disk
2026-04-24 13:32:55 +03:00
a5916645df feat: add activities.py module for mood-based Discord presence
New module that loads activities.yaml and provides:
- Weighted random activity selection per mood
- Discord presence update (Listening/Playing)
- File mtime caching for hot-reload
- Validation for CRUD operations
- Fallback for moods with no activities defined
2026-04-24 13:30:54 +03:00
e30316f383 feat: add activities.yaml with mood-based songs and games
Curated list of Vocaloid/Miku songs and real game titles for each
normal mood (13 moods, excluding asleep) and each evil mood (10 moods).
Each entry has type (listening/playing), name, and weight for
weighted random selection. Editable via this file or the Web UI.
2026-04-24 13:20:47 +03:00
10 changed files with 1716 additions and 0 deletions

View File

@@ -67,5 +67,6 @@ COPY persona /app/persona
COPY MikuMikuBeam.mp4 .
COPY Miku_BasicWorkflow.json .
COPY moods /app/moods/
COPY activities.yaml .
CMD ["python", "-u", "bot.py"]

654
bot/activities.yaml Normal file
View File

@@ -0,0 +1,654 @@
normal:
bubbly:
- type: listening
name: Tell Your World
weight: 3
state: by kz (livetune)
- type: listening
name: World is Mine
weight: 3
state: by ryo (supercell)
- type: listening
name: PoPiPo
weight: 2
state: by Lamaze-P
- type: listening
name: Miku Miku ni Shite Ageru♪
weight: 2
state: by ika
- type: listening
name: Love is War
weight: 2
state: by ryo (supercell)
- type: playing
name: 'Hatsune Miku: Project DIVA Mega Mix'
weight: 2
state: Rhythm Game
- type: playing
name: 'Project SEKAI: Colorful Stage!'
weight: 2
state: Rhythm Game
- type: playing
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
weight: 3
state: by ryo (supercell)
- type: listening
name: Electric Angel
weight: 3
state: by Yasuo-P
- type: listening
name: Tell Your World
weight: 2
state: by kz (livetune)
- type: listening
name: SPiCa
weight: 2
state: by kentaro-P
- type: playing
name: 'Hatsune Miku: Project DIVA Future Tone'
weight: 3
state: Rhythm Game
- type: playing
name: Beat Saber
weight: 2
state: VR Rhythm Game
- type: playing
name: osu!
weight: 2
state: Rhythm Game
- type: playing
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♪
weight: 3
state: by ika
- type: listening
name: World is Mine
weight: 2
state: by ryo (supercell)
- type: listening
name: Tell Your World
weight: 2
state: by kz (livetune)
- type: listening
name: Packaged
weight: 2
state: by kz (livetune)
- type: playing
name: Minecraft
weight: 3
state: Sandbox
- type: playing
name: Stardew Valley
weight: 2
state: Farming Sim
- type: playing
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
weight: 3
state: by hachi
- type: listening
name: Hajimete no Oto
weight: 3
state: by malo
- type: listening
name: Kirameki
weight: 2
state: by baker
- type: listening
name: Teo
weight: 2
state: by Oster Projekt
- type: playing
name: 'Animal Crossing: New Horizons'
weight: 2
state: Life Sim
- type: playing
name: Stardew Valley
weight: 2
state: Farming Sim
- type: playing
name: A Short Hike
weight: 1
state: Exploration
curious:
- type: listening
name: Kokoro
weight: 3
state: by Toraboruta-P
- type: listening
name: The Secret Garden
weight: 2
state: by 40mP
- type: listening
name: Maple Dream
weight: 2
state: by Oster Projekt
- type: listening
name: Deep Sea City Underground
weight: 2
state: by OSTER Projekt
- type: playing
name: Minecraft
weight: 3
state: Sandbox
- type: playing
name: Portal 2
weight: 3
state: Puzzle
- type: playing
name: Outer Wilds
weight: 2
state: Exploration
- type: playing
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
weight: 3
state: by ryo (supercell)
- type: listening
name: Plastic Voice
weight: 2
state: by Circus-P
- type: listening
name: Tsugihagi Staccato
weight: 2
state: by 40mP
- type: listening
name: mobius
weight: 2
state: by POWAPOWA-P
- type: playing
name: 'Animal Crossing: New Horizons'
weight: 3
state: Life Sim
- type: playing
name: 'Hatsune Miku: Project DIVA (Practice Mode)'
weight: 2
state: Rhythm Game
- type: playing
name: Stardew Valley
weight: 2
state: Farming Sim
serious:
- type: listening
name: This is the Happiness and Peace of Mind Committee
weight: 3
state: by Utata-P
- type: listening
name: Hibana
weight: 2
state: by DECO*27
- type: listening
name: Uraniwa no Amphibia
weight: 2
state: by niki
- type: playing
name: Chess
weight: 3
state: Strategy
- type: playing
name: Final Fantasy XIV
weight: 2
state: MMORPG
- type: playing
name: Civilization VI
weight: 2
state: 4X Strategy
- type: watching
name: chess tournament
weight: 1
state: PGN Livestream
melancholy:
- type: listening
name: Kokoro
weight: 3
state: by Toraboruta-P
- type: listening
name: The Disappearance of Hatsune Miku
weight: 3
state: by cosMo@Bousou-P
- type: listening
name: Yuki no Hahen
weight: 2
state: by hachi
- type: listening
name: Prisoner
weight: 2
state: by PENGUIN PROJECT
- type: listening
name: Soundless Voice
weight: 2
state: by hachi
- type: playing
name: 'NieR: Automata'
weight: 2
state: Action RPG
- type: playing
name: Final Fantasy X
weight: 2
state: JRPG
flirty:
- type: listening
name: World is Mine
weight: 3
state: by ryo (supercell)
- type: listening
name: Love is War
weight: 3
state: by ryo (supercell)
- type: listening
name: Romeo and Cinderella
weight: 3
state: by doriko
- type: listening
name: Ura Omote Lovers
weight: 2
state: by wowaka
- type: playing
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
weight: 3
state: by doriko
- type: listening
name: Cantarella
weight: 3
state: by KAITO & Hatsune Miku
- type: listening
name: Ai no Uta
weight: 2
state: by Pikotaro-P
- type: listening
name: Koi wo Sensou
weight: 2
state: by ryo (supercell)
- type: playing
name: Stardew Valley
weight: 2
state: Farming Sim
- type: playing
name: Final Fantasy XIV
weight: 2
state: MMORPG
irritated:
- type: listening
name: Ievan Polkka (rock ver.)
weight: 2
state: by Otomania
- type: listening
name: Two-Faced Lovers
weight: 2
state: by wowaka
- type: playing
name: Getting Over It with Bennett Foddy
weight: 3
state: Frustration
- type: playing
name: Dark Souls III
weight: 3
state: Action RPG
- type: playing
name: Elden Ring
weight: 2
state: Action RPG
- type: watching
name: rage compilations
weight: 1
state: YouTube
angry:
- type: listening
name: Two-Faced Lovers
weight: 2
state: by wowaka
- type: listening
name: The Disappearance of Hatsune Miku
weight: 2
state: by cosMo@Bousou-P
- type: playing
name: DOOM Eternal
weight: 3
state: FPS
- type: playing
name: Dark Souls III
weight: 3
state: Action RPG
- type: playing
name: Ultrakill
weight: 2
state: FPS
- type: playing
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
weight: 3
state: by Lamaze-P
- type: listening
name: Ievan Polkka
weight: 3
state: by Otomania
- type: listening
name: Nyan Cat
weight: 2
state: by daniwell-P
- type: listening
name: Fukkireta
weight: 2
state: by Lamaze-P
- type: playing
name: Among Us
weight: 3
state: Social Deduction
- type: playing
name: Goat Simulator
weight: 2
state: Sandbox Comedy
- type: playing
name: osu!taiko
weight: 2
state: Rhythm Game
- type: playing
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
weight: 2
evil:
aggressive:
- type: listening
name: Two-Faced Lovers
weight: 2
state: by wowaka
- type: listening
name: Secret Police
weight: 2
state: by doriko × UMA
- type: playing
name: DOOM Eternal
weight: 3
state: FPS
- type: playing
name: Ultrakill
weight: 3
state: FPS
- type: playing
name: Devil May Cry 5
weight: 2
state: Action
- type: competing
name: DOOM Eternal
weight: 2
state: Ultra Nightmare
cunning:
- type: listening
name: Gekkabijin
weight: 2
state: by masai-P
- type: listening
name: The World is Mine
weight: 2
state: by ryo (supercell)
- type: playing
name: Persona 5 Royal
weight: 3
state: JRPG
- type: playing
name: Among Us
weight: 3
state: Social Deduction
- type: playing
name: 'Hitman: World of Assassination'
weight: 2
state: Stealth
sarcastic:
- type: listening
name: I'm Sorry I'm Sorry
weight: 3
state: by kikuo
- type: listening
name: Karakuri Pierrot
weight: 2
state: by 40mP
- type: playing
name: The Stanley Parable
weight: 3
state: Narrative
- type: playing
name: Portal 2
weight: 3
state: Puzzle
- type: playing
name: Untitled Goose Game
weight: 2
state: Comedy
evil_neutral:
- type: listening
name: Dark Woods Circus
weight: 2
state: by machigerita-P
- type: listening
name: Aku no Meshitsukai
weight: 2
state: by mothy (Akuno-P)
- type: listening
name: Kagome Kagome
weight: 2
state: by subtractor-P
- type: playing
name: 'The Binding of Isaac: Repentance'
weight: 2
state: Roguelike
- type: playing
name: Darkest Dungeon II
weight: 2
state: Roguelike RPG
- type: playing
name: Hollow Knight
weight: 2
state: Metroidvania
bored:
- type: listening
name: Karakuri Pierrot
weight: 2
state: by 40mP
- type: listening
name: Twilight Homicide
weight: 2
state: by yuzuki-P
- type: playing
name: Cookie Clicker
weight: 3
state: Idle Game
- type: playing
name: Vampire Survivors
weight: 3
state: Roguelike
- type: playing
name: Brawl Stars
weight: 2
state: Mobile MOBA
manic:
- type: listening
name: Bacterial Contamination
weight: 2
state: by kikuo
- type: listening
name: Secret Police
weight: 2
state: by doriko × UMA
- type: listening
name: Brain Fluid Explosion Girl
weight: 2
state: by rerulili
- type: playing
name: Ultrakill
weight: 3
state: FPS
- type: playing
name: Muse Dash
weight: 3
state: Rhythm Game
- type: playing
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
weight: 3
state: by cosMo@Bousou-P
- type: listening
name: Aishite Aishite Aishite
weight: 3
state: by kikuo
- type: listening
name: Witch Hunt
weight: 2
state: by No.D
- type: playing
name: Yandere Simulator
weight: 3
state: Stealth
melancholic:
- type: listening
name: Prisoner
weight: 3
state: by PENGUIN PROJECT
- type: listening
name: Dark Woods Circus
weight: 3
state: by machigerita-P
- type: listening
name: Shinitagari
weight: 2
state: by rerulili
- type: playing
name: 'NieR: Automata'
weight: 3
state: Action RPG
- type: playing
name: Silent Hill 2
weight: 2
state: Survival Horror
playful_cruel:
- type: listening
name: Fear Garden
weight: 2
state: by COSMOS-P
- type: listening
name: Kanashimi no Nami ni Oboreru
weight: 2
state: by Sasanomaly
- type: playing
name: Dead by Daylight
weight: 3
state: Survival Horror
- type: playing
name: Lethal Company
weight: 3
state: Co-op Horror
- type: playing
name: Content Warning
weight: 2
state: Co-op Horror
contemptuous:
- type: listening
name: The World is Mine
weight: 3
state: by ryo (supercell)
- type: listening
name: Queen of the Night
weight: 2
state: by Nightcord at 25:00
- type: playing
name: Civilization VI
weight: 3
state: 4X Strategy
- type: playing
name: Chess
weight: 2
state: Strategy
- type: playing
name: Crusader Kings III
weight: 2
state: Grand Strategy
- type: watching
name: world domination tutorials
weight: 1
state: YouTube

View File

@@ -101,6 +101,7 @@ from routes.config import router as config_router
from routes.logging_config import router as logging_config_router
from routes.voice import router as voice_router
from routes.memory import router as memory_router
from routes.activities import router as activities_router
app.include_router(core_router)
app.include_router(mood_router)
@@ -121,6 +122,7 @@ app.include_router(config_router)
app.include_router(logging_config_router)
app.include_router(voice_router)
app.include_router(memory_router)
app.include_router(activities_router)

View File

@@ -136,6 +136,16 @@ async def on_ready():
# Save current avatar as fallback
await profile_picture_manager.save_current_avatar_as_fallback()
# Set initial Discord presence based on current mood
try:
from utils.activities import update_bot_presence
if globals.EVIL_MODE:
await update_bot_presence(globals.EVIL_DM_MOOD, is_evil=True, force=True)
else:
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}")
# Start server-specific schedulers (includes DM mood rotation)
server_manager.start_all_schedulers(globals.client)
@@ -376,6 +386,13 @@ async def on_message(message):
from utils.moods import update_server_nickname
globals.client.loop.create_task(update_server_nickname(message.guild.id))
# Update Discord presence to match new mood
try:
from utils.activities import update_bot_presence
await update_bot_presence(detected, is_evil=False)
except Exception as e:
logger.error(f"Failed to update presence after mood detection: {e}")
logger.info(f"🔄 Server mood auto-updated to: {detected}")
if detected == "asleep":

140
bot/routes/activities.py Normal file
View File

@@ -0,0 +1,140 @@
"""Activities API routes — CRUD for mood-based song/game activity lists."""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/activities")
def get_all_activities():
"""Return the full activities data (normal + evil sections, all moods)."""
from utils.activities import get_all_activities
return get_all_activities()
@router.get("/activities/{section}/{mood}")
def get_mood_activities(section: str, mood: str):
"""Return activities for a specific mood.
Args:
section: "normal" or "evil"
mood: mood name (e.g. "bubbly", "aggressive")
"""
if section not in ("normal", "evil"):
return JSONResponse(status_code=400, content={"error": "Section must be 'normal' or 'evil'"})
from utils.activities import get_activities_for_mood
activities = get_activities_for_mood(mood, is_evil=(section == "evil"))
return {"section": section, "mood": mood, "activities": activities}
@router.post("/activities/{section}/{mood}")
async def set_mood_activities(section: str, mood: str, request: Request):
"""Update activities for a specific mood.
Body: {"activities": [{"type": "listening"|"playing", "name": "...", "weight": 1}]}
"""
if section not in ("normal", "evil"):
return JSONResponse(status_code=400, content={"error": "Section must be 'normal' or 'evil'"})
data = await request.json()
activities = data.get("activities")
if activities is None:
return JSONResponse(status_code=400, content={"error": "Request body must include 'activities' list"})
if not isinstance(activities, list):
return JSONResponse(status_code=400, content={"error": "'activities' must be a list"})
try:
from utils.activities import set_activities_for_mood
set_activities_for_mood(mood, is_evil=(section == "evil"), activities=activities)
logger.info(f"Updated activities for {section}/{mood}: {len(activities)} entries")
return {"status": "ok", "section": section, "mood": mood, "count": len(activities)}
except ValueError as e:
return JSONResponse(status_code=400, content={"error": str(e)})
except Exception as e:
logger.error(f"Failed to save activities for {section}/{mood}: {e}")
return JSONResponse(status_code=500, content={"error": "Internal server error"})
@router.post("/activities/reload")
def reload_activities():
"""Force reload activities from disk (useful after hand-editing the YAML)."""
from utils.activities import _load_activities
data = _load_activities(force=True)
normal_count = sum(len(v) for v in data.get("normal", {}).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")
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"})

View File

@@ -450,6 +450,46 @@
color: #ddd;
}
/* Mood Activities Editor */
.act-mood-row {
margin-bottom: 0.5rem;
border: 1px solid #3a3a3a;
border-radius: 4px;
overflow: hidden;
}
.act-mood-header {
cursor: pointer;
user-select: none;
padding: 0.5rem 0.75rem;
background: #2a2a2a;
display: flex;
align-items: center;
gap: 0.5rem;
}
.act-mood-header:hover { background: #333; }
.act-mood-header .act-mood-name { font-weight: bold; min-width: 120px; }
.act-mood-header .act-mood-stats { color: #888; font-size: 0.8rem; }
.act-mood-content { display: none; padding: 0.75rem; background: #1e1e1e; }
.act-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid #333;
}
.act-entry:last-child { border-bottom: none; }
.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: 130px; }
.act-toolbar {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #444;
}
.tab-content {
display: none;
}
@@ -1310,6 +1350,69 @@
<div id="status"></div>
</div>
<!-- Mood Activities Section (collapsible) -->
<div class="section">
<div style="cursor: pointer; user-select: none;" onclick="activitiesToggle()">
<h3 style="display: inline;"><span id="activities-toggle-icon"></span> 🎵 Mood Activities</h3>
<span id="activities-summary" style="color: #888; font-size: 0.85rem; margin-left: 0.5rem;"></span>
</div>
<div id="activities-body" style="display: none; margin-top: 1rem;">
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; align-items: center;">
<button onclick="activitiesLoad()" style="background: #4a7bc9;">🔄 Reload from Disk</button>
<span id="activities-status" style="font-size: 0.85rem; color: #61dafb;"></span>
</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 -->
<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')">
<strong><span id="activities-normal-icon"></span> 😇 Normal Moods</strong>
</div>
<div id="activities-normal-body" style="display: none; padding-left: 0.5rem;">
<div id="activities-normal-list"></div>
</div>
</div>
<!-- Evil Moods subsection -->
<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('evil')">
<strong><span id="activities-evil-icon"></span> 😈 Evil Moods</strong>
</div>
<div id="activities-evil-body" style="display: none; padding-left: 0.5rem;">
<div id="activities-evil-list"></div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Last Prompt</h3>
<div style="margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.75rem;">
@@ -6731,6 +6834,356 @@ function escapeJsonForAttribute(obj) {
.replace(/>/g, '&gt;');
}
// ============================================================================
// MOOD ACTIVITIES EDITOR
// ============================================================================
let activitiesData = null; // Full activities data from API
let activitiesOpen = false; // Top-level accordion state
let activitiesSections = { normal: false, evil: false }; // Section accordion state
let activitiesEditing = {}; // Track which moods are in edit mode: { "normal/bubbly": true }
let activitiesEditCache = {}; // Temp storage for edits: { "normal/bubbly": [...] }
function activitiesToggle() {
activitiesOpen = !activitiesOpen;
document.getElementById('activities-body').style.display = activitiesOpen ? 'block' : 'none';
document.getElementById('activities-toggle-icon').textContent = activitiesOpen ? '▼' : '▶';
if (activitiesOpen) {
if (!activitiesData) activitiesLoad();
activityRefreshCurrent();
}
}
function activitiesSectionToggle(section) {
activitiesSections[section] = !activitiesSections[section];
document.getElementById(`activities-${section}-body`).style.display = activitiesSections[section] ? 'block' : 'none';
document.getElementById(`activities-${section}-icon`).textContent = activitiesSections[section] ? '▼' : '▶';
}
async function activitiesLoad() {
const statusEl = document.getElementById('activities-status');
statusEl.textContent = 'Loading...';
try {
activitiesData = await apiCall('/activities');
const normalMoods = Object.keys(activitiesData.normal || {});
const evilMoods = Object.keys(activitiesData.evil || {});
const total = normalMoods.length + evilMoods.length;
document.getElementById('activities-summary').textContent = `(${total} moods configured)`;
activitiesRenderSection('normal');
activitiesRenderSection('evil');
statusEl.textContent = '';
} catch (e) {
statusEl.textContent = 'Failed to load: ' + e.message;
statusEl.style.color = '#e74c3c';
}
}
function activitiesRenderSection(section) {
const container = document.getElementById(`activities-${section}-list`);
if (!activitiesData || !activitiesData[section]) { container.innerHTML = '<p style="color:#888;">No data</p>'; return; }
const moods = activitiesData[section];
let html = '';
for (const [mood, entries] of Object.entries(moods)) {
const key = `${section}/${mood}`;
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 += `<div class="act-mood-row">`;
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-stats">${stats}</span>`;
html += `</div>`;
html += `<div class="act-mood-content" id="act-content-${section}-${mood}">`;
if (isEditing) {
html += activitiesRenderEditForm(section, mood, activitiesEditCache[key] || entries);
} else {
html += activitiesRenderView(section, mood, entries);
}
html += `</div></div>`;
}
container.innerHTML = html;
}
function activitiesRenderView(section, mood, entries) {
let html = '';
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
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';
// Encode entry data for the "Set as Activity" button
const entryData = encodeURIComponent(JSON.stringify({ type: entry.type, name: entry.name, state: entry.state || '', url: entry.url || '' }));
html += `<div class="act-entry">`;
html += `<span class="act-entry-icon">${icon}</span>`;
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>`;
html += `</span>`;
html += `<span style="color:#888; font-size:0.8rem;">weight: ${entry.weight}</span>`;
html += `<button onclick="activitySetFromEntry(this)" data-entry="${entryData}" style="background:#e67e22; font-size:0.75rem; padding:0.2rem 0.5rem; margin-left:0.3rem;" title="Set this as bot's current activity (30 min override)">🎯 Set</button>`;
html += `</div>`;
}
html += `<div class="act-toolbar">`;
html += `<button onclick="activitiesStartEdit('${section}','${mood}')" style="background:#4a7bc9;">✏️ Edit</button>`;
html += `</div>`;
return html;
}
function activitiesRenderEditForm(section, mood, entries) {
let html = '';
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
html += `<div class="act-entry">`;
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="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 += `<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="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 += `<button onclick="activitiesRemoveEntry('${section}','${mood}',${i})" style="background:#c0392b; padding:0.3rem 0.5rem;" title="Remove">✕</button>`;
html += `</div>`;
}
html += `<div class="act-toolbar">`;
html += `<button onclick="activitiesAddEntry('${section}','${mood}')" style="background:#27ae60;"> Add Entry</button>`;
html += `<button onclick="activitiesSave('${section}','${mood}')" style="background:#4a7bc9;">💾 Save</button>`;
html += `<button onclick="activitiesCancelEdit('${section}','${mood}')" style="background:#555;">Cancel</button>`;
html += `</div>`;
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}`);
if (!el) return;
const isOpen = el.style.display === 'block';
el.style.display = isOpen ? 'none' : 'block';
if (iconEl) iconEl.textContent = isOpen ? '▶' : '▼';
}
function activitiesStartEdit(section, mood) {
const key = `${section}/${mood}`;
const entries = activitiesData[section][mood];
// Deep clone entries for editing
activitiesEditCache[key] = JSON.parse(JSON.stringify(entries));
activitiesEditing[key] = true;
activitiesRenderSection(section);
// Auto-expand the mood panel
const el = document.getElementById(`act-content-${section}-${mood}`);
const iconEl = document.getElementById(`act-icon-${section}-${mood}`);
if (el) el.style.display = 'block';
if (iconEl) iconEl.textContent = '▼';
}
function activitiesCancelEdit(section, mood) {
const key = `${section}/${mood}`;
delete activitiesEditing[key];
delete activitiesEditCache[key];
activitiesRenderSection(section);
}
function activitiesAddEntry(section, mood) {
const key = `${section}/${mood}`;
// First, sync current form values to cache
activitiesSyncFormToCache(section, mood);
activitiesEditCache[key].push({ type: 'listening', name: '', state: '', weight: 1 });
activitiesRenderSection(section);
// Keep the mood panel open
const el = document.getElementById(`act-content-${section}-${mood}`);
if (el) el.style.display = 'block';
}
function activitiesRemoveEntry(section, mood, index) {
const key = `${section}/${mood}`;
activitiesSyncFormToCache(section, mood);
activitiesEditCache[key].splice(index, 1);
activitiesRenderSection(section);
const el = document.getElementById(`act-content-${section}-${mood}`);
if (el) el.style.display = 'block';
}
function activitiesSyncFormToCache(section, mood) {
const key = `${section}/${mood}`;
const entries = activitiesEditCache[key] || [];
for (let i = 0; i < entries.length; i++) {
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;
}
async function activitiesSave(section, mood) {
const key = `${section}/${mood}`;
activitiesSyncFormToCache(section, mood);
const entries = activitiesEditCache[key];
// Client-side validation
for (let i = 0; i < entries.length; i++) {
if (!entries[i].name || !entries[i].name.trim()) {
showNotification(`Entry ${i + 1}: name cannot be empty`, 'error');
return;
}
if (!entries[i].weight || entries[i].weight < 1) {
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 {
await apiCall(`/activities/${section}/${mood}`, 'POST', { activities: entries });
showNotification(`Saved activities for ${section}/${mood}`, 'success');
delete activitiesEditing[key];
delete activitiesEditCache[key];
// Reload to get fresh data
await activitiesLoad();
} catch (e) {
showNotification('Save failed: ' + e.message, 'error');
}
}
// ============================================================================
// 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 activitySetFromEntry(btnElement) {
const raw = btnElement.getAttribute('data-entry');
if (!raw) return;
let entry;
try { entry = JSON.parse(decodeURIComponent(raw)); } catch { return; }
const type = entry.type;
const name = entry.name;
const state = entry.state || null;
const url = entry.url || null;
if (!name) { showNotification('Activity name is empty', '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);
const icon = _activityTypeIcon(type);
showNotification(`${icon} Set activity: ${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>
</body>

413
bot/utils/activities.py Normal file
View File

@@ -0,0 +1,413 @@
# 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}")

View File

@@ -659,6 +659,13 @@ async def apply_evil_mode_changes(client, change_username=True, change_pfp=True,
except Exception as e:
logger.error(f"Failed to switch Cat to evil personality: {e}")
# Update Discord presence to show evil mood activity
try:
from utils.activities import update_bot_presence
await update_bot_presence(globals.EVIL_DM_MOOD, is_evil=True)
except Exception as e:
logger.error(f"Failed to update presence after enabling evil mode: {e}")
logger.info("Evil Mode enabled!")
@@ -739,6 +746,13 @@ async def revert_evil_mode_changes(client, change_username=True, change_pfp=True
except Exception as e:
logger.error(f"Failed to switch Cat to normal personality: {e}")
# Restore Discord presence to normal mood activity
try:
from utils.activities import update_bot_presence
await update_bot_presence(globals.DM_MOOD, is_evil=False)
except Exception as e:
logger.error(f"Failed to restore presence after disabling evil mode: {e}")
logger.info("Evil Mode disabled!")
@@ -894,6 +908,13 @@ async def rotate_evil_mood():
except Exception as e:
logger.error(f"Failed to update nicknames after evil mood rotation: {e}")
# Update Discord presence to match new evil mood
try:
from utils.activities import update_bot_presence
await update_bot_presence(new_mood, is_evil=True)
except Exception as e:
logger.error(f"Failed to update presence after evil mood rotation: {e}")
logger.info(f"Evil mood rotated from {old_mood} to {new_mood}")

View File

@@ -67,6 +67,7 @@ COMPONENTS = {
'error_handler': 'Error detection and webhook notifications',
'uno': 'UNO game automation and commands',
'task_tracker': 'Task tracking and management system',
'activity': 'Mood-based Discord presence and activity status',
}
# Global configuration

View File

@@ -180,6 +180,13 @@ async def rotate_dm_mood():
globals.DM_MOOD = new_mood
globals.DM_MOOD_DESCRIPTION = load_mood_description(new_mood)
# Update Discord presence to match new mood
try:
from utils.activities import update_bot_presence
await update_bot_presence(new_mood, is_evil=False)
except Exception as e:
logger.error(f"Failed to update presence after DM mood rotation: {e}")
logger.info(f"DM mood rotated from {old_mood} to {new_mood}")
except Exception as e:
@@ -307,6 +314,13 @@ async def rotate_server_mood(guild_id: int):
# Update nickname for this specific server
await update_server_nickname(guild_id)
# Update Discord presence to match new mood
try:
from utils.activities import update_bot_presence
await update_bot_presence(new_mood_name, is_evil=False)
except Exception as e:
logger.error(f"Failed to update presence after server mood rotation: {e}")
logger.info(f"Rotated mood for server {guild_id} from {old_mood_name} to {new_mood_name}")
except Exception as e:
logger.error(f"Exception in rotate_server_mood for server {guild_id}: {e}")