refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
""" Voice call management routes + helpers. """
import asyncio
from fastapi import APIRouter , Form
2026-04-15 15:43:18 +03:00
from fastapi . responses import JSONResponse
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
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 ( 30 min timeout )
6. Auto - disconnect 45 s 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 ( ) :
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 503 , content = { " success " : False , " error " : " Bot is not running " } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
# 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 )
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 500 , content = { " success " : False , " error " : str ( e ) } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
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 :
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 404 , content = { " success " : False , " error " : " User not found " } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
channel = globals . client . get_channel ( channel_id_int )
if not channel or not isinstance ( channel , discord . VoiceChannel ) :
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 404 , content = { " success " : False , " error " : " Voice channel not found " } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
# 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 :
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 404 , content = { " success " : False , " error " : " No accessible text channel found " } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
# Start containers
logger . info ( " Starting voice containers... " )
containers_started = await ContainerManager . start_voice_containers ( )
if not containers_started :
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 500 , content = { " success " : False , " error " : " Failed to start voice containers " } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
# 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 ( )
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 500 , content = { " success " : False , " error " : f " Failed to start voice session: { str ( e ) } " } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
# 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 )
2026-04-15 15:43:18 +03:00
return JSONResponse ( status_code = 500 , content = { " success " : False , " error " : str ( e ) } )
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
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 )
2026-04-15 15:43:18 +03:00
except Exception :
refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:
core.py (7 routes), mood.py (10), language.py (3), evil_mode.py (6),
bipolar_mode.py (9), gpu.py (2), bot_actions.py (4), autonomous.py (13),
profile_picture.py (26), manual_send.py (3), servers.py (6),
figurines.py (5), dms.py (18), image_generation.py (4), chat.py (1),
config.py (7), logging_config.py (9), voice.py (3), memory.py (10)
All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00
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 ' } "
}