feat: add chunked meeting transcription and logging
This commit is contained in:
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,8 +16,13 @@ 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()
|
||||
|
||||
@@ -38,14 +42,14 @@ commands_synced = False
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
global commands_synced
|
||||
print(f"Logged in as {bot.user}")
|
||||
logger.info("Logged in as %s", 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}")
|
||||
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):
|
||||
@@ -57,6 +61,7 @@ async def resolve_text_channel(channel_id: int | 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
|
||||
@@ -80,19 +85,35 @@ async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float =
|
||||
last_size = -1
|
||||
stable_count = 0
|
||||
|
||||
for _ in range(attempts):
|
||||
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
|
||||
|
||||
|
||||
@@ -107,44 +128,72 @@ async def process_recording(
|
||||
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, f"❌ Recording failed: {error}")
|
||||
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."
|
||||
"⚠️ 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 as exc:
|
||||
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, f"❌ Meeting summary failed: {exc}")
|
||||
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)
|
||||
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
|
||||
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,
|
||||
@@ -153,8 +202,8 @@ def make_after_callback(file_path: str, guild_id: int, fallback_channel_id: int
|
||||
def _consume_future(fut):
|
||||
try:
|
||||
fut.result()
|
||||
except Exception as exc:
|
||||
print(f"Post-recording processing failed: {exc}")
|
||||
except Exception:
|
||||
logger.exception("Post-recording processing future failed: guild_id=%s", guild_id)
|
||||
|
||||
future.add_done_callback(_consume_future)
|
||||
|
||||
@@ -171,6 +220,7 @@ async def set_output(interaction: discord.Interaction, channel: discord.TextChan
|
||||
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,
|
||||
@@ -221,6 +271,12 @@ async def join(interaction: discord.Interaction):
|
||||
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)
|
||||
@@ -230,13 +286,14 @@ async def join(interaction: discord.Interaction):
|
||||
f"🎙️ Joined **{user_voice.channel.name}** and started recording. Use `/leave` to stop.",
|
||||
ephemeral=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
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:
|
||||
pass
|
||||
await interaction.followup.send(f"❌ Failed to start recording: {exc}", ephemeral=True)
|
||||
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()
|
||||
@@ -264,16 +321,18 @@ async def leave(interaction: discord.Interaction):
|
||||
|
||||
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.",
|
||||
"🎧 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 as exc:
|
||||
except Exception:
|
||||
processing.discard(guild_id)
|
||||
await interaction.followup.send(f"❌ Failed to stop recording: {exc}", ephemeral=True)
|
||||
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()
|
||||
@@ -302,9 +361,9 @@ async def status(interaction: discord.Interaction):
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not TOKEN:
|
||||
print("ERROR: DISCORD_BOT_TOKEN not set. Create a .env file.")
|
||||
logger.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.")
|
||||
logger.error("OPENROUTER_API_KEY not set. Create a .env file.")
|
||||
raise SystemExit(1)
|
||||
bot.run(TOKEN)
|
||||
|
||||
Reference in New Issue
Block a user