From 979217e7ccd2ee95700972b3d17a2e97fcb9e7a5 Mon Sep 17 00:00:00 2001 From: koko210Serve Date: Wed, 15 Apr 2026 11:38:14 +0300 Subject: [PATCH] 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). --- bot/Dockerfile | 1 + bot/api.py | 3585 +------------------------------ bot/api_monolith_backup.py | 3597 ++++++++++++++++++++++++++++++++ bot/routes/__init__.py | 2 + bot/routes/autonomous.py | 261 +++ bot/routes/bipolar_mode.py | 292 +++ bot/routes/bot_actions.py | 52 + bot/routes/chat.py | 192 ++ bot/routes/config.py | 183 ++ bot/routes/core.py | 139 ++ bot/routes/dms.py | 467 +++++ bot/routes/evil_mode.py | 110 + bot/routes/figurines.py | 80 + bot/routes/gpu.py | 38 + bot/routes/image_generation.py | 108 + bot/routes/language.py | 74 + bot/routes/logging_config.py | 173 ++ bot/routes/manual_send.py | 196 ++ bot/routes/memory.py | 193 ++ bot/routes/models.py | 100 + bot/routes/mood.py | 192 ++ bot/routes/profile_picture.py | 527 +++++ bot/routes/servers.py | 137 ++ bot/routes/voice.py | 207 ++ bot/tests/test_config_state.py | 28 +- bot/tests/test_route_split.py | 231 ++ 26 files changed, 7624 insertions(+), 3541 deletions(-) create mode 100644 bot/api_monolith_backup.py create mode 100644 bot/routes/__init__.py create mode 100644 bot/routes/autonomous.py create mode 100644 bot/routes/bipolar_mode.py create mode 100644 bot/routes/bot_actions.py create mode 100644 bot/routes/chat.py create mode 100644 bot/routes/config.py create mode 100644 bot/routes/core.py create mode 100644 bot/routes/dms.py create mode 100644 bot/routes/evil_mode.py create mode 100644 bot/routes/figurines.py create mode 100644 bot/routes/gpu.py create mode 100644 bot/routes/image_generation.py create mode 100644 bot/routes/language.py create mode 100644 bot/routes/logging_config.py create mode 100644 bot/routes/manual_send.py create mode 100644 bot/routes/memory.py create mode 100644 bot/routes/models.py create mode 100644 bot/routes/mood.py create mode 100644 bot/routes/profile_picture.py create mode 100644 bot/routes/servers.py create mode 100644 bot/routes/voice.py create mode 100644 bot/tests/test_route_split.py diff --git a/bot/Dockerfile b/bot/Dockerfile index b76bec9..2f7d004 100644 --- a/bot/Dockerfile +++ b/bot/Dockerfile @@ -61,6 +61,7 @@ COPY memory /app/memory COPY static /app/static COPY globals.py . COPY api.py . +COPY routes /app/routes COPY api_main.py . COPY persona /app/persona COPY MikuMikuBeam.mp4 . diff --git a/bot/api.py b/bot/api.py index 751db97..58593df 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,82 +1,21 @@ -# api.py +# api.py — Thin orchestrator (routes live in routes/*.py) +# +# Monolith backup: api_monolith_backup.py +# To revert: cp api_monolith_backup.py api.py -from fastapi import ( - FastAPI, - Query, - Request, UploadFile, - File, - Form -) -from fastapi.responses import StreamingResponse -from typing import List, Optional -from pydantic import BaseModel -import globals -from server_manager import server_manager -from utils.conversation_history import conversation_history -from commands.actions import ( - force_sleep, - wake_up, - set_mood, - reset_mood, - check_mood, - calm_miku, - reset_conversation, - send_bedtime_now -) -from utils.autonomous import ( - miku_autonomous_tick, - miku_say_something_general, - miku_engage_random_user, - share_miku_tweet, - handle_custom_prompt, - miku_detect_and_join_conversation -) -import asyncio -import nest_asyncio -import subprocess -import io -import discord -import aiofiles -import aiohttp +from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, PlainTextResponse -import os -import json -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.dm_logger import dm_logger -from utils.logger import get_logger, list_components, get_component_stats -from utils.log_config import ( - load_config as load_log_config, - save_config as save_log_config, - update_component, - update_global_level, - update_timestamp_format, - update_api_filters, - reset_to_defaults, - reload_all_loggers -) +from utils.logger import get_logger +from utils.log_config import load_config as load_log_config import time from fnmatch import fnmatch +import nest_asyncio nest_asyncio.apply() # Initialize API logger logger = get_logger('api') api_requests_logger = get_logger('api.requests') -# ========== GPU Selection Helper ========== -def get_current_gpu_url(): - """Get the URL for the currently selected GPU""" - from config_manager import config_manager - if config_manager.get_gpu() == "amd": - return globals.LLAMA_AMD_URL - return globals.LLAMA_URL - app = FastAPI() # ========== Global Exception Handler ========== @@ -91,42 +30,42 @@ async def global_exception_handler(request: Request, exc: Exception): async def log_requests(request: Request, call_next): """Middleware to log HTTP requests based on configuration.""" start_time = time.time() - + # Get logging config log_config = load_log_config() api_config = log_config.get('components', {}).get('api.requests', {}) - + # Check if API request logging is enabled if not api_config.get('enabled', False): return await call_next(request) - + # Get filters filters = api_config.get('filters', {}) exclude_paths = filters.get('exclude_paths', []) exclude_status = filters.get('exclude_status', []) include_slow_requests = filters.get('include_slow_requests', True) slow_threshold_ms = filters.get('slow_threshold_ms', 1000) - + # Process request response = await call_next(request) - + # Calculate duration duration_ms = (time.time() - start_time) * 1000 - + # Check if path should be excluded path = request.url.path for pattern in exclude_paths: if fnmatch(path, pattern): return response - + # Check if status should be excluded (unless it's a slow request) is_slow = duration_ms >= slow_threshold_ms if response.status_code in exclude_status and not (include_slow_requests and is_slow): return response - + # Log the request log_msg = f"{request.method} {path} - {response.status_code} ({duration_ms:.2f}ms)" - + if is_slow: api_requests_logger.warning(f"SLOW REQUEST: {log_msg}") elif response.status_code >= 500: @@ -135,3463 +74,55 @@ async def log_requests(request: Request, call_next): api_requests_logger.warning(log_msg) else: api_requests_logger.api(log_msg) - + return response # Serve static folder app.mount("/static", StaticFiles(directory="static"), name="static") -# ========== Models ========== -class MoodSetRequest(BaseModel): - mood: str +# ========== Include Route Modules ========== +from routes.core import router as core_router +from routes.mood import router as mood_router +from routes.language import router as language_router +from routes.evil_mode import router as evil_mode_router +from routes.bipolar_mode import router as bipolar_mode_router +from routes.gpu import router as gpu_router +from routes.bot_actions import router as bot_actions_router +from routes.autonomous import router as autonomous_router +from routes.profile_picture import router as profile_picture_router +from routes.manual_send import router as manual_send_router +from routes.servers import router as servers_router +from routes.figurines import router as figurines_router +from routes.dms import router as dms_router +from routes.image_generation import router as image_generation_router +from routes.chat import router as chat_router +from routes.config import router as config_router +from routes.logging_config import router as logging_config_router +from routes.voice import router as voice_router +from routes.memory import router as memory_router + +app.include_router(core_router) +app.include_router(mood_router) +app.include_router(language_router) +app.include_router(evil_mode_router) +app.include_router(bipolar_mode_router) +app.include_router(gpu_router) +app.include_router(bot_actions_router) +app.include_router(autonomous_router) +app.include_router(profile_picture_router) +app.include_router(manual_send_router) +app.include_router(servers_router) +app.include_router(figurines_router) +app.include_router(dms_router) +app.include_router(image_generation_router) +app.include_router(chat_router) +app.include_router(config_router) +app.include_router(logging_config_router) +app.include_router(voice_router) +app.include_router(memory_router) -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 - -# ========== Routes ========== -@app.get("/") -def read_index(): - return FileResponse("static/index.html") - -@app.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 f"Error reading log file: {e}" - -@app.get("/prompt") -def get_last_prompt(): - return {"prompt": globals.LAST_FULL_PROMPT or "No prompt has been issued yet."} - -@app.get("/prompt/cat") -def get_last_cat_prompt(): - """Get the last Cheshire Cat interaction (full prompt + response) for Web UI.""" - interaction = globals.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 - -@app.get("/mood") -def get_current_mood(): - return {"mood": globals.DM_MOOD, "description": globals.DM_MOOD_DESCRIPTION} - -@app.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 {"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} - -@app.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"} - -@app.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"} - -# ========== Language Mode Management ========== -@app.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 - } - -@app.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()}!" - } - -@app.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 {"error": f"Invalid language mode '{language}'. Use 'english' or 'japanese'."}, 400 - - 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()}!" - } - -# ========== Evil Mode Management ========== -@app.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 - } - -@app.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 {"status": "error", "message": "Discord client not ready"} - -@app.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 {"status": "error", "message": "Discord client not ready"} - -@app.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 {"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} - -@app.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 - } - -@app.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 { - "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 {"status": "error", "message": "Failed to set evil mood"} - -# ========== Bipolar Mode Management ========== -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 = "" - -@app.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) - } - -@app.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} - -@app.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} - -@app.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 - } - -@app.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 {"status": "error", "message": "Invalid channel ID format"} - - message_id = None - if data.message_id: - try: - message_id = int(data.message_id) - except ValueError: - return {"status": "error", "message": "Invalid message ID format"} - - if not is_bipolar_mode(): - return {"status": "error", "message": "Bipolar mode is not enabled"} - - if is_argument_in_progress(channel_id): - return {"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 {"status": "error", "message": "Discord client not ready"} - - # If message_id is provided, use the message-based trigger - if message_id: - import asyncio - - 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 {"status": "error", "message": f"Channel {channel_id} not found"} - - # Trigger the argument - 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 - } - -@app.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 {"status": "error", "message": "Message ID is required"} - - # Parse message ID - try: - message_id = int(message_id_str) - except ValueError: - return {"status": "error", "message": "Invalid message ID format"} - - if not is_bipolar_mode(): - return {"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 {"status": "error", "message": "Discord client not ready"} - - import asyncio - - 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: - 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}" - } - -@app.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() - } - -@app.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 {"status": "error", "message": "Discord client not ready"} - - globals.client.loop.create_task(cleanup_webhooks(globals.client)) - return {"status": "ok", "message": "Webhook cleanup started"} - -# ========== GPU Selection ========== -@app.get("/gpu-status") -def get_gpu_status(): - """Get current GPU selection""" - from config_manager import config_manager - return {"gpu": config_manager.get_gpu()} - -@app.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 {"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 {"status": "error", "message": "Failed to save GPU state"} - except Exception as e: - logger.error(f"GPU Selection Error: {e}") - return {"status": "error", "message": str(e)} - -@app.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} - -# ========== Per-Server Mood Management ========== -@app.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 - } - -@app.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 {"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 {"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 {"status": "error", "message": "Failed to set server mood"} - -@app.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 {"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 {"status": "error", "message": "Failed to reset server mood"} - -@app.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 {"status": "error", "message": "Server not found"} - -@app.post("/conversation/reset") -def reset_convo(data: ConversationResetRequest): - reset_conversation(data.user_id) - return {"status": "ok", "message": "Conversation reset"} - -@app.post("/sleep") -async def force_sleep_endpoint(): - await force_sleep() - return {"status": "ok", "message": "Miku is now sleeping"} - -@app.post("/wake") -async def wake_up_endpoint(): - await wake_up() - return {"status": "ok", "message": "Miku is now awake"} - -@app.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 {"status": "error", "message": "Bot not ready"} - -@app.post("/autonomous/general") -async def trigger_autonomous_general(guild_id: int = None): - # If guild_id is provided, send autonomous message 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.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: - # Send to all servers (legacy behavior) - 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 {"status": "error", "message": "Bot not ready"} - -@app.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" -): - # If guild_id is provided, send autonomous engagement only to that server - # If no guild_id, send to all servers (legacy behavior) - # user_id: Optional specific user to engage (Discord user ID as string) - # engagement_type: Optional type - 'activity', 'general', 'status', or None for random - # manual_trigger: If True (as string), bypass the "recently engaged" check (for web UI manual triggers) - # Convert manual_trigger string to boolean - 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: - # Send to specific server only - 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)) - - # Build detailed message - 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: - # Send to all servers (legacy behavior) - 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 {"status": "error", "message": "Bot not ready"} - -@app.post("/autonomous/tweet") -async def trigger_autonomous_tweet(guild_id: int = None, tweet_url: str = None): - # If guild_id is provided, send tweet only to that server - # If no guild_id, send to all servers (legacy behavior) - # If tweet_url is provided, share that specific tweet; otherwise fetch one - 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.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: - # Send to all servers (legacy behavior) - 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 {"status": "error", "message": "Bot not ready"} - -@app.post("/autonomous/custom") -async def custom_autonomous_message(req: CustomPromptRequest, guild_id: int = None): - # If guild_id is provided, send custom prompt 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.autonomous import handle_custom_prompt_for_server - # Use create_task to avoid timeout context manager error - 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: - # Send to all servers (legacy behavior) - from utils.autonomous import handle_custom_prompt - # Use create_task to avoid timeout context manager error - globals.client.loop.create_task(handle_custom_prompt(req.prompt)) - return {"status": "ok", "message": "Custom autonomous message queued for all servers"} - else: - return {"status": "error", "message": "Bot not ready"} - -@app.post("/autonomous/reaction") -async def trigger_autonomous_reaction(guild_id: int = None): - # If guild_id is provided, trigger reaction only for that server - # If no guild_id, trigger for all servers (legacy behavior) - if globals.client and globals.client.loop and globals.client.loop.is_running(): - if guild_id is not None: - # Trigger for specific server only (force=True bypasses 50% chance) - 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: - # Trigger for all servers (legacy behavior, force=True bypasses 50% chance) - 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 {"status": "error", "message": "Bot not ready"} - -@app.post("/autonomous/join-conversation") -async def trigger_detect_and_join_conversation(guild_id: int = None): - # If guild_id is provided, detect and join conversation only for that server - # If no guild_id, trigger for all servers - 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: - # Trigger for specific server only (force=True to bypass checks when manually triggered) - 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: - # Trigger for all servers (force=True to bypass checks when manually triggered) - 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 {"status": "error", "message": "Bot not ready"} - -@app.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 {"status": "error", "message": "Bot not ready"} - - try: - from utils.profile_picture_manager import profile_picture_manager - from server_manager import server_manager - - # Get mood from guild_id (if provided) - mood = None - if guild_id is not None: - mood, _ = server_manager.get_server_mood(guild_id) - else: - # Use DM mood as fallback - mood = globals.DM_MOOD - - # If file provided, use it - custom_image_bytes = None - if file: - custom_image_bytes = await file.read() - logger.info(f"Received custom image upload ({len(custom_image_bytes)} bytes)") - - # Change profile picture - 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 { - "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 {"status": "error", "message": f"Unexpected error: {str(e)}"} - -@app.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 {"status": "error", "message": str(e)} - -@app.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 {"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 {"status": "error", "message": "Failed to restore fallback"} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"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 {"status": "error", "message": "Failed to reset color"} - except Exception as e: - return {"status": "error", "message": str(e)} - -# === Profile Picture Image Serving === - -@app.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 {"status": "error", "message": "No original image found"} - return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) - -@app.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 {"status": "error", "message": "No current image found"} - return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) - -# === Profile Picture Manual Crop Workflow === - -@app.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. - Saves the full-resolution original for manual cropping later. - """ - if not globals.client or not globals.client.loop or not globals.client.loop.is_running(): - return {"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 { - "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 {"status": "error", "message": f"Unexpected error: {str(e)}"} - -class ManualCropRequest(BaseModel): - x: int - y: int - width: int - height: int - -@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -class DescriptionUpdateRequest(BaseModel): - description: str - -@app.post("/profile-picture/description") -async def update_profile_picture_description(req: DescriptionUpdateRequest): - """Update the profile picture description (and optionally re-inject into Cat)""" - 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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": str(e)} - -# === Profile Picture Album / Gallery === - -@app.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 {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": "Album entry not found"} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"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 {"status": "error", "message": f"No {image_type} image for this entry"} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - logger.error(f"Error adding to album: {e}") - return {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": str(e)} - -@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -class AlbumCropRequest(BaseModel): - x: int - y: int - width: int - height: int - -@app.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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -class AlbumDescriptionRequest(BaseModel): - description: str - -@app.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 {"status": "error", "message": result.get("error", "Unknown error")} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": "Album entry not found"} - except Exception as e: - return {"status": "error", "message": str(e)} - -class BulkDeleteRequest(BaseModel): - entry_ids: List[str] - -@app.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 {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": "No current PFP to archive"} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"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 {"status": "error", "message": f"Failed to read file {file.filename}: {e}"} - - # Use create_task to avoid timeout context manager error - async def send_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: - 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 - - # Send the main message - 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}") - - # Send files if any - 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 {"status": "error", "message": f"Error: {e}"} - - -@app.post("/manual/send-webhook") -async def manual_send_webhook( - message: str = Form(...), - channel_id: str = Form(...), - persona: str = Form("miku"), # "miku" or "evil" - 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 {"status": "error", "message": "Channel not found"} - - # Validate persona - if persona not in ["miku", "evil"]: - return {"status": "error", "message": "Invalid persona. Must be 'miku' or 'evil'"} - - # 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 {"status": "error", "message": f"Failed to read file {file.filename}: {e}"} - - # Use create_task to avoid timeout context manager error - async def send_webhook_message(): - try: - # Get or create webhooks for this channel (inside the task) - webhooks = await get_or_create_webhooks_for_channel(channel) - if not webhooks: - logger.error(f"Failed to create webhooks for channel #{channel.name}") - return - - # Select the appropriate webhook - 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() - - # Prepare files for webhook - discord_files = [] - for file_info in file_data: - discord_files.append(discord.File(io.BytesIO(file_info['content']), filename=file_info['filename'])) - - # Get current avatar URL for the persona - 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") - - # Send via webhook with display name and current avatar - 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 {"status": "error", "message": f"Error: {e}"} - - -@app.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 - } - -@app.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} - -@app.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]} - -# ========== Figurine DM Subscription APIs ========== -@app.get("/figurines/subscribers") -async def get_figurine_subscribers(): - subs = figurine_load_subscribers() - return {"subscribers": [str(uid) for uid in subs]} - -@app.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 {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": "Bot not ready"} - - -@app.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 {"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 {"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"} - -# ========== Server Management Endpoints ========== -@app.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)}") - - # Debug: Check config file directly - 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() - # Convert set to list for JSON serialization - server_data['enabled_features'] = list(server_data['enabled_features']) - - # Convert guild_id to string to prevent JavaScript integer precision loss - 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} - -@app.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: - # Restart schedulers to include the new server - 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 {"status": "error", "message": "Failed to add server"} - -@app.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 {"status": "error", "message": "Failed to remove server"} - -@app.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: - # Restart schedulers to apply changes - server_manager.stop_all_schedulers() - server_manager.start_all_schedulers(globals.client) - return {"status": "ok", "message": "Server configuration updated"} - else: - return {"status": "error", "message": "Failed to update server configuration"} - -@app.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}") - - # Validate the data - required_fields = ['bedtime_hour', 'bedtime_minute', 'bedtime_hour_end', 'bedtime_minute_end'] - for field in required_fields: - if field not in data: - return {"status": "error", "message": f"Missing required field: {field}"} - - # Validate time ranges - 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']) - - # Basic validation - if not (0 <= bedtime_hour <= 23) or not (0 <= bedtime_hour_end <= 23): - return {"status": "error", "message": "Hours must be between 0 and 23"} - if not (0 <= bedtime_minute <= 59) or not (0 <= bedtime_minute_end <= 59): - return {"status": "error", "message": "Minutes must be between 0 and 59"} - - except (ValueError, TypeError): - return {"status": "error", "message": "Invalid time values provided"} - - # Update the server configuration - success = server_manager.update_server_config(guild_id, **data) - if success: - # Update just the bedtime job for this server (avoid restarting all schedulers) - 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 {"status": "error", "message": "Updated config but failed to update scheduler"} - else: - return {"status": "error", "message": "Failed to update bedtime range"} - -@app.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 {"status": "error", "message": f"Failed to trigger autonomous message: {e}"} - -@app.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 - - Args: - guild_id: The server ID to engage in - user_id: Optional specific user to engage (Discord user ID as string) - engagement_type: Optional type - 'activity', 'general', 'status', or None for random - manual_trigger: If True (as string), bypass the "recently engaged" check (for web UI manual triggers) - """ - # Convert manual_trigger string to boolean - 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) - - # Build detailed message - 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 {"status": "error", "message": f"Failed to trigger user engagement: {e}"} - -@app.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 {"status": "error", "message": f"Failed to send custom message to server {guild_id}"} - except Exception as e: - return {"status": "error", "message": f"Error: {e}"} - -@app.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 {"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 - from utils.dm_logger import dm_logger - 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 {"status": "error", "message": "Invalid user ID format"} - except Exception as e: - return {"status": "error", "message": f"Error: {e}"} - -@app.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 {"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 {"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) - from utils.dm_logger import dm_logger - 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 {"status": "error", "message": "Invalid user ID format"} - except Exception as e: - return {"status": "error", "message": f"Error: {e}"} - -@app.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 {"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 {"status": "error", "message": "Failed to generate image"} - - except Exception as e: - return {"status": "error", "message": f"Error: {e}"} - -@app.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 {"status": "error", "message": f"Error: {e}"} - -@app.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 {"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 {"status": "error", "message": f"Error: {e}"} - -@app.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 {"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 {"status": "error", "message": f"Error serving image: {e}"} - -@app.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 {"status": "error", "message": f"Failed to trigger tweet sharing: {e}"} - -@app.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 {"status": "error", "message": f"Failed to repair configuration: {e}"} - -@app.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())} - -@app.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 {"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 {"status": "error", "message": "Mood change failed"} - -# ========== DM Logging Endpoints ========== -@app.get("/dms/users") -def get_dm_users(): - """Get summary of all users who have DMed the bot""" - try: - from utils.dm_logger import dm_logger - users = dm_logger.get_all_dm_users() - return {"status": "ok", "users": users} - except Exception as e: - return {"status": "error", "message": f"Failed to get DM users: {e}"} - -@app.get("/dms/users/{user_id}") -def get_dm_user_conversation(user_id: str): - """Get conversation summary for a specific user""" - try: - from utils.dm_logger import dm_logger - # 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} - except Exception as e: - return {"status": "error", "message": f"Failed to get user conversation: {e}"} - -@app.get("/dms/users/{user_id}/conversations") -def get_dm_conversations(user_id: str, limit: int = 50): - """Get recent conversations with a specific user""" - try: - from utils.dm_logger import dm_logger - # 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 {"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 {"status": "error", "message": f"Failed to get conversations: {e}"} - -@app.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: - from utils.dm_logger import dm_logger - # 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} - except Exception as e: - return {"status": "error", "message": f"Failed to search conversations: {e}"} - -@app.get("/dms/users/{user_id}/export") -def export_dm_conversation(user_id: str, format: str = "json"): - """Export all conversations with a user""" - try: - from utils.dm_logger import dm_logger - # 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} - except Exception as e: - return {"status": "error", "message": f"Failed to export conversation: {e}"} - -@app.delete("/dms/users/{user_id}") -def delete_dm_user_logs(user_id: str): - """Delete all DM logs for a specific user""" - try: - from utils.dm_logger import dm_logger - import os - - # 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 {"status": "error", "message": f"No DM logs found for user {user_id}"} - except ValueError: - return {"status": "error", "message": f"Invalid user ID format: {user_id}"} - except Exception as e: - return {"status": "error", "message": f"Failed to delete DM logs: {e}"} - -# ========== User Blocking & DM Management ========== - -@app.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 {"status": "error", "message": f"Failed to get blocked users: {e}"} - -@app.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 {"status": "error", "message": f"User {username} is already blocked"} - - except ValueError: - return {"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 {"status": "error", "message": f"Failed to block user: {e}"} - -@app.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 {"status": "error", "message": f"User is not blocked"} - - except ValueError: - return {"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 {"status": "error", "message": f"Failed to unblock user: {e}"} - -@app.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) - - import asyncio - success = 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 {"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 {"status": "error", "message": f"Failed to delete conversation: {e}"} - -@app.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) - - import asyncio - success = 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 {"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 {"status": "error", "message": f"Failed to delete conversations: {e}"} - -@app.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 {"status": "error", "message": "No user data found"} - - except ValueError: - return {"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 {"status": "error", "message": f"Failed to delete user: {e}"} - -# ========== DM Interaction Analysis Endpoints ========== - -@app.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 {"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 {"status": "error", "message": f"Failed to run DM analysis: {e}"} - -@app.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 {"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 {"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 {"status": "error", "message": f"Failed to analyze user: {e}"} - -@app.get("/dms/analysis/reports") -def get_analysis_reports(limit: int = 20): - """Get recent analysis reports""" - try: - import os - import json - 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 {"status": "error", "message": f"Failed to get reports: {e}"} - -@app.get("/dms/analysis/reports/{user_id}") -def get_user_reports(user_id: str, limit: int = 10): - """Get analysis reports for a specific user""" - try: - import os - import json - 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} - except Exception as e: - logger.error(f"Failed to get user reports: {e}") - return {"status": "error", "message": f"Failed to get user reports: {e}"} - -# ========== Message Reaction Endpoint ========== -@app.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 {"status": "error", "message": "Bot not ready"} - - # Convert IDs to integers - try: - msg_id = int(message_id) - chan_id = int(channel_id) - except ValueError: - return {"status": "error", "message": "Invalid message ID or channel ID format"} - - # Fetch the channel - channel = globals.client.get_channel(chan_id) - if not channel: - return {"status": "error", "message": f"Channel {channel_id} not found"} - - # Queue the reaction task - 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 {"status": "error", "message": f"Failed to add reaction: {e}"} - -# ========== Autonomous V2 Endpoints ========== - -@app.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 {"status": "error", "message": str(e)} - -@app.get("/autonomous/v2/check/{guild_id}") -async def manual_v2_check(guild_id: int): - """ - Manually trigger a V2 context check (doesn't make Miku act, just shows what she's thinking) - Useful for debugging and understanding the decision system. - """ - try: - from utils.autonomous_v2_integration import manual_trigger_v2_check - - if not globals.client: - return {"status": "error", "message": "Bot not ready"} - - result = await manual_trigger_v2_check(guild_id, globals.client) - - if isinstance(result, str): - return {"status": "error", "message": result} - - return {"status": "ok", "guild_id": guild_id, "analysis": result} - except Exception as e: - return {"status": "error", "message": str(e)} - -@app.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 {"status": "error", "message": str(e)} - -# ========== Chat Interface Endpoints ========== - -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 - -@app.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 - } - ) - -# ========== Configuration Management (New Unified System) ========== - -@app.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 {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": str(e)} - -@app.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 {"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 {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": str(e)} - -# ========== Logging Configuration (Existing System) ========== -@app.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 {"success": False, "error": str(e)} - -@app.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 {"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 {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": "Failed to reload configuration"} - except Exception as e: - logger.error(f"Failed to reload log config: {e}") - return {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": "Failed to update filters"} - except Exception as e: - logger.error(f"Failed to update filters: {e}") - return {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": "Failed to reset configuration"} - except Exception as e: - logger.error(f"Failed to reset log config: {e}") - return {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": f"Failed to update global level {level}"} - except Exception as e: - logger.error(f"Failed to update global level: {e}") - return {"success": False, "error": str(e)} - -@app.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 {"success": False, "error": f"Invalid timestamp format: {format_type}"} - except Exception as e: - logger.error(f"Failed to update timestamp format: {e}") - return {"success": False, "error": str(e)} - -@app.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 {"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 {"success": False, "error": str(e)} - - -# ============================================================================ -# Voice Call Management -# ============================================================================ - -@app.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 {"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 {"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 {"success": False, "error": "User not found"} - - channel = globals.client.get_channel(channel_id_int) - if not channel or not isinstance(channel, discord.VoiceChannel): - return {"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 {"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 {"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 {"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 (create a mock message object for logging) - # The dm_logger.log_user_message expects a discord.Message object - # So we need to use the actual sent_message - 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 {"success": False, "error": str(e)} - - -async def _voice_call_timeout_handler(voice_session: 'VoiceSession', 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: - pass - - except asyncio.CancelledError: - # User joined in time, normal operation - pass - - -@app.get("/voice/debug-mode") -def get_voice_debug_mode(): - """Get current voice debug mode status""" - return { - "debug_mode": globals.VOICE_DEBUG_MODE - } - - -@app.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'}" - } - - -# ========== Cheshire Cat Memory Management (Phase 3) ========== - -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 - -@app.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 - } - -@app.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'}" - } - -@app.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 {"success": False, "error": "Could not reach Cheshire Cat"} - return {"success": True, "collections": stats.get("collections", [])} - -@app.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)} - -@app.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 {"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)} - -@app.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 {"success": False, "error": "Consolidation failed or timed out"} - return {"success": True, "result": result} - -@app.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 { - "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 { - "success": False, - "error": "Failed to wipe memory collections. Check Cat connection." - } - -@app.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 {"success": False, "error": f"Failed to delete point {point_id}"} - -@app.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 {"success": False, "error": f"Failed to update point {point_id}"} - -@app.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 {"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 {"success": False, "error": "Failed to create memory point"} def start_api(): import uvicorn uvicorn.run(app, host="0.0.0.0", port=3939) - - diff --git a/bot/api_monolith_backup.py b/bot/api_monolith_backup.py new file mode 100644 index 0000000..751db97 --- /dev/null +++ b/bot/api_monolith_backup.py @@ -0,0 +1,3597 @@ +# api.py + +from fastapi import ( + FastAPI, + Query, + Request, UploadFile, + File, + Form +) +from fastapi.responses import StreamingResponse +from typing import List, Optional +from pydantic import BaseModel +import globals +from server_manager import server_manager +from utils.conversation_history import conversation_history +from commands.actions import ( + force_sleep, + wake_up, + set_mood, + reset_mood, + check_mood, + calm_miku, + reset_conversation, + send_bedtime_now +) +from utils.autonomous import ( + miku_autonomous_tick, + miku_say_something_general, + miku_engage_random_user, + share_miku_tweet, + handle_custom_prompt, + miku_detect_and_join_conversation +) +import asyncio +import nest_asyncio +import subprocess +import io +import discord +import aiofiles +import aiohttp +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, PlainTextResponse +import os +import json +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.dm_logger import dm_logger +from utils.logger import get_logger, list_components, get_component_stats +from utils.log_config import ( + load_config as load_log_config, + save_config as save_log_config, + update_component, + update_global_level, + update_timestamp_format, + update_api_filters, + reset_to_defaults, + reload_all_loggers +) +import time +from fnmatch import fnmatch +nest_asyncio.apply() + +# Initialize API logger +logger = get_logger('api') +api_requests_logger = get_logger('api.requests') + +# ========== GPU Selection Helper ========== +def get_current_gpu_url(): + """Get the URL for the currently selected GPU""" + from config_manager import config_manager + if config_manager.get_gpu() == "amd": + return globals.LLAMA_AMD_URL + return globals.LLAMA_URL + +app = FastAPI() + +# ========== Global Exception Handler ========== +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Catch all unhandled exceptions and log them properly.""" + logger.error(f"Unhandled exception on {request.method} {request.url.path}: {exc}", exc_info=True) + return {"success": False, "error": "Internal server error"} + +# ========== Logging Middleware ========== +@app.middleware("http") +async def log_requests(request: Request, call_next): + """Middleware to log HTTP requests based on configuration.""" + start_time = time.time() + + # Get logging config + log_config = load_log_config() + api_config = log_config.get('components', {}).get('api.requests', {}) + + # Check if API request logging is enabled + if not api_config.get('enabled', False): + return await call_next(request) + + # Get filters + filters = api_config.get('filters', {}) + exclude_paths = filters.get('exclude_paths', []) + exclude_status = filters.get('exclude_status', []) + include_slow_requests = filters.get('include_slow_requests', True) + slow_threshold_ms = filters.get('slow_threshold_ms', 1000) + + # Process request + response = await call_next(request) + + # Calculate duration + duration_ms = (time.time() - start_time) * 1000 + + # Check if path should be excluded + path = request.url.path + for pattern in exclude_paths: + if fnmatch(path, pattern): + return response + + # Check if status should be excluded (unless it's a slow request) + is_slow = duration_ms >= slow_threshold_ms + if response.status_code in exclude_status and not (include_slow_requests and is_slow): + return response + + # Log the request + log_msg = f"{request.method} {path} - {response.status_code} ({duration_ms:.2f}ms)" + + if is_slow: + api_requests_logger.warning(f"SLOW REQUEST: {log_msg}") + elif response.status_code >= 500: + api_requests_logger.error(log_msg) + elif response.status_code >= 400: + api_requests_logger.warning(log_msg) + else: + api_requests_logger.api(log_msg) + + return response + +# Serve static folder +app.mount("/static", StaticFiles(directory="static"), name="static") + +# ========== Models ========== +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 + +# ========== Routes ========== +@app.get("/") +def read_index(): + return FileResponse("static/index.html") + +@app.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 f"Error reading log file: {e}" + +@app.get("/prompt") +def get_last_prompt(): + return {"prompt": globals.LAST_FULL_PROMPT or "No prompt has been issued yet."} + +@app.get("/prompt/cat") +def get_last_cat_prompt(): + """Get the last Cheshire Cat interaction (full prompt + response) for Web UI.""" + interaction = globals.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 + +@app.get("/mood") +def get_current_mood(): + return {"mood": globals.DM_MOOD, "description": globals.DM_MOOD_DESCRIPTION} + +@app.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 {"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} + +@app.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"} + +@app.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"} + +# ========== Language Mode Management ========== +@app.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 + } + +@app.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()}!" + } + +@app.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 {"error": f"Invalid language mode '{language}'. Use 'english' or 'japanese'."}, 400 + + 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()}!" + } + +# ========== Evil Mode Management ========== +@app.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 + } + +@app.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 {"status": "error", "message": "Discord client not ready"} + +@app.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 {"status": "error", "message": "Discord client not ready"} + +@app.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 {"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} + +@app.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 + } + +@app.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 { + "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 {"status": "error", "message": "Failed to set evil mood"} + +# ========== Bipolar Mode Management ========== +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 = "" + +@app.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) + } + +@app.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} + +@app.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} + +@app.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 + } + +@app.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 {"status": "error", "message": "Invalid channel ID format"} + + message_id = None + if data.message_id: + try: + message_id = int(data.message_id) + except ValueError: + return {"status": "error", "message": "Invalid message ID format"} + + if not is_bipolar_mode(): + return {"status": "error", "message": "Bipolar mode is not enabled"} + + if is_argument_in_progress(channel_id): + return {"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 {"status": "error", "message": "Discord client not ready"} + + # If message_id is provided, use the message-based trigger + if message_id: + import asyncio + + 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 {"status": "error", "message": f"Channel {channel_id} not found"} + + # Trigger the argument + 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 + } + +@app.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 {"status": "error", "message": "Message ID is required"} + + # Parse message ID + try: + message_id = int(message_id_str) + except ValueError: + return {"status": "error", "message": "Invalid message ID format"} + + if not is_bipolar_mode(): + return {"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 {"status": "error", "message": "Discord client not ready"} + + import asyncio + + 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: + 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}" + } + +@app.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() + } + +@app.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 {"status": "error", "message": "Discord client not ready"} + + globals.client.loop.create_task(cleanup_webhooks(globals.client)) + return {"status": "ok", "message": "Webhook cleanup started"} + +# ========== GPU Selection ========== +@app.get("/gpu-status") +def get_gpu_status(): + """Get current GPU selection""" + from config_manager import config_manager + return {"gpu": config_manager.get_gpu()} + +@app.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 {"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 {"status": "error", "message": "Failed to save GPU state"} + except Exception as e: + logger.error(f"GPU Selection Error: {e}") + return {"status": "error", "message": str(e)} + +@app.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} + +# ========== Per-Server Mood Management ========== +@app.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 + } + +@app.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 {"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 {"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 {"status": "error", "message": "Failed to set server mood"} + +@app.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 {"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 {"status": "error", "message": "Failed to reset server mood"} + +@app.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 {"status": "error", "message": "Server not found"} + +@app.post("/conversation/reset") +def reset_convo(data: ConversationResetRequest): + reset_conversation(data.user_id) + return {"status": "ok", "message": "Conversation reset"} + +@app.post("/sleep") +async def force_sleep_endpoint(): + await force_sleep() + return {"status": "ok", "message": "Miku is now sleeping"} + +@app.post("/wake") +async def wake_up_endpoint(): + await wake_up() + return {"status": "ok", "message": "Miku is now awake"} + +@app.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 {"status": "error", "message": "Bot not ready"} + +@app.post("/autonomous/general") +async def trigger_autonomous_general(guild_id: int = None): + # If guild_id is provided, send autonomous message 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.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: + # Send to all servers (legacy behavior) + 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 {"status": "error", "message": "Bot not ready"} + +@app.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" +): + # If guild_id is provided, send autonomous engagement only to that server + # If no guild_id, send to all servers (legacy behavior) + # user_id: Optional specific user to engage (Discord user ID as string) + # engagement_type: Optional type - 'activity', 'general', 'status', or None for random + # manual_trigger: If True (as string), bypass the "recently engaged" check (for web UI manual triggers) + # Convert manual_trigger string to boolean + 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: + # Send to specific server only + 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)) + + # Build detailed message + 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: + # Send to all servers (legacy behavior) + 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 {"status": "error", "message": "Bot not ready"} + +@app.post("/autonomous/tweet") +async def trigger_autonomous_tweet(guild_id: int = None, tweet_url: str = None): + # If guild_id is provided, send tweet only to that server + # If no guild_id, send to all servers (legacy behavior) + # If tweet_url is provided, share that specific tweet; otherwise fetch one + 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.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: + # Send to all servers (legacy behavior) + 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 {"status": "error", "message": "Bot not ready"} + +@app.post("/autonomous/custom") +async def custom_autonomous_message(req: CustomPromptRequest, guild_id: int = None): + # If guild_id is provided, send custom prompt 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.autonomous import handle_custom_prompt_for_server + # Use create_task to avoid timeout context manager error + 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: + # Send to all servers (legacy behavior) + from utils.autonomous import handle_custom_prompt + # Use create_task to avoid timeout context manager error + globals.client.loop.create_task(handle_custom_prompt(req.prompt)) + return {"status": "ok", "message": "Custom autonomous message queued for all servers"} + else: + return {"status": "error", "message": "Bot not ready"} + +@app.post("/autonomous/reaction") +async def trigger_autonomous_reaction(guild_id: int = None): + # If guild_id is provided, trigger reaction only for that server + # If no guild_id, trigger for all servers (legacy behavior) + if globals.client and globals.client.loop and globals.client.loop.is_running(): + if guild_id is not None: + # Trigger for specific server only (force=True bypasses 50% chance) + 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: + # Trigger for all servers (legacy behavior, force=True bypasses 50% chance) + 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 {"status": "error", "message": "Bot not ready"} + +@app.post("/autonomous/join-conversation") +async def trigger_detect_and_join_conversation(guild_id: int = None): + # If guild_id is provided, detect and join conversation only for that server + # If no guild_id, trigger for all servers + 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: + # Trigger for specific server only (force=True to bypass checks when manually triggered) + 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: + # Trigger for all servers (force=True to bypass checks when manually triggered) + 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 {"status": "error", "message": "Bot not ready"} + +@app.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 {"status": "error", "message": "Bot not ready"} + + try: + from utils.profile_picture_manager import profile_picture_manager + from server_manager import server_manager + + # Get mood from guild_id (if provided) + mood = None + if guild_id is not None: + mood, _ = server_manager.get_server_mood(guild_id) + else: + # Use DM mood as fallback + mood = globals.DM_MOOD + + # If file provided, use it + custom_image_bytes = None + if file: + custom_image_bytes = await file.read() + logger.info(f"Received custom image upload ({len(custom_image_bytes)} bytes)") + + # Change profile picture + 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 { + "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 {"status": "error", "message": f"Unexpected error: {str(e)}"} + +@app.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 {"status": "error", "message": str(e)} + +@app.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 {"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 {"status": "error", "message": "Failed to restore fallback"} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"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 {"status": "error", "message": "Failed to reset color"} + except Exception as e: + return {"status": "error", "message": str(e)} + +# === Profile Picture Image Serving === + +@app.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 {"status": "error", "message": "No original image found"} + return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) + +@app.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 {"status": "error", "message": "No current image found"} + return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) + +# === Profile Picture Manual Crop Workflow === + +@app.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. + Saves the full-resolution original for manual cropping later. + """ + if not globals.client or not globals.client.loop or not globals.client.loop.is_running(): + return {"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 { + "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 {"status": "error", "message": f"Unexpected error: {str(e)}"} + +class ManualCropRequest(BaseModel): + x: int + y: int + width: int + height: int + +@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +class DescriptionUpdateRequest(BaseModel): + description: str + +@app.post("/profile-picture/description") +async def update_profile_picture_description(req: DescriptionUpdateRequest): + """Update the profile picture description (and optionally re-inject into Cat)""" + 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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": str(e)} + +# === Profile Picture Album / Gallery === + +@app.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 {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": "Album entry not found"} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"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 {"status": "error", "message": f"No {image_type} image for this entry"} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + logger.error(f"Error adding to album: {e}") + return {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": str(e)} + +@app.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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +class AlbumCropRequest(BaseModel): + x: int + y: int + width: int + height: int + +@app.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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +class AlbumDescriptionRequest(BaseModel): + description: str + +@app.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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": "Album entry not found"} + except Exception as e: + return {"status": "error", "message": str(e)} + +class BulkDeleteRequest(BaseModel): + entry_ids: List[str] + +@app.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 {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": "No current PFP to archive"} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"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 {"status": "error", "message": f"Failed to read file {file.filename}: {e}"} + + # Use create_task to avoid timeout context manager error + async def send_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: + 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 + + # Send the main message + 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}") + + # Send files if any + 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 {"status": "error", "message": f"Error: {e}"} + + +@app.post("/manual/send-webhook") +async def manual_send_webhook( + message: str = Form(...), + channel_id: str = Form(...), + persona: str = Form("miku"), # "miku" or "evil" + 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 {"status": "error", "message": "Channel not found"} + + # Validate persona + if persona not in ["miku", "evil"]: + return {"status": "error", "message": "Invalid persona. Must be 'miku' or 'evil'"} + + # 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 {"status": "error", "message": f"Failed to read file {file.filename}: {e}"} + + # Use create_task to avoid timeout context manager error + async def send_webhook_message(): + try: + # Get or create webhooks for this channel (inside the task) + webhooks = await get_or_create_webhooks_for_channel(channel) + if not webhooks: + logger.error(f"Failed to create webhooks for channel #{channel.name}") + return + + # Select the appropriate webhook + 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() + + # Prepare files for webhook + discord_files = [] + for file_info in file_data: + discord_files.append(discord.File(io.BytesIO(file_info['content']), filename=file_info['filename'])) + + # Get current avatar URL for the persona + 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") + + # Send via webhook with display name and current avatar + 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 {"status": "error", "message": f"Error: {e}"} + + +@app.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 + } + +@app.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} + +@app.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]} + +# ========== Figurine DM Subscription APIs ========== +@app.get("/figurines/subscribers") +async def get_figurine_subscribers(): + subs = figurine_load_subscribers() + return {"subscribers": [str(uid) for uid in subs]} + +@app.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 {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": "Bot not ready"} + + +@app.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 {"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 {"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"} + +# ========== Server Management Endpoints ========== +@app.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)}") + + # Debug: Check config file directly + 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() + # Convert set to list for JSON serialization + server_data['enabled_features'] = list(server_data['enabled_features']) + + # Convert guild_id to string to prevent JavaScript integer precision loss + 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} + +@app.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: + # Restart schedulers to include the new server + 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 {"status": "error", "message": "Failed to add server"} + +@app.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 {"status": "error", "message": "Failed to remove server"} + +@app.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: + # Restart schedulers to apply changes + server_manager.stop_all_schedulers() + server_manager.start_all_schedulers(globals.client) + return {"status": "ok", "message": "Server configuration updated"} + else: + return {"status": "error", "message": "Failed to update server configuration"} + +@app.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}") + + # Validate the data + required_fields = ['bedtime_hour', 'bedtime_minute', 'bedtime_hour_end', 'bedtime_minute_end'] + for field in required_fields: + if field not in data: + return {"status": "error", "message": f"Missing required field: {field}"} + + # Validate time ranges + 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']) + + # Basic validation + if not (0 <= bedtime_hour <= 23) or not (0 <= bedtime_hour_end <= 23): + return {"status": "error", "message": "Hours must be between 0 and 23"} + if not (0 <= bedtime_minute <= 59) or not (0 <= bedtime_minute_end <= 59): + return {"status": "error", "message": "Minutes must be between 0 and 59"} + + except (ValueError, TypeError): + return {"status": "error", "message": "Invalid time values provided"} + + # Update the server configuration + success = server_manager.update_server_config(guild_id, **data) + if success: + # Update just the bedtime job for this server (avoid restarting all schedulers) + 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 {"status": "error", "message": "Updated config but failed to update scheduler"} + else: + return {"status": "error", "message": "Failed to update bedtime range"} + +@app.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 {"status": "error", "message": f"Failed to trigger autonomous message: {e}"} + +@app.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 + + Args: + guild_id: The server ID to engage in + user_id: Optional specific user to engage (Discord user ID as string) + engagement_type: Optional type - 'activity', 'general', 'status', or None for random + manual_trigger: If True (as string), bypass the "recently engaged" check (for web UI manual triggers) + """ + # Convert manual_trigger string to boolean + 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) + + # Build detailed message + 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 {"status": "error", "message": f"Failed to trigger user engagement: {e}"} + +@app.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 {"status": "error", "message": f"Failed to send custom message to server {guild_id}"} + except Exception as e: + return {"status": "error", "message": f"Error: {e}"} + +@app.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 {"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 + from utils.dm_logger import dm_logger + 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 {"status": "error", "message": "Invalid user ID format"} + except Exception as e: + return {"status": "error", "message": f"Error: {e}"} + +@app.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 {"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 {"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) + from utils.dm_logger import dm_logger + 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 {"status": "error", "message": "Invalid user ID format"} + except Exception as e: + return {"status": "error", "message": f"Error: {e}"} + +@app.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 {"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 {"status": "error", "message": "Failed to generate image"} + + except Exception as e: + return {"status": "error", "message": f"Error: {e}"} + +@app.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 {"status": "error", "message": f"Error: {e}"} + +@app.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 {"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 {"status": "error", "message": f"Error: {e}"} + +@app.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 {"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 {"status": "error", "message": f"Error serving image: {e}"} + +@app.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 {"status": "error", "message": f"Failed to trigger tweet sharing: {e}"} + +@app.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 {"status": "error", "message": f"Failed to repair configuration: {e}"} + +@app.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())} + +@app.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 {"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 {"status": "error", "message": "Mood change failed"} + +# ========== DM Logging Endpoints ========== +@app.get("/dms/users") +def get_dm_users(): + """Get summary of all users who have DMed the bot""" + try: + from utils.dm_logger import dm_logger + users = dm_logger.get_all_dm_users() + return {"status": "ok", "users": users} + except Exception as e: + return {"status": "error", "message": f"Failed to get DM users: {e}"} + +@app.get("/dms/users/{user_id}") +def get_dm_user_conversation(user_id: str): + """Get conversation summary for a specific user""" + try: + from utils.dm_logger import dm_logger + # 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"status": "error", "message": f"Failed to get user conversation: {e}"} + +@app.get("/dms/users/{user_id}/conversations") +def get_dm_conversations(user_id: str, limit: int = 50): + """Get recent conversations with a specific user""" + try: + from utils.dm_logger import dm_logger + # 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 {"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 {"status": "error", "message": f"Failed to get conversations: {e}"} + +@app.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: + from utils.dm_logger import dm_logger + # 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"status": "error", "message": f"Failed to search conversations: {e}"} + +@app.get("/dms/users/{user_id}/export") +def export_dm_conversation(user_id: str, format: str = "json"): + """Export all conversations with a user""" + try: + from utils.dm_logger import dm_logger + # 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"status": "error", "message": f"Failed to export conversation: {e}"} + +@app.delete("/dms/users/{user_id}") +def delete_dm_user_logs(user_id: str): + """Delete all DM logs for a specific user""" + try: + from utils.dm_logger import dm_logger + import os + + # 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 {"status": "error", "message": f"No DM logs found for user {user_id}"} + except ValueError: + return {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"status": "error", "message": f"Failed to delete DM logs: {e}"} + +# ========== User Blocking & DM Management ========== + +@app.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 {"status": "error", "message": f"Failed to get blocked users: {e}"} + +@app.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 {"status": "error", "message": f"User {username} is already blocked"} + + except ValueError: + return {"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 {"status": "error", "message": f"Failed to block user: {e}"} + +@app.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 {"status": "error", "message": f"User is not blocked"} + + except ValueError: + return {"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 {"status": "error", "message": f"Failed to unblock user: {e}"} + +@app.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) + + import asyncio + success = 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 {"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 {"status": "error", "message": f"Failed to delete conversation: {e}"} + +@app.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) + + import asyncio + success = 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 {"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 {"status": "error", "message": f"Failed to delete conversations: {e}"} + +@app.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 {"status": "error", "message": "No user data found"} + + except ValueError: + return {"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 {"status": "error", "message": f"Failed to delete user: {e}"} + +# ========== DM Interaction Analysis Endpoints ========== + +@app.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 {"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 {"status": "error", "message": f"Failed to run DM analysis: {e}"} + +@app.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 {"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 {"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 {"status": "error", "message": f"Failed to analyze user: {e}"} + +@app.get("/dms/analysis/reports") +def get_analysis_reports(limit: int = 20): + """Get recent analysis reports""" + try: + import os + import json + 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 {"status": "error", "message": f"Failed to get reports: {e}"} + +@app.get("/dms/analysis/reports/{user_id}") +def get_user_reports(user_id: str, limit: int = 10): + """Get analysis reports for a specific user""" + try: + import os + import json + 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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + logger.error(f"Failed to get user reports: {e}") + return {"status": "error", "message": f"Failed to get user reports: {e}"} + +# ========== Message Reaction Endpoint ========== +@app.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 {"status": "error", "message": "Bot not ready"} + + # Convert IDs to integers + try: + msg_id = int(message_id) + chan_id = int(channel_id) + except ValueError: + return {"status": "error", "message": "Invalid message ID or channel ID format"} + + # Fetch the channel + channel = globals.client.get_channel(chan_id) + if not channel: + return {"status": "error", "message": f"Channel {channel_id} not found"} + + # Queue the reaction task + 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 {"status": "error", "message": f"Failed to add reaction: {e}"} + +# ========== Autonomous V2 Endpoints ========== + +@app.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 {"status": "error", "message": str(e)} + +@app.get("/autonomous/v2/check/{guild_id}") +async def manual_v2_check(guild_id: int): + """ + Manually trigger a V2 context check (doesn't make Miku act, just shows what she's thinking) + Useful for debugging and understanding the decision system. + """ + try: + from utils.autonomous_v2_integration import manual_trigger_v2_check + + if not globals.client: + return {"status": "error", "message": "Bot not ready"} + + result = await manual_trigger_v2_check(guild_id, globals.client) + + if isinstance(result, str): + return {"status": "error", "message": result} + + return {"status": "ok", "guild_id": guild_id, "analysis": result} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.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 {"status": "error", "message": str(e)} + +# ========== Chat Interface Endpoints ========== + +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 + +@app.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 + } + ) + +# ========== Configuration Management (New Unified System) ========== + +@app.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 {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": str(e)} + +@app.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 {"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 {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": str(e)} + +# ========== Logging Configuration (Existing System) ========== +@app.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 {"success": False, "error": str(e)} + +@app.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 {"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 {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": "Failed to reload configuration"} + except Exception as e: + logger.error(f"Failed to reload log config: {e}") + return {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": "Failed to update filters"} + except Exception as e: + logger.error(f"Failed to update filters: {e}") + return {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": "Failed to reset configuration"} + except Exception as e: + logger.error(f"Failed to reset log config: {e}") + return {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": f"Failed to update global level {level}"} + except Exception as e: + logger.error(f"Failed to update global level: {e}") + return {"success": False, "error": str(e)} + +@app.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 {"success": False, "error": f"Invalid timestamp format: {format_type}"} + except Exception as e: + logger.error(f"Failed to update timestamp format: {e}") + return {"success": False, "error": str(e)} + +@app.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 {"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 {"success": False, "error": str(e)} + + +# ============================================================================ +# Voice Call Management +# ============================================================================ + +@app.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 {"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 {"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 {"success": False, "error": "User not found"} + + channel = globals.client.get_channel(channel_id_int) + if not channel or not isinstance(channel, discord.VoiceChannel): + return {"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 {"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 {"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 {"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 (create a mock message object for logging) + # The dm_logger.log_user_message expects a discord.Message object + # So we need to use the actual sent_message + 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 {"success": False, "error": str(e)} + + +async def _voice_call_timeout_handler(voice_session: 'VoiceSession', 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: + pass + + except asyncio.CancelledError: + # User joined in time, normal operation + pass + + +@app.get("/voice/debug-mode") +def get_voice_debug_mode(): + """Get current voice debug mode status""" + return { + "debug_mode": globals.VOICE_DEBUG_MODE + } + + +@app.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'}" + } + + +# ========== Cheshire Cat Memory Management (Phase 3) ========== + +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 + +@app.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 + } + +@app.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'}" + } + +@app.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 {"success": False, "error": "Could not reach Cheshire Cat"} + return {"success": True, "collections": stats.get("collections", [])} + +@app.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)} + +@app.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 {"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)} + +@app.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 {"success": False, "error": "Consolidation failed or timed out"} + return {"success": True, "result": result} + +@app.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 { + "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 { + "success": False, + "error": "Failed to wipe memory collections. Check Cat connection." + } + +@app.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 {"success": False, "error": f"Failed to delete point {point_id}"} + +@app.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 {"success": False, "error": f"Failed to update point {point_id}"} + +@app.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 {"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 {"success": False, "error": "Failed to create memory point"} + + +def start_api(): + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=3939) + + diff --git a/bot/routes/__init__.py b/bot/routes/__init__.py new file mode 100644 index 0000000..a7bf79c --- /dev/null +++ b/bot/routes/__init__.py @@ -0,0 +1,2 @@ +# routes/ — Split from the original api.py monolith. +# Each module exposes a FastAPI APIRouter named `router`. diff --git a/bot/routes/autonomous.py b/bot/routes/autonomous.py new file mode 100644 index 0000000..0e76b3c --- /dev/null +++ b/bot/routes/autonomous.py @@ -0,0 +1,261 @@ +"""Autonomous action routes: V1, V2, per-server autonomous.""" + +from fastapi import APIRouter +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 {"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 {"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 {"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 {"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 {"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 {"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 {"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 {"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 {"status": "error", "message": f"Failed to send custom message to server {guild_id}"} + except Exception as e: + return {"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 {"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 {"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 {"status": "error", "message": "Bot not ready"} + + result = await manual_trigger_v2_check(guild_id, globals.client) + + if isinstance(result, str): + return {"status": "error", "message": result} + + return {"status": "ok", "guild_id": guild_id, "analysis": result} + except Exception as e: + return {"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 {"status": "error", "message": str(e)} diff --git a/bot/routes/bipolar_mode.py b/bot/routes/bipolar_mode.py new file mode 100644 index 0000000..aa1708e --- /dev/null +++ b/bot/routes/bipolar_mode.py @@ -0,0 +1,292 @@ +"""Bipolar mode routes.""" + +import asyncio +from fastapi import APIRouter +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 {"status": "error", "message": "Invalid channel ID format"} + + message_id = None + if data.message_id: + try: + message_id = int(data.message_id) + except ValueError: + return {"status": "error", "message": "Invalid message ID format"} + + if not is_bipolar_mode(): + return {"status": "error", "message": "Bipolar mode is not enabled"} + + if is_argument_in_progress(channel_id): + return {"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 {"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 {"status": "error", "message": f"Channel {channel_id} not found"} + + # Trigger the argument + 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 {"status": "error", "message": "Message ID is required"} + + # Parse message ID + try: + message_id = int(message_id_str) + except ValueError: + return {"status": "error", "message": "Invalid message ID format"} + + if not is_bipolar_mode(): + return {"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 {"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: + 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 {"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} diff --git a/bot/routes/bot_actions.py b/bot/routes/bot_actions.py new file mode 100644 index 0000000..c384027 --- /dev/null +++ b/bot/routes/bot_actions.py @@ -0,0 +1,52 @@ +"""Core bot action routes: conversation reset, sleep, wake, bedtime.""" + +from fastapi import APIRouter +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 {"status": "error", "message": "Bot not ready"} diff --git a/bot/routes/chat.py b/bot/routes/chat.py new file mode 100644 index 0000000..6261065 --- /dev/null +++ b/bot/routes/chat.py @@ -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 + } + ) diff --git a/bot/routes/config.py b/bot/routes/config.py new file mode 100644 index 0000000..4f4bd72 --- /dev/null +++ b/bot/routes/config.py @@ -0,0 +1,183 @@ +"""Configuration management routes: get/set/reset/validate config.""" + +from fastapi import APIRouter, Request +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 {"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 {"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 {"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 {"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 {"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 {"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 {"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 {"success": False, "error": str(e)} diff --git a/bot/routes/core.py b/bot/routes/core.py new file mode 100644 index 0000000..11a1448 --- /dev/null +++ b/bot/routes/core.py @@ -0,0 +1,139 @@ +"""Core routes: index, logs, prompts, status, conversation.""" + +from fastapi import APIRouter +from fastapi.responses import FileResponse +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(): + return FileResponse("static/index.html") + + +@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 f"Error reading log file: {e}" + + +@router.get("/prompt") +def get_last_prompt(): + return {"prompt": globals.LAST_FULL_PROMPT or "No prompt has been issued yet."} + + +@router.get("/prompt/cat") +def get_last_cat_prompt(): + """Get the last Cheshire Cat interaction (full prompt + response) for Web UI.""" + interaction = globals.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("/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]} diff --git a/bot/routes/dms.py b/bot/routes/dms.py new file mode 100644 index 0000000..d04161b --- /dev/null +++ b/bot/routes/dms.py @@ -0,0 +1,467 @@ +"""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 +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 {"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 {"status": "error", "message": "Invalid user ID format"} + except Exception as e: + return {"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 {"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 {"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 {"status": "error", "message": "Invalid user ID format"} + except Exception as e: + return {"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 {"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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"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 {"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 {"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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"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 {"status": "error", "message": f"No DM logs found for user {user_id}"} + except ValueError: + return {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + return {"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 {"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 {"status": "error", "message": f"User {username} is already blocked"} + + except ValueError: + return {"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 {"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 {"status": "error", "message": f"User is not blocked"} + + except ValueError: + return {"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 {"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 {"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 {"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 {"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 {"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 {"status": "error", "message": "No user data found"} + + except ValueError: + return {"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 {"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 {"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 {"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 {"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 {"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 {"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 {"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 {"status": "error", "message": f"Invalid user ID format: {user_id}"} + except Exception as e: + logger.error(f"Failed to get user reports: {e}") + return {"status": "error", "message": f"Failed to get user reports: {e}"} diff --git a/bot/routes/evil_mode.py b/bot/routes/evil_mode.py new file mode 100644 index 0000000..0ff0a82 --- /dev/null +++ b/bot/routes/evil_mode.py @@ -0,0 +1,110 @@ +"""Evil mode routes.""" + +from fastapi import APIRouter +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 {"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 {"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 {"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 { + "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 {"status": "error", "message": "Failed to set evil mood"} diff --git a/bot/routes/figurines.py b/bot/routes/figurines.py new file mode 100644 index 0000000..40df1f4 --- /dev/null +++ b/bot/routes/figurines.py @@ -0,0 +1,80 @@ +"""Figurine subscriber and send routes.""" + +from fastapi import APIRouter, Form +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 {"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 {"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 {"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 {"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 {"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"} diff --git a/bot/routes/gpu.py b/bot/routes/gpu.py new file mode 100644 index 0000000..15c460c --- /dev/null +++ b/bot/routes/gpu.py @@ -0,0 +1,38 @@ +"""GPU selection routes.""" + +from fastapi import APIRouter, Request +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 {"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 {"status": "error", "message": "Failed to save GPU state"} + except Exception as e: + logger.error(f"GPU Selection Error: {e}") + return {"status": "error", "message": str(e)} diff --git a/bot/routes/image_generation.py b/bot/routes/image_generation.py new file mode 100644 index 0000000..097386d --- /dev/null +++ b/bot/routes/image_generation.py @@ -0,0 +1,108 @@ +"""Image generation routes: generate, status, test-detection, view.""" + +import os +from fastapi import APIRouter +from fastapi.responses import FileResponse +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 {"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 {"status": "error", "message": "Failed to generate image"} + + except Exception as e: + return {"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 {"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 {"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 {"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 {"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 {"status": "error", "message": f"Error serving image: {e}"} diff --git a/bot/routes/language.py b/bot/routes/language.py new file mode 100644 index 0000000..b4715d6 --- /dev/null +++ b/bot/routes/language.py @@ -0,0 +1,74 @@ +"""Language mode routes.""" + +from fastapi import APIRouter +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 {"error": f"Invalid language mode '{language}'. Use 'english' or 'japanese'."}, 400 + + 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()}!" + } diff --git a/bot/routes/logging_config.py b/bot/routes/logging_config.py new file mode 100644 index 0000000..9226254 --- /dev/null +++ b/bot/routes/logging_config.py @@ -0,0 +1,173 @@ +"""Logging configuration routes: get/set log levels, filters, components.""" + +from fastapi import APIRouter +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 {"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 {"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 {"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 {"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 {"success": False, "error": "Failed to reload configuration"} + except Exception as e: + logger.error(f"Failed to reload log config: {e}") + return {"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 {"success": False, "error": "Failed to update filters"} + except Exception as e: + logger.error(f"Failed to update filters: {e}") + return {"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 {"success": False, "error": "Failed to reset configuration"} + except Exception as e: + logger.error(f"Failed to reset log config: {e}") + return {"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 {"success": False, "error": f"Failed to update global level {level}"} + except Exception as e: + logger.error(f"Failed to update global level: {e}") + return {"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 {"success": False, "error": f"Invalid timestamp format: {format_type}"} + except Exception as e: + logger.error(f"Failed to update timestamp format: {e}") + return {"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 {"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 {"success": False, "error": str(e)} diff --git a/bot/routes/manual_send.py b/bot/routes/manual_send.py new file mode 100644 index 0000000..2cfc0d9 --- /dev/null +++ b/bot/routes/manual_send.py @@ -0,0 +1,196 @@ +"""Manual message sending routes + message reactions.""" + +import io +from typing import List +from fastapi import APIRouter, UploadFile, File, Form +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 {"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 {"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 {"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 {"status": "error", "message": "Channel not found"} + + if persona not in ["miku", "evil"]: + return {"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 {"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 {"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 {"status": "error", "message": "Bot not ready"} + + try: + msg_id = int(message_id) + chan_id = int(channel_id) + except ValueError: + return {"status": "error", "message": "Invalid message ID or channel ID format"} + + channel = globals.client.get_channel(chan_id) + if not channel: + return {"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 {"status": "error", "message": f"Failed to add reaction: {e}"} diff --git a/bot/routes/memory.py b/bot/routes/memory.py new file mode 100644 index 0000000..f036c61 --- /dev/null +++ b/bot/routes/memory.py @@ -0,0 +1,193 @@ +"""Cheshire Cat memory management routes.""" + +from typing import Optional +from fastapi import APIRouter, Form +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 {"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 {"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 {"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 { + "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 { + "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 {"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 {"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 {"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 {"success": False, "error": "Failed to create memory point"} diff --git a/bot/routes/models.py b/bot/routes/models.py new file mode 100644 index 0000000..d203900 --- /dev/null +++ b/bot/routes/models.py @@ -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 = "" + + +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 diff --git a/bot/routes/mood.py b/bot/routes/mood.py new file mode 100644 index 0000000..2011970 --- /dev/null +++ b/bot/routes/mood.py @@ -0,0 +1,192 @@ +"""Mood management routes: DM mood, per-server mood, available moods, test mood.""" + +from fastapi import APIRouter +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 {"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 {"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 {"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 {"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 {"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 {"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 {"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 {"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 {"status": "error", "message": "Mood change failed"} diff --git a/bot/routes/profile_picture.py b/bot/routes/profile_picture.py new file mode 100644 index 0000000..ed57101 --- /dev/null +++ b/bot/routes/profile_picture.py @@ -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 +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 {"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 { + "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 {"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 {"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 {"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 {"status": "error", "message": "Failed to restore fallback"} + except Exception as e: + return {"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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"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 {"status": "error", "message": "Failed to reset color"} + except Exception as e: + return {"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 {"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 {"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 {"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 { + "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 {"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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"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 {"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 {"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 {"status": "error", "message": "Album entry not found"} + except Exception as e: + return {"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 {"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 {"status": "error", "message": f"No {image_type} image for this entry"} + except Exception as e: + return {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + logger.error(f"Error adding to album: {e}") + return {"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 {"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 {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"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 {"status": "error", "message": "Album entry not found"} + except Exception as e: + return {"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 {"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 {"status": "error", "message": "No current PFP to archive"} + except Exception as e: + return {"status": "error", "message": str(e)} diff --git a/bot/routes/servers.py b/bot/routes/servers.py new file mode 100644 index 0000000..5c2b3ba --- /dev/null +++ b/bot/routes/servers.py @@ -0,0 +1,137 @@ +"""Server management routes: CRUD, bedtime, repair.""" + +import os +import json +from fastapi import APIRouter +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 {"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 {"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 {"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 {"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 {"status": "error", "message": "Hours must be between 0 and 23"} + if not (0 <= bedtime_minute <= 59) or not (0 <= bedtime_minute_end <= 59): + return {"status": "error", "message": "Minutes must be between 0 and 59"} + + except (ValueError, TypeError): + return {"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 {"status": "error", "message": "Updated config but failed to update scheduler"} + else: + return {"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 {"status": "error", "message": f"Failed to repair configuration: {e}"} diff --git a/bot/routes/voice.py b/bot/routes/voice.py new file mode 100644 index 0000000..733d29a --- /dev/null +++ b/bot/routes/voice.py @@ -0,0 +1,207 @@ +"""Voice call management routes + helpers.""" + +import asyncio +from fastapi import APIRouter, Form +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 {"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 {"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 {"success": False, "error": "User not found"} + + channel = globals.client.get_channel(channel_id_int) + if not channel or not isinstance(channel, discord.VoiceChannel): + return {"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 {"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 {"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 {"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 {"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: + 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'}" + } diff --git a/bot/tests/test_config_state.py b/bot/tests/test_config_state.py index aff4884..14f8573 100644 --- a/bot/tests/test_config_state.py +++ b/bot/tests/test_config_state.py @@ -384,19 +384,20 @@ def test_no_create_default_config(): def test_gpu_url_helper_delegates(): """Step 10: get_current_gpu_url() uses config_manager, not direct file read.""" import inspect - import api - src = inspect.getsource(api.get_current_gpu_url) + 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" - assert "config_manager" in src, \ - "get_current_gpu_url should delegate to config_manager" + # 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 - import api - src = inspect.getsource(api.get_gpu_status) + 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, \ @@ -405,18 +406,17 @@ def test_gpu_status_endpoint_delegates(): def test_gpu_url_returns_correct_url(): """Step 10: URL switches correctly between nvidia/amd.""" - from config_manager import config_manager - import api + from routes.chat import get_current_gpu_url - old_gpu = config_manager.get_gpu() + old_val = g.PREFER_AMD_GPU try: - config_manager.set_gpu("nvidia") - assert api.get_current_gpu_url() == g.LLAMA_URL + g.PREFER_AMD_GPU = False + assert get_current_gpu_url() == g.LLAMA_URL - config_manager.set_gpu("amd") - assert api.get_current_gpu_url() == g.LLAMA_AMD_URL + g.PREFER_AMD_GPU = True + assert get_current_gpu_url() == g.LLAMA_AMD_URL finally: - config_manager.set_gpu(old_gpu) + g.PREFER_AMD_GPU = old_val # ═══════════════════════════════════════════════════ diff --git a/bot/tests/test_route_split.py b/bot/tests/test_route_split.py new file mode 100644 index 0000000..5ce63e1 --- /dev/null +++ b/bot/tests/test_route_split.py @@ -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"