diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index 68cbd465..d010c74d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -118,6 +118,14 @@ public class ChannelManager { */ private final vip.mate.channel.feishu.FeishuClientFactory feishuClientFactory; + /** + * Speech-to-text service used by the Feishu adapter to transcribe + * inbound voice messages. WeCom and DingTalk get ASR text directly + * from their webhooks; Feishu does not, so the adapter has to call + * STT itself before the agent can reason about the message. + */ + private final vip.mate.stt.SttService sttService; + /** * Distributed leader election. Channels whose adapter reports * {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so @@ -1185,7 +1193,7 @@ public class ChannelManager { case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper, feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager, - feishuCardDispatcher, feishuClientFactory, generatedFileCache); + feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index d0f3ab87..9d55ce25 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -155,10 +155,25 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre */ private final vip.mate.tool.document.GeneratedFileCache generatedFileCache; + /** + * STT service for transcribing inbound voice messages. Feishu, unlike + * WeCom / DingTalk, does NOT include ASR text in the webhook payload — + * its inbound audio carries only a {@code file_key}. So we download the + * bytes (via {@link #downloadResource}) and run them through + * {@link vip.mate.stt.SttService} here, prepending the transcript as a + * text {@link MessageContentPart} so the agent reasons over actual + * content instead of the bare {@code "[音频]"} placeholder. + * + *
Nullable for legacy callers / tests — STT is skipped entirely when + * absent, and the message still goes through as audio-only (degraded + * but not broken). + */ + private final vip.mate.stt.SttService sttService; + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { - this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null); + this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -167,7 +182,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre FeishuMediaUploader mediaUploader, GeneratedFileScrubber generatedFileScrubber) { this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, - null, null, null, null); + null, null, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -177,7 +192,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre GeneratedFileScrubber generatedFileScrubber, FeishuStreamingCardManager streamingCardManager) { this(channelEntity, messageRouter, objectMapper, mediaUploader, - generatedFileScrubber, streamingCardManager, null, null, null); + generatedFileScrubber, streamingCardManager, null, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -188,7 +203,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre FeishuStreamingCardManager streamingCardManager, vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) { this(channelEntity, messageRouter, objectMapper, mediaUploader, - generatedFileScrubber, streamingCardManager, cardDispatcher, null, null); + generatedFileScrubber, streamingCardManager, cardDispatcher, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -200,6 +215,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher, FeishuClientFactory clientFactory, vip.mate.tool.document.GeneratedFileCache generatedFileCache) { + this(channelEntity, messageRouter, objectMapper, mediaUploader, + generatedFileScrubber, streamingCardManager, cardDispatcher, + clientFactory, generatedFileCache, null); + } + + public FeishuChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + FeishuMediaUploader mediaUploader, + GeneratedFileScrubber generatedFileScrubber, + FeishuStreamingCardManager streamingCardManager, + vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher, + FeishuClientFactory clientFactory, + vip.mate.tool.document.GeneratedFileCache generatedFileCache, + vip.mate.stt.SttService sttService) { super(channelEntity, messageRouter, objectMapper); this.mediaUploader = mediaUploader; this.generatedFileScrubber = generatedFileScrubber; @@ -207,6 +237,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre this.cardDispatcher = cardDispatcher; this.clientFactory = clientFactory; this.generatedFileCache = generatedFileCache; + this.sttService = sttService; // Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1); } @@ -1331,9 +1362,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre case "audio" -> { String fileKey = (String) contentObj.get("file_key"); if (fileKey != null) { - DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", null); + // Feishu voice messages are always opus (no extension on the + // wire) — give downloadResource the hint so the on-disk file + // ends in .opus and SttService gets a usable MIME for routing. + DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", "voice.opus"); MessageContentPart part = MessageContentPart.audio(fileKey, null); applyDownload(part, dl); + // STT hop: inject the transcript as a sibling text part BEFORE + // the audio part so ChannelMessageRouter.buildPromptFromParts + // sees real content instead of just "[音频]". WeCom / DingTalk + // skip this step because their webhooks already include ASR + // text; Feishu does not. + String transcript = transcribeInboundAudio(dl); + if (transcript != null && !transcript.isBlank()) { + parts.add(MessageContentPart.text(transcript)); + } parts.add(part); } yield "[音频]"; @@ -1569,6 +1612,52 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre * caller can keep the part as a bare {@code file_key} placeholder * when download was disabled / failed. */ + /** + * Read the downloaded audio bytes from disk and run them through + * {@link vip.mate.stt.SttService}. Returns the transcribed text, or + * {@code null} when STT is not wired, disabled, the download didn't + * produce a usable path, or every provider failed. + * + *
Best-effort by design — STT failure must not block the agent
+ * from seeing the audio part. The user gets the "[音频]" placeholder
+ * and any agent that's tooled-up can still investigate.
+ */
+ // Package-private for unit tests.
+ String transcribeInboundAudio(DownloadedResource dl) {
+ if (sttService == null || dl == null || dl.path() == null) {
+ return null;
+ }
+ try {
+ byte[] audioBytes = Files.readAllBytes(Path.of(dl.path()));
+ if (audioBytes.length == 0) {
+ log.debug("[feishu-stt] empty audio file at {}, skipping STT", dl.path());
+ return null;
+ }
+ String fileName = dl.fileName() != null ? dl.fileName() : "voice.opus";
+ String contentType = dl.contentType() != null ? dl.contentType() : "audio/opus";
+ // language=null → SttService falls back to the system-settings
+ // UI language hint; works for both Chinese and English without
+ // needing per-channel config.
+ Map Why this matters: Feishu — unlike WeCom and DingTalk — does NOT
+ * include ASR text in its inbound webhook payload, only an opaque
+ * {@code file_key}. Without the {@code transcribeInboundAudio} hop the
+ * agent sees the literal text {@code "[音频]"} and cannot reason about
+ * what the user said. That was the production gap reported when a user
+ * sent a voice message to the bot.
+ *
+ * The contract this test pins:
+ *
+ *
+ */
+class FeishuAudioSttTest {
+
+ @TempDir
+ Path tmpDir;
+
+ private SttService sttService;
+
+ @BeforeEach
+ void setUp() {
+ sttService = mock(SttService.class);
+ }
+
+ @Test
+ @DisplayName("STT success → transcript returned for prepending as text part")
+ void transcriptReturnedOnSuccess() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ when(sttService.transcribe(any(), eq("voice.opus"), eq("audio/opus"), eq(null)))
+ .thenReturn(Map.of("success", true, "text", "你好,能帮我查一下天气吗"));
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ "/api/v1/files/generated/some-id",
+ "voice.opus",
+ "audio/opus");
+
+ String transcript = adapter.transcribeInboundAudio(dl);
+ assertEquals("你好,能帮我查一下天气吗", transcript);
+ }
+
+ @Test
+ @DisplayName("STT failure → null, agent still sees audio part (no throw)")
+ void nullOnSttFailure() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ when(sttService.transcribe(any(), any(), any(), any()))
+ .thenReturn(Map.of("success", false, "error", "no provider"));
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl),
+ "STT failure must return null, not throw — agent gets [音频] placeholder only");
+ }
+
+ @Test
+ @DisplayName("empty transcript text → null (don't inject blank text parts)")
+ void nullOnEmptyTranscript() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ // Some STT providers return success=true with empty text for silence
+ // or unsupported audio — those shouldn't pollute the prompt with a
+ // blank text part.
+ when(sttService.transcribe(any(), any(), any(), any()))
+ .thenReturn(Map.of("success", true, "text", " "));
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ }
+
+ @Test
+ @DisplayName("SttService missing (legacy ctor) → null, no NPE")
+ void nullWhenSttServiceMissing() throws Exception {
+ Path audioFile = tmpDir.resolve("voice.opus");
+ Files.write(audioFile, "fake-opus-bytes".getBytes());
+
+ FeishuChannelAdapter adapter = adapterWithStt(null);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ audioFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ }
+
+ @Test
+ @DisplayName("null DownloadedResource → null, STT not called (download was disabled / failed)")
+ void nullOnMissingDownload() {
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+
+ assertNull(adapter.transcribeInboundAudio(null));
+ // Verify we never billed the provider for a no-op.
+ verify(sttService, never()).transcribe(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("empty audio file → null, no STT call (don't bill provider for 0 bytes)")
+ void nullOnEmptyAudioFile() throws Exception {
+ Path emptyFile = tmpDir.resolve("empty.opus");
+ Files.write(emptyFile, new byte[0]);
+
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ emptyFile.toAbsolutePath().toString(),
+ null, "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ verify(sttService, never()).transcribe(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("missing file path → null, STT not called (download flag was off)")
+ void nullOnMissingPath() {
+ FeishuChannelAdapter adapter = adapterWithStt(sttService);
+ FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
+ null, "/api/v1/files/generated/x", "voice.opus", "audio/opus");
+
+ assertNull(adapter.transcribeInboundAudio(dl));
+ verify(sttService, never()).transcribe(any(), any(), any(), any());
+ }
+
+ @Test
+ @DisplayName("fileName/contentType from download propagate to SttService for provider routing")
+ void fileNameAndMimePropagate() throws Exception {
+ Path audioFile = tmpDir.resolve("custom.mp3");
+ Files.write(audioFile, "fake".getBytes());
+
+ Map