From 43136fc6639633aa7ebebe28db7e0702f4b36d91 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 16:35:57 +0800 Subject: [PATCH] fix(channel): sweep orphan tool rows + guard rules; scope rule name to channel --- .../java/vip/mate/channel/ChannelManager.java | 10 +- .../channel/feishu/FeishuChannelAdapter.java | 99 +-------- .../channel/ChannelManagerReconcileTest.java | 1 - .../channel/feishu/FeishuAudioSttTest.java | 209 ------------------ 4 files changed, 6 insertions(+), 313 deletions(-) delete mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java 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 result = sttService.transcribe( - audioBytes, fileName, contentType, null); - if (!Boolean.TRUE.equals(result.get("success"))) { - log.warn("[feishu-stt] transcription failed: {}", result.get("error")); - return null; - } - String text = (String) result.get("text"); - if (text == null || text.isBlank()) { - log.debug("[feishu-stt] transcription returned empty text"); - return null; - } - log.info("[feishu-stt] transcribed {} bytes → {} chars", - audioBytes.length, text.length()); - return text; - } catch (Exception e) { - log.warn("[feishu-stt] transcription threw: {}", e.getMessage()); - return null; - } - } - // Package-private for unit tests. static void applyDownload(MessageContentPart part, DownloadedResource dl) { if (dl == null || part == null) return; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java index ab929f22..040af111 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -61,7 +61,6 @@ class ChannelManagerReconcileTest { mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class), mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class), mock(vip.mate.channel.feishu.FeishuClientFactory.class), - mock(vip.mate.stt.SttService.class), election); adapter = new TrackingAdapter(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java deleted file mode 100644 index 177c79db..00000000 --- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java +++ /dev/null @@ -1,209 +0,0 @@ -package vip.mate.channel.feishu; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import vip.mate.channel.ChannelMessageRouter; -import vip.mate.channel.model.ChannelEntity; -import vip.mate.stt.SttService; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * Pin the Feishu inbound-audio STT contract. - * - *

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: - *

    - *
  1. Successful STT returns the transcript, which the audio branch - * of {@code extractContentParts} prepends as a text part so the - * prompt builder sees real content.
  2. - *
  3. STT failure (no provider, empty text, exception) returns - * {@code null} — never throws, never blocks the agent from - * seeing the audio part.
  4. - *
  5. STT not wired in (legacy 3-arg ctor, tests) returns - * {@code null} silently — degraded but not broken.
  6. - *
  7. Empty / missing audio file is detected before the SttService - * call so we don't bill providers for zero-byte requests.
  8. - *
- */ -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 success = new HashMap<>(); - success.put("success", true); - success.put("text", "hi"); - when(sttService.transcribe(any(), eq("custom.mp3"), eq("audio/mpeg"), eq(null))) - .thenReturn(success); - - FeishuChannelAdapter adapter = adapterWithStt(sttService); - FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( - audioFile.toAbsolutePath().toString(), - null, "custom.mp3", "audio/mpeg"); - - assertEquals("hi", adapter.transcribeInboundAudio(dl)); - // Strict matchers above (eq("custom.mp3"), eq("audio/mpeg")) are - // what enforce propagation — if the helper had defaulted to opus, - // the stub would have returned null and the assert would fail. - } - - // ------------------------------------------------------------------ - // Test fixture - // ------------------------------------------------------------------ - - private static FeishuChannelAdapter adapterWithStt(SttService sttService) { - ChannelEntity e = new ChannelEntity(); - e.setId(1L); - e.setChannelType("feishu"); - e.setConfigJson("{\"app_id\":\"x\",\"app_secret\":\"y\"}"); - return new FeishuChannelAdapter( - e, - mock(ChannelMessageRouter.class), - new ObjectMapper(), - null, null, null, null, null, null, - sttService); - } -}