fix(feishu): classify native voice messages as VOICE for auto-transcription

Lark's native "audio" msg_type is an in-app voice recording — uploaded
audio files arrive as "file"/"media". But _resolve_normalized_message_type
resolved the "audio" preferred type to MessageType.AUDIO, which the gateway
treats as a non-transcribed file attachment (run.py: AUDIO -> audio_file_paths,
"never STT"; VOICE -> audio_paths, "always STT"). Result: a Feishu voice
note reached the agent as an untranscribable audio attachment and was
silently ignored — the user's spoken message never became text.

Every other platform that receives native voice notes (Telegram, Discord,
Slack, WhatsApp, Signal, Matrix, WeChat, WeCom, DingTalk, QQ, BlueBubbles,
Mattermost, Yuanbao) classifies them as MessageType.VOICE. Feishu was the
only one classifying them as AUDIO. This is the follow-up to #28993, which
added native voice-note transcription for Discord + DingTalk but did not
cover Feishu.

Return MessageType.VOICE for the "audio" branch. The branch is reached only
for Lark's top-level audio msg_type (set in the normalizer; file uploads map
to "document"), so VOICE is unconditionally correct here — no risk of
auto-transcribing an uploaded music/audio file.

- plugins/platforms/feishu/adapter.py: _resolve_normalized_message_type audio
  branch returns VOICE instead of resolving to AUDIO via mime.
- tests/gateway/test_feishu.py: test_extract_audio_message_downloads_and_caches
  asserted the old AUDIO behavior on a fixture literally named voice.ogg —
  updated to expect VOICE (the corrected classification).
- tests/gateway/test_feishu_voice_message_type.py: new focused regression
  tests (audio->VOICE with and without mime; photo/document/text unaffected).

Rebased onto current main: the Feishu platform moved from the single-file
gateway/platforms/feishu.py into the plugins/platforms/feishu/ package; the
fix applies to the same _resolve_normalized_message_type logic at its new home.

Verified the new voice tests fail when the branch resolves to AUDIO and pass
with VOICE, while the photo/document/text cases are unaffected either way.

Note: classification is mock-tested here; the downstream STT pipeline is the
shared, already-proven path (#28993). End-to-end verification on a live
Feishu account would be a welcome confirmation.

Refs #28993 (sibling-gap: Feishu was the platform left uncovered).
This commit is contained in:
wuli666 2026-06-26 18:37:51 +08:00 committed by Teknium
parent 2cf656f23d
commit 1ca1deb7f6
3 changed files with 60 additions and 2 deletions

View file

@ -3874,7 +3874,15 @@ class FeishuAdapter(BasePlatformAdapter):
if preferred == "photo":
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.PHOTO)
if preferred == "audio":
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.AUDIO)
# Lark's native "audio" msg_type is an in-app voice recording, not
# an uploaded audio file (those arrive as "file"/"media" and are
# normalized to "document"). Classify it as VOICE so the gateway
# auto-transcribes it (Opus → STT) the same way
# Discord/DingTalk/Telegram/etc. do — otherwise a Feishu voice note
# reaches the agent as an untranscribable AUDIO attachment and is
# silently ignored. Follow-up to #28993, which added native
# voice-note transcription for Discord + DingTalk.
return MessageType.VOICE
if preferred == "document":
return self._resolve_media_message_type(media_types[0] if media_types else "", default=MessageType.DOCUMENT)
return MessageType.TEXT

View file

@ -1490,7 +1490,11 @@ class TestAdapterBehavior(unittest.TestCase):
text, msg_type, media_urls, media_types, _mentions = asyncio.run(adapter._extract_message_content(message))
self.assertEqual(text, "")
self.assertEqual(msg_type.value, "audio")
# Lark "audio" msg_type is a native voice recording (the fixture is
# literally voice.ogg) — it must classify as VOICE so the gateway
# auto-transcribes it, not AUDIO (a non-transcribed file attachment).
# See the #28993 follow-up fix in _resolve_normalized_message_type.
self.assertEqual(msg_type.value, "voice")
self.assertEqual(media_urls, ["/tmp/feishu-audio.ogg"])
self.assertEqual(media_types, ["audio/ogg"])

View file

@ -0,0 +1,46 @@
"""Regression tests for Feishu native voice-note classification.
Lark's native ``audio`` msg_type is an in-app voice recording (uploaded
audio files arrive as ``file``/``media`` and normalize to ``document``). It
must be classified as MessageType.VOICE so the gateway auto-transcribes it
(Opus STT), the same way Discord/DingTalk/Telegram do. Before the fix it
resolved to MessageType.AUDIO, which the gateway treats as a non-transcribed
file attachment so a Feishu voice note silently reached the agent as
untranscribable audio. Follow-up to #28993 (Discord + DingTalk).
"""
from gateway.platforms.base import MessageType
from plugins.platforms.feishu.adapter import FeishuAdapter, FeishuNormalizedMessage
def _resolve(preferred: str, media_types):
"""Call _resolve_normalized_message_type without a full adapter init.
The method only reads normalized.preferred_message_type and delegates to
the static _resolve_media_message_type no instance state so we bypass
__init__ (which needs Lark credentials/config) via __new__.
"""
adapter = FeishuAdapter.__new__(FeishuAdapter)
normalized = FeishuNormalizedMessage(
raw_type=preferred,
text_content="",
preferred_message_type=preferred,
)
return adapter._resolve_normalized_message_type(normalized, media_types)
def test_native_voice_audio_is_classified_as_voice():
"""Lark audio msg_type (voice recording) → VOICE, so it gets transcribed."""
assert _resolve("audio", ["audio/opus"]) is MessageType.VOICE
def test_native_voice_audio_without_media_type_is_voice():
"""A voice note with no resolved mime still classifies as VOICE."""
assert _resolve("audio", []) is MessageType.VOICE
def test_photo_and_document_unaffected():
"""The fix is scoped to the audio branch — other types are unchanged."""
assert _resolve("photo", ["image/png"]) is MessageType.PHOTO
assert _resolve("document", ["application/pdf"]) is MessageType.DOCUMENT
assert _resolve("text", []) is MessageType.TEXT