-
Notifications
You must be signed in to change notification settings - Fork 52.3k
fix(gateway): support Feishu voice TTS opus delivery #37804
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,8 @@ | |
| import mimetypes | ||
| import os | ||
| import re | ||
| import shutil | ||
| import subprocess | ||
| import threading | ||
| import time | ||
| import uuid | ||
|
|
@@ -4500,12 +4502,18 @@ async def _send_uploaded_file_message( | |
| file_path=display_name, | ||
| requested_message_type=outbound_message_type, | ||
| ) | ||
| upload_duration_ms = ( | ||
| self._probe_audio_duration_ms(file_path) | ||
| if upload_file_type == "opus" | ||
| else None | ||
| ) | ||
| try: | ||
| with open(file_path, "rb") as file_obj: | ||
| body = self._build_file_upload_body( | ||
| file_type=upload_file_type, | ||
| file_name=display_name, | ||
| file=file_obj, | ||
| duration=upload_duration_ms, | ||
| ) | ||
| request = self._build_file_upload_request(body) | ||
| upload_response = await self._run_blocking(self._client.im.v1.file.create, request) | ||
|
|
@@ -4531,10 +4539,13 @@ async def _send_uploaded_file_message( | |
| metadata=metadata, | ||
| ) | ||
| else: | ||
| message_payload = {"file_key": file_key} | ||
| if resolved_message_type == "audio" and upload_duration_ms and upload_duration_ms > 0: | ||
| message_payload["duration"] = upload_duration_ms | ||
| message_response = await self._feishu_send_with_retry( | ||
| chat_id=chat_id, | ||
| msg_type=resolved_message_type, | ||
| payload=json.dumps({"file_key": file_key}, ensure_ascii=False), | ||
| payload=json.dumps(message_payload, ensure_ascii=False), | ||
| reply_to=reply_to, | ||
| metadata=metadata, | ||
| ) | ||
|
|
@@ -4925,16 +4936,24 @@ def _build_image_upload_request(request_body: Any) -> Any: | |
| return SimpleNamespace(request_body=request_body) | ||
|
|
||
| @staticmethod | ||
| def _build_file_upload_body(*, file_type: str, file_name: str, file: Any) -> Any: | ||
| def _build_file_upload_body( | ||
| *, | ||
| file_type: str, | ||
| file_name: str, | ||
| file: Any, | ||
| duration: Optional[int] = None, | ||
| ) -> Any: | ||
| if "CreateFileRequestBody" in globals(): | ||
| return ( | ||
| builder = ( | ||
| CreateFileRequestBody.builder() | ||
| .file_type(file_type) | ||
| .file_name(file_name) | ||
| .file(file) | ||
| .build() | ||
| ) | ||
| return SimpleNamespace(file_type=file_type, file_name=file_name, file=file) | ||
| if duration is not None: | ||
| builder = builder.duration(duration) | ||
| return builder.build() | ||
| return SimpleNamespace(file_type=file_type, file_name=file_name, file=file, duration=duration) | ||
|
|
||
| @staticmethod | ||
| def _build_file_upload_request(request_body: Any) -> Any: | ||
|
|
@@ -4951,6 +4970,34 @@ def _build_media_post_payload(self, *, caption: str, media_tag: Dict[str, str]) | |
| content.append([media_tag]) | ||
| return json.dumps(payload, ensure_ascii=False) | ||
|
|
||
| @staticmethod | ||
| def _probe_audio_duration_ms(file_path: str) -> Optional[int]: | ||
| ffmpeg = shutil.which("ffmpeg") | ||
| if not ffmpeg: | ||
| return None | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please pass |
||
| try: | ||
| result = subprocess.run( | ||
| [ffmpeg, "-i", file_path, "-f", "null", "-"], | ||
| stdout=subprocess.DEVNULL, | ||
| stderr=subprocess.PIPE, | ||
| text=True, | ||
| timeout=10, | ||
| ) | ||
| except (FileNotFoundError, subprocess.TimeoutExpired, OSError): | ||
| return None | ||
|
|
||
| match = re.search( | ||
| r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", | ||
| result.stderr or "", | ||
| ) | ||
| if not match: | ||
| return None | ||
|
|
||
| hours = int(match.group(1)) | ||
| minutes = int(match.group(2)) | ||
| seconds = float(match.group(3)) | ||
| return int(round(((hours * 60 + minutes) * 60 + seconds) * 1000)) | ||
|
|
||
| @staticmethod | ||
| def _resolve_outbound_file_routing( | ||
| *, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -204,7 +204,7 @@ def test_output_format_rejects_unknown(self): | |
| assert _get_command_tts_output_format({"format": "m4a"}) == DEFAULT_COMMAND_TTS_OUTPUT_FORMAT | ||
|
|
||
| def test_output_format_supported_set(self): | ||
| assert COMMAND_TTS_OUTPUT_FORMATS == frozenset({"mp3", "wav", "ogg", "flac"}) | ||
| assert COMMAND_TTS_OUTPUT_FORMATS == frozenset({"mp3", "wav", "ogg", "opus", "flac"}) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This freezes the full mutable format catalog. Test the intended contract instead: assert that |
||
|
|
||
| def test_voice_compatible_boolean(self): | ||
| assert _is_command_tts_voice_compatible({"voice_compatible": True}) is True | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This synchronous probe runs
subprocess.run(..., timeout=10)on the gateway event loop. Please await the adapter's existing_run_blocking(self._probe_audio_duration_ms, file_path)(or an equivalent nonblocking call), and ensure the probe still supplies a positive duration when ffmpeg is unavailable.