feat: Implement dedicated voice text channel functionality and logging for voice interactions

This commit is contained in:
Slipstream 2025-05-30 22:34:27 -06:00
parent 1197a345b9
commit 36f3f80487
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
4 changed files with 197 additions and 30 deletions

View File

@ -7,6 +7,10 @@ import wave # For saving audio data
import functools # Added for partial
import subprocess # For audio conversion
from discord.ext import voice_recv # For receiving voice
from typing import Optional # For type hinting
# Gurt specific imports
from gurt import config as GurtConfig
# Attempt to import STT and VAD libraries
try:
@ -241,6 +245,7 @@ class VoiceGatewayCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.active_sinks = {} # guild_id: VoiceAudioSink
self.dedicated_voice_text_channels: dict[int, int] = {} # guild_id: channel_id
self.whisper_model = None
if whisper:
try:
@ -253,6 +258,98 @@ class VoiceGatewayCog(commands.Cog):
else:
print("Whisper library not available. STT functionality will be disabled.")
async def _ensure_dedicated_voice_text_channel(self, guild: discord.Guild, voice_channel: discord.VoiceChannel) -> Optional[discord.TextChannel]:
if not GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_ENABLED:
return None
existing_channel_id = self.dedicated_voice_text_channels.get(guild.id)
if existing_channel_id:
channel = guild.get_channel(existing_channel_id)
if channel and isinstance(channel, discord.TextChannel):
print(f"Found existing dedicated voice text channel: {channel.name} ({channel.id})")
return channel
else:
print(f"Dedicated voice text channel ID {existing_channel_id} for guild {guild.id} is invalid or not found. Will create a new one.")
del self.dedicated_voice_text_channels[guild.id] # Remove invalid ID
# Create new channel
channel_name = GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_NAME_TEMPLATE.format(
voice_channel_name=voice_channel.name,
guild_name=guild.name
# Add more placeholders if needed
)
# Sanitize channel name (Discord has restrictions)
channel_name = "".join(c for c in channel_name if c.isalnum() or c in ['-', '_', ' ']).strip()
channel_name = channel_name.replace(' ', '-').lower()
if not channel_name: # Fallback if template results in empty string
channel_name = "gurt-voice-chat"
# Check if a channel with this name already exists (to avoid duplicates if bot restarted without proper cleanup)
for existing_guild_channel in guild.text_channels:
if existing_guild_channel.name == channel_name:
print(f"Found existing channel by name '{channel_name}' ({existing_guild_channel.id}). Reusing.")
self.dedicated_voice_text_channels[guild.id] = existing_guild_channel.id
# Optionally update topic and permissions if needed
try:
if existing_guild_channel.topic != GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_TOPIC:
await existing_guild_channel.edit(topic=GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_TOPIC)
# Send initial message if channel is empty or last message isn't the initial one
async for last_message in existing_guild_channel.history(limit=1):
if last_message.content != GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_INITIAL_MESSAGE:
await existing_guild_channel.send(GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_INITIAL_MESSAGE)
break # Only need the very last message
else: # No messages in channel
await existing_guild_channel.send(GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_INITIAL_MESSAGE)
except discord.Forbidden:
print(f"Missing permissions to update reused dedicated channel {channel_name}")
except Exception as e_reuse:
print(f"Error updating reused dedicated channel {channel_name}: {e_reuse}")
return existing_guild_channel
overwrites = {
guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True, manage_messages=True), # GURT needs to manage
guild.default_role: discord.PermissionOverwrite(read_messages=False, send_messages=False) # Private by default
# Consider adding server admins/mods with read/send permissions
}
# Add owner and admins with full perms to the channel
if guild.owner:
overwrites[guild.owner] = discord.PermissionOverwrite(read_messages=True, send_messages=True, manage_channels=True, manage_messages=True)
for role in guild.roles:
if role.permissions.administrator and not role.is_default(): # Check for admin roles
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True, manage_channels=True, manage_messages=True)
try:
print(f"Creating new dedicated voice text channel: {channel_name}")
new_channel = await guild.create_text_channel(
name=channel_name,
overwrites=overwrites,
topic=GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_TOPIC,
reason="GURT Dedicated Voice Chat Channel"
)
self.dedicated_voice_text_channels[guild.id] = new_channel.id
if GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_INITIAL_MESSAGE:
await new_channel.send(GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_INITIAL_MESSAGE)
print(f"Created dedicated voice text channel: {new_channel.name} ({new_channel.id})")
return new_channel
except discord.Forbidden:
print(f"Forbidden: Could not create dedicated voice text channel '{channel_name}' in guild {guild.name}.")
return None
except Exception as e:
print(f"Error creating dedicated voice text channel '{channel_name}': {e}")
return None
def get_dedicated_text_channel_for_guild(self, guild_id: int) -> Optional[discord.TextChannel]:
channel_id = self.dedicated_voice_text_channels.get(guild_id)
if channel_id:
guild = self.bot.get_guild(guild_id)
if guild:
channel = guild.get_channel(channel_id)
if isinstance(channel, discord.TextChannel):
return channel
return None
async def cog_load(self):
print("VoiceGatewayCog loaded!")
@ -262,15 +359,32 @@ class VoiceGatewayCog(commands.Cog):
for vc in list(self.bot.voice_clients): # Iterate over a copy
guild_id = vc.guild.id
if guild_id in self.active_sinks:
# Ensure vc is an instance of VoiceRecvClient or compatible for stop_listening
if vc.is_connected() and hasattr(vc, 'is_listening') and vc.is_listening():
# Check if stop_listening exists, VoiceRecvClient might have different API
if hasattr(vc, 'stop_listening'):
vc.stop_listening()
else: # Or equivalent for VoiceRecvClient
pass # May need specific cleanup for voice_recv
pass
self.active_sinks[guild_id].cleanup()
del self.active_sinks[guild_id]
# Handle dedicated text channel cleanup on cog unload
if GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_ENABLED and GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_CLEANUP_ON_LEAVE:
dedicated_channel_id = self.dedicated_voice_text_channels.get(guild_id)
if dedicated_channel_id:
try:
channel_to_delete = vc.guild.get_channel(dedicated_channel_id) or await self.bot.fetch_channel(dedicated_channel_id)
if channel_to_delete:
print(f"Deleting dedicated voice text channel {channel_to_delete.name} ({channel_to_delete.id}) during cog unload.")
await channel_to_delete.delete(reason="GURT VoiceGatewayCog unload")
except discord.NotFound:
print(f"Dedicated voice text channel {dedicated_channel_id} not found for deletion during unload.")
except discord.Forbidden:
print(f"Forbidden: Could not delete dedicated voice text channel {dedicated_channel_id} during unload.")
except Exception as e:
print(f"Error deleting dedicated voice text channel {dedicated_channel_id} during unload: {e}")
if guild_id in self.dedicated_voice_text_channels:
del self.dedicated_voice_text_channels[guild_id]
if vc.is_connected():
await vc.disconnect(force=True)
print("VoiceGatewayCog unloaded and disconnected from voice channels.")
@ -281,54 +395,53 @@ class VoiceGatewayCog(commands.Cog):
return None, "Channel not provided."
guild = channel.guild
voice_client = guild.voice_client # This will be VoiceRecvClient if already connected by this cog
voice_client = guild.voice_client
if voice_client and voice_client.is_connected():
if voice_client.channel == channel:
print(f"Already connected to {channel.name} in {guild.name}.")
# Ensure listening is active if already connected
# Check if it's a VoiceRecvClient instance
if isinstance(voice_client, voice_recv.VoiceRecvClient):
if guild.id not in self.active_sinks or not voice_client.is_listening():
self.start_listening_for_vc(voice_client)
else: # If it's a regular VoiceClient, we need to reconnect with VoiceRecvClient
# Ensure dedicated channel is set up even if already connected
await self._ensure_dedicated_voice_text_channel(guild, channel)
else:
print(f"Reconnecting with VoiceRecvClient to {channel.name}.")
await voice_client.disconnect(force=True)
try: # Reconnect with VoiceRecvClient
try:
voice_client = await channel.connect(cls=voice_recv.VoiceRecvClient, timeout=10.0)
print(f"Reconnected to {channel.name} in {guild.name} with VoiceRecvClient.")
self.start_listening_for_vc(voice_client)
await self._ensure_dedicated_voice_text_channel(guild, channel)
except asyncio.TimeoutError:
return None, f"Timeout trying to reconnect to {channel.name} with VoiceRecvClient."
except Exception as e:
return None, f"Error reconnecting to {channel.name} with VoiceRecvClient: {str(e)}"
return voice_client, "Already connected to this channel."
else:
# Handling move_to for VoiceRecvClient might need care.
# Simplest: disconnect and reconnect with VoiceRecvClient to the new channel.
print(f"Moving to {channel.name} in {guild.name}. Reconnecting with VoiceRecvClient.")
await voice_client.disconnect(force=True)
await voice_client.disconnect(force=True) # This will trigger cleanup for old channel's dedicated text channel if configured
try:
voice_client = await channel.connect(cls=voice_recv.VoiceRecvClient, timeout=10.0)
print(f"Moved and reconnected to {channel.name} in {guild.name} with VoiceRecvClient.")
self.start_listening_for_vc(voice_client)
await self._ensure_dedicated_voice_text_channel(guild, channel)
except asyncio.TimeoutError:
return None, f"Timeout trying to move and connect to {channel.name}."
except Exception as e:
return None, f"Error moving and connecting to {channel.name}: {str(e)}"
else:
try:
# Connect using VoiceRecvClient
voice_client = await channel.connect(cls=voice_recv.VoiceRecvClient, timeout=10.0)
print(f"Connected to {channel.name} in {guild.name} with VoiceRecvClient.")
self.start_listening_for_vc(voice_client)
await self._ensure_dedicated_voice_text_channel(guild, channel)
except asyncio.TimeoutError:
return None, f"Timeout trying to connect to {channel.name}."
except Exception as e:
return None, f"Error connecting to {channel.name}: {str(e)}"
if not voice_client: # Should not happen if connect succeeded
if not voice_client:
return None, "Failed to establish voice client after connection."
return voice_client, f"Successfully connected and listening in {channel.name}."
@ -365,6 +478,24 @@ class VoiceGatewayCog(commands.Cog):
self.active_sinks[guild_id].cleanup()
del self.active_sinks[guild_id]
# Handle dedicated text channel cleanup
if GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_ENABLED and GurtConfig.VOICE_DEDICATED_TEXT_CHANNEL_CLEANUP_ON_LEAVE:
dedicated_channel_id = self.dedicated_voice_text_channels.get(guild_id)
if dedicated_channel_id:
try:
channel_to_delete = guild.get_channel(dedicated_channel_id) or await self.bot.fetch_channel(dedicated_channel_id)
if channel_to_delete:
print(f"Deleting dedicated voice text channel {channel_to_delete.name} ({channel_to_delete.id}).")
await channel_to_delete.delete(reason="GURT disconnected from voice channel")
except discord.NotFound:
print(f"Dedicated voice text channel {dedicated_channel_id} not found for deletion.")
except discord.Forbidden:
print(f"Forbidden: Could not delete dedicated voice text channel {dedicated_channel_id}.")
except Exception as e:
print(f"Error deleting dedicated voice text channel {dedicated_channel_id}: {e}")
if guild_id in self.dedicated_voice_text_channels:
del self.dedicated_voice_text_channels[guild_id]
await voice_client.disconnect(force=True)
print(f"Disconnected from voice in {guild.name}.")
return True, f"Disconnected from voice in {guild.name}."

