Add manual retry flow for failed meeting summaries
This commit is contained in:
@@ -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,18 +196,25 @@ 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
|
||||
|
||||
@@ -163,20 +230,27 @@ 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user