From ae4caaaa776a75a3a9b8cff8b85effc2ba3fe64f Mon Sep 17 00:00:00 2001 From: Hu Date: Sat, 27 Jun 2026 00:12:17 +0800 Subject: [PATCH 1/3] fix(feishu): native voice bubble support for TTS audio messages Three changes to enable native Feishu voice bubbles instead of file attachments: 1. tools/tts_tool.py: Add 'feishu' to want_opus guard so TTS generates .ogg (opus) format for Feishu, matching the existing Telegram behavior. Related: #45637 2. plugins/platforms/feishu/adapter.py - upload duration: Include audio duration in CreateFileRequestBody when uploading opus files. Duration is extracted by parsing the OGG container (pure Python, no ffprobe dependency). 3. plugins/platforms/feishu/adapter.py - thread routing fallback: Feishu's create message API rejects msg_type='audio' with receive_id_type='thread_id' (error 99992402). As a workaround, when audio send fails in a thread: a) Try the reply API by fetching the last message in the thread b) Fall back to chat_id routing (main chat) if reply also fails Fixes #18831 Fixes #16524 --- plugins/platforms/feishu/adapter.py | 101 ++++++++++++++++++++++++++-- tools/tts_tool.py | 2 +- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index c3ce2c29431f..5ee682fb1536 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -4386,6 +4386,41 @@ def _build_outbound_payload(self, content: str) -> tuple[str, str]: text_payload = {"text": content} return "text", json.dumps(text_payload, ensure_ascii=False) + @staticmethod + def _get_audio_duration_ms(file_path: str) -> int: + """Extract OGG/Opus audio duration in milliseconds (pure Python, no deps). + + Parses the OGG container to find the last granule position and divides + by the Opus sample rate (48000 Hz). Returns 0 for non-OGG files or on error. + """ + import struct + try: + with open(file_path, "rb") as f: + data = f.read() + pos = 0 + last_granule = 0 + while pos < len(data) - 27: + idx = data.find(b"OggS", pos) + if idx == -1: + break + pos = idx + if pos + 27 > len(data): + break + granule = struct.unpack_from(" 0: + last_granule = granule + segment_end = pos + 27 + num_segments + if segment_end > len(data): + break + page_size = num_segments + for i in range(num_segments): + page_size += data[pos + 27 + i] + pos += page_size + return int(last_granule / 48000 * 1000) if last_granule > 0 else 0 + except Exception: + return 0 + async def _send_uploaded_file_message( self, *, @@ -4408,11 +4443,15 @@ async def _send_uploaded_file_message( requested_message_type=outbound_message_type, ) try: + duration_ms = 0 + if upload_file_type == "opus": + duration_ms = self._get_audio_duration_ms(file_path) 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=duration_ms, ) request = self._build_file_upload_request(body) upload_response = await asyncio.to_thread(self._client.im.v1.file.create, request) @@ -4445,11 +4484,63 @@ async def _send_uploaded_file_message( reply_to=reply_to, metadata=metadata, ) + # Audio messages may fail with 99992402 when using thread_id routing. + # Try replying to the last message in the thread, then fall back to chat_id. + if (not self._response_succeeded(message_response) + and getattr(message_response, "code", None) == 99992402 + and resolved_message_type == "audio" + and (metadata or {}).get("thread_id")): + # Try reply API with thread_id as reply anchor + thread_msg_id = (metadata or {}).get("reply_to_message_id") + if not thread_msg_id: + thread_msg_id = await self._fetch_last_message_in_thread( + (metadata or {}).get("thread_id") + ) + if thread_msg_id: + logger.info("[Feishu] Audio: retrying via reply API in thread") + 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), + reply_to=thread_msg_id, + metadata=metadata, + ) + if not self._response_succeeded(message_response): + logger.warning("[Feishu] Audio send failed in thread, retrying with chat_id") + 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), + reply_to=None, + metadata=None, + ) return self._finalize_send_result(message_response, "file send failed") except Exception as exc: logger.error("[Feishu] Failed to send file %s: %s", file_path, exc, exc_info=True) return SendResult(success=False, error=str(exc)) + async def _fetch_last_message_in_thread(self, thread_id: str) -> Optional[str]: + """Fetch the last message_id in a thread for reply-based routing.""" + if not self._client or not thread_id: + return None + try: + from lark_oapi.api.im.v1 import ListMessageRequest + request = ( + ListMessageRequest.builder() + .container_id_type("thread") + .container_id(thread_id) + .page_size(1) + .build() + ) + response = await asyncio.to_thread(self._client.im.v1.message.list, request) + if response and getattr(response, "success", lambda: False)(): + items = getattr(getattr(response, "data", None), "items", None) + if items and len(items) > 0: + return getattr(items[0], "message_id", None) + except Exception as exc: + logger.debug("[Feishu] Failed to fetch last message in thread %s: %s", thread_id, exc) + return None + async def _send_raw_message( self, *, @@ -4832,16 +4923,18 @@ 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: int = 0) -> 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 > 0: + 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: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index d803086983e0..657ce5b12a8d 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -2177,7 +2177,7 @@ def text_to_speech_tool( # and needs ffmpeg for conversion. from gateway.session_context import get_session_env platform = get_session_env("HERMES_SESSION_PLATFORM", "").lower() - want_opus = (platform == "telegram") + want_opus = platform in {"telegram", "feishu"} # Determine output path if output_path: From 525744a7377c76744c267ab638877addedd137a6 Mon Sep 17 00:00:00 2001 From: Hu Date: Sat, 27 Jun 2026 09:22:25 +0800 Subject: [PATCH 2/3] fix: add LLQWQ foxmail email to AUTHOR_MAP for attribution check --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 425f0958b063..7de75e8031a6 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1112,6 +1112,7 @@ "bernylinville@devopsthink.org": "bernylinville", "brian@bde.io": "briandevans", "hubin_ll@qq.com": "LLQWQ", + "hubin-ll@foxmail.com": "LLQWQ", "memosr_email@gmail.com": "memosr", "jperlow@gmail.com": "perlowja", "jasonpette1783@gmail.com": "web-dev0521", From 7606a45bb634066cc5845b91f5bfd6e9134a7c1f Mon Sep 17 00:00:00 2001 From: LLQWQ <48668845+LLQWQ@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:28:19 +0800 Subject: [PATCH 3/3] ci: retrigger checks