View File

@ -238,6 +238,14 @@ DOCKER_COMMAND_TIMEOUT = int(os.getenv("DOCKER_COMMAND_TIMEOUT", 10))
DOCKER_CPU_LIMIT = os.getenv("DOCKER_CPU_LIMIT", "0.5")
DOCKER_MEM_LIMIT = os.getenv("DOCKER_MEM_LIMIT", "64m")
# --- Voice Configuration ---
VOICE_DEDICATED_TEXT_CHANNEL_ENABLED = os.getenv("VOICE_DEDICATED_TEXT_CHANNEL_ENABLED", "true").lower() == "true"
VOICE_DEDICATED_TEXT_CHANNEL_NAME_TEMPLATE = os.getenv("VOICE_DEDICATED_TEXT_CHANNEL_NAME_TEMPLATE", "🎙gurt-voice-chat")
VOICE_DEDICATED_TEXT_CHANNEL_TOPIC = os.getenv("VOICE_DEDICATED_TEXT_CHANNEL_TOPIC", "GURT Voice Chat | Transcriptions & Text Interactions")
VOICE_DEDICATED_TEXT_CHANNEL_CLEANUP_ON_LEAVE = os.getenv("VOICE_DEDICATED_TEXT_CHANNEL_CLEANUP_ON_LEAVE", "false").lower() == "true"
VOICE_DEDICATED_TEXT_CHANNEL_INITIAL_MESSAGE = os.getenv("VOICE_DEDICATED_TEXT_CHANNEL_INITIAL_MESSAGE", "GURT is listening in voice. Transcriptions and text-based voice interactions will appear here. Type your messages here to talk to GURT in voice!")
VOICE_LOG_SPEECH_TO_DEDICATED_CHANNEL = os.getenv("VOICE_LOG_SPEECH_TO_DEDICATED_CHANNEL", "true").lower() == "true"
# --- Response Schema ---
RESPONSE_SCHEMA = {
"name": "gurt_response",

View File

@ -731,7 +731,7 @@ async def on_voice_transcription_received_listener(cog: 'GurtCog', guild: discor
"""Listener for transcribed voice messages."""
from .api import get_ai_response # For processing the text
from .utils import format_message, simulate_human_typing # For creating pseudo-message and sending response
from .config import IGNORED_CHANNEL_IDS # To respect ignored channels if applicable
from .config import IGNORED_CHANNEL_IDS, VOICE_DEDICATED_TEXT_CHANNEL_ENABLED # Import new config
print(f"Voice transcription received from {user.name} ({user.id}) in {guild.name}: '{text}'")
@ -746,26 +746,36 @@ async def on_voice_transcription_received_listener(cog: 'GurtCog', guild: discor
# Or, if GURT is in a voice channel, it might have an associated text channel.
# This part needs careful consideration for the best UX.
# Try to find a suitable text channel in the guild.
# This logic might need to be more sophisticated, e.g. last active channel for the user.
text_channel = None
if guild:
# Prefer system channel or a common channel name
if guild.system_channel and guild.system_channel.permissions_for(guild.me).send_messages:
text_channel = guild.system_channel
if VOICE_DEDICATED_TEXT_CHANNEL_ENABLED:
voice_gateway_cog = cog.bot.get_cog("VoiceGatewayCog")
if voice_gateway_cog:
text_channel = voice_gateway_cog.get_dedicated_text_channel_for_guild(guild.id)
if text_channel:
print(f"Using dedicated voice text channel: {text_channel.name} ({text_channel.id})")
else:
print(f"Dedicated voice text channel feature is ON, but no channel found for guild {guild.id}. Aborting voice transcription processing.")
return # Do not proceed if dedicated channel is expected but not found
else:
for channel in guild.text_channels:
if channel.name.lower() in ["general", "chat", "lounge", "discussion"] and channel.permissions_for(guild.me).send_messages:
text_channel = channel
break
if not text_channel and guild.text_channels: # Fallback to first available text channel
text_channel = guild.text_channels[0]
print("VoiceGatewayCog not found. Cannot get dedicated text channel. Aborting voice transcription processing.")
return
else: # Fallback to old behavior if dedicated channel feature is off
if guild:
if guild.system_channel and guild.system_channel.permissions_for(guild.me).send_messages:
text_channel = guild.system_channel
else:
for channel in guild.text_channels:
if channel.name.lower() in ["general", "chat", "lounge", "discussion"] and channel.permissions_for(guild.me).send_messages:
text_channel = channel
break
if not text_channel and guild.text_channels:
text_channel = guild.text_channels[0]
if not text_channel:
print(f"Could not find a suitable text channel in guild {guild.name} for voice transcription context. Aborting.")
return
# Check if this pseudo-channel context should be ignored
# Check if this pseudo-channel context should be ignored (applies to both dedicated and fallback)
if text_channel.id in IGNORED_CHANNEL_IDS:
print(f"Skipping voice transcription as target context channel {text_channel.name} ({text_channel.id}) is ignored.")
return

View File

@ -3043,6 +3043,9 @@ async def speak_in_voice_channel(cog: commands.Cog, text_to_speak: str, tts_prov
if not active_vc or not active_vc.is_connected():
return {"status": "error", "error": "GURT is not connected to a voice channel."}
# Import GurtConfig for voice channel settings
from .config import VOICE_DEDICATED_TEXT_CHANNEL_ENABLED, VOICE_LOG_SPEECH_TO_DEDICATED_CHANNEL
tts_cog = cog.bot.get_cog("TTSProviderCog")
if not tts_cog:
return {"status": "error", "error": "TTSProviderCog not loaded."}
@ -3080,6 +3083,21 @@ async def speak_in_voice_channel(cog: commands.Cog, text_to_speak: str, tts_prov
play_success, play_message = await voice_gateway_cog.play_audio_file(active_vc, audio_file_path)
if play_success:
# Log to dedicated text channel if enabled
if VOICE_DEDICATED_TEXT_CHANNEL_ENABLED and VOICE_LOG_SPEECH_TO_DEDICATED_CHANNEL:
if voice_gateway_cog: # Should exist if we got this far
dedicated_channel = voice_gateway_cog.get_dedicated_text_channel_for_guild(active_vc.guild.id)
if dedicated_channel:
try:
await dedicated_channel.send(f"GURT (Voice): {text_to_speak}")
print(f"Logged GURT's speech to dedicated channel {dedicated_channel.name}")
except Exception as e_log:
print(f"Error logging GURT's speech to dedicated channel {dedicated_channel.name}: {e_log}")
else:
print(f"Could not find dedicated text channel for guild {active_vc.guild.id} to log speech.")
else: # Should not happen
print("VoiceGatewayCog not found for logging speech to dedicated channel.")
return {"status": "success", "message": play_message, "text_spoken": text_to_speak, "provider_used": chosen_provider}
else:
# TTSProviderCog's cleanup should handle the audio_file_path if play fails