free-claude-code/messaging/platforms/voice_flow.py
Ali Khokhar 85b601884d
Some checks are pending
CI / Ban suppressions and legacy annotations (push) Waiting to run
CI / pytest (push) Waiting to run
CI / ruff-check (push) Waiting to run
CI / ruff-format (push) Waiting to run
CI / ty (push) Waiting to run
Remove legacy future annotation imports (#982)
## Problem

Python 3.14 provides native lazy annotations, but the codebase still
relied on legacy future annotation imports. Those imports also made
type-only import cycles easier to hide instead of fixing ownership
boundaries.

## Changes

| Before | After |
| --- | --- |
| Python files used `from __future__ import annotations`. | Python files
rely on Python 3.14 native lazy annotations. |
| Some runtime modules used `TYPE_CHECKING` or local imports for
required dependencies. | Runtime modules use top-level owner-module
imports with explicit boundaries. |
| Local and GitHub guardrails only rejected type ignore suppressions. |
Local and GitHub guardrails reject type ignore suppressions and legacy
future annotation imports. |
| Agent docs only documented the no-type-ignore rule. | Agent docs
document the Python 3.14 annotation and import-boundary rules. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR moves the codebase to Python 3.14 native lazy annotations. The
main changes are:

- Removed legacy `from __future__ import annotations` imports across
Python modules.
- Promoted selected runtime dependencies from `TYPE_CHECKING` or local
imports to explicit owner-module imports.
- Added local, GitHub, and contract-test guardrails to reject legacy
future annotation imports.
- Updated agent docs with the annotation and import-boundary rules.
- Bumped the package patch version for production-file changes.
</details>

<h3>Confidence Score: 5/5</h3>

Safe to merge with low risk.

The changes are mostly mechanical annotation cleanup with matching CI
and contract-test guardrails. Reviewed import-boundary updates did not
show a confirmed runtime cycle or dependency break.

No files require special attention.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Performed an end-to-end validation of the guardrail contract suite: an
environment check confirmed uv availability, a guardrail pytest run used
CPython 3.14.0 with 5 passing contract tests, 3 focused CI-script tests
passed, and the direct CI suppressions guardrail command (including the
legacy future-annotations grep) also passed.

<a
href="https://app.greptile.com/trex/runs/13303335/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| api/runtime.py | Moves messaging, CLI manager, session, limiter, and
tree dependencies from local/type-checking imports to explicit top-level
owner-module imports. |
| messaging/platforms/telegram.py | Removes future annotations and
promotes Telegram SDK type imports into the existing availability guard.
|
| messaging/platforms/telegram_inbound.py | Removes future annotations
and imports Telegram SDK types at module scope for inbound
normalization. |
| tests/contracts/test_import_boundaries.py | Adds an AST contract that
rejects legacy future annotation imports across Python files. |
| scripts/ci.sh | Extends the local suppression check to reject legacy
future annotation imports alongside type-ignore suppressions. |
| scripts/ci.ps1 | Mirrors the local PowerShell CI suppression check for
legacy future annotations. |
| .github/workflows/tests.yml | Renames and broadens the GitHub
guardrail job to reject both type suppressions and legacy future
annotations. |
| pyproject.toml | Bumps the patch version for production-file changes.
|

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Dev as Developer/CI
participant Guard as Suppression guard
participant AST as Import-boundary contract test
participant Py as Python modules

Dev->>Guard: Run local/GitHub suppression check
Guard->>Py: "Scan *.py for type ignores and future annotations"
Guard-->>Dev: Fail if legacy annotation import remains
Dev->>AST: Run pytest contract tests
AST->>Py: Parse imports with ast
AST-->>Dev: Assert no future annotations/import-boundary violations
Py-->>Dev: Use Python 3.14 native lazy annotations
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Dev as Developer/CI
participant Guard as Suppression guard
participant AST as Import-boundary contract test
participant Py as Python modules

Dev->>Guard: Run local/GitHub suppression check
Guard->>Py: "Scan *.py for type ignores and future annotations"
Guard-->>Dev: Fail if legacy annotation import remains
Dev->>AST: Run pytest contract tests
AST->>Py: Parse imports with ast
AST-->>Dev: Assert no future annotations/import-boundary violations
Py-->>Dev: Use Python 3.14 native lazy annotations
```

</a>
</details>

<sub>Reviews (2): Last reviewed commit: ["Remove legacy future
annotations
import"](6e6cda69da)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=41875785)</sub>

<!-- /greptile_comment -->
2026-07-04 21:41:51 -07:00

293 lines
9.8 KiB
Python

"""Shared voice-note flow for messaging platform adapters."""
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 core.anthropic import format_user_error_preview
from ..models import IncomingMessage
from ..voice import PendingVoiceRegistry, VoiceTranscriptionService
AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a")
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]]
QueueDelete = 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
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,
*,
voice_note_enabled: bool,
whisper_model: str,
whisper_device: str,
hf_token: str,
nvidia_nim_api_key: str,
log_raw_messaging_content: bool,
log_api_error_tracebacks: bool,
) -> None:
self._voice_note_enabled = voice_note_enabled
self._whisper_model = whisper_model
self._whisper_device = whisper_device
self._log_raw_messaging_content = log_raw_messaging_content
self._log_api_error_tracebacks = log_api_error_tracebacks
self._pending_voice = PendingVoiceRegistry()
self._voice_transcription = VoiceTranscriptionService(
hf_token=hf_token,
nvidia_nim_api_key=nvidia_nim_api_key,
)
@property
def is_enabled(self) -> bool:
"""Return whether voice-note handling is enabled."""
return self._voice_note_enabled
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._voice_note_enabled:
return False
await reply_text(VOICE_DISABLED_MESSAGE)
return True
async def register_pending_voice(
self, chat_id: str, voice_msg_id: str, status_msg_id: str
) -> None:
"""Register a voice note as pending transcription."""
await self._pending_voice.register(chat_id, voice_msg_id, status_msg_id)
async def cancel_pending_voice(
self, chat_id: str, reply_id: str
) -> tuple[str, str] | None:
"""Cancel a pending voice transcription."""
return await self._pending_voice.cancel(chat_id, reply_id)
async def is_voice_still_pending(self, chat_id: str, voice_msg_id: str) -> bool:
"""Return whether a voice note is still pending."""
return await self._pending_voice.is_pending(chat_id, voice_msg_id)
async def complete_pending_voice(
self, chat_id: str, voice_msg_id: str, status_msg_id: str
) -> None:
"""Mark a voice note as no longer pending."""
await self._pending_voice.complete(chat_id, voice_msg_id, status_msg_id)
async def handle(
self,
request: VoiceNoteRequest,
*,
message_handler: MessageHandler | None,
queue_send_message: QueueSend,
queue_delete_message: QueueDelete,
) -> 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
status_msg_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,
)
status_msg_id_text = str(status_msg_id)
await self.register_pending_voice(
request.chat_id,
request.message_id,
status_msg_id_text,
)
handed_off = False
with tempfile.NamedTemporaryFile(
suffix=request.temp_suffix, delete=False
) as tmp:
tmp_path = Path(tmp.name)
try:
await request.download_to(tmp_path)
transcribed = await self._voice_transcription.transcribe(
tmp_path,
request.content_type,
whisper_model=self._whisper_model,
whisper_device=self._whisper_device,
)
if not await self.is_voice_still_pending(
request.chat_id,
request.message_id,
):
await queue_delete_message(request.chat_id, status_msg_id_text)
return True
await self.complete_pending_voice(
request.chat_id,
request.message_id,
status_msg_id_text,
)
handed_off = True
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)
await message_handler(incoming)
return True
except ValueError as e:
await self._clear_failed_pending_voice(
request,
status_msg_id_text,
queue_delete_message,
handed_off=handed_off,
)
await request.reply_text(format_user_error_preview(e))
return True
except ImportError as e:
await self._clear_failed_pending_voice(
request,
status_msg_id_text,
queue_delete_message,
handed_off=handed_off,
)
await request.reply_text(format_user_error_preview(e))
return True
except Exception as e:
await self._clear_failed_pending_voice(
request,
status_msg_id_text,
queue_delete_message,
handed_off=handed_off,
)
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
finally:
with contextlib.suppress(OSError):
tmp_path.unlink(missing_ok=True)
async def _clear_failed_pending_voice(
self,
request: VoiceNoteRequest,
status_msg_id: str,
queue_delete_message: QueueDelete,
*,
handed_off: bool,
) -> None:
await self.complete_pending_voice(
request.chat_id,
request.message_id,
status_msg_id,
)
if not handed_off:
with contextlib.suppress(Exception):
await queue_delete_message(request.chat_id, status_msg_id)
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),
)