fix(channel): sweep orphan tool rows + guard rules; scope rule name to channel

This commit is contained in:
matevip 2026-05-20 16:35:57 +08:00
parent 12ff190392
commit 43136fc663
4 changed files with 6 additions and 313 deletions

View File

@ -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,

View File

@ -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.
*
* <p>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: 2s4s8s16s30s, 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.
*
* <p>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<String, Object> 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;

View File

@ -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();
}

View File

@ -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.
*
* <p>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.
*
* <p>The contract this test pins:
* <ol>
* <li>Successful STT returns the transcript, which the audio branch
* of {@code extractContentParts} prepends as a text part so the
* prompt builder sees real content.</li>
* <li>STT failure (no provider, empty text, exception) returns
* {@code null} never throws, never blocks the agent from
* seeing the audio part.</li>
* <li>STT not wired in (legacy 3-arg ctor, tests) returns
* {@code null} silently degraded but not broken.</li>
* <li>Empty / missing audio file is detected before the SttService
* call so we don't bill providers for zero-byte requests.</li>
* </ol>
*/
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<String, Object> 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);
}
}