Files
miku-discord/bot/routes/voice.py
koko210Serve 979217e7cc refactor: split api.py monolith into 19 route modules (Phase B)
Split 3,598-line api.py into thin orchestrator (128 lines) + 19 route
modules in bot/routes/:

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

All 146 routes verified present via test_route_split.py (149 tests).
21/21 regression tests (test_config_state.py) pass.
Monolith backup: bot/api_monolith_backup.py (revert: cp it to api.py).
2026-04-15 11:38:14 +03:00

208 lines
7.2 KiB
Python

"""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'}"
}