dheraingoud's picture
feat: sync upstream commits up to f17c92bc
a1bab2d
Raw
History Blame Contribute Delete
13.6 kB
"""Shared voice-note flow for messaging platform adapters."""
from __future__ import annotations
import asyncio
import contextlib
import tempfile
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loguru import logger
from free_claude_code.core.diagnostics import format_user_error_preview
from ..models import IncomingMessage, MessageScope
from ..voice import (
PendingVoiceClaim,
PendingVoiceRegistry,
Transcriber,
VoiceCancellationResult,
VoiceHandoffOutcome,
)
AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a")
MAX_AUDIO_SIZE_BYTES = 25 * 1024 * 1024
VOICE_DISABLED_MESSAGE = "Voice notes are disabled."
VOICE_TRANSCRIPTION_ERROR_MESSAGE = (
"Could not transcribe voice note. Please try again or send text."
)
MessageHandler = Callable[[IncomingMessage], Awaitable[None]]
QueueSend = Callable[..., Awaitable[str | None]]
QueueDeleteMany = Callable[..., Awaitable[None]]
@dataclass(frozen=True)
class VoiceNoteRequest:
"""Platform-normalized voice-note input."""
platform: str
chat_id: str
user_id: str
message_id: str
raw_event: Any
content_type: str
temp_suffix: str
status_text: str
download_to: Callable[[Path], Awaitable[None]]
reply_text: Callable[[str], Awaitable[None]]
reply_to_message_id: str | None = None
status_parse_mode: str | None = None
message_thread_id: str | None = None
username: str | None = None
@property
def scope(self) -> MessageScope:
return MessageScope(platform=self.platform, chat_id=self.chat_id)
def is_audio_metadata(filename: str | None, content_type: str | None) -> bool:
"""Return whether attachment metadata describes an audio file."""
normalized_content_type = (content_type or "").lower()
normalized_filename = (filename or "").lower()
return normalized_content_type.startswith("audio/") or any(
normalized_filename.endswith(extension) for extension in AUDIO_EXTENSIONS
)
def audio_suffix_from_metadata(
*,
filename: str | None = None,
content_type: str | None = None,
default: str = ".ogg",
) -> str:
"""Choose a temp-file suffix from platform attachment metadata."""
normalized_filename = (filename or "").lower()
normalized_content_type = (content_type or "").lower()
if "m4a" in normalized_content_type:
return ".m4a"
if "mp4" in normalized_content_type:
if normalized_filename.endswith(".m4a"):
return ".m4a"
return ".mp4"
if "mpeg" in normalized_content_type or "mp3" in normalized_content_type:
return ".mp3"
if "wav" in normalized_content_type:
return ".wav"
for extension in AUDIO_EXTENSIONS:
if normalized_filename.endswith(extension):
return extension
return default
class VoiceNoteFlow:
"""Own common voice transcription state and control flow."""
def __init__(
self,
*,
transcriber: Transcriber | None,
log_raw_messaging_content: bool,
log_api_error_tracebacks: bool,
) -> None:
self._transcriber = transcriber
self._log_raw_messaging_content = log_raw_messaging_content
self._log_api_error_tracebacks = log_api_error_tracebacks
self._pending_voice = PendingVoiceRegistry()
@property
def is_enabled(self) -> bool:
"""Return whether voice-note handling is enabled."""
return self._transcriber is not None
async def reply_if_disabled(
self, reply_text: Callable[[str], Awaitable[None]]
) -> bool:
"""Reply with the disabled message when voice-note handling is disabled."""
if self.is_enabled:
return False
await reply_text(VOICE_DISABLED_MESSAGE)
return True
async def cancel_pending_voice(
self, scope: MessageScope, reply_id: str
) -> VoiceCancellationResult | None:
"""Cancel a pending voice transcription."""
return await self._pending_voice.cancel(scope, reply_id)
async def cancel_all_pending_voices(
self,
) -> tuple[VoiceCancellationResult, ...]:
"""Cancel every pending voice transcription and published handoff."""
return await self._pending_voice.cancel_all()
async def cancel_pending_voices_in_scope(
self,
scope: MessageScope,
) -> tuple[VoiceCancellationResult, ...]:
"""Cancel pending voice transcriptions belonging to one chat."""
return await self._pending_voice.cancel_scope(scope)
async def handle(
self,
request: VoiceNoteRequest,
*,
message_handler: MessageHandler | None,
queue_send_message: QueueSend,
queue_delete_messages: QueueDeleteMany,
) -> bool:
"""Transcribe a voice note and hand the resulting turn to messaging."""
if await self.reply_if_disabled(request.reply_text):
return True
if message_handler is None:
return False
claim = await self._pending_voice.reserve(
request.scope,
request.message_id,
)
if claim is None:
return True
status_msg_id: str | None = None
status_bound = False
tmp_path: Path | None = None
handed_off = False
try:
delivered_status_id = await queue_send_message(
request.chat_id,
request.status_text,
reply_to=request.message_id,
parse_mode=request.status_parse_mode,
fire_and_forget=False,
message_thread_id=request.message_thread_id,
)
if delivered_status_id is None:
raise RuntimeError("Voice status delivery returned no message ID.")
status_msg_id = str(delivered_status_id)
status_bound = await self._pending_voice.bind_status(claim, status_msg_id)
if not status_bound:
_, cancellation = await self._finish_failed_pending_voice(
request,
claim,
status_msg_id,
queue_delete_messages,
status_bound=False,
handed_off=False,
)
if cancellation is not None:
raise cancellation from None
return True
with tempfile.NamedTemporaryFile(
suffix=request.temp_suffix, delete=False
) as tmp:
tmp_path = Path(tmp.name)
await request.download_to(tmp_path)
_validate_audio_file(tmp_path)
transcriber = self._transcriber
if transcriber is None:
raise RuntimeError("Voice transcription is not configured.")
transcribed = await transcriber.transcribe(tmp_path)
incoming = IncomingMessage(
text=transcribed,
chat_id=request.chat_id,
user_id=request.user_id,
message_id=request.message_id,
platform=request.platform,
reply_to_message_id=request.reply_to_message_id,
message_thread_id=request.message_thread_id,
username=request.username,
raw_event=request.raw_event,
status_message_id=status_msg_id,
)
self._log_transcription(request, transcribed)
async def handle_incoming() -> None:
nonlocal handed_off
handed_off = True
await message_handler(incoming)
handoff_outcome = await self._pending_voice.handoff(
claim,
handle_incoming,
)
if handoff_outcome is VoiceHandoffOutcome.REJECTED:
_, cancellation = await self._finish_failed_pending_voice(
request,
claim,
status_msg_id,
queue_delete_messages,
status_bound=status_bound,
handed_off=False,
)
if cancellation is not None:
raise cancellation from None
return True
return True
except asyncio.CancelledError:
await self._finish_failed_pending_voice(
request,
claim,
status_msg_id,
queue_delete_messages,
status_bound=status_bound,
handed_off=handed_off,
)
raise
except (ValueError, ImportError) as e:
discarded, cancellation = await self._finish_failed_pending_voice(
request,
claim,
status_msg_id,
queue_delete_messages,
status_bound=status_bound,
handed_off=handed_off,
)
if cancellation is not None:
raise cancellation from None
if not handed_off and not discarded:
return True
await request.reply_text(format_user_error_preview(e))
return True
except Exception as e:
discarded, cancellation = await self._finish_failed_pending_voice(
request,
claim,
status_msg_id,
queue_delete_messages,
status_bound=status_bound,
handed_off=handed_off,
)
if cancellation is not None:
raise cancellation from None
if not handed_off and not discarded:
return True
if self._log_api_error_tracebacks:
logger.error("Voice transcription failed: {}", e)
else:
logger.error(
"Voice transcription failed: exc_type={}",
type(e).__name__,
)
await request.reply_text(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
return True
except BaseException:
await self._finish_failed_pending_voice(
request,
claim,
status_msg_id,
queue_delete_messages,
status_bound=status_bound,
handed_off=handed_off,
)
raise
finally:
if tmp_path is not None:
with contextlib.suppress(OSError):
tmp_path.unlink(missing_ok=True)
async def _finish_failed_pending_voice(
self,
request: VoiceNoteRequest,
claim: PendingVoiceClaim,
status_msg_id: str | None,
queue_delete_messages: QueueDeleteMany,
*,
status_bound: bool,
handed_off: bool,
) -> tuple[bool, asyncio.CancelledError | None]:
"""Finish owned cleanup before propagating any caller cancellation."""
cleanup_task = asyncio.create_task(
self._clear_failed_pending_voice(
request,
claim,
status_msg_id,
queue_delete_messages,
status_bound=status_bound,
handed_off=handed_off,
),
name=f"voice-cleanup-{claim.claim_id}",
)
cancellation: asyncio.CancelledError | None = None
current = asyncio.current_task()
while True:
cancelling_before = current.cancelling() if current is not None else 0
try:
return await asyncio.shield(cleanup_task), cancellation
except asyncio.CancelledError as error:
if current is None or current.cancelling() <= cancelling_before:
raise
cancellation = cancellation or error
async def _clear_failed_pending_voice(
self,
request: VoiceNoteRequest,
claim: PendingVoiceClaim,
status_msg_id: str | None,
queue_delete_messages: QueueDeleteMany,
*,
status_bound: bool,
handed_off: bool,
) -> bool:
discarded = await self._pending_voice.discard(claim)
if (
not handed_off
and status_msg_id is not None
and (discarded or not status_bound)
):
with contextlib.suppress(Exception):
await queue_delete_messages(request.chat_id, [status_msg_id])
return discarded
def _log_transcription(self, request: VoiceNoteRequest, transcribed: str) -> None:
label = request.platform.upper()
if self._log_raw_messaging_content:
logger.info(
"{}_VOICE: chat_id={} message_id={} transcribed={!r}",
label,
request.chat_id,
request.message_id,
(transcribed[:80] + "..." if len(transcribed) > 80 else transcribed),
)
else:
logger.info(
"{}_VOICE: chat_id={} message_id={} transcribed_len={}",
label,
request.chat_id,
request.message_id,
len(transcribed),
)
def _validate_audio_file(file_path: Path) -> None:
if not file_path.exists():
raise FileNotFoundError(f"Audio file not found: {file_path}")
size = file_path.stat().st_size
if size > MAX_AUDIO_SIZE_BYTES:
raise ValueError(
f"Audio file too large ({size} bytes). Max {MAX_AUDIO_SIZE_BYTES} bytes."
)