from __future__ import annotations import asyncio import os import shutil import uuid from pathlib import Path import discord from discord import app_commands from discord.ext import commands, voice_recv from dotenv import load_dotenv import config from helpers import chunk_message, command_channel_error from openrouter_client import summarize, transcribe from voice import MeetingRecorder load_dotenv() TOKEN = os.getenv("DISCORD_BOT_TOKEN") OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY") intents = discord.Intents.default() intents.voice_states = True bot = commands.Bot(command_prefix="!", intents=intents) recorders: dict[int, MeetingRecorder] = {} processing: set[int] = set() commands_synced = False @bot.event async def on_ready(): global commands_synced print(f"Logged in as {bot.user}") if not commands_synced: try: synced = await bot.tree.sync() commands_synced = True print(f"Synced {len(synced)} commands") except Exception as exc: print(f"Sync error: {exc}") async def resolve_text_channel(channel_id: int | None): if not channel_id: return None channel = bot.get_channel(channel_id) if channel is None: try: channel = await bot.fetch_channel(channel_id) except discord.DiscordException: return None return channel async def send_chunked(channel, text: str): for chunk in chunk_message(text, limit=1900): await channel.send(chunk) async def ensure_command_channel(interaction: discord.Interaction) -> str | None: guild_id = interaction.guild_id if guild_id is None: return "❌ This command can only be used in a server." allowed_channel_id = await config.get_output_channel(guild_id) return command_channel_error(interaction.channel_id, allowed_channel_id) async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float = 0.25) -> bool: last_size = -1 stable_count = 0 for _ in range(attempts): if os.path.exists(file_path): size = os.path.getsize(file_path) if size > 0 and size == last_size: stable_count += 1 if stable_count >= 2: return True else: stable_count = 0 last_size = size await asyncio.sleep(delay) return False async def process_recording( file_path: str, guild_id: int, fallback_channel_id: int | None, error: Exception | None, ): try: target_channel = await resolve_text_channel( await config.get_output_channel(guild_id) or fallback_channel_id ) if error is not None: if target_channel: await send_chunked(target_channel, f"❌ Recording failed: {error}") return if not await wait_for_file_ready(file_path): if target_channel: await target_channel.send( "⚠️ Recording finished, but the audio file was not finalized in time." ) return transcript = await transcribe(file_path) summary = await summarize(transcript) if target_channel: await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}") except Exception as exc: error_channel = await resolve_text_channel(fallback_channel_id) if error_channel: await send_chunked(error_channel, f"❌ Meeting summary failed: {exc}") finally: processing.discard(guild_id) recorders.pop(guild_id, None) try: os.remove(file_path) session_dir = Path(file_path).parent if session_dir.exists() and not any(session_dir.iterdir()): session_dir.rmdir() guild_dir = session_dir.parent if guild_dir.exists() and not any(guild_dir.iterdir()): guild_dir.rmdir() except OSError: pass def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int | None): def _after(error: Exception | None): future = asyncio.run_coroutine_threadsafe( process_recording(file_path, guild_id, fallback_channel_id, error), bot.loop, ) def _consume_future(fut): try: fut.result() except Exception as exc: print(f"Post-recording processing failed: {exc}") future.add_done_callback(_consume_future) return _after @app_commands.guild_only() @bot.tree.command(name="set_output", description="Set the text channel for meeting summaries") @app_commands.describe(channel="The text channel to post summaries to") async def set_output(interaction: discord.Interaction, channel: discord.TextChannel): guild_id = interaction.guild_id if guild_id is None: await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True) return await config.set_output_channel(guild_id, channel.id) await interaction.response.send_message( f"✅ Output channel set to {channel.mention}", ephemeral=True, ) @app_commands.guild_only() @bot.tree.command(name="join", description="Join your voice channel and start recording") async def join(interaction: discord.Interaction): guild_id = interaction.guild_id guild = interaction.guild if guild_id is None or guild is None: await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True) return if guild_id in processing: await interaction.response.send_message( "⚠️ I'm still processing the previous recording for this server.", ephemeral=True, ) return channel_error = await ensure_command_channel(interaction) if channel_error: await interaction.response.send_message(channel_error, ephemeral=True) return user_voice = getattr(interaction.user, "voice", None) if not user_voice or not user_voice.channel: await interaction.response.send_message( "❌ You need to be in a voice channel first.", ephemeral=True, ) return if guild.voice_client: await interaction.response.send_message( "⚠️ I'm already connected in this server. Use `/leave` first.", ephemeral=True, ) return await interaction.response.defer(ephemeral=True, thinking=True) voice_client = None try: session_dir = Path("recordings") / str(guild_id) / str(uuid.uuid4()) audio_path = session_dir / "meeting.wav" after_callback = make_after_callback(str(audio_path), guild_id, interaction.channel_id) voice_client = await user_voice.channel.connect(cls=voice_recv.VoiceRecvClient) recorder = MeetingRecorder(voice_client, str(audio_path)) await recorder.start(after_callback) recorders[guild_id] = recorder await interaction.followup.send( f"🎙️ Joined **{user_voice.channel.name}** and started recording. Use `/leave` to stop.", ephemeral=True, ) except Exception as exc: if voice_client and voice_client.is_connected(): try: await voice_client.disconnect(force=True) except Exception: pass await interaction.followup.send(f"❌ Failed to start recording: {exc}", ephemeral=True) @app_commands.guild_only() @bot.tree.command(name="leave", description="Stop recording, transcribe, summarize, and post to output channel") async def leave(interaction: discord.Interaction): guild_id = interaction.guild_id guild = interaction.guild if guild_id is None or guild is None: await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True) return recorder = recorders.get(guild_id) voice_client = guild.voice_client channel_error = await ensure_command_channel(interaction) if channel_error: await interaction.response.send_message(channel_error, ephemeral=True) return if not voice_client or not recorder: await interaction.response.send_message("❌ I'm not recording in this server right now.", ephemeral=True) return await interaction.response.defer(ephemeral=True, thinking=True) try: processing.add(guild_id) await recorder.stop() recorders.pop(guild_id, None) await voice_client.disconnect(force=True) await interaction.followup.send( "🎧 Stopped recording. I'm transcribing and summarizing now — I'll post the result in the configured output channel.", ephemeral=True, ) except Exception as exc: processing.discard(guild_id) await interaction.followup.send(f"❌ Failed to stop recording: {exc}", ephemeral=True) @app_commands.guild_only() @bot.tree.command(name="status", description="Check bot state") async def status(interaction: discord.Interaction): guild_id = interaction.guild_id guild = interaction.guild if guild_id is None or guild is None: await interaction.response.send_message("❌ This command can only be used in a server.", ephemeral=True) return vc = guild.voice_client recorder = recorders.get(guild_id) channel_error = await ensure_command_channel(interaction) if channel_error: await interaction.response.send_message(channel_error, ephemeral=True) return lines = ["**📊 Bot Status**"] lines.append(f"Voice: {'Connected' if vc else 'Disconnected'}") lines.append(f"Recording: {'Yes' if recorder and recorder.recording else 'No'}") lines.append(f"Processing summary: {'Yes' if guild_id in processing else 'No'}") ch_id = await config.get_output_channel(guild_id) lines.append(f"Output channel: {'<#' + str(ch_id) + '>' if ch_id else 'Not set'}") await interaction.response.send_message("\n".join(lines), ephemeral=True) if __name__ == "__main__": if not TOKEN: print("ERROR: DISCORD_BOT_TOKEN not set. Create a .env file.") raise SystemExit(1) if not OPENROUTER_KEY: print("ERROR: OPENROUTER_API_KEY not set. Create a .env file.") raise SystemExit(1) bot.run(TOKEN)