From 53d73df4337dd7f58e9d8b3fa8b9bd8e60d134c3 Mon Sep 17 00:00:00 2001 From: Pheby Date: Tue, 9 Jun 2026 11:42:13 +0000 Subject: [PATCH] Add manual retry flow for failed meeting summaries --- bot.py | 184 +++++++++++++++++++++++++++++++++++++------ config.py | 39 ++++++++- tests/test_bot.py | 68 ++++++++++++++++ tests/test_config.py | 41 ++++++++++ 4 files changed, 308 insertions(+), 24 deletions(-) create mode 100644 tests/test_bot.py create mode 100644 tests/test_config.py diff --git a/bot.py b/bot.py index 38ef7e2..d6d080f 100644 --- a/bot.py +++ b/bot.py @@ -72,6 +72,44 @@ async def send_chunked(channel, text: str): await channel.send(chunk) +async def safe_send_chunked(primary_channel, text: str, *, fallback_channels: list | None = None, purpose: str = "message"): + candidates = [] + seen_ids = set() + for channel in [primary_channel, *(fallback_channels or [])]: + if channel is None: + continue + channel_id = getattr(channel, "id", id(channel)) + if channel_id in seen_ids: + continue + seen_ids.add(channel_id) + candidates.append(channel) + + for channel in candidates: + try: + await send_chunked(channel, text) + logger.info( + "Delivered %s to channel_id=%s", + purpose, + getattr(channel, "id", "unknown"), + ) + return channel + except discord.Forbidden: + logger.exception( + "Missing permission while sending %s to channel_id=%s", + purpose, + getattr(channel, "id", "unknown"), + ) + except discord.DiscordException: + logger.exception( + "Discord API error while sending %s to channel_id=%s", + purpose, + getattr(channel, "id", "unknown"), + ) + + logger.error("Failed to deliver %s to any candidate channel", purpose) + return None + + async def ensure_command_channel(interaction: discord.Interaction) -> str | None: guild_id = interaction.guild_id if guild_id is None: @@ -117,17 +155,39 @@ async def wait_for_file_ready(file_path: str, attempts: int = 20, delay: float = return False +def build_retry_notice(file_path: str, reason: str) -> str: + file_name = Path(file_path).name + return ( + "❌ Meeting summary failed. " + f"Reason: {reason}. " + f"Original recording kept on disk ({file_name}). " + "Use `/retry` later to try the latest saved recording again." + ) + + +async def _save_retry_state(guild_id: int, file_path: str, source_channel_id: int | None, reason: str) -> None: + await config.set_retry_state(guild_id, file_path, source_channel_id, reason) + logger.info( + "Saved retry state: guild_id=%s path=%s source_channel_id=%s reason=%s", + guild_id, + file_path, + source_channel_id, + reason, + ) + + 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 - ) + output_channel_id = await config.get_output_channel(guild_id) + output_channel = await resolve_text_channel(output_channel_id) + fallback_channel = await resolve_text_channel(fallback_channel_id) + notify_channels = [channel for channel in [output_channel, fallback_channel] if channel is not None] + try: logger.info( "Starting post-recording processing: guild_id=%s path=%s fallback_channel_id=%s", guild_id, @@ -136,19 +196,26 @@ async def process_recording( ) if error is not None: + reason = f"recording stopped with callback error: {error}" 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.", - ) + await _save_retry_state(guild_id, file_path, fallback_channel_id, reason) + await safe_send_chunked( + output_channel, + build_retry_notice(file_path, reason), + fallback_channels=[fallback_channel], + purpose="recording failure notice", + ) 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." - ) + reason = "recording file was not finalized in time" + await _save_retry_state(guild_id, file_path, fallback_channel_id, reason) + await safe_send_chunked( + output_channel, + build_retry_notice(file_path, reason), + fallback_channels=[fallback_channel], + purpose="file-finalization warning", + ) return if os.path.exists(file_path): @@ -163,21 +230,28 @@ async def process_recording( 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)) + await config.clear_retry_state(guild_id) - if target_channel: - await send_chunked(target_channel, f"📋 **Meeting Summary**\n\n{summary}") - except Exception: + await safe_send_chunked( + output_channel, + f"📋 **Meeting Summary**\n\n{summary}", + fallback_channels=[fallback_channel], + purpose="meeting summary", + ) + except Exception as exc: + reason = str(exc).strip() or exc.__class__.__name__ 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.", - ) + await _save_retry_state(guild_id, file_path, fallback_channel_id, reason) + await safe_send_chunked( + output_channel, + build_retry_notice(file_path, reason), + fallback_channels=[fallback_channel], + purpose="processing failure notice", + ) finally: processing.discard(guild_id) recorders.pop(guild_id, None) @@ -335,6 +409,70 @@ async def leave(interaction: discord.Interaction): await interaction.followup.send("❌ Failed to stop recording. Check the bot logs for details.", ephemeral=True) +@app_commands.guild_only() +@bot.tree.command(name="retry", description="Retry the latest saved recording for this server") +async def retry(interaction: discord.Interaction): + 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 + + channel_error = await ensure_command_channel(interaction) + if channel_error: + await interaction.response.send_message(channel_error, ephemeral=True) + return + + if guild_id in processing: + await interaction.response.send_message( + "âš ī¸ I'm already processing a recording for this server.", + ephemeral=True, + ) + return + + retry_state = await config.get_retry_state(guild_id) + if not retry_state: + await interaction.response.send_message( + "â„šī¸ There isn't a saved failed recording to retry right now.", + ephemeral=True, + ) + return + + file_path = retry_state.get("file_path") + if not file_path or not os.path.exists(file_path): + await config.clear_retry_state(guild_id) + await interaction.response.send_message( + "âš ī¸ I found retry metadata, but the saved recording is no longer on disk. I cleared the stale retry state.", + ephemeral=True, + ) + return + + await interaction.response.defer(ephemeral=True, thinking=True) + processing.add(guild_id) + logger.info( + "Manual retry requested: guild_id=%s path=%s source_channel_id=%s reason=%s", + guild_id, + file_path, + retry_state.get("source_channel_id"), + retry_state.get("reason"), + ) + future = asyncio.run_coroutine_threadsafe( + process_recording(file_path, guild_id, interaction.channel_id, None), + bot.loop, + ) + + def _consume_retry_future(fut): + try: + fut.result() + except Exception: + logger.exception("Retry processing future failed: guild_id=%s", guild_id) + + future.add_done_callback(_consume_retry_future) + await interaction.followup.send( + f"🔁 Retrying the latest saved recording: `{Path(file_path).name}`. I'll post the result in the configured output channel if it works.", + ephemeral=True, + ) + + @app_commands.guild_only() @bot.tree.command(name="status", description="Check bot state") async def status(interaction: discord.Interaction): @@ -356,6 +494,8 @@ async def status(interaction: discord.Interaction): 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'}") + retry_state = await config.get_retry_state(guild_id) + lines.append(f"Saved retry recording: {'Yes' if retry_state else 'No'}") await interaction.response.send_message("\n".join(lines), ephemeral=True) diff --git a/config.py b/config.py index 3c95eb3..796af90 100644 --- a/config.py +++ b/config.py @@ -24,9 +24,14 @@ async def save_config(config: dict) -> None: await f.write(json.dumps(config, indent=2)) -async def get_output_channel(guild_id: int) -> Optional[int]: +async def _get_guild_config(guild_id: int) -> dict: config = await load_config() - return config.get(str(guild_id), {}).get("output_channel_id") + return config.get(str(guild_id), {}) + + +async def get_output_channel(guild_id: int) -> Optional[int]: + guild_config = await _get_guild_config(guild_id) + return guild_config.get("output_channel_id") async def set_output_channel(guild_id: int, channel_id: int) -> None: @@ -36,3 +41,33 @@ async def set_output_channel(guild_id: int, channel_id: int) -> None: guild_config["output_channel_id"] = channel_id config[guild_key] = guild_config await save_config(config) + + +async def get_retry_state(guild_id: int) -> Optional[dict]: + guild_config = await _get_guild_config(guild_id) + retry_state = guild_config.get("retry_state") + return retry_state if isinstance(retry_state, dict) else None + + +async def set_retry_state(guild_id: int, file_path: str, source_channel_id: int | None, reason: str) -> None: + config = await load_config() + guild_key = str(guild_id) + guild_config = config.get(guild_key, {}) + guild_config["retry_state"] = { + "file_path": file_path, + "source_channel_id": source_channel_id, + "reason": reason, + } + config[guild_key] = guild_config + await save_config(config) + + +async def clear_retry_state(guild_id: int) -> None: + config = await load_config() + guild_key = str(guild_id) + guild_config = config.get(guild_key) + if not guild_config: + return + guild_config.pop("retry_state", None) + config[guild_key] = guild_config + await save_config(config) diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..4b7f392 --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,68 @@ +import asyncio +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import discord + +import bot + + +class FakeChannel: + def __init__(self, failures_before_success=0, channel_id=999): + self.failures_before_success = failures_before_success + self.messages = [] + self.attempts = 0 + self.id = channel_id + + async def send(self, text): + self.attempts += 1 + if self.attempts <= self.failures_before_success: + response = SimpleNamespace(status=403, reason="Forbidden") + raise discord.Forbidden(response, {"message": "Missing Permissions", "code": 50013}) + self.messages.append(text) + + +def test_safe_send_chunked_falls_back_to_secondary_channel(): + async def run(): + primary = FakeChannel(failures_before_success=1, channel_id=100) + fallback = FakeChannel(channel_id=200) + delivered = await bot.safe_send_chunked( + primary, + "hello world", + fallback_channels=[fallback], + purpose="test notification", + ) + return delivered, fallback.messages + + delivered, messages = asyncio.run(run()) + assert messages == ["hello world"] + assert delivered is not None + + +def test_safe_send_chunked_returns_none_when_all_channels_fail(): + async def run(): + primary = FakeChannel(failures_before_success=10, channel_id=100) + fallback = FakeChannel(failures_before_success=10, channel_id=200) + return await bot.safe_send_chunked( + primary, + "hello world", + fallback_channels=[fallback], + purpose="test notification", + ) + + assert asyncio.run(run()) is None + + +def test_build_retry_notice_mentions_reason_and_path(): + message = bot.build_retry_notice( + file_path=str(Path("recordings") / "1" / "session" / "meeting.wav"), + reason="OpenRouter 502", + ) + + assert "OpenRouter 502" in message + assert "meeting.wav" in message + assert "`/retry`" in message diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..2650a7a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,41 @@ +import asyncio +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import config + + +def _reset_config_path(tmp_path: Path): + config.CONFIG_PATH = tmp_path / "config.json" + + +def test_retry_state_round_trip(tmp_path): + async def run(): + _reset_config_path(tmp_path) + await config.set_retry_state( + guild_id=123, + file_path="recordings/123/session/meeting.wav", + source_channel_id=456, + reason="transcription failed", + ) + return await config.get_retry_state(123) + + state = asyncio.run(run()) + assert state == { + "file_path": "recordings/123/session/meeting.wav", + "source_channel_id": 456, + "reason": "transcription failed", + } + + +def test_clear_retry_state_removes_saved_entry(tmp_path): + async def run(): + _reset_config_path(tmp_path) + await config.set_retry_state(123, "a.wav", 456, "oops") + await config.clear_retry_state(123) + return await config.get_retry_state(123) + + assert asyncio.run(run()) is None