mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(feishu): transcribe inbound voice messages via SttService
This commit is contained in:
parent
6b4456043e
commit
12ff190392
@ -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,
|
||||
|
||||
@ -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: 2s→4s→8s→16s→30s, 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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user