Files
discord-meeting-bot/bot.py
T

370 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import logging
import os
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
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logging.getLogger("discord.ext.voice_recv.reader").setLevel(logging.WARNING)
logging.getLogger("discord.ext.voice_recv.gateway").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
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
logger.info("Logged in as %s", bot.user)
if not commands_synced:
try:
synced = await bot.tree.sync()
commands_synced = True
logger.info("Synced %s commands", len(synced))
except Exception:
logger.exception("Sync error while registering slash commands")
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:
logger.warning("Failed to resolve text channel: channel_id=%s", channel_id)
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 attempt in range(1, attempts + 1):
if os.path.exists(file_path):
size = os.path.getsize(file_path)
logger.info(
"Checking recording file readiness: path=%s attempt=%s/%s size_bytes=%s",
file_path,
attempt,
attempts,
size,
)
if size > 0 and size == last_size:
stable_count += 1
if stable_count >= 2:
logger.info("Recording file finalized: path=%s size_bytes=%s", file_path, size)
return True
else:
stable_count = 0
last_size = size
else:
logger.info(
"Recording file not present yet: path=%s attempt=%s/%s",
file_path,
attempt,
attempts,
)
await asyncio.sleep(delay)
logger.warning("Recording file did not stabilize before timeout: path=%s", file_path)
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
)
logger.info(
"Starting post-recording processing: guild_id=%s path=%s fallback_channel_id=%s",
guild_id,
file_path,
fallback_channel_id,
)
if error is not None:
logger.exception("Recording callback reported an error for guild_id=%s", guild_id, exc_info=error)
if target_channel:
await send_chunked(
target_channel,
"❌ Recording failed. Check the bot logs for details. The original recording was kept if it was written to disk.",
)
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. Check the bot logs. The original recording path was kept for retry."
)
return
if os.path.exists(file_path):
logger.info(
"Original recording preserved for guild_id=%s at path=%s size_mib=%.2f",
guild_id,
str(Path(file_path).resolve()),
os.path.getsize(file_path) / 1024 / 1024,
)
transcript = await transcribe(file_path)
logger.info("Transcription complete for guild_id=%s transcript_chars=%s", guild_id, len(transcript))
summary = await summarize(transcript)
logger.info("Summarization complete for guild_id=%s summary_chars=%s", guild_id, len(summary))
if target_channel:
await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}")
except Exception:
logger.exception(
"Meeting summary processing failed: guild_id=%s path=%s",
guild_id,
file_path,
)
error_channel = await resolve_text_channel(fallback_channel_id)
if error_channel:
await send_chunked(
error_channel,
"❌ Meeting summary failed. Check the bot logs for details. The original recording was kept on disk for manual retry.",
)
finally:
processing.discard(guild_id)
recorders.pop(guild_id, None)
if os.path.exists(file_path):
logger.info("Finished processing guild_id=%s; original recording retained at %s", guild_id, str(Path(file_path).resolve()))
def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int | None):
def _after(error: Exception | None):
logger.info(
"Record callback fired: guild_id=%s path=%s had_error=%s",
guild_id,
file_path,
error is not 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:
logger.exception("Post-recording processing future failed: guild_id=%s", guild_id)
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)
logger.info("Configured output channel: guild_id=%s channel_id=%s", 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)
logger.info(
"Starting recording session: guild_id=%s voice_channel=%s output_path=%s",
guild_id,
user_voice.channel.id,
str(audio_path.resolve()),
)
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:
logger.exception("Failed to start recording: guild_id=%s", guild_id)
if voice_client and voice_client.is_connected():
try:
await voice_client.disconnect(force=True)
except Exception:
logger.exception("Failed to disconnect voice client after startup error: guild_id=%s", guild_id)
await interaction.followup.send("❌ Failed to start recording. Check the bot logs for details.", 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)
logger.info("Stopping recording session: guild_id=%s", 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. The original recording will be kept on disk.",
ephemeral=True,
)
except Exception:
processing.discard(guild_id)
logger.exception("Failed to stop recording cleanly: guild_id=%s", guild_id)
await interaction.followup.send("❌ Failed to stop recording. Check the bot logs for details.", 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:
logger.error("DISCORD_BOT_TOKEN not set. Create a .env file.")
raise SystemExit(1)
if not OPENROUTER_KEY:
logger.error("OPENROUTER_API_KEY not set. Create a .env file.")
raise SystemExit(1)
bot.run(TOKEN)