feat(feishu): transcribe inbound voice messages via SttService

This commit is contained in:
matevip 2026-05-20 16:35:46 +08:00
parent 6b4456043e
commit 12ff190392
5 changed files with 380 additions and 10 deletions

View File

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

View File

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

@ -21,8 +21,10 @@ import vip.mate.tool.repository.ToolMapper;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@ -184,6 +186,12 @@ public class ChannelToolService {
}
}
// 0. Cross-process orphan sweep catches rows left over from channels
// that were deleted while this node was down (the unregister loop below
// only sees channels this process once registered, so without the sweep
// pre-existing orphans live forever).
sweepOrphans(desired.keySet());
// 1. Unregister channels no longer in the desired set
for (Long goneId : new ArrayList<>(registered.keySet())) {
if (!desired.containsKey(goneId)) {
@ -220,7 +228,7 @@ public class ChannelToolService {
return;
}
Map<String, String> nameMap = upsertToolRows(ch, descriptors);
seedGuardRules(descriptors, nameMap);
seedGuardRules(ch, descriptors, nameMap);
ChannelToolContext context = new ChannelToolContext(
ch.getId(), ch.getName(), ch.getChannelType(), ch.getAgentId(),
@ -321,7 +329,9 @@ public class ChannelToolService {
* safe. Triggers a registry reload so the new rule is immediately
* visible to the next invocation.
*/
private void seedGuardRules(List<ChannelToolDescriptor> descriptors, Map<String, String> nameMap) {
private void seedGuardRules(ChannelEntity ch, List<ChannelToolDescriptor> descriptors, Map<String, String> nameMap) {
String legacyName = "Channel write tool — approval required";
String channelScopedName = legacyName + " (" + ch.getName() + ")";
boolean changed = false;
for (ChannelToolDescriptor d : descriptors) {
if (!d.mutating()) continue;
@ -330,10 +340,20 @@ public class ChannelToolService {
String ruleId = "channel_tool:" + actualName;
ToolGuardRuleEntity existing = guardRuleMapper.selectOne(
new LambdaQueryWrapper<ToolGuardRuleEntity>().eq(ToolGuardRuleEntity::getRuleId, ruleId));
if (existing != null) continue; // already seeded never override user edits
if (existing != null) {
// One-time migration: rename rows that still carry the original
// hardcoded label so the UI can tell channels apart. User-edited
// names (anything other than the legacy literal) are preserved.
if (legacyName.equals(existing.getName())) {
existing.setName(channelScopedName);
guardRuleMapper.updateById(existing);
changed = true;
}
continue;
}
ToolGuardRuleEntity row = new ToolGuardRuleEntity();
row.setRuleId(ruleId);
row.setName("Channel write tool — approval required");
row.setName(channelScopedName);
row.setDescription("Auto-seeded approval gate for channel-native write tool " + actualName);
row.setToolName(actualName);
row.setParamName("args");
@ -364,6 +384,49 @@ public class ChannelToolService {
}
}
/**
* Reverse reconciliation: drop any channel-scoped {@code mate_tool} row
* whose {@code channel_id} is not in the live set, then drop any seeded
* {@code mate_tool_guard_rule} whose target tool no longer exists. Closes
* the gap left by {@link #reconcile()}'s unregister loop, which only sees
* channels this process registered itself orphans from channels deleted
* while the node was down survived previously.
*/
private void sweepOrphans(Set<Long> liveChannelIds) {
List<ToolEntity> channelTools = toolMapper.selectList(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getToolType, "channel"));
List<Long> staleToolIds = channelTools.stream()
.filter(t -> t.getChannelId() == null || !liveChannelIds.contains(t.getChannelId()))
.map(ToolEntity::getId)
.toList();
if (!staleToolIds.isEmpty()) {
toolMapper.delete(new LambdaQueryWrapper<ToolEntity>().in(ToolEntity::getId, staleToolIds));
log.info("[channel-tool] Swept {} orphan mate_tool row(s)", staleToolIds.size());
}
Set<String> liveChannelToolNames = new HashSet<>();
for (ToolEntity t : channelTools) {
if (t.getChannelId() != null && liveChannelIds.contains(t.getChannelId())) {
liveChannelToolNames.add(t.getName());
}
}
List<ToolGuardRuleEntity> seededRules = guardRuleMapper.selectList(
new LambdaQueryWrapper<ToolGuardRuleEntity>().likeRight(ToolGuardRuleEntity::getRuleId, "channel_tool:"));
List<Long> staleRuleIds = seededRules.stream()
.filter(r -> !liveChannelToolNames.contains(r.getToolName()))
.map(ToolGuardRuleEntity::getId)
.toList();
if (!staleRuleIds.isEmpty()) {
guardRuleMapper.delete(new LambdaQueryWrapper<ToolGuardRuleEntity>().in(ToolGuardRuleEntity::getId, staleRuleIds));
try {
guardRuleRegistry.reload();
} catch (Exception e) {
log.debug("[channel-tool] guard rule reload after sweep failed (non-fatal): {}", e.getMessage());
}
log.info("[channel-tool] Swept {} orphan mate_tool_guard_rule row(s)", staleRuleIds.size());
}
}
private void deleteToolRows(Long channelId) {
int deleted = toolMapper.delete(
new LambdaQueryWrapper<ToolEntity>().eq(ToolEntity::getChannelId, channelId));

View File

@ -61,6 +61,7 @@ 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

@ -0,0 +1,209 @@
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);
}
}