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 d010c74d..68cbd465 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -118,14 +118,6 @@ 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 @@ -1193,7 +1185,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, sttService); + feishuCardDispatcher, feishuClientFactory, generatedFileCache); 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 9d55ce25..d0f3ab87 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,25 +155,10 @@ 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, null); + this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -182,7 +167,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, @@ -192,7 +177,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre GeneratedFileScrubber generatedFileScrubber, FeishuStreamingCardManager streamingCardManager) { this(channelEntity, messageRouter, objectMapper, mediaUploader, - generatedFileScrubber, streamingCardManager, null, null, null, null); + generatedFileScrubber, streamingCardManager, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -203,7 +188,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, null); + generatedFileScrubber, streamingCardManager, cardDispatcher, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -215,21 +200,6 @@ 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; @@ -237,7 +207,6 @@ 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); } @@ -1362,21 +1331,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre case "audio" -> { String fileKey = (String) contentObj.get("file_key"); if (fileKey != 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"); + DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", null); 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 "[音频]"; @@ -1612,52 +1569,6 @@ 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