From 12ff190392c9d3b4e3dc7bd252215eaf87234159 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 16:35:46 +0800 Subject: [PATCH] feat(feishu): transcribe inbound voice messages via SttService --- .../java/vip/mate/channel/ChannelManager.java | 10 +- .../channel/feishu/FeishuChannelAdapter.java | 99 ++++++++- .../mate/channel/tool/ChannelToolService.java | 71 +++++- .../channel/ChannelManagerReconcileTest.java | 1 + .../channel/feishu/FeishuAudioSttTest.java | 209 ++++++++++++++++++ 5 files changed, 380 insertions(+), 10 deletions(-) create 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 68cbd465..d010c74d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -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, 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 d0f3ab87..9d55ce25 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,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. + * + *

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. + * + *

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/main/java/vip/mate/channel/tool/ChannelToolService.java b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java index f103e50d..bd64c14e 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java @@ -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 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 descriptors, Map nameMap) { + private void seedGuardRules(ChannelEntity ch, List descriptors, Map 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().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 liveChannelIds) { + List channelTools = toolMapper.selectList( + new LambdaQueryWrapper().eq(ToolEntity::getToolType, "channel")); + List staleToolIds = channelTools.stream() + .filter(t -> t.getChannelId() == null || !liveChannelIds.contains(t.getChannelId())) + .map(ToolEntity::getId) + .toList(); + if (!staleToolIds.isEmpty()) { + toolMapper.delete(new LambdaQueryWrapper().in(ToolEntity::getId, staleToolIds)); + log.info("[channel-tool] Swept {} orphan mate_tool row(s)", staleToolIds.size()); + } + + Set liveChannelToolNames = new HashSet<>(); + for (ToolEntity t : channelTools) { + if (t.getChannelId() != null && liveChannelIds.contains(t.getChannelId())) { + liveChannelToolNames.add(t.getName()); + } + } + List seededRules = guardRuleMapper.selectList( + new LambdaQueryWrapper().likeRight(ToolGuardRuleEntity::getRuleId, "channel_tool:")); + List staleRuleIds = seededRules.stream() + .filter(r -> !liveChannelToolNames.contains(r.getToolName())) + .map(ToolGuardRuleEntity::getId) + .toList(); + if (!staleRuleIds.isEmpty()) { + guardRuleMapper.delete(new LambdaQueryWrapper().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().eq(ToolEntity::getChannelId, channelId)); 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 040af111..ab929f22 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -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(); } 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 new file mode 100644 index 00000000..177c79db --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuAudioSttTest.java @@ -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. + * + *

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); + } +}