mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(channel): sweep orphan tool rows + guard rules; scope rule name to channel
This commit is contained in:
parent
12ff190392
commit
43136fc663
@ -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,
|
||||
|
||||
@ -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: 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.
|
||||
*
|
||||
* <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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user