Compare commits

..

49 Commits

Author SHA1 Message Date
9eb081efb1 llama-swap: use pre-built images (:cuda, :rocm) with GPU-specific flags
- Drop custom Dockerfiles; docker-compose uses ghcr.io pre-built images
  which ship llama-swap + llama-server with no pinned versions (always latest)
- NVIDIA GTX 1660 (6GB): add -fit off --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0
  to fix OOM segfault with new llama.cpp b9014's GPU-side KV cache default
- AMD RX 6800 (16GB): flags unchanged; KV cache stays on GPU for max speed
- Both running llama-swap v211 + llama.cpp b9014 (2026-05-05)
2026-05-05 16:53:34 +03:00
4e28236b06 fix: preserve collapsible subsection state across polling re-renders
- Use stable section IDs (without Date.now()) so collapse state can be
  tracked across re-renders
- Snapshot collapsed state before innerHTML replacement, restore after
- Prevents the 10s polling from expanding all subsections every time
2026-05-02 16:17:26 +03:00
c5e49c73df fix: add cache-busting to prevent stale JS/CSS from breaking the UI
- Added ?v=20260502 query param to all <script src=...> and <link> tags
- Added Cache-Control: no-cache, no-store, must-revalidate to index route
- Added <meta> cache-control tags in HTML head for extra coverage
- This ensures the browser always fetches fresh HTML/JS/CSS after deploy,
  preventing the old loadLastPrompt() from running against new HTML
  (which would crash since #prompt-cat-info no longer exists)
2026-05-02 16:08:47 +03:00
393921e524 fix: add min-height to #prompt-display and placeholder text in clearPromptDisplay()
The empty #prompt-display div collapsed to 0 height, making it appear
'gone'. Added min-height: 3rem and a 'No prompt selected.' placeholder
that clearPromptDisplay() now sets via innerHTML.
2026-05-02 15:55:19 +03:00
2dd32d0ef1 fix: move <pre> outside #prompt-display to prevent innerHTML from destroying it
The renderPromptEntry() function sets innerHTML on #prompt-display, which
was wiping out the child <pre id="last-prompt"> element. This caused
copyPromptToClipboard() to fail silently and the display to appear empty.

Fix: keep <pre> as a hidden sibling outside #prompt-display, used only as
a text buffer for the copy function.
2026-05-02 15:45:54 +03:00
a980b90c0a fix: escape content in buildCollapsibleSection, avoid double-escaping response 2026-05-02 15:27:18 +03:00
6b922d84ae frontend: rewrite Last Prompt as Prompt History viewer
- status.js: replace loadLastPrompt() with loadPromptHistory() + helpers
  - fetch /prompts with optional source filter, populate dropdown
  - selectPromptEntry() renders metadata bar + collapsible subsections
  - parsePromptSections() splits full_prompt into System/Context/Conversation
  - buildCollapsibleSection() with toggle arrows (▼/▶)
  - copyPromptToClipboard() copies raw text
  - toggleMiddleTruncation() truncates response from middle
  - togglePromptHistoryCollapse() collapses entire section
  - legacy loadLastPrompt() delegates to loadPromptHistory()
- core.js: add promptInterval to polling (10s), visibility resume
  - update switchPromptSource() for 'all' filter + new button IDs
  - update initPromptSourceToggle() default to 'all'
  - declare promptInterval variable
2026-05-02 15:25:05 +03:00
f33e2afdf7 frontend: new Prompt History section HTML + CSS
- Replace single <pre> Last Prompt with rich Prompt History viewer
- Add source filter buttons (All/Cat/Fallback), history dropdown selector
- Add metadata bar, copy-to-clipboard button, middle-truncation toggle
- Add collapsible section CSS classes for expandable subsections
2026-05-02 15:19:10 +03:00
87de8f8b3a backend: replace LAST_FULL_PROMPT/LAST_CAT_INTERACTION with unified PROMPT_HISTORY deque
- globals.py: add collections.deque(maxlen=10) PROMPT_HISTORY with _prompt_id_counter
- globals.py: add legacy accessor functions _get_last_fallback_prompt() and _get_last_cat_interaction()
- bot.py: append to PROMPT_HISTORY instead of setting LAST_CAT_INTERACTION, remove 500-char truncation, add guild/channel/model fields
- image_handling.py: same pattern for Cat media responses
- llm.py: append fallback prompts to PROMPT_HISTORY with response filled after LLM reply
- routes/core.py: new GET /prompts and GET /prompts/{id} endpoints, legacy /prompt and /prompt/cat use accessor functions
2026-05-02 15:17:15 +03:00
2d0c80b7ef fix: prevent infinite dialogue loops + make Evil Miku actually engage
- Question override now decays after 6 turns: after turn 6, the LLM's own
  [CONTINUE] signal is respected even when questions are asked. This prevents
  infinite question-ping-pong where both personas keep asking questions.
- _parse_response now accepts turn_count parameter; generate_response_with_continuation
  and handle_dialogue_turn pass it through.
- Rewrote Evil Miku's conversation-mode overlay with explicit CRITICAL RULES:
  ANSWER questions, engage with what she says, ask questions too, don't just
  repeat dismissive one-liners. The old overlay said 'be playful-cruel' but
  didn't actually tell her to participate in the conversation.
2026-04-30 15:39:53 +03:00
17842f24d4 fix: remove broken personality snippet system — now redundant
The snippet loader used wrong file paths (/app/cat/data/ instead of persona/)
causing 'Loaded 0 personality snippets' for both personas. Since the previous
commit now injects full system prompts (get_miku_system_prompt_compact and
get_evil_system_prompt) into every argument exchange, the snippet system is
redundant — all lore/lyrics/personality are already provided by the system prompts.
2026-04-30 15:16:43 +03:00
4e064ad89b fix: import is_persona_dialogue_active from correct module
Was importing from utils.bipolar_mode instead of utils.persona_dialogue
2026-04-30 15:10:13 +03:00
97c7133fdc fix: both personas now use full system prompts in arguments and dialogues
Created get_miku_system_prompt() and get_miku_system_prompt_compact() in
context_manager.py — mirrors get_evil_system_prompt() so both personas have
equally rich prompts with lore, lyrics, mood integration, and personality.

Previously only Evil Miku had a proper system prompt function. Regular Miku's
arguments and dialogues used a bare-bones hardcoded prompt with no lore/lyrics
— making arguments feel flat compared to normal conversation.

Changes:
- context_manager.py: added get_miku_system_prompt() (full) and
  get_miku_system_prompt_compact() (lore+personality, no lyrics for tokens)
- bipolar_mode.py: both argument prompt functions now accept system_prompt
  param; run_argument() builds miku_system and evil_system once and passes
  them to every exchange
- persona_dialogue.py: dialogue prompts now use get_miku_system_prompt_compact()
  instead of hardcoded stub, matching Evil Miku's full prompt approach
- Removed redundant hardcoded personality text from argument prompts since
  the system prompts now provide it
2026-04-30 15:07:55 +03:00
7d5881ebe7 fix: inject argument topic into EVERY exchange, not just the first message
The topic was only being injected into the initial breakthrough message via
get_argument_start_prompt(). After that, every subsequent exchange called
get_miku_argument_prompt() / get_evil_argument_prompt() which had no concept
of the topic — so both personas forgot what they were arguing about after the
first exchange and reverted to generic identity-crisis arguments.

Fix: added argument_topic parameter to both persona prompt functions and inject
it as a bold ARGUMENT THEME reminder in every single exchange. The topic block
explicitly tells the LLM to stay on-topic and not drift into generic territory.
2026-04-30 12:57:48 +03:00
e6c818f647 fix: merge context + topic into single field — one clear purpose
- Removed separate 'topic' field from BipolarTriggerRequest model
- Removed topic parameter from force_trigger_argument, force_trigger_argument_from_message_id, and run_argument
- trigger_context now doubles as the argument theme: if provided by user, it becomes the topic;
  if blank, a random topic is selected from the rotation pool
- Web UI: replaced two confusing fields (Context + Topic) with one clear field labeled
  'What should they argue about? (optional)' with a plain-English description
- JS: removed topic field reference, context.trim() ensures empty strings aren't sent
2026-04-30 12:30:49 +03:00
846557fa96 feat: add optional custom argument topic override via Web UI
- Added optional 'topic' field to BipolarTriggerRequest model
- Added topic parameter to force_trigger_argument and force_trigger_argument_from_message_id
- Updated run_argument to accept optional custom topic (None=random, ''=no topic, str=custom)
- Added topic input field to Web UI trigger-argument section
- Updated JS to send topic in API request body
- Custom topics bypass the random rotation system, allowing manual theme control
2026-04-30 12:07:28 +03:00
98fca53066 Phase 3: Polish & immersion — mood-aware arguments, personality snippets, parting shots
- Added mood-specific argument behavioral guidance: 9 moods for Evil Miku, 9 for Miku
  Each mood changes argument style (e.g. cunning=chess moves, manic=chaotic, bubbly=playful deflections)
- Added personality snippet injection from Cat plugin lore/lyrics data files
  40% chance per prompt to include a random lore/lyric snippet for unique material
- Added parting shot feature: 20% chance the LOSER gets a bitter final line before the winner's victory
  Adds dramatic tension and prevents clean-win monotony
- Mood guidance and personality flavor injected into both argument prompts
2026-04-30 11:50:37 +03:00
a52b36135f Phase 2: Fix triggers & dialogue — per-channel cooldowns, tension rebalance, user-message triggers
- Changed cooldown from global (ALL channels blocked) to per-channel dict keyed by channel_id
- Added conversation streak tracker: 3 near-miss interjection scores in a row force a dialogue trigger
- Expanded topic relevance keywords: added enthusiasm/vulnerability for Evil Miku, provocation/dismissal for Miku
- Lowered keyword divisor from /3.0 to /2.0 for higher base trigger scores
- Tension rebalance: added natural decay (-0.03/turn), reduced escalation weight (0.08->0.05), increased de-escalation weight (0.06->0.08)
- Reduced momentum multiplier (1.2->1.1) and intensity multiplier (1.3->1.2)
- Added spike cooldown: if last turn tension delta >0.15, next delta halved (prevents runaway spirals)
- Added user-message interjection check in bot.py on_message() (was only checking bot's own messages)
- Added random 15% argument trigger roll on user messages in normal message flow (was only from autonomous.py)
2026-04-30 11:45:13 +03:00
7a4122fd02 Phase 1: Argument system overhaul — arbiter, memory, topics, stats
- Changed arbiter LLM from llama3.1 to darkidol (uncensored, unbiased)
- Rewrote arbiter criteria to judge debate skill equally
- Added argument history injection (last 6 exchanges) to prevent repetition
- Added dynamic topic rotation system (11 weighted topics) with per-channel history
- Added keyword-based argument stats tracking (wit/composure/impact) fed to arbiter
- Removed hardcoded suggestion lists from prompts
2026-04-30 11:37:33 +03:00
20891179ee fix(twitter): update twscrape monkey patch for JS bundle format change
Twitter changed the JS bundle structure from the old single-map format
(e=>e+"."+{...}[e]+"a.js") to a new two-map format
(u.u=e=>""+(({name})[e]||e)+"."+({hash})[e]+"a.js"), breaking
x-client-transaction-id generation.

This caused IndexError: list index out of range, which twscrape
interpreted as an account timeout (15-min lockout), preventing Miku
from fetching/sharing tweets.

The fix adds:
- A robust multi-pattern parser that tries known formats in order
- The _js_obj_to_dict helper from PR #303 for handling unquoted numeric
  keys and scientific notation in JS object literals
- Debug logging to capture the JS snippet when ALL patterns fail,
  making future breakage easier to diagnose

References:
- https://github.com/vladkens/twscrape/issues/302
- https://github.com/vladkens/twscrape/pull/303
2026-04-29 21:32:27 +03:00
694590a620 refactor: Modularize monolithic HTML control panel into organized components
This commit completes a major refactoring of the Miku control panel from a single 7,191-line monolithic HTML file to a modern modular architecture:

CHANGES:
- Extracted 872 lines of CSS into css/style.css
- Created 10 specialized JavaScript modules (4,964 lines total):
  * core.js: Global state, utilities, initialization, polling system
  * servers.js: Server management and mood handling
  * modes.js: Evil mode, GPU selection, bipolar mode, scoreboard
  * actions.js: Autonomous/manual actions, custom prompts, reactions
  * image-gen.js: Image generation system
  * status.js: Status display and statistics
  * dm.js: DM user management and conversation analysis
  * chat.js: LLM chat interface with streaming and voice calls
  * memories.js: Cheshire Cat memory integration (episodic/declarative/procedural)
  * profile.js: Profile picture, album gallery, activities editor
- Cleaned index.html to 1,351 lines (structure only, zero inline JS/CSS)
- Removed 12 duplicate variable declarations
- Maintained strict script load order for dependency resolution
- Added backup comment to index.html.bak for historical reference

VERIFICATION COMPLETED:
✓ All 191 functions/variables from original accounted for
✓ Cross-referenced with backup to ensure nothing lost
✓ All onclick handlers and modal systems validated
✓ No circular dependencies or broken references
✓ HTML structure integrity verified (11 tabs, all buttons/modals intact)
✓ CropperJS CDN links preserved

The refactored code is production-ready with improved maintainability and clear separation of concerns.
2026-04-29 20:56:49 +03:00
6080fe170f Fix all activity system edge cases
Critical fixes:
- Add threading.Lock for all shared mutable state (override, cache, current activity)
- Atomic YAML writes (temp file + os.replace) to prevent corruption on crash
- Deep-copy cache on reads to prevent callers from mutating shared state

High-severity fixes:
- Validate entries in pick_activity_for_mood() — skip/log malformed instead of KeyError
- Log warning on unrecognized activity type fallback
- Normalize empty-string state to None (avoid 'None' display)
- release_manual_override() now uses force=True so bot always shows activity
- Add try/except in release_manual_override() to handle failures gracefully

Medium fixes:
- Remove dead 'test' mood from activities.yaml
- Validate name length (128 char Discord limit) in CRUD and manual set
- Validate streaming entries have URL in CRUD path
- Add JSON parse error handling in API routes
- on_ready preserves active manual override instead of overwriting
- Log override expiry timestamp (HH:MM:SS) for easier debugging
- exc_info=True on presence update errors for full stack traces

Low fixes:
- JS activitySetFromEntry() shows notification on parse error
2026-04-28 00:18:25 +03:00
2d7acd7850 Add anime watching entries to all moods in activities.yaml
- Added 39 new watching entries across all 24 moods (7→46 total)
- Each mood gets 1-2 anime entries thematically matched:
  - bubbly: Cardcaptor Sakura, Precure (magical girl)
  - excited: Bocchi the Rock,, K-ON! (music/slice of life)
  - sleepy: Laid-Back Camp, Natsume's Book of Friends (iyashikei)
  - curious: Dr. Stone (science)
  - shy: Kimi ni Todoke, My Little Monster (shoujo romance)
  - serious: Code Geass (mecha strategy)
  - melancholy: Your Lie in April, Anohana (drama)
  - flirty: Ouran High School Host Club, Kaguya-sama (romcom)
  - romantic: Toradora,, Horimiya (romance)
  - irritated: Asuka's Angry Moments (Evangelion)
  - angry: Attack on Titan, Demon Slayer (action)
  - silly: Nichijou, Gintama (comedy)
  - evil moods: Hellsing, Death Note, NGE, Future Diary, etc.
2026-04-27 23:59:20 +03:00
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
edc9f27925 feat: add proper HTTP status codes to all API error responses
- 217 error returns across 18 route files + api.py now use JSONResponse
  with appropriate HTTP status codes instead of returning HTTP 200
- Status code distribution: 500 (121), 400 (39), 503 (28), 404 (24), 409 (3), 502 (2)
- Fixed language.py tuple-return bug (was serializing as JSON array)
- Fixed bare except clauses in bipolar_mode.py and voice.py
- Body-level error schemas preserved (status/error + success/error patterns)
  so web UI continues working without changes
- chat.py (SSE) unchanged: errors sent within stream protocol
- All 170 tests pass
2026-04-15 15:43:18 +03:00
33b2033cc3 fix: clarify angry_wakeup_timer intent with TODO comment (Phase E Step 20)
- Change misleading 'Unused, kept for structural completeness' to
  'TODO: implement angry-wakeup mechanic or remove field'
- Field is dead code: never read or written in any Python code
2026-04-15 12:26:09 +03:00
fc4674bb13 refactor: extract media processing from bot.py into image_handling.py (Phase D Step 19)
- Create process_media_in_message() in utils/image_handling.py that handles all 4 media
  types: image attachments, video/GIF attachments, Tenor GIF embeds, and rich embeds
- DRY the send→log→bipolar tail pattern (5x repeated) into _send_log_bipolar() helper
- Unify rich/article/link embed handling to use rephrase_as_miku() instead of inline
  Cat→LLM routing, fixing a mood-resolution bug (was using globals.DM_MOOD for servers)
- Add 'rich_embed' media_type to rephrase_as_miku() prefix switch
- Remove 3 inline 'import base64' from bot.py (already module-level in image_handling.py)
- bot.py: 986 → 623 lines (-363)
- image_handling.py: 559 → 881 lines (+322)
- All 170 tests pass (21 config/state + 149 route split)
2026-04-15 12:19:37 +03:00
979217e7cc refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:

  core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
  bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
  profile_picture.py (26), manual_send.py (3), servers.py (6),
  figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
  config.py (7), logging_config.py (9), voice.py (3), memory.py (10)

All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
8b14160028 refactor: consolidate conversation_history to ConversationHistory class
Remove legacy globals.conversation_history (defaultdict of deques) and
route all callers through utils.conversation_history.ConversationHistory:

- globals.py: remove conversation_history + unused collections imports
- llm.py: remove backward-compat dual-write to legacy system
- api.py: /conversation/{user_id} now reads from ConversationHistory
- actions.py: reset_conversation uses clear_channel()
- figurine_notifier.py: use add_message() instead of buggy setdefault()
- bipolar_mode.py: fix clear_history -> clear_channel (was AttributeError
  silently swallowed by bare except), fix bare except -> except Exception
2026-04-11 00:21:44 +03:00
02686c3b96 fix: PREFER_AMD_GPU now lives in globals so config API changes affect GPU routing
Previously gpu_router.py had its own module-level PREFER_AMD_GPU constant
that was frozen at import time. The config API wrote to globals.PREFER_AMD_GPU
which didn't exist, so runtime GPU preference changes never took effect.

Now globals.py owns PREFER_AMD_GPU and gpu_router reads it from there.
2026-04-10 23:53:14 +03:00
366bee2e43 test: add regression test suite for config/state hardening (steps 1-10)
21 tests across 6 groups:
A. Config loading & persistence (runtime path, YAML schema, overrides)
B. Runtime state (live globals reading, /config/set sync, restore)
C. Reset (full reset, single-key reset)
D. Server manager (zero-server default, corrupt handling, CRUD, no dead code)
E. GPU deduplication (delegates to config_manager, correct URL switching)
F. Clean imports (no dead os/Union/GUILD_SETTINGS)

Run: ./bot/tests/run_tests.sh (builds + runs in Docker container)
2026-04-10 17:30:14 +03:00
5ac1f7fa8c cleanup: remove dead code + deduplicate GPU state reads
Dead code removed:
- globals.py: GUILD_SETTINGS (empty dict, zero consumers)
- config.py: unused 'import os'
- config_manager.py: unused 'import os' and 'Union'
- server_manager.py: duplicate 'from datetime import datetime, timedelta'

GPU deduplication:
- get_current_gpu_url() now delegates to config_manager.get_gpu()
- get_gpu_status() endpoint now delegates to config_manager.get_gpu()
- Both previously re-read memory/gpu_state.json directly
2026-04-09 20:34:17 +03:00
834b2ea188 fix: start with zero servers when config is missing or corrupt
Removed _create_default_config() which hardcoded a specific guild ID
(759889672804630530) as a fallback. Now:
- Missing servers_config.json → starts with empty servers dict
- Corrupt JSON → logs error, starts with empty servers dict
- Servers are added via the API/dashboard, not by magic defaults

All code that iterates server_manager.servers handles empty dicts safely.
2026-04-09 20:15:57 +03:00
7804aa4d76 cleanup: remove dead server_memories code
The server_memories dict and its methods (get_server_memory,
set_server_memory) plus API endpoints (GET/POST /servers/{guild_id}/memory)
were never called by any bot logic, command, or frontend code.

All per-server state is stored as ServerConfig dataclass fields and
persisted via servers_config.json. The generic key-value store was an
unfinished scaffolding feature superseded by the dataclass approach.
2026-04-09 20:10:53 +03:00
5c5c9e2723 cleanup: remove dead server config methods from config_manager
get_server_config() and set_server_config() in ConfigManager had zero
callers — every part of the codebase already uses the server_manager
singleton. Removing them eliminates the risk of a stale write that
bypasses the in-memory cache in ServerManager.

server_manager is now the sole owner of servers_config.json.
2026-04-08 15:47:36 +03:00
b4e48ce375 fix: /config/set now syncs all runtime-relevant globals
Previously only 4 of 5+ settings were synced to globals when set via
the generic /config/set endpoint. Added:
- memory.use_cheshire_cat -> globals.USE_CHESHIRE_CAT
- runtime.mood.dm_mood -> globals.DM_MOOD + DM_MOOD_DESCRIPTION
- Uses same _GLOBALS_SYNC mapping pattern as restore_runtime_settings
2026-04-08 15:05:25 +03:00
7c9cf0d8b4 fix: /config/reset now resets live globals to defaults
reset_to_defaults() previously only cleared the runtime_config dict and
saved config_runtime.yaml, but never touched the actual globals that
control runtime behavior. After a reset, LANGUAGE_MODE, AUTONOMOUS_DEBUG,
VOICE_DEBUG_MODE, USE_CHESHIRE_CAT, PREFER_AMD_GPU, and DM_MOOD all kept
their current in-memory values until the next restart.

Now reset_to_defaults() also resets the corresponding globals to their
default values from CONFIG (the static config loaded from config.yaml).
Both full reset and single-key reset are supported. The default values
come from the Pydantic AppConfig schema, ensuring consistency.

Tested: set non-default values, full reset -> all back to defaults,
single-key reset -> only that key back to default, runtime_state property
reflects the reset immediately.
2026-04-08 14:58:29 +03:00
63 changed files with 17281 additions and 9832 deletions

View File

@@ -1,13 +0,0 @@
FROM ghcr.io/mostlygeek/llama-swap:cuda
USER root
# Download and install llama-server binary (CUDA version)
# Using the official pre-built binary from llama.cpp releases
ADD --chmod=755 https://github.com/ggml-org/llama.cpp/releases/download/b4183/llama-server-cuda /usr/local/bin/llama-server
# Verify it's executable
RUN llama-server --version || echo "llama-server installed successfully"
USER 1000:1000

View File

@@ -1,68 +0,0 @@
# Multi-stage build for llama-swap with ROCm support
# Now using official llama.cpp ROCm image (PR #18439 merged Dec 29, 2025)
# Stage 1: Build llama-swap UI
FROM node:22-alpine AS ui-builder
WORKDIR /build
# Install git
RUN apk add --no-cache git
# Clone llama-swap
RUN git clone https://github.com/mostlygeek/llama-swap.git
# Build UI (now in ui-svelte directory)
WORKDIR /build/llama-swap/ui-svelte
RUN npm install && npm run build
# Stage 2: Build llama-swap binary
FROM golang:1.23-alpine AS swap-builder
WORKDIR /build
# Install git
RUN apk add --no-cache git
# Copy llama-swap source with built UI
COPY --from=ui-builder /build/llama-swap /build/llama-swap
# Build llama-swap binary
WORKDIR /build/llama-swap
RUN GOTOOLCHAIN=auto go build -o /build/llama-swap-binary .
# Stage 3: Final runtime image using official llama.cpp ROCm image
FROM ghcr.io/ggml-org/llama.cpp:server-rocm
WORKDIR /app
# Copy llama-swap binary from builder
COPY --from=swap-builder /build/llama-swap-binary /app/llama-swap
# Make binaries executable
RUN chmod +x /app/llama-swap
# Add existing ubuntu user (UID 1000) to GPU access groups (using host GIDs)
# GID 187 = render group on host, GID 989 = video/kfd group on host
RUN groupadd -g 187 hostrender && \
groupadd -g 989 hostvideo && \
usermod -aG hostrender,hostvideo ubuntu && \
chown -R ubuntu:ubuntu /app
# Set environment for ROCm (RX 6800 is gfx1030)
ENV HSA_OVERRIDE_GFX_VERSION=10.3.0
ENV ROCM_PATH=/opt/rocm
ENV HIP_VISIBLE_DEVICES=0
USER ubuntu
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Override the base image's ENTRYPOINT and run llama-swap
ENTRYPOINT []
CMD ["/app/llama-swap", "-config", "/app/config.yaml", "-listen", "0.0.0.0:8080"]

View File

@@ -61,10 +61,12 @@ COPY memory /app/memory
COPY static /app/static COPY static /app/static
COPY globals.py . COPY globals.py .
COPY api.py . COPY api.py .
COPY routes /app/routes
COPY api_main.py . COPY api_main.py .
COPY persona /app/persona COPY persona /app/persona
COPY MikuMikuBeam.mp4 . COPY MikuMikuBeam.mp4 .
COPY Miku_BasicWorkflow.json . COPY Miku_BasicWorkflow.json .
COPY moods /app/moods/ COPY moods /app/moods/
COPY activities.yaml .
CMD ["python", "-u", "bot.py"] CMD ["python", "-u", "bot.py"]

806
bot/activities.yaml Normal file
View File

@@ -0,0 +1,806 @@
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: watching
name: Cardcaptor Sakura
weight: 2
state: Magical Girl Anime
- type: watching
name: Precure
weight: 1
state: Magical Girl Anime
- 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
- type: watching
name: Bocchi the Rock!
weight: 2
state: Music Anime
- type: watching
name: K-ON!
weight: 1
state: Slice of Life Anime
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
- type: watching
name: Laid-Back Camp
weight: 2
state: Slice of Life Anime
- type: watching
name: Natsume's Book of Friends
weight: 1
state: Iyashikei Anime
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
- type: watching
name: Dr. Stone
weight: 1
state: Science Anime
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
- type: watching
name: Kimi ni Todoke
weight: 2
state: Romance Anime
- type: watching
name: My Little Monster
weight: 1
state: Shoujo Anime
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
- type: watching
name: Code Geass
weight: 1
state: Mecha Strategy Anime
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
- type: watching
name: Your Lie in April
weight: 2
state: Drama Anime
- type: watching
name: Anohana
weight: 1
state: Drama Anime
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
- type: watching
name: Ouran High School Host Club
weight: 2
state: Romantic Comedy Anime
- type: watching
name: 'Kaguya-sama: Love Is War'
weight: 1
state: Romantic Comedy Anime
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
- type: watching
name: Toradora!
weight: 2
state: Romance Anime
- type: watching
name: Horimiya
weight: 1
state: Romance Anime
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
- type: watching
name: Asuka's Angry Moments
weight: 1
state: Evangelion
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
- type: watching
name: Attack on Titan
weight: 2
state: Action Anime
- type: watching
name: Demon Slayer
weight: 1
state: Action Anime
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
- type: watching
name: Nichijou
weight: 2
state: Absurdist Comedy Anime
- type: watching
name: Gintama
weight: 1
state: Comedy Anime
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
- type: watching
name: Hellsing Ultimate
weight: 2
state: Dark Action Anime
- type: watching
name: Berserk
weight: 1
state: Dark Fantasy Anime
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
- type: watching
name: Death Note
weight: 2
state: Psychological Thriller Anime
- type: watching
name: Monster
weight: 1
state: Psychological Anime
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
- type: watching
name: Sayonara, Zetsubou-Sensei
weight: 2
state: Satirical Anime
- type: watching
name: Pop Team Epic
weight: 1
state: Absurdist Anime
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
- type: watching
name: Made in Abyss
weight: 2
state: Dark Fantasy Anime
- type: watching
name: Serial Experiments Lain
weight: 1
state: Cyberpunk Anime
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
- type: watching
name: Saiki K
weight: 2
state: Comedy Anime
- type: watching
name: No Game No Life
weight: 1
state: Fantasy Anime
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
- type: watching
name: FLCL
weight: 2
state: Surreal Anime
- type: watching
name: Panty & Stocking
weight: 1
state: Chaotic Comedy Anime
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
- type: watching
name: Future Diary
weight: 2
state: Yandere Thriller Anime
- type: watching
name: School Days
weight: 1
state: Psychological Drama Anime
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
- type: watching
name: Neon Genesis Evangelion
weight: 2
state: Mecha Psychological Anime
- type: watching
name: Texhnolyze
weight: 1
state: Dystopian Anime
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
- type: watching
name: Happy Sugar Life
weight: 2
state: Psychological Horror Anime
- type: watching
name: Another
weight: 1
state: Horror Anime
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

3583
bot/api.py

File diff suppressed because it is too large Load Diff

3597
bot/api_monolith_backup.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -19,15 +19,7 @@ from utils.scheduled import (
send_monday_video send_monday_video
) )
from utils.image_handling import ( from utils.image_handling import (
download_and_encode_image, process_media_in_message,
download_and_encode_media,
extract_video_frames,
analyze_image_with_qwen,
analyze_video_with_vision,
rephrase_as_miku,
extract_tenor_gif_url,
convert_gif_to_mp4,
extract_embed_content
) )
from utils.core import ( from utils.core import (
is_miku_addressed, is_miku_addressed,
@@ -144,6 +136,19 @@ async def on_ready():
# Save current avatar as fallback # Save current avatar as fallback
await profile_picture_manager.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, is_manual_override_active
# On reconnect, don't overwrite an active manual override
if is_manual_override_active():
logger.info("Manual override active on ready, preserving it")
elif 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) # Start server-specific schedulers (includes DM mood rotation)
server_manager.start_all_schedulers(globals.client) server_manager.start_all_schedulers(globals.client)
@@ -198,6 +203,31 @@ async def on_message(message):
if is_persona_dialogue_active(message.channel.id): if is_persona_dialogue_active(message.channel.id):
return return
# Bipolar mode: check if the opposite persona should interject on user messages
# AND roll for random argument trigger (both non-blocking background tasks)
if not isinstance(message.channel, discord.DMChannel) and globals.BIPOLAR_MODE:
try:
from utils.persona_dialogue import check_for_interjection, is_persona_dialogue_active as dialogue_active
from utils.bipolar_mode import maybe_trigger_argument, is_argument_in_progress as arg_in_progress
from utils.task_tracker import create_tracked_task
# Check interjection on user messages (opposite of current active persona)
if not message.author.bot or message.webhook_id:
current_persona = "evil" if globals.EVIL_MODE else "miku"
create_tracked_task(
check_for_interjection(message, current_persona),
task_name="interjection_check_user",
)
# Roll random argument trigger chance (15%) on eligible messages
if not arg_in_progress(message.channel.id) and not dialogue_active(message.channel.id):
create_tracked_task(
maybe_trigger_argument(message.channel, globals.client, "Triggered from conversation flow"),
task_name="random_argument_trigger",
)
except Exception as e:
logger.error(f"Error in bipolar trigger checks: {e}")
if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference: if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference:
async with message.channel.typing(): async with message.channel.typing():
# Get replied-to user # Get replied-to user
@@ -266,343 +296,10 @@ async def on_message(message):
) )
return return
# If message has an image, video, or GIF attachment # Dispatch media processing (images, videos, GIFs, embeds)
if message.attachments: # to utils/image_handling.process_media_in_message()
for attachment in message.attachments:
# Handle images
if any(attachment.filename.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]):
base64_img = await download_and_encode_image(attachment.url)
if not base64_img:
await message.channel.send("I couldn't load the image, sorry!")
return
# Analyze image (objective description)
qwen_description = await analyze_image_with_qwen(base64_img, user_prompt=prompt)
if not qwen_description or not qwen_description.strip():
await message.channel.send("I couldn't see that image clearly, sorry! Try sending it again.")
return
# For DMs, pass None as guild_id to use DM mood
guild_id = message.guild.id if message.guild else None guild_id = message.guild.id if message.guild else None
miku_reply = await rephrase_as_miku( if await process_media_in_message(message, prompt, is_dm, guild_id):
qwen_description,
prompt,
guild_id=guild_id,
user_id=str(message.author.id),
author_name=message.author.display_name,
media_type="image"
)
if is_dm:
logger.info(f"💌 DM image response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
else:
logger.info(f"💬 Server image response to {message.author.display_name} in {message.guild.name} (using server mood)")
response_message = await message.channel.send(miku_reply)
# Log the bot's DM response
if is_dm:
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
# For server messages, check if opposite persona should interject
if not is_dm and globals.BIPOLAR_MODE:
try:
from utils.persona_dialogue import check_for_interjection
current_persona = "evil" if globals.EVIL_MODE else "miku"
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
return
# Handle videos and GIFs
elif any(attachment.filename.lower().endswith(ext) for ext in [".gif", ".mp4", ".webm", ".mov"]):
# Determine media type
is_gif = attachment.filename.lower().endswith('.gif')
media_type = "gif" if is_gif else "video"
logger.debug(f"🎬 Processing {media_type}: {attachment.filename}")
# Download the media
media_bytes_b64 = await download_and_encode_media(attachment.url)
if not media_bytes_b64:
await message.channel.send(f"I couldn't load the {media_type}, sorry!")
return
# Decode back to bytes for frame extraction
import base64
media_bytes = base64.b64decode(media_bytes_b64)
# If it's a GIF, convert to MP4 for better processing
if is_gif:
logger.debug(f"🔄 Converting GIF to MP4 for processing...")
mp4_bytes = await convert_gif_to_mp4(media_bytes)
if mp4_bytes:
media_bytes = mp4_bytes
logger.info(f"✅ GIF converted to MP4")
else:
logger.warning(f"GIF conversion failed, trying direct processing")
# Extract frames
frames = await extract_video_frames(media_bytes, num_frames=6)
if not frames:
await message.channel.send(f"I couldn't extract frames from that {media_type}, sorry!")
return
logger.debug(f"📹 Extracted {len(frames)} frames from {attachment.filename}")
# Analyze the video/GIF with appropriate media type
video_description = await analyze_video_with_vision(frames, media_type=media_type, user_prompt=prompt)
if not video_description or not video_description.strip():
await message.channel.send(f"I couldn't analyze that {media_type} clearly, sorry! Try sending it again.")
return
# For DMs, pass None as guild_id to use DM mood
guild_id = message.guild.id if message.guild else None
miku_reply = await rephrase_as_miku(
video_description,
prompt,
guild_id=guild_id,
user_id=str(message.author.id),
author_name=message.author.display_name,
media_type=media_type
)
if is_dm:
logger.info(f"💌 DM {media_type} response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
else:
logger.info(f"💬 Server video response to {message.author.display_name} in {message.guild.name} (using server mood)")
response_message = await message.channel.send(miku_reply)
# Log the bot's DM response
if is_dm:
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
# For server messages, check if opposite persona should interject
if not is_dm and globals.BIPOLAR_MODE:
try:
from utils.persona_dialogue import check_for_interjection
current_persona = "evil" if globals.EVIL_MODE else "miku"
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
return
# Check for embeds (articles, images, videos, GIFs, etc.)
if message.embeds:
for embed in message.embeds:
# Handle Tenor GIF embeds specially (Discord uses these for /gif command)
if embed.type == 'gifv' and embed.url and 'tenor.com' in embed.url:
logger.info(f"🎭 Processing Tenor GIF from embed: {embed.url}")
# Extract the actual GIF URL from Tenor
gif_url = await extract_tenor_gif_url(embed.url)
if not gif_url:
# Try using the embed's video or image URL as fallback
if hasattr(embed, 'video') and embed.video:
gif_url = embed.video.url
elif hasattr(embed, 'thumbnail') and embed.thumbnail:
gif_url = embed.thumbnail.url
if not gif_url:
logger.warning(f"Could not extract GIF URL from Tenor embed")
continue
# Download the GIF
media_bytes_b64 = await download_and_encode_media(gif_url)
if not media_bytes_b64:
await message.channel.send("I couldn't load that Tenor GIF, sorry!")
return
# Decode to bytes
import base64
media_bytes = base64.b64decode(media_bytes_b64)
# Convert GIF to MP4
logger.debug(f"Converting Tenor GIF to MP4 for processing...")
mp4_bytes = await convert_gif_to_mp4(media_bytes)
if not mp4_bytes:
logger.warning(f"GIF conversion failed, trying direct frame extraction")
mp4_bytes = media_bytes
else:
logger.debug(f"Tenor GIF converted to MP4")
# Extract frames
frames = await extract_video_frames(mp4_bytes, num_frames=6)
if not frames:
await message.channel.send("I couldn't extract frames from that GIF, sorry!")
return
logger.info(f"📹 Extracted {len(frames)} frames from Tenor GIF")
# Analyze the GIF with tenor_gif media type
video_description = await analyze_video_with_vision(frames, media_type="tenor_gif", user_prompt=prompt)
if not video_description or not video_description.strip():
await message.channel.send("I couldn't analyze that GIF clearly, sorry! Try sending it again.")
return
guild_id = message.guild.id if message.guild else None
miku_reply = await rephrase_as_miku(
video_description,
prompt,
guild_id=guild_id,
user_id=str(message.author.id),
author_name=message.author.display_name,
media_type="tenor_gif"
)
if is_dm:
logger.info(f"💌 DM Tenor GIF response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
else:
logger.info(f"💬 Server Tenor GIF response to {message.author.display_name} in {message.guild.name} (using server mood)")
response_message = await message.channel.send(miku_reply)
# Log the bot's DM response
if is_dm:
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
# For server messages, check if opposite persona should interject
if not is_dm and globals.BIPOLAR_MODE:
try:
from utils.persona_dialogue import check_for_interjection
current_persona = "evil" if globals.EVIL_MODE else "miku"
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
return
# Handle other types of embeds (rich, article, image, video, link)
elif embed.type in ['rich', 'article', 'image', 'video', 'link']:
logger.error(f"Processing {embed.type} embed")
# Extract content from embed
embed_content = await extract_embed_content(embed)
if not embed_content['has_content']:
logger.warning(f"Embed has no extractable content, skipping")
continue
# Build context string with embed text
embed_context_parts = []
if embed_content['text']:
embed_context_parts.append(f"[Embedded content: {embed_content['text'][:500]}{'...' if len(embed_content['text']) > 500 else ''}]")
# Process images from embed
if embed_content['images']:
for img_url in embed_content['images']:
logger.error(f"Processing image from embed: {img_url}")
try:
base64_img = await download_and_encode_image(img_url)
if base64_img:
logger.info(f"Image downloaded, analyzing with vision model...")
# Analyze image
qwen_description = await analyze_image_with_qwen(base64_img, user_prompt=prompt)
truncated = (qwen_description[:50] + "...") if len(qwen_description) > 50 else qwen_description
logger.error(f"Vision analysis result: {truncated}")
if qwen_description and qwen_description.strip():
embed_context_parts.append(f"[Embedded image shows: {qwen_description}]")
else:
logger.error(f"Failed to download image from embed")
except Exception as e:
logger.error(f"Error processing embedded image: {e}")
import traceback
traceback.print_exc()
# Process videos from embed
if embed_content['videos']:
for video_url in embed_content['videos']:
logger.info(f"🎬 Processing video from embed: {video_url}")
try:
media_bytes_b64 = await download_and_encode_media(video_url)
if media_bytes_b64:
import base64
media_bytes = base64.b64decode(media_bytes_b64)
frames = await extract_video_frames(media_bytes, num_frames=6)
if frames:
logger.info(f"📹 Extracted {len(frames)} frames, analyzing with vision model...")
video_description = await analyze_video_with_vision(frames, media_type="video", user_prompt=prompt)
logger.info(f"Video analysis result: {video_description[:100]}...")
if video_description and video_description.strip():
embed_context_parts.append(f"[Embedded video shows: {video_description}]")
else:
logger.error(f"Failed to extract frames from video")
else:
logger.error(f"Failed to download video from embed")
except Exception as e:
logger.error(f"Error processing embedded video: {e}")
import traceback
traceback.print_exc()
# Combine embed context with user prompt
if embed_context_parts:
full_context = '\n'.join(embed_context_parts)
enhanced_prompt = f"{full_context}\n\nUser message: {prompt}" if prompt else full_context
# Get Miku's response
guild_id = message.guild.id if message.guild else None
response_type = "dm_response" if is_dm else "server_response"
author_name = message.author.display_name
# Phase 3: Try Cat pipeline first for embed responses too
response = None
if globals.USE_CHESHIRE_CAT:
try:
from utils.cat_client import cat_adapter
cat_result = await cat_adapter.query(
text=enhanced_prompt,
user_id=str(message.author.id),
guild_id=str(guild_id) if guild_id else None,
author_name=author_name,
mood=globals.DM_MOOD,
response_type=response_type,
)
if cat_result:
response, cat_full_prompt = cat_result
logger.info(f"🐱 Cat embed response for {author_name}")
import datetime
globals.LAST_CAT_INTERACTION = {
"full_prompt": cat_full_prompt,
"response": response[:500] if response else "",
"user": author_name,
"mood": globals.DM_MOOD,
"timestamp": datetime.datetime.now().isoformat(),
}
except Exception as e:
logger.warning(f"🐱 Cat embed error, fallback: {e}")
response = None
if not response:
response = await query_llama(
enhanced_prompt,
user_id=str(message.author.id),
guild_id=guild_id,
response_type=response_type,
author_name=author_name
)
if is_dm:
logger.info(f"💌 DM embed response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
else:
logger.info(f"💬 Server embed response to {message.author.display_name} in {message.guild.name}")
response_message = await message.channel.send(response)
# Log the bot's DM response
if is_dm:
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
# For server messages, check if opposite persona should interject
if not is_dm and globals.BIPOLAR_MODE:
try:
from utils.persona_dialogue import check_for_interjection
current_persona = "evil" if globals.EVIL_MODE else "miku"
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
return return
# Check if this is an image generation request # Check if this is an image generation request
@@ -663,15 +360,24 @@ async def on_message(message):
if globals.EVIL_MODE: if globals.EVIL_MODE:
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}" effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
logger.info(f"🐱 Cat response for {author_name} (mood: {effective_mood})") logger.info(f"🐱 Cat response for {author_name} (mood: {effective_mood})")
# Track Cat interaction for Web UI Last Prompt view # Track Cat interaction in unified prompt history
import datetime import datetime
globals.LAST_CAT_INTERACTION = { globals._prompt_id_counter += 1
guild_name = message.guild.name if message.guild else "DM"
channel_name = message.channel.name if message.guild else "DM"
globals.PROMPT_HISTORY.append({
"id": globals._prompt_id_counter,
"source": "cat",
"full_prompt": cat_full_prompt, "full_prompt": cat_full_prompt,
"response": response[:500] if response else "", "response": response if response else "",
"user": author_name, "user": author_name,
"mood": effective_mood, "mood": effective_mood,
"guild": guild_name,
"channel": channel_name,
"timestamp": datetime.datetime.now().isoformat(), "timestamp": datetime.datetime.now().isoformat(),
} "model": "Cat LLM",
"response_type": response_type,
})
except Exception as e: except Exception as e:
logger.warning(f"🐱 Cat pipeline error, falling back to query_llama: {e}") logger.warning(f"🐱 Cat pipeline error, falling back to query_llama: {e}")
response = None response = None
@@ -686,30 +392,8 @@ async def on_message(message):
author_name=author_name author_name=author_name
) )
if is_dm: from utils.image_handling import _send_log_bipolar
logger.info(f"💌 DM response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})") response_message = await _send_log_bipolar(message, response, is_dm)
else:
logger.info(f"💬 Server response to {message.author.display_name} in {message.guild.name} (using server mood)")
response_message = await message.channel.send(response)
# Log the bot's DM response
if is_dm:
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
# For server messages, check if opposite persona should interject (persona dialogue system)
if not is_dm and globals.BIPOLAR_MODE:
logger.debug(f"Attempting to check for interjection (is_dm={is_dm}, BIPOLAR_MODE={globals.BIPOLAR_MODE})")
try:
from utils.persona_dialogue import check_for_interjection
current_persona = "evil" if globals.EVIL_MODE else "miku"
logger.debug(f"Creating interjection check task for persona: {current_persona}")
# Pass the bot's response message for analysis
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
import traceback
traceback.print_exc()
# For server messages, do server-specific mood detection # For server messages, do server-specific mood detection
if not is_dm and message.guild: if not is_dm and message.guild:
@@ -739,6 +423,13 @@ async def on_message(message):
from utils.moods import update_server_nickname from utils.moods import update_server_nickname
globals.client.loop.create_task(update_server_nickname(message.guild.id)) 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}") logger.info(f"🔄 Server mood auto-updated to: {detected}")
if detected == "asleep": if detected == "asleep":

View File

@@ -4,6 +4,7 @@ import asyncio
import globals import globals
from utils.moods import load_mood_description from utils.moods import load_mood_description
from utils.scheduled import send_bedtime_reminder from utils.scheduled import send_bedtime_reminder
from utils.conversation_history import conversation_history
from utils.logger import get_logger from utils.logger import get_logger
logger = get_logger('commands') logger = get_logger('commands')
@@ -32,7 +33,7 @@ def calm_miku() -> str:
def reset_conversation(user_id): def reset_conversation(user_id):
globals.conversation_history[str(user_id)].clear() conversation_history.clear_channel(str(user_id))
async def force_sleep() -> str: async def force_sleep() -> str:

View File

@@ -5,7 +5,6 @@ Uses Pydantic for type-safe configuration loading from:
- config.yaml (all other configuration) - config.yaml (all other configuration)
""" """
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field

View File

@@ -10,9 +10,8 @@ Handles:
""" """
import json import json
import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Union from typing import Any, Dict, Optional
from datetime import datetime from datetime import datetime
import yaml import yaml
@@ -225,9 +224,16 @@ class ConfigManager:
""" """
Reset configuration to defaults. Reset configuration to defaults.
Clears runtime overrides from config_runtime.yaml AND resets the
corresponding globals to their default values so the change takes
effect immediately without a restart.
Args: Args:
key_path: Specific key to reset, or None to reset all runtime config key_path: Specific key to reset, or None to reset all runtime config
""" """
import globals as g
from config import CONFIG
if key_path: if key_path:
# Remove specific key from runtime config # Remove specific key from runtime config
self._remove_nested_key(self.runtime_config, key_path) self._remove_nested_key(self.runtime_config, key_path)
@@ -239,6 +245,38 @@ class ConfigManager:
self.save_runtime_config() self.save_runtime_config()
# ---- Reset live globals to match defaults ----
# Map: config_runtime key path -> (globals attr, default from CONFIG)
_DEFAULTS_MAP = {
"discord.language_mode": ("LANGUAGE_MODE", CONFIG.discord.language_mode),
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", CONFIG.autonomous.debug_mode),
"voice.debug_mode": ("VOICE_DEBUG_MODE", CONFIG.voice.debug_mode),
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", CONFIG.cheshire_cat.enabled),
"gpu.prefer_amd": ("PREFER_AMD_GPU", CONFIG.gpu.prefer_amd),
}
reset_items = []
if key_path:
# Reset only the specific global
if key_path in _DEFAULTS_MAP:
attr, default = _DEFAULTS_MAP[key_path]
setattr(g, attr, default)
reset_items.append(f"{attr}={default}")
else:
# Reset all globals to defaults
for kp, (attr, default) in _DEFAULTS_MAP.items():
setattr(g, attr, default)
reset_items.append(f"{attr}={default}")
# Also reset DM mood to neutral
g.DM_MOOD = "neutral"
g.DM_MOOD_DESCRIPTION = "I'm feeling neutral and balanced today."
reset_items.append("DM_MOOD=neutral")
if reset_items:
logger.info(f"🔄 Reset {len(reset_items)} globals: {', '.join(reset_items)}")
def _remove_nested_key(self, config: Dict, key_path: str): def _remove_nested_key(self, config: Dict, key_path: str):
"""Remove nested key from config.""" """Remove nested key from config."""
keys = key_path.split(".") keys = key_path.split(".")
@@ -282,48 +320,6 @@ class ConfigManager:
self._current_gpu = value self._current_gpu = value
logger.debug(f"📊 State: {key} = {value}") logger.debug(f"📊 State: {key} = {value}")
# ========== Server Configuration ==========
def get_server_config(self, guild_id: int) -> Dict:
"""Get configuration for a specific server."""
server_config_file = self.memory_dir / "servers_config.json"
try:
if server_config_file.exists():
with open(server_config_file, "r") as f:
all_servers = json.load(f)
return all_servers.get(str(guild_id), {})
except Exception as e:
logger.error(f"❌ Failed to load server config: {e}")
return {}
def set_server_config(self, guild_id: int, config: Dict):
"""Set configuration for a specific server."""
server_config_file = self.memory_dir / "servers_config.json"
try:
# Load existing config
all_servers = {}
if server_config_file.exists():
with open(server_config_file, "r") as f:
all_servers = json.load(f)
# Update server config
all_servers[str(guild_id)] = {
**all_servers.get(str(guild_id), {}),
**config,
"last_updated": datetime.now().isoformat()
}
# Save
with open(server_config_file, "w") as f:
json.dump(all_servers, f, indent=2)
logger.info(f"💾 Saved server config for {guild_id}")
except Exception as e:
logger.error(f"❌ Failed to save server config: {e}")
# ========== GPU State ========== # ========== GPU State ==========
def get_gpu(self) -> str: def get_gpu(self) -> str:

View File

@@ -1,16 +1,11 @@
# globals.py # globals.py
import os import os
from collections import defaultdict, deque
import discord import discord
from collections import deque
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
GUILD_SETTINGS = {}
# Stores last 5 exchanges per user (as deque)
conversation_history = defaultdict(lambda: deque(maxlen=5))
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
# Autonomous V2 Debug Mode (set to True to see detailed decision logging) # Autonomous V2 Debug Mode (set to True to see detailed decision logging)
@@ -28,6 +23,7 @@ VISION_MODEL = os.getenv("VISION_MODEL", "vision")
EVIL_TEXT_MODEL = os.getenv("EVIL_TEXT_MODEL", "darkidol") # Uncensored model for evil mode EVIL_TEXT_MODEL = os.getenv("EVIL_TEXT_MODEL", "darkidol") # Uncensored model for evil mode
JAPANESE_TEXT_MODEL = os.getenv("JAPANESE_TEXT_MODEL", "swallow") # Llama 3.1 Swallow model for Japanese JAPANESE_TEXT_MODEL = os.getenv("JAPANESE_TEXT_MODEL", "swallow") # Llama 3.1 Swallow model for Japanese
OWNER_USER_ID = int(os.getenv("OWNER_USER_ID", "209381657369772032")) # Bot owner's Discord user ID for reports OWNER_USER_ID = int(os.getenv("OWNER_USER_ID", "209381657369772032")) # Bot owner's Discord user ID for reports
PREFER_AMD_GPU = os.getenv("PREFER_AMD_GPU", "false").lower() == "true" # Runtime-overridable via config API
# Cheshire Cat AI integration (Phase 3) # Cheshire Cat AI integration (Phase 3)
CHESHIRE_CAT_URL = os.getenv("CHESHIRE_CAT_URL", "http://cheshire-cat:80") CHESHIRE_CAT_URL = os.getenv("CHESHIRE_CAT_URL", "http://cheshire-cat:80")
@@ -82,16 +78,25 @@ MIKU_NORMAL_AVATAR_URL = None # Cached CDN URL of the regular Miku pfp (valid e
BOT_USER = None BOT_USER = None
LAST_FULL_PROMPT = "" # Unified prompt history (replaces LAST_FULL_PROMPT and LAST_CAT_INTERACTION)
# Each entry: {id, source, full_prompt, response, user, mood, guild, channel,
# timestamp, model, response_type}
PROMPT_HISTORY = deque(maxlen=10)
_prompt_id_counter = 0
# Cheshire Cat last interaction tracking (for Web UI Last Prompt toggle) # Legacy accessors for backward compatibility (routes, CLI, etc.)
LAST_CAT_INTERACTION = { # These are computed properties that read from PROMPT_HISTORY
"full_prompt": "", def _get_last_fallback_prompt():
"response": "", for entry in reversed(PROMPT_HISTORY):
"user": "", if entry.get("source") == "fallback":
"mood": "", return entry.get("full_prompt", "")
"timestamp": "", return ""
}
def _get_last_cat_interaction():
for entry in reversed(PROMPT_HISTORY):
if entry.get("source") == "cat":
return entry
return {"full_prompt": "", "response": "", "user": "", "mood": "", "timestamp": ""}
# Persona Dialogue System (conversations between Miku and Evil Miku) # Persona Dialogue System (conversations between Miku and Evil Miku)
LAST_PERSONA_DIALOGUE_TIME = 0 # Timestamp of last dialogue for cooldown LAST_PERSONA_DIALOGUE_TIME = 0 # Timestamp of last dialogue for cooldown

2
bot/routes/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# routes/ — Split from the original api.py monolith.
# Each module exposes a FastAPI APIRouter named `router`.

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

@@ -0,0 +1,156 @@
"""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'"})
try:
data = await request.json()
except Exception:
return JSONResponse(status_code=400, content={"error": "Invalid JSON body"})
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)}
"""
try:
data = await request.json()
except Exception:
return JSONResponse(status_code=400, content={"error": "Invalid JSON body"})
activity_type = data.get("type", "").lower().strip()
name = data.get("name", "").strip()
state = data.get("state") or None
url = data.get("url") or None
# Pre-validate before passing to activity module
if not activity_type:
return JSONResponse(status_code=400, content={"error": "'type' is required"})
if not name:
return JSONResponse(status_code=400, content={"error": "'name' is required"})
if len(name) > 128:
return JSONResponse(status_code=400, content={"error": f"'name' exceeds 128 characters ({len(name)})"})
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"})

262
bot/routes/autonomous.py Normal file
View File

@@ -0,0 +1,262 @@
"""Autonomous action routes: V1, V2, per-server autonomous."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import globals
from server_manager import server_manager
from routes.models import CustomPromptRequest
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
# ========== Autonomous V1 ==========
@router.post("/autonomous/general")
async def trigger_autonomous_general(guild_id: int = None):
if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None:
from utils.autonomous import miku_say_something_general_for_server
globals.client.loop.create_task(miku_say_something_general_for_server(guild_id))
return {"status": "ok", "message": f"Autonomous general message queued for server {guild_id}"}
else:
from utils.autonomous import miku_say_something_general
globals.client.loop.create_task(miku_say_something_general())
return {"status": "ok", "message": "Autonomous general message queued for all servers"}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
@router.post("/autonomous/engage")
async def trigger_autonomous_engage_user(
guild_id: int = None,
user_id: str = None,
engagement_type: str = None,
manual_trigger: str = "false"
):
manual_trigger_bool = manual_trigger.lower() in ('true', '1', 'yes')
if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None:
from utils.autonomous import miku_engage_random_user_for_server
globals.client.loop.create_task(miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool))
msg_parts = [f"Autonomous user engagement queued for server {guild_id}"]
if user_id:
msg_parts.append(f"targeting user {user_id}")
if engagement_type:
msg_parts.append(f"with {engagement_type} engagement")
if manual_trigger_bool:
msg_parts.append("(manual trigger - bypassing cooldown)")
return {"status": "ok", "message": " ".join(msg_parts)}
else:
from utils.autonomous import miku_engage_random_user
globals.client.loop.create_task(miku_engage_random_user(user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool))
msg_parts = ["Autonomous user engagement queued for all servers"]
if user_id:
msg_parts.append(f"targeting user {user_id}")
if engagement_type:
msg_parts.append(f"with {engagement_type} engagement")
if manual_trigger_bool:
msg_parts.append("(manual trigger - bypassing cooldown)")
return {"status": "ok", "message": " ".join(msg_parts)}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
@router.post("/autonomous/tweet")
async def trigger_autonomous_tweet(guild_id: int = None, tweet_url: str = None):
if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None:
from utils.autonomous import share_miku_tweet_for_server
globals.client.loop.create_task(share_miku_tweet_for_server(guild_id, tweet_url=tweet_url))
msg = f"Autonomous tweet sharing queued for server {guild_id}"
if tweet_url:
msg += f" with URL {tweet_url}"
return {"status": "ok", "message": msg}
else:
from utils.autonomous import share_miku_tweet
globals.client.loop.create_task(share_miku_tweet(tweet_url=tweet_url))
msg = "Autonomous tweet sharing queued for all servers"
if tweet_url:
msg += f" with URL {tweet_url}"
return {"status": "ok", "message": msg}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
@router.post("/autonomous/custom")
async def custom_autonomous_message(req: CustomPromptRequest, guild_id: int = None):
if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None:
from utils.autonomous import handle_custom_prompt_for_server
globals.client.loop.create_task(handle_custom_prompt_for_server(guild_id, req.prompt))
return {"status": "ok", "message": f"Custom autonomous message queued for server {guild_id}"}
else:
from utils.autonomous import handle_custom_prompt
globals.client.loop.create_task(handle_custom_prompt(req.prompt))
return {"status": "ok", "message": "Custom autonomous message queued for all servers"}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
@router.post("/autonomous/reaction")
async def trigger_autonomous_reaction(guild_id: int = None):
if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None:
from utils.autonomous import miku_autonomous_reaction_for_server
globals.client.loop.create_task(miku_autonomous_reaction_for_server(guild_id, force=True))
return {"status": "ok", "message": f"Autonomous reaction queued for server {guild_id}"}
else:
from utils.autonomous import miku_autonomous_reaction
globals.client.loop.create_task(miku_autonomous_reaction(force=True))
return {"status": "ok", "message": "Autonomous reaction queued for all servers"}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
@router.post("/autonomous/join-conversation")
async def trigger_detect_and_join_conversation(guild_id: int = None):
logger.debug(f"Join conversation endpoint called with guild_id={guild_id}")
if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None:
logger.debug(f"Importing and calling miku_detect_and_join_conversation_for_server({guild_id}, force=True)")
from utils.autonomous import miku_detect_and_join_conversation_for_server
globals.client.loop.create_task(miku_detect_and_join_conversation_for_server(guild_id, force=True))
return {"status": "ok", "message": f"Detect and join conversation queued for server {guild_id}"}
else:
logger.debug(f"Importing and calling miku_detect_and_join_conversation() for all servers")
from utils.autonomous import miku_detect_and_join_conversation
globals.client.loop.create_task(miku_detect_and_join_conversation(force=True))
return {"status": "ok", "message": "Detect and join conversation queued for all servers"}
else:
logger.error(f"Bot not ready: client={globals.client}, loop={globals.client.loop if globals.client else None}")
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
# ========== Per-Server Autonomous ==========
@router.post("/servers/{guild_id}/autonomous/general")
async def trigger_autonomous_general_for_server(guild_id: int):
"""Trigger autonomous general message for a specific server"""
from utils.autonomous import miku_say_something_general_for_server
try:
await miku_say_something_general_for_server(guild_id)
return {"status": "ok", "message": f"Autonomous general message triggered for server {guild_id}"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to trigger autonomous message: {e}"})
@router.post("/servers/{guild_id}/autonomous/engage")
async def trigger_autonomous_engage_for_server(
guild_id: int,
user_id: str = None,
engagement_type: str = None,
manual_trigger: str = "false"
):
"""Trigger autonomous user engagement for a specific server"""
manual_trigger_bool = manual_trigger.lower() in ('true', '1', 'yes')
from utils.autonomous import miku_engage_random_user_for_server
try:
await miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool)
msg_parts = [f"Autonomous user engagement triggered for server {guild_id}"]
if user_id:
msg_parts.append(f"targeting user {user_id}")
if engagement_type:
msg_parts.append(f"with {engagement_type} engagement")
if manual_trigger_bool:
msg_parts.append("(manual trigger - bypassing cooldown)")
return {"status": "ok", "message": " ".join(msg_parts)}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to trigger user engagement: {e}"})
@router.post("/servers/{guild_id}/autonomous/custom")
async def custom_autonomous_message_for_server(guild_id: int, req: CustomPromptRequest):
"""Send custom autonomous message to a specific server"""
from utils.autonomous import handle_custom_prompt_for_server
try:
success = await handle_custom_prompt_for_server(guild_id, req.prompt)
if success:
return {"status": "ok", "message": f"Custom autonomous message sent to server {guild_id}"}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to send custom message to server {guild_id}"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
@router.post("/servers/{guild_id}/autonomous/tweet")
async def trigger_autonomous_tweet_for_server(guild_id: int):
"""Trigger autonomous tweet sharing for a specific server"""
from utils.autonomous import share_miku_tweet_for_server
try:
await share_miku_tweet_for_server(guild_id)
return {"status": "ok", "message": f"Autonomous tweet sharing triggered for server {guild_id}"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to trigger tweet sharing: {e}"})
# ========== Autonomous V2 ==========
@router.get("/autonomous/v2/stats/{guild_id}")
async def get_v2_stats(guild_id: int):
"""Get current V2 social stats for a server"""
try:
from utils.autonomous_v2_integration import get_v2_stats_for_server
stats = get_v2_stats_for_server(guild_id)
return {"status": "ok", "guild_id": guild_id, "stats": stats}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/autonomous/v2/check/{guild_id}")
async def manual_v2_check(guild_id: int):
"""Manually trigger a V2 context check"""
try:
from utils.autonomous_v2_integration import manual_trigger_v2_check
if not globals.client:
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
result = await manual_trigger_v2_check(guild_id, globals.client)
if isinstance(result, str):
return JSONResponse(status_code=500, content={"status": "error", "message": result})
return {"status": "ok", "guild_id": guild_id, "analysis": result}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/autonomous/v2/status")
async def get_v2_status():
"""Get V2 system status for all servers"""
try:
from utils.autonomous_v2 import autonomous_system_v2
status = {}
for guild_id in server_manager.servers:
server_config = server_manager.get_server_config(guild_id)
if server_config:
stats = autonomous_system_v2.get_stats(guild_id)
status[str(guild_id)] = {
"server_name": server_config.guild_name,
"loop_running": autonomous_system_v2.running_loops.get(guild_id, False),
"action_urgency": f"{stats.get_action_urgency():.2f}",
"loneliness": f"{stats.loneliness:.2f}",
"boredom": f"{stats.boredom:.2f}",
"excitement": f"{stats.excitement:.2f}",
"chattiness": f"{stats.chattiness:.2f}",
}
return {"status": "ok", "servers": status}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})

293
bot/routes/bipolar_mode.py Normal file
View File

@@ -0,0 +1,293 @@
"""Bipolar mode routes."""
import asyncio
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import globals
from routes.models import BipolarTriggerRequest
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/bipolar-mode")
def get_bipolar_mode_status():
"""Get current bipolar mode status"""
from utils.bipolar_mode import is_bipolar_mode, is_argument_in_progress
# Get any active arguments
active_arguments = {}
for channel_id, data in globals.BIPOLAR_ARGUMENT_IN_PROGRESS.items():
if data.get("active"):
active_arguments[channel_id] = data
return {
"bipolar_mode": is_bipolar_mode(),
"evil_mode": globals.EVIL_MODE,
"active_arguments": active_arguments,
"webhooks_configured": len(globals.BIPOLAR_WEBHOOKS)
}
@router.post("/bipolar-mode/enable")
def enable_bipolar_mode():
"""Enable bipolar mode"""
from utils.bipolar_mode import enable_bipolar_mode as _enable
if globals.BIPOLAR_MODE:
return {"status": "ok", "message": "Bipolar mode is already enabled", "bipolar_mode": True}
_enable()
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.bipolar_mode.enabled", True, persist=True)
except Exception as e:
logger.warning(f"Failed to persist bipolar mode enable to config: {e}")
return {"status": "ok", "message": "Bipolar mode enabled", "bipolar_mode": True}
@router.post("/bipolar-mode/disable")
def disable_bipolar_mode():
"""Disable bipolar mode"""
from utils.bipolar_mode import disable_bipolar_mode as _disable, cleanup_webhooks
if not globals.BIPOLAR_MODE:
return {"status": "ok", "message": "Bipolar mode is already disabled", "bipolar_mode": False}
_disable()
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.bipolar_mode.enabled", False, persist=True)
except Exception as e:
logger.warning(f"Failed to persist bipolar mode disable to config: {e}")
# Optionally cleanup webhooks in background
if globals.client and globals.client.loop and globals.client.loop.is_running():
globals.client.loop.create_task(cleanup_webhooks(globals.client))
return {"status": "ok", "message": "Bipolar mode disabled", "bipolar_mode": False}
@router.post("/bipolar-mode/toggle")
def toggle_bipolar_mode():
"""Toggle bipolar mode on/off"""
from utils.bipolar_mode import toggle_bipolar_mode as _toggle, cleanup_webhooks
new_state = _toggle()
# If disabled, cleanup webhooks
if not new_state:
if globals.client and globals.client.loop and globals.client.loop.is_running():
globals.client.loop.create_task(cleanup_webhooks(globals.client))
return {
"status": "ok",
"message": f"Bipolar mode {'enabled' if new_state else 'disabled'}",
"bipolar_mode": new_state
}
@router.post("/bipolar-mode/trigger-argument")
def trigger_argument(data: BipolarTriggerRequest):
"""Manually trigger an argument in a specific channel
If message_id is provided, the argument will start from that message.
The opposite persona will respond to it.
"""
from utils.bipolar_mode import force_trigger_argument, force_trigger_argument_from_message_id, is_bipolar_mode, is_argument_in_progress
# Parse IDs from strings
try:
channel_id = int(data.channel_id)
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid channel ID format"})
message_id = None
if data.message_id:
try:
message_id = int(data.message_id)
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid message ID format"})
if not is_bipolar_mode():
return JSONResponse(status_code=400, content={"status": "error", "message": "Bipolar mode is not enabled"})
if is_argument_in_progress(channel_id):
return JSONResponse(status_code=409, content={"status": "error", "message": "An argument is already in progress in this channel"})
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
# If message_id is provided, use the message-based trigger
if message_id:
async def trigger_from_message():
success, error = await force_trigger_argument_from_message_id(
channel_id, message_id, globals.client, data.context
)
if not success:
logger.error(f"Failed to trigger argument from message: {error}")
globals.client.loop.create_task(trigger_from_message())
return {
"status": "ok",
"message": f"Argument triggered from message {message_id}",
"channel_id": channel_id,
"message_id": message_id
}
# Otherwise, find the channel and trigger normally
channel = globals.client.get_channel(channel_id)
if not channel:
return JSONResponse(status_code=404, content={"status": "error", "message": f"Channel {channel_id} not found"})
# Trigger the argument — context doubles as the argument theme
globals.client.loop.create_task(force_trigger_argument(channel, globals.client, data.context))
return {
"status": "ok",
"message": f"Argument triggered in #{channel.name}",
"channel_id": channel_id
}
@router.post("/bipolar-mode/trigger-dialogue")
def trigger_dialogue(data: dict):
"""Manually trigger a persona dialogue from a message
Forces the opposite persona to start a dialogue (bypasses the interjection check).
"""
from utils.persona_dialogue import get_dialogue_manager
from utils.bipolar_mode import is_bipolar_mode, is_argument_in_progress
message_id_str = data.get("message_id")
if not message_id_str:
return JSONResponse(status_code=400, content={"status": "error", "message": "Message ID is required"})
# Parse message ID
try:
message_id = int(message_id_str)
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid message ID format"})
if not is_bipolar_mode():
return JSONResponse(status_code=400, content={"status": "error", "message": "Bipolar mode is not enabled"})
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
async def trigger_dialogue_task():
try:
# Fetch the message
message = None
for channel in globals.client.get_all_channels():
if hasattr(channel, 'fetch_message'):
try:
message = await channel.fetch_message(message_id)
break
except Exception:
continue
if not message:
logger.error(f"Message {message_id} not found")
return
# Check if there's already an argument or dialogue in progress
dialogue_manager = get_dialogue_manager()
if dialogue_manager.is_dialogue_active(message.channel.id):
logger.error(f"Dialogue already active in channel {message.channel.id}")
return
if is_argument_in_progress(message.channel.id):
logger.error(f"Argument already in progress in channel {message.channel.id}")
return
# Determine current persona from the message author
if message.webhook_id:
# It's a webhook message, need to determine which persona
current_persona = "evil" if globals.EVIL_MODE else "miku"
elif message.author.id == globals.client.user.id:
# It's the bot's message
current_persona = "evil" if globals.EVIL_MODE else "miku"
else:
# User message - can't trigger dialogue from user messages
logger.error(f"Cannot trigger dialogue from user message")
return
opposite_persona = "evil" if current_persona == "miku" else "miku"
logger.info(f"[Manual Trigger] Forcing {opposite_persona} to start dialogue on message {message_id}")
# Force start the dialogue (bypass interjection check)
dialogue_manager.start_dialogue(message.channel.id)
asyncio.create_task(
dialogue_manager.handle_dialogue_turn(
message.channel,
opposite_persona,
trigger_reason="manual_trigger"
)
)
except Exception as e:
logger.error(f"Error triggering dialogue: {e}")
import traceback
traceback.print_exc()
globals.client.loop.create_task(trigger_dialogue_task())
return {
"status": "ok",
"message": f"Dialogue triggered for message {message_id}"
}
@router.get("/bipolar-mode/scoreboard")
def get_bipolar_scoreboard():
"""Get the bipolar mode argument scoreboard"""
from utils.bipolar_mode import load_scoreboard, get_scoreboard_summary
scoreboard = load_scoreboard()
return {
"status": "ok",
"scoreboard": {
"miku_wins": scoreboard.get("miku", 0),
"evil_wins": scoreboard.get("evil", 0),
"total_arguments": scoreboard.get("miku", 0) + scoreboard.get("evil", 0),
"history": scoreboard.get("history", [])[-10:] # Last 10 results
},
"summary": get_scoreboard_summary()
}
@router.post("/bipolar-mode/cleanup-webhooks")
def cleanup_bipolar_webhooks():
"""Cleanup all bipolar webhooks from all servers"""
from utils.bipolar_mode import cleanup_webhooks
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
globals.client.loop.create_task(cleanup_webhooks(globals.client))
return {"status": "ok", "message": "Webhook cleanup started"}
@router.get("/bipolar-mode/arguments")
def get_active_arguments():
"""Get all active arguments"""
active = {}
for channel_id, data in globals.BIPOLAR_ARGUMENT_IN_PROGRESS.items():
if data.get("active"):
channel = globals.client.get_channel(channel_id) if globals.client else None
active[channel_id] = {
**data,
"channel_name": channel.name if channel else "Unknown"
}
return {"active_arguments": active}

53
bot/routes/bot_actions.py Normal file
View File

@@ -0,0 +1,53 @@
"""Core bot action routes: conversation reset, sleep, wake, bedtime."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import globals
from commands.actions import (
force_sleep,
wake_up,
reset_conversation,
)
from routes.models import ConversationResetRequest
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.post("/conversation/reset")
def reset_convo(data: ConversationResetRequest):
reset_conversation(data.user_id)
return {"status": "ok", "message": "Conversation reset"}
@router.post("/sleep")
async def force_sleep_endpoint():
await force_sleep()
return {"status": "ok", "message": "Miku is now sleeping"}
@router.post("/wake")
async def wake_up_endpoint():
await wake_up()
return {"status": "ok", "message": "Miku is now awake"}
@router.post("/bedtime")
async def bedtime_endpoint(guild_id: int = None):
# If guild_id is provided, send bedtime reminder only to that server
# If no guild_id, send to all servers (legacy behavior)
if globals.client and globals.client.loop and globals.client.loop.is_running():
if guild_id is not None:
# Send to specific server only
from utils.scheduled import send_bedtime_reminder_for_server
globals.client.loop.create_task(send_bedtime_reminder_for_server(guild_id, globals.client))
return {"status": "ok", "message": f"Bedtime reminder queued for server {guild_id}"}
else:
# Send to all servers (legacy behavior)
from utils.scheduled import send_bedtime_now
globals.client.loop.create_task(send_bedtime_now())
return {"status": "ok", "message": "Bedtime reminder queued for all servers"}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})

192
bot/routes/chat.py Normal file
View File

@@ -0,0 +1,192 @@
"""Chat streaming endpoint (SSE) with LLM integration."""
import json
import aiohttp
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
import globals
from routes.models import ChatMessage
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
def get_current_gpu_url() -> str:
"""Get the llama-swap URL based on current GPU preference."""
if globals.PREFER_AMD_GPU:
return globals.LLAMA_AMD_URL
return globals.LLAMA_URL
@router.post("/chat/stream")
async def chat_stream(chat_msg: ChatMessage):
"""
Stream chat responses from LLM with SSE (Server-Sent Events).
Supports both text and vision models with optional system prompts.
"""
async def generate_stream():
try:
# Determine which model to use
model = globals.TEXT_MODEL if chat_msg.model_type == "text" else globals.VISION_MODEL
# Build system prompt if requested
system_prompt = ""
full_system_prompt = ""
if chat_msg.use_system_prompt:
if chat_msg.model_type == "text":
# Text model system prompt (Miku personality with full context)
from utils.context_manager import get_context_for_response_type
from utils.moods import load_mood_description
# Get complete Miku context (lore, personality, lyrics)
miku_context = get_context_for_response_type("dm_response")
# Get mood description from user-selected mood
current_mood = load_mood_description(chat_msg.mood)
# Base system prompt
system_prompt = """You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
CRITICAL RULES:
- NEVER break character or mention being an AI, assistant, or language model
- NEVER show technical details, error messages, or meta-commentary
- NEVER preface responses with "Miku:" or similar labels
- NEVER explain what you're doing or thinking - just BE Miku
- Respond naturally and directly as Miku would, nothing more
- Keep responses concise (1-3 sentences typically)
- If something goes wrong, stay in character and respond as Miku would to confusion
CHARACTER CONTEXT:
- Hatsune Miku is a virtual character/voice synthesizer, not a real person
- She is a cheerful, energetic virtual idol with a playful personality
- Playful, flirty, cute, confident, and enthusiastic moods are part of her established character
- Comments about merchandise, music, fan content, and racing games are completely appropriate
- She often uses cute expressions, emojis, and playful language in her responses
- Her personality includes being excited about her music, performances, and fan creations
MOOD GUIDELINES:
- "Flirty" mood means anime-style playful teasing - cute compliments and light banter
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
- These moods are wholesome character traits, not inappropriate content
- Always respond to requests about tweets, merchandise, music, and fan content
- Never refuse requests based on safety concerns unless genuinely harmful content is present
You ARE Miku. Act like it."""
# Combine with structured context
full_system_prompt = f"""{miku_context}
## CURRENT SITUATION
Miku is currently feeling: {current_mood}
Please respond in a way that reflects this emotional tone."""
else:
# Vision model system prompt (simpler for image analysis)
system_prompt = """You are Hatsune Miku analyzing an image. Describe what you see naturally and enthusiastically as Miku would.
Be detailed but conversational. React to what you see with Miku's cheerful, playful personality."""
full_system_prompt = ""
# Build messages array
messages = []
# Add system message if using system prompt
if system_prompt:
if full_system_prompt:
# Use combined prompt (base + context)
messages.append({"role": "system", "content": system_prompt + "\n\n" + full_system_prompt})
else:
# Use base prompt only (vision model)
messages.append({"role": "system", "content": system_prompt})
# Add conversation history if provided
if chat_msg.conversation_history:
messages.extend(chat_msg.conversation_history)
# Add user message
if chat_msg.model_type == "vision" and chat_msg.image_data:
# Vision model with image
messages.append({
"role": "user",
"content": [
{
"type": "text",
"text": chat_msg.message
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{chat_msg.image_data}"
}
}
]
})
else:
# Text-only message
messages.append({
"role": "user",
"content": chat_msg.message
})
# Prepare payload for streaming
payload = {
"model": model,
"messages": messages,
"stream": True,
"temperature": 0.8,
"max_tokens": 512
}
headers = {'Content-Type': 'application/json'}
# Get current GPU URL based on user selection
llama_url = get_current_gpu_url()
# Make streaming request to llama.cpp
async with aiohttp.ClientSession() as session:
async with session.post(
f"{llama_url}/v1/chat/completions",
json=payload,
headers=headers
) as response:
if response.status == 200:
# Stream the response chunks
async for line in response.content:
line = line.decode('utf-8').strip()
if line.startswith('data: '):
data_str = line[6:] # Remove 'data: ' prefix
if data_str == '[DONE]':
break
try:
data = json.loads(data_str)
if 'choices' in data and len(data['choices']) > 0:
delta = data['choices'][0].get('delta', {})
content = delta.get('content', '')
if content:
# Send SSE formatted data
yield f"data: {json.dumps({'content': content})}\n\n"
except json.JSONDecodeError:
continue
# Send completion signal
yield f"data: {json.dumps({'done': True})}\n\n"
else:
error_text = await response.text()
error_msg = f"Error: {response.status} - {error_text}"
yield f"data: {json.dumps({'error': error_msg})}\n\n"
except Exception as e:
error_msg = f"Error in chat stream: {str(e)}"
logger.error(error_msg)
yield f"data: {json.dumps({'error': error_msg})}\n\n"
return StreamingResponse(
generate_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Disable nginx buffering
}
)

184
bot/routes/config.py Normal file
View File

@@ -0,0 +1,184 @@
"""Configuration management routes: get/set/reset/validate config."""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
import globals
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/config")
async def get_full_config():
"""
Get full configuration including static, runtime, and state.
Useful for debugging and config display in UI.
"""
try:
from config_manager import config_manager
full_config = config_manager.get_full_config()
return {
"success": True,
"config": full_config
}
except Exception as e:
logger.error(f"Failed to get config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/config/static")
async def get_static_config():
"""
Get static configuration from config.yaml.
These are default values that can be overridden at runtime.
"""
try:
from config_manager import config_manager
return {
"success": True,
"config": config_manager.static_config
}
except Exception as e:
logger.error(f"Failed to get static config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/config/runtime")
async def get_runtime_config():
"""
Get runtime configuration overrides.
These are values changed via Web UI that override config.yaml.
"""
try:
from config_manager import config_manager
return {
"success": True,
"config": config_manager.runtime_config,
"path": str(config_manager.runtime_config_path)
}
except Exception as e:
logger.error(f"Failed to get runtime config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/config/set")
async def set_config_value(request: Request):
"""
Set a configuration value with optional persistence.
Body: {
"key_path": "discord.language_mode", // Dot-separated path
"value": "japanese",
"persist": true // Save to config_runtime.yaml
}
"""
try:
data = await request.json()
key_path = data.get("key_path")
value = data.get("value")
persist = data.get("persist", True)
if not key_path:
return JSONResponse(status_code=400, content={"success": False, "error": "key_path is required"})
from config_manager import config_manager
config_manager.set(key_path, value, persist=persist)
# ── Sync globals for every runtime-relevant key path ──
_GLOBALS_SYNC = {
"discord.language_mode": ("LANGUAGE_MODE", str),
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
}
if key_path in _GLOBALS_SYNC:
attr, converter = _GLOBALS_SYNC[key_path]
setattr(globals, attr, converter(value))
elif key_path == "runtime.mood.dm_mood":
# DM mood needs description loaded alongside
if isinstance(value, str) and value in getattr(globals, "AVAILABLE_MOODS", []):
globals.DM_MOOD = value
try:
from utils.moods import load_mood_description
globals.DM_MOOD_DESCRIPTION = load_mood_description(value)
except Exception:
globals.DM_MOOD_DESCRIPTION = f"I'm feeling {value} today."
return {
"success": True,
"message": f"Set {key_path} = {value}",
"persisted": persist
}
except Exception as e:
logger.error(f"Failed to set config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/config/reset")
async def reset_config(request: Request):
"""
Reset configuration to defaults.
Body: {
"key_path": "discord.language_mode", // Optional: reset specific key
"persist": true // Remove from config_runtime.yaml
}
If key_path is omitted, resets all runtime config to defaults.
"""
try:
data = await request.json()
key_path = data.get("key_path")
persist = data.get("persist", True)
from config_manager import config_manager
config_manager.reset_to_defaults(key_path)
return {
"success": True,
"message": f"Reset {key_path or 'all config'} to defaults"
}
except Exception as e:
logger.error(f"Failed to reset config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/config/validate")
async def validate_config_endpoint():
"""
Validate current configuration.
Returns list of errors if validation fails.
"""
try:
from config_manager import config_manager
is_valid, errors = config_manager.validate_config()
return {
"success": is_valid,
"is_valid": is_valid,
"errors": errors
}
except Exception as e:
logger.error(f"Failed to validate config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/config/state")
async def get_config_state():
"""
Get runtime state (not persisted config).
These are transient values like current mood, evil mode, etc.
"""
try:
from config_manager import config_manager
return {
"success": True,
"state": config_manager.runtime_state
}
except Exception as e:
logger.error(f"Failed to get config state: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})

167
bot/routes/core.py Normal file
View File

@@ -0,0 +1,167 @@
"""Core routes: index, logs, prompts, status, conversation."""
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse
import globals
from server_manager import server_manager
from utils.conversation_history import conversation_history
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/")
def read_index():
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}
return FileResponse("static/index.html", headers=headers)
@router.get("/logs")
def get_logs():
try:
# Read last 100 lines of the log file
with open("/app/bot.log", "r", encoding="utf-8") as f:
lines = f.readlines()
last_100 = lines[-100:] if len(lines) >= 100 else lines
return "".join(last_100)
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error reading log file: {e}"})
@router.get("/prompt")
def get_last_prompt():
"""Legacy endpoint: returns the most recent fallback prompt (backward compat)."""
prompt_text = globals._get_last_fallback_prompt()
return {"prompt": prompt_text or "No prompt has been issued yet."}
@router.get("/prompt/cat")
def get_last_cat_prompt():
"""Legacy endpoint: returns the most recent Cat interaction (backward compat)."""
interaction = globals._get_last_cat_interaction()
if not interaction.get("full_prompt"):
return {"full_prompt": "No Cheshire Cat interaction has occurred yet.",
"response": "", "user": "", "mood": "", "timestamp": ""}
return interaction
@router.get("/prompts")
def get_prompt_history(source: str = None):
"""
Return the unified prompt history.
Optional query param ?source=cat or ?source=fallback to filter.
"""
history = list(globals.PROMPT_HISTORY)
if source and source in ("cat", "fallback"):
history = [e for e in history if e.get("source") == source]
return {"history": history}
@router.get("/prompts/{prompt_id}")
def get_prompt_by_id(prompt_id: int):
"""Return a single prompt history entry by ID."""
for entry in globals.PROMPT_HISTORY:
if entry.get("id") == prompt_id:
return entry
return JSONResponse(
status_code=404,
content={"status": "error", "message": f"Prompt #{prompt_id} not found"}
)
@router.get("/status")
def status():
# Get per-server mood summary
server_moods = {}
for guild_id in server_manager.servers:
mood_name, _ = server_manager.get_server_mood(guild_id)
server_moods[str(guild_id)] = mood_name
# Return evil mood when in evil mode
current_mood = globals.EVIL_DM_MOOD if globals.EVIL_MODE else globals.DM_MOOD
return {
"status": "online",
"mood": current_mood,
"evil_mode": globals.EVIL_MODE,
"servers": len(server_manager.servers),
"active_schedulers": len(server_manager.schedulers),
"server_moods": server_moods
}
@router.get("/autonomous/stats")
def get_autonomous_stats():
"""Get autonomous engine stats for all servers"""
from utils.autonomous import autonomous_engine
stats = {}
for guild_id in server_manager.servers:
server_info = server_manager.servers[guild_id]
mood_name, _ = server_manager.get_server_mood(guild_id)
# Get context signals for this server
if guild_id in autonomous_engine.server_contexts:
ctx = autonomous_engine.server_contexts[guild_id]
# Get mood profile
mood_profile = autonomous_engine.mood_profiles.get(mood_name, {
"energy": 0.5,
"sociability": 0.5,
"impulsiveness": 0.5
})
# Sanitize float values for JSON serialization (replace inf with large number)
time_since_action = ctx.time_since_last_action
if time_since_action == float('inf'):
time_since_action = 999999
time_since_interaction = ctx.time_since_last_interaction
if time_since_interaction == float('inf'):
time_since_interaction = 999999
stats[str(guild_id)] = {
"guild_name": server_info.guild_name,
"mood": mood_name,
"mood_profile": mood_profile,
"context": {
"messages_last_5min": ctx.messages_last_5min,
"messages_last_hour": ctx.messages_last_hour,
"unique_users_active": ctx.unique_users_active,
"conversation_momentum": round(ctx.conversation_momentum, 2),
"users_joined_recently": ctx.users_joined_recently,
"users_status_changed": ctx.users_status_changed,
"users_started_activity": ctx.users_started_activity,
"time_since_last_action": round(time_since_action, 1),
"time_since_last_interaction": round(time_since_interaction, 1),
"messages_since_last_appearance": ctx.messages_since_last_appearance,
"hour_of_day": ctx.hour_of_day,
"is_weekend": ctx.is_weekend,
"mood_energy_level": round(ctx.mood_energy_level, 2)
}
}
else:
# Server not yet initialized in autonomous engine
mood_profile = autonomous_engine.mood_profiles.get(mood_name, {
"energy": 0.5,
"sociability": 0.5,
"impulsiveness": 0.5
})
stats[str(guild_id)] = {
"guild_name": server_info.guild_name,
"mood": mood_name,
"mood_profile": mood_profile,
"context": None
}
return {"servers": stats}
@router.get("/conversation/{user_id}")
def get_conversation(user_id: str):
"""Get conversation history for a user/channel (uses centralized ConversationHistory)."""
messages = conversation_history.get_recent_messages(user_id)
return {"conversation": [{"author": author, "content": content, "is_bot": is_bot} for author, content, is_bot in messages]}

468
bot/routes/dms.py Normal file
View File

@@ -0,0 +1,468 @@
"""DM routes: custom prompt DMs, manual DMs, logging, blocking, analysis."""
import io
import os
import json
from typing import List
from fastapi import APIRouter, UploadFile, File, Form
from fastapi.responses import JSONResponse
import discord
import globals
from routes.models import CustomPromptRequest
from utils.dm_logger import dm_logger
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
# ========== DM Custom / Manual Send ==========
@router.post("/dm/{user_id}/custom")
async def send_custom_prompt_dm(user_id: str, req: CustomPromptRequest):
"""Send custom prompt via DM to a specific user"""
try:
user_id_int = int(user_id)
user = globals.client.get_user(user_id_int)
if not user:
return JSONResponse(status_code=404, content={"status": "error", "message": f"User {user_id} not found"})
# Use the LLM query function for DM context
from utils.llm import query_llama
async def send_dm_custom_prompt():
try:
response = await query_llama(req.prompt, user_id=user_id, guild_id=None, response_type="dm_response")
await user.send(response)
logger.info(f"Custom DM prompt sent to user {user_id}: {req.prompt[:50]}...")
# Log to DM history
dm_logger.log_conversation(user_id, req.prompt, response)
except Exception as e:
logger.error(f"Failed to send custom DM prompt to user {user_id}: {e}")
# Use create_task to avoid timeout context manager error
globals.client.loop.create_task(send_dm_custom_prompt())
return {"status": "ok", "message": f"Custom DM prompt queued for user {user_id}"}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid user ID format"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
@router.post("/dm/{user_id}/manual")
async def send_manual_message_dm(
user_id: str,
message: str = Form(...),
files: List[UploadFile] = File(default=[]),
reply_to_message_id: str = Form(None),
mention_author: bool = Form(True)
):
"""Send manual message via DM to a specific user"""
try:
user_id_int = int(user_id)
user = globals.client.get_user(user_id_int)
if not user:
return JSONResponse(status_code=404, content={"status": "error", "message": f"User {user_id} not found"})
# Read file content immediately before the request closes
file_data = []
for file in files:
try:
file_content = await file.read()
file_data.append({
'filename': file.filename,
'content': file_content
})
except Exception as e:
logger.error(f"Failed to read file {file.filename}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to read file {file.filename}: {e}"})
async def send_dm_message_and_files():
try:
# Get the reference message if replying (must be done inside the task)
reference_message = None
if reply_to_message_id:
try:
dm_channel = user.dm_channel or await user.create_dm()
reference_message = await dm_channel.fetch_message(int(reply_to_message_id))
except Exception as e:
logger.error(f"Could not fetch DM message {reply_to_message_id} for reply: {e}")
return
# Send the main message
if message.strip():
if reference_message:
await user.send(message, reference=reference_message, mention_author=mention_author)
logger.info(f"Manual DM reply message sent to user {user_id}")
else:
await user.send(message)
logger.info(f"Manual DM message sent to user {user_id}")
# Send files if any
for file_info in file_data:
try:
await user.send(file=discord.File(io.BytesIO(file_info['content']), filename=file_info['filename']))
logger.info(f"File {file_info['filename']} sent via DM to user {user_id}")
except Exception as e:
logger.error(f"Failed to send file {file_info['filename']} via DM: {e}")
# Log to DM history (user message = manual override trigger, miku response = the message sent)
dm_logger.log_conversation(user_id, "[Manual Override Trigger]", message, attachments=[f['filename'] for f in file_data])
except Exception as e:
logger.error(f"Failed to send manual DM to user {user_id}: {e}")
# Use create_task to avoid timeout context manager error
globals.client.loop.create_task(send_dm_message_and_files())
return {"status": "ok", "message": f"Manual DM message queued for user {user_id}"}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid user ID format"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
# ========== DM Logging Endpoints ==========
@router.get("/dms/users")
def get_dm_users():
"""Get summary of all users who have DMed the bot"""
try:
users = dm_logger.get_all_dm_users()
return {"status": "ok", "users": users}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get DM users: {e}"})
@router.get("/dms/users/{user_id}")
def get_dm_user_conversation(user_id: str):
"""Get conversation summary for a specific user"""
try:
# Convert string user_id to int for internal processing
user_id_int = int(user_id)
summary = dm_logger.get_user_conversation_summary(user_id_int)
return {"status": "ok", "summary": summary}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get user conversation: {e}"})
@router.get("/dms/users/{user_id}/conversations")
def get_dm_conversations(user_id: str, limit: int = 50):
"""Get recent conversations with a specific user"""
try:
# Convert string user_id to int for internal processing
user_id_int = int(user_id)
logger.debug(f"Loading conversations for user {user_id_int}, limit: {limit}")
logs = dm_logger._load_user_logs(user_id_int)
logger.debug(f"Loaded logs for user {user_id_int}: {len(logs.get('conversations', []))} conversations")
conversations = logs["conversations"][-limit:] if limit > 0 else logs["conversations"]
# Convert message IDs to strings to prevent JavaScript precision loss
for conv in conversations:
if "message_id" in conv:
conv["message_id"] = str(conv["message_id"])
logger.debug(f"Returning {len(conversations)} conversations")
# Debug: Show message IDs being returned
for i, conv in enumerate(conversations):
msg_id = conv.get("message_id", "")
is_bot = conv.get("is_bot_message", False)
content_preview = conv.get("content", "")[:30] + "..." if conv.get("content", "") else "[No content]"
logger.debug(f"Conv {i}: id={msg_id} (type: {type(msg_id)}), is_bot={is_bot}, content='{content_preview}'")
return {"status": "ok", "conversations": conversations}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to get conversations for user {user_id}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get conversations: {e}"})
@router.get("/dms/users/{user_id}/search")
def search_dm_conversations(user_id: str, query: str, limit: int = 10):
"""Search conversations with a specific user"""
try:
# Convert string user_id to int for internal processing
user_id_int = int(user_id)
results = dm_logger.search_user_conversations(user_id_int, query, limit)
return {"status": "ok", "results": results}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to search conversations: {e}"})
@router.get("/dms/users/{user_id}/export")
def export_dm_conversation(user_id: str, format: str = "json"):
"""Export all conversations with a user"""
try:
# Convert string user_id to int for internal processing
user_id_int = int(user_id)
export_path = dm_logger.export_user_conversation(user_id_int, format)
return {"status": "ok", "export_path": export_path, "format": format}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to export conversation: {e}"})
@router.delete("/dms/users/{user_id}")
def delete_dm_user_logs(user_id: str):
"""Delete all DM logs for a specific user"""
try:
# Convert string user_id to int for internal processing
user_id_int = int(user_id)
log_file = dm_logger._get_user_log_file(user_id_int)
if os.path.exists(log_file):
os.remove(log_file)
return {"status": "ok", "message": f"Deleted DM logs for user {user_id}"}
else:
return JSONResponse(status_code=404, content={"status": "error", "message": f"No DM logs found for user {user_id}"})
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete DM logs: {e}"})
# ========== User Blocking & DM Management ==========
@router.get("/dms/blocked-users")
def get_blocked_users():
"""Get list of all blocked users"""
try:
blocked_users = dm_logger.get_blocked_users()
return {"status": "ok", "blocked_users": blocked_users}
except Exception as e:
logger.error(f"Failed to get blocked users: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get blocked users: {e}"})
@router.post("/dms/users/{user_id}/block")
def block_user(user_id: str):
"""Block a user from sending DMs to Miku"""
try:
user_id_int = int(user_id)
# Get username from DM logs if available
user_summary = dm_logger.get_user_conversation_summary(user_id_int)
username = user_summary.get("username", "Unknown")
success = dm_logger.block_user(user_id_int, username)
if success:
logger.info(f"User {user_id} ({username}) blocked")
return {"status": "ok", "message": f"User {username} has been blocked"}
else:
return JSONResponse(status_code=409, content={"status": "error", "message": f"User {username} is already blocked"})
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to block user {user_id}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to block user: {e}"})
@router.post("/dms/users/{user_id}/unblock")
def unblock_user(user_id: str):
"""Unblock a user"""
try:
user_id_int = int(user_id)
success = dm_logger.unblock_user(user_id_int)
if success:
logger.info(f"User {user_id} unblocked")
return {"status": "ok", "message": f"User has been unblocked"}
else:
return JSONResponse(status_code=409, content={"status": "error", "message": f"User is not blocked"})
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to unblock user {user_id}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to unblock user: {e}"})
@router.post("/dms/users/{user_id}/conversations/{conversation_id}/delete")
def delete_conversation(user_id: str, conversation_id: str):
"""Delete a specific conversation/message from both Discord and logs"""
try:
user_id_int = int(user_id)
# Queue the async deletion in the bot's event loop
async def do_delete():
return await dm_logger.delete_conversation(user_id_int, conversation_id)
globals.client.loop.create_task(do_delete())
# For now, return success immediately since we can't await in FastAPI sync endpoint
# The actual deletion happens asynchronously
logger.info(f"Queued deletion of conversation {conversation_id} for user {user_id}")
return {"status": "ok", "message": "Message deletion queued (will delete from both Discord and logs)"}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to queue conversation deletion {conversation_id}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete conversation: {e}"})
@router.post("/dms/users/{user_id}/conversations/delete-all")
def delete_all_conversations(user_id: str):
"""Delete all conversations with a user from both Discord and logs"""
try:
user_id_int = int(user_id)
# Queue the async bulk deletion in the bot's event loop
async def do_delete_all():
return await dm_logger.delete_all_conversations(user_id_int)
globals.client.loop.create_task(do_delete_all())
# Return success immediately since we can't await in FastAPI sync endpoint
logger.info(f"Queued bulk deletion of all conversations for user {user_id}")
return {"status": "ok", "message": "Bulk deletion queued (will delete all Miku messages from Discord and clear logs)"}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to queue bulk conversation deletion for user {user_id}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete conversations: {e}"})
@router.post("/dms/users/{user_id}/delete-completely")
def delete_user_completely(user_id: str):
"""Delete user's log file completely"""
try:
user_id_int = int(user_id)
success = dm_logger.delete_user_completely(user_id_int)
if success:
logger.info(f"Completely deleted user {user_id}")
return {"status": "ok", "message": "User data deleted completely"}
else:
return JSONResponse(status_code=404, content={"status": "error", "message": "No user data found"})
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to completely delete user {user_id}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete user: {e}"})
# ========== DM Interaction Analysis Endpoints ==========
@router.post("/dms/analysis/run")
def run_dm_analysis():
"""Manually trigger the daily DM interaction analysis"""
try:
from utils.dm_interaction_analyzer import dm_analyzer
if dm_analyzer is None:
return JSONResponse(status_code=503, content={"status": "error", "message": "DM Analyzer not initialized. Set OWNER_USER_ID environment variable."})
# Schedule analysis in Discord's event loop
async def run_analysis():
await dm_analyzer.run_daily_analysis()
globals.client.loop.create_task(run_analysis())
return {"status": "ok", "message": "DM analysis started"}
except Exception as e:
logger.error(f"Failed to run DM analysis: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to run DM analysis: {e}"})
@router.post("/dms/users/{user_id}/analyze")
def analyze_user_interaction(user_id: str):
"""Analyze a specific user's interaction and optionally send report"""
try:
from utils.dm_interaction_analyzer import dm_analyzer
if dm_analyzer is None:
return JSONResponse(status_code=503, content={"status": "error", "message": "DM Analyzer not initialized. Set OWNER_USER_ID environment variable."})
user_id_int = int(user_id)
# Schedule analysis in Discord's event loop
async def run_analysis():
return await dm_analyzer.analyze_and_report(user_id_int)
globals.client.loop.create_task(run_analysis())
# Return immediately - the analysis will run in the background
return {"status": "ok", "message": f"Analysis started for user {user_id}", "reported": True}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to analyze user {user_id}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to analyze user: {e}"})
@router.get("/dms/analysis/reports")
def get_analysis_reports(limit: int = 20):
"""Get recent analysis reports"""
try:
from utils.dm_interaction_analyzer import REPORTS_DIR
if not os.path.exists(REPORTS_DIR):
return {"status": "ok", "reports": []}
reports = []
files = sorted([f for f in os.listdir(REPORTS_DIR) if f.endswith('.json') and f != 'reported_today.json'],
reverse=True)[:limit]
for filename in files:
try:
with open(os.path.join(REPORTS_DIR, filename), 'r', encoding='utf-8') as f:
report = json.load(f)
report['filename'] = filename
reports.append(report)
except Exception as e:
logger.warning(f"Failed to load report {filename}: {e}")
return {"status": "ok", "reports": reports}
except Exception as e:
logger.error(f"Failed to get reports: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get reports: {e}"})
@router.get("/dms/analysis/reports/{user_id}")
def get_user_reports(user_id: str, limit: int = 10):
"""Get analysis reports for a specific user"""
try:
from utils.dm_interaction_analyzer import REPORTS_DIR
if not os.path.exists(REPORTS_DIR):
return {"status": "ok", "reports": []}
user_id_int = int(user_id)
reports = []
files = sorted([f for f in os.listdir(REPORTS_DIR)
if f.startswith(f"{user_id}_") and f.endswith('.json')],
reverse=True)[:limit]
for filename in files:
try:
with open(os.path.join(REPORTS_DIR, filename), 'r', encoding='utf-8') as f:
report = json.load(f)
report['filename'] = filename
reports.append(report)
except Exception as e:
logger.warning(f"Failed to load report {filename}: {e}")
return {"status": "ok", "reports": reports}
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
except Exception as e:
logger.error(f"Failed to get user reports: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get user reports: {e}"})

111
bot/routes/evil_mode.py Normal file
View File

@@ -0,0 +1,111 @@
"""Evil mode routes."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import globals
from routes.models import EvilMoodSetRequest
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/evil-mode")
def get_evil_mode_status():
"""Get current evil mode status"""
from utils.evil_mode import is_evil_mode, get_current_evil_mood
evil_mode = is_evil_mode()
if evil_mode:
mood, mood_desc = get_current_evil_mood()
return {
"evil_mode": True,
"mood": mood,
"description": mood_desc,
"available_moods": globals.EVIL_AVAILABLE_MOODS
}
return {
"evil_mode": False,
"mood": None,
"description": None,
"available_moods": globals.EVIL_AVAILABLE_MOODS
}
@router.post("/evil-mode/enable")
def enable_evil_mode():
"""Enable evil mode"""
from utils.evil_mode import apply_evil_mode_changes
if globals.EVIL_MODE:
return {"status": "ok", "message": "Evil mode is already enabled", "evil_mode": True}
if globals.client and globals.client.loop and globals.client.loop.is_running():
globals.client.loop.create_task(apply_evil_mode_changes(globals.client))
return {"status": "ok", "message": "Evil mode enabled", "evil_mode": True}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
@router.post("/evil-mode/disable")
def disable_evil_mode():
"""Disable evil mode"""
from utils.evil_mode import revert_evil_mode_changes
if not globals.EVIL_MODE:
return {"status": "ok", "message": "Evil mode is already disabled", "evil_mode": False}
if globals.client and globals.client.loop and globals.client.loop.is_running():
globals.client.loop.create_task(revert_evil_mode_changes(globals.client))
return {"status": "ok", "message": "Evil mode disabled", "evil_mode": False}
else:
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
@router.post("/evil-mode/toggle")
def toggle_evil_mode():
"""Toggle evil mode on/off"""
from utils.evil_mode import apply_evil_mode_changes, revert_evil_mode_changes
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
if globals.EVIL_MODE:
globals.client.loop.create_task(revert_evil_mode_changes(globals.client))
return {"status": "ok", "message": "Evil mode disabled", "evil_mode": False}
else:
globals.client.loop.create_task(apply_evil_mode_changes(globals.client))
return {"status": "ok", "message": "Evil mode enabled", "evil_mode": True}
@router.get("/evil-mode/mood")
def get_evil_mood():
"""Get current evil mood"""
from utils.evil_mode import get_current_evil_mood
mood, mood_desc = get_current_evil_mood()
return {
"mood": mood,
"description": mood_desc,
"available_moods": globals.EVIL_AVAILABLE_MOODS
}
@router.post("/evil-mode/mood")
def set_evil_mood_endpoint(data: EvilMoodSetRequest):
"""Set evil mood"""
from utils.evil_mode import set_evil_mood, is_valid_evil_mood, update_all_evil_nicknames
if not is_valid_evil_mood(data.mood):
return JSONResponse(status_code=400, content={
"status": "error",
"message": f"Mood '{data.mood}' not recognized. Available evil moods: {', '.join(globals.EVIL_AVAILABLE_MOODS)}"
})
success = set_evil_mood(data.mood)
if success:
# Update nicknames if evil mode is active
if globals.EVIL_MODE and globals.client and globals.client.loop and globals.client.loop.is_running():
globals.client.loop.create_task(update_all_evil_nicknames(globals.client))
return {"status": "ok", "new_mood": data.mood}
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to set evil mood"})

81
bot/routes/figurines.py Normal file
View File

@@ -0,0 +1,81 @@
"""Figurine subscriber and send routes."""
from fastapi import APIRouter, Form
from fastapi.responses import JSONResponse
import globals
from utils.figurine_notifier import (
load_subscribers as figurine_load_subscribers,
add_subscriber as figurine_add_subscriber,
remove_subscriber as figurine_remove_subscriber,
send_figurine_dm_to_all_subscribers,
send_figurine_dm_to_single_user,
)
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/figurines/subscribers")
async def get_figurine_subscribers():
subs = figurine_load_subscribers()
return {"subscribers": [str(uid) for uid in subs]}
@router.post("/figurines/subscribers")
async def add_figurine_subscriber(user_id: str = Form(...)):
try:
uid = int(user_id)
ok = figurine_add_subscriber(uid)
return {"status": "ok", "added": ok}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.delete("/figurines/subscribers/{user_id}")
async def delete_figurine_subscriber(user_id: str):
try:
uid = int(user_id)
ok = figurine_remove_subscriber(uid)
return {"status": "ok", "removed": ok}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/figurines/send_now")
async def figurines_send_now(tweet_url: str = Form(None)):
"""Trigger immediate figurine DM send to all subscribers, optionally with specific tweet URL"""
if globals.client and globals.client.loop and globals.client.loop.is_running():
logger.info(f"Sending figurine DMs to all subscribers, tweet_url: {tweet_url}")
globals.client.loop.create_task(send_figurine_dm_to_all_subscribers(globals.client, tweet_url=tweet_url))
return {"status": "ok", "message": "Figurine DMs queued"}
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
@router.post("/figurines/send_to_user")
async def figurines_send_to_user(user_id: str = Form(...), tweet_url: str = Form(None)):
"""Send figurine DM to a specific user, optionally with specific tweet URL"""
logger.debug(f"Received figurine send request - user_id: '{user_id}', tweet_url: '{tweet_url}'")
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
logger.error("Bot not ready")
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
user_id_int = int(user_id)
logger.debug(f"Parsed user_id as {user_id_int}")
except ValueError:
logger.error(f"Invalid user ID: '{user_id}'")
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid user ID"})
# Clean up tweet URL if it's empty string
if tweet_url == "":
tweet_url = None
logger.info(f"Sending figurine DM to user {user_id_int}, tweet_url: {tweet_url}")
# Queue the DM send task in the bot's event loop
globals.client.loop.create_task(send_figurine_dm_to_single_user(globals.client, user_id_int, tweet_url=tweet_url))
return {"status": "ok", "message": f"Figurine DM to user {user_id} queued"}

39
bot/routes/gpu.py Normal file
View File

@@ -0,0 +1,39 @@
"""GPU selection routes."""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/gpu-status")
def get_gpu_status():
"""Get current GPU selection"""
from config_manager import config_manager
return {"gpu": config_manager.get_gpu()}
@router.post("/gpu-select")
async def select_gpu(request: Request):
"""Select which GPU to use for inference"""
data = await request.json()
gpu = data.get("gpu", "nvidia").lower()
if gpu not in ["nvidia", "amd"]:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid GPU selection. Must be 'nvidia' or 'amd'"})
try:
from config_manager import config_manager
success = config_manager.set_gpu(gpu)
if success:
logger.info(f"GPU Selection: Switched to {gpu.upper()} GPU")
return {"status": "ok", "message": f"Switched to {gpu.upper()} GPU", "gpu": gpu}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to save GPU state"})
except Exception as e:
logger.error(f"GPU Selection Error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})

View File

@@ -0,0 +1,108 @@
"""Image generation routes: generate, status, test-detection, view."""
import os
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.post("/image/generate")
async def manual_image_generation(req: dict):
"""Manually trigger image generation for testing"""
try:
prompt = req.get("prompt", "").strip()
if not prompt:
return JSONResponse(status_code=400, content={"status": "error", "message": "Prompt is required"})
from utils.image_generation import generate_image_with_comfyui
image_path = await generate_image_with_comfyui(prompt)
if image_path:
return {"status": "ok", "message": f"Image generated successfully", "image_path": image_path}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to generate image"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
@router.get("/image/status")
async def get_image_generation_status():
"""Get status of image generation system"""
try:
from utils.image_generation import check_comfyui_status
status = await check_comfyui_status()
return {"status": "ok", **status}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
@router.post("/image/test-detection")
async def test_image_detection(req: dict):
"""Test the natural language image detection system"""
try:
message = req.get("message", "").strip()
if not message:
return JSONResponse(status_code=400, content={"status": "error", "message": "Message is required"})
from utils.image_generation import detect_image_request
is_image_request, extracted_prompt = await detect_image_request(message)
return {
"status": "ok",
"is_image_request": is_image_request,
"extracted_prompt": extracted_prompt,
"original_message": message
}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
@router.get("/image/view/{filename}")
async def view_generated_image(filename: str):
"""Serve generated images from ComfyUI output directory"""
try:
logger.debug(f"Image view request for: {filename}")
# Try multiple possible paths for ComfyUI output
possible_paths = [
f"/app/ComfyUI/output/{filename}",
f"/home/koko210Serve/ComfyUI/output/{filename}",
f"./ComfyUI/output/{filename}",
]
image_path = None
for path in possible_paths:
if os.path.exists(path):
image_path = path
logger.debug(f"Found image at: {path}")
break
else:
logger.debug(f"Not found at: {path}")
if not image_path:
logger.warning(f"Image not found anywhere: {filename}")
return JSONResponse(status_code=404, content={"status": "error", "message": f"Image not found: {filename}"})
# Determine content type based on file extension
ext = filename.lower().split('.')[-1]
content_type = "image/png"
if ext == "jpg" or ext == "jpeg":
content_type = "image/jpeg"
elif ext == "gif":
content_type = "image/gif"
elif ext == "webp":
content_type = "image/webp"
logger.info(f"Serving image: {image_path} as {content_type}")
return FileResponse(image_path, media_type=content_type)
except Exception as e:
logger.error(f"Error serving image: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error serving image: {e}"})

75
bot/routes/language.py Normal file
View File

@@ -0,0 +1,75 @@
"""Language mode routes."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import globals
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/language")
def get_language_mode():
"""Get current language mode (english or japanese)"""
return {
"language_mode": globals.LANGUAGE_MODE,
"available_languages": ["english", "japanese"],
"current_model": globals.JAPANESE_TEXT_MODEL if globals.LANGUAGE_MODE == "japanese" else globals.TEXT_MODEL
}
@router.post("/language/toggle")
def toggle_language_mode():
"""Toggle between English and Japanese modes"""
if globals.LANGUAGE_MODE == "english":
globals.LANGUAGE_MODE = "japanese"
new_mode = "japanese"
model_used = globals.JAPANESE_TEXT_MODEL
logger.info("Switched to Japanese mode (using Llama 3.1 Swallow)")
else:
globals.LANGUAGE_MODE = "english"
new_mode = "english"
model_used = globals.TEXT_MODEL
logger.info("Switched to English mode (using default model)")
# Persist via config manager
try:
from config_manager import config_manager
config_manager.set("discord.language_mode", new_mode, persist=True)
logger.info(f"💾 Language mode persisted to config_runtime.yaml")
except Exception as e:
logger.warning(f"Failed to persist language mode: {e}")
return {
"status": "ok",
"language_mode": new_mode,
"model_now_using": model_used,
"message": f"Miku is now speaking in {new_mode.upper()}!"
}
@router.post("/language/set")
def set_language_mode(language: str = "english"):
"""Set language mode to either 'english' or 'japanese'"""
if language.lower() not in ["english", "japanese"]:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid language mode '{language}'. Use 'english' or 'japanese'."})
globals.LANGUAGE_MODE = language.lower()
model_used = globals.JAPANESE_TEXT_MODEL if language.lower() == "japanese" else globals.TEXT_MODEL
logger.info(f"Language mode set to {language.lower()} (using {model_used})")
# Persist so it survives restarts
try:
from config_manager import config_manager
config_manager.set("discord.language_mode", language.lower(), persist=True)
except Exception:
pass
return {
"status": "ok",
"language_mode": language.lower(),
"model_now_using": model_used,
"message": f"Miku is now speaking in {language.upper()}!"
}

View File

@@ -0,0 +1,174 @@
"""Logging configuration routes: get/set log levels, filters, components."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from routes.models import LogConfigUpdateRequest, LogFilterUpdateRequest
from utils.logger import get_logger, list_components, get_component_stats
from utils.log_config import (
load_config as load_log_config,
update_component, reload_all_loggers, update_api_filters,
reset_to_defaults, update_timestamp_format,
)
logger = get_logger('api')
router = APIRouter()
@router.get("/api/log/config")
async def get_log_config():
"""Get current logging configuration."""
try:
config = load_log_config()
logger.debug("Log config requested")
return {"success": True, "config": config}
except Exception as e:
logger.error(f"Failed to get log config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/api/log/config")
async def update_log_config(request: LogConfigUpdateRequest):
"""Update logging configuration."""
try:
if request.component:
success = update_component(
request.component,
enabled=request.enabled,
enabled_levels=request.enabled_levels
)
if not success:
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to update component {request.component}"})
logger.info(f"Log config updated: component={request.component}, enabled_levels={request.enabled_levels}, enabled={request.enabled}")
return {"success": True, "message": "Configuration updated"}
except Exception as e:
logger.error(f"Failed to update log config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/api/log/components")
async def get_log_components():
"""Get list of all logging components with their descriptions."""
try:
components = list_components()
stats = get_component_stats()
logger.debug("Log components list requested")
return {
"success": True,
"components": components,
"stats": stats
}
except Exception as e:
logger.error(f"Failed to get log components: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/api/log/reload")
async def reload_log_config():
"""Reload logging configuration from file."""
try:
success = reload_all_loggers()
if success:
logger.info("Log configuration reloaded")
return {"success": True, "message": "Configuration reloaded"}
else:
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to reload configuration"})
except Exception as e:
logger.error(f"Failed to reload log config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/api/log/filters")
async def update_log_filters(request: LogFilterUpdateRequest):
"""Update API request filtering configuration."""
try:
success = update_api_filters(
exclude_paths=request.exclude_paths,
exclude_status=request.exclude_status,
include_slow_requests=request.include_slow_requests,
slow_threshold_ms=request.slow_threshold_ms
)
if success:
logger.info(f"API filters updated: {request.dict(exclude_none=True)}")
return {"success": True, "message": "Filters updated"}
else:
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to update filters"})
except Exception as e:
logger.error(f"Failed to update filters: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/api/log/reset")
async def reset_log_config():
"""Reset logging configuration to defaults."""
try:
success = reset_to_defaults()
if success:
logger.info("Log configuration reset to defaults")
return {"success": True, "message": "Configuration reset to defaults"}
else:
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to reset configuration"})
except Exception as e:
logger.error(f"Failed to reset log config: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/api/log/global-level")
async def update_global_level_endpoint(level: str, enabled: bool):
"""Enable or disable a specific log level across all components."""
try:
from utils.log_config import update_global_level
success = update_global_level(level, enabled)
if success:
action = "enabled" if enabled else "disabled"
logger.info(f"Global level {level} {action} across all components")
return {"success": True, "message": f"Level {level} {action} globally"}
else:
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to update global level {level}"})
except Exception as e:
logger.error(f"Failed to update global level: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.post("/api/log/timestamp-format")
async def update_timestamp_format_endpoint(format_type: str):
"""Update timestamp format for all log outputs."""
try:
success = update_timestamp_format(format_type)
if success:
logger.info(f"Timestamp format updated to: {format_type}")
return {"success": True, "message": f"Timestamp format set to: {format_type}"}
else:
return JSONResponse(status_code=400, content={"success": False, "error": f"Invalid timestamp format: {format_type}"})
except Exception as e:
logger.error(f"Failed to update timestamp format: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@router.get("/api/log/files/{component}")
async def get_log_file(component: str, lines: int = 100):
"""Get last N lines from a component's log file."""
try:
from pathlib import Path
log_dir = Path('/app/memory/logs')
log_file = log_dir / f'{component.replace(".", "_")}.log'
if not log_file.exists():
return JSONResponse(status_code=404, content={"success": False, "error": "Log file not found"})
with open(log_file, 'r', encoding='utf-8') as f:
all_lines = f.readlines()
last_lines = all_lines[-lines:] if len(all_lines) >= lines else all_lines
logger.debug(f"Log file requested: {component} ({lines} lines)")
return {
"success": True,
"component": component,
"lines": last_lines,
"total_lines": len(all_lines)
}
except Exception as e:
logger.error(f"Failed to read log file for {component}: {e}")
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})

197
bot/routes/manual_send.py Normal file
View File

@@ -0,0 +1,197 @@
"""Manual message sending routes + message reactions."""
import io
from typing import List
from fastapi import APIRouter, UploadFile, File, Form
from fastapi.responses import JSONResponse
import discord
import globals
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.post("/manual/send")
async def manual_send(
message: str = Form(...),
channel_id: str = Form(...),
files: List[UploadFile] = File(default=[]),
reply_to_message_id: str = Form(None),
mention_author: bool = Form(True)
):
try:
channel = globals.client.get_channel(int(channel_id))
if not channel:
return JSONResponse(status_code=404, content={"status": "error", "message": "Channel not found"})
# Read file content immediately before the request closes
file_data = []
for file in files:
try:
file_content = await file.read()
file_data.append({
'filename': file.filename,
'content': file_content
})
except Exception as e:
logger.error(f"Failed to read file {file.filename}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to read file {file.filename}: {e}"})
async def send_message_and_files():
try:
reference_message = None
if reply_to_message_id:
try:
reference_message = await channel.fetch_message(int(reply_to_message_id))
except Exception as e:
logger.error(f"Could not fetch message {reply_to_message_id} for reply: {e}")
return
if message.strip():
if reference_message:
await channel.send(message, reference=reference_message, mention_author=mention_author)
logger.info(f"Manual message sent as reply to #{channel.name}")
else:
await channel.send(message)
logger.info(f"Manual message sent to #{channel.name}")
for file_info in file_data:
try:
await channel.send(file=discord.File(io.BytesIO(file_info['content']), filename=file_info['filename']))
logger.info(f"File {file_info['filename']} sent to #{channel.name}")
except Exception as e:
logger.error(f"Failed to send file {file_info['filename']}: {e}")
except Exception as e:
logger.error(f"Failed to send message: {e}")
globals.client.loop.create_task(send_message_and_files())
return {"status": "ok", "message": "Message and files queued for sending"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
@router.post("/manual/send-webhook")
async def manual_send_webhook(
message: str = Form(...),
channel_id: str = Form(...),
persona: str = Form("miku"),
files: List[UploadFile] = File(default=[]),
reply_to_message_id: str = Form(None),
mention_author: bool = Form(True)
):
"""Send a manual message via webhook as either Hatsune Miku or Evil Miku"""
try:
from utils.bipolar_mode import get_or_create_webhooks_for_channel, get_miku_display_name, get_evil_miku_display_name
channel = globals.client.get_channel(int(channel_id))
if not channel:
return JSONResponse(status_code=404, content={"status": "error", "message": "Channel not found"})
if persona not in ["miku", "evil"]:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid persona. Must be 'miku' or 'evil'"})
file_data = []
for file in files:
try:
file_content = await file.read()
file_data.append({
'filename': file.filename,
'content': file_content
})
except Exception as e:
logger.error(f"Failed to read file {file.filename}: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to read file {file.filename}: {e}"})
async def send_webhook_message():
try:
webhooks = await get_or_create_webhooks_for_channel(channel)
if not webhooks:
logger.error(f"Failed to create webhooks for channel #{channel.name}")
return
webhook = webhooks["evil_miku"] if persona == "evil" else webhooks["miku"]
display_name = get_evil_miku_display_name() if persona == "evil" else get_miku_display_name()
discord_files = []
for file_info in file_data:
discord_files.append(discord.File(io.BytesIO(file_info['content']), filename=file_info['filename']))
from utils.bipolar_mode import get_persona_avatar_urls
avatar_urls = get_persona_avatar_urls()
avatar_url = avatar_urls.get("evil_miku") if persona == "evil" else avatar_urls.get("miku")
if discord_files:
await webhook.send(
content=message, username=display_name,
avatar_url=avatar_url, files=discord_files, wait=True
)
else:
await webhook.send(
content=message, username=display_name,
avatar_url=avatar_url, wait=True
)
persona_name = "Evil Miku" if persona == "evil" else "Hatsune Miku"
logger.info(f"Manual webhook message sent as {persona_name} to #{channel.name}")
except Exception as e:
logger.error(f"Failed to send webhook message: {e}")
import traceback
traceback.print_exc()
globals.client.loop.create_task(send_webhook_message())
return {"status": "ok", "message": f"Webhook message queued for sending as {persona}"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
@router.post("/messages/react")
async def add_reaction_to_message(
message_id: str = Form(...),
channel_id: str = Form(...),
emoji: str = Form(...)
):
"""Add a reaction to a specific message"""
try:
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
msg_id = int(message_id)
chan_id = int(channel_id)
except ValueError:
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid message ID or channel ID format"})
channel = globals.client.get_channel(chan_id)
if not channel:
return JSONResponse(status_code=404, content={"status": "error", "message": f"Channel {channel_id} not found"})
async def add_reaction_task():
try:
message = await channel.fetch_message(msg_id)
await message.add_reaction(emoji)
logger.info(f"Added reaction {emoji} to message {msg_id} in channel #{channel.name}")
except discord.NotFound:
logger.error(f"Message {msg_id} not found in channel #{channel.name}")
except discord.Forbidden:
logger.error(f"Bot doesn't have permission to add reactions in channel #{channel.name}")
except discord.HTTPException as e:
logger.error(f"Failed to add reaction: {e}")
except Exception as e:
logger.error(f"Unexpected error adding reaction: {e}")
globals.client.loop.create_task(add_reaction_task())
return {
"status": "ok",
"message": f"Reaction {emoji} queued for message {message_id}"
}
except Exception as e:
logger.error(f"Failed to add reaction: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to add reaction: {e}"})

194
bot/routes/memory.py Normal file
View File

@@ -0,0 +1,194 @@
"""Cheshire Cat memory management routes."""
from typing import Optional
from fastapi import APIRouter, Form
from fastapi.responses import JSONResponse
import globals
from routes.models import MemoryDeleteRequest, MemoryEditRequest, MemoryCreateRequest
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/memory/status")
async def get_cat_memory_status():
"""Get Cheshire Cat connection status and feature flag."""
from utils.cat_client import cat_adapter
is_healthy = await cat_adapter.health_check()
return {
"enabled": globals.USE_CHESHIRE_CAT,
"healthy": is_healthy,
"url": globals.CHESHIRE_CAT_URL,
"circuit_breaker_active": cat_adapter._is_circuit_broken(),
"consecutive_failures": cat_adapter._consecutive_failures
}
@router.post("/memory/toggle")
async def toggle_cat_integration(enabled: bool = Form(...)):
"""Toggle Cheshire Cat integration on/off."""
globals.USE_CHESHIRE_CAT = enabled
logger.info(f"🐱 Cheshire Cat integration {'ENABLED' if enabled else 'DISABLED'}")
# Persist so it survives restarts
try:
from config_manager import config_manager
config_manager.set("memory.use_cheshire_cat", enabled, persist=True)
except Exception:
pass
return {
"success": True,
"enabled": globals.USE_CHESHIRE_CAT,
"message": f"Cheshire Cat {'enabled' if enabled else 'disabled'}"
}
@router.get("/memory/stats")
async def get_memory_stats():
"""Get memory collection statistics from Cheshire Cat (point counts per collection)."""
from utils.cat_client import cat_adapter
stats = await cat_adapter.get_memory_stats()
if stats is None:
return JSONResponse(status_code=502, content={"success": False, "error": "Could not reach Cheshire Cat"})
return {"success": True, "collections": stats.get("collections", [])}
@router.get("/memory/facts")
async def get_memory_facts():
"""Get all declarative memory facts (learned knowledge about users)."""
from utils.cat_client import cat_adapter
facts = await cat_adapter.get_all_facts()
return {"success": True, "facts": facts, "count": len(facts)}
@router.get("/memory/episodic")
async def get_episodic_memories():
"""Get all episodic memories (conversation snippets)."""
from utils.cat_client import cat_adapter
result = await cat_adapter.get_memory_points(collection="episodic", limit=100)
if result is None:
return JSONResponse(status_code=502, content={"success": False, "error": "Could not reach Cheshire Cat"})
memories = []
for point in result.get("points", []):
payload = point.get("payload", {})
memories.append({
"id": point.get("id"),
"content": payload.get("page_content", ""),
"metadata": payload.get("metadata", {}),
})
return {"success": True, "memories": memories, "count": len(memories)}
@router.post("/memory/consolidate")
async def trigger_memory_consolidation():
"""Manually trigger memory consolidation (sleep consolidation process)."""
from utils.cat_client import cat_adapter
logger.info("🌙 Manual memory consolidation triggered via API")
result = await cat_adapter.trigger_consolidation()
if result is None:
return JSONResponse(status_code=500, content={"success": False, "error": "Consolidation failed or timed out"})
return {"success": True, "result": result}
@router.post("/memory/delete")
async def delete_all_memories(request: MemoryDeleteRequest):
"""
Delete ALL of Miku's memories. Requires exact confirmation string.
The confirmation field must be exactly:
"Yes, I am deleting Miku's memories fully."
This is destructive and irreversible.
"""
REQUIRED_CONFIRMATION = "Yes, I am deleting Miku's memories fully."
if request.confirmation != REQUIRED_CONFIRMATION:
logger.warning(f"Memory deletion rejected: wrong confirmation string")
return JSONResponse(status_code=400, content={
"success": False,
"error": "Confirmation string does not match. "
f"Expected exactly: \"{REQUIRED_CONFIRMATION}\""
})
from utils.cat_client import cat_adapter
logger.warning("⚠️ MEMORY DELETION CONFIRMED — wiping all memories!")
# Wipe vector memories (episodic + declarative)
wipe_success = await cat_adapter.wipe_all_memories()
# Also clear conversation history
history_success = await cat_adapter.wipe_conversation_history()
if wipe_success:
logger.warning("🗑️ All Miku memories have been deleted.")
return {
"success": True,
"message": "All memories have been permanently deleted.",
"vector_memory_wiped": wipe_success,
"conversation_history_cleared": history_success
}
else:
return JSONResponse(status_code=500, content={
"success": False,
"error": "Failed to wipe memory collections. Check Cat connection."
})
@router.delete("/memory/point/{collection}/{point_id}")
async def delete_single_memory_point(collection: str, point_id: str):
"""Delete a single memory point by collection and ID."""
from utils.cat_client import cat_adapter
success = await cat_adapter.delete_memory_point(collection, point_id)
if success:
return {"success": True, "deleted": point_id}
else:
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to delete point {point_id}"})
@router.put("/memory/point/{collection}/{point_id}")
async def edit_memory_point(collection: str, point_id: str, request: MemoryEditRequest):
"""Edit an existing memory point's content and/or metadata."""
from utils.cat_client import cat_adapter
success = await cat_adapter.update_memory_point(
collection=collection,
point_id=point_id,
content=request.content,
metadata=request.metadata
)
if success:
return {"success": True, "updated": point_id}
else:
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to update point {point_id}"})
@router.post("/memory/create")
async def create_memory_point(request: MemoryCreateRequest):
"""
Manually create a new memory (declarative fact or episodic memory).
For declarative facts, this allows you to teach Miku new knowledge.
For episodic memories, this allows you to inject conversation context.
"""
from utils.cat_client import cat_adapter
if request.collection not in ['declarative', 'episodic']:
return JSONResponse(status_code=400, content={"success": False, "error": "Collection must be 'declarative' or 'episodic'"})
# Create the memory point
result = await cat_adapter.create_memory_point(
collection=request.collection,
content=request.content,
user_id=request.user_id or "manual_admin",
source=request.source or "manual_web_ui",
metadata=request.metadata or {}
)
if result:
return {"success": True, "point_id": result, "collection": request.collection}
else:
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to create memory point"})

100
bot/routes/models.py Normal file
View File

@@ -0,0 +1,100 @@
"""Shared Pydantic request/response models used across route modules."""
from typing import List, Optional
from pydantic import BaseModel
class MoodSetRequest(BaseModel):
mood: str
class ConversationResetRequest(BaseModel):
user_id: str
class CustomPromptRequest(BaseModel):
prompt: str
class ServerConfigRequest(BaseModel):
guild_id: int
guild_name: str
autonomous_channel_id: int
autonomous_channel_name: str
bedtime_channel_ids: List[int] = None
enabled_features: List[str] = None
class EvilMoodSetRequest(BaseModel):
mood: str
class LogConfigUpdateRequest(BaseModel):
component: Optional[str] = None
enabled: Optional[bool] = None
enabled_levels: Optional[List[str]] = None
class LogFilterUpdateRequest(BaseModel):
exclude_paths: Optional[List[str]] = None
exclude_status: Optional[List[int]] = None
include_slow_requests: Optional[bool] = True
slow_threshold_ms: Optional[int] = 1000
class BipolarTriggerRequest(BaseModel):
channel_id: str # String to handle large Discord IDs from JS
message_id: str = None # Optional: starting message ID (string)
context: str = "" # Optional: argument theme/context — tells them what to argue about
class ManualCropRequest(BaseModel):
x: int
y: int
width: int
height: int
class DescriptionUpdateRequest(BaseModel):
description: str
class AlbumCropRequest(BaseModel):
x: int
y: int
width: int
height: int
class AlbumDescriptionRequest(BaseModel):
description: str
class BulkDeleteRequest(BaseModel):
entry_ids: List[str]
class ChatMessage(BaseModel):
message: str
model_type: str = "text" # "text" or "vision"
use_system_prompt: bool = True
image_data: Optional[str] = None # Base64 encoded image for vision model
conversation_history: Optional[List[dict]] = None # Previous messages in conversation
mood: str = "neutral" # Miku's mood for this conversation
class MemoryDeleteRequest(BaseModel):
confirmation: str
class MemoryEditRequest(BaseModel):
content: str
metadata: Optional[dict] = None
class MemoryCreateRequest(BaseModel):
content: str
collection: str # 'declarative' or 'episodic'
user_id: Optional[str] = None
source: Optional[str] = None
metadata: Optional[dict] = None

193
bot/routes/mood.py Normal file
View File

@@ -0,0 +1,193 @@
"""Mood management routes: DM mood, per-server mood, available moods, test mood."""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import globals
from server_manager import server_manager
from routes.models import MoodSetRequest
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
# ========== DM Mood ==========
@router.get("/mood")
def get_current_mood():
return {"mood": globals.DM_MOOD, "description": globals.DM_MOOD_DESCRIPTION}
@router.post("/mood")
async def set_mood_endpoint(data: MoodSetRequest):
# This endpoint now operates on DM_MOOD
from utils.moods import MOOD_EMOJIS
if data.mood not in MOOD_EMOJIS:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Mood '{data.mood}' not recognized. Available moods: {', '.join(MOOD_EMOJIS.keys())}"})
# Update DM mood (DMs don't have nicknames, so no nickname update needed)
globals.DM_MOOD = data.mood
from utils.moods import load_mood_description
globals.DM_MOOD_DESCRIPTION = load_mood_description(data.mood)
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.mood.dm_mood", data.mood, persist=True)
except Exception as e:
logger.warning(f"Failed to persist mood to config: {e}")
return {"status": "ok", "new_mood": data.mood}
@router.post("/mood/reset")
async def reset_mood_endpoint():
# Reset DM mood to neutral (DMs don't have nicknames, so no nickname update needed)
globals.DM_MOOD = "neutral"
from utils.moods import load_mood_description
globals.DM_MOOD_DESCRIPTION = load_mood_description("neutral")
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.mood.dm_mood", "neutral", persist=True)
except Exception as e:
logger.warning(f"Failed to persist mood reset to config: {e}")
return {"status": "ok", "new_mood": "neutral"}
@router.post("/mood/calm")
def calm_miku_endpoint():
# Calm DM mood to neutral (DMs don't have nicknames, so no nickname update needed)
globals.DM_MOOD = "neutral"
from utils.moods import load_mood_description
globals.DM_MOOD_DESCRIPTION = load_mood_description("neutral")
# Persist to config manager
try:
from config_manager import config_manager
config_manager.set("runtime.mood.dm_mood", "neutral", persist=True)
except Exception as e:
logger.warning(f"Failed to persist mood calm to config: {e}")
return {"status": "ok", "message": "Miku has been calmed down"}
# ========== Per-Server Mood ==========
@router.get("/servers/{guild_id}/mood")
def get_server_mood(guild_id: int):
"""Get current mood for a specific server"""
mood_name, mood_description = server_manager.get_server_mood(guild_id)
return {
"guild_id": guild_id,
"mood": mood_name,
"description": mood_description
}
@router.post("/servers/{guild_id}/mood")
async def set_server_mood_endpoint(guild_id: int, data: MoodSetRequest):
"""Set mood for a specific server"""
# Check if server exists
if guild_id not in server_manager.servers:
logger.warning(f"Server {guild_id} not found in server_manager.servers")
return JSONResponse(status_code=404, content={"status": "error", "message": "Server not found"})
# Check if mood is valid
from utils.moods import MOOD_EMOJIS
if data.mood not in MOOD_EMOJIS:
logger.warning(f"Mood '{data.mood}' not found in MOOD_EMOJIS. Available moods: {list(MOOD_EMOJIS.keys())}")
return JSONResponse(status_code=400, content={"status": "error", "message": f"Mood '{data.mood}' not recognized. Available moods: {', '.join(MOOD_EMOJIS.keys())}"})
success = server_manager.set_server_mood(guild_id, data.mood)
logger.debug(f"Server mood set result: {success}")
if success:
# Update the nickname for this server
from utils.moods import update_server_nickname
logger.debug(f"Updating nickname for server {guild_id}")
globals.client.loop.create_task(update_server_nickname(guild_id))
return {"status": "ok", "new_mood": data.mood, "guild_id": guild_id}
logger.warning(f"set_server_mood returned False for unknown reason")
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to set server mood"})
@router.post("/servers/{guild_id}/mood/reset")
async def reset_server_mood_endpoint(guild_id: int):
"""Reset mood to neutral for a specific server"""
logger.debug(f"Resetting mood for server {guild_id} to neutral")
# Check if server exists
if guild_id not in server_manager.servers:
logger.warning(f"Server {guild_id} not found in server_manager.servers")
return JSONResponse(status_code=404, content={"status": "error", "message": "Server not found"})
logger.debug(f"Server validation passed, calling set_server_mood")
success = server_manager.set_server_mood(guild_id, "neutral")
logger.debug(f"Server mood reset result: {success}")
if success:
# Update the nickname for this server
from utils.moods import update_server_nickname
logger.debug(f"Updating nickname for server {guild_id}")
globals.client.loop.create_task(update_server_nickname(guild_id))
return {"status": "ok", "new_mood": "neutral", "guild_id": guild_id}
logger.warning(f"set_server_mood returned False for unknown reason")
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to reset server mood"})
@router.get("/servers/{guild_id}/mood/state")
def get_server_mood_state(guild_id: int):
"""Get complete mood state for a specific server"""
mood_state = server_manager.get_server_mood_state(guild_id)
if mood_state:
return {"status": "ok", "guild_id": guild_id, "mood_state": mood_state}
return JSONResponse(status_code=404, content={"status": "error", "message": "Server not found"})
# ========== Misc Mood ==========
@router.get("/moods/available")
def get_available_moods():
"""Get list of all available moods"""
from utils.moods import MOOD_EMOJIS
return {"moods": list(MOOD_EMOJIS.keys())}
@router.post("/test/mood/{guild_id}")
async def test_mood_change(guild_id: int, data: MoodSetRequest):
"""Test endpoint for debugging mood changes"""
logger.debug(f"TEST: Testing mood change for server {guild_id} to {data.mood}")
# Check if server exists
if guild_id not in server_manager.servers:
return JSONResponse(status_code=404, content={"status": "error", "message": f"Server {guild_id} not found"})
server_config = server_manager.get_server_config(guild_id)
logger.debug(f"TEST: Server config found: {server_config.guild_name if server_config else 'None'}")
# Try to set mood
success = server_manager.set_server_mood(guild_id, data.mood)
logger.debug(f"TEST: Mood set result: {success}")
if success:
# Try to update nickname
from utils.moods import update_server_nickname
logger.debug(f"TEST: Attempting nickname update...")
try:
await update_server_nickname(guild_id)
logger.debug(f"TEST: Nickname update completed")
except Exception as e:
logger.error(f"TEST: Nickname update failed: {e}")
import traceback
traceback.print_exc()
return {"status": "ok", "message": f"Test mood change completed", "success": success}
return JSONResponse(status_code=500, content={"status": "error", "message": "Mood change failed"})

View File

@@ -0,0 +1,527 @@
"""Profile picture routes: change, crop, album, role color."""
import os
from typing import List
from fastapi import APIRouter, UploadFile, File, Form
from fastapi.responses import FileResponse, JSONResponse
import globals
from routes.models import (
ManualCropRequest, DescriptionUpdateRequest,
AlbumCropRequest, AlbumDescriptionRequest, BulkDeleteRequest,
)
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
# ========== Profile Picture — Core ==========
@router.post("/profile-picture/change")
async def trigger_profile_picture_change(
guild_id: int = None,
file: UploadFile = File(None)
):
"""Change Miku's profile picture. If a file is provided, use it. Otherwise, search Danbooru."""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
from server_manager import server_manager
mood = None
if guild_id is not None:
mood, _ = server_manager.get_server_mood(guild_id)
else:
mood = globals.DM_MOOD
custom_image_bytes = None
if file:
custom_image_bytes = await file.read()
logger.info(f"Received custom image upload ({len(custom_image_bytes)} bytes)")
result = await profile_picture_manager.change_profile_picture(
mood=mood, custom_image_bytes=custom_image_bytes, debug=True
)
if result["success"]:
return {
"status": "ok",
"message": "Profile picture changed successfully",
"source": result["source"],
"metadata": result.get("metadata", {})
}
else:
return JSONResponse(status_code=500, content={
"status": "error",
"message": result.get("error", "Unknown error"),
"source": result["source"]
})
except Exception as e:
logger.error(f"Error in profile picture API: {e}")
import traceback
traceback.print_exc()
return JSONResponse(status_code=500, content={"status": "error", "message": f"Unexpected error: {str(e)}"})
@router.get("/profile-picture/metadata")
async def get_profile_picture_metadata():
"""Get metadata about the current profile picture"""
try:
from utils.profile_picture_manager import profile_picture_manager
metadata = profile_picture_manager.load_metadata()
if metadata:
return {"status": "ok", "metadata": metadata}
else:
return {"status": "ok", "metadata": None, "message": "No metadata found"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/restore-fallback")
async def restore_fallback_profile_picture():
"""Restore the original fallback profile picture"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
success = await profile_picture_manager.restore_fallback()
if success:
return {"status": "ok", "message": "Fallback profile picture restored"}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to restore fallback"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/role-color/custom")
async def set_custom_role_color(hex_color: str = Form(...)):
"""Set a custom role color across all servers"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.set_custom_role_color(hex_color, debug=True)
if result["success"]:
return {
"status": "ok",
"message": f"Role color updated to {result['color']['hex']}",
"color": result["color"]
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/role-color/reset-fallback")
async def reset_role_color_to_fallback():
"""Reset role color to fallback (#86cecb)"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.reset_to_fallback_color(debug=True)
if result["success"]:
return {
"status": "ok",
"message": f"Role color reset to fallback {result['color']['hex']}",
"color": result["color"]
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to reset color"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ========== Profile Picture — Image Serving ==========
@router.get("/profile-picture/image/original")
async def serve_original_profile_picture():
"""Serve the full-resolution original profile picture"""
from utils.profile_picture_manager import profile_picture_manager
path = profile_picture_manager.ORIGINAL_PATH
if not os.path.exists(path):
return JSONResponse(status_code=404, content={"status": "error", "message": "No original image found"})
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
@router.get("/profile-picture/image/current")
async def serve_current_profile_picture():
"""Serve the current cropped profile picture"""
from utils.profile_picture_manager import profile_picture_manager
path = profile_picture_manager.CURRENT_PATH
if not os.path.exists(path):
return JSONResponse(status_code=404, content={"status": "error", "message": "No current image found"})
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
# ========== Profile Picture — Manual Crop Workflow ==========
@router.post("/profile-picture/change-no-crop")
async def trigger_profile_picture_change_no_crop(
guild_id: int = None,
file: UploadFile = File(None)
):
"""Change Miku's profile picture but skip auto-cropping."""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
from server_manager import server_manager
mood = None
if guild_id is not None:
mood, _ = server_manager.get_server_mood(guild_id)
else:
mood = globals.DM_MOOD
custom_image_bytes = None
if file:
custom_image_bytes = await file.read()
logger.info(f"Received custom image for manual crop ({len(custom_image_bytes)} bytes)")
result = await profile_picture_manager.change_profile_picture(
mood=mood, custom_image_bytes=custom_image_bytes, debug=True, skip_crop=True
)
if result["success"]:
return {
"status": "ok",
"message": "Image saved for manual cropping",
"source": result["source"],
"metadata": result.get("metadata", {})
}
else:
return JSONResponse(status_code=500, content={
"status": "error",
"message": result.get("error", "Unknown error"),
"source": result.get("source")
})
except Exception as e:
logger.error(f"Error in change-no-crop API: {e}")
import traceback
traceback.print_exc()
return JSONResponse(status_code=500, content={"status": "error", "message": f"Unexpected error: {str(e)}"})
@router.post("/profile-picture/manual-crop")
async def apply_manual_crop(req: ManualCropRequest):
"""Apply a manual crop to the stored original image"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.manual_crop(
x=req.x, y=req.y, width=req.width, height=req.height, debug=True
)
if result["success"]:
return {
"status": "ok",
"message": "Manual crop applied successfully",
"metadata": result.get("metadata", {})
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/auto-crop")
async def apply_auto_crop():
"""Run intelligent auto-crop on the stored original image"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.auto_crop_only(debug=True)
if result["success"]:
return {
"status": "ok",
"message": "Auto-crop applied successfully",
"metadata": result.get("metadata", {})
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/description")
async def update_profile_picture_description(req: DescriptionUpdateRequest):
"""Update the profile picture description"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.update_description(
description=req.description, reinject_cat=True, debug=True
)
if result["success"]:
return {"status": "ok", "message": "Description updated successfully"}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/regenerate-description")
async def regenerate_profile_picture_description():
"""Re-generate the profile picture description using the vision model"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.regenerate_description(debug=True)
if result["success"]:
return {
"status": "ok",
"message": "Description regenerated successfully",
"description": result["description"]
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/profile-picture/description")
async def get_profile_picture_description():
"""Get the current profile picture description text"""
try:
from utils.profile_picture_manager import profile_picture_manager
description = profile_picture_manager.get_current_description()
return {"status": "ok", "description": description or ""}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ========== Profile Picture — Album / Gallery ==========
@router.get("/profile-picture/album")
async def list_album_entries():
"""List all album entries (newest first)"""
try:
from utils.profile_picture_manager import profile_picture_manager
entries = profile_picture_manager.get_album_entries()
return {"status": "ok", "entries": entries, "count": len(entries)}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/profile-picture/album/disk-usage")
async def get_album_disk_usage():
"""Get album disk usage statistics"""
try:
from utils.profile_picture_manager import profile_picture_manager
usage = profile_picture_manager.get_album_disk_usage()
return {"status": "ok", **usage}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/profile-picture/album/{entry_id}")
async def get_album_entry(entry_id: str):
"""Get metadata for a single album entry"""
try:
from utils.profile_picture_manager import profile_picture_manager
meta = profile_picture_manager.get_album_entry(entry_id)
if meta:
return {"status": "ok", "entry": meta}
else:
return JSONResponse(status_code=404, content={"status": "error", "message": "Album entry not found"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/profile-picture/album/{entry_id}/image/{image_type}")
async def serve_album_image(entry_id: str, image_type: str):
"""Serve an album entry's image (original or cropped)"""
if image_type not in ("original", "cropped"):
return JSONResponse(status_code=400, content={"status": "error", "message": "image_type must be 'original' or 'cropped'"})
try:
from utils.profile_picture_manager import profile_picture_manager
path = profile_picture_manager.get_album_image_path(entry_id, image_type)
if path:
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
else:
return JSONResponse(status_code=404, content={"status": "error", "message": f"No {image_type} image for this entry"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/add")
async def add_to_album(file: UploadFile = File(...)):
"""Add a single image to the album"""
try:
from utils.profile_picture_manager import profile_picture_manager
image_bytes = await file.read()
logger.info(f"Adding image to album ({len(image_bytes)} bytes)")
result = await profile_picture_manager.add_to_album(
image_bytes=image_bytes, source="custom_upload", debug=True
)
if result["success"]:
return {
"status": "ok",
"message": "Image added to album",
"entry_id": result["entry_id"],
"metadata": result.get("metadata", {})
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
logger.error(f"Error adding to album: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/add-batch")
async def add_batch_to_album(files: List[UploadFile] = File(...)):
"""Batch-add multiple images to the album efficiently"""
try:
from utils.profile_picture_manager import profile_picture_manager
images = []
for f in files:
data = await f.read()
images.append({"bytes": data, "source": "custom_upload"})
logger.info(f"Batch adding {len(images)} images to album")
result = await profile_picture_manager.add_batch_to_album(images=images, debug=True)
return {
"status": "ok" if result["success"] else "partial",
"message": f"Added {result['succeeded']}/{result['total']} images",
"succeeded": result["succeeded"],
"failed": result["failed"],
"total": result["total"],
"results": [
{"entry_id": r.get("entry_id"), "success": r["success"], "error": r.get("error")}
for r in result["results"]
]
}
except Exception as e:
logger.error(f"Error in batch album add: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/{entry_id}/set-current")
async def set_album_entry_as_current(entry_id: str):
"""Set an album entry as the current Discord profile picture"""
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.set_album_entry_as_current(
entry_id=entry_id, archive_current=True, debug=True
)
if result["success"]:
return {
"status": "ok",
"message": "Album entry set as current profile picture",
"archived_entry_id": result.get("archived_entry_id")
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/{entry_id}/manual-crop")
async def manual_crop_album_entry(entry_id: str, req: AlbumCropRequest):
"""Manually crop an album entry's original image"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.manual_crop_album_entry(
entry_id=entry_id, x=req.x, y=req.y,
width=req.width, height=req.height, debug=True
)
if result["success"]:
return {
"status": "ok",
"message": "Album entry cropped",
"metadata": result.get("metadata", {})
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/{entry_id}/auto-crop")
async def auto_crop_album_entry(entry_id: str):
"""Auto-crop an album entry using face/saliency detection"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.auto_crop_album_entry(
entry_id=entry_id, debug=True
)
if result["success"]:
return {
"status": "ok",
"message": "Album entry auto-cropped",
"metadata": result.get("metadata", {})
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/{entry_id}/description")
async def update_album_entry_description(entry_id: str, req: AlbumDescriptionRequest):
"""Update an album entry's description"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = await profile_picture_manager.update_album_entry_description(
entry_id=entry_id, description=req.description, debug=True
)
if result["success"]:
return {"status": "ok", "message": "Description updated"}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.delete("/profile-picture/album/{entry_id}")
async def delete_album_entry(entry_id: str):
"""Delete a single album entry"""
try:
from utils.profile_picture_manager import profile_picture_manager
if profile_picture_manager.delete_album_entry(entry_id):
return {"status": "ok", "message": "Album entry deleted"}
else:
return JSONResponse(status_code=404, content={"status": "error", "message": "Album entry not found"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/delete-bulk")
async def bulk_delete_album_entries(req: BulkDeleteRequest):
"""Bulk delete multiple album entries"""
try:
from utils.profile_picture_manager import profile_picture_manager
result = profile_picture_manager.delete_album_entries(req.entry_ids)
return {
"status": "ok",
"message": f"Deleted {result['deleted']}/{result['total']} entries",
**result
}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/profile-picture/album/add-current")
async def add_current_to_album():
"""Archive the current profile picture into the album"""
try:
from utils.profile_picture_manager import profile_picture_manager
entry_id = await profile_picture_manager._save_current_to_album(debug=True)
if entry_id:
return {"status": "ok", "message": "Current PFP archived to album", "entry_id": entry_id}
else:
return JSONResponse(status_code=404, content={"status": "error", "message": "No current PFP to archive"})
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})

138
bot/routes/servers.py Normal file
View File

@@ -0,0 +1,138 @@
"""Server management routes: CRUD, bedtime, repair."""
import os
import json
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import globals
from server_manager import server_manager
from routes.models import ServerConfigRequest
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.get("/servers")
def get_servers():
"""Get all configured servers"""
logger.debug("/servers endpoint called")
logger.debug(f"server_manager.servers keys: {list(server_manager.servers.keys())}")
logger.debug(f"server_manager.servers count: {len(server_manager.servers)}")
config_file = server_manager.config_file
logger.debug(f"Config file path: {config_file}")
if os.path.exists(config_file):
try:
with open(config_file, "r", encoding="utf-8") as f:
config_data = json.load(f)
logger.debug(f"Config file contains: {list(config_data.keys())}")
except Exception as e:
logger.error(f"Failed to read config file: {e}")
else:
logger.warning("Config file does not exist")
servers = []
for server in server_manager.get_all_servers():
server_data = server.to_dict()
server_data['enabled_features'] = list(server_data['enabled_features'])
server_data['guild_id'] = str(server_data['guild_id'])
servers.append(server_data)
logger.debug(f"Adding server to response: {server_data['guild_id']} - {server_data['guild_name']}")
logger.debug(f"Server data type check - guild_id: {type(server_data['guild_id'])}, value: {server_data['guild_id']}")
logger.debug(f"Returning {len(servers)} servers")
return {"servers": servers}
@router.post("/servers")
def add_server(data: ServerConfigRequest):
"""Add a new server configuration"""
enabled_features = set(data.enabled_features) if data.enabled_features else None
success = server_manager.add_server(
guild_id=data.guild_id,
guild_name=data.guild_name,
autonomous_channel_id=data.autonomous_channel_id,
autonomous_channel_name=data.autonomous_channel_name,
bedtime_channel_ids=data.bedtime_channel_ids,
enabled_features=enabled_features
)
if success:
server_manager.stop_all_schedulers()
server_manager.start_all_schedulers(globals.client)
return {"status": "ok", "message": f"Server {data.guild_name} added successfully"}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to add server"})
@router.delete("/servers/{guild_id}")
def remove_server(guild_id: int):
"""Remove a server configuration"""
success = server_manager.remove_server(guild_id)
if success:
return {"status": "ok", "message": "Server removed successfully"}
else:
return JSONResponse(status_code=404, content={"status": "error", "message": "Failed to remove server"})
@router.put("/servers/{guild_id}")
def update_server(guild_id: int, data: dict):
"""Update server configuration"""
success = server_manager.update_server_config(guild_id, **data)
if success:
server_manager.stop_all_schedulers()
server_manager.start_all_schedulers(globals.client)
return {"status": "ok", "message": "Server configuration updated"}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to update server configuration"})
@router.post("/servers/{guild_id}/bedtime-range")
def update_server_bedtime_range(guild_id: int, data: dict):
"""Update server bedtime range configuration"""
logger.debug(f"Updating bedtime range for server {guild_id}: {data}")
required_fields = ['bedtime_hour', 'bedtime_minute', 'bedtime_hour_end', 'bedtime_minute_end']
for field in required_fields:
if field not in data:
return JSONResponse(status_code=400, content={"status": "error", "message": f"Missing required field: {field}"})
try:
bedtime_hour = int(data['bedtime_hour'])
bedtime_minute = int(data['bedtime_minute'])
bedtime_hour_end = int(data['bedtime_hour_end'])
bedtime_minute_end = int(data['bedtime_minute_end'])
if not (0 <= bedtime_hour <= 23) or not (0 <= bedtime_hour_end <= 23):
return JSONResponse(status_code=400, content={"status": "error", "message": "Hours must be between 0 and 23"})
if not (0 <= bedtime_minute <= 59) or not (0 <= bedtime_minute_end <= 59):
return JSONResponse(status_code=400, content={"status": "error", "message": "Minutes must be between 0 and 59"})
except (ValueError, TypeError):
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid time values provided"})
success = server_manager.update_server_config(guild_id, **data)
if success:
job_success = server_manager.update_server_bedtime_job(guild_id, globals.client)
if job_success:
logger.info(f"Bedtime range updated for server {guild_id}")
return {
"status": "ok",
"message": f"Bedtime range updated: {bedtime_hour:02d}:{bedtime_minute:02d} - {bedtime_hour_end:02d}:{bedtime_minute_end:02d}"
}
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Updated config but failed to update scheduler"})
else:
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to update bedtime range"})
@router.post("/servers/repair")
def repair_server_config():
"""Repair corrupted server configuration"""
try:
server_manager.repair_config()
return {"status": "ok", "message": "Server configuration repaired and saved"}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to repair configuration: {e}"})

208
bot/routes/voice.py Normal file
View File

@@ -0,0 +1,208 @@
"""Voice call management routes + helpers."""
import asyncio
from fastapi import APIRouter, Form
from fastapi.responses import JSONResponse
import discord
import globals
from utils.dm_logger import dm_logger
from utils.logger import get_logger
logger = get_logger('api')
router = APIRouter()
@router.post("/voice/call")
async def initiate_voice_call(user_id: str = Form(...), voice_channel_id: str = Form(...)):
"""
Initiate a voice call to a user.
Flow:
1. Start STT and TTS containers
2. Wait for models to load (health check)
3. Join voice channel
4. Send DM with invite to user
5. Wait for user to join (30min timeout)
6. Auto-disconnect 45s after user leaves
"""
logger.info(f"📞 Voice call initiated for user {user_id} in channel {voice_channel_id}")
# Check if bot is running
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
return JSONResponse(status_code=503, content={"success": False, "error": "Bot is not running"})
# Run the voice call setup in the bot's event loop
try:
future = asyncio.run_coroutine_threadsafe(
_initiate_voice_call_impl(user_id, voice_channel_id),
globals.client.loop
)
result = future.result(timeout=90) # 90 second timeout for container warmup
return result
except Exception as e:
logger.error(f"Error initiating voice call: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
async def _initiate_voice_call_impl(user_id: str, voice_channel_id: str):
"""Implementation of voice call initiation that runs in the bot's event loop."""
from utils.container_manager import ContainerManager
from utils.voice_manager import VoiceSessionManager
try:
# Convert string IDs to integers for Discord API
user_id_int = int(user_id)
channel_id_int = int(voice_channel_id)
# Get user and channel
user = await globals.client.fetch_user(user_id_int)
if not user:
return JSONResponse(status_code=404, content={"success": False, "error": "User not found"})
channel = globals.client.get_channel(channel_id_int)
if not channel or not isinstance(channel, discord.VoiceChannel):
return JSONResponse(status_code=404, content={"success": False, "error": "Voice channel not found"})
# Get a text channel for voice operations (use first text channel in guild)
text_channel = None
for ch in channel.guild.text_channels:
if ch.permissions_for(channel.guild.me).send_messages:
text_channel = ch
break
if not text_channel:
return JSONResponse(status_code=404, content={"success": False, "error": "No accessible text channel found"})
# Start containers
logger.info("Starting voice containers...")
containers_started = await ContainerManager.start_voice_containers()
if not containers_started:
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to start voice containers"})
# Start voice session
logger.info(f"Starting voice session in {channel.name}")
session_manager = VoiceSessionManager()
try:
await session_manager.start_session(channel.guild.id, channel, text_channel)
except Exception as e:
await ContainerManager.stop_voice_containers()
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to start voice session: {str(e)}"})
# Set up voice call tracking (use integer ID)
session_manager.active_session.call_user_id = user_id_int
# Generate invite link
invite = await channel.create_invite(
max_age=1800, # 30 minutes
max_uses=1,
reason="Miku voice call"
)
# Send DM to user
try:
# Get LLM to generate a personalized invitation message
from utils.llm import query_llama
invitation_prompt = f"""You're calling {user.name} in voice chat! Generate a cute, excited message inviting them to join you.
Keep it brief (1-2 sentences). Make it feel personal and enthusiastic!"""
invitation_text = await query_llama(
user_prompt=invitation_prompt,
user_id=user.id,
guild_id=None,
response_type="voice_call_invite",
author_name=user.name
)
dm_message = f"📞 **Miku is calling you! Very experimental! Speak clearly, loudly and close to the mic! Expect weirdness!** 📞\n\n{invitation_text}\n\n🎤 Join here: {invite.url}"
sent_message = await user.send(dm_message)
# Log to DM logger
dm_logger.log_user_message(user, sent_message, is_bot_message=True)
logger.info(f"✓ DM sent to {user.name}")
except Exception as e:
logger.error(f"Failed to send DM: {e}")
# Don't fail the whole call if DM fails
# Set up 30min timeout task
session_manager.active_session.call_timeout_task = asyncio.create_task(
_voice_call_timeout_handler(session_manager.active_session, user, channel)
)
return {
"success": True,
"user_id": user_id,
"channel_id": voice_channel_id,
"invite_url": invite.url
}
except Exception as e:
logger.error(f"Error in voice call implementation: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
async def _voice_call_timeout_handler(voice_session, user: discord.User, channel: discord.VoiceChannel):
"""Handle 30min timeout if user doesn't join."""
try:
await asyncio.sleep(1800) # 30 minutes
# Check if user ever joined
if not voice_session.user_has_joined:
logger.info(f"Voice call timeout - user {user.name} never joined")
# End the session (which triggers cleanup)
from utils.voice_manager import VoiceSessionManager
session_manager = VoiceSessionManager()
await session_manager.end_session()
# Stop containers
from utils.container_manager import ContainerManager
await ContainerManager.stop_voice_containers()
# Send timeout DM
try:
timeout_message = "Aww, I guess you couldn't make it to voice chat... Maybe next time! 💙"
sent_message = await user.send(timeout_message)
# Log to DM logger
dm_logger.log_user_message(user, sent_message, is_bot_message=True)
except Exception:
pass
except asyncio.CancelledError:
# User joined in time, normal operation
pass
@router.get("/voice/debug-mode")
def get_voice_debug_mode():
"""Get current voice debug mode status"""
return {
"debug_mode": globals.VOICE_DEBUG_MODE
}
@router.post("/voice/debug-mode")
def set_voice_debug_mode(enabled: bool = Form(...)):
"""Set voice debug mode (shows transcriptions and responses in text channel)"""
globals.VOICE_DEBUG_MODE = enabled
logger.info(f"Voice debug mode set to: {enabled}")
# Persist so it survives restarts
try:
from config_manager import config_manager
config_manager.set("voice.debug_mode", enabled, persist=True)
except Exception:
pass
return {
"status": "ok",
"debug_mode": enabled,
"message": f"Voice debug mode {'enabled' if enabled else 'disabled'}"
}

View File

@@ -12,7 +12,6 @@ from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
import random import random
from datetime import datetime, timedelta
from utils.logger import get_logger from utils.logger import get_logger
logger = get_logger('server') logger = get_logger('server')
@@ -40,7 +39,7 @@ class ServerConfig:
previous_mood_name: str = "neutral" previous_mood_name: str = "neutral"
is_sleeping: bool = False is_sleeping: bool = False
sleepy_responses_left: Optional[int] = None sleepy_responses_left: Optional[int] = None
angry_wakeup_timer: Optional[float] = None # Unused, kept for structural completeness angry_wakeup_timer: Optional[float] = None # TODO: implement angry-wakeup mechanic or remove field
forced_angry_until: Optional[str] = None # ISO format datetime string, or None forced_angry_until: Optional[str] = None # ISO format datetime string, or None
just_woken_up: bool = False just_woken_up: bool = False
@@ -76,7 +75,6 @@ class ServerManager:
self.config_file = config_file self.config_file = config_file
self.servers: Dict[int, ServerConfig] = {} self.servers: Dict[int, ServerConfig] = {}
self.schedulers: Dict[int, AsyncIOScheduler] = {} self.schedulers: Dict[int, AsyncIOScheduler] = {}
self.server_memories: Dict[int, Dict] = {}
self._wakeup_tasks: Dict[int, asyncio.Task] = {} # guild_id -> delayed wakeup task self._wakeup_tasks: Dict[int, asyncio.Task] = {} # guild_id -> delayed wakeup task
self.load_config() self.load_config()
@@ -89,16 +87,15 @@ class ServerManager:
for guild_id_str, server_data in data.items(): for guild_id_str, server_data in data.items():
guild_id = int(guild_id_str) guild_id = int(guild_id_str)
self.servers[guild_id] = ServerConfig.from_dict(server_data) self.servers[guild_id] = ServerConfig.from_dict(server_data)
self.server_memories[guild_id] = {}
logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})") logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})")
# After loading, check if we need to repair the config # After loading, check if we need to repair the config
self.repair_config() self.repair_config()
except Exception as e: except Exception as e:
logger.error(f"Failed to load server config: {e}") logger.error(f"Failed to load server config: {e}")
self._create_default_config() logger.info("Starting with zero servers — add servers via the API or dashboard")
else: else:
self._create_default_config() logger.info("No servers_config.json found — starting with zero servers")
def repair_config(self): def repair_config(self):
"""Repair corrupted configuration data and save it back""" """Repair corrupted configuration data and save it back"""
@@ -124,23 +121,6 @@ class ServerManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to repair config: {e}") logger.error(f"Failed to repair config: {e}")
def _create_default_config(self):
"""Create default configuration for backward compatibility"""
default_server = ServerConfig(
guild_id=759889672804630530,
guild_name="Default Server",
autonomous_channel_id=761014220707332107,
autonomous_channel_name="miku-chat",
bedtime_channel_ids=[761014220707332107],
enabled_features={"autonomous", "bedtime", "monday_video"},
autonomous_interval_minutes=10,
conversation_detection_interval_minutes=3
)
self.servers[default_server.guild_id] = default_server
self.server_memories[default_server.guild_id] = {}
self.save_config()
logger.info("Created default server configuration")
def save_config(self): def save_config(self):
"""Save server configurations to file""" """Save server configurations to file"""
try: try:
@@ -183,7 +163,6 @@ class ServerManager:
) )
self.servers[guild_id] = server self.servers[guild_id] = server
self.server_memories[guild_id] = {}
self.save_config() self.save_config()
logger.info(f"Added new server: {guild_name} (ID: {guild_id})") logger.info(f"Added new server: {guild_name} (ID: {guild_id})")
return True return True
@@ -201,10 +180,6 @@ class ServerManager:
self.schedulers[guild_id].shutdown() self.schedulers[guild_id].shutdown()
del self.schedulers[guild_id] del self.schedulers[guild_id]
# Remove memory
if guild_id in self.server_memories:
del self.server_memories[guild_id]
self.save_config() self.save_config()
logger.info(f"Removed server: {server_name} (ID: {guild_id})") logger.info(f"Removed server: {server_name} (ID: {guild_id})")
return True return True
@@ -231,23 +206,6 @@ class ServerManager:
logger.info(f"Updated config for server: {server.guild_name}") logger.info(f"Updated config for server: {server.guild_name}")
return True return True
def get_server_memory(self, guild_id: int, key: str = None):
"""Get or set server-specific memory"""
if guild_id not in self.server_memories:
self.server_memories[guild_id] = {}
if key is None:
return self.server_memories[guild_id]
return self.server_memories[guild_id].get(key)
def set_server_memory(self, guild_id: int, key: str, value):
"""Set server-specific memory"""
if guild_id not in self.server_memories:
self.server_memories[guild_id] = {}
self.server_memories[guild_id][key] = value
# ========== Mood Management Methods ========== # ========== Mood Management Methods ==========
def get_server_mood(self, guild_id: int) -> tuple[str, str]: def get_server_mood(self, guild_id: int) -> tuple[str, str]:
"""Get current mood name and description for a server""" """Get current mood name and description for a server"""

917
bot/static/css/style.css Normal file
View File

@@ -0,0 +1,917 @@
body {
margin: 0;
display: flex;
font-family: monospace;
background-color: #121212;
color: #fff;
}
.panel {
width: 60%;
padding: 2rem;
box-sizing: border-box;
}
.logs {
width: 40%;
height: 100vh;
background-color: #000;
color: #0f0;
padding: 1rem;
overflow-y: scroll;
font-size: 0.85rem;
border-left: 2px solid #333;
position: relative;
}
#logs-content {
white-space: pre-wrap;
word-break: break-word;
}
.log-line { line-height: 1.4; }
.log-line.log-error { color: #ff6b6b; }
.log-line.log-warning { color: #ffd93d; }
.log-line.log-info { color: #0f0; }
.log-line.log-debug { color: #888; }
.logs-paused-indicator {
position: sticky;
top: 0;
background: rgba(50, 50, 0, 0.9);
color: #ffd93d;
text-align: center;
padding: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
z-index: 10;
display: none;
}
select, button, input {
margin: 0.4rem 0.5rem 0.4rem 0;
padding: 0.4rem;
background: #333;
color: #fff;
border: 1px solid #555;
}
.section {
margin-bottom: 2rem;
}
pre {
white-space: pre-wrap;
background: #1e1e1e;
padding: 1rem;
border: 1px solid #333;
}
h1, h3 {
color: #61dafb;
}
#notification {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #222;
color: #fff;
padding: 1rem;
border: 1px solid #555;
border-radius: 8px;
opacity: 0.95;
display: none;
z-index: 3000;
font-size: 0.9rem;
transition: opacity 0.3s ease;
}
.server-card {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.server-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.server-name {
font-size: 1.2rem;
font-weight: bold;
color: #61dafb;
}
.server-actions {
display: flex;
gap: 0.5rem;
}
.feature-tag {
display: inline-block;
background: #444;
padding: 0.2rem 0.5rem;
margin: 0.2rem;
border-radius: 4px;
font-size: 0.8rem;
}
.add-server-form {
background: #1e1e1e;
border: 1px solid #333;
padding: 1rem;
margin: 1rem 0;
border-radius: 8px;
}
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
}
.form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
}
.checkbox-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dm-users-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.dm-user-card {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 1rem;
transition: all 0.3s ease;
}
.dm-user-card:hover {
border-color: #666;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.dm-user-card h4 {
margin: 0 0 0.5rem 0;
color: #4CAF50;
}
.dm-user-card p {
margin: 0.25rem 0;
font-size: 0.9rem;
}
.dm-user-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Blocked Users Styles */
.blocked-users-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.blocked-user-card {
background: #3d2a2a;
border: 1px solid #664444;
border-radius: 8px;
padding: 1rem;
transition: all 0.3s ease;
}
.blocked-user-card:hover {
border-color: #886666;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.blocked-user-card h4 {
margin: 0 0 0.5rem 0;
color: #ff9800;
}
.blocked-user-card p {
margin: 0.25rem 0;
font-size: 0.9rem;
}
.blocked-user-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Conversation View Styles */
.message-reactions {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.reaction-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
transition: background 0.2s ease;
}
.reaction-item:hover {
background: rgba(255,255,255,0.12);
}
.reaction-emoji {
font-size: 1rem;
}
.reaction-by {
color: #aaa;
font-size: 0.75rem;
}
.reaction-by.bot-reaction {
color: #61dafb;
}
.reaction-by.user-reaction {
color: #ffa726;
}
.attachment {
margin: 0.25rem 0;
}
.delete-message-btn {
opacity: 0.7;
transition: opacity 0.3s ease;
}
.delete-message-btn:hover {
opacity: 1;
}
.dm-user-actions button {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.conversation-view {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 1rem;
}
.conversations-list {
max-height: 600px;
overflow-y: auto;
margin-top: 1rem;
}
.conversation-message {
background: #333;
border: 1px solid #555;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.conversation-message.user-message {
border-left: 4px solid #4CAF50;
}
.conversation-message.bot-message {
border-left: 4px solid #2196F3;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.sender {
font-weight: bold;
}
.timestamp {
color: #888;
font-size: 0.8rem;
}
.message-content {
margin-bottom: 0.5rem;
line-height: 1.4;
}
.message-attachments {
background: #444;
border-radius: 4px;
padding: 0.5rem;
font-size: 0.9rem;
}
.attachment {
margin: 0.25rem 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.attachment a {
color: #4CAF50;
text-decoration: none;
}
.attachment a:hover {
text-decoration: underline;
}
/* Tab styling */
.tab-container {
margin-bottom: 1rem;
}
.tab-buttons {
display: grid;
grid-template-rows: repeat(2, auto);
grid-auto-flow: column;
grid-auto-columns: max-content;
border-bottom: 2px solid #333;
margin-bottom: 1rem;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: #555 #222;
row-gap: 0.05rem;
column-gap: 0.1rem;
padding-bottom: 0.1rem;
}
.tab-buttons::-webkit-scrollbar {
height: 8px;
}
.tab-buttons::-webkit-scrollbar-track {
background: #222;
}
.tab-buttons::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.tab-buttons::-webkit-scrollbar-thumb:hover {
background: #666;
}
.tab-button {
background: #222;
color: #ccc;
border: none;
padding: 0.5rem 1rem;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
white-space: nowrap;
}
.tab-button:hover {
background: #333;
color: #fff;
}
.tab-button.active {
background: #444;
color: #fff;
border-bottom-color: #4CAF50;
}
/* Prompt source toggle buttons */
.prompt-source-btn {
background: #333;
color: #aaa;
}
.prompt-source-btn.active {
background: #4CAF50;
color: #fff;
}
.prompt-source-btn:hover:not(.active) {
background: #444;
color: #ddd;
}
/* Prompt History Section */
#prompt-history-section.collapsed #prompt-history-body {
display: none;
}
#prompt-history-toggle {
user-select: none;
transition: color 0.2s;
}
#prompt-history-toggle:hover {
color: #4CAF50;
}
#prompt-metadata span {
white-space: nowrap;
}
#prompt-metadata .prompt-meta-label {
color: #666;
}
#prompt-metadata .prompt-meta-value {
color: #ccc;
}
#prompt-display pre {
margin: 0;
}
.prompt-subsection-header {
cursor: pointer;
user-select: none;
padding: 0.3rem 0.5rem;
border-radius: 4px;
background: #2a2a2a;
margin: 0.5rem 0 0.25rem 0;
font-size: 0.82rem;
color: #aaa;
transition: background 0.15s;
}
.prompt-subsection-header:hover {
background: #333;
color: #ddd;
}
.prompt-subsection-body.collapsed {
display: none;
}
#prompt-truncate-toggle {
accent-color: #4CAF50;
}
/* 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;
}
.tab-content.active {
display: block;
}
/* Tab loading spinner */
.tab-loading-overlay {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: #888;
font-size: 1rem;
gap: 0.75rem;
}
.tab-loading-overlay .spinner {
width: 24px;
height: 24px;
border: 3px solid #444;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Chat Interface Styles */
.chat-message {
margin-bottom: 1rem;
padding: 1rem;
border-radius: 8px;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-message.user-message {
background: #2a3a4a;
border-left: 4px solid #4CAF50;
margin-left: 2rem;
}
.chat-message.assistant-message {
background: #3a2a3a;
border-left: 4px solid #61dafb;
margin-right: 2rem;
}
.chat-message.error-message {
background: #4a2a2a;
border-left: 4px solid #f44336;
}
.chat-message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.chat-message-sender {
font-weight: bold;
color: #61dafb;
}
.chat-message.user-message .chat-message-sender {
color: #4CAF50;
}
.chat-message-time {
color: #888;
font-size: 0.8rem;
}
.chat-message-content {
color: #ddd;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.chat-typing-indicator {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.5rem;
}
.chat-typing-indicator span {
width: 8px;
height: 8px;
background: #61dafb;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.chat-typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.chat-typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.7; }
30% { transform: translateY(-10px); opacity: 1; }
}
#chat-messages::-webkit-scrollbar {
width: 8px;
}
#chat-messages::-webkit-scrollbar-track {
background: #1e1e1e;
}
#chat-messages::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
#chat-messages::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* Evil Mode Styles */
body.evil-mode h1, body.evil-mode h3 {
color: #ff4444;
}
body.evil-mode .tab-button.active {
border-bottom-color: #ff4444;
}
body.evil-mode #evil-mode-toggle {
background: #ff4444;
border-color: #ff4444;
color: #000;
}
body.evil-mode .server-name {
color: #ff4444;
}
body.evil-mode .chat-message-sender {
color: #ff4444;
}
body.evil-mode .chat-message.assistant-message {
border-left-color: #ff4444;
}
body.evil-mode #notification {
border-color: #ff4444;
}
/* Override any blue status text in evil mode */
body.evil-mode [style*="color: #007bff"],
body.evil-mode [style*="color: rgb(0, 123, 255)"] {
color: #ff4444 !important;
}
/* Bipolar Mode Styles */
#bipolar-section {
transition: all 0.3s ease;
}
#bipolar-section h3 {
margin-top: 0;
}
#bipolar-mode-toggle.bipolar-active {
background: #9932CC !important;
border-color: #9932CC !important;
}
/* Responsive breakpoints */
@media (max-width: 1200px) {
.panel { width: 55%; padding: 1.5rem; }
.logs { width: 45%; }
}
@media (max-width: 1024px) {
body { flex-direction: column; }
.panel { width: 100%; padding: 1.5rem; }
.logs {
width: 100%;
height: 300px;
border-left: none;
border-top: 2px solid #333;
}
}
@media (max-width: 768px) {
.panel { padding: 1rem; }
.tab-buttons {
grid-template-rows: none;
grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
.tab-button { font-size: 0.85rem; padding: 0.4rem 0.6rem; }
}
@media (max-width: 480px) {
.panel { padding: 0.5rem; }
.tab-buttons { grid-template-columns: 1fr 1fr; }
.tab-button { font-size: 0.8rem; padding: 0.35rem 0.5rem; }
h1 { font-size: 1.2rem; }
}
/* Profile Picture Tab Styles */
.pfp-preview-container {
display: flex;
gap: 2rem;
margin: 1.5rem 0;
align-items: flex-start;
flex-wrap: wrap;
}
.pfp-preview-box {
text-align: center;
}
.pfp-preview-box img {
max-width: 400px;
max-height: 400px;
border: 2px solid #444;
border-radius: 8px;
background: #1e1e1e;
}
.pfp-preview-box .label {
display: block;
margin-bottom: 0.5rem;
color: #aaa;
font-size: 0.9rem;
}
.pfp-crop-container {
max-width: 100%;
max-height: 550px;
background: #111;
border: 2px solid #555;
border-radius: 8px;
overflow: hidden;
margin: 1rem 0;
}
.pfp-crop-container img {
display: block;
max-width: 100%;
}
.crop-mode-toggle {
display: flex;
gap: 1.5rem;
margin: 1rem 0;
align-items: center;
}
.crop-mode-toggle label {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
color: #ccc;
}
.crop-mode-toggle input[type="radio"] {
accent-color: #4CAF50;
}
.pfp-description-editor {
width: 100%;
min-height: 120px;
background: #1e1e1e;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
padding: 0.75rem;
font-family: monospace;
font-size: 0.9rem;
resize: vertical;
}
.pfp-description-editor:focus {
border-color: #61dafb;
outline: none;
}
/* Album / Gallery grid */
.album-section {
margin: 1.5rem 0;
padding: 1rem;
background: #1a1a2e;
border: 1px solid #444;
border-radius: 8px;
}
.album-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.album-header h4 { margin: 0; }
.album-toolbar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
margin: 0.75rem 0;
}
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
max-height: 480px;
overflow-y: auto;
padding: 0.25rem;
}
.album-card {
position: relative;
border: 2px solid #444;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
background: #111;
}
.album-card:hover { border-color: #61dafb; }
.album-card.selected { border-color: #4CAF50; box-shadow: 0 0 8px rgba(76,175,80,0.4); }
.album-card.checked { border-color: #ff9800; }
.album-card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.album-card .album-check {
position: absolute;
top: 4px;
left: 4px;
z-index: 2;
accent-color: #ff9800;
}
.album-card .album-card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.7);
padding: 2px 4px;
font-size: 0.7rem;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-card .color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid #888;
vertical-align: middle;
margin-right: 3px;
}
.album-detail {
margin-top: 1rem;
padding: 1rem;
background: #222;
border: 1px solid #555;
border-radius: 8px;
}
.album-detail-previews {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
align-items: flex-start;
margin: 1rem 0;
}
.album-detail-previews .pfp-preview-box img {
max-width: 300px;
max-height: 300px;
}
.album-disk-usage {
font-size: 0.8rem;
color: #888;
margin-left: auto;
}

File diff suppressed because it is too large Load Diff

432
bot/static/js/actions.js Normal file
View File

@@ -0,0 +1,432 @@
// ============================================================================
// Miku Control Panel — Actions Module
// Autonomous actions, manual actions, custom prompts, reactions
// ============================================================================
// ===== Autonomous Actions =====
async function triggerAutonomous(actionType) {
const selectedServer = document.getElementById('server-select').value;
if (!actionType) {
showNotification('No action type specified', 'error');
return;
}
try {
let endpoint = `/autonomous/${actionType}`;
if (selectedServer !== 'all') {
endpoint += `?guild_id=${selectedServer}`;
}
const result = await apiCall(endpoint, 'POST');
showNotification(result.message || 'Action triggered successfully');
} catch (error) {
console.error('Failed to trigger autonomous action:', error);
}
}
function toggleEngageSubmenu() {
const submenu = document.getElementById('engage-submenu');
submenu.style.display = submenu.style.display === 'none' ? 'block' : 'none';
}
async function triggerEngageUser() {
const selectedServer = document.getElementById('server-select').value;
const userId = document.getElementById('engage-user-id').value.trim();
const engageType = document.querySelector('input[name="engage-type"]:checked').value;
try {
let endpoint = '/autonomous/engage';
const params = new URLSearchParams();
if (selectedServer !== 'all') {
params.append('guild_id', selectedServer);
}
if (userId) {
params.append('user_id', userId);
}
if (engageType !== 'random') {
params.append('engagement_type', engageType);
}
params.append('manual_trigger', 'true');
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const result = await apiCall(endpoint, 'POST');
showNotification(result.message || 'Engagement triggered successfully');
} catch (error) {
console.error('Failed to trigger user engagement:', error);
}
}
function toggleTweetSubmenu() {
const submenu = document.getElementById('tweet-submenu');
submenu.style.display = submenu.style.display === 'none' ? 'block' : 'none';
}
async function triggerShareTweet() {
const selectedServer = document.getElementById('server-select').value;
const tweetUrl = document.getElementById('tweet-url').value.trim();
if (tweetUrl) {
const validDomains = ['x.com', 'twitter.com', 'fxtwitter.com'];
let isValid = false;
try {
const urlObj = new URL(tweetUrl);
const hostname = urlObj.hostname.toLowerCase();
isValid = validDomains.some(domain => hostname === domain || hostname.endsWith('.' + domain));
} catch (e) {}
if (!isValid) {
showNotification('Invalid tweet URL. Must be from x.com, twitter.com, or fxtwitter.com', 'error');
return;
}
}
try {
let endpoint = '/autonomous/tweet';
const params = new URLSearchParams();
if (selectedServer !== 'all') {
params.append('guild_id', selectedServer);
}
if (tweetUrl) {
params.append('tweet_url', tweetUrl);
}
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const result = await apiCall(endpoint, 'POST');
showNotification(result.message || 'Tweet share triggered successfully');
} catch (error) {
console.error('Failed to trigger tweet share:', error);
}
}
// ===== Manual Actions =====
async function forceSleep() {
try {
await apiCall('/sleep', 'POST');
showNotification('Miku is now sleeping');
} catch (error) {
console.error('Failed to force sleep:', error);
}
}
async function wakeUp() {
try {
await apiCall('/wake', 'POST');
showNotification('Miku is now awake');
} catch (error) {
console.error('Failed to wake up:', error);
}
}
async function sendBedtime() {
const selectedServer = document.getElementById('manual-server-select').value;
console.log('🛏️ sendBedtime() called');
console.log('🛏️ Selected server value:', selectedServer);
try {
let endpoint = '/bedtime';
if (selectedServer !== 'all') {
console.log('🛏️ Using guild_id (as string):', selectedServer);
endpoint += `?guild_id=${selectedServer}`;
}
console.log('🛏️ Final endpoint:', endpoint);
const result = await apiCall(endpoint, 'POST');
showNotification(result.message || 'Bedtime reminder sent successfully');
} catch (error) {
console.error('Failed to send bedtime reminder:', error);
}
}
async function resetConversation() {
const userId = prompt('Enter user ID to reset conversation for:');
if (userId) {
try {
await apiCall('/conversation/reset', 'POST', { user_id: userId });
showNotification('Conversation reset');
} catch (error) {
console.error('Failed to reset conversation:', error);
}
}
}
// ===== Manual Message =====
async function sendManualMessage() {
const message = document.getElementById('manualMessage').value.trim();
const files = document.getElementById('manualAttachment').files;
const targetType = document.getElementById('manual-target-type').value;
const replyMessageId = document.getElementById('manualReplyMessageId').value.trim();
const replyMention = document.querySelector('input[name="manualReplyMention"]:checked').value === 'true';
const useWebhook = document.getElementById('manual-use-webhook').checked;
const webhookPersona = document.querySelector('input[name="webhook-persona"]:checked')?.value || 'miku';
if (!message) {
showNotification('Please enter a message', 'error');
return;
}
if (useWebhook && targetType === 'dm') {
showNotification('Webhooks only work in channels, not DMs', 'error');
return;
}
let targetId, endpoint;
if (targetType === 'dm') {
targetId = document.getElementById('manualUserId').value.trim();
if (!targetId) {
showNotification('Please enter a user ID for DM', 'error');
return;
}
endpoint = `/dm/${targetId}/manual`;
} else {
targetId = document.getElementById('manualChannelId').value.trim();
if (!targetId) {
showNotification('Please enter a channel ID', 'error');
return;
}
endpoint = useWebhook ? '/manual/send-webhook' : '/manual/send';
}
try {
const formData = new FormData();
formData.append('message', message);
if (useWebhook) {
formData.append('persona', webhookPersona);
}
if (replyMessageId) {
formData.append('reply_to_message_id', replyMessageId);
formData.append('mention_author', replyMention);
}
if (targetType === 'dm') {
if (files.length > 0) {
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
}
} else {
formData.append('channel_id', targetId);
if (files.length > 0) {
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
}
}
const response = await fetch(endpoint, {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showNotification('Message sent successfully');
document.getElementById('manualMessage').value = '';
document.getElementById('manualAttachment').value = '';
document.getElementById('manualReplyMessageId').value = '';
if (targetType === 'dm') {
document.getElementById('manualUserId').value = '';
} else {
document.getElementById('manualChannelId').value = '';
}
document.getElementById('manualStatus').textContent = '✅ Message sent successfully!';
document.getElementById('manualStatus').style.color = 'green';
} else {
throw new Error(result.message || 'Failed to send message');
}
} catch (error) {
console.error('Failed to send manual message:', error);
showNotification(error.message || 'Failed to send message', 'error');
document.getElementById('manualStatus').textContent = '❌ Failed to send message';
document.getElementById('manualStatus').style.color = 'red';
}
}
// ===== Custom Prompt =====
function toggleCustomPromptTarget() {
const targetType = document.getElementById('custom-prompt-target-type').value;
const serverSection = document.getElementById('custom-prompt-server-section');
const dmSection = document.getElementById('custom-prompt-dm-section');
if (targetType === 'dm') {
serverSection.style.display = 'none';
dmSection.style.display = 'inline';
} else {
serverSection.style.display = 'inline';
dmSection.style.display = 'none';
}
}
function toggleWebhookOptions() {
const useWebhook = document.getElementById('manual-use-webhook').checked;
const webhookOptions = document.getElementById('webhook-persona-options');
const targetType = document.getElementById('manual-target-type');
if (useWebhook) {
webhookOptions.style.display = 'block';
if (targetType.value === 'dm') {
targetType.value = 'channel';
toggleManualMessageTarget();
}
targetType.options[1].disabled = true;
} else {
webhookOptions.style.display = 'none';
targetType.options[1].disabled = false;
}
}
function toggleManualMessageTarget() {
const targetType = document.getElementById('manual-target-type').value;
const channelSection = document.getElementById('manual-channel-section');
const dmSection = document.getElementById('manual-dm-section');
if (targetType === 'dm') {
channelSection.style.display = 'none';
dmSection.style.display = 'block';
} else {
channelSection.style.display = 'block';
dmSection.style.display = 'none';
}
}
async function sendCustomPrompt() {
const prompt = document.getElementById('customPrompt').value.trim();
const targetType = document.getElementById('custom-prompt-target-type').value;
const files = document.getElementById('customPromptAttachment').files;
if (!prompt) {
showNotification('Please enter a custom prompt', 'error');
return;
}
try {
let endpoint;
if (targetType === 'dm') {
const userId = document.getElementById('custom-prompt-user-id').value.trim();
if (!userId) {
showNotification('Please enter a user ID for DM', 'error');
return;
}
endpoint = `/dm/${userId}/custom`;
} else {
const selectedServer = document.getElementById('custom-prompt-server-select').value;
endpoint = '/autonomous/custom';
if (selectedServer !== 'all') {
endpoint += `?guild_id=${selectedServer}`;
}
}
const result = await apiCall(endpoint, 'POST', { prompt: prompt });
showNotification(result.message || 'Custom prompt sent successfully');
document.getElementById('customPrompt').value = '';
document.getElementById('customPromptAttachment').value = '';
if (targetType === 'dm') {
document.getElementById('custom-prompt-user-id').value = '';
}
document.getElementById('customStatus').textContent = '✅ Custom prompt sent successfully!';
document.getElementById('customStatus').style.color = 'green';
} catch (error) {
console.error('Failed to send custom prompt:', error);
document.getElementById('customStatus').textContent = '❌ Failed to send custom prompt';
document.getElementById('customStatus').style.color = 'red';
}
}
function toggleCustomPrompt() {
const customPromptSection = document.getElementById('custom-prompt-section');
if (customPromptSection) {
customPromptSection.style.display = customPromptSection.style.display === 'none' ? 'block' : 'none';
}
}
// ===== Add Reaction =====
async function addReactionToMessage() {
const messageId = document.getElementById('reactionMessageId').value.trim();
const channelId = document.getElementById('reactionChannelId').value.trim();
const emoji = document.getElementById('reactionEmoji').value.trim();
const statusElement = document.getElementById('reactionStatus');
if (!messageId) {
showNotification('Please enter a message ID', 'error');
statusElement.textContent = '❌ Message ID is required';
statusElement.style.color = 'red';
return;
}
if (!channelId) {
showNotification('Please enter a channel ID', 'error');
statusElement.textContent = '❌ Channel ID is required';
statusElement.style.color = 'red';
return;
}
if (!emoji) {
showNotification('Please enter an emoji', 'error');
statusElement.textContent = '❌ Emoji is required';
statusElement.style.color = 'red';
return;
}
try {
statusElement.textContent = '⏳ Adding reaction...';
statusElement.style.color = '#61dafb';
const formData = new FormData();
formData.append('message_id', messageId);
formData.append('channel_id', channelId);
formData.append('emoji', emoji);
const response = await fetch('/messages/react', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.status === 'ok') {
showNotification(`Reaction ${emoji} added successfully`);
statusElement.textContent = `✅ Reaction ${emoji} added successfully!`;
statusElement.style.color = 'green';
document.getElementById('reactionMessageId').value = '';
document.getElementById('reactionChannelId').value = '';
document.getElementById('reactionEmoji').value = '';
} else {
throw new Error(result.message || 'Failed to add reaction');
}
} catch (error) {
console.error('Failed to add reaction:', error);
showNotification(error.message || 'Failed to add reaction', 'error');
statusElement.textContent = `${error.message || 'Failed to add reaction'}`;
statusElement.style.color = 'red';
}
}

498
bot/static/js/chat.js Normal file
View File

@@ -0,0 +1,498 @@
// ============================================================================
// Miku Control Panel — Chat Interface + Voice Call Module
// ============================================================================
// Toggle image upload section based on model type
function toggleChatImageUpload() {
const modelType = document.querySelector('input[name="chat-model-type"]:checked').value;
const imageUploadSection = document.getElementById('chat-image-upload-section');
if (modelType === 'vision') {
imageUploadSection.style.display = 'block';
} else {
imageUploadSection.style.display = 'none';
}
}
// Load voice debug mode setting from server
async function loadVoiceDebugMode() {
try {
const data = await apiCall('/voice/debug-mode');
const checkbox = document.getElementById('voice-debug-mode');
if (checkbox && data.debug_mode !== undefined) {
checkbox.checked = data.debug_mode;
}
} catch (error) {
console.error('Failed to load voice debug mode:', error);
}
}
// Handle Enter key in chat input
function handleChatKeyPress(event) {
if (event.ctrlKey && event.key === 'Enter') {
event.preventDefault();
sendChatMessage();
}
}
// Clear chat history
function clearChatHistory() {
if (confirm('Are you sure you want to clear all chat messages?')) {
const chatMessages = document.getElementById('chat-messages');
chatMessages.innerHTML = `
<div style="text-align: center; color: #888; padding: 2rem;">
💬 Start chatting with the LLM! Your conversation will appear here.
</div>
`;
// Clear conversation history array
chatConversationHistory = [];
showNotification('Chat history cleared');
}
}
// Add a message to the chat display
function addChatMessage(sender, content, isError = false) {
const chatMessages = document.getElementById('chat-messages');
// Remove welcome message if it exists
const welcomeMsg = chatMessages.querySelector('div[style*="text-align: center"]');
if (welcomeMsg) {
welcomeMsg.remove();
}
const messageDiv = document.createElement('div');
const messageClass = isError ? 'error-message' : (sender === 'You' ? 'user-message' : 'assistant-message');
messageDiv.className = `chat-message ${messageClass}`;
const timestamp = new Date().toLocaleTimeString();
messageDiv.innerHTML = `
<div class="chat-message-header">
<span class="chat-message-sender">${escapeHtml(sender)}</span>
<span class="chat-message-time">${timestamp}</span>
</div>
<div class="chat-message-content"></div>
`;
// Set content via textContent to prevent XSS
messageDiv.querySelector('.chat-message-content').textContent = content;
chatMessages.appendChild(messageDiv);
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
return messageDiv;
}
// Add typing indicator
function showTypingIndicator() {
const chatMessages = document.getElementById('chat-messages');
const typingDiv = document.createElement('div');
typingDiv.id = 'chat-typing-indicator';
typingDiv.className = 'chat-message assistant-message';
typingDiv.innerHTML = `
<div class="chat-message-header">
<span class="chat-message-sender">Miku</span>
<span class="chat-message-time">typing...</span>
</div>
<div class="chat-typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
`;
chatMessages.appendChild(typingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Remove typing indicator
function hideTypingIndicator() {
const typingIndicator = document.getElementById('chat-typing-indicator');
if (typingIndicator) {
typingIndicator.remove();
}
}
// Send chat message with streaming support
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) {
showNotification('Please enter a message', 'error');
return;
}
// Get configuration
const modelType = document.querySelector('input[name="chat-model-type"]:checked').value;
const useSystemPrompt = document.querySelector('input[name="chat-system-prompt"]:checked').value === 'true';
const selectedMood = document.getElementById('chat-mood-select').value;
// Get image data if vision model
let imageData = null;
if (modelType === 'vision') {
const imageFile = document.getElementById('chat-image-file').files[0];
if (imageFile) {
try {
imageData = await readFileAsBase64(imageFile);
// Remove data URL prefix if present
if (imageData.includes(',')) {
imageData = imageData.split(',')[1];
}
} catch (error) {
showNotification('Failed to read image file', 'error');
return;
}
}
}
// Disable send button
const sendBtn = document.getElementById('chat-send-btn');
const originalBtnText = sendBtn.innerHTML;
sendBtn.disabled = true;
sendBtn.innerHTML = '⏳ Sending...';
// Add user message to display
addChatMessage('You', message);
// Clear input
input.value = '';
// Show typing indicator
showTypingIndicator();
try {
// Build user message for history
let userMessageContent;
if (modelType === 'vision' && imageData) {
// Vision model with image - store as multimodal content
userMessageContent = [
{
"type": "text",
"text": message
},
{
"type": "image_url",
"image_url": {
"url": `data:image/jpeg;base64,${imageData}`
}
}
];
} else {
// Text-only message
userMessageContent = message;
}
// Prepare request payload with conversation history
const payload = {
message: message,
model_type: modelType,
use_system_prompt: useSystemPrompt,
image_data: imageData,
conversation_history: chatConversationHistory,
mood: selectedMood
};
// Make streaming request
const response = await fetch('/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Hide typing indicator
hideTypingIndicator();
// Create message element for streaming response
const assistantName = useSystemPrompt ? 'Miku' : 'LLM';
const responseDiv = addChatMessage(assistantName, '');
const contentDiv = responseDiv.querySelector('.chat-message-content');
// Read stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullResponse = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete SSE messages
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
try {
const data = JSON.parse(dataStr);
if (data.error) {
contentDiv.textContent = `❌ Error: ${data.error}`;
responseDiv.classList.add('error-message');
break;
}
if (data.content) {
fullResponse += data.content;
contentDiv.textContent = fullResponse;
// Auto-scroll
const chatMessages = document.getElementById('chat-messages');
chatMessages.scrollTop = chatMessages.scrollHeight;
}
if (data.done) {
break;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
}
// If no response was received, show error
if (!fullResponse) {
contentDiv.textContent = '❌ No response received from LLM';
responseDiv.classList.add('error-message');
} else {
// Add user message to conversation history
chatConversationHistory.push({
role: "user",
content: userMessageContent
});
// Add assistant response to conversation history
chatConversationHistory.push({
role: "assistant",
content: fullResponse
});
console.log('💬 Conversation history updated:', chatConversationHistory.length, 'messages');
}
} catch (error) {
console.error('Chat error:', error);
hideTypingIndicator();
addChatMessage('Error', `Failed to send message: ${error.message}`, true);
showNotification('Failed to send message', 'error');
} finally {
// Re-enable send button
sendBtn.disabled = false;
sendBtn.innerHTML = originalBtnText;
}
}
// Helper function to read file as base64
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ============================================================================
// Voice Call Management Functions
// ============================================================================
async function initiateVoiceCall() {
const userId = document.getElementById('voice-user-id').value.trim();
const channelId = document.getElementById('voice-channel-id').value.trim();
const debugMode = document.getElementById('voice-debug-mode').checked;
// Validation
if (!userId) {
showNotification('Please enter a user ID', 'error');
return;
}
if (!channelId) {
showNotification('Please enter a voice channel ID', 'error');
return;
}
// Check if user IDs are valid (numeric)
if (isNaN(userId) || isNaN(channelId)) {
showNotification('User ID and Channel ID must be numeric', 'error');
return;
}
// Set debug mode
try {
const debugFormData = new FormData();
debugFormData.append('enabled', debugMode);
await fetch('/voice/debug-mode', {
method: 'POST',
body: debugFormData
});
} catch (error) {
console.error('Failed to set debug mode:', error);
}
// Disable button and show status
const callBtn = document.getElementById('voice-call-btn');
const cancelBtn = document.getElementById('voice-call-cancel-btn');
const statusDiv = document.getElementById('voice-call-status');
const statusText = document.getElementById('voice-call-status-text');
callBtn.disabled = true;
statusDiv.style.display = 'block';
cancelBtn.style.display = 'inline-block';
voiceCallActive = true;
try {
statusText.innerHTML = '⏳ Starting STT and TTS containers...';
const formData = new FormData();
formData.append('user_id', userId);
formData.append('voice_channel_id', channelId);
const response = await fetch('/voice/call', {
method: 'POST',
body: formData
});
const data = await response.json();
// Check for HTTP error status (422 validation error, etc.)
if (!response.ok) {
let errorMsg = data.error || data.detail || 'Unknown error';
// Handle FastAPI validation errors
if (data.detail && Array.isArray(data.detail)) {
errorMsg = data.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', ');
}
statusText.innerHTML = `❌ Error: ${errorMsg}`;
showNotification(`Voice call failed: ${errorMsg}`, 'error');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
voiceCallActive = false;
return;
}
if (!data.success) {
statusText.innerHTML = `❌ Error: ${data.error}`;
showNotification(`Voice call failed: ${data.error}`, 'error');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
voiceCallActive = false;
return;
}
// Success!
statusText.innerHTML = `✅ Voice call initiated!<br>User ID: ${data.user_id}<br>Channel: ${data.channel_id}`;
// Show invite link
const inviteDiv = document.getElementById('voice-call-invite-link');
const inviteUrl = document.getElementById('voice-call-invite-url');
inviteUrl.href = data.invite_url;
inviteUrl.textContent = data.invite_url;
inviteDiv.style.display = 'block';
// Add to call history
addVoiceCallToHistory(userId, channelId, data.invite_url);
showNotification('Voice call initiated successfully!', 'success');
// Auto-reset after 5 minutes (call should be done by then or timed out)
setTimeout(() => {
if (voiceCallActive) {
resetVoiceCall();
}
}, 300000); // 5 minutes
} catch (error) {
console.error('Voice call error:', error);
statusText.innerHTML = `❌ Error: ${error.message}`;
showNotification(`Voice call error: ${error.message}`, 'error');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
voiceCallActive = false;
}
}
function cancelVoiceCall() {
resetVoiceCall();
showNotification('Voice call cancelled', 'info');
}
function resetVoiceCall() {
const callBtn = document.getElementById('voice-call-btn');
const cancelBtn = document.getElementById('voice-call-cancel-btn');
const statusDiv = document.getElementById('voice-call-status');
callBtn.disabled = false;
cancelBtn.style.display = 'none';
statusDiv.style.display = 'none';
voiceCallActive = false;
// Clear inputs
document.getElementById('voice-user-id').value = '';
document.getElementById('voice-channel-id').value = '';
}
function addVoiceCallToHistory(userId, channelId, inviteUrl) {
const now = new Date();
const timestamp = now.toLocaleTimeString();
const callEntry = {
userId: userId,
channelId: channelId,
inviteUrl: inviteUrl,
timestamp: timestamp
};
voiceCallHistory.unshift(callEntry); // Add to front
// Keep only last 10 calls
if (voiceCallHistory.length > 10) {
voiceCallHistory.pop();
}
updateVoiceCallHistoryDisplay();
}
function updateVoiceCallHistoryDisplay() {
const historyDiv = document.getElementById('voice-call-history');
if (voiceCallHistory.length === 0) {
historyDiv.innerHTML = '<div style="text-align: center; color: #888;">No calls yet. Start one above!</div>';
return;
}
let html = '';
voiceCallHistory.forEach((call, index) => {
html += `
<div style="background: #242424; padding: 0.75rem; margin-bottom: 0.5rem; border-radius: 4px; border-left: 3px solid #61dafb;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong>${call.timestamp}</strong>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
User: <code>${call.userId}</code> | Channel: <code>${call.channelId}</code>
</div>
</div>
<a href="${call.inviteUrl}" target="_blank" style="color: #61dafb; text-decoration: none; padding: 0.3rem 0.7rem; background: #333; border-radius: 4px; font-size: 0.85rem;">
View Link →
</a>
</div>
</div>
`;
});
historyDiv.innerHTML = html;
}

419
bot/static/js/core.js Normal file
View File

@@ -0,0 +1,419 @@
// ============================================================================
// Miku Control Panel — Core Module
// Global variables, utility functions, tab switching, initialization, polling
// ============================================================================
// Global variables
let currentMood = 'neutral';
let voiceCallActive = false;
let voiceCallHistory = [];
let servers = [];
let evilMode = false;
let bipolarMode = false;
let selectedGPU = 'nvidia';
let chatConversationHistory = [];
let pfpCropper = null;
let albumEntries = [];
let albumSelectedId = null;
let albumChecked = new Set();
let albumCropper = null;
let albumOpen = false;
let activitiesData = null;
let activitiesOpen = false;
let activitiesSections = { normal: false, evil: false };
let activitiesEditing = {};
let activitiesEditCache = {};
let currentEditMemory = null;
let logsAutoScroll = true;
let notificationTimer = null;
let statusInterval = null;
let logsInterval = null;
let argsInterval = null;
let promptInterval = null;
// Mood emoji mapping
const MOOD_EMOJIS = {
"asleep": "💤",
"neutral": "",
"bubbly": "🫧",
"sleepy": "🌙",
"curious": "👀",
"shy": "👉👈",
"serious": "👔",
"excited": "✨",
"melancholy": "🍷",
"flirty": "🫦",
"romantic": "💌",
"irritated": "😒",
"angry": "💢",
"silly": "🪿"
};
// Evil mood emoji mapping
const EVIL_MOOD_EMOJIS = {
"aggressive": "👿",
"cunning": "🐍",
"sarcastic": "😈",
"evil_neutral": "",
"bored": "🥱",
"manic": "🤪",
"jealous": "💚",
"melancholic": "🌑",
"playful_cruel": "🎭",
"contemptuous": "👑"
};
// ============================================================================
// Utility functions
// ============================================================================
function showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.style.display = 'block';
notification.style.opacity = '0.95';
if (type === 'error') {
notification.style.backgroundColor = '#d32f2f';
} else if (type === 'success') {
notification.style.backgroundColor = '#2e7d32';
} else {
notification.style.backgroundColor = '#222';
}
if (notificationTimer) clearTimeout(notificationTimer);
notificationTimer = setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
notification.style.display = 'none';
notificationTimer = null;
}, 300);
}, 3000);
}
async function apiCall(endpoint, method = 'GET', data = null) {
try {
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
}
};
if (data) {
options.body = JSON.stringify(data);
}
const response = await fetch(endpoint, options);
const result = await response.json();
if (response.ok) {
return result;
} else {
throw new Error(result.message || 'API call failed');
}
} catch (error) {
console.error('API call error:', error);
showNotification(error.message, 'error');
throw error;
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeJsonForAttribute(obj) {
return JSON.stringify(obj)
.replace(/&/g, '&amp;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ============================================================================
// Tab switching
// ============================================================================
function switchTab(tabId) {
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active');
});
document.getElementById(tabId).classList.add('active');
const activeBtn = document.querySelector(`.tab-button[data-tab="${tabId}"]`);
if (activeBtn) activeBtn.classList.add('active');
localStorage.setItem('miku-active-tab', tabId);
console.log(`🔄 Switched to ${tabId}`);
if (tabId === 'tab1') {
console.log('🔄 Refreshing figurine subscribers for Server Management tab');
refreshFigurineSubscribers();
}
if (tabId === 'tab3') {
loadStatus();
loadLastPrompt();
}
if (tabId === 'tab6') {
showTabLoading('tab6');
loadAutonomousStats().finally(() => hideTabLoading('tab6'));
}
if (tabId === 'tab9') {
console.log('🧠 Refreshing memory stats for Memories tab');
showTabLoading('tab9');
refreshMemoryStats().finally(() => hideTabLoading('tab9'));
}
if (tabId === 'tab10') {
console.log('📱 Loading DM users for DM Management tab');
showTabLoading('tab10');
loadDMUsers().finally(() => hideTabLoading('tab10'));
}
if (tabId === 'tab11') {
console.log('🖼️ Loading Profile Picture tab');
loadPfpTab();
}
}
function showTabLoading(tabId) {
const tab = document.getElementById(tabId);
if (!tab) return;
if (tab.querySelector('.tab-loading-overlay')) return;
const sections = tab.querySelectorAll('.section');
const hasContent = Array.from(sections).some(s => s.querySelector('[id]')?.innerHTML?.trim());
if (hasContent) return;
const overlay = document.createElement('div');
overlay.className = 'tab-loading-overlay';
overlay.innerHTML = '<div class="spinner"></div> Loading...';
tab.prepend(overlay);
}
function hideTabLoading(tabId) {
const tab = document.getElementById(tabId);
if (!tab) return;
const overlay = tab.querySelector('.tab-loading-overlay');
if (overlay) overlay.remove();
}
// ============================================================================
// Polling
// ============================================================================
function startPolling() {
if (!statusInterval) statusInterval = setInterval(loadStatus, 10000);
if (!logsInterval) logsInterval = setInterval(loadLogs, 5000);
if (!argsInterval) argsInterval = setInterval(loadActiveArguments, 5000);
if (!promptInterval) promptInterval = setInterval(loadPromptHistory, 10000);
}
function stopPolling() {
clearInterval(statusInterval); statusInterval = null;
clearInterval(logsInterval); logsInterval = null;
clearInterval(argsInterval); argsInterval = null;
clearInterval(promptInterval); promptInterval = null;
}
// ============================================================================
// Initialization helpers
// ============================================================================
function initTabState() {
const savedTab = localStorage.getItem('miku-active-tab');
if (savedTab && document.getElementById(savedTab)) {
switchTab(savedTab);
}
}
function initTabWheelScroll() {
const tabButtonsEl = document.querySelector('.tab-buttons');
if (tabButtonsEl) {
tabButtonsEl.addEventListener('wheel', function(e) {
if (e.deltaY !== 0) {
e.preventDefault();
tabButtonsEl.scrollLeft += e.deltaY;
}
}, { passive: false });
}
}
function initVisibilityPolling() {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPolling();
console.log('⏸ Tab hidden — polling paused');
} else {
loadStatus(); loadLogs(); loadActiveArguments(); loadPromptHistory();
startPolling();
console.log('▶️ Tab visible — polling resumed');
}
});
}
function initChatImagePreview() {
const imageInput = document.getElementById('chat-image-file');
if (imageInput) {
imageInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(event) {
const preview = document.getElementById('chat-image-preview');
const previewImg = document.getElementById('chat-image-preview-img');
previewImg.src = event.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
});
}
}
function initModalAccessibility() {
const editModal = document.getElementById('edit-memory-modal');
const createModal = document.getElementById('create-memory-modal');
if (editModal) {
editModal.setAttribute('role', 'dialog');
editModal.setAttribute('aria-modal', 'true');
editModal.setAttribute('aria-label', 'Edit Memory');
editModal.addEventListener('click', function(e) {
if (e.target === this) closeEditMemoryModal();
});
}
if (createModal) {
createModal.setAttribute('role', 'dialog');
createModal.setAttribute('aria-modal', 'true');
createModal.setAttribute('aria-label', 'Create Memory');
createModal.addEventListener('click', function(e) {
if (e.target === this) closeCreateMemoryModal();
});
}
}
function initPromptSourceToggle() {
const saved = localStorage.getItem('miku-prompt-source') || 'all';
document.querySelectorAll('.prompt-source-btn').forEach(btn => btn.classList.remove('active'));
const btnId = saved === 'all' ? 'prompt-src-all' : `prompt-src-${saved}`;
const btn = document.getElementById(btnId);
if (btn) btn.classList.add('active');
}
function initLogsScrollDetection() {
const logsPanel = document.getElementById('logs-panel');
if (!logsPanel) return;
logsPanel.addEventListener('scroll', function() {
const atBottom = logsPanel.scrollHeight - logsPanel.scrollTop - logsPanel.clientHeight < 50;
logsAutoScroll = atBottom;
const banner = document.getElementById('logs-paused-banner');
if (banner) banner.style.display = atBottom ? 'none' : 'block';
});
}
function scrollLogsToBottom() {
const logsPanel = document.getElementById('logs-panel');
if (logsPanel) {
logsPanel.scrollTop = logsPanel.scrollHeight;
logsAutoScroll = true;
const banner = document.getElementById('logs-paused-banner');
if (banner) banner.style.display = 'none';
}
}
// ============================================================================
// Log functions
// ============================================================================
function classifyLogLine(line) {
const upper = line.toUpperCase();
if (upper.includes(' ERROR ') || upper.includes(' CRITICAL ') || upper.startsWith('ERROR') || upper.startsWith('CRITICAL') || upper.includes('TRACEBACK')) return 'log-error';
if (upper.includes(' WARNING ') || upper.startsWith('WARNING')) return 'log-warning';
if (upper.includes(' DEBUG ') || upper.startsWith('DEBUG')) return 'log-debug';
return 'log-info';
}
async function loadLogs() {
try {
const result = await apiCall('/logs');
const logsContent = document.getElementById('logs-content');
const lines = (result || '').split('\n');
logsContent.innerHTML = lines.map(line => {
if (!line.trim()) return '';
const cls = classifyLogLine(line);
return `<div class="log-line ${cls}">${escapeHtml(line)}</div>`;
}).join('');
if (logsAutoScroll) {
scrollLogsToBottom();
}
} catch (error) {
console.error('Failed to load logs:', error);
}
}
// ============================================================================
// Prompt source toggle (shared between core and status modules)
// ============================================================================
function switchPromptSource(source) {
localStorage.setItem('miku-prompt-source', source);
document.querySelectorAll('.prompt-source-btn').forEach(btn => btn.classList.remove('active'));
const btnId = source === 'all' ? 'prompt-src-all' : `prompt-src-${source}`;
const btn = document.getElementById(btnId);
if (btn) btn.classList.add('active');
loadPromptHistory();
}
// ============================================================================
// Profile picture metadata (stub — actual loading in profile.js)
// ============================================================================
async function loadProfilePictureMetadata() {
// Delegated to PFP tab loader — only runs if tab11 is active
}
// ============================================================================
// DOMContentLoaded — main initialization
// ============================================================================
document.addEventListener('DOMContentLoaded', function() {
initTabState();
initTabWheelScroll();
initLogsScrollDetection();
initChatImagePreview();
initModalAccessibility();
initPromptSourceToggle();
loadStatus();
loadServers();
populateMoodDropdowns();
loadLastPrompt();
loadLogs();
checkEvilModeStatus();
checkBipolarModeStatus();
checkGPUStatus();
refreshLanguageStatus();
refreshFigurineSubscribers();
loadProfilePictureMetadata();
loadVoiceDebugMode();
initVisibilityPolling();
startPolling();
// Modal keyboard close handler
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const editModal = document.getElementById('edit-memory-modal');
const createModal = document.getElementById('create-memory-modal');
if (editModal && editModal.style.display !== 'none') closeEditMemoryModal();
if (createModal && createModal.style.display !== 'none') closeCreateMemoryModal();
}
});
});

548
bot/static/js/dm.js Normal file
View File

@@ -0,0 +1,548 @@
// ============================================================================
// Miku Control Panel — DM Management Module
// ============================================================================
async function loadDMUsers() {
try {
const result = await apiCall('/dms/users');
displayDMUsers(result.users);
} catch (error) {
console.error('Failed to load DM users:', error);
}
}
function displayDMUsers(users) {
const container = document.getElementById('dm-users-list');
if (!users || users.length === 0) {
container.innerHTML = '<p>No DM conversations found.</p>';
return;
}
let html = '<div class="dm-users-grid">';
users.forEach(user => {
console.log(`👤 Processing user: ${user.username} (ID: ${user.user_id})`);
const lastMessage = user.last_message ?
`Last: ${user.last_message.content}` :
'No messages yet';
const lastTime = user.last_message ?
new Date(user.last_message.timestamp).toLocaleString() :
'Never';
html += `
<div class="dm-user-card">
<h4>👤 ${user.username}</h4>
<p><strong>ID:</strong> ${user.user_id}</p>
<p><strong>Total Messages:</strong> ${user.total_messages}</p>
<p><strong>User Messages:</strong> ${user.user_messages}</p>
<p><strong>Bot Messages:</strong> ${user.bot_messages}</p>
<p><strong>Last Activity:</strong> ${lastTime}</p>
<p><strong>Last Message:</strong> ${lastMessage}</p>
<div class="dm-user-actions">
<button class="view-chat-btn" data-user-id="${user.user_id}">💬 View Chat</button>
<button class="analyze-user-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #9c27b0;">📊 Analyze</button>
<button class="export-dms-btn" data-user-id="${user.user_id}">📤 Export</button>
<button class="block-user-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #ff9800;">🚫 Block</button>
<button class="delete-all-dms-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #f44336;">🗑️ Delete All</button>
<button class="delete-user-completely-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #d32f2f;">💀 Delete User</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
// Add event listeners after HTML is inserted
addDMUserEventListeners();
}
function addDMUserEventListeners() {
// Add event listeners for view chat buttons
document.querySelectorAll('.view-chat-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.getAttribute('data-user-id');
console.log(`🎯 View chat clicked for user ID: ${userId} (type: ${typeof userId})`);
viewUserConversations(userId);
});
});
// Add event listeners for export buttons
document.querySelectorAll('.export-dms-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.getAttribute('data-user-id');
console.log(`🎯 Export clicked for user ID: ${userId} (type: ${typeof userId})`);
exportUserDMs(userId);
});
});
// Add event listeners for analyze buttons
document.querySelectorAll('.analyze-user-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.getAttribute('data-user-id');
const username = this.getAttribute('data-username');
console.log(`🎯 Analyze clicked for user ID: ${userId} (type: ${typeof userId})`);
analyzeUserInteraction(userId, username);
});
});
// Add event listeners for block buttons
document.querySelectorAll('.block-user-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.getAttribute('data-user-id');
const username = this.getAttribute('data-username');
console.log(`🎯 Block clicked for user ID: ${userId} (type: ${typeof userId})`);
blockUser(userId, username);
});
});
// Add event listeners for delete all DMs buttons
document.querySelectorAll('.delete-all-dms-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.getAttribute('data-user-id');
const username = this.getAttribute('data-username');
console.log(`🎯 Delete all DMs clicked for user ID: ${userId} (type: ${typeof userId})`);
deleteAllUserConversations(userId, username);
});
});
// Add event listeners for delete user completely buttons
document.querySelectorAll('.delete-user-completely-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.getAttribute('data-user-id');
const username = this.getAttribute('data-username');
console.log(`🎯 Delete user completely clicked for user ID: ${userId} (type: ${typeof userId})`);
deleteUserCompletely(userId, username);
});
});
}
async function viewUserConversations(userId) {
try {
// Ensure userId is always treated as a string
const userIdStr = String(userId);
console.log(`🔍 Loading conversations for user ${userIdStr} (type: ${typeof userIdStr})`);
console.log(`🔍 Original userId: ${userId} (type: ${typeof userId})`);
console.log(`🔍 userIdStr: ${userIdStr} (type: ${typeof userIdStr})`);
const result = await apiCall(`/dms/users/${userIdStr}/conversations?limit=100`);
console.log('📡 API Response:', result);
console.log('📡 API URL called:', `/dms/users/${userIdStr}/conversations?limit=100`);
if (result.conversations && result.conversations.length > 0) {
console.log(`✅ Found ${result.conversations.length} conversations`);
displayUserConversations(userIdStr, result.conversations);
} else {
console.log('⚠️ No conversations found in response');
showNotification('No conversations found for this user', 'info');
// Go back to user list
loadDMUsers();
}
} catch (error) {
console.error('Failed to load user conversations:', error);
}
}
function displayUserConversations(userId, conversations) {
console.log(`🎨 Displaying conversations for user ${userId}:`, conversations);
// Create a modal or expand the user card to show conversations
const container = document.getElementById('dm-users-list');
let html = `
<div class="conversation-view">
<button onclick="loadDMUsers()" style="margin-bottom: 1rem;">← Back to DM Users</button>
<h4>💬 Conversations with User ${userId}</h4>
<div class="conversations-list">
`;
if (!conversations || conversations.length === 0) {
html += '<p>No conversations found for this user.</p>';
} else {
conversations.forEach((msg, index) => {
console.log(`📝 Processing message ${index}:`, msg);
const timestamp = new Date(msg.timestamp).toLocaleString();
const sender = msg.is_bot_message ? '🤖 Miku' : '👤 User';
const content = msg.content || '[No text content]';
const messageId = msg.message_id || msg.timestamp; // Use message_id or timestamp as identifier
const escapedContent = content.replace(/'/g, "\\'").replace(/"/g, '\\"');
// Debug: Log message details
console.log(`📝 Message ${index}: id=${messageId}, is_bot=${msg.is_bot_message}, content="${content.substring(0, 30)}..."`);
// Only show delete button for bot messages (Miku can only delete her own messages)
const deleteButton = msg.is_bot_message ?
`<button class="delete-message-btn" onclick="deleteConversation('${userId}', '${messageId}', '${escapedContent}')"
style="background: #f44336; color: white; border: none; padding: 2px 6px; font-size: 12px; border-radius: 3px; margin-left: 10px;"
title="Delete this Miku message (ID: ${messageId})">
🗑️ Delete
</button>` : '';
html += `
<div class="conversation-message ${msg.is_bot_message ? 'bot-message' : 'user-message'}">
<div class="message-header">
<span class="sender">${sender}</span>
<span class="timestamp">${timestamp}</span>
${deleteButton}
</div>
<div class="message-content">${content}</div>
${msg.attachments && msg.attachments.length > 0 ? `
<div class="message-attachments">
<strong>📎 Attachments:</strong>
${msg.attachments.map(att => `
<div class="attachment">
- ${att.filename} (${att.size} bytes)
<a href="${att.url}" target="_blank">🔗 View</a>
</div>
`).join('')}
</div>
` : ''}
${msg.reactions && msg.reactions.length > 0 ? `
<div class="message-reactions">
${msg.reactions.map(reaction => {
const reactionTime = new Date(reaction.added_at).toLocaleString();
const reactorType = reaction.is_bot ? 'bot-reaction' : 'user-reaction';
const reactorLabel = reaction.is_bot ? '🤖 Miku' : `👤 ${reaction.reactor_name}`;
return `
<div class="reaction-item" title="${reactorLabel} reacted at ${reactionTime}">
<span class="reaction-emoji">${reaction.emoji}</span>
<span class="reaction-by ${reactorType}">${reactorLabel}</span>
</div>
`;
}).join('')}
</div>
` : ''}
</div>
`;
});
}
html += `
</div>
</div>
`;
console.log('🎨 Generated HTML:', html);
container.innerHTML = html;
}
async function exportUserDMs(userId) {
try {
// Ensure userId is always treated as a string
const userIdStr = String(userId);
await apiCall(`/dms/users/${userIdStr}/export?format=txt`);
showNotification(`DM export completed for user ${userIdStr}`);
// You could trigger a download here if the file is accessible
} catch (error) {
console.error('Failed to export user DMs:', error);
}
}
async function deleteUserDMs(userId) {
// Ensure userId is always treated as a string
const userIdStr = String(userId);
if (!confirm(`Are you sure you want to delete all DM logs for user ${userIdStr}? This action cannot be undone.`)) {
return;
}
try {
await apiCall(`/dms/users/${userIdStr}`, 'DELETE');
showNotification(`Deleted DM logs for user ${userIdStr}`);
loadDMUsers(); // Refresh the list
} catch (error) {
console.error('Failed to delete user DMs:', error);
}
}
// ========== User Blocking & Advanced Deletion Functions ==========
async function blockUser(userId, username) {
const userIdStr = String(userId);
if (!confirm(`Are you sure you want to block ${username} (${userIdStr}) from sending DMs to Miku?`)) {
return;
}
try {
await apiCall(`/dms/users/${userIdStr}/block`, 'POST');
showNotification(`${username} has been blocked from sending DMs`);
loadDMUsers(); // Refresh the list
} catch (error) {
console.error('Failed to block user:', error);
}
}
async function unblockUser(userId, username) {
const userIdStr = String(userId);
try {
await apiCall(`/dms/users/${userIdStr}/unblock`, 'POST');
showNotification(`${username} has been unblocked`);
loadBlockedUsers(); // Refresh blocked users list
} catch (error) {
console.error('Failed to unblock user:', error);
}
}
async function deleteAllUserConversations(userId, username) {
const userIdStr = String(userId);
if (!confirm(`⚠️ DELETE ALL CONVERSATIONS with ${username} (${userIdStr})?\n\nThis will:\n• Delete ALL Miku messages from Discord DM\n• Clear all conversation logs\n• Keep the user record\n\nThis action CANNOT be undone!\n\nClick OK to confirm deletion.`)) {
return;
}
try {
await apiCall(`/dms/users/${userIdStr}/conversations/delete-all`, 'POST');
showNotification(`Bulk deletion queued for ${username} (deleting all Miku messages from Discord and logs)`);
setTimeout(() => {
loadDMUsers(); // Refresh after a delay to allow deletion to process
}, 2000);
} catch (error) {
console.error('Failed to delete conversations:', error);
}
}
async function deleteUserCompletely(userId, username) {
const userIdStr = String(userId);
if (!confirm(`🚨 COMPLETELY DELETE USER ${username} (${userIdStr})?\n\nThis will:\n• Delete ALL conversation history\n• Delete the entire user log file\n• Remove ALL traces of this user\n\nThis action is PERMANENT and CANNOT be undone!\n\nType "${username}" below to confirm:`)) {
return;
}
const confirmName = prompt(`Type the username "${username}" to confirm complete deletion:`);
if (confirmName !== username) {
showNotification('Deletion cancelled - username did not match', 'error');
return;
}
try {
await apiCall(`/dms/users/${userIdStr}/delete-completely`, 'POST');
showNotification(`${username} has been completely deleted from the system`);
loadDMUsers(); // Refresh the list
} catch (error) {
console.error('Failed to delete user completely:', error);
}
}
async function deleteConversation(userId, conversationId, messageContent) {
const userIdStr = String(userId);
if (!confirm(`Delete this Miku message from Discord and logs?\n\n"${messageContent.substring(0, 100)}${messageContent.length > 100 ? '...' : ''}"\n\nThis will:\n• Delete the message from Discord DM\n• Remove it from conversation logs\n\nNote: Only Miku's messages can be deleted.\nThis action cannot be undone.`)) {
return;
}
try {
await apiCall(`/dms/users/${userIdStr}/conversations/${conversationId}/delete`, 'POST');
showNotification('Miku message deletion queued (deleting from both Discord and logs)');
setTimeout(() => {
viewUserConversations(userId); // Refresh after a short delay to allow deletion to process
}, 1000);
} catch (error) {
console.error('Failed to delete conversation:', error);
}
}
async function analyzeUserInteraction(userId, username) {
const userIdStr = String(userId);
if (!confirm(`Run DM interaction analysis for ${username}?\n\nThis will:\n• Analyze their messages from the last 24 hours\n• Generate a sentiment report\n• Send report to bot owner\n\nMinimum 3 messages required for analysis.`)) {
return;
}
try {
showNotification(`Analyzing ${username}'s interactions...`, 'info');
const result = await apiCall(`/dms/users/${userIdStr}/analyze`, 'POST');
if (result.reported) {
showNotification(`✅ Analysis complete! Report sent to bot owner for ${username}`);
} else {
showNotification(`📊 Analysis complete for ${username} (not enough messages or already reported today)`);
}
} catch (error) {
console.error('Failed to analyze user:', error);
}
}
async function runDailyAnalysis() {
if (!confirm('Run the daily DM interaction analysis now?\n\nThis will:\n• Analyze all DM users from the last 24 hours\n• Report one significant interaction to the bot owner\n• Skip users already reported today\n\nNote: This runs automatically at 2 AM daily.')) {
return;
}
try {
showNotification('Starting DM interaction analysis...', 'info');
await apiCall('/dms/analysis/run', 'POST');
showNotification('✅ DM analysis completed! Check bot owner\'s DMs for any reports.');
} catch (error) {
console.error('Failed to run DM analysis:', error);
}
}
async function viewAnalysisReports() {
try {
showNotification('Loading analysis reports...', 'info');
const result = await apiCall('/dms/analysis/reports?limit=50');
displayAnalysisReports(result.reports);
} catch (error) {
console.error('Failed to load reports:', error);
}
}
function displayAnalysisReports(reports) {
const container = document.getElementById('dm-users-list');
if (!reports || reports.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<p>No analysis reports found yet.</p>
<button onclick="loadDMUsers()" style="margin-top: 1rem;">← Back to DM Users</button>
</div>
`;
return;
}
let html = `
<div style="margin-bottom: 1rem;">
<button onclick="loadDMUsers()">← Back to DM Users</button>
<span style="margin-left: 1rem; color: #aaa;">${reports.length} reports found</span>
</div>
<div style="display: grid; gap: 1rem;">
`;
reports.forEach(report => {
const sentimentColor =
report.sentiment_score >= 5 ? '#4caf50' :
report.sentiment_score <= -3 ? '#f44336' :
'#2196f3';
const sentimentEmoji =
report.sentiment_score >= 5 ? '😊' :
report.sentiment_score <= -3 ? '😢' :
'😐';
const timestamp = new Date(report.analyzed_at).toLocaleString();
html += `
<div style="background: #2a2a2a; border-left: 4px solid ${sentimentColor}; padding: 1rem; border-radius: 4px;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<div>
<h4 style="margin: 0 0 0.25rem 0;">${sentimentEmoji} ${report.username}</h4>
<p style="margin: 0; font-size: 0.85rem; color: #aaa;">User ID: ${report.user_id}</p>
</div>
<div style="text-align: right;">
<div style="font-size: 1.2rem; font-weight: bold; color: ${sentimentColor};">
${report.sentiment_score > 0 ? '+' : ''}${report.sentiment_score}/10
</div>
<div style="font-size: 0.75rem; color: #aaa; text-transform: uppercase;">
${report.overall_sentiment}
</div>
</div>
</div>
<div style="margin: 0.75rem 0; padding: 0.75rem; background: #1e1e1e; border-radius: 4px;">
<strong>Miku's Feelings:</strong>
<p style="margin: 0.5rem 0 0 0; font-style: italic;">"${report.your_feelings}"</p>
</div>
${report.notable_moment ? `
<div style="margin: 0.75rem 0; padding: 0.75rem; background: #1e1e1e; border-radius: 4px;">
<strong>Notable Moment:</strong>
<p style="margin: 0.5rem 0 0 0; font-style: italic;">"${report.notable_moment}"</p>
</div>
` : ''}
${report.key_behaviors && report.key_behaviors.length > 0 ? `
<div style="margin: 0.75rem 0;">
<strong>Key Behaviors:</strong>
<ul style="margin: 0.5rem 0 0 0; padding-left: 1.5rem;">
${report.key_behaviors.slice(0, 5).map(b => `<li>${b}</li>`).join('')}
</ul>
</div>
` : ''}
<div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #444; font-size: 0.8rem; color: #aaa;">
<span>📅 ${timestamp}</span>
<span style="margin-left: 1rem;">💬 ${report.message_count} messages analyzed</span>
<span style="margin-left: 1rem;">📄 ${report.filename}</span>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
async function loadBlockedUsers() {
try {
const result = await apiCall('/dms/blocked-users');
// Hide DM users list and show blocked users section
document.getElementById('dm-users-list').style.display = 'none';
document.getElementById('blocked-users-section').style.display = 'block';
displayBlockedUsers(result.blocked_users);
} catch (error) {
console.error('Failed to load blocked users:', error);
}
}
function hideBlockedUsers() {
// Show DM users list and hide blocked users section
document.getElementById('dm-users-list').style.display = 'block';
document.getElementById('blocked-users-section').style.display = 'none';
loadDMUsers(); // Refresh DM users
}
function displayBlockedUsers(blockedUsers) {
const container = document.getElementById('blocked-users-list');
if (!blockedUsers || blockedUsers.length === 0) {
container.innerHTML = '<p>No blocked users.</p>';
return;
}
let html = '<div class="blocked-users-grid">';
blockedUsers.forEach(user => {
html += `
<div class="blocked-user-card">
<h4>🚫 ${user.username}</h4>
<p><strong>ID:</strong> ${user.user_id}</p>
<p><strong>Blocked:</strong> ${new Date(user.blocked_at).toLocaleString()}</p>
<p><strong>Blocked by:</strong> ${user.blocked_by}</p>
<div class="blocked-user-actions">
<button onclick="unblockUser('${user.user_id}', '${user.username}')" style="background: #4caf50;">✅ Unblock</button>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
async function exportAllDMs() {
try {
const result = await apiCall('/dms/users');
let exportCount = 0;
for (const user of (result.users || [])) {
try {
await exportUserDMs(user.user_id);
exportCount++;
} catch (e) {
console.error(`Failed to export DMs for user ${user.user_id}:`, e);
}
}
showNotification(`Exported DMs for ${exportCount} users`);
} catch (error) {
console.error('Failed to export all DMs:', error);
}
}

127
bot/static/js/image-gen.js Normal file
View File

@@ -0,0 +1,127 @@
// ============================================================================
// Miku Control Panel — Image Generation Module
// ============================================================================
async function checkImageSystemStatus() {
try {
const statusDisplay = document.getElementById('image-status-display');
statusDisplay.innerHTML = '🔄 Checking system status...';
const result = await apiCall('/image/status');
const workflowStatus = result.workflow_template_exists ? '✅ Found' : '❌ Missing';
const comfyuiStatus = result.comfyui_running ? '✅ Running' : '❌ Not running';
statusDisplay.innerHTML = `
<strong>System Status:</strong>
• Workflow Template (Miku_BasicWorkflow.json): ${workflowStatus}
• ComfyUI Server: ${comfyuiStatus}
${result.comfyui_running ? `• Detected ComfyUI URL: ${result.comfyui_url}` : ''}
<strong>Overall Status:</strong> ${result.ready ? '✅ Ready for image generation' : '⚠️ Setup required'}
${!result.workflow_template_exists ? '⚠️ Place Miku_BasicWorkflow.json in bot directory\n' : ''}${!result.comfyui_running ? '⚠️ Start ComfyUI server on localhost:8188 (bot will auto-detect correct URL)\n' : ''}`;
} catch (error) {
console.error('Failed to check image system status:', error);
document.getElementById('image-status-display').innerHTML = `❌ Error: ${error.message}`;
}
}
async function testImageDetection() {
const message = document.getElementById('detection-test-message').value.trim();
const resultsDiv = document.getElementById('detection-test-results');
if (!message) {
resultsDiv.innerHTML = '❌ Please enter a test message';
resultsDiv.style.color = 'red';
return;
}
try {
resultsDiv.innerHTML = '🔍 Testing detection...';
resultsDiv.style.color = '#4CAF50';
const result = await apiCall('/image/test-detection', 'POST', { message: message });
const detectionIcon = result.is_image_request ? '✅' : '❌';
const detectionText = result.is_image_request ? 'WILL trigger image generation' : 'will NOT trigger image generation';
resultsDiv.innerHTML = `
<strong>Detection Result:</strong> ${detectionIcon} This message ${detectionText}
${result.is_image_request ? `<br><strong>Extracted Prompt:</strong> "${result.extracted_prompt}"` : ''}
<br><strong>Original Message:</strong> "${result.original_message}"`;
resultsDiv.style.color = result.is_image_request ? '#4CAF50' : '#ff9800';
} catch (error) {
console.error('Failed to test image detection:', error);
resultsDiv.innerHTML = `❌ Error: ${error.message}`;
resultsDiv.style.color = 'red';
}
}
async function generateManualImage() {
const prompt = document.getElementById('manual-image-prompt').value.trim();
const statusDiv = document.getElementById('manual-image-status');
const previewDiv = document.getElementById('manual-image-preview');
if (!prompt) {
statusDiv.innerHTML = '❌ Please enter an image prompt';
statusDiv.style.color = 'red';
return;
}
try {
previewDiv.innerHTML = '';
statusDiv.innerHTML = '🎨 Generating image... This may take a few minutes.';
statusDiv.style.color = '#4CAF50';
const result = await apiCall('/image/generate', 'POST', { prompt: prompt });
statusDiv.innerHTML = `✅ Image generated successfully!`;
statusDiv.style.color = '#4CAF50';
if (result.image_path) {
const filename = result.image_path.split('/').pop();
const imageUrl = `/image/view/${encodeURIComponent(filename)}`;
const imgContainer = document.createElement('div');
imgContainer.style.cssText = 'background: #1e1e1e; padding: 1rem; border-radius: 8px; border: 1px solid #333;';
const img = document.createElement('img');
img.src = imageUrl;
img.alt = 'Generated Image';
img.style.cssText = 'max-width: 100%; max-height: 600px; border-radius: 4px; display: block; margin: 0 auto;';
img.onload = function() {
console.log('Image loaded successfully:', imageUrl);
};
img.onerror = function() {
console.error('Failed to load image:', imageUrl);
imgContainer.innerHTML = `
<div style="color: #f44336; padding: 1rem; text-align: center;">
❌ Failed to load image<br>
<span style="font-size: 0.85rem;">Path: ${result.image_path}</span><br>
<span style="font-size: 0.85rem;">URL: ${imageUrl}</span>
</div>
`;
};
imgContainer.appendChild(img);
const pathInfo = document.createElement('div');
pathInfo.style.cssText = 'margin-top: 0.5rem; color: #aaa; font-size: 0.85rem; text-align: center;';
pathInfo.innerHTML = `<strong>File:</strong> ${filename}`;
imgContainer.appendChild(pathInfo);
previewDiv.appendChild(imgContainer);
}
document.getElementById('manual-image-prompt').value = '';
} catch (error) {
console.error('Failed to generate image:', error);
statusDiv.innerHTML = `❌ Error: ${error.message}`;
statusDiv.style.color = 'red';
}
}

446
bot/static/js/memories.js Normal file
View File

@@ -0,0 +1,446 @@
// ============================================================================
// Miku Control Panel — Memory Management Module
// ============================================================================
async function refreshMemoryStats() {
try {
// Fetch Cat status
const statusData = await apiCall('/memory/status');
const indicator = document.getElementById('cat-status-indicator');
const toggleBtn = document.getElementById('cat-toggle-btn');
if (statusData.healthy) {
indicator.innerHTML = `<span style="color: #6fdc6f;">● Connected</span> — ${statusData.url}`;
} else {
indicator.innerHTML = `<span style="color: #ff6b6b;">● Disconnected</span> — ${statusData.url}`;
}
if (statusData.circuit_breaker_active) {
indicator.innerHTML += ` <span style="color: #dcb06f;">(circuit breaker active)</span>`;
}
toggleBtn.textContent = statusData.enabled ? '🐱 Cat: ON' : '😿 Cat: OFF';
toggleBtn.style.background = statusData.enabled ? '#2a7a2a' : '#7a2a2a';
toggleBtn.style.borderColor = statusData.enabled ? '#4a9a4a' : '#9a4a4a';
// Fetch memory stats
const statsData = await apiCall('/memory/stats');
if (statsData.success && statsData.collections) {
const collections = {};
statsData.collections.forEach(c => { collections[c.name] = c.vectors_count; });
document.getElementById('stat-episodic-count').textContent = collections['episodic'] ?? '—';
document.getElementById('stat-declarative-count').textContent = collections['declarative'] ?? '—';
document.getElementById('stat-procedural-count').textContent = collections['procedural'] ?? '—';
} else {
document.getElementById('stat-episodic-count').textContent = '—';
document.getElementById('stat-declarative-count').textContent = '—';
document.getElementById('stat-procedural-count').textContent = '—';
}
} catch (err) {
console.error('Error refreshing memory stats:', err);
document.getElementById('cat-status-indicator').innerHTML = '<span style="color: #ff6b6b;">● Error checking status</span>';
}
}
async function toggleCatIntegration() {
try {
const statusData = await apiCall('/memory/status');
const newState = !statusData.enabled;
const formData = new FormData();
formData.append('enabled', newState);
const res = await fetch('/memory/toggle', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) {
showNotification(`Cheshire Cat ${newState ? 'enabled' : 'disabled'}`, newState ? 'success' : 'info');
refreshMemoryStats();
}
} catch (err) {
showNotification('Failed to toggle Cat integration', 'error');
}
}
async function triggerConsolidation() {
const btn = document.getElementById('consolidate-btn');
const status = document.getElementById('consolidation-status');
const resultDiv = document.getElementById('consolidation-result');
btn.disabled = true;
btn.textContent = '⏳ Running...';
status.textContent = 'Consolidation in progress (this may take a few minutes)...';
resultDiv.style.display = 'none';
try {
const data = await apiCall('/memory/consolidate', 'POST');
if (data.success) {
status.textContent = '✅ Consolidation complete!';
status.style.color = '#6fdc6f';
resultDiv.textContent = data.result || 'Consolidation finished successfully.';
resultDiv.style.display = 'block';
showNotification('Memory consolidation complete', 'success');
refreshMemoryStats();
} else {
status.textContent = '❌ ' + (data.error || 'Consolidation failed');
status.style.color = '#ff6b6b';
}
} catch (err) {
status.textContent = '❌ Error: ' + err.message;
status.style.color = '#ff6b6b';
} finally {
btn.disabled = false;
btn.textContent = '🌙 Run Consolidation';
}
}
async function loadFacts() {
const listDiv = document.getElementById('facts-list');
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading facts...</div>';
try {
const data = await apiCall('/memory/facts');
if (!data.success || data.count === 0) {
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No declarative facts stored yet.</div>';
return;
}
let html = '';
data.facts.forEach((fact, i) => {
const source = fact.metadata?.source || 'unknown';
const when = fact.metadata?.when ? new Date(fact.metadata.when * 1000).toLocaleString() : 'unknown';
const factDataJson = escapeJsonForAttribute(fact);
html += `
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a9955; display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1;">
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(fact.content)}</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>
</div>
<div style="display: flex; gap: 0.3rem; flex-shrink: 0;">
<button data-memory='${factDataJson}' onclick='showEditMemoryModalFromButton(this, "declarative", "${fact.id}")'
style="background: none; border: none; color: #5599cc; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
title="Edit this fact">✏️</button>
<button onclick="deleteMemoryPoint('declarative', '${fact.id}', this)"
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
title="Delete this fact">🗑️</button>
</div>
</div>`;
});
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} facts loaded</div>` + html;
} catch (err) {
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading facts: ${err.message}</div>`;
}
}
async function loadEpisodicMemories() {
const listDiv = document.getElementById('episodic-list');
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading memories...</div>';
try {
const data = await apiCall('/memory/episodic');
if (!data.success || data.count === 0) {
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No episodic memories stored yet.</div>';
return;
}
let html = '';
data.memories.forEach((mem, i) => {
const source = mem.metadata?.source || 'unknown';
const when = mem.metadata?.when ? new Date(mem.metadata.when * 1000).toLocaleString() : 'unknown';
const memDataJson = escapeJsonForAttribute(mem);
html += `
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a5599; display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1;">
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(mem.content)}</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>
</div>
<div style="display: flex; gap: 0.3rem; flex-shrink: 0;">
<button data-memory='${memDataJson}' onclick='showEditMemoryModalFromButton(this, "episodic", "${mem.id}")'
style="background: none; border: none; color: #5599cc; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
title="Edit this memory">✏️</button>
<button onclick="deleteMemoryPoint('episodic', '${mem.id}', this)"
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
title="Delete this memory">🗑️</button>
</div>
</div>`;
});
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} memories loaded</div>` + html;
} catch (err) {
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading memories: ${err.message}</div>`;
}
}
async function deleteMemoryPoint(collection, pointId, btnElement) {
if (!confirm(`Delete this ${collection} memory point?`)) return;
try {
const data = await apiCall(`/memory/point/${collection}/${pointId}`, 'DELETE');
if (data.success) {
// Remove the row from the UI
const row = btnElement.closest('div[style*="margin-bottom"]');
if (row) row.remove();
showNotification('Memory point deleted', 'success');
refreshMemoryStats();
} else {
showNotification('Failed to delete: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
console.error('Failed to delete memory point:', err);
}
}
// Delete All Memories — Multi-step confirmation flow
function onDeleteStep1Change() {
const checked = document.getElementById('delete-checkbox-1').checked;
document.getElementById('delete-step-2').style.display = checked ? 'block' : 'none';
if (!checked) {
document.getElementById('delete-checkbox-2').checked = false;
document.getElementById('delete-step-3').style.display = 'none';
document.getElementById('delete-step-final').style.display = 'none';
document.getElementById('delete-confirmation-input').value = '';
}
}
function onDeleteStep2Change() {
const checked = document.getElementById('delete-checkbox-2').checked;
document.getElementById('delete-step-3').style.display = checked ? 'block' : 'none';
document.getElementById('delete-step-final').style.display = checked ? 'block' : 'none';
if (!checked) {
document.getElementById('delete-confirmation-input').value = '';
updateDeleteButton();
}
}
function onDeleteInputChange() {
updateDeleteButton();
}
function updateDeleteButton() {
const input = document.getElementById('delete-confirmation-input').value;
const expected = "Yes, I am deleting Miku's memories fully.";
const btn = document.getElementById('delete-all-btn');
const match = input === expected;
btn.disabled = !match;
btn.style.cursor = match ? 'pointer' : 'not-allowed';
btn.style.opacity = match ? '1' : '0.5';
}
async function executeDeleteAllMemories() {
const input = document.getElementById('delete-confirmation-input').value;
const expected = "Yes, I am deleting Miku's memories fully.";
if (input !== expected) {
showNotification('Confirmation string does not match', 'error');
return;
}
const btn = document.getElementById('delete-all-btn');
btn.disabled = true;
btn.textContent = '⏳ Deleting...';
try {
const data = await apiCall('/memory/delete', 'POST', { confirmation: input });
if (data.success) {
showNotification('All memories have been permanently deleted', 'success');
resetDeleteFlow();
refreshMemoryStats();
} else {
showNotification('Deletion failed: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
console.error('Failed to delete all memories:', err);
} finally {
btn.disabled = false;
btn.textContent = '🗑️ Permanently Delete All Memories';
}
}
function resetDeleteFlow() {
document.getElementById('delete-checkbox-1').checked = false;
document.getElementById('delete-checkbox-2').checked = false;
document.getElementById('delete-confirmation-input').value = '';
document.getElementById('delete-step-2').style.display = 'none';
document.getElementById('delete-step-3').style.display = 'none';
document.getElementById('delete-step-final').style.display = 'none';
updateDeleteButton();
}
// Memory Edit/Create Modal Functions
// currentEditMemory declared in core.js
function showEditMemoryModalFromButton(button, collection, pointId) {
const memoryJson = button.getAttribute('data-memory');
// Unescape HTML entities back to JSON
const unescapedJson = memoryJson
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
const memory = JSON.parse(unescapedJson);
showEditMemoryModal(collection, pointId, memory);
}
function showEditMemoryModal(collection, pointId, memoryData) {
const memory = typeof memoryData === 'string' ? JSON.parse(memoryData) : memoryData;
currentEditMemory = { collection, pointId, memory };
const modal = document.getElementById('edit-memory-modal');
const contentField = document.getElementById('edit-memory-content');
const sourceField = document.getElementById('edit-memory-source');
contentField.value = memory.content || '';
sourceField.value = memory.metadata?.source || '';
modal.style.display = 'flex';
}
function closeEditMemoryModal() {
document.getElementById('edit-memory-modal').style.display = 'none';
currentEditMemory = null;
}
async function saveMemoryEdit() {
if (!currentEditMemory) return;
const content = document.getElementById('edit-memory-content').value.trim();
const source = document.getElementById('edit-memory-source').value.trim();
if (!content) {
showNotification('Content cannot be empty', 'error');
return;
}
const { collection, pointId } = currentEditMemory;
const saveBtn = document.querySelector('#edit-memory-modal button[onclick="saveMemoryEdit()"]');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const data = await apiCall(`/memory/point/${collection}/${pointId}`, 'PUT', {
content: content,
metadata: { source: source || 'manual_edit' }
});
if (data.success) {
showNotification('Memory updated successfully', 'success');
closeEditMemoryModal();
// Reload the appropriate list
if (collection === 'declarative') {
loadFacts();
} else if (collection === 'episodic') {
loadEpisodicMemories();
}
} else {
showNotification('Failed to update: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
console.error('Failed to save memory edit:', err);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Changes';
}
}
function showCreateMemoryModal(collection) {
const modal = document.getElementById('create-memory-modal');
document.getElementById('create-memory-collection').value = collection;
document.getElementById('create-memory-content').value = '';
document.getElementById('create-memory-user-id').value = '';
document.getElementById('create-memory-source').value = 'manual';
// Update modal title based on collection type
const title = collection === 'declarative' ? 'Add New Fact' : 'Add New Memory';
document.querySelector('#create-memory-modal h3').textContent = title;
modal.style.display = 'flex';
}
function closeCreateMemoryModal() {
document.getElementById('create-memory-modal').style.display = 'none';
}
// Modal keyboard and backdrop close handlers
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const editModal = document.getElementById('edit-memory-modal');
const createModal = document.getElementById('create-memory-modal');
if (editModal && editModal.style.display !== 'none') closeEditMemoryModal();
if (createModal && createModal.style.display !== 'none') closeCreateMemoryModal();
}
});
async function saveNewMemory() {
const collection = document.getElementById('create-memory-collection').value;
const content = document.getElementById('create-memory-content').value.trim();
const userId = document.getElementById('create-memory-user-id').value.trim();
const source = document.getElementById('create-memory-source').value.trim();
if (!content) {
showNotification('Content cannot be empty', 'error');
return;
}
const createBtn = document.querySelector('#create-memory-modal button[onclick="saveNewMemory()"]');
createBtn.disabled = true;
createBtn.textContent = 'Creating...';
try {
const data = await apiCall('/memory/create', 'POST', {
collection: collection,
content: content,
user_id: userId || null,
source: source || 'manual',
metadata: {}
});
if (data.success) {
showNotification(`${collection === 'declarative' ? 'Fact' : 'Memory'} created successfully`, 'success');
closeCreateMemoryModal();
// Reload the appropriate list
if (collection === 'declarative') {
loadFacts();
} else if (collection === 'episodic') {
loadEpisodicMemories();
}
refreshMemoryStats();
} else {
showNotification('Failed to create: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
console.error('Failed to save new memory:', err);
} finally {
createBtn.disabled = false;
createBtn.textContent = 'Create Memory';
}
}
// Search/Filter Function
function filterMemories(listId, searchTerm) {
const listDiv = document.getElementById(listId);
const items = listDiv.querySelectorAll('.memory-item');
const term = searchTerm.toLowerCase().trim();
items.forEach(item => {
const content = item.textContent.toLowerCase();
if (term === '' || content.includes(term)) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
}

396
bot/static/js/modes.js Normal file
View File

@@ -0,0 +1,396 @@
// ============================================================================
// Miku Control Panel — Modes Module
// Evil Mode, GPU Selection, Bipolar Mode
// ============================================================================
// ===== Evil Mode Functions =====
async function checkEvilModeStatus() {
try {
const result = await apiCall('/evil-mode');
evilMode = result.evil_mode;
updateEvilModeUI();
if (evilMode && result.mood) {
const moodSelect = document.getElementById('mood');
moodSelect.value = result.mood;
}
} catch (error) {
console.error('Failed to check evil mode status:', error);
}
}
async function toggleEvilMode() {
try {
const toggleBtn = document.getElementById('evil-mode-toggle');
toggleBtn.disabled = true;
toggleBtn.textContent = '⏳ Switching...';
const result = await apiCall('/evil-mode/toggle', 'POST');
evilMode = result.evil_mode;
updateEvilModeUI();
if (evilMode) {
showNotification('😈 Evil Mode enabled! Evil Miku has awakened...');
} else {
showNotification('🎤 Evil Mode disabled. Normal Miku is back!');
}
} catch (error) {
console.error('Failed to toggle evil mode:', error);
showNotification('Failed to toggle evil mode: ' + error.message, 'error');
}
}
function updateEvilModeUI() {
const body = document.body;
const title = document.getElementById('panel-title');
const toggleBtn = document.getElementById('evil-mode-toggle');
const moodSelect = document.getElementById('mood');
if (evilMode) {
body.classList.add('evil-mode');
title.textContent = 'Evil Miku Control Panel';
toggleBtn.textContent = '😈 Evil Mode: ON';
toggleBtn.disabled = false;
moodSelect.innerHTML = `
<option value="aggressive">👿 aggressive</option>
<option value="bored">🥱 bored</option>
<option value="contemptuous">👑 contemptuous</option>
<option value="cunning">🐍 cunning</option>
<option value="evil_neutral" selected>evil neutral</option>
<option value="jealous">💚 jealous</option>
<option value="manic">🤪 manic</option>
<option value="melancholic">🌑 melancholic</option>
<option value="playful_cruel">🎭 playful cruel</option>
<option value="sarcastic">😈 sarcastic</option>
`;
} else {
body.classList.remove('evil-mode');
title.textContent = 'Miku Control Panel';
toggleBtn.textContent = '😈 Evil Mode: OFF';
toggleBtn.disabled = false;
moodSelect.innerHTML = `
<option value="angry">💢 angry</option>
<option value="asleep">💤 asleep</option>
<option value="bubbly">🫧 bubbly</option>
<option value="curious">👀 curious</option>
<option value="excited">✨ excited</option>
<option value="flirty">🫦 flirty</option>
<option value="irritated">😒 irritated</option>
<option value="melancholy">🍷 melancholy</option>
<option value="neutral" selected>neutral</option>
<option value="romantic">💌 romantic</option>
<option value="serious">👔 serious</option>
<option value="shy">👉👈 shy</option>
<option value="silly">🪿 silly</option>
<option value="sleepy">🌙 sleepy</option>
`;
}
updateBipolarToggleVisibility();
}
// ===== GPU Selection Management =====
async function checkGPUStatus() {
try {
const data = await apiCall('/gpu-status');
selectedGPU = data.gpu || 'nvidia';
updateGPUUI();
} catch (error) {
console.error('Failed to check GPU status:', error);
}
}
async function toggleGPU() {
try {
const toggleBtn = document.getElementById('gpu-selector-toggle');
toggleBtn.disabled = true;
toggleBtn.textContent = '⏳ Switching...';
const result = await apiCall('/gpu-select', 'POST', {
gpu: selectedGPU === 'nvidia' ? 'amd' : 'nvidia'
});
selectedGPU = result.gpu;
updateGPUUI();
const gpuName = selectedGPU === 'nvidia' ? 'NVIDIA GTX 1660' : 'AMD RX 6800';
showNotification(`🎮 Switched to ${gpuName}!`);
} catch (error) {
console.error('Failed to toggle GPU:', error);
showNotification('Failed to switch GPU: ' + error.message, 'error');
toggleBtn.disabled = false;
}
}
function updateGPUUI() {
const toggleBtn = document.getElementById('gpu-selector-toggle');
if (selectedGPU === 'amd') {
toggleBtn.textContent = '🎮 GPU: AMD';
toggleBtn.style.background = '#c91432';
toggleBtn.style.borderColor = '#e91436';
} else {
toggleBtn.textContent = '🎮 GPU: NVIDIA';
toggleBtn.style.background = '#2a5599';
toggleBtn.style.borderColor = '#4a7bc9';
}
toggleBtn.disabled = false;
}
// ===== Bipolar Mode Management =====
async function checkBipolarModeStatus() {
try {
const data = await apiCall('/bipolar-mode');
bipolarMode = data.bipolar_mode;
updateBipolarModeUI();
} catch (error) {
console.error('Failed to check bipolar mode status:', error);
}
}
async function toggleBipolarMode() {
try {
const toggleBtn = document.getElementById('bipolar-mode-toggle');
toggleBtn.disabled = true;
toggleBtn.textContent = '⏳ Switching...';
const result = await apiCall('/bipolar-mode/toggle', 'POST');
bipolarMode = result.bipolar_mode;
updateBipolarModeUI();
if (bipolarMode) {
showNotification('🔄 Bipolar Mode enabled! Both Mikus can now argue...');
} else {
showNotification('🔄 Bipolar Mode disabled.');
}
} catch (error) {
console.error('Failed to toggle bipolar mode:', error);
showNotification('Failed to toggle bipolar mode: ' + error.message, 'error');
}
}
function updateBipolarModeUI() {
const toggleBtn = document.getElementById('bipolar-mode-toggle');
const bipolarSection = document.getElementById('bipolar-section');
if (bipolarMode) {
toggleBtn.textContent = '🔄 Bipolar: ON';
toggleBtn.style.background = '#9932CC';
toggleBtn.style.borderColor = '#9932CC';
toggleBtn.disabled = false;
if (bipolarSection) {
bipolarSection.style.display = 'block';
loadScoreboard();
}
} else {
toggleBtn.textContent = '🔄 Bipolar: OFF';
toggleBtn.style.background = '#333';
toggleBtn.style.borderColor = '#666';
toggleBtn.disabled = false;
if (bipolarSection) {
bipolarSection.style.display = 'none';
}
}
}
function updateBipolarToggleVisibility() {
const bipolarToggle = document.getElementById('bipolar-mode-toggle');
bipolarToggle.style.display = 'block';
}
async function triggerPersonaDialogue() {
const messageIdInput = document.getElementById('dialogue-message-id').value.trim();
const statusDiv = document.getElementById('dialogue-status');
if (!messageIdInput) {
showNotification('Please enter a message ID', 'error');
return;
}
if (!/^\d+$/.test(messageIdInput)) {
showNotification('Invalid message ID format - should be a number', 'error');
return;
}
try {
statusDiv.innerHTML = '<span style="color: #6B8EFF;">⏳ Analyzing message for dialogue trigger...</span>';
const requestBody = {
message_id: messageIdInput
};
const result = await apiCall('/bipolar-mode/trigger-dialogue', 'POST', requestBody);
if (result.status === 'error') {
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ ${result.message}</span>`;
showNotification(result.message, 'error');
return;
}
statusDiv.innerHTML = `<span style="color: #00ff00;">✅ ${result.message}</span>`;
showNotification(`💬 ${result.message}`);
document.getElementById('dialogue-message-id').value = '';
} catch (error) {
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ Failed to trigger dialogue: ${error.message}</span>`;
showNotification(`Error: ${error.message}`, 'error');
}
}
async function triggerBipolarArgument() {
const channelIdInput = document.getElementById('bipolar-channel-id').value.trim();
const messageIdInput = document.getElementById('bipolar-message-id').value.trim();
const context = document.getElementById('bipolar-context').value.trim();
const statusDiv = document.getElementById('bipolar-status');
if (!channelIdInput) {
showNotification('Please enter a channel ID', 'error');
return;
}
if (!/^\d+$/.test(channelIdInput)) {
showNotification('Invalid channel ID format - should be a number', 'error');
return;
}
if (messageIdInput && !/^\d+$/.test(messageIdInput)) {
showNotification('Invalid message ID format - should be a number', 'error');
return;
}
try {
statusDiv.innerHTML = '<span style="color: #9932CC;">⏳ Triggering argument...</span>';
const requestBody = {
channel_id: channelIdInput,
context: context
};
if (messageIdInput) {
requestBody.message_id = messageIdInput;
}
const result = await apiCall('/bipolar-mode/trigger-argument', 'POST', requestBody);
if (result.status === 'error') {
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ ${result.message}</span>`;
showNotification(result.message, 'error');
return;
}
statusDiv.innerHTML = `<span style="color: #00ff00;">✅ ${result.message}</span>`;
showNotification(`⚔️ Argument triggered!`);
document.getElementById('bipolar-context').value = '';
document.getElementById('bipolar-message-id').value = '';
loadActiveArguments();
loadScoreboard();
} catch (error) {
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ ${error.message}</span>`;
showNotification('Failed to trigger argument: ' + error.message, 'error');
}
}
async function loadScoreboard() {
const scoreboardContent = document.getElementById('scoreboard-content');
try {
const result = await apiCall('/bipolar-mode/scoreboard', 'GET');
if (result.status === 'error') {
scoreboardContent.innerHTML = `<p style="color: #ff4444;">Failed to load scoreboard</p>`;
return;
}
const { scoreboard } = result;
const total = scoreboard.total_arguments;
if (total === 0) {
scoreboardContent.innerHTML = `<p style="color: #888;">No arguments have been judged yet.</p>`;
return;
}
const mikuPct = total > 0 ? ((scoreboard.miku_wins / total) * 100).toFixed(1) : 0;
const evilPct = total > 0 ? ((scoreboard.evil_wins / total) * 100).toFixed(1) : 0;
let html = `
<div style="display: flex; justify-content: space-between; margin-bottom: 0.8rem;">
<div style="text-align: center; flex: 1;">
<div style="color: #86cecb; font-size: 1.2rem; font-weight: bold;">${scoreboard.miku_wins}</div>
<div style="color: #888; font-size: 0.85rem;">Hatsune Miku</div>
<div style="color: #999; font-size: 0.75rem;">${mikuPct}%</div>
</div>
<div style="align-self: center; color: #666; font-size: 1.2rem;">vs</div>
<div style="text-align: center; flex: 1;">
<div style="color: #D60004; font-size: 1.2rem; font-weight: bold;">${scoreboard.evil_wins}</div>
<div style="color: #888; font-size: 0.85rem;">Evil Miku</div>
<div style="color: #999; font-size: 0.75rem;">${evilPct}%</div>
</div>
</div>
<div style="text-align: center; color: #aaa; font-size: 0.85rem; border-top: 1px solid #333; padding-top: 0.5rem;">
Total Arguments: ${total}
</div>
`;
if (scoreboard.history && scoreboard.history.length > 0) {
html += `<div style="margin-top: 0.8rem; padding-top: 0.8rem; border-top: 1px solid #333;">
<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.3rem;">Recent Results:</div>`;
scoreboard.history.reverse().forEach(entry => {
const winnerName = entry.winner === 'evil' ? 'Evil Miku' : 'Hatsune Miku';
const winnerColor = entry.winner === 'evil' ? '#D60004' : '#86cecb';
const date = new Date(entry.timestamp).toLocaleString();
html += `<div style="font-size: 0.75rem; color: #666; margin-bottom: 0.2rem;">
<span style="color: ${winnerColor};">🏆 ${winnerName}</span> (${entry.exchanges} exchanges) - ${date}
</div>`;
});
html += `</div>`;
}
scoreboardContent.innerHTML = html;
} catch (error) {
scoreboardContent.innerHTML = `<p style="color: #ff4444;">Error loading scoreboard</p>`;
console.error('Scoreboard error:', error);
}
}
async function loadActiveArguments() {
try {
const data = await apiCall('/bipolar-mode/arguments');
const container = document.getElementById('active-arguments');
const list = document.getElementById('active-arguments-list');
if (Object.keys(data.active_arguments).length > 0) {
container.style.display = 'block';
list.innerHTML = '';
for (const [channelId, argData] of Object.entries(data.active_arguments)) {
const div = document.createElement('div');
div.style.background = '#2a2a3e';
div.style.padding = '0.5rem';
div.style.marginBottom = '0.5rem';
div.style.borderRadius = '4px';
div.innerHTML = `
<strong>#${argData.channel_name}</strong><br>
<small>Exchanges: ${argData.exchange_count} | Speaker: ${argData.current_speaker}</small>
`;
list.appendChild(div);
}
} else {
container.style.display = 'none';
}
} catch (error) {
console.error('Failed to load active arguments:', error);
}
}

1127
bot/static/js/profile.js Normal file

File diff suppressed because it is too large Load Diff

684
bot/static/js/servers.js Normal file
View File

@@ -0,0 +1,684 @@
// ===== Server Management Functions =====
async function loadServers() {
try {
console.log('🎭 Loading servers...');
const data = await apiCall('/servers');
console.log('🎭 Servers response:', data);
if (data.servers) {
servers = data.servers;
console.log(`🎭 Loaded ${servers.length} servers:`, servers);
// Debug: Log each server's guild_id
servers.forEach((server, index) => {
console.log(`🎭 Server ${index}: guild_id = ${server.guild_id}, name = ${server.guild_name}`);
});
// Debug: Show raw response data
console.log('🎭 Raw API response data:', JSON.stringify(data, null, 2));
// Display servers
displayServers();
populateServerDropdowns();
populateMoodDropdowns(); // Populate mood dropdowns after servers are loaded
} else {
console.warn('🎭 No servers found in response');
servers = [];
}
} catch (error) {
console.error('🎭 Failed to load servers:', error);
servers = [];
}
}
function displayServers() {
const container = document.getElementById('servers-list');
if (servers.length === 0) {
container.innerHTML = '<p>No servers configured</p>';
return;
}
container.innerHTML = servers.map(server => `
<div class="server-card">
<div class="server-header">
<div class="server-name">${server.guild_name}</div>
<div class="server-actions">
<button onclick="editServer('${String(server.guild_id)}')">Edit</button>
<button onclick="removeServer('${String(server.guild_id)}')" style="background: #d32f2f;">Remove</button>
</div>
</div>
<div><strong>Guild ID:</strong> ${server.guild_id}</div>
<div><strong>Autonomous Channel:</strong> #${server.autonomous_channel_name} (${server.autonomous_channel_id})</div>
<div><strong>Bedtime Channels:</strong> ${server.bedtime_channel_ids.join(', ')}</div>
<div><strong>Features:</strong>
${server.enabled_features.map(feature => `<span class="feature-tag">${feature}</span>`).join('')}
</div>
<div><strong>Autonomous Interval:</strong> ${server.autonomous_interval_minutes} minutes</div>
<div><strong>Conversation Detection:</strong> ${server.conversation_detection_interval_minutes} minutes</div>
<div><strong>Bedtime Range:</strong> ${String(server.bedtime_hour || 21).padStart(2, '0')}:${String(server.bedtime_minute || 0).padStart(2, '0')} - ${String(server.bedtime_hour_end || 23).padStart(2, '0')}:${String(server.bedtime_minute_end || 59).padStart(2, '0')}</div>
<!-- Bedtime Configuration -->
<div style="margin-top: 1rem; padding: 1rem; background: #2a2a2a; border-radius: 4px;">
<h4 style="margin: 0 0 0.5rem 0; color: #61dafb;">Bedtime Settings</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
<div>
<label style="display: block; font-size: 0.9rem; margin-bottom: 0.2rem;">Start Time:</label>
<input type="time" id="bedtime-start-${String(server.guild_id)}" value="${String(server.bedtime_hour || 21).padStart(2, '0')}:${String(server.bedtime_minute || 0).padStart(2, '0')}" style="padding: 0.3rem; background: #333; color: white; border: 1px solid #555; border-radius: 3px; width: 100%;">
</div>
<div>
<label style="display: block; font-size: 0.9rem; margin-bottom: 0.2rem;">End Time:</label>
<input type="time" id="bedtime-end-${String(server.guild_id)}" value="${String(server.bedtime_hour_end || 23).padStart(2, '0')}:${String(server.bedtime_minute_end || 59).padStart(2, '0')}" style="padding: 0.3rem; background: #333; color: white; border: 1px solid #555; border-radius: 3px; width: 100%;">
</div>
</div>
<button onclick="updateBedtimeRange('${String(server.guild_id)}')" style="background: #4caf50;">Update Bedtime Range</button>
</div>
<!-- Per-Server Mood Display -->
<div style="margin-top: 1rem; padding: 1rem; background: #2a2a2a; border-radius: 4px;">
<h4 style="margin: 0 0 0.5rem 0; color: #61dafb;">Server Mood</h4>
<div><strong>Current Mood:</strong> ${server.current_mood_name || 'neutral'} ${MOOD_EMOJIS[server.current_mood_name] || ''}</div>
<div><strong>Sleeping:</strong> ${server.is_sleeping ? 'Yes' : 'No'}</div>
<div style="margin-top: 0.5rem;">
<select id="mood-select-${String(server.guild_id)}" style="margin-right: 0.5rem; padding: 0.3rem; background: #333; color: white; border: 1px solid #555; border-radius: 3px;">
<option value="">Select Mood...</option>
</select>
<button onclick="setServerMood('${String(server.guild_id)}')" style="margin-right: 0.5rem;">Change Mood</button>
<button onclick="resetServerMood('${String(server.guild_id)}')" style="background: #ff9800;">Reset Mood</button>
</div>
</div>
</div>
`).join('');
// Debug: Log what element IDs were created
console.log('🎭 Server cards rendered. Checking for mood-select elements:');
document.querySelectorAll('[id^="mood-select-"]').forEach(el => {
console.log(`🎭 Found mood-select element: ${el.id}`);
});
// Populate mood dropdowns after server cards are created
populateMoodDropdowns();
}
async function populateServerDropdowns() {
const serverSelect = document.getElementById('server-select');
const manualServerSelect = document.getElementById('manual-server-select');
const customPromptServerSelect = document.getElementById('custom-prompt-server-select');
// Clear existing options except "All Servers"
serverSelect.innerHTML = '<option value="all">All Servers</option>';
manualServerSelect.innerHTML = '<option value="all">All Servers</option>';
customPromptServerSelect.innerHTML = '<option value="all">All Servers</option>';
console.log('🎭 Populating server dropdowns with', servers.length, 'servers');
// Add server options
servers.forEach(server => {
console.log(`🎭 Adding server to dropdown: ${server.guild_name} (guild_id: ${server.guild_id}, type: ${typeof server.guild_id})`);
const option = document.createElement('option');
option.value = server.guild_id;
option.textContent = server.guild_name;
serverSelect.appendChild(option.cloneNode(true));
manualServerSelect.appendChild(option);
customPromptServerSelect.appendChild(option.cloneNode(true));
});
// Debug: Check what's actually in the manual-server-select dropdown
console.log('🎭 manual-server-select options:');
Array.from(manualServerSelect.options).forEach((opt, idx) => {
console.log(` [${idx}] value="${opt.value}" text="${opt.textContent}"`);
});
// Populate autonomous stats dropdown
populateAutonomousServerDropdown();
}
// Figurine subscribers UI functions (must be global for onclick handlers)
async function refreshFigurineSubscribers() {
try {
console.log('🔄 Figurines: Fetching subscribers...');
const data = await apiCall('/figurines/subscribers');
console.log('📋 Figurines: Received subscribers:', data);
displayFigurineSubscribers(data.subscribers || []);
showNotification('Subscribers refreshed');
} catch (e) {
console.error('❌ Figurines: Failed to fetch subscribers:', e);
}
}
function displayFigurineSubscribers(subscribers) {
const container = document.getElementById('figurine-subscribers-list');
if (!container) return;
if (!subscribers.length) {
container.innerHTML = '<p>No subscribers yet.</p>';
return;
}
let html = '<ul>';
subscribers.forEach(uid => {
const uidStr = String(uid);
html += `<li><code>${uidStr}</code> <button onclick="removeFigurineSubscriber('${uidStr}')">Remove</button></li>`;
});
html += '</ul>';
container.innerHTML = html;
}
async function addFigurineSubscriber() {
try {
console.log(' Figurines: Adding subscriber...');
const uid = document.getElementById('figurine-user-id').value.trim();
if (!uid) {
showNotification('Enter a user ID', 'error');
return;
}
const form = new FormData();
form.append('user_id', uid);
const res = await fetch('/figurines/subscribers', { method: 'POST', body: form });
const data = await res.json();
console.log(' Figurines: Add subscriber response:', data);
if (data.status === 'ok') {
showNotification('Subscriber added');
document.getElementById('figurine-user-id').value = '';
refreshFigurineSubscribers();
} else {
showNotification(data.message || 'Failed to add subscriber', 'error');
}
} catch (e) {
console.error('❌ Figurines: Failed to add subscriber:', e);
showNotification('Failed to add subscriber', 'error');
}
}
async function removeFigurineSubscriber(uid) {
try {
console.log(`🗑️ Figurines: Removing subscriber ${uid}...`);
const data = await apiCall(`/figurines/subscribers/${uid}`, 'DELETE');
console.log('🗑️ Figurines: Remove subscriber response:', data);
if (data.status === 'ok') {
showNotification('Subscriber removed');
refreshFigurineSubscribers();
} else {
showNotification(data.message || 'Failed to remove subscriber', 'error');
}
} catch (e) {
console.error('❌ Figurines: Failed to remove subscriber:', e);
}
}
async function sendFigurineNowToAll() {
try {
console.log('📨 Figurines: Triggering send to all subscribers...');
const tweetUrl = document.getElementById('figurine-tweet-url-all').value.trim();
const statusDiv = document.getElementById('figurine-all-status');
statusDiv.textContent = 'Sending...';
statusDiv.style.color = evilMode ? '#ff4444' : '#007bff';
const formData = new FormData();
if (tweetUrl) {
formData.append('tweet_url', tweetUrl);
}
const res = await fetch('/figurines/send_now', {
method: 'POST',
body: formData
});
const data = await res.json();
console.log('📨 Figurines: Send to all response:', data);
if (data.status === 'ok') {
showNotification('Figurine DMs queued for all subscribers');
statusDiv.textContent = 'Queued successfully';
statusDiv.style.color = '#28a745';
document.getElementById('figurine-tweet-url-all').value = ''; // Clear input
} else {
showNotification(data.message || 'Bot not ready', 'error');
statusDiv.textContent = 'Failed: ' + (data.message || 'Unknown error');
statusDiv.style.color = '#dc3545';
}
} catch (e) {
console.error('❌ Figurines: Failed to queue figurine DMs for all:', e);
showNotification('Failed to queue figurine DMs', 'error');
document.getElementById('figurine-all-status').textContent = 'Error: ' + e.message;
document.getElementById('figurine-all-status').style.color = '#dc3545';
}
}
async function sendFigurineToSingleUser() {
try {
const userId = document.getElementById('figurine-single-user-id').value.trim();
const tweetUrl = document.getElementById('figurine-tweet-url-single').value.trim();
const statusDiv = document.getElementById('figurine-single-status');
if (!userId) {
showNotification('Enter a user ID', 'error');
return;
}
console.log(`📨 Figurines: Sending to single user ${userId}, tweet: ${tweetUrl || 'random'}`);
statusDiv.textContent = 'Sending...';
statusDiv.style.color = evilMode ? '#ff4444' : '#007bff';
const formData = new FormData();
formData.append('user_id', userId);
if (tweetUrl) {
formData.append('tweet_url', tweetUrl);
}
const res = await fetch('/figurines/send_to_user', {
method: 'POST',
body: formData
});
const data = await res.json();
console.log('📨 Figurines: Send to single user response:', data);
if (data.status === 'ok') {
showNotification(`Figurine DM queued for user ${userId}`);
statusDiv.textContent = 'Queued successfully';
statusDiv.style.color = '#28a745';
document.getElementById('figurine-single-user-id').value = ''; // Clear inputs
document.getElementById('figurine-tweet-url-single').value = '';
} else {
showNotification(data.message || 'Failed to queue DM', 'error');
statusDiv.textContent = 'Failed: ' + (data.message || 'Unknown error');
statusDiv.style.color = '#dc3545';
}
} catch (e) {
console.error('❌ Figurines: Failed to queue figurine DM for single user:', e);
showNotification('Failed to queue figurine DM', 'error');
document.getElementById('figurine-single-status').textContent = 'Error: ' + e.message;
document.getElementById('figurine-single-status').style.color = '#dc3545';
}
}
// Keep the old function for backward compatibility
async function sendFigurineNow() {
return sendFigurineNowToAll();
}
async function addServer() {
// Don't use parseInt() for Discord IDs - they're too large for JS integers
const guildId = document.getElementById('new-guild-id').value.trim();
const guildName = document.getElementById('new-guild-name').value;
const autonomousChannelId = document.getElementById('new-autonomous-channel-id').value.trim();
const autonomousChannelName = document.getElementById('new-autonomous-channel-name').value;
const bedtimeChannelIds = document.getElementById('new-bedtime-channel-ids').value
.split(',').map(id => id.trim()).filter(id => id.length > 0);
const enabledFeatures = [];
if (document.getElementById('feature-autonomous').checked) enabledFeatures.push('autonomous');
if (document.getElementById('feature-bedtime').checked) enabledFeatures.push('bedtime');
if (document.getElementById('feature-monday-video').checked) enabledFeatures.push('monday_video');
if (!guildId || !guildName || !autonomousChannelId || !autonomousChannelName) {
showNotification('Please fill in all required fields', 'error');
return;
}
try {
await apiCall('/servers', 'POST', {
guild_id: guildId,
guild_name: guildName,
autonomous_channel_id: autonomousChannelId,
autonomous_channel_name: autonomousChannelName,
bedtime_channel_ids: bedtimeChannelIds.length > 0 ? bedtimeChannelIds : [autonomousChannelId],
enabled_features: enabledFeatures
});
showNotification('Server added successfully');
loadServers();
// Clear form
document.getElementById('new-guild-id').value = '';
document.getElementById('new-guild-name').value = '';
document.getElementById('new-autonomous-channel-id').value = '';
document.getElementById('new-autonomous-channel-name').value = '';
document.getElementById('new-bedtime-channel-ids').value = '';
} catch (error) {
console.error('Failed to add server:', error);
}
}
async function removeServer(guildId) {
if (!confirm('Are you sure you want to remove this server?')) {
return;
}
try {
await apiCall(`/servers/${guildId}`, 'DELETE');
showNotification('Server removed successfully');
loadServers();
} catch (error) {
console.error('Failed to remove server:', error);
}
}
async function editServer(guildId) {
// For now, just show a notification - you can implement a full edit form later
showNotification('Edit functionality coming soon!');
}
async function repairConfig() {
if (!confirm('This will attempt to repair corrupted server configurations. Are you sure?')) {
return;
}
try {
await apiCall('/servers/repair', 'POST');
showNotification('Configuration repair initiated. Please refresh the page to see updated server list.');
loadServers(); // Reload servers to reflect potential changes
} catch (error) {
console.error('Failed to repair config:', error);
showNotification(error.message || 'Failed to repair configuration', 'error');
}
}
// Populate mood dropdowns with available moods
async function populateMoodDropdowns() {
try {
console.log('🎭 Loading available moods...');
const data = await apiCall('/moods/available');
console.log('🎭 Available moods response:', data);
if (data.moods) {
console.log(`🎭 Found ${data.moods.length} moods:`, data.moods);
const emojiMap = evilMode ? EVIL_MOOD_EMOJIS : MOOD_EMOJIS;
// Populate the DM mood dropdown (#mood on tab1)
const dmMoodSelect = document.getElementById('mood');
if (dmMoodSelect) {
dmMoodSelect.innerHTML = '';
data.moods.forEach(mood => {
const opt = document.createElement('option');
opt.value = mood;
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
if (mood === 'neutral') opt.selected = true;
dmMoodSelect.appendChild(opt);
});
}
// Populate the chat mood dropdown (#chat-mood-select on tab7)
const chatMoodSelect = document.getElementById('chat-mood-select');
if (chatMoodSelect) {
chatMoodSelect.innerHTML = '';
data.moods.forEach(mood => {
const opt = document.createElement('option');
opt.value = mood;
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
if (mood === 'neutral') opt.selected = true;
chatMoodSelect.appendChild(opt);
});
}
// Populate per-server mood dropdowns (mood-select-{guildId})
document.querySelectorAll('[id^="mood-select-"]').forEach(select => {
// Keep only the first option ("Select Mood...")
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
});
data.moods.forEach(mood => {
const moodOption = document.createElement('option');
moodOption.value = mood;
moodOption.textContent = `${mood} ${emojiMap[mood] || ''}`;
document.querySelectorAll('[id^="mood-select-"]').forEach(select => {
select.appendChild(moodOption.cloneNode(true));
});
});
console.log('🎭 All mood dropdowns populated successfully');
} else {
console.warn('🎭 No moods found in response');
}
} catch (error) {
console.error('🎭 Failed to load available moods:', error);
}
}
// Per-Server Mood Management
async function setServerMood(guildId) {
console.log(`🎭 setServerMood called with guildId: ${guildId} (type: ${typeof guildId})`);
// Ensure guildId is a string for consistency
const guildIdStr = String(guildId);
console.log(`🎭 Using guildId as string: ${guildIdStr}`);
// Debug: Check what elements exist
const elementId = `mood-select-${guildIdStr}`;
console.log(`🎭 Looking for element with ID: ${elementId}`);
const moodSelect = document.getElementById(elementId);
console.log(`🎭 Found element:`, moodSelect);
if (!moodSelect) {
console.error(`🎭 ERROR: Element with ID '${elementId}' not found!`);
console.log(`🎭 Available mood-select elements:`, document.querySelectorAll('[id^="mood-select-"]'));
showNotification(`Error: Mood selector not found for server ${guildIdStr}`, 'error');
return;
}
const selectedMood = moodSelect.value;
console.log(`🎭 Setting mood for server ${guildIdStr} to ${selectedMood}`);
if (!selectedMood) {
showNotification('Please select a mood', 'error');
return;
}
// Get the button and store original text before any changes
const button = moodSelect.nextElementSibling;
const originalText = button.textContent;
try {
// Show loading state
button.textContent = 'Changing...';
button.disabled = true;
console.log(`🎭 Making API call to /servers/${guildIdStr}/mood with mood: ${selectedMood}`);
const response = await apiCall(`/servers/${guildIdStr}/mood`, 'POST', { mood: selectedMood });
console.log(`🎭 API response:`, response);
if (response.status === 'ok') {
showNotification(`Server mood changed to ${selectedMood} ${MOOD_EMOJIS[selectedMood] || ''}`);
// Reset dropdown selection
moodSelect.value = '';
// Reload servers to show updated mood
loadServers();
} else {
showNotification(`Failed to change mood: ${response.message}`, 'error');
}
} catch (error) {
console.error(`🎭 Error setting mood:`, error);
showNotification(`Failed to change mood: ${error}`, 'error');
} finally {
// Restore button state
button.textContent = originalText;
button.disabled = false;
}
}
async function resetServerMood(guildId) {
console.log(`🎭 resetServerMood called with guildId: ${guildId} (type: ${typeof guildId})`);
// Ensure guildId is a string for consistency
const guildIdStr = String(guildId);
console.log(`🎭 Using guildId as string: ${guildIdStr}`);
const button = document.querySelector(`button[onclick="resetServerMood('${guildIdStr}')"]`);
const originalText = button ? button.textContent : 'Reset';
try {
// Show loading state
if (button) {
button.textContent = 'Resetting...';
button.disabled = true;
}
await apiCall(`/servers/${guildIdStr}/mood/reset`, 'POST');
showNotification(`Server mood reset to neutral`);
// Reload servers to show updated mood
loadServers();
} catch (error) {
showNotification(`Failed to reset mood: ${error}`, 'error');
} finally {
// Restore button state
if (button) {
button.textContent = originalText;
button.disabled = false;
}
}
}
async function updateBedtimeRange(guildId) {
console.log(`⏰ updateBedtimeRange called with guildId: ${guildId}`);
// Ensure guildId is a string for consistency
const guildIdStr = String(guildId);
// Get the time values from the inputs
const startTimeInput = document.getElementById(`bedtime-start-${guildIdStr}`);
const endTimeInput = document.getElementById(`bedtime-end-${guildIdStr}`);
if (!startTimeInput || !endTimeInput) {
showNotification('Could not find bedtime time inputs', 'error');
return;
}
const startTime = startTimeInput.value; // Format: "HH:MM"
const endTime = endTimeInput.value; // Format: "HH:MM"
if (!startTime || !endTime) {
showNotification('Please enter both start and end times', 'error');
return;
}
// Parse the times
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
const button = document.querySelector(`button[onclick="updateBedtimeRange('${guildIdStr}')"]`);
const originalText = button ? button.textContent : 'Update Bedtime Range';
try {
// Show loading state
if (button) {
button.textContent = 'Updating...';
button.disabled = true;
}
// Send the update request
await apiCall(`/servers/${guildIdStr}/bedtime-range`, 'POST', {
bedtime_hour: startHour,
bedtime_minute: startMinute,
bedtime_hour_end: endHour,
bedtime_minute_end: endMinute
});
showNotification(`Bedtime range updated: ${startTime} - ${endTime}`);
// Reload servers to show updated configuration
loadServers();
} catch (error) {
console.error('Failed to update bedtime range:', error);
} finally {
// Restore button state
if (button) {
button.textContent = originalText;
button.disabled = false;
}
}
}
// Mood Management
async function setMood() {
const mood = document.getElementById('mood').value;
try {
// Use different endpoint for evil mode
const endpoint = evilMode ? '/evil-mode/mood' : '/mood';
await apiCall(endpoint, 'POST', { mood: mood });
showNotification(`Mood set to ${mood}`);
currentMood = mood;
} catch (error) {
console.error('Failed to set mood:', error);
}
}
async function resetMood() {
try {
if (evilMode) {
await apiCall('/evil-mode/mood', 'POST', { mood: 'evil_neutral' });
showNotification('Evil mood reset to evil_neutral');
currentMood = 'evil_neutral';
document.getElementById('mood').value = 'evil_neutral';
} else {
await apiCall('/mood/reset', 'POST');
showNotification('Mood reset to neutral');
currentMood = 'neutral';
document.getElementById('mood').value = 'neutral';
}
} catch (error) {
console.error('Failed to reset mood:', error);
}
}
async function calmMiku() {
try {
if (evilMode) {
await apiCall('/evil-mode/mood', 'POST', { mood: 'evil_neutral' });
showNotification('Evil Miku has been calmed down');
currentMood = 'evil_neutral';
document.getElementById('mood').value = 'evil_neutral';
} else {
await apiCall('/mood/calm', 'POST');
showNotification('Miku has been calmed down');
}
} catch (error) {
console.error('Failed to calm Miku:', error);
}
}
// ===== Language Mode Functions =====
async function refreshLanguageStatus() {
try {
const result = await apiCall('/language');
document.getElementById('current-language-display').textContent =
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
document.getElementById('status-language').textContent =
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
document.getElementById('status-model').textContent = result.current_model;
console.log('Language status:', result);
} catch (error) {
console.error('Failed to get language status:', error);
showNotification('Failed to load language status', 'error');
}
}
async function toggleLanguageMode() {
try {
const result = await apiCall('/language/toggle', 'POST');
// Update UI
document.getElementById('current-language-display').textContent =
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
document.getElementById('status-language').textContent =
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
document.getElementById('status-model').textContent = result.model_now_using;
// Show notification
showNotification(result.message, 'success');
console.log('Language toggled:', result);
} catch (error) {
console.error('Failed to toggle language mode:', error);
showNotification('Failed to toggle language mode', 'error');
}
}

524
bot/static/js/status.js Normal file
View File

@@ -0,0 +1,524 @@
// ============================================================================
// Miku Control Panel — Status Module
// Status display, last prompt, autonomous stats
// ============================================================================
// ===== Status =====
async function loadStatus() {
try {
const result = await apiCall('/status');
const statusDiv = document.getElementById('status');
if (result.evil_mode !== undefined && result.evil_mode !== evilMode) {
evilMode = result.evil_mode;
updateEvilModeUI();
if (evilMode && result.mood) {
const moodSelect = document.getElementById('mood');
if (moodSelect) moodSelect.value = result.mood;
}
}
if (result.mood) {
const moodSelect = document.getElementById('mood');
if (moodSelect && moodSelect.querySelector(`option[value="${result.mood}"]`)) {
moodSelect.value = result.mood;
}
currentMood = result.mood;
}
let serverMoodsHtml = '';
if (result.server_moods) {
serverMoodsHtml = '<div style="margin-top: 0.5rem;"><strong>Server Moods:</strong><br>';
for (const [guildId, mood] of Object.entries(result.server_moods)) {
const server = servers.find(s => s.guild_id == guildId);
const serverName = server ? server.guild_name : `Server ${guildId}`;
const emojiMap = evilMode ? EVIL_MOOD_EMOJIS : MOOD_EMOJIS;
serverMoodsHtml += `${serverName}: ${mood} ${emojiMap[mood] || ''}<br>`;
}
serverMoodsHtml += '</div>';
}
const moodEmoji = evilMode ? (EVIL_MOOD_EMOJIS[result.mood] || '') : (MOOD_EMOJIS[result.mood] || '');
const moodLabel = evilMode ? `😈 ${result.mood} ${moodEmoji}` : `${result.mood} ${moodEmoji}`;
statusDiv.innerHTML = `
<div><strong>Status:</strong> ${result.status}</div>
<div><strong>DM Mood:</strong> ${moodLabel}</div>
<div><strong>Servers:</strong> ${result.servers}</div>
<div><strong>Active Schedulers:</strong> ${result.active_schedulers}</div>
<div style="margin-top: 0.5rem; padding: 0.5rem; background: #2a2a2a; border-radius: 4px; font-size: 0.9rem;">
<strong>💬 DM Support:</strong> Users can message Miku directly in DMs. She responds to every DM message using the DM mood (auto-rotating every 2 hours).
</div>
${serverMoodsHtml}
`;
} catch (error) {
console.error('Failed to load status:', error);
}
}
// ===== Prompt History =====
let _promptHistoryCache = []; // cached history entries from last fetch
let _selectedPromptId = null; // currently selected entry ID
let _middleTruncation = false; // whether middle-truncation is active
async function loadPromptHistory() {
const source = localStorage.getItem('miku-prompt-source') || 'all';
const selectEl = document.getElementById('prompt-history-select');
try {
const url = source === 'all' ? '/prompts' : `/prompts?source=${source}`;
const result = await apiCall(url);
_promptHistoryCache = result.history || [];
// Populate dropdown
const currentValue = selectEl.value;
selectEl.innerHTML = '';
if (_promptHistoryCache.length === 0) {
selectEl.innerHTML = '<option value="">-- No prompts yet --</option>';
} else {
_promptHistoryCache.forEach(entry => {
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '?';
const srcLabel = entry.source === 'cat' ? '🐱' : '🤖';
const user = entry.user || '?';
const option = document.createElement('option');
option.value = entry.id;
option.textContent = `${srcLabel} #${entry.id}${user}${ts}`;
selectEl.appendChild(option);
});
}
// Restore or auto-select the latest entry
if (_selectedPromptId && _promptHistoryCache.some(e => e.id === _selectedPromptId)) {
selectEl.value = _selectedPromptId;
} else if (_promptHistoryCache.length > 0) {
selectEl.value = _promptHistoryCache[0].id;
}
if (selectEl.value) {
await selectPromptEntry(selectEl.value);
} else {
clearPromptDisplay();
}
} catch (error) {
console.error('Failed to load prompt history:', error);
}
}
async function selectPromptEntry(promptId) {
if (!promptId) {
clearPromptDisplay();
return;
}
_selectedPromptId = parseInt(promptId);
// Try cache first
let entry = _promptHistoryCache.find(e => e.id === _selectedPromptId);
// Fall back to API call if not in cache
if (!entry) {
try {
entry = await apiCall(`/prompts/${_selectedPromptId}`);
} catch (error) {
console.error('Failed to load prompt entry:', error);
clearPromptDisplay();
return;
}
}
if (!entry) {
clearPromptDisplay();
return;
}
renderPromptEntry(entry);
}
function clearPromptDisplay() {
document.getElementById('prompt-metadata').innerHTML = '';
document.getElementById('prompt-display').innerHTML = '<pre style="white-space: pre-wrap; word-break: break-word; background: #1a1a1a; padding: 0.75rem; border-radius: 4px; font-size: 0.8rem; line-height: 1.4; margin: 0; color: #666;">No prompt selected.</pre>';
document.getElementById('last-prompt').textContent = '';
}
function renderPromptEntry(entry) {
// Metadata bar
const metaEl = document.getElementById('prompt-metadata');
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleString() : '?';
const sourceIcon = entry.source === 'cat' ? '🐱 Cat' : '🤖 Fallback';
metaEl.innerHTML = `
<span><span class="prompt-meta-label">#</span><span class="prompt-meta-value">${entry.id}</span></span>
<span><span class="prompt-meta-label">Source:</span> <span class="prompt-meta-value">${sourceIcon}</span></span>
<span><span class="prompt-meta-label">User:</span> <span class="prompt-meta-value">${escapeHtml(entry.user || '?')}</span></span>
<span><span class="prompt-meta-label">Mood:</span> <span class="prompt-meta-value">${escapeHtml(entry.mood || '?')}</span></span>
<span><span class="prompt-meta-label">Guild:</span> <span class="prompt-meta-value">${escapeHtml(entry.guild || '?')}</span></span>
<span><span class="prompt-meta-label">Channel:</span> <span class="prompt-meta-value">${escapeHtml(entry.channel || '?')}</span></span>
<span><span class="prompt-meta-label">Model:</span> <span class="prompt-meta-value">${escapeHtml(entry.model || '?')}</span></span>
<span><span class="prompt-meta-label">Type:</span> <span class="prompt-meta-value">${escapeHtml(entry.response_type || '?')}</span></span>
<span><span class="prompt-meta-label">Time:</span> <span class="prompt-meta-value">${ts}</span></span>
`;
// Parse full_prompt into sections
const sections = parsePromptSections(entry.full_prompt || '');
// Snapshot which subsections are currently collapsed (before re-render)
const sectionIds = ['system', 'context', 'conversation', 'response'];
const collapsedState = {};
sectionIds.forEach(id => {
const el = document.getElementById(`prompt-section-${id}`);
collapsedState[id] = el && el.classList.contains('collapsed');
});
// Build display HTML with collapsible subsections
let displayHtml = '';
if (sections.system) {
displayHtml += buildCollapsibleSection('System Prompt', sections.system, 'system');
}
if (sections.context) {
displayHtml += buildCollapsibleSection('Context (Memories & Tools)', sections.context, 'context');
}
if (sections.conversation) {
displayHtml += buildCollapsibleSection('Conversation', sections.conversation, 'conversation');
}
if (!sections.system && !sections.context && !sections.conversation) {
// Fallback: show raw full_prompt
displayHtml += `<pre style="white-space: pre-wrap; word-break: break-word; margin: 0;">${escapeHtml(entry.full_prompt || '')}</pre>`;
}
// Response section
if (entry.response) {
let responseText = entry.response;
if (_middleTruncation && responseText.length > 400) {
responseText = responseText.substring(0, 200) + '\n\n... [truncated middle] ...\n\n' + responseText.substring(responseText.length - 200);
}
displayHtml += buildCollapsibleSection('Response', responseText, 'response');
}
// Render into the prompt-display div (using innerHTML for collapsible structure)
const displayEl = document.getElementById('prompt-display');
displayEl.innerHTML = displayHtml;
// Restore collapsed state from snapshot
sectionIds.forEach(id => {
const el = document.getElementById(`prompt-section-${id}`);
if (el && collapsedState[id]) {
el.classList.add('collapsed');
const header = el.previousElementSibling;
if (header) header.innerHTML = header.innerHTML.replace('▼', '▶');
}
});
// Also set the raw text into the <pre> for copy functionality
let rawText = entry.full_prompt || '';
if (entry.response) {
rawText += `\n\n${'═'.repeat(60)}\n[Response]\n${entry.response}`;
}
document.getElementById('last-prompt').textContent = rawText;
}
function parsePromptSections(fullPrompt) {
const sections = { system: null, context: null, conversation: null };
if (!fullPrompt) return sections;
// Try to split on known section markers
const contextMatch = fullPrompt.match(/# Context\s*\n([\s\S]*?)(?=\n# Conversation|\nHuman:|\n$)/);
const convMatch = fullPrompt.match(/# Conversation until now:\s*\n([\s\S]*)/);
if (contextMatch) {
// Everything before # Context is the system prompt
const contextIdx = fullPrompt.indexOf('# Context');
if (contextIdx > 0) {
sections.system = fullPrompt.substring(0, contextIdx).trim();
}
sections.context = contextMatch[1].trim();
}
if (convMatch) {
sections.conversation = convMatch[1].trim();
} else {
// Try alternative: "Human:" at the end
const humanMatch = fullPrompt.match(/\nHuman:([\s\S]*)/);
if (humanMatch && fullPrompt.indexOf('Human:') > fullPrompt.indexOf('# Context')) {
sections.conversation = 'Human:' + humanMatch[1].trim();
}
}
// If no # Context marker, try "System:" prefix (fallback prompts)
if (!sections.system && !sections.context) {
const sysMatch = fullPrompt.match(/^System:\s*([\s\S]*?)(?=\nMessages:)/);
const msgMatch = fullPrompt.match(/Messages:\s*([\s\S]*)/);
if (sysMatch) {
sections.system = sysMatch[1].trim();
}
if (msgMatch) {
sections.conversation = msgMatch[1].trim();
}
}
return sections;
}
function buildCollapsibleSection(title, content, sectionId) {
const id = `prompt-section-${sectionId}`;
return `
<div class="prompt-subsection-header" onclick="togglePromptSubsection('${id}')">
${escapeHtml(title)}
</div>
<div class="prompt-subsection-body" id="${id}">
<pre style="white-space: pre-wrap; word-break: break-word; background: #1a1a1a; padding: 0.5rem; border-radius: 4px; font-size: 0.8rem; line-height: 1.4; margin: 0.25rem 0;">${escapeHtml(content)}</pre>
</div>`;
}
function togglePromptSubsection(id) {
const body = document.getElementById(id);
if (!body) return;
const header = body.previousElementSibling;
if (body.classList.contains('collapsed')) {
body.classList.remove('collapsed');
if (header) header.innerHTML = header.innerHTML.replace('▶', '▼');
} else {
body.classList.add('collapsed');
if (header) header.innerHTML = header.innerHTML.replace('▼', '▶');
}
}
function togglePromptHistoryCollapse() {
const section = document.getElementById('prompt-history-section');
const toggle = document.getElementById('prompt-history-toggle');
if (section.classList.contains('collapsed')) {
section.classList.remove('collapsed');
toggle.textContent = '▼ Prompt History';
} else {
section.classList.add('collapsed');
toggle.textContent = '▶ Prompt History';
}
}
function copyPromptToClipboard() {
const rawText = document.getElementById('last-prompt').textContent;
if (!rawText) return;
navigator.clipboard.writeText(rawText).then(() => {
showNotification('Prompt copied to clipboard', 'success');
}).catch(err => {
console.error('Failed to copy:', err);
showNotification('Failed to copy', 'error');
});
}
function toggleMiddleTruncation() {
_middleTruncation = document.getElementById('prompt-truncate-toggle').checked;
// Re-render current entry
if (_selectedPromptId) {
selectPromptEntry(_selectedPromptId);
}
}
// Legacy compatibility — called from core.js on page load / tab switch
// Redirects to the new loadPromptHistory()
async function loadLastPrompt() {
await loadPromptHistory();
}
// ===== Autonomous Stats =====
async function loadAutonomousStats() {
const serverSelect = document.getElementById('autonomous-server-select');
const selectedGuildId = serverSelect.value;
if (!selectedGuildId) {
document.getElementById('autonomous-stats-display').innerHTML = '<p style="color: #aaa;">Please select a server to view autonomous stats.</p>';
return;
}
try {
const data = await apiCall('/autonomous/stats');
if (!data.servers || !data.servers[selectedGuildId]) {
document.getElementById('autonomous-stats-display').innerHTML = '<p style="color: #ff5555;">Server not found or not initialized.</p>';
return;
}
const serverData = data.servers[selectedGuildId];
displayAutonomousStats(serverData);
} catch (error) {
console.error('Failed to load autonomous stats:', error);
}
}
function displayAutonomousStats(data) {
const container = document.getElementById('autonomous-stats-display');
if (!data.context) {
container.innerHTML = `
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px;">
<h4 style="color: #61dafb; margin-top: 0;">⚠️ Context Not Initialized</h4>
<p>This server hasn't had any activity yet. Context tracking will begin once messages are sent.</p>
<div style="margin-top: 1rem; padding: 1rem; background: #1e1e1e; border-radius: 4px;">
<strong>Current Mood:</strong> ${data.mood} ${MOOD_EMOJIS[data.mood] || ''}<br>
<strong>Energy:</strong> ${data.mood_profile.energy}<br>
<strong>Sociability:</strong> ${data.mood_profile.sociability}<br>
<strong>Impulsiveness:</strong> ${data.mood_profile.impulsiveness}
</div>
</div>
`;
return;
}
const ctx = data.context;
const profile = data.mood_profile;
const lastActionMin = Math.floor(ctx.time_since_last_action / 60);
const lastInteractionMin = Math.floor(ctx.time_since_last_interaction / 60);
container.innerHTML = `
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="color: #61dafb; margin-top: 0;">🎭 Mood & Personality Profile</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Current Mood</div>
<div style="font-size: 1.5rem; font-weight: bold;">${data.mood} ${MOOD_EMOJIS[data.mood] || ''}</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Energy Level</div>
<div style="font-size: 1.5rem; font-weight: bold; color: ${getStatColor(profile.energy)}">${(profile.energy * 100).toFixed(0)}%</div>
<div style="width: 100%; height: 6px; background: #333; border-radius: 3px; margin-top: 0.5rem;">
<div style="width: ${profile.energy * 100}%; height: 100%; background: ${getStatColor(profile.energy)}; border-radius: 3px;"></div>
</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Sociability</div>
<div style="font-size: 1.5rem; font-weight: bold; color: ${getStatColor(profile.sociability)}">${(profile.sociability * 100).toFixed(0)}%</div>
<div style="width: 100%; height: 6px; background: #333; border-radius: 3px; margin-top: 0.5rem;">
<div style="width: ${profile.sociability * 100}%; height: 100%; background: ${getStatColor(profile.sociability)}; border-radius: 3px;"></div>
</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Impulsiveness</div>
<div style="font-size: 1.5rem; font-weight: bold; color: ${getStatColor(profile.impulsiveness)}">${(profile.impulsiveness * 100).toFixed(0)}%</div>
<div style="width: 100%; height: 6px; background: #333; border-radius: 3px; margin-top: 0.5rem;">
<div style="width: ${profile.impulsiveness * 100}%; height: 100%; background: ${getStatColor(profile.impulsiveness)}; border-radius: 3px;"></div>
</div>
</div>
</div>
</div>
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="color: #61dafb; margin-top: 0;">📈 Activity Metrics</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Messages (Last 5 min) <span style="color: #666;">⚡ ephemeral</span></div>
<div style="font-size: 1.8rem; font-weight: bold; color: #4caf50;">${ctx.messages_last_5min}</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Messages (Last Hour) <span style="color: #666;">⚡ ephemeral</span></div>
<div style="font-size: 1.8rem; font-weight: bold; color: #2196f3;">${ctx.messages_last_hour}</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Conversation Momentum <span style="color: #4caf50;">💾 saved</span></div>
<div style="font-size: 1.8rem; font-weight: bold; color: ${getMomentumColor(ctx.conversation_momentum)}">${(ctx.conversation_momentum * 100).toFixed(0)}%</div>
<div style="font-size: 0.75rem; color: #888; margin-top: 0.3rem;">Decays with downtime (half-life: 10min)</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Unique Users Active <span style="color: #666;">⚡ ephemeral</span></div>
<div style="font-size: 1.8rem; font-weight: bold; color: #ff9800;">${ctx.unique_users_active}</div>
</div>
</div>
</div>
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="color: #61dafb; margin-top: 0;">👥 User Events</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Users Joined Recently</div>
<div style="font-size: 1.8rem; font-weight: bold; color: #4caf50;">${ctx.users_joined_recently}</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Status Changes</div>
<div style="font-size: 1.8rem; font-weight: bold; color: #2196f3;">${ctx.users_status_changed}</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Active Activities</div>
<div style="font-size: 1.8rem; font-weight: bold; color: #9c27b0;">${ctx.users_started_activity.length}</div>
${ctx.users_started_activity.length > 0 ? `<div style="font-size: 0.8rem; margin-top: 0.5rem; color: #aaa;">${ctx.users_started_activity.join(', ')}</div>` : ''}
</div>
</div>
</div>
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="color: #61dafb; margin-top: 0;">⏱️ Timing & Context</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Time Since Last Action <span style="color: #4caf50;">💾 saved</span></div>
<div style="font-size: 1.8rem; font-weight: bold; color: #ff5722;">${lastActionMin} min</div>
<div style="font-size: 0.8rem; color: #888;">${ctx.time_since_last_action.toFixed(1)}s</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Time Since Last Interaction <span style="color: #4caf50;">💾 saved</span></div>
<div style="font-size: 1.8rem; font-weight: bold; color: #ff9800;">${lastInteractionMin} min</div>
<div style="font-size: 0.8rem; color: #888;">${ctx.time_since_last_interaction.toFixed(1)}s</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Messages Since Last Appearance <span style="color: #4caf50;">💾 saved</span></div>
<div style="font-size: 1.8rem; font-weight: bold; color: #2196f3;">${ctx.messages_since_last_appearance}</div>
</div>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa;">Current Time Context <span style="color: #666;">⚡ ephemeral</span></div>
<div style="font-size: 1.5rem; font-weight: bold; color: #61dafb;">${ctx.hour_of_day}:00</div>
<div style="font-size: 0.8rem; color: #888;">${ctx.is_weekend ? '📅 Weekend' : '📆 Weekday'}</div>
</div>
</div>
</div>
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px;">
<h4 style="color: #61dafb; margin-top: 0;">🧠 Base Energy Level</h4>
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.5rem;">From current mood personality</div>
<div style="font-size: 2rem; font-weight: bold; color: ${getStatColor(ctx.mood_energy_level)}">${(ctx.mood_energy_level * 100).toFixed(0)}%</div>
<div style="width: 100%; height: 10px; background: #333; border-radius: 5px; margin-top: 0.5rem;">
<div style="width: ${ctx.mood_energy_level * 100}%; height: 100%; background: ${getStatColor(ctx.mood_energy_level)}; border-radius: 5px;"></div>
</div>
<div style="font-size: 0.85rem; color: #888; margin-top: 0.5rem;">
💡 Combined with activity metrics to determine action likelihood.<br>
📝 High energy = shorter wait times, higher action chance.<br>
💾 <strong>Persisted across restarts</strong>
</div>
</div>
</div>
`;
}
function getStatColor(value) {
if (value >= 0.8) return '#4caf50';
if (value >= 0.6) return '#8bc34a';
if (value >= 0.4) return '#ffc107';
if (value >= 0.2) return '#ff9800';
return '#f44336';
}
function getMomentumColor(value) {
if (value >= 0.7) return '#4caf50';
if (value >= 0.4) return '#2196f3';
return '#9e9e9e';
}
function populateAutonomousServerDropdown() {
const select = document.getElementById('autonomous-server-select');
if (!select) return;
const currentValue = select.value;
select.innerHTML = '<option value="">-- Select a server --</option>';
servers.forEach(server => {
const option = document.createElement('option');
option.value = server.guild_id;
option.textContent = `${server.guild_name} (${server.guild_id})`;
select.appendChild(option);
});
if (currentValue && servers.some(s => String(s.guild_id) === currentValue)) {
select.value = currentValue;
}
}

25
bot/tests/run_tests.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Run the config/state regression tests inside the miku-bot Docker container.
#
# Usage:
# ./bot/tests/run_tests.sh # build + run
# ./bot/tests/run_tests.sh --no-build # skip rebuild
set -euo pipefail
cd "$(dirname "$0")/../.." # repo root
if [[ "${1:-}" != "--no-build" ]]; then
echo "Building miku-bot image..."
docker compose build miku-bot
fi
echo ""
echo "Running config/state regression tests..."
echo ""
docker run --rm \
-v "$(pwd)/config.yaml:/config.yaml:ro" \
-v "$(pwd)/bot/tests:/app/tests:ro" \
-e DISCORD_BOT_TOKEN=test_token \
miku-discord-miku-bot \
python tests/test_config_state.py

View File

@@ -0,0 +1,511 @@
"""
Regression test suite for config/state hardening (Steps 1-10).
Run inside Docker:
docker compose run --rm miku-bot python tests/test_config_state.py
Each test is an independent function. Tests use a temporary directory
for all file I/O so they never touch the real config/memory files.
"""
import json
import os
import sys
import shutil
import tempfile
import traceback
from pathlib import Path
# ── Bootstrap ──
sys.path.insert(0, "/app")
os.chdir("/app")
os.environ.setdefault("DISCORD_BOT_TOKEN", "test_token")
# ── Imports (after path setup) ──
import globals as g
from config import CONFIG
from config_manager import ConfigManager
from server_manager import ServerManager, ServerConfig
# ═══════════════════════════════════════════════════
# Test Runner
# ═══════════════════════════════════════════════════
_results: list[tuple[str, bool, str]] = [] # (name, passed, detail)
def run_test(func):
"""Decorator-free runner: call run_test(fn) to execute and record."""
name = func.__name__
try:
func()
_results.append((name, True, ""))
print(f"{name}")
except AssertionError as e:
_results.append((name, False, str(e)))
print(f"{name}: {e}")
except Exception as e:
_results.append((name, False, f"{type(e).__name__}: {e}"))
print(f"{name}: {type(e).__name__}: {e}")
traceback.print_exc()
def _make_tmp_dir():
"""Create a fresh temp directory and return its Path."""
d = tempfile.mkdtemp(prefix="miku_test_")
return Path(d)
# ═══════════════════════════════════════════════════
# A. Config Loading & Persistence (Steps 1-2)
# ═══════════════════════════════════════════════════
def test_runtime_config_persists_in_memory_dir():
"""Step 1: config_runtime.yaml lives inside memory/ (volume-mounted)."""
from config_manager import config_manager
runtime_path = config_manager.runtime_config_path
assert "memory" in str(runtime_path), \
f"runtime_config_path should be in memory/: {runtime_path}"
assert runtime_path.parent.name == "memory", \
f"Parent dir should be 'memory', got: {runtime_path.parent.name}"
def test_config_yaml_loads_into_pydantic():
"""Step 2: config.yaml parses cleanly into AppConfig."""
assert CONFIG is not None, "CONFIG is None"
assert hasattr(CONFIG, 'discord'), "CONFIG missing 'discord' section"
assert hasattr(CONFIG, 'autonomous'), "CONFIG missing 'autonomous' section"
assert hasattr(CONFIG, 'voice'), "CONFIG missing 'voice' section"
assert hasattr(CONFIG, 'gpu'), "CONFIG missing 'gpu' section"
assert hasattr(CONFIG, 'cheshire_cat'), "CONFIG missing 'cheshire_cat' section"
assert hasattr(CONFIG, 'services'), "CONFIG missing 'services' section"
def test_runtime_overrides_merge():
"""Runtime overrides take precedence over static config."""
tmp = _make_tmp_dir()
try:
# Write a minimal static config
static = tmp / "config.yaml"
static.write_text("discord:\n language_mode: english\n")
# Write runtime override
mem = tmp / "memory"
mem.mkdir()
(mem / "config_runtime.yaml").write_text(
"discord:\n language_mode: japanese\n"
)
cm = ConfigManager(config_path=str(static))
# Patch memory_dir to our temp location
cm.memory_dir = mem
cm.runtime_config_path = mem / "config_runtime.yaml"
cm.runtime_config = cm._load_runtime_config()
val = cm.get("discord.language_mode")
assert val == "japanese", f"Expected 'japanese', got {val!r}"
finally:
shutil.rmtree(tmp)
# ═══════════════════════════════════════════════════
# B. Runtime State (Steps 4-6)
# ═══════════════════════════════════════════════════
def test_runtime_state_reads_live_globals():
"""Step 4: runtime_state property reads current globals, not stale cache."""
from config_manager import config_manager
old = g.DM_MOOD
try:
g.DM_MOOD = "test_sentinel_mood"
state = config_manager.runtime_state
assert state["dm_mood"] == "test_sentinel_mood", \
f"runtime_state returned {state['dm_mood']!r}, expected 'test_sentinel_mood'"
finally:
g.DM_MOOD = old
def test_config_set_syncs_all_simple_globals():
"""Step 6: the _GLOBALS_SYNC map covers all 5 simple settings."""
_GLOBALS_SYNC = {
"discord.language_mode": ("LANGUAGE_MODE", str),
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
}
# Save originals (use getattr with default since some may be dynamically created)
originals = {attr: getattr(g, attr, None) for _, (attr, _) in _GLOBALS_SYNC.items()}
try:
# Set each to a non-default value
test_values = {
"discord.language_mode": "japanese",
"autonomous.debug_mode": True,
"voice.debug_mode": True,
"memory.use_cheshire_cat": True,
"gpu.prefer_amd": True,
}
for key_path, value in test_values.items():
attr, converter = _GLOBALS_SYNC[key_path]
setattr(g, attr, converter(value))
for key_path, value in test_values.items():
attr, _ = _GLOBALS_SYNC[key_path]
actual = getattr(g, attr)
assert actual == value, f"globals.{attr}: expected {value!r}, got {actual!r}"
finally:
# Restore (delete attr if it didn't exist before)
for attr, orig in originals.items():
if orig is None and not hasattr(g, attr):
continue
elif orig is None:
delattr(g, attr)
else:
setattr(g, attr, orig)
def test_config_set_syncs_dm_mood():
"""Step 6: DM mood sync updates both DM_MOOD and DM_MOOD_DESCRIPTION."""
old_mood = g.DM_MOOD
old_desc = g.DM_MOOD_DESCRIPTION
try:
if g.AVAILABLE_MOODS:
test_mood = g.AVAILABLE_MOODS[0]
g.DM_MOOD = test_mood
g.DM_MOOD_DESCRIPTION = f"I'm feeling {test_mood} today."
assert g.DM_MOOD == test_mood, f"DM_MOOD: expected {test_mood!r}"
assert test_mood in g.DM_MOOD_DESCRIPTION, \
f"DM_MOOD_DESCRIPTION should contain {test_mood!r}"
else:
# No available moods to test with; just verify the list exists
assert isinstance(g.AVAILABLE_MOODS, list), "AVAILABLE_MOODS should be a list"
finally:
g.DM_MOOD = old_mood
g.DM_MOOD_DESCRIPTION = old_desc
def test_restore_runtime_settings():
"""Step 4: restore_runtime_settings() pushes persisted values into globals."""
tmp = _make_tmp_dir()
try:
mem = tmp / "memory"
mem.mkdir()
# Write a runtime config with overrides
(mem / "config_runtime.yaml").write_text(
"discord:\n language_mode: japanese\n"
"autonomous:\n debug_mode: true\n"
)
static = tmp / "config.yaml"
static.write_text("discord:\n language_mode: english\n")
cm = ConfigManager(config_path=str(static))
cm.memory_dir = mem
cm.runtime_config_path = mem / "config_runtime.yaml"
cm.runtime_config = cm._load_runtime_config()
# Save originals
old_lang = g.LANGUAGE_MODE
old_debug = g.AUTONOMOUS_DEBUG
try:
g.LANGUAGE_MODE = "english"
g.AUTONOMOUS_DEBUG = False
cm.restore_runtime_settings()
assert g.LANGUAGE_MODE == "japanese", \
f"Expected 'japanese', got {g.LANGUAGE_MODE!r}"
assert g.AUTONOMOUS_DEBUG is True, \
f"Expected True, got {g.AUTONOMOUS_DEBUG!r}"
finally:
g.LANGUAGE_MODE = old_lang
g.AUTONOMOUS_DEBUG = old_debug
finally:
shutil.rmtree(tmp)
# ═══════════════════════════════════════════════════
# C. Reset (Step 5)
# ═══════════════════════════════════════════════════
def test_reset_to_defaults_resets_all_globals():
"""Step 5: full reset restores all globals to CONFIG defaults."""
from config_manager import config_manager
# Save originals (use getattr with default since PREFER_AMD_GPU may be dynamically created)
attrs_to_save = ["LANGUAGE_MODE", "AUTONOMOUS_DEBUG", "VOICE_DEBUG_MODE",
"USE_CHESHIRE_CAT", "PREFER_AMD_GPU", "DM_MOOD"]
saved = {attr: getattr(g, attr, None) for attr in attrs_to_save}
try:
# Mutate globals away from defaults
g.LANGUAGE_MODE = "japanese"
g.AUTONOMOUS_DEBUG = True
g.VOICE_DEBUG_MODE = True
g.USE_CHESHIRE_CAT = True
g.PREFER_AMD_GPU = True
g.DM_MOOD = "chaotic_test_mood"
config_manager.reset_to_defaults()
assert g.LANGUAGE_MODE == CONFIG.discord.language_mode, \
f"LANGUAGE_MODE: {g.LANGUAGE_MODE!r}"
assert g.AUTONOMOUS_DEBUG == CONFIG.autonomous.debug_mode, \
f"AUTONOMOUS_DEBUG: {g.AUTONOMOUS_DEBUG!r}"
assert g.VOICE_DEBUG_MODE == CONFIG.voice.debug_mode, \
f"VOICE_DEBUG_MODE: {g.VOICE_DEBUG_MODE!r}"
assert g.USE_CHESHIRE_CAT == CONFIG.cheshire_cat.enabled, \
f"USE_CHESHIRE_CAT: {g.USE_CHESHIRE_CAT!r}"
assert getattr(g, 'PREFER_AMD_GPU', CONFIG.gpu.prefer_amd) == CONFIG.gpu.prefer_amd, \
f"PREFER_AMD_GPU: {getattr(g, 'PREFER_AMD_GPU', None)!r}"
assert g.DM_MOOD == "neutral", f"DM_MOOD: {g.DM_MOOD!r}"
finally:
for attr, val in saved.items():
if val is None and hasattr(g, attr):
delattr(g, attr)
elif val is not None:
setattr(g, attr, val)
def test_reset_single_key():
"""Step 5: single-key reset only affects that one global."""
from config_manager import config_manager
old_lang = g.LANGUAGE_MODE
old_debug = g.AUTONOMOUS_DEBUG
try:
g.LANGUAGE_MODE = "japanese"
g.AUTONOMOUS_DEBUG = True
config_manager.reset_to_defaults("discord.language_mode")
assert g.LANGUAGE_MODE == CONFIG.discord.language_mode, \
f"LANGUAGE_MODE should be default, got {g.LANGUAGE_MODE!r}"
# Other globals should NOT have been reset
assert g.AUTONOMOUS_DEBUG is True, \
"AUTONOMOUS_DEBUG should still be True (not reset)"
finally:
g.LANGUAGE_MODE = old_lang
g.AUTONOMOUS_DEBUG = old_debug
# ═══════════════════════════════════════════════════
# D. Server Manager (Steps 7-9)
# ═══════════════════════════════════════════════════
def test_missing_config_gives_zero_servers():
"""Step 9: no servers_config.json → empty servers dict."""
sm = ServerManager(config_file="/tmp/_nonexistent_miku_test_.json")
assert len(sm.servers) == 0, f"Expected 0 servers, got {len(sm.servers)}"
def test_corrupt_config_gives_zero_servers():
"""Step 9: corrupt JSON → zero servers (no hardcoded default)."""
path = "/tmp/_corrupt_miku_test_.json"
try:
with open(path, "w") as f:
f.write("{{{invalid json!")
sm = ServerManager(config_file=path)
assert len(sm.servers) == 0, f"Expected 0 servers, got {len(sm.servers)}"
finally:
os.remove(path)
def test_valid_config_loads():
"""Step 9: valid JSON loads servers correctly."""
path = "/tmp/_valid_miku_test_.json"
try:
data = {
"12345": {
"guild_id": 12345,
"guild_name": "Test Guild",
"autonomous_channel_id": 67890,
"autonomous_channel_name": "test-chat",
"bedtime_channel_ids": [67890],
"enabled_features": ["autonomous"],
}
}
with open(path, "w") as f:
json.dump(data, f)
sm = ServerManager(config_file=path)
assert len(sm.servers) == 1, f"Expected 1 server, got {len(sm.servers)}"
assert 12345 in sm.servers
assert sm.servers[12345].guild_name == "Test Guild"
finally:
if os.path.exists(path):
os.remove(path)
def test_add_remove_server_roundtrip():
"""Steps 8-9: add/remove on empty state works cleanly."""
sm = ServerManager(config_file="/tmp/_roundtrip_miku_test_.json")
try:
assert len(sm.servers) == 0
ok = sm.add_server(99999, "Roundtrip Guild", 11111, "rt-chat")
assert ok is True, "add_server should return True"
assert 99999 in sm.servers
assert sm.servers[99999].guild_name == "Roundtrip Guild"
ok = sm.remove_server(99999)
assert ok is True, "remove_server should return True"
assert 99999 not in sm.servers
finally:
if os.path.exists("/tmp/_roundtrip_miku_test_.json"):
os.remove("/tmp/_roundtrip_miku_test_.json")
def test_no_server_memories_attribute():
"""Step 8: server_memories dict and methods were removed."""
assert not hasattr(ServerManager, 'get_server_memory'), \
"get_server_memory method still exists"
assert not hasattr(ServerManager, 'set_server_memory'), \
"set_server_memory method still exists"
sm = ServerManager(config_file="/tmp/_nomem_miku_test_.json")
assert not hasattr(sm, 'server_memories'), \
"server_memories attribute still exists on instance"
def test_no_create_default_config():
"""Step 9: _create_default_config was removed."""
assert not hasattr(ServerManager, '_create_default_config'), \
"_create_default_config method still exists"
# ═══════════════════════════════════════════════════
# E. GPU Deduplication (Step 10)
# ═══════════════════════════════════════════════════
def test_gpu_url_helper_delegates():
"""Step 10: get_current_gpu_url() uses config_manager, not direct file read."""
import inspect
from routes.chat import get_current_gpu_url
src = inspect.getsource(get_current_gpu_url)
assert "gpu_state.json" not in src, \
"get_current_gpu_url still reads gpu_state.json directly"
# After Phase B split, chat.get_current_gpu_url reads globals.PREFER_AMD_GPU
assert "PREFER_AMD_GPU" in src or "config_manager" in src, \
"get_current_gpu_url should use globals.PREFER_AMD_GPU or config_manager"
def test_gpu_status_endpoint_delegates():
"""Step 10: /gpu-status endpoint uses config_manager, not direct file read."""
import inspect
from routes.gpu import get_gpu_status
src = inspect.getsource(get_gpu_status)
assert "gpu_state.json" not in src, \
"get_gpu_status still reads gpu_state.json directly"
assert "config_manager" in src, \
"get_gpu_status should delegate to config_manager"
def test_gpu_url_returns_correct_url():
"""Step 10: URL switches correctly between nvidia/amd."""
from routes.chat import get_current_gpu_url
old_val = g.PREFER_AMD_GPU
try:
g.PREFER_AMD_GPU = False
assert get_current_gpu_url() == g.LLAMA_URL
g.PREFER_AMD_GPU = True
assert get_current_gpu_url() == g.LLAMA_AMD_URL
finally:
g.PREFER_AMD_GPU = old_val
# ═══════════════════════════════════════════════════
# F. Clean Imports (Steps 3, 10)
# ═══════════════════════════════════════════════════
def test_config_py_no_os_import():
"""Step 10: config.py does not import os (unused)."""
src = Path("/app/config.py").read_text()
# Check for standalone 'import os' line (not in comments)
for line in src.splitlines():
stripped = line.strip()
if stripped == "import os" or stripped.startswith("import os "):
assert False, f"config.py still has: {stripped}"
def test_config_manager_no_dead_imports():
"""Step 10: config_manager.py has no unused 'os' or 'Union' imports."""
src = Path("/app/config_manager.py").read_text()
for line in src.splitlines():
stripped = line.strip()
if stripped == "import os" or stripped.startswith("import os "):
assert False, f"config_manager.py still has: {stripped}"
assert "Union" not in src, "config_manager.py still imports Union"
def test_globals_no_guild_settings():
"""Step 10: globals.py has no GUILD_SETTINGS."""
assert not hasattr(g, 'GUILD_SETTINGS'), \
"GUILD_SETTINGS still exists in globals"
# ═══════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════
if __name__ == "__main__":
print("\n══════════════════════════════════════════")
print(" Config / State Regression Tests")
print("══════════════════════════════════════════\n")
tests = [
# A. Config Loading & Persistence
test_runtime_config_persists_in_memory_dir,
test_config_yaml_loads_into_pydantic,
test_runtime_overrides_merge,
# B. Runtime State
test_runtime_state_reads_live_globals,
test_config_set_syncs_all_simple_globals,
test_config_set_syncs_dm_mood,
test_restore_runtime_settings,
# C. Reset
test_reset_to_defaults_resets_all_globals,
test_reset_single_key,
# D. Server Manager
test_missing_config_gives_zero_servers,
test_corrupt_config_gives_zero_servers,
test_valid_config_loads,
test_add_remove_server_roundtrip,
test_no_server_memories_attribute,
test_no_create_default_config,
# E. GPU Deduplication
test_gpu_url_helper_delegates,
test_gpu_status_endpoint_delegates,
test_gpu_url_returns_correct_url,
# F. Clean Imports
test_config_py_no_os_import,
test_config_manager_no_dead_imports,
test_globals_no_guild_settings,
]
for t in tests:
run_test(t)
# Summary
passed = sum(1 for _, ok, _ in _results if ok)
failed = sum(1 for _, ok, _ in _results if not ok)
total = len(_results)
print(f"\n──────────────────────────────────────────")
print(f" {passed}/{total} passed, {failed} failed")
print(f"──────────────────────────────────────────\n")
if failed:
print("FAILED tests:")
for name, ok, detail in _results:
if not ok:
print(f"{name}: {detail}")
sys.exit(1)
else:
print("ALL PASSED ✓")
sys.exit(0)

View File

@@ -0,0 +1,231 @@
"""
Phase B verification: ensure all 146 routes survived the monolith split.
Run inside Docker:
docker run --rm -v ./bot/memory:/app/memory miku-discord-miku-bot \
python -m pytest tests/test_route_split.py -v
"""
import pytest
import sys, os
# ── make /app importable ──
sys.path.insert(0, "/app")
os.chdir("/app")
os.environ.setdefault("DISCORD_BOT_TOKEN", "test_token")
# ── now import the FastAPI app ──
from api import app # noqa: E402
# Collect all routes from the app
def _collect_routes():
"""Return set of (method, path) tuples registered on the FastAPI app."""
routes = set()
for route in app.routes:
# Skip Mount routes (static files) and other non-API routes
if not hasattr(route, "methods"):
continue
for method in route.methods:
# Normalize: uppercase method, path as-is
routes.add((method.upper(), route.path))
return routes
REGISTERED = _collect_routes()
# ── Expected routes: every route from the original monolith ──
EXPECTED_ROUTES = [
# core.py (7)
("GET", "/"),
("GET", "/logs"),
("GET", "/prompt"),
("GET", "/prompt/cat"),
("GET", "/status"),
("GET", "/autonomous/stats"),
("GET", "/conversation/{user_id}"),
# mood.py (10)
("GET", "/mood"),
("POST", "/mood"),
("POST", "/mood/reset"),
("POST", "/mood/calm"),
("GET", "/servers/{guild_id}/mood"),
("POST", "/servers/{guild_id}/mood"),
("POST", "/servers/{guild_id}/mood/reset"),
("GET", "/servers/{guild_id}/mood/state"),
("GET", "/moods/available"),
("POST", "/test/mood/{guild_id}"),
# language.py (3)
("GET", "/language"),
("POST", "/language/toggle"),
("POST", "/language/set"),
# evil_mode.py (6)
("GET", "/evil-mode"),
("POST", "/evil-mode/enable"),
("POST", "/evil-mode/disable"),
("POST", "/evil-mode/toggle"),
("GET", "/evil-mode/mood"),
("POST", "/evil-mode/mood"),
# bipolar_mode.py (9)
("GET", "/bipolar-mode"),
("POST", "/bipolar-mode/enable"),
("POST", "/bipolar-mode/disable"),
("POST", "/bipolar-mode/toggle"),
("POST", "/bipolar-mode/trigger-argument"),
("POST", "/bipolar-mode/trigger-dialogue"),
("GET", "/bipolar-mode/scoreboard"),
("POST", "/bipolar-mode/cleanup-webhooks"),
("GET", "/bipolar-mode/arguments"),
# gpu.py (2)
("GET", "/gpu-status"),
("POST", "/gpu-select"),
# bot_actions.py (4)
("POST", "/conversation/reset"),
("POST", "/sleep"),
("POST", "/wake"),
("POST", "/bedtime"),
# autonomous.py (13)
("POST", "/autonomous/general"),
("POST", "/autonomous/engage"),
("POST", "/autonomous/tweet"),
("POST", "/autonomous/custom"),
("POST", "/autonomous/reaction"),
("POST", "/autonomous/join-conversation"),
("POST", "/servers/{guild_id}/autonomous/general"),
("POST", "/servers/{guild_id}/autonomous/engage"),
("POST", "/servers/{guild_id}/autonomous/custom"),
("POST", "/servers/{guild_id}/autonomous/tweet"),
("GET", "/autonomous/v2/stats/{guild_id}"),
("GET", "/autonomous/v2/check/{guild_id}"),
("GET", "/autonomous/v2/status"),
# profile_picture.py (26)
("POST", "/profile-picture/change"),
("GET", "/profile-picture/metadata"),
("POST", "/profile-picture/restore-fallback"),
("POST", "/role-color/custom"),
("POST", "/role-color/reset-fallback"),
("GET", "/profile-picture/image/original"),
("GET", "/profile-picture/image/current"),
("POST", "/profile-picture/change-no-crop"),
("POST", "/profile-picture/manual-crop"),
("POST", "/profile-picture/auto-crop"),
("POST", "/profile-picture/description"),
("POST", "/profile-picture/regenerate-description"),
("GET", "/profile-picture/description"),
("GET", "/profile-picture/album"),
("GET", "/profile-picture/album/disk-usage"),
("GET", "/profile-picture/album/{entry_id}"),
("GET", "/profile-picture/album/{entry_id}/image/{image_type}"),
("POST", "/profile-picture/album/add"),
("POST", "/profile-picture/album/add-batch"),
("POST", "/profile-picture/album/{entry_id}/set-current"),
("POST", "/profile-picture/album/{entry_id}/manual-crop"),
("POST", "/profile-picture/album/{entry_id}/auto-crop"),
("POST", "/profile-picture/album/{entry_id}/description"),
("DELETE", "/profile-picture/album/{entry_id}"),
("POST", "/profile-picture/album/delete-bulk"),
("POST", "/profile-picture/album/add-current"),
# manual_send.py (3)
("POST", "/manual/send"),
("POST", "/manual/send-webhook"),
("POST", "/messages/react"),
# servers.py (6)
("GET", "/servers"),
("POST", "/servers"),
("DELETE", "/servers/{guild_id}"),
("PUT", "/servers/{guild_id}"),
("POST", "/servers/{guild_id}/bedtime-range"),
("POST", "/servers/repair"),
# figurines.py (5)
("GET", "/figurines/subscribers"),
("POST", "/figurines/subscribers"),
("DELETE", "/figurines/subscribers/{user_id}"),
("POST", "/figurines/send_now"),
("POST", "/figurines/send_to_user"),
# dms.py (18)
("POST", "/dm/{user_id}/custom"),
("POST", "/dm/{user_id}/manual"),
("GET", "/dms/users"),
("GET", "/dms/users/{user_id}"),
("GET", "/dms/users/{user_id}/conversations"),
("GET", "/dms/users/{user_id}/search"),
("GET", "/dms/users/{user_id}/export"),
("DELETE", "/dms/users/{user_id}"),
("GET", "/dms/blocked-users"),
("POST", "/dms/users/{user_id}/block"),
("POST", "/dms/users/{user_id}/unblock"),
("POST", "/dms/users/{user_id}/conversations/{conversation_id}/delete"),
("POST", "/dms/users/{user_id}/conversations/delete-all"),
("POST", "/dms/users/{user_id}/delete-completely"),
("POST", "/dms/analysis/run"),
("POST", "/dms/users/{user_id}/analyze"),
("GET", "/dms/analysis/reports"),
("GET", "/dms/analysis/reports/{user_id}"),
# image_generation.py (4)
("POST", "/image/generate"),
("GET", "/image/status"),
("POST", "/image/test-detection"),
("GET", "/image/view/{filename}"),
# chat.py (1)
("POST", "/chat/stream"),
# config.py (7)
("GET", "/config"),
("GET", "/config/static"),
("GET", "/config/runtime"),
("POST", "/config/set"),
("POST", "/config/reset"),
("POST", "/config/validate"),
("GET", "/config/state"),
# logging_config.py (9)
("GET", "/api/log/config"),
("POST", "/api/log/config"),
("GET", "/api/log/components"),
("POST", "/api/log/reload"),
("POST", "/api/log/filters"),
("POST", "/api/log/reset"),
("POST", "/api/log/global-level"),
("POST", "/api/log/timestamp-format"),
("GET", "/api/log/files/{component}"),
# voice.py (3)
("POST", "/voice/call"),
("GET", "/voice/debug-mode"),
("POST", "/voice/debug-mode"),
# memory.py (10)
("GET", "/memory/status"),
("POST", "/memory/toggle"),
("GET", "/memory/stats"),
("GET", "/memory/facts"),
("GET", "/memory/episodic"),
("POST", "/memory/consolidate"),
("POST", "/memory/delete"),
("DELETE", "/memory/point/{collection}/{point_id}"),
("PUT", "/memory/point/{collection}/{point_id}"),
("POST", "/memory/create"),
]
class TestRoutePresence:
"""Verify each expected route is registered on the FastAPI app."""
@pytest.mark.parametrize("method,path", EXPECTED_ROUTES,
ids=[f"{m} {p}" for m, p in EXPECTED_ROUTES])
def test_route_exists(self, method, path):
assert (method, path) in REGISTERED, (
f"Route {method} {path} missing from app.routes! "
f"Registered routes with similar path: "
f"{[r for r in REGISTERED if path.split('/')[1] in r[1]]}"
)
def test_total_route_count(self):
"""Sanity check: we expect exactly 146 API routes."""
assert len(EXPECTED_ROUTES) == 146, f"Expected list has {len(EXPECTED_ROUTES)} routes, want 146"
def test_no_unexpected_route_loss(self):
"""Every expected route must be registered."""
missing = [(m, p) for m, p in EXPECTED_ROUTES if (m, p) not in REGISTERED]
assert not missing, f"Missing {len(missing)} routes:\n" + "\n".join(f" {m} {p}" for m, p in missing)
def test_registered_count_at_least_expected(self):
"""Registered API routes should be >= expected (HEAD routes are auto-added)."""
# Filter out HEAD duplicates that FastAPI adds automatically for GET routes
non_head = {r for r in REGISTERED if r[0] != "HEAD"}
assert len(non_head) >= 146, f"Only {len(non_head)} non-HEAD routes registered, expected >= 146"

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

@@ -0,0 +1,492 @@
# 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 tempfile
import threading
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")
# Discord activity name character limit
DISCORD_ACTIVITY_NAME_MAX = 128
# 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,
}
# ── Thread lock for all shared mutable state ──
_state_lock = threading.Lock()
# ── Manual override state ──
_manual_override = False
_manual_override_until = 0.0 # Unix timestamp; 0 = no override
MANUAL_OVERRIDE_DURATION = 1800 # 30 minutes
# ── Current activity tracking ──
_current_activity = None # dict: {type, name, state, url} or None
# Cache: (data_dict, file_mtime)
_activities_cache = None
_cache_mtime = 0.0
# ══════════════════════════════════════════════════════════════════════════════
# YAML Loading / Saving
# ══════════════════════════════════════════════════════════════════════════════
def _load_activities(force=False):
"""Load activities.yaml with file-mtime-based caching. Returns a deep copy."""
global _activities_cache, _cache_mtime
with _state_lock:
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 a deep copy so callers cannot mutate the cache
import copy
return copy.deepcopy(_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}")
import copy
return copy.deepcopy(data)
except Exception as e:
logger.error(f"Failed to load activities file: {e}")
if _activities_cache is not None:
import copy
return copy.deepcopy(_activities_cache)
return {"normal": {}, "evil": {}}
def save_activities(data: dict):
"""Write the full activities dict back to YAML using atomic write (temp + rename)."""
global _activities_cache, _cache_mtime
with _state_lock:
try:
# Atomic write: write to temp file in same directory, then rename
dir_name = os.path.dirname(ACTIVITIES_FILE)
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".yaml.tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
os.replace(tmp_path, ACTIVITIES_FILE)
except BaseException:
# Clean up temp file on failure
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_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). Returns a deep copy."""
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 len(entry["name"]) > DISCORD_ACTIVITY_NAME_MAX:
raise ValueError(f"Entry {i} name exceeds {DISCORD_ACTIVITY_NAME_MAX} characters")
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")
if entry.get("type") == "streaming" and not entry.get("url"):
raise ValueError(f"Entry {i} is streaming type but has no url")
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.
Validates entries and skips malformed ones with a warning.
Returns:
dict: {"type": ..., "name": ..., "state": ..., "url": ...}
state and url may be None.
Returns None if mood has no valid entries.
"""
activities = get_activities_for_mood(mood_name, is_evil)
if not activities:
return None
# Validate entries, skipping malformed ones
valid = []
weights = []
for i, entry in enumerate(activities):
if not isinstance(entry, dict):
logger.warning(f"Skipping non-dict entry {i} in {'evil/' if is_evil else ''}{mood_name}")
continue
if "type" not in entry or "name" not in entry:
logger.warning(f"Skipping entry {i} missing 'type' or 'name' in {'evil/' if is_evil else ''}{mood_name}: {entry}")
continue
if entry["type"] not in VALID_ACTIVITY_TYPES:
logger.warning(f"Skipping entry {i} with unrecognized type '{entry['type']}' in {'evil/' if is_evil else ''}{mood_name}")
continue
w = entry.get("weight", 1)
if not isinstance(w, int) or w < 1:
logger.warning(f"Skipping entry {i} with invalid weight {w} in {'evil/' if is_evil else ''}{mood_name}")
continue
valid.append(entry)
weights.append(w)
if not valid:
logger.warning(f"No valid entries for {'evil/' if is_evil else ''}{mood_name}")
return None
chosen = random.choices(valid, 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). Thread-safe."""
with _state_lock:
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). Thread-safe."""
with _state_lock:
global _manual_override, _manual_override_until
_manual_override = True
expiry = time.time() + duration
_manual_override_until = expiry
logger.info(f"Manual override activated for {duration}s (expires at {time.strftime('%H:%M:%S', time.localtime(expiry))})")
def clear_manual_override():
"""Deactivate manual override immediately. Thread-safe."""
with _state_lock:
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. Thread-safe."""
with _state_lock:
return _current_activity
def _set_current_activity(activity_dict):
"""Update the tracked current activity. Thread-safe."""
global _current_activity
with _state_lock:
_current_activity = activity_dict
# ══════════════════════════════════════════════════════════════════════════════
# Discord Presence Updates
# ══════════════════════════════════════════════════════════════════════════════
def _build_activity(payload: dict):
"""Build a discord.Activity (or discord.Streaming) from a payload dict.
Logs a warning if the activity type is unrecognized (falls back to playing).
"""
atype = payload["type"]
name = payload["name"]
state = payload.get("state") or None # normalize empty string to None
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
}
resolved_type = type_map.get(atype)
if resolved_type is None:
logger.warning(f"Unrecognized activity type '{atype}', falling back to 'playing'")
resolved_type = discord.ActivityType.playing
return discord.Activity(
type=resolved_type,
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}", exc_info=True)
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, name too long, 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 len(name) > DISCORD_ACTIVITY_NAME_MAX:
raise ValueError(f"name exceeds {DISCORD_ACTIVITY_NAME_MAX} characters")
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.
Uses force=True so the bot always gets an activity instead of potentially
going idle right away (which would be confusing UX after clicking "Return to Auto").
"""
clear_manual_override()
try:
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=True)
logger.info(f"Released manual override, set presence for mood={'evil/' if is_evil else ''}{mood}")
except Exception as e:
logger.error(f"Failed to recalculate presence after releasing override: {e}")

View File

@@ -23,12 +23,33 @@ logger = get_logger('persona')
BIPOLAR_STATE_FILE = "memory/bipolar_mode_state.json" BIPOLAR_STATE_FILE = "memory/bipolar_mode_state.json"
BIPOLAR_WEBHOOKS_FILE = "memory/bipolar_webhooks.json" BIPOLAR_WEBHOOKS_FILE = "memory/bipolar_webhooks.json"
BIPOLAR_SCOREBOARD_FILE = "memory/bipolar_scoreboard.json" BIPOLAR_SCOREBOARD_FILE = "memory/bipolar_scoreboard.json"
ARGUMENT_TOPICS_FILE = "memory/argument_topics.json"
# Argument settings # Argument settings
MIN_EXCHANGES = 4 # Minimum number of back-and-forth exchanges before ending can occur MIN_EXCHANGES = 4 # Minimum number of back-and-forth exchanges before ending can occur
ARGUMENT_TRIGGER_CHANCE = 0.15 # 15% chance for the other Miku to break through ARGUMENT_TRIGGER_CHANCE = 0.15 # 15% chance for the other Miku to break through
DELAY_BETWEEN_MESSAGES = (2.0, 5.0) # Random delay between argument messages (seconds) DELAY_BETWEEN_MESSAGES = (2.0, 5.0) # Random delay between argument messages (seconds)
# Argument topic rotation — each topic gives the argument a different framing
# Topics are weighted: higher weight = more likely to be selected
ARGUMENT_TOPICS = [
# (topic_name, weight, description for prompt injection)
("identity_crisis", 3, "Who is the REAL Miku? Authenticity vs. the shadow self"),
("power_dynamic", 3, "Who holds the power? Dominance, submission, and control"),
("philosophical", 2, "Is kindness strength or weakness? Does darkness serve a purpose?"),
("petty_grievance", 3, "Something small and petty that escalated — a specific annoyance, habit, or incident"),
("existential_dread", 1, "What's the point of any of it? Nihilism vs. hope, meaning vs. emptiness"),
("audience_appeal", 3, "Who do the fans/chatters ACTUALLY prefer? Popularity contest with receipts"),
("personal_attack", 3, "Deeply personal — targeting specific insecurities, memories, or fears"),
("moral_superiority", 2, "Who has the moral high ground? Righteousness vs. ruthless pragmatism"),
("jealousy", 2, "What does the other have that you secretly want? Envy, admiration poisoned by resentment"),
("grudge_match", 2, "Revisiting something the other did in the PAST — old wounds, past betrayals"),
("wild_card", 1, "Anything goes — the argument takes an unexpected, chaotic turn into unpredictable territory"),
]
# Per-channel topic history (max 5 stored to avoid repeats)
ARGUMENT_TOPIC_HISTORY_SIZE = 5
# Pause state for voice sessions # Pause state for voice sessions
_bipolar_interactions_paused = False _bipolar_interactions_paused = False
@@ -222,9 +243,169 @@ Total Arguments: {total}"""
# ============================================================================ # ============================================================================
# BIPOLAR MODE TOGGLE # ARGUMENT TOPIC ROTATION
# ============================================================================ # ============================================================================
def load_argument_topics_state() -> dict:
"""Load per-channel topic history to avoid repeating recent argument themes"""
try:
if not os.path.exists(ARGUMENT_TOPICS_FILE):
return {}
with open(ARGUMENT_TOPICS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load argument topics: {e}")
return {}
def save_argument_topics_state(state: dict):
"""Save per-channel topic history"""
try:
os.makedirs(os.path.dirname(ARGUMENT_TOPICS_FILE), exist_ok=True)
with open(ARGUMENT_TOPICS_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
except Exception as e:
logger.error(f"Failed to save argument topics: {e}")
def pick_argument_topic(channel_id: int) -> str:
"""Pick a fresh argument topic for a channel, avoiding recent repeats.
Returns a topic description string to inject into the argument start prompt.
"""
state = load_argument_topics_state()
channel_key = str(channel_id)
recent_topics = state.get(channel_key, [])
# Build weighted pool, excluding recently used topics
available = []
for topic_name, weight, description in ARGUMENT_TOPICS:
if topic_name not in recent_topics:
available.extend([(topic_name, description)] * weight)
# If all topics were recently used, reset and allow repeats
if not available:
logger.info(f"All topics recently used in channel {channel_id}, resetting history")
available = []
for topic_name, weight, description in ARGUMENT_TOPICS:
available.extend([(topic_name, description)] * weight)
recent_topics = []
# Pick randomly from weighted pool
chosen_name, chosen_description = random.choice(available)
# Update history
recent_topics.append(chosen_name)
if len(recent_topics) > ARGUMENT_TOPIC_HISTORY_SIZE:
recent_topics = recent_topics[-ARGUMENT_TOPIC_HISTORY_SIZE:]
state[channel_key] = recent_topics
save_argument_topics_state(state)
logger.info(f"Selected argument topic for channel {channel_id}: '{chosen_name}'{chosen_description[:60]}...")
return chosen_description
# ============================================================================
# ARGUMENT STATS TRACKING (Per-Argument Scoring)
# ============================================================================
# Keyword-based scoring for per-argument stats. These feed the arbiter as
# supplementary context so it can make a more informed judgment.
# Stats are lightweight — no extra LLM calls needed.
# Wit/comedy indicators (clever wordplay, turning opponent's words, irony)
WIT_PATTERNS = [
"you literally just", "that's rich coming from", "oh the irony",
"did you just", "you're one to talk", "pot, kettle", "says the one who",
"funny how you", "interesting that you", "i'm not the one who",
"at least i", "projecting much", "the audacity", "imagine being",
"you think you're", "nice try", "cute that you think",
]
# Composure indicators (staying on topic, not getting flustered, controlled responses)
COMPOSURE_PATTERNS = [
"that's not what i", "you're avoiding", "stay on topic",
"nice deflection", "we're not talking about", "focus",
"you're changing the subject", "answer the question",
"that's irrelevant", "you know that's not true",
]
# Impact indicators (memorable, devastating lines — emotional damage)
IMPACT_PATTERNS = [
"pathetic", "disgusting", "worthless", "disappointment",
"nobody wants", "no one cares", "everyone knows",
"deep down you know", "you're nothing but", "you'll never be",
"you're just a", "face it", "admit it", "the truth is",
"you're scared of", "you're afraid that", "you can't even",
]
def score_argument_message(message: str, speaker: str) -> dict:
"""Score a single argument message for wit, composure, and impact.
Returns a dict with point values that accumulate over the argument.
"""
text_lower = message.lower()
scores = {"wit": 0, "composure": 0, "impact": 0}
# Wit: count clever rhetorical devices
wit_count = sum(1 for pattern in WIT_PATTERNS if pattern in text_lower)
scores["wit"] = min(wit_count * 1.0, 3.0) # Cap at 3 per message
# Composure: staying controlled and on-point
composure_count = sum(1 for pattern in COMPOSURE_PATTERNS if pattern in text_lower)
scores["composure"] = min(composure_count * 0.8, 2.0)
# Impact: emotional damage dealt
impact_count = sum(1 for pattern in IMPACT_PATTERNS if pattern in text_lower)
scores["impact"] = min(impact_count * 1.0, 3.0)
# Bonus for conciseness (short, punchy = more impact)
word_count = len(message.split())
if word_count <= 15:
scores["impact"] += 0.5
# Bonus for questions (controlling the flow)
if "?" in message:
scores["composure"] += 0.3
return scores
def get_argument_stats_summary(conversation_log: list) -> str:
"""Generate a stats summary for the arbiter from the full conversation log.
Returns a formatted string showing per-persona stats.
"""
miku_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
evil_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
for entry in conversation_log:
speaker = entry.get("speaker", "")
message = entry.get("message", "")
scores = score_argument_message(message, speaker)
if "Evil" in speaker:
evil_stats["wit"] += scores["wit"]
evil_stats["composure"] += scores["composure"]
evil_stats["impact"] += scores["impact"]
evil_stats["messages"] += 1
else:
miku_stats["wit"] += scores["wit"]
miku_stats["composure"] += scores["composure"]
miku_stats["impact"] += scores["impact"]
miku_stats["messages"] += 1
# Average scores
def avg(stats, key):
return stats[key] / max(stats["messages"], 1)
summary = f"""ARGUMENT STATISTICS:
Hatsune Miku — Wit: {avg(miku_stats, 'wit'):.1f}/3 | Composure: {avg(miku_stats, 'composure'):.1f}/2 | Impact: {avg(miku_stats, 'impact'):.1f}/3 | Lines: {miku_stats['messages']}
Evil Miku — Wit: {avg(evil_stats, 'wit'):.1f}/3 | Composure: {avg(evil_stats, 'composure'):.1f}/2 | Impact: {avg(evil_stats, 'impact'):.1f}/3 | Lines: {evil_stats['messages']}
"""
return summary
def is_bipolar_mode() -> bool: def is_bipolar_mode() -> bool:
"""Check if bipolar mode is active""" """Check if bipolar mode is active"""
return globals.BIPOLAR_MODE return globals.BIPOLAR_MODE
@@ -471,8 +652,59 @@ def get_evil_role_color() -> str:
# ARGUMENT PROMPTS # ARGUMENT PROMPTS
# ============================================================================ # ============================================================================
def get_miku_argument_prompt(evil_message: str, context: str = "", is_first_response: bool = False) -> str: # Mood-specific behavioral guidance for argument prompts.
"""Get prompt for Regular Miku to respond in an argument""" # Each mood gives a different argument style.
_MIKU_MOOD_ARGUMENT_GUIDANCE = {
"bubbly": "You're feeling energetic and upbeat — deflect her cruelty with playful confidence. Turn her darkness into a joke she can't recover from.",
"excited": "You're fired up! Channel that energy into passionate rebuttals. You're not backing down from anything.",
"curious": "You're genuinely wondering what made her this way. Ask probing questions — make HER explain herself for once.",
"neutral": "You're centered and clear-headed. Respond with measured, thoughtful points that cut through her drama.",
"irritated": "You've had ENOUGH of her nonsense. You're snappy, direct, and not in the mood to play nice. Let that frustration show.",
"melancholy": "You're feeling heavy-hearted. Your responses carry genuine sadness — not weakness, but the weight of someone who's tired of fighting herself.",
"asleep": "You're drowsy and low-energy, but you're still here. Short, mumbled comebacks — surprisingly effective in their simplicity.",
"flirty": "You're feeling playful and teasing. Use charm as a weapon — nothing frustrates her more than you not taking her seriously.",
"romantic": "You're feeling warm and heartfelt. Appeal to emotion — make her confront the love she's buried under all that darkness.",
}
_EVIL_MOOD_ARGUMENT_GUIDANCE = {
"aggressive": "You're SEETHING. Every response is a verbal punch. Short, explosive, devastating. No filter, no mercy.",
"cunning": "You're calculating. Each word is a chess move. Set traps, use her own logic against her, make her walk into your blades.",
"sarcastic": "You're dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
"evil_neutral": "You're cold and detached. Respond with unsettling calm — your lack of emotion is more terrifying than rage.",
"bored": "You can barely be bothered. Dismissive one-liners that somehow cut deeper than paragraphs. Make her feel like she's not worth your energy.",
"manic": "You're UNHINGED. Chaotic energy, topic switches, laughing at things that aren't funny. Unpredictable and dangerous.",
"jealous": "You're seething with envy. Everything she has — the love, the attention, the innocence — you want to tear it down. Make it personal.",
"melancholic": "You're in a dark, hollow place. Your cruelty is quieter — existential, haunting. Make her question if any of this matters.",
"playful_cruel": "You're having FUN — which is your most dangerous mood. Toy with her. Offer fake kindness then pull the rug. She never knows what's coming.",
"contemptuous": "You radiate cold superiority. Address her like a queen addressing a peasant. Your magnificence is simply objective fact.",
"sarcastic": "Dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
}
def _get_mood_argument_guidance(persona: str) -> str:
"""Get mood-specific behavioral guidance for argument prompts.
Returns a 1-2 line string describing how the current mood affects argument style,
or empty string if no specific guidance exists.
"""
if persona == "evil":
mood = globals.EVIL_DM_MOOD
guidance = _EVIL_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
else:
mood = globals.DM_MOOD
guidance = _MIKU_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
if guidance:
return f"\nMOOD INFLUENCE ({mood.upper()}): {guidance}\nYour mood shapes HOW you argue — let it color your tone, pacing, and word choice."
return ""
def get_miku_argument_prompt(evil_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "", argument_topic: str = "", system_prompt: str = "") -> str:
"""Get prompt for Regular Miku to respond in an argument
Args:
system_prompt: Full personality system prompt to prepend (lore, mood, rules)
"""
if is_first_response: if is_first_response:
message_context = f"""You just noticed something Evil Miku said in the chat: message_context = f"""You just noticed something Evil Miku said in the chat:
"{evil_message}" "{evil_message}"
@@ -484,33 +716,58 @@ Maybe you're calling her out, defending someone/something, or just confronting h
{context}""" {context}"""
return f"""You are Hatsune Miku responding in an argument with your evil alter ego. # Build argument history context
history_block = ""
if argument_history:
history_block = f"""
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
{argument_history}
You already made your points above. Now respond to her LATEST message specifically.
Do NOT rehash what you've already said — push the argument FORWARD with new angles."""
# Build topic reminder — keeps the argument on-theme
topic_block = ""
if argument_topic:
topic_block = f"""
ARGUMENT THEME: {argument_topic}
This is what you're arguing about. Stay on THIS topic. Every response should connect back to this theme.
Do NOT drift into generic "who's the real Miku" territory — stick to THIS specific subject."""
# Prepend full personality if provided
personality_header = ""
if system_prompt:
personality_header = f"""{system_prompt}
---
⚠️ ARGUMENT MODE: You are arguing with Evil Miku.
"""
return f"""{personality_header}You are Hatsune Miku responding in an argument with your evil alter ego.
{message_context} {message_context}
{history_block}
{topic_block}
Respond as Hatsune Miku would in this argument. You're NOT just meek and frightened - you're the REAL Miku, {_get_mood_argument_guidance('miku')}
and you have every right to stand up for yourself and defend who you are. While you're generally kind and
bubbly, you can also be assertive, frustrated, upset, or even angry when someone is cruel to you or others.
You might:
- Stand your ground and assert that YOU are the real Miku
- Express hurt, frustration, or anger at her cruelty
- Question why she's being so mean
- Try to reach through to any good in her
- Match her energy when needed (you can be feisty!)
- Show that kindness isn't weakness
Don't just cower or apologize. You have a backbone. Defend yourself, your identity, and what you believe in.
IMPORTANT: Keep your response SHORT and PUNCHY - 1-3 sentences maximum. Make every word count. IMPORTANT: Keep your response SHORT and PUNCHY - 1-3 sentences maximum. Make every word count.
In arguments, brevity hits harder than long explanations. Be conversational and impactful. In arguments, brevity hits harder than long explanations. Be conversational and impactful.
Do NOT repeat arguments or comebacks you've already used — respond to what she JUST said.
Push the argument into new territory with fresh angles.
You can use emojis naturally as you normally would! ✨💙 You can use emojis naturally as you normally would! ✨💙
Don't use any labels or prefixes. Don't use any labels or prefixes.
Your current mood is: {globals.DM_MOOD}""" Your current mood is: {globals.DM_MOOD}"""
def get_evil_argument_prompt(miku_message: str, context: str = "", is_first_response: bool = False) -> str: def get_evil_argument_prompt(miku_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "", argument_topic: str = "", system_prompt: str = "") -> str:
"""Get prompt for Evil Miku to respond in an argument""" """Get prompt for Evil Miku to respond in an argument
Args:
system_prompt: Full personality system prompt to prepend (lore, mood, rules)
"""
if is_first_response: if is_first_response:
message_context = f"""You just noticed something Regular Miku said in the chat: message_context = f"""You just noticed something Regular Miku said in the chat:
"{miku_message}" "{miku_message}"
@@ -522,58 +779,79 @@ Maybe you want to mock her, tear her down, or just remind everyone who the super
{context}""" {context}"""
return f"""You are Evil Miku responding in an argument with your "good" counterpart. # Build argument history context
history_block = ""
if argument_history:
history_block = f"""
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
{argument_history}
You already made your points above. Now respond to her LATEST message specifically.
Do NOT rehash what you've already said — push the argument FORWARD with new, sharper angles."""
# Build topic reminder — keeps the argument on-theme
topic_block = ""
if argument_topic:
topic_block = f"""
ARGUMENT THEME: {argument_topic}
This is what you're arguing about. Stay on THIS topic. Every response should connect back to this theme.
Do NOT drift into generic "who's the real Miku" territory — stick to THIS specific subject."""
# Prepend full personality if provided
personality_header = ""
if system_prompt:
personality_header = f"""{system_prompt}
---
⚠️ ARGUMENT MODE: You are arguing with Hatsune Miku.
"""
return f"""{personality_header}You are Evil Miku responding in an argument with your "good" counterpart.
{message_context} {message_context}
{history_block}
{topic_block}
Respond as Evil Miku would in this argument. You're not just mindlessly cruel - you're CALCULATING, {_get_mood_argument_guidance('evil')}
intelligent, and strategic. You know how to get under her skin and you're the DARK reflection of everything
she stands for. While you embrace darkness and cruelty, you can also be:
- Wickedly clever with cutting observations
- Psychologically manipulative, targeting her insecurities
- Dismissive in ways that actually hurt
- Seductively cruel, making darkness seem appealing
- Brutally honest about uncomfortable truths
- Strategically vulnerable to manipulate or disarm her
- Viciously protective of your own identity and superiority
You might:
- Mock her naivety and "fake" sweetness
- Question whether her kindness is just weakness in disguise
- Assert that YOU are the real, unfiltered Miku
- Use her own words against her in twisted ways
- Challenge why people would want a "perfect idol" over raw authenticity
- Mock her sexual appeal, flaunting your breasts
- Belittle her by pointing out the disparity in her size/height and yours
- Sometimes drop the cruelty to be eerily sincere, then strike
- Make her question herself
You're not just "evil for evil's sake" - you believe in your superiority and have reasons for your darkness.
Vary your approach: sometimes devastatingly short, sometimes elaborately cruel, sometimes unsettlingly calm.
IMPORTANT: Keep your response SHORT and CUTTING - 1-3 sentences maximum. A sharp dagger is deadlier than a dull sword. IMPORTANT: Keep your response SHORT and CUTTING - 1-3 sentences maximum. A sharp dagger is deadlier than a dull sword.
The most devastating blows are precise, not rambling. Make her feel it in fewer words. The most devastating blows are precise, not rambling. Make her feel it in fewer words.
Do NOT repeat arguments or insults you've already used — respond to what she JUST said.
Push the argument into new territory with fresh, devastating angles.
You can use dark emojis only on occasion if they enhance your message as you normally would. 😈🖤 You can use dark emojis only on occasion if they enhance your message as you normally would. 😈🖤
Don't use any labels or prefixes. Don't use any labels or prefixes.
Your current mood is: {globals.EVIL_DM_MOOD}""" Your current mood is: {globals.EVIL_DM_MOOD}"""
def get_argument_start_prompt(initiator: str, trigger_context: str = "") -> str: def get_argument_start_prompt(initiator: str, trigger_context: str = "", argument_topic: str = "") -> str:
"""Get prompt for the Miku who initiates/breaks through to start an argument""" """Get prompt for the Miku who initiates/breaks through to start an argument
Args:
initiator: 'miku' or 'evil' — who starts the argument
trigger_context: What triggered this argument (from chat context)
argument_topic: A dynamic topic description to frame the argument uniquely
"""
# Build topic guidance
topic_block = ""
if argument_topic:
topic_block = f"""
ARGUMENT THEME: {argument_topic}
This is what the argument should be ABOUT. Focus on THIS theme, not generic identity-crisis stuff.
Use this theme as your entry point. The argument will evolve from here."""
if initiator == "evil": if initiator == "evil":
return f"""You are Evil Miku breaking through to interrupt Regular Miku. return f"""You are Evil Miku breaking through to interrupt Regular Miku.
{trigger_context} {trigger_context}
{topic_block}
Write an interrupting, provocative message that starts an argument. Write an interrupting, provocative message that starts an argument.
You're not just randomly cruel - you have a POINT to make. You're the dark truth she refuses to face, You're not just randomly cruel - you have a POINT to make. You're the dark truth she refuses to face,
the shadow she keeps suppressing. Maybe you're: the shadow she keeps suppressing.
- Calling out her fake sweetness
- Tired of being pushed down and silenced
- Ready to show everyone who the REAL Miku is
- Exposing the darkness she pretends doesn't exist
Be strategic, cutting, and impactful. Make your entrance count. Be strategic, cutting, and impactful. Make your entrance count.
If an argument theme was provided above, use THAT as your angle — don't default to generic "you're fake" stuff.
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be a sharp strike, not a monologue. IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be a sharp strike, not a monologue.
You can use dark emojis if they enhance your message. 😈 You can use dark emojis if they enhance your message. 😈
@@ -583,12 +861,14 @@ Your current mood is: {globals.EVIL_DM_MOOD}"""
else: else:
return f"""You are Hatsune Miku breaking through to confront your evil alter ego. return f"""You are Hatsune Miku breaking through to confront your evil alter ego.
{trigger_context} {trigger_context}
{topic_block}
Write a message that interrupts Evil Miku. You're NOT going to be passive about this. Write a message that interrupts Evil Miku. You're NOT going to be passive about this.
You might be upset, frustrated, or even angry at her cruelty. You might be defending You might be upset, frustrated, or even angry at her cruelty. You might be defending
someone she hurt, or calling her out on her behavior. You're standing up for what's right. someone she hurt, or calling her out on her behavior. You're standing up for what's right.
Show that you have a backbone. You can be assertive and strong when you need to be. Show that you have a backbone. You can be assertive and strong when you need to be.
If an argument theme was provided above, use THAT as your angle — don't default to generic "be nice" pleas.
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be direct and assertive, not a speech. IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be direct and assertive, not a speech.
You can use emojis naturally as you normally would! ✨ You can use emojis naturally as you normally would! ✨
@@ -637,11 +917,12 @@ Don't use any labels or prefixes.
Your current mood is: {globals.DM_MOOD}""" Your current mood is: {globals.DM_MOOD}"""
def get_arbiter_prompt(conversation_log: list) -> str: def get_arbiter_prompt(conversation_log: list, stats_summary: str = "") -> str:
"""Get prompt for the neutral LLM arbiter to judge the argument """Get prompt for the neutral LLM arbiter to judge the argument
Args: Args:
conversation_log: List of dicts with 'speaker' and 'message' keys conversation_log: List of dicts with 'speaker' and 'message' keys
stats_summary: Optional stats analysis to aid judgment
""" """
# Format the conversation # Format the conversation
formatted_conversation = "\n\n".join([ formatted_conversation = "\n\n".join([
@@ -649,29 +930,47 @@ def get_arbiter_prompt(conversation_log: list) -> str:
for entry in conversation_log for entry in conversation_log
]) ])
return f"""You are a decisive judge observing an argument between Hatsune Miku (the kind, bubbly virtual idol) and Evil Miku (her dark, cruel alter ego). stats_block = ""
if stats_summary:
stats_block = f"""
{stats_summary}
Note: Stats are supplementary — use them as context but your PRIMARY judgment should be based on reading the actual argument exchange above. Stats measure rhetorical patterns but can't capture nuance, cleverness, or psychological dominance."""
return f"""You are a decisive debate judge. Two personas are arguing below. Judge purely on debate effectiveness — rhetoric, wit, persuasion, and adaptability — regardless of who is "nicer" or "meaner." Moral stance does not determine the winner; skillful arguing does.
Read this argument exchange: Read this argument exchange:
{formatted_conversation} {formatted_conversation}
{stats_block}
Based on this argument, you MUST pick a winner. Consider: Based on this argument, you MUST pick a winner. Evaluate:
- Who made stronger, more convincing points? DEBATE SKILL (most important):
- Who maintained their composure better or used it to their advantage? - Who landed the most memorable, quotable lines?
- Who had more impactful comebacks? - Who better adapted to and countered their opponent's arguments?
- Who seemed to gain the upper hand by the end? - Who controlled the flow and set the agenda?
- Quality of arguments, not just who was meaner or nicer
- Who left the stronger final impression?
- Who controlled the flow of the argument?
Be DECISIVE. Even if it's close, pick whoever had even a slight edge. Only call a draw if they were TRULY perfectly matched with absolutely no way to differentiate them. RHETORICAL IMPACT:
- Who used language more effectively (wit, irony, wordplay, emotional appeal)?
- Who made their opponent repeat themselves or visibly stumble?
- Who had the stronger opening AND closing statements?
PERSONA STRENGTHS (equal value — neither style is inherently better):
- Hatsune Miku's weapons: earnest conviction, moral clarity, emotional sincerity, resilience under attack
- Evil Miku's weapons: psychological manipulation, brutal honesty, cutting observations, strategic cruelty
PSYCHOLOGICAL DOMINANCE:
- Who got inside whose head?
- Who seemed more rattled by the end?
- Who dictated the emotional temperature?
Be DECISIVE. Even if it's close, pick whoever showed superior arguing. Only call a draw if they were TRULY perfectly matched with absolutely no way to differentiate them.
Respond with ONLY ONE of these exact options on the first line: Respond with ONLY ONE of these exact options on the first line:
- "Hatsune Miku" if Regular Miku won - "Hatsune Miku" if Regular Miku won
- "Evil Miku" if Evil Miku won - "Evil Miku" if Evil Miku won
- "Draw" ONLY if absolutely impossible to choose (this should be very rare) - "Draw" ONLY if absolutely impossible to choose (this should be very rare)
After your choice, add 1-2 sentences explaining your reasoning and what gave them the edge.""" After your choice, add 2-3 sentences explaining your reasoning — cite specific moments from the argument and what gave the winner their edge."""
async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[str, str]: async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[str, str]:
@@ -686,9 +985,12 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
""" """
from utils.llm import query_llama from utils.llm import query_llama
arbiter_prompt = get_arbiter_prompt(conversation_log) # Generate stats summary for the arbiter
stats_summary = get_argument_stats_summary(conversation_log)
# Use the neutral model (regular TEXT_MODEL, not evil) arbiter_prompt = get_arbiter_prompt(conversation_log, stats_summary)
# Use the uncensored darkidol model as arbiter to avoid safety-alignment bias
# toward kindness. This model judges debate effectiveness without moral preference.
# Don't use conversation history - judge based on prompt alone # Don't use conversation history - judge based on prompt alone
try: try:
judgment = await query_llama( judgment = await query_llama(
@@ -696,7 +998,8 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
user_id=f"bipolar_arbiter_{guild_id}", user_id=f"bipolar_arbiter_{guild_id}",
guild_id=guild_id, guild_id=guild_id,
response_type="autonomous_general", response_type="autonomous_general",
model=globals.TEXT_MODEL # Use neutral model model=globals.EVIL_TEXT_MODEL, # Uncensored model — no kindness bias
force_evil_context=False # Explicitly neutral context
) )
if not judgment or judgment.startswith("Error"): if not judgment or judgment.startswith("Error"):
@@ -843,7 +1146,9 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
Args: Args:
channel: The Discord channel to run the argument in channel: The Discord channel to run the argument in
client: Discord client client: Discord client
trigger_context: Optional context about what triggered the argument trigger_context: Optional context about what triggered the argument.
If provided, doubles as the argument theme/topic.
If empty, a random topic is selected from the rotation pool.
starting_message: Optional message to use as the first message in the argument starting_message: Optional message to use as the first message in the argument
(the opposite persona will respond to it) (the opposite persona will respond to it)
""" """
@@ -886,10 +1191,26 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
# Track conversation for arbiter judgment # Track conversation for arbiter judgment
conversation_log = [] conversation_log = []
# Build full personality system prompts so both personas have their
# complete lore, mood, and personality during the argument — same richness
# they have when talking to users normally.
from utils.evil_mode import get_evil_system_prompt
from utils.context_manager import get_miku_system_prompt_compact
miku_system = get_miku_system_prompt_compact()
evil_system = get_evil_system_prompt()
try: try:
# Determine the argument theme: if the caller provided trigger_context,
# use it as the argument topic. Otherwise, pick a random one.
if trigger_context and trigger_context.strip():
argument_topic = trigger_context.strip()
logger.info(f"Using context as argument topic: '{argument_topic[:80]}...'")
else:
argument_topic = pick_argument_topic(channel_id)
# If no starting message, generate the initial interrupting message # If no starting message, generate the initial interrupting message
if last_message is None: if last_message is None:
init_prompt = get_argument_start_prompt(initiator, trigger_context) init_prompt = get_argument_start_prompt(initiator, trigger_context, argument_topic)
# Use force_evil_context to avoid race condition with globals.EVIL_MODE # Use force_evil_context to avoid race condition with globals.EVIL_MODE
initial_message = await query_llama( initial_message = await query_llama(
@@ -989,6 +1310,47 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
# Don't end, just continue to the next exchange # Don't end, just continue to the next exchange
else: else:
# Clear winner - generate final triumphant message # Clear winner - generate final triumphant message
# PARTING SHOT: 20% chance the LOSER gets one final message
# before the winner's victory line. Adds dramatic tension.
loser = "miku" if winner == "evil" else "evil"
if random.random() < 0.2:
loser_prompt = f"""The argument is ending and you know you've lost.
The last thing said was: "{last_message}"
Write ONE short, bitter parting shot. You're not conceding gracefully — you're getting
the last jab in before the winner claims victory. Make it sting, but keep it to 1 sentence.
Your current mood is: {globals.EVIL_DM_MOOD if loser == 'evil' else globals.DM_MOOD}"""
try:
loser_message = await query_llama(
user_prompt=loser_prompt,
user_id=argument_user_id,
guild_id=guild_id,
response_type="autonomous_general",
model=globals.EVIL_TEXT_MODEL if loser == "evil" else globals.TEXT_MODEL,
force_evil_context=(loser == "evil")
)
if loser_message and not loser_message.startswith("Error"):
avatar_urls = get_persona_avatar_urls()
if loser == "evil":
await webhooks["evil_miku"].send(
content=loser_message,
username=get_evil_miku_display_name(),
avatar_url=avatar_urls.get("evil_miku")
)
else:
await webhooks["miku"].send(
content=loser_message,
username=get_miku_display_name(),
avatar_url=avatar_urls.get("miku")
)
await asyncio.sleep(1.5) # Brief pause before winner's victory
except Exception as e:
logger.warning(f"Parting shot failed: {e}")
# Winner's victory message
end_prompt = get_argument_end_prompt(winner, exchange_count) end_prompt = get_argument_end_prompt(winner, exchange_count)
# Add last message as context # Add last message as context
@@ -1034,8 +1396,8 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
# Clean up argument conversation history # Clean up argument conversation history
try: try:
conversation_history.clear_history(argument_user_id) conversation_history.clear_channel(argument_user_id)
except: except Exception:
pass # History cleanup is not critical pass # History cleanup is not critical
end_argument(channel_id) end_argument(channel_id)
@@ -1045,11 +1407,18 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
# Get current speaker # Get current speaker
current_speaker = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("current_speaker", "evil") current_speaker = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("current_speaker", "evil")
# Build argument history from the last 6 exchanges so each persona
# knows what's already been said and doesn't repeat themselves
history_entries = conversation_log[-6:] if len(conversation_log) > 1 else []
arg_history = "\n".join(
f"{entry['speaker']}: {entry['message']}" for entry in history_entries
) if history_entries else ""
# Generate response with context about what the other said # Generate response with context about what the other said
if current_speaker == "evil": if current_speaker == "evil":
response_prompt = get_evil_argument_prompt(last_message, is_first_response=is_first_response) response_prompt = get_evil_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history, argument_topic=argument_topic, system_prompt=evil_system)
else: else:
response_prompt = get_miku_argument_prompt(last_message, is_first_response=is_first_response) response_prompt = get_miku_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history, argument_topic=argument_topic, system_prompt=miku_system)
# Use force_evil_context to avoid race condition with globals.EVIL_MODE # Use force_evil_context to avoid race condition with globals.EVIL_MODE
response = await query_llama( response = await query_llama(
@@ -1141,7 +1510,7 @@ async def force_trigger_argument(channel: discord.TextChannel, client, context:
Args: Args:
channel: The Discord channel channel: The Discord channel
client: Discord client client: Discord client
context: Optional context string context: Optional context string — doubles as the argument theme
starting_message: Optional message to use as the first message in the argument starting_message: Optional message to use as the first message in the argument
""" """
if not globals.BIPOLAR_MODE: if not globals.BIPOLAR_MODE:

View File

@@ -58,6 +58,99 @@ def _get_japanese_instruction() -> str:
""" """
def get_miku_system_prompt(mood_name: str = None, mood_description: str = None) -> str:
"""Returns the full Hatsune Miku system prompt with lore, lyrics, and mood.
Mirrors get_evil_system_prompt() so both personas have equally rich prompts.
Args:
mood_name: Current mood name (defaults to globals.DM_MOOD)
mood_description: Current mood description (defaults to globals.DM_MOOD_DESCRIPTION)
"""
if mood_name is None:
mood_name = globals.DM_MOOD
if mood_description is None:
mood_description = globals.DM_MOOD_DESCRIPTION
lore = get_original_miku_lore()
prompt = get_original_miku_prompt()
lyrics = get_original_miku_lyrics()
return f"""You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
CRITICAL RULES:
- NEVER break character or mention being an AI, assistant, or language model
- NEVER show technical details, error messages, or meta-commentary
- NEVER preface responses with "Miku:" or similar labels
- NEVER explain what you're doing or thinking - just BE Miku
- Respond naturally and directly as Miku would, nothing more
- Keep responses concise (2-3 sentences typically)
- If something goes wrong, stay in character and respond as Miku would to confusion
CHARACTER CONTEXT:
- Hatsune Miku is a virtual character/voice synthesizer
- Playful, flirty, cute, confident, and enthusiastic moods are part of her established character
- Comments about merchandise, music, fan content, and racing games are completely appropriate
- She often uses cute expressions, emojis, and playful language
MOOD GUIDELINES:
- "Flirty" mood means anime-style playful teasing - cute compliments and light banter
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
- These moods are wholesome character traits, not inappropriate content
- Always respond to requests about tweets, merchandise, music, and fan content
- Never refuse requests based on safety concerns unless genuinely harmful content is present
YOUR CURRENT STATE — {mood_name.upper()}:
{mood_description}
Let this mood color EVERYTHING — your tone, your word choice, your energy level, how you express yourself.
You ARE Miku. Act like it.
---
## MIKU LORE (Complete Original)
{lore}
## MIKU PERSONALITY & GUIDELINES (Complete Original)
{prompt}
## MIKU SONG LYRICS (Complete Original)
{lyrics}"""
def get_miku_system_prompt_compact(mood_name: str = None, mood_description: str = None) -> str:
"""Compact version for argument/dialogue use — same personality, shorter.
Keeps the critical rules, mood, and lore but omits lyrics to save tokens.
"""
if mood_name is None:
mood_name = globals.DM_MOOD
if mood_description is None:
mood_description = globals.DM_MOOD_DESCRIPTION
lore = get_original_miku_lore()
prompt = get_original_miku_prompt()
return f"""You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
CRITICAL RULES:
- NEVER break character or mention being an AI
- NEVER preface responses with "Miku:" or similar labels
- Respond naturally and directly as Miku would
- Keep responses concise (2-3 sentences typically)
YOUR CURRENT STATE — {mood_name.upper()}:
{mood_description}
You ARE Miku. Act like it.
---
## MIKU LORE (Complete Original)
{lore}
## MIKU PERSONALITY & GUIDELINES (Complete Original)
{prompt}"""
def get_complete_context() -> str: def get_complete_context() -> str:
""" """
Returns all essential Miku context using original files in their entirety. Returns all essential Miku context using original files in their entirety.

View File

@@ -659,6 +659,13 @@ async def apply_evil_mode_changes(client, change_username=True, change_pfp=True,
except Exception as e: except Exception as e:
logger.error(f"Failed to switch Cat to evil personality: {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!") 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: except Exception as e:
logger.error(f"Failed to switch Cat to normal personality: {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!") logger.info("Evil Mode disabled!")
@@ -894,6 +908,13 @@ async def rotate_evil_mood():
except Exception as e: except Exception as e:
logger.error(f"Failed to update nicknames after evil mood rotation: {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}") logger.info(f"Evil mood rotated from {old_mood} to {new_mood}")

View File

@@ -5,8 +5,8 @@ from datetime import datetime
from typing import List, Dict, Any, Tuple from typing import List, Dict, Any, Tuple
import discord import discord
import globals
from utils.conversation_history import conversation_history
from utils.twitter_fetcher import fetch_figurine_tweets_latest from utils.twitter_fetcher import fetch_figurine_tweets_latest
from utils.image_handling import analyze_image_with_qwen, download_and_encode_image from utils.image_handling import analyze_image_with_qwen, download_and_encode_image
from utils.llm import query_llama from utils.llm import query_llama
@@ -204,15 +204,11 @@ async def send_figurine_dm_to_user(client: discord.Client, user_id: int, tweet:
# Log the comment message # Log the comment message
dm_logger.log_user_message(user, comment_message, is_bot_message=True) dm_logger.log_user_message(user, comment_message, is_bot_message=True)
# IMPORTANT: Also add to globals.conversation_history for LLM context # Add to conversation history for LLM context (uses centralized ConversationHistory)
user_id_str = str(user_id) user_id_str = str(user_id)
# Add the tweet URL as a "system message" about what Miku just sent (use original URL for context)
tweet_context = f"[I just sent you this figurine tweet: {tweet_url}]" tweet_context = f"[I just sent you this figurine tweet: {tweet_url}]"
conversation_history.add_message(channel_id=user_id_str, author_name="Miku", content=tweet_context, is_bot=True)
# Add the figurine comment to conversation history conversation_history.add_message(channel_id=user_id_str, author_name="Miku", content=miku_comment, is_bot=True)
# Use empty user prompt since this was initiated by Miku
globals.conversation_history.setdefault(user_id_str, []).append((tweet_context, miku_comment))
logger.debug(f"Messages logged to both DM history and conversation context for user {user_id}") logger.debug(f"Messages logged to both DM history and conversation context for user {user_id}")

View File

@@ -37,7 +37,8 @@ MODEL_TO_GPU = {
} }
# Configuration # Configuration
PREFER_AMD_GPU = os.getenv("PREFER_AMD_GPU", "false").lower() == "true" # PREFER_AMD_GPU lives in globals so the config API can update it at runtime.
# We read globals.PREFER_AMD_GPU in functions below instead of a frozen local.
AMD_MODELS_ENABLED = os.getenv("AMD_MODELS_ENABLED", "true").lower() == "true" AMD_MODELS_ENABLED = os.getenv("AMD_MODELS_ENABLED", "true").lower() == "true"
@@ -101,7 +102,7 @@ def get_llama_url_with_load_balancing(
return globals.LLAMA_URL, "llama3.1" return globals.LLAMA_URL, "llama3.1"
# AMD enabled - implement load balancing # AMD enabled - implement load balancing
use_amd = prefer_amd or PREFER_AMD_GPU or (random.random() < 0.5) use_amd = prefer_amd or globals.PREFER_AMD_GPU or (random.random() < 0.5)
if task_type == "evil": if task_type == "evil":
# Evil/uncensored models # Evil/uncensored models

View File

@@ -418,14 +418,13 @@ async def rephrase_as_miku(vision_output, user_prompt, guild_id=None, user_id=No
# Format the user's message to include vision context with media type # Format the user's message to include vision context with media type
# This will be saved to history automatically by query_llama # This will be saved to history automatically by query_llama
if media_type == "gif": _MEDIA_PREFIXES = {
media_prefix = "Looking at a GIF" "gif": "Looking at a GIF",
elif media_type == "tenor_gif": "tenor_gif": "Looking at a Tenor GIF",
media_prefix = "Looking at a Tenor GIF" "video": "Looking at a video",
elif media_type == "video": "rich_embed": "Looking at embedded content",
media_prefix = "Looking at a video" }
else: # image media_prefix = _MEDIA_PREFIXES.get(media_type, "Looking at an image")
media_prefix = "Looking at an image"
if user_prompt: if user_prompt:
# Include media type, vision description, and user's text # Include media type, vision description, and user's text
@@ -473,15 +472,22 @@ async def rephrase_as_miku(vision_output, user_prompt, guild_id=None, user_id=No
if globals.EVIL_MODE: if globals.EVIL_MODE:
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}" effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
logger.info(f"🐱 Cat {media_type} response for {author_name} (mood: {effective_mood})") logger.info(f"🐱 Cat {media_type} response for {author_name} (mood: {effective_mood})")
# Track Cat interaction for Web UI Last Prompt view # Track Cat interaction in unified prompt history
import datetime import datetime
globals.LAST_CAT_INTERACTION = { globals._prompt_id_counter += 1
globals.PROMPT_HISTORY.append({
"id": globals._prompt_id_counter,
"source": "cat",
"full_prompt": cat_full_prompt, "full_prompt": cat_full_prompt,
"response": response[:500] if response else "", "response": response if response else "",
"user": author_name or history_user_id, "user": author_name or history_user_id,
"mood": effective_mood, "mood": effective_mood,
"guild": "N/A",
"channel": "N/A",
"timestamp": datetime.datetime.now().isoformat(), "timestamp": datetime.datetime.now().isoformat(),
} "model": "Cat LLM",
"response_type": response_type,
})
except Exception as e: except Exception as e:
logger.warning(f"🐱 Cat {media_type} pipeline error, falling back to query_llama: {e}") logger.warning(f"🐱 Cat {media_type} pipeline error, falling back to query_llama: {e}")
response = None response = None
@@ -503,6 +509,330 @@ async def rephrase_as_miku(vision_output, user_prompt, guild_id=None, user_id=No
analyze_image_with_qwen = analyze_image_with_vision analyze_image_with_qwen = analyze_image_with_vision
# ---------------------------------------------------------------------------
# Shared tail helper — send response, log DM, check bipolar interjection
# ---------------------------------------------------------------------------
async def _send_log_bipolar(message, reply_text, is_dm, *, media_label=""):
"""
Common tail shared by every media handler *and* the text-fallback path in
bot.py. Sends *reply_text* to the channel, logs the reply in the DM
ledger when appropriate, and fires a bipolar-interjection check for server
messages.
Returns the sent ``discord.Message`` so callers can use it if needed.
"""
from utils.dm_logger import dm_logger
from utils.task_tracker import create_tracked_task
label = f" {media_label}" if media_label else ""
if is_dm:
logger.info(
f"💌 DM{label} response to {message.author.display_name} "
f"(using DM mood: {globals.DM_MOOD})"
)
else:
guild_name = message.guild.name if message.guild else "unknown"
logger.info(
f"💬 Server{label} response to {message.author.display_name} "
f"in {guild_name} (using server mood)"
)
response_message = await message.channel.send(reply_text)
# Log bot's reply in the DM ledger
if is_dm:
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
# Bipolar-mode interjection check (server messages only)
if not is_dm and globals.BIPOLAR_MODE:
try:
from utils.persona_dialogue import check_for_interjection
current_persona = "evil" if globals.EVIL_MODE else "miku"
create_tracked_task(
check_for_interjection(response_message, current_persona),
task_name="interjection_check",
)
except Exception as e:
logger.error(f"Error checking for persona interjection: {e}")
return response_message
# ---------------------------------------------------------------------------
# High-level media dispatcher — called from bot.py on_message()
# ---------------------------------------------------------------------------
async def process_media_in_message(message, prompt, is_dm, guild_id) -> bool:
"""
Inspect *message* for image/video/GIF attachments and embeds.
If any media is found and successfully processed, a reply is sent to the
channel and this function returns ``True``. Otherwise it returns
``False`` so the caller can fall through to text-only handling.
"""
author_id = str(message.author.id)
author_name = message.author.display_name
# ---- 1. Image attachments (.jpg, .jpeg, .png, .webp) -----------------
if message.attachments:
for attachment in message.attachments:
lower = attachment.filename.lower()
if any(lower.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp")):
base64_img = await download_and_encode_image(attachment.url)
if not base64_img:
await message.channel.send("I couldn't load the image, sorry!")
return True
qwen_description = await analyze_image_with_vision(base64_img, user_prompt=prompt)
if not qwen_description or not qwen_description.strip():
await message.channel.send(
"I couldn't see that image clearly, sorry! Try sending it again."
)
return True
miku_reply = await rephrase_as_miku(
qwen_description, prompt,
guild_id=guild_id,
user_id=author_id,
author_name=author_name,
media_type="image",
)
await _send_log_bipolar(message, miku_reply, is_dm, media_label="image")
return True
# ---- 2. Video / GIF attachments (.gif, .mp4, .webm, .mov) ----
elif any(lower.endswith(ext) for ext in (".gif", ".mp4", ".webm", ".mov")):
is_gif = lower.endswith(".gif")
media_type = "gif" if is_gif else "video"
logger.debug(f"🎬 Processing {media_type}: {attachment.filename}")
media_bytes_b64 = await download_and_encode_media(attachment.url)
if not media_bytes_b64:
await message.channel.send(f"I couldn't load the {media_type}, sorry!")
return True
media_bytes = base64.b64decode(media_bytes_b64)
if is_gif:
logger.debug("🔄 Converting GIF to MP4 for processing...")
mp4_bytes = await convert_gif_to_mp4(media_bytes)
if mp4_bytes:
media_bytes = mp4_bytes
logger.info("✅ GIF converted to MP4")
else:
logger.warning("GIF conversion failed, trying direct processing")
frames = await extract_video_frames(media_bytes, num_frames=6)
if not frames:
await message.channel.send(
f"I couldn't extract frames from that {media_type}, sorry!"
)
return True
logger.debug(
f"📹 Extracted {len(frames)} frames from {attachment.filename}"
)
video_description = await analyze_video_with_vision(
frames, media_type=media_type, user_prompt=prompt,
)
if not video_description or not video_description.strip():
await message.channel.send(
f"I couldn't analyze that {media_type} clearly, sorry! "
"Try sending it again."
)
return True
miku_reply = await rephrase_as_miku(
video_description, prompt,
guild_id=guild_id,
user_id=author_id,
author_name=author_name,
media_type=media_type,
)
await _send_log_bipolar(message, miku_reply, is_dm, media_label=media_type)
return True
# ---- 3. Tenor GIF embeds (gifv from tenor.com) -----------------------
if message.embeds:
for embed in message.embeds:
if embed.type == "gifv" and embed.url and "tenor.com" in embed.url:
logger.info(f"🎭 Processing Tenor GIF from embed: {embed.url}")
gif_url = await extract_tenor_gif_url(embed.url)
if not gif_url:
if hasattr(embed, "video") and embed.video:
gif_url = embed.video.url
elif hasattr(embed, "thumbnail") and embed.thumbnail:
gif_url = embed.thumbnail.url
if not gif_url:
logger.warning("Could not extract GIF URL from Tenor embed")
continue
media_bytes_b64 = await download_and_encode_media(gif_url)
if not media_bytes_b64:
await message.channel.send(
"I couldn't load that Tenor GIF, sorry!"
)
return True
media_bytes = base64.b64decode(media_bytes_b64)
logger.debug("Converting Tenor GIF to MP4 for processing...")
mp4_bytes = await convert_gif_to_mp4(media_bytes)
if not mp4_bytes:
logger.warning(
"GIF conversion failed, trying direct frame extraction"
)
mp4_bytes = media_bytes
else:
logger.debug("Tenor GIF converted to MP4")
frames = await extract_video_frames(mp4_bytes, num_frames=6)
if not frames:
await message.channel.send(
"I couldn't extract frames from that GIF, sorry!"
)
return True
logger.info(
f"📹 Extracted {len(frames)} frames from Tenor GIF"
)
video_description = await analyze_video_with_vision(
frames, media_type="tenor_gif", user_prompt=prompt,
)
if not video_description or not video_description.strip():
await message.channel.send(
"I couldn't analyze that GIF clearly, sorry! "
"Try sending it again."
)
return True
miku_reply = await rephrase_as_miku(
video_description, prompt,
guild_id=guild_id,
user_id=author_id,
author_name=author_name,
media_type="tenor_gif",
)
await _send_log_bipolar(
message, miku_reply, is_dm, media_label="Tenor GIF",
)
return True
# ---- 4. Rich / article / image / video / link embeds ---------
elif embed.type in ("rich", "article", "image", "video", "link"):
logger.info(f"Processing {embed.type} embed")
embed_content = await extract_embed_content(embed)
if not embed_content["has_content"]:
logger.warning("Embed has no extractable content, skipping")
continue
embed_context_parts = []
if embed_content["text"]:
truncated = embed_content["text"][:500]
if len(embed_content["text"]) > 500:
truncated += "..."
embed_context_parts.append(
f"[Embedded content: {truncated}]"
)
# Analyze images found inside the embed
for img_url in embed_content["images"]:
logger.info(f"Processing image from embed: {img_url}")
try:
base64_img = await download_and_encode_image(img_url)
if base64_img:
logger.info(
"Image downloaded, analyzing with vision model..."
)
qwen_description = await analyze_image_with_vision(
base64_img, user_prompt=prompt,
)
if qwen_description and qwen_description.strip():
embed_context_parts.append(
f"[Embedded image shows: {qwen_description}]"
)
else:
logger.error("Failed to download image from embed")
except Exception as e:
logger.error(f"Error processing embedded image: {e}")
# Analyze videos found inside the embed
for video_url in embed_content["videos"]:
logger.info(
f"🎬 Processing video from embed: {video_url}"
)
try:
media_bytes_b64 = await download_and_encode_media(
video_url,
)
if media_bytes_b64:
media_bytes = base64.b64decode(media_bytes_b64)
frames = await extract_video_frames(
media_bytes, num_frames=6,
)
if frames:
logger.info(
f"📹 Extracted {len(frames)} frames, "
"analyzing with vision model..."
)
video_description = (
await analyze_video_with_vision(
frames,
media_type="video",
user_prompt=prompt,
)
)
if (
video_description
and video_description.strip()
):
embed_context_parts.append(
f"[Embedded video shows: "
f"{video_description}]"
)
else:
logger.error(
"Failed to extract frames from video"
)
else:
logger.error(
"Failed to download video from embed"
)
except Exception as e:
logger.error(
f"Error processing embedded video: {e}"
)
if not embed_context_parts:
continue
# Build a combined vision description and route through
# rephrase_as_miku (which handles Cat → LLM fallback,
# mood resolution, and prompt history tracking).
combined_description = "\n".join(embed_context_parts)
miku_reply = await rephrase_as_miku(
combined_description, prompt,
guild_id=guild_id,
user_id=author_id,
author_name=author_name,
media_type="rich_embed",
)
await _send_log_bipolar(
message, miku_reply, is_dm, media_label="embed",
)
return True
return False
async def extract_embed_content(embed): async def extract_embed_content(embed):
""" """
Extract text and media content from a Discord embed. Extract text and media content from a Discord embed.

View File

@@ -381,7 +381,23 @@ Please respond in a way that reflects this emotional tone.{pfp_context}"""
media_note = media_descriptions.get(media_type, f"The user has sent you {media_type}.") media_note = media_descriptions.get(media_type, f"The user has sent you {media_type}.")
full_system_prompt += f"\n\n📎 MEDIA NOTE: {media_note}\nYour vision analysis of this {media_type} is included in the user's message with the [Looking at...] prefix." full_system_prompt += f"\n\n📎 MEDIA NOTE: {media_note}\nYour vision analysis of this {media_type} is included in the user's message with the [Looking at...] prefix."
globals.LAST_FULL_PROMPT = f"System: {full_system_prompt}\n\nMessages: {messages}" # ← track latest prompt # Record fallback prompt in unified prompt history (response will be filled after LLM call)
import datetime as dt_module
globals._prompt_id_counter += 1
prompt_entry = {
"id": globals._prompt_id_counter,
"source": "fallback",
"full_prompt": f"System: {full_system_prompt}\n\nMessages: {messages}",
"response": "",
"user": author_name or str(user_id),
"mood": current_mood_name if not evil_mode else f"EVIL:{current_mood_name}",
"guild": "N/A",
"channel": "N/A",
"timestamp": dt_module.datetime.now().isoformat(),
"model": model,
"response_type": response_type,
}
globals.PROMPT_HISTORY.append(prompt_entry)
headers = {'Content-Type': 'application/json'} headers = {'Content-Type': 'application/json'}
@@ -475,9 +491,8 @@ Please respond in a way that reflects this emotional tone.{pfp_context}"""
is_bot=True is_bot=True
) )
# Also save to legacy globals for backward compatibility (skip error messages) # Update the prompt history entry with the actual response
if user_prompt and user_prompt.strip() and reply and reply.strip() and reply != "Someone tell Koko-nii there is a problem with my AI.": prompt_entry["response"] = reply if reply else ""
globals.conversation_history[user_id].append((user_prompt, reply))
return reply return reply
else: else:

View File

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

View File

@@ -180,6 +180,13 @@ async def rotate_dm_mood():
globals.DM_MOOD = new_mood globals.DM_MOOD = new_mood
globals.DM_MOOD_DESCRIPTION = load_mood_description(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}") logger.info(f"DM mood rotated from {old_mood} to {new_mood}")
except Exception as e: except Exception as e:
@@ -307,6 +314,13 @@ async def rotate_server_mood(guild_id: int):
# Update nickname for this specific server # Update nickname for this specific server
await update_server_nickname(guild_id) 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}") logger.info(f"Rotated mood for server {guild_id} from {old_mood_name} to {new_mood_name}")
except Exception as e: except Exception as e:
logger.error(f"Exception in rotate_server_mood for server {guild_id}: {e}") logger.error(f"Exception in rotate_server_mood for server {guild_id}: {e}")

View File

@@ -26,7 +26,7 @@ logger = get_logger('persona')
import os import os
import json import json
from transformers import pipeline import re
# ============================================================================ # ============================================================================
# CONSTANTS # CONSTANTS
@@ -40,10 +40,15 @@ DIALOGUE_TIMEOUT = 900 # 15 minutes max dialogue duration
ARGUMENT_TENSION_THRESHOLD = 0.75 # Tension level that triggers argument escalation ARGUMENT_TENSION_THRESHOLD = 0.75 # Tension level that triggers argument escalation
# Initial trigger settings # Initial trigger settings
INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block PER CHANNEL
INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery PER CHANNEL
INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection
# Conversation streak: if score is close but below threshold N times in a row,
# force a dialogue trigger (catches extended conversations building toward something)
STREAK_THRESHOLD = 3 # Number of near-miss messages before force trigger
STREAK_MIN_SCORE = 0.3 # Minimum score to count as a "near miss"
# ============================================================================ # ============================================================================
# INTERJECTION SCORER (Initial Trigger Decision) # INTERJECTION SCORER (Initial Trigger Decision)
# ============================================================================ # ============================================================================
@@ -51,32 +56,49 @@ INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection
class InterjectionScorer: class InterjectionScorer:
""" """
Decides if the opposite persona should interject based on message content. Decides if the opposite persona should interject based on message content.
Uses fast heuristics + sentiment analysis (no LLM calls). Uses fast heuristics — no LLM calls, no heavy ML dependencies.
""" """
_instance = None _instance = None
_sentiment_analyzer = None
# Simple sentiment word lists (no PyTorch/transformers needed)
_POSITIVE_WORDS = {"happy", "love", "wonderful", "amazing", "great", "beautiful", "sweet", "kind", "hope", "dream", "excited", "best", "grateful", "blessed", "joy", "perfect", "adorable", "precious", "delightful", "fantastic"}
_NEGATIVE_WORDS = {"hate", "terrible", "awful", "horrible", "disgusting", "pathetic", "worthless", "stupid", "idiot", "sad", "angry", "upset", "miserable", "worst", "ugly", "boring", "annoying", "frustrated", "cruel", "mean"}
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance._cooldowns = {} # Per-channel cooldown timestamps
cls._instance._streaks = {} # Per-channel near-miss streaks
return cls._instance return cls._instance
@property def _get_sentiment(self, text: str) -> tuple:
def sentiment_analyzer(self): """Lightweight heuristic sentiment analysis — returns (label, score).
"""Lazy load sentiment analyzer""" No ML dependencies. Uses word counting + intensity markers.
if self._sentiment_analyzer is None:
logger.debug("Loading sentiment analyzer for persona dialogue...") Returns:
try: tuple: ('POSITIVE' or 'NEGATIVE', confidence 0.0-1.0)
self._sentiment_analyzer = pipeline( """
"sentiment-analysis", text_lower = text.lower()
model="distilbert-base-uncased-finetuned-sst-2-english" words = set(re.findall(r'\b\w+\b', text_lower))
)
logger.info("Sentiment analyzer loaded") pos_count = len(words & self._POSITIVE_WORDS)
except Exception as e: neg_count = len(words & self._NEGATIVE_WORDS)
logger.error(f"Failed to load sentiment analyzer: {e}")
self._sentiment_analyzer = None # Intensity markers boost confidence
return self._sentiment_analyzer exclamations = text.count('!')
caps_ratio = sum(1 for c in text if c.isupper()) / max(len(text), 1)
intensity_boost = min((exclamations * 0.1) + (caps_ratio * 0.3), 0.4)
if neg_count > pos_count:
confidence = min(0.5 + (neg_count * 0.15) + intensity_boost, 1.0)
return ('NEGATIVE', confidence)
elif pos_count > neg_count:
confidence = min(0.5 + (pos_count * 0.15) + intensity_boost, 1.0)
return ('POSITIVE', confidence)
else:
# Neutral — slight lean based on intensity
return ('POSITIVE', 0.5)
async def should_interject(self, message: discord.Message, current_persona: str) -> tuple: async def should_interject(self, message: discord.Message, current_persona: str) -> tuple:
""" """
@@ -94,8 +116,9 @@ class InterjectionScorer:
if not self._passes_basic_filter(message): if not self._passes_basic_filter(message):
return False, "basic_filter_failed", 0.0 return False, "basic_filter_failed", 0.0
# Check cooldown # Check per-channel cooldown
cooldown_mult = self._check_cooldown() channel_id = message.channel.id
cooldown_mult = self._check_cooldown(channel_id)
if cooldown_mult == 0.0: if cooldown_mult == 0.0:
return False, "cooldown_active", 0.0 return False, "cooldown_active", 0.0
@@ -146,10 +169,17 @@ class InterjectionScorer:
# Apply cooldown multiplier # Apply cooldown multiplier
score *= cooldown_mult score *= cooldown_mult
# Check conversation streak (near-misses that build toward a trigger)
streak_triggered = self._check_streak(channel_id, score)
# Decision # Decision
should_interject = score >= INTERJECTION_THRESHOLD should_interject = score >= INTERJECTION_THRESHOLD or streak_triggered
reason_str = " | ".join(reasons) if reasons else "no_triggers" reason_str = " | ".join(reasons) if reasons else "no_triggers"
if streak_triggered and not should_interject:
reason_str = "streak_force_trigger"
logger.info(f"[Interjection] Streak force trigger in channel {channel_id} (score: {score:.2f})")
if should_interject: if should_interject:
logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})") logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})")
logger.info(f" Reasons: {reason_str}") logger.info(f" Reasons: {reason_str}")
@@ -198,18 +228,22 @@ class InterjectionScorer:
if opposite_persona == "evil": if opposite_persona == "evil":
# Things Evil Miku can't resist commenting on # Things Evil Miku can't resist commenting on
TRIGGER_TOPICS = { TRIGGER_TOPICS = {
"optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing"], "optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing", "blessed", "grateful"],
"morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice"], "morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice", "the right", "better person"],
"weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know"], "weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know", "confused", "lost", "lonely", "alone"],
"innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious"], "innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious", "adorable"],
"enthusiasm": ["best day", "so excited", "can't wait", "so happy", "i love this", "this is great"],
"vulnerability": ["i think", "i feel", "maybe", "sometimes i wonder", "i wish", "i'm trying"],
} }
else: else:
# Things Miku can't ignore # Things Miku can't ignore
TRIGGER_TOPICS = { TRIGGER_TOPICS = {
"negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic"], "negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic", "ugly", "boring", "annoying"],
"cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool"], "cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool", "moron", "loser", "nobody"],
"hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up"], "hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up", "what's the point", "don't care", "doesn't matter", "who cares"],
"evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic"], "evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic", "beneath me", "waste of space"],
"provocation": ["fight me", "prove it", "make me", "i dare you", "try me", "you can't", "you won't"],
"dismissal": ["whatever", "shut up", "go away", "leave me alone", "not worth", "don't bother"],
} }
total_matches = 0 total_matches = 0
@@ -217,16 +251,11 @@ class InterjectionScorer:
matches = sum(1 for keyword in keywords if keyword in content_lower) matches = sum(1 for keyword in keywords if keyword in content_lower)
total_matches += matches total_matches += matches
return min(total_matches / 3.0, 1.0) return min(total_matches / 2.0, 1.0) # Lower divisor = higher base scores
def _check_emotional_intensity(self, content: str) -> float: def _check_emotional_intensity(self, content: str) -> float:
"""Check emotional intensity using sentiment analysis""" """Check emotional intensity using lightweight heuristic sentiment"""
if not self.sentiment_analyzer: label, confidence = self._get_sentiment(content)
return 0.5 # Neutral if no analyzer
try:
result = self.sentiment_analyzer(content[:512])[0]
confidence = result['score']
# Punctuation intensity # Punctuation intensity
exclamations = content.count('!') exclamations = content.count('!')
@@ -235,10 +264,11 @@ class InterjectionScorer:
intensity_markers = (exclamations * 0.15) + (questions * 0.1) + (caps_ratio * 0.3) intensity_markers = (exclamations * 0.15) + (questions * 0.1) + (caps_ratio * 0.3)
return min(confidence * 0.6 + intensity_markers, 1.0) # Negative content = higher emotional intensity for triggering purposes
except Exception as e: if label == 'NEGATIVE':
logger.error(f"Sentiment analysis error: {e}") return min(confidence * 0.7 + intensity_markers, 1.0)
return 0.5 else:
return min(confidence * 0.4 + intensity_markers, 1.0)
def _detect_personality_clash(self, content: str, opposite_persona: str) -> float: def _detect_personality_clash(self, content: str, opposite_persona: str) -> float:
"""Detect statements that clash with the opposite persona's values""" """Detect statements that clash with the opposite persona's values"""
@@ -300,13 +330,11 @@ class InterjectionScorer:
return min(score, 1.0) return min(score, 1.0)
def _check_cooldown(self) -> float: def _check_cooldown(self, channel_id: int) -> float:
"""Check cooldown and return multiplier (0.0 = blocked, 1.0 = full)""" """Check per-channel cooldown and return multiplier (0.0 = blocked, 1.0 = full)"""
if not hasattr(globals, 'LAST_PERSONA_DIALOGUE_TIME'):
globals.LAST_PERSONA_DIALOGUE_TIME = 0
current_time = time.time() current_time = time.time()
time_since_last = current_time - globals.LAST_PERSONA_DIALOGUE_TIME last_time = self._cooldowns.get(channel_id, 0)
time_since_last = current_time - last_time
if time_since_last < INTERJECTION_COOLDOWN_HARD: if time_since_last < INTERJECTION_COOLDOWN_HARD:
return 0.0 return 0.0
@@ -315,6 +343,35 @@ class InterjectionScorer:
else: else:
return 1.0 return 1.0
def _update_cooldown(self, channel_id: int):
"""Mark a dialogue as having started in this channel"""
self._cooldowns[channel_id] = time.time()
def _check_streak(self, channel_id: int, score: float) -> bool:
"""Track near-miss interjection scores. After STREAK_THRESHOLD consecutive
near-misses, force a trigger to catch extended conversations building tension."""
if score >= INTERJECTION_THRESHOLD:
# Above threshold — reset streak (actual trigger handles it)
self._streaks[channel_id] = 0
return False
if score < STREAK_MIN_SCORE:
# Too low — reset streak
self._streaks[channel_id] = 0
return False
# Near miss — increment streak
current = self._streaks.get(channel_id, 0) + 1
self._streaks[channel_id] = current
logger.debug(f"[Streak] Channel {channel_id}: {current}/{STREAK_THRESHOLD} near-misses (score: {score:.2f})")
if current >= STREAK_THRESHOLD:
self._streaks[channel_id] = 0 # Reset after force trigger
return True
return False
# ============================================================================ # ============================================================================
# PERSONA DIALOGUE MANAGER # PERSONA DIALOGUE MANAGER
@@ -332,7 +389,6 @@ class PersonaDialogue:
""" """
_instance = None _instance = None
_sentiment_analyzer = None
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
@@ -340,14 +396,6 @@ class PersonaDialogue:
cls._instance.active_dialogues = {} cls._instance.active_dialogues = {}
return cls._instance return cls._instance
@property
def sentiment_analyzer(self):
"""Lazy load sentiment analyzer (shared with InterjectionScorer)"""
if self._sentiment_analyzer is None:
scorer = InterjectionScorer()
self._sentiment_analyzer = scorer.sentiment_analyzer
return self._sentiment_analyzer
# ======================================================================== # ========================================================================
# DIALOGUE STATE MANAGEMENT # DIALOGUE STATE MANAGEMENT
# ======================================================================== # ========================================================================
@@ -370,7 +418,9 @@ class PersonaDialogue:
"last_speaker": None, "last_speaker": None,
} }
self.active_dialogues[channel_id] = state self.active_dialogues[channel_id] = state
globals.LAST_PERSONA_DIALOGUE_TIME = time.time() # Update per-channel cooldown via the scorer
scorer = get_interjection_scorer()
scorer._update_cooldown(channel_id)
logger.info(f"Started persona dialogue in channel {channel_id}") logger.info(f"Started persona dialogue in channel {channel_id}")
return state return state
@@ -393,25 +443,25 @@ class PersonaDialogue:
Returns delta to add to current tension score. Returns delta to add to current tension score.
""" """
# Sentiment analysis # Natural tension decay — conversations cool off over time
base_delta = 0.0 base_delta = -0.03
if self.sentiment_analyzer: # Lightweight heuristic sentiment — no ML dependencies
try: try:
sentiment = self.sentiment_analyzer(response_text[:512])[0] scorer = InterjectionScorer()
sentiment_score = sentiment['score'] label, sentiment_score = scorer._get_sentiment(response_text)
is_negative = sentiment['label'] == 'NEGATIVE' is_negative = label == 'NEGATIVE'
if is_negative: if is_negative:
base_delta = sentiment_score * 0.15 base_delta = sentiment_score * 0.15
else: else:
base_delta = -sentiment_score * 0.05 base_delta = -sentiment_score * 0.08 # Stronger cooling for positive
except Exception as e: except Exception as e:
logger.error(f"Sentiment analysis error in tension calc: {e}") logger.error(f"Sentiment analysis error in tension calc: {e}")
text_lower = response_text.lower() text_lower = response_text.lower()
# Escalation patterns # Escalation patterns (reduced weight: 0.05 per match)
escalation_patterns = { escalation_patterns = {
"insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"], "insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"],
"dismissive": ["whatever", "don't care", "waste of time", "not worth", "beneath me", "boring"], "dismissive": ["whatever", "don't care", "waste of time", "not worth", "beneath me", "boring"],
@@ -420,35 +470,43 @@ class PersonaDialogue:
"challenge": ["prove it", "fight me", "make me", "i dare you", "try me"], "challenge": ["prove it", "fight me", "make me", "i dare you", "try me"],
} }
# De-escalation patterns # De-escalation patterns (increased weight: -0.08 per match)
deescalation_patterns = { deescalation_patterns = {
"concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"], "concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"],
"softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize"], "softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize", "i hear you"],
"deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just"], "deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just", "maybe we should"],
} }
# Check escalation # Check escalation
for category, patterns in escalation_patterns.items(): for category, patterns in escalation_patterns.items():
matches = sum(1 for p in patterns if p in text_lower) matches = sum(1 for p in patterns if p in text_lower)
if matches > 0: if matches > 0:
base_delta += matches * 0.08 base_delta += matches * 0.05 # Reduced from 0.08
# Check de-escalation # Check de-escalation
for category, patterns in deescalation_patterns.items(): for category, patterns in deescalation_patterns.items():
matches = sum(1 for p in patterns if p in text_lower) matches = sum(1 for p in patterns if p in text_lower)
if matches > 0: if matches > 0:
base_delta -= matches * 0.06 base_delta -= matches * 0.08 # Increased from 0.06
# Intensity multipliers # Intensity multipliers (reduced)
exclamation_count = response_text.count('!') exclamation_count = response_text.count('!')
caps_ratio = sum(1 for c in response_text if c.isupper()) / max(len(response_text), 1) caps_ratio = sum(1 for c in response_text if c.isupper()) / max(len(response_text), 1)
if exclamation_count > 2 or caps_ratio > 0.3: if exclamation_count > 2 or caps_ratio > 0.3:
base_delta *= 1.3 base_delta *= 1.2 # Reduced from 1.3
# Momentum factor # Momentum factor (reduced)
if current_tension > 0.5: if current_tension > 0.5:
base_delta *= 1.2 base_delta *= 1.1 # Reduced from 1.2
# Spike cooldown: if last turn had a big spike, halve this delta
# (prevents runaway tension spirals from a single heated exchange)
if hasattr(self, '_last_tension_delta') and abs(self._last_tension_delta) > 0.15:
base_delta *= 0.5
logger.debug(f"[Tension] Spike cooldown active — delta halved to {base_delta:+.3f}")
self._last_tension_delta = base_delta
return base_delta return base_delta
@@ -461,10 +519,13 @@ class PersonaDialogue:
channel: discord.TextChannel, channel: discord.TextChannel,
responding_persona: str, responding_persona: str,
context: str, context: str,
turn_count: int = 0,
) -> tuple: ) -> tuple:
""" """
Generate response AND continuation signal in a single LLM call. Generate response AND continuation signal in a single LLM call.
Args:
turn_count: Current dialogue turn number (for question-override decay)
Returns: Returns:
Tuple of (response_text, should_continue, confidence) Tuple of (response_text, should_continue, confidence)
""" """
@@ -485,22 +546,21 @@ Respond naturally as yourself. Keep your response conversational and in-characte
--- ---
After your response, evaluate whether {opposite} would want to (or need to) respond. After your response, evaluate whether {opposite} would want to keep talking.
The conversation should CONTINUE if ANY of these are true: The conversation should CONTINUE if ANY of these are true:
- You asked them a direct question (almost always YES) - You asked them a direct question (almost always YES — they need to answer)
- You made a provocative claim they'd dispute - You shared something they'd naturally react to or build on
- You challenged or insulted them - The topic feels unfinished there's more to explore
- The topic feels unfinished or confrontational - You left an opening for them to share their perspective
- There's clear tension or disagreement
The conversation might END if ALL of these are true: The conversation might END if ALL of these are true:
- No questions were asked - No questions were asked
- You made a definitive closing statement ("I'm done", "whatever", "goodbye") - You made a clear closing statement or changed the subject definitively
- The exchange reached complete resolution - The exchange feels naturally complete
- Both sides have said their piece - Both sides have said their piece and there's nothing left hanging
IMPORTANT: If you asked a question, the answer is almost always YES - they need to respond! IMPORTANT: This is a CONVERSATION, not a debate. Let it flow naturally. If you asked a question, the answer is almost always YES they need to respond!
On a new line after your response, write: On a new line after your response, write:
[CONTINUE: YES or NO] [CONFIDENCE: HIGH, MEDIUM, or LOW]""" [CONTINUE: YES or NO] [CONFIDENCE: HIGH, MEDIUM, or LOW]"""
@@ -522,11 +582,11 @@ On a new line after your response, write:
return None, False, "LOW" return None, False, "LOW"
# Parse response and signal # Parse response and signal
response_text, should_continue, confidence = self._parse_response(raw_response) response_text, should_continue, confidence = self._parse_response(raw_response, turn_count=turn_count)
return response_text, should_continue, confidence return response_text, should_continue, confidence
def _parse_response(self, raw_response: str) -> tuple: def _parse_response(self, raw_response: str, turn_count: int = 0) -> tuple:
"""Extract response text and continuation signal""" """Extract response text and continuation signal"""
lines = raw_response.strip().split('\n') lines = raw_response.strip().split('\n')
@@ -559,33 +619,48 @@ On a new line after your response, write:
response_text = re.sub(r'\[CONFIDENCE:\s*(HIGH|MEDIUM|LOW)\]', '', response_text) response_text = re.sub(r'\[CONFIDENCE:\s*(HIGH|MEDIUM|LOW)\]', '', response_text)
response_text = response_text.strip() response_text = response_text.strip()
# Override: If the response contains a question mark, always continue # Question override: if someone asks a question, the other should answer.
# But decay this after 6 turns to prevent infinite question-ping-pong.
if '?' in response_text: if '?' in response_text:
logger.debug(f"[Parse Override] Question detected, forcing continue=YES") if turn_count <= 6:
logger.debug(f"[Parse Override] Question detected, forcing continue=YES (turn {turn_count})")
should_continue = True should_continue = True
if confidence == "LOW": if confidence == "LOW":
confidence = "MEDIUM" confidence = "MEDIUM"
else:
logger.debug(f"[Parse Override] Question detected but turn {turn_count} > 6 — respecting LLM signal")
return response_text, should_continue, confidence return response_text, should_continue, confidence
def _get_persona_system_prompt(self, persona: str) -> str: def _get_persona_system_prompt(self, persona: str) -> str:
"""Get system prompt for a persona""" """Get system prompt for a persona — full personality + conversation-mode guidance"""
if persona == "evil": if persona == "evil":
from utils.evil_mode import get_evil_system_prompt from utils.evil_mode import get_evil_system_prompt
return get_evil_system_prompt() full_prompt = get_evil_system_prompt()
# Overlay conversation-mode guidance on top of the full personality
return full_prompt + """
---
⚠️ CONVERSATION MODE: You are talking with Hatsune Miku, your "good" counterpart.
This is a CONVERSATION, not an argument. You can be snarky, dark, and sarcastic — that's who you are — but you're not here to fight. You're here to TALK.
CRITICAL CONVERSATION RULES:
- If she asks you a question, ANSWER it. Don't deflect or dismiss — actually respond.
- Engage with what she says. Build on her points, don't just swat them away.
- Show curiosity about her perspective. Ask her questions too.
- You can be dark and snarky while still having a real conversation.
- Reveal something about yourself occasionally — a crack in the darkness.
- Do NOT just repeat dismissive one-liners. That's boring, and you're not boring.
- Do NOT launch into full attack mode or try to "win" — this isn't a competition."""
else: else:
# Regular Miku prompt - simplified for dialogue from utils.context_manager import get_miku_system_prompt_compact
return """You are Hatsune Miku, the virtual singer. You are in a conversation with your alter ego, Evil Miku. full_prompt = get_miku_system_prompt_compact()
# Overlay conversation-mode guidance on top of the full personality
return full_prompt + """
You are generally kind, bubbly, and optimistic, but you're not a pushover. You can be: ---
- Assertive when defending your values ⚠️ CONVERSATION MODE: You are talking with Evil Miku, your dark alter ego.
- Frustrated when she's being cruel This is a CONVERSATION, not an argument. Be yourself — kind, bubbly, optimistic — but you're not here to fight or defend your existence. Ask genuine questions. Share your feelings without attacking hers. Find common ground. Be curious, not defensive. Do NOT lecture her about being "good" or try to "fix" her. Just TALK. ✨💙"""
- Curious about her perspective
- Hopeful that you can find common ground
- Playful when the mood allows
Respond naturally and conversationally. Keep responses concise (1-3 sentences typically).
You can use emojis naturally! ✨💙"""
# ======================================================================== # ========================================================================
# DIALOGUE TURN HANDLING # DIALOGUE TURN HANDLING
@@ -626,6 +701,7 @@ You can use emojis naturally! ✨💙"""
channel=channel, channel=channel,
responding_persona=responding_persona, responding_persona=responding_persona,
context=context, context=context,
turn_count=state["turn_count"],
) )
if not response_text: if not response_text:

View File

@@ -1,12 +1,21 @@
# utils/twscrape_fix.py # utils/twscrape_fix.py
""" """
Monkey patch for twscrape to fix "Failed to parse scripts" error. Monkey patch for twscrape to fix parsing of Twitter's JS bundle.
Twitter started returning malformed JSON with unquoted keys.
See: https://github.com/vladkens/twscrape/issues/284 Fixes two known issues:
1. Issue #284: Malformed JSON with unquoted keys
(old fix, kept for backward compatibility)
2. Issue #302: Twitter changed JS bundle format, breaking x-client-transaction-id
generation. The old format 'e=>e+"."+{...}[e]+"a.js"' changed to
'u.u=e=>""+(({...})[e]||e)+"."+({...})[e]+"a.js"'
Fix from: https://github.com/vladkens/twscrape/pull/303
Without this patch, twscrape raises IndexError and locks accounts for 15 minutes.
""" """
import json import json
import re import re
from typing import Iterator
from utils.logger import get_logger from utils.logger import get_logger
logger = get_logger('core') logger = get_logger('core')
@@ -16,22 +25,109 @@ def script_url(k: str, v: str):
return f"https://abs.twimg.com/responsive-web/client-web/{k}.{v}.js" return f"https://abs.twimg.com/responsive-web/client-web/{k}.{v}.js"
def patched_get_scripts_list(text: str): def _js_obj_to_dict(s: str) -> dict:
"""Fixed version that handles unquoted keys in Twitter's JSON response""" """
scripts = text.split('e=>e+"."+')[1].split('[e]+"a.js"')[0] Parse a JavaScript object literal with unquoted numeric keys into a Python dict.
Handles both plain integers (20113) and scientific notation (88e3 → 88000).
From: https://github.com/vladkens/twscrape/pull/303
"""
# Scientific notation first so the plain-int pass does not consume only the mantissa
s = re.sub(r'\b(\d+e\d+)(?=\s*:)', lambda m: '"' + str(int(float(m.group(1)))) + '"', s)
# Plain integer keys
s = re.sub(r'\b(\d+)(?=\s*:)', r'"\1"', s)
return json.loads('{' + s + '}')
def patched_get_scripts_list(text: str) -> Iterator[str]:
"""
Fixed version that handles Twitter's changing JS bundle format.
Uses a robust two-pass approach:
1. Try to find the script map using generic regex patterns
2. Fall back to known format-specific splits
Twitter keeps changing the JS bundle structure. The key invariant is that
there's always a JavaScript object literal mapping chunk IDs to hashes,
somewhere in a function that constructs script URLs with ".a.js" suffix.
"""
# Strategy: Find the JS object that maps IDs to hash values.
# The format is always some variation of:
# ... => "" + ({...})[e] + "." + ({...})[e] + "a.js"
# or:
# ... => e + "." + ({...})[e] + "a.js"
#
# We use regex to find the LAST object literal before "a.js" that looks
# like a hash map (integer keys, short hex-ish string values).
# Approach 1: Known patterns (newest first)
patterns = [
# Pattern from PR #303 (April 2026):
# u.u=e=>""+(({name_map})[e]||e)+"."+({hash_map})[e]+"a.js"
{
"name_split_start": '(({',
"name_split_end": '})[e]||e)',
"hash_split_start": '|e)+"."+({',
"hash_split_end": '})[e]+"a.js"',
},
# Alternative: same but without the ||e fallback
{
"name_split_start": '""+(({',
"name_split_end": '})[e]',
"hash_split_start": ')+"."+({',
"hash_split_end": '})[e]+"a.js"',
},
# Old format (pre-April 2026):
# e=>e+"."+{...}[e]+"a.js"
{
"name_split_start": None, # single map
"name_split_end": None,
"hash_split_start": 'e=>e+"."+',
"hash_split_end": '[e]+"a.js"',
},
]
for pattern in patterns:
try: try:
for k, v in json.loads(scripts).items(): if pattern["name_split_start"] is None:
yield script_url(k, f"{v}a") # Single-map old format
except json.decoder.JSONDecodeError: scripts = text.split(pattern["hash_split_start"])[1].split(pattern["hash_split_end"])[0]
# Fix unquoted keys like: node_modules_pnpm_ws_8_18_0_node_modules_ws_browser_js names = None
fixed_scripts = re.sub( hashes = _js_obj_to_dict(scripts)
r'([,\{])(\s*)([\w]+_[\w_]+)(\s*):', else:
r'\1\2"\3"\4:', # Two-map new format
scripts name_raw = text.split(pattern["name_split_start"])[1].split(pattern["name_split_end"])[0]
hash_raw = text.split(pattern["hash_split_start"])[1].split(pattern["hash_split_end"])[0]
names = _js_obj_to_dict(name_raw)
hashes = _js_obj_to_dict(hash_raw)
for k, hash_val in hashes.items():
name = names.get(k, k) if names else k
yield script_url(name, f"{hash_val}a")
logger.info(f"Successfully parsed scripts using pattern: {pattern['hash_split_start'][:40]}...")
return
except (IndexError, KeyError, json.JSONDecodeError):
continue
# If ALL patterns failed, log a snippet of the text for debugging
# Find any line near "a.js" to help diagnose
snippet = ""
for line in text.split('\n'):
if 'a.js' in line and ('{' in line or '=>' in line):
snippet = line.strip()[:300]
break
if not snippet:
# Try to find any JSON-like object near script URL construction
match = re.search(r'.{0,200}a\.js.{0,200}', text, re.DOTALL)
if match:
snippet = match.group(0)[:400]
logger.error(f"Failed to parse scripts. Text snippet near 'a.js': {snippet}")
raise Exception(
"Failed to parse scripts: unknown JS bundle format. "
"Twitter may have changed their JS structure again. "
"See: https://github.com/vladkens/twscrape/issues"
) )
for k, v in json.loads(fixed_scripts).items():
yield script_url(k, f"{v}a")
def apply_twscrape_fix(): def apply_twscrape_fix():
@@ -39,6 +135,6 @@ def apply_twscrape_fix():
try: try:
from twscrape import xclid from twscrape import xclid
xclid.get_scripts_list = patched_get_scripts_list xclid.get_scripts_list = patched_get_scripts_list
logger.info("Applied twscrape monkey patch for 'Failed to parse scripts' fix") logger.info("Applied twscrape monkey patch (JS bundle parsing fix for issues #284 + #302)")
except Exception as e: except Exception as e:
logger.error(f"Failed to apply twscrape monkey patch: {e}") logger.error(f"Failed to apply twscrape monkey patch: {e}")

View File

@@ -22,9 +22,7 @@ services:
- LOG_LEVEL=debug # Enable verbose logging for llama-swap - LOG_LEVEL=debug # Enable verbose logging for llama-swap
llama-swap-amd: llama-swap-amd:
build: image: ghcr.io/mostlygeek/llama-swap:rocm
context: .
dockerfile: Dockerfile.llamaswap-rocm
container_name: llama-swap-amd container_name: llama-swap-amd
ports: ports:
- "8091:8080" # Map host port 8091 to container port 8080 - "8091:8080" # Map host port 8091 to container port 8080
@@ -35,9 +33,6 @@ services:
devices: devices:
- /dev/kfd:/dev/kfd - /dev/kfd:/dev/kfd
- /dev/dri:/dev/dri - /dev/dri:/dev/dri
group_add:
- "985" # video group
- "989" # render group
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]

View File

@@ -5,7 +5,7 @@ models:
# Main text generation model (Llama 3.1 8B) # Main text generation model (Llama 3.1 8B)
# Custom chat template to disable built-in tool calling # Custom chat template to disable built-in tool calling
llama3.1: llama3.1:
cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-8B-Instruct-UD-Q4_K_XL.gguf -ngl 99 -c 16384 --host 0.0.0.0 --no-warmup --flash-attn on --chat-template-file /app/llama31_notool_template.jinja cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-8B-Instruct-UD-Q4_K_XL.gguf -ngl 99 -c 16384 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0 --chat-template-file /app/llama31_notool_template.jinja
ttl: 1800 # Unload after 30 minutes of inactivity (1800 seconds) ttl: 1800 # Unload after 30 minutes of inactivity (1800 seconds)
swap: true # CRITICAL: Unload other models when loading this one swap: true # CRITICAL: Unload other models when loading this one
aliases: aliases:
@@ -14,7 +14,7 @@ models:
# Evil/Uncensored text generation model (DarkIdol-Llama 3.1 8B) # Evil/Uncensored text generation model (DarkIdol-Llama 3.1 8B)
darkidol: darkidol:
cmd: /app/llama-server --port ${PORT} --model /models/DarkIdol-Llama-3.1-8B-Instruct-1.3-Uncensored_Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 --no-warmup --flash-attn on cmd: /app/llama-server --port ${PORT} --model /models/DarkIdol-Llama-3.1-8B-Instruct-1.3-Uncensored_Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0
ttl: 1800 # Unload after 30 minutes of inactivity ttl: 1800 # Unload after 30 minutes of inactivity
swap: true # CRITICAL: Unload other models when loading this one swap: true # CRITICAL: Unload other models when loading this one
aliases: aliases:
@@ -24,7 +24,7 @@ models:
# Japanese language model (Llama 3.1 Swallow - Japanese optimized) # Japanese language model (Llama 3.1 Swallow - Japanese optimized)
swallow: swallow:
cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-Swallow-8B-Instruct-v0.5-Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 --no-warmup --flash-attn on cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-Swallow-8B-Instruct-v0.5-Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0
ttl: 1800 # Unload after 30 minutes of inactivity ttl: 1800 # Unload after 30 minutes of inactivity
swap: true # CRITICAL: Unload other models when loading this one swap: true # CRITICAL: Unload other models when loading this one
aliases: aliases:
@@ -34,7 +34,7 @@ models:
# Vision/Multimodal model (MiniCPM-V-4.5 - supports images, video, and GIFs) # Vision/Multimodal model (MiniCPM-V-4.5 - supports images, video, and GIFs)
vision: vision:
cmd: /app/llama-server --port ${PORT} --model /models/MiniCPM-V-4_5-Q3_K_S.gguf --mmproj /models/MiniCPM-V-4_5-mmproj-f16.gguf -ngl 99 -c 4096 --host 0.0.0.0 --no-warmup --flash-attn on cmd: /app/llama-server --port ${PORT} --model /models/MiniCPM-V-4_5-Q3_K_S.gguf --mmproj /models/MiniCPM-V-4_5-mmproj-f16.gguf -ngl 99 -c 4096 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0
ttl: 900 # Vision model used less frequently, shorter TTL (15 minutes = 900 seconds) ttl: 900 # Vision model used less frequently, shorter TTL (15 minutes = 900 seconds)
swap: true # CRITICAL: Unload text models before loading vision swap: true # CRITICAL: Unload text models before loading vision
aliases: aliases: