openclaw-voice/discord_bot/commands.py
Jezza Hehn 3450e57ca6 Fix voice portal: WebSocket routing, Caddy keepalive, audio pipeline
- Fix app.py: @app.get -> @app.websocket for /ws/voice route (was returning 403)
- Fix app.py: create static_dir before mounting it (AttributeError on startup)
- Fix voice.html: AudioWorkletNode constructor (was AudioWorkletProcessor)
- Fix voice.html: use ScriptProcessor directly (more reliable)
- Fix voice.html: send Float32 directly (server expects float32, was sending Int16)
- Fix voice.html: auto-detect ws/wss protocol from page URL
- Add Caddy reverse proxy keepalive pings every 15s to prevent timeout
- Add detailed message type logging in WebSocket receive loop
- Strip Jarvis/Sage personas, rename bot to MoltMic
- Add /moltmic voice slash command for portal URL
- Update portal URL to https://voice.jezzahehn.com
2026-04-10 04:47:31 +00:00

140 lines
5.7 KiB
Python

"""Discord slash commands for MoltMic voice bot."""
from typing import Optional
import discord
from discord import app_commands
from utils.logging import get_logger
try:
from discord.ext import voice_recv
HAS_VOICE_RECV = True
except ImportError:
voice_recv = None
HAS_VOICE_RECV = False
logger = get_logger(__name__)
class MoltMicCommands(app_commands.Group):
"""Slash commands for MoltMic voice bot."""
def __init__(self, bot):
super().__init__(name="moltmic", description="MoltMic voice commands")
self.bot = bot
@app_commands.command(name="join", description="Join your voice channel and start listening")
@app_commands.describe(channel="Voice channel to join (defaults to your current channel)")
async def join(self, interaction: discord.Interaction, channel: Optional[discord.VoiceChannel] = None):
await interaction.response.defer(thinking=True)
try:
target = channel or (interaction.user.voice and interaction.user.voice.channel)
if not target:
await interaction.followup.send("❌ You're not in a voice channel.", ephemeral=True)
return
# Already in this channel?
if interaction.guild.voice_client and interaction.guild.voice_client.channel.id == target.id:
await interaction.followup.send(f"Already in {target.mention}", ephemeral=True)
return
# Move or connect
if interaction.guild.voice_client:
await interaction.guild.voice_client.move_to(target)
voice_client = interaction.guild.voice_client
else:
connect_cls = voice_recv.VoiceRecvClient if HAS_VOICE_RECV else discord.VoiceClient
voice_client = await target.connect(cls=connect_cls, self_deaf=False, timeout=60.0)
await self.bot.on_voice_join(interaction.guild, target, voice_client)
await interaction.followup.send(f"🎙️ Joined {target.mention} and listening...")
except discord.errors.ClientException as e:
logger.error(f"Failed to join: {e}")
await interaction.followup.send(f"❌ Failed to join: {e}", ephemeral=True)
except Exception as e:
logger.exception(f"Join error: {e}")
await interaction.followup.send("❌ Unexpected error", ephemeral=True)
@app_commands.command(name="leave", description="Leave the voice channel")
async def leave(self, interaction: discord.Interaction):
await interaction.response.defer(thinking=True)
try:
if not interaction.guild.voice_client:
await interaction.followup.send("❌ Not in a voice channel.", ephemeral=True)
return
await self.bot.on_voice_leave(interaction.guild)
await interaction.followup.send("👋 Left voice channel.")
except Exception as e:
logger.exception(f"Leave error: {e}")
await interaction.followup.send("❌ Error leaving.", ephemeral=True)
@app_commands.command(name="status", description="Show bot status")
async def status(self, interaction: discord.Interaction):
await interaction.response.defer(thinking=True)
try:
session = self.bot.session_manager.get_session(interaction.guild.id)
if not session:
await interaction.followup.send("❌ Not in a voice channel.", ephemeral=True)
return
embed = discord.Embed(title="🎙️ MoltMic Status", color=discord.Color.green())
embed.add_field(name="Channel", value=f"<#{session.channel_id}>", inline=True)
embed.add_field(name="Duration", value=f"{session.duration:.0f}s", inline=True)
embed.add_field(name="Users", value=str(session.get_user_count()), inline=True)
await interaction.followup.send(embed=embed)
except Exception as e:
logger.exception(f"Status error: {e}")
await interaction.followup.send("❌ Error.", ephemeral=True)
@app_commands.command(name="voice", description="Open voice portal in browser")
async def voice(self, interaction: discord.Interaction):
"""Generate a voice portal URL for browser-based speech."""
await interaction.response.defer(thinking=True)
try:
# Import here to avoid circular dependency
from server.voice_ws import create_session_id
session_id = create_session_id()
portal_url = f"https://voice.jezzahehn.com/voice?session={session_id}"
embed = discord.Embed(
title="🎙️ Voice Portal",
description="Click below to open the voice portal in your browser",
color=discord.Color.blue()
)
embed.add_field(
name="Portal URL",
value=f"[Open Voice Portal]({portal_url})",
inline=False
)
embed.add_field(
name="Instructions",
value="1. Click the link above\n2. Allow microphone access\n3. Start talking! The bot will listen and respond.",
inline=False
)
embed.set_footer(text="The bot will start listening when you connect")
await interaction.followup.send(embed=embed)
logger.info(f"Voice portal created for session {session_id}")
except Exception as e:
logger.exception(f"Voice portal error: {e}")
await interaction.followup.send("❌ Failed to create voice portal.", ephemeral=True)
async def setup_commands(bot):
"""Register slash commands."""
cmds = MoltMicCommands(bot)
bot.tree.add_command(cmds)
logger.info("Slash commands registered (moltmic: join, leave, status)")
return cmds