mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
fix(feishu): outbound generated-file URLs become native attachments
This commit is contained in:
parent
6b397a10ed
commit
6b4456043e
@ -1857,9 +1857,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
if (finalContent.isBlank()) {
|
if (finalContent.isBlank()) {
|
||||||
finalContent = "(无回复内容)";
|
finalContent = "(无回复内容)";
|
||||||
}
|
}
|
||||||
streamingCardManager.finishCard(sessionKey, finalContent);
|
// Strip any /api/v1/files/generated/{id} URLs out of the card
|
||||||
|
// text (replacing each with a "📎 filename" marker) AND send
|
||||||
|
// each cache-resolved file as a native Feishu attachment. The
|
||||||
|
// streaming card can only render markdown — without this hop
|
||||||
|
// the user sees a broken-looking download link instead of the
|
||||||
|
// actual file. Cache-miss URLs fall back to the user-facing
|
||||||
|
// retry hint that GeneratedFileScrubber emits.
|
||||||
|
String renderedContent = scrubAndSendAttachments(receiveId, finalContent);
|
||||||
|
streamingCardManager.finishCard(sessionKey, renderedContent);
|
||||||
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
|
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
|
||||||
sessionKey, finalContent.length());
|
sessionKey, renderedContent.length());
|
||||||
return finalContent;
|
return finalContent;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -1902,12 +1910,54 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
? message.getReplyToken()
|
? message.getReplyToken()
|
||||||
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
|
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
|
||||||
if (replyTarget != null) {
|
if (replyTarget != null) {
|
||||||
sendMessage(replyTarget, finalContent);
|
// Same scrub-and-upload hop as the streaming card finish path —
|
||||||
|
// a generated-file URL in plain text would otherwise reach the
|
||||||
|
// user as a markdown link that opens to nothing useful in IM.
|
||||||
|
String renderedContent = scrubAndSendAttachments(replyTarget, finalContent);
|
||||||
|
sendMessage(replyTarget, renderedContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return finalContent;
|
return finalContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run {@link GeneratedFileScrubber} over outbound text. For every
|
||||||
|
* {@code /api/v1/files/generated/{id}} URL the cache resolves, send
|
||||||
|
* the bytes as a native Feishu attachment to {@code targetId} via
|
||||||
|
* the existing upload path. Returns the rewritten text (URLs replaced
|
||||||
|
* with {@code "📎 filename"} markers for hits, retry hints for
|
||||||
|
* cache-misses) so the caller can use it for the text bubble / card.
|
||||||
|
*
|
||||||
|
* <p>No-op when scrubber / uploader / channel entity is missing —
|
||||||
|
* returns input unchanged so legacy callers (3-arg ctor, tests)
|
||||||
|
* continue to behave like the pre-scrubber adapter. The cost of the
|
||||||
|
* regex scan on a normal reply with no generated URLs is one
|
||||||
|
* Matcher.find() that returns false, negligible.
|
||||||
|
*/
|
||||||
|
// Package-private so tests can call directly without driving the full
|
||||||
|
// streaming pipeline. Production callers are processStream and
|
||||||
|
// processStreamAsText, both internal.
|
||||||
|
String scrubAndSendAttachments(String targetId, String content) {
|
||||||
|
if (content == null || content.isEmpty()
|
||||||
|
|| generatedFileScrubber == null
|
||||||
|
|| mediaUploader == null
|
||||||
|
|| channelEntity == null
|
||||||
|
|| channelEntity.getId() == null) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
GeneratedFileScrubber.ScrubResult scrubbed = generatedFileScrubber.scrub(content);
|
||||||
|
Long channelId = channelEntity.getId();
|
||||||
|
for (GeneratedFileScrubber.AttachmentHit hit : scrubbed.attachments()) {
|
||||||
|
uploadAndSendAttachment(targetId, channelId,
|
||||||
|
new MediaSource.Bytes(hit.bytes()),
|
||||||
|
hit.fileName(),
|
||||||
|
hit.mediaType(),
|
||||||
|
hit.mimeType(),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
return scrubbed.rewrittenText();
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve the best id to receive a streaming card — prefer reply token, then chat, then sender. */
|
/** Resolve the best id to receive a streaming card — prefer reply token, then chat, then sender. */
|
||||||
private static String pickReceiveId(ChannelMessage message) {
|
private static String pickReceiveId(ChannelMessage message) {
|
||||||
if (message == null) return null;
|
if (message == null) return null;
|
||||||
@ -2255,10 +2305,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
* downgrade note as a follow-up text bubble so users understand
|
* downgrade note as a follow-up text bubble so users understand
|
||||||
* why a bubble isn't native.
|
* why a bubble isn't native.
|
||||||
*/
|
*/
|
||||||
private void uploadAndSendAttachment(String targetId, Long channelId,
|
// Package-private + non-final so tests can override and capture upload
|
||||||
MediaSource source, String fileName,
|
// attempts without standing up a real Feishu HTTP client.
|
||||||
String mediaType, String contentType,
|
void uploadAndSendAttachment(String targetId, Long channelId,
|
||||||
Integer durationMillis) {
|
MediaSource source, String fileName,
|
||||||
|
String mediaType, String contentType,
|
||||||
|
Integer durationMillis) {
|
||||||
try {
|
try {
|
||||||
MediaUploadResult result = mediaUploader.upload(new MediaUploadRequest(
|
MediaUploadResult result = mediaUploader.upload(new MediaUploadRequest(
|
||||||
channelId, source, fileName, mediaType, contentType, durationMillis));
|
channelId, source, fileName, mediaType, contentType, durationMillis));
|
||||||
|
|||||||
@ -0,0 +1,207 @@
|
|||||||
|
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 vip.mate.channel.ChannelMessageRouter;
|
||||||
|
import vip.mate.channel.media.GeneratedFileScrubber;
|
||||||
|
import vip.mate.channel.media.MediaSource;
|
||||||
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
import vip.mate.tool.document.GeneratedFileCache;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the scrub-on-finish contract for outbound Feishu replies.
|
||||||
|
*
|
||||||
|
* <p>The agent often replies with a markdown link like
|
||||||
|
* <code>[report.pdf](/api/v1/files/generated/abc-123)</code> after
|
||||||
|
* generating a file. The IM client can't open the
|
||||||
|
* tenant-token-protected backend endpoint that link points at — so
|
||||||
|
* without intervention the user gets a dead link instead of the file.
|
||||||
|
*
|
||||||
|
* <p>The fix is the {@code scrubAndSendAttachments} hop, called by
|
||||||
|
* both {@code processStream} (CardKit streaming path) and
|
||||||
|
* {@code processStreamAsText} (fallback). This test pins that hop:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>The URL is replaced with a {@code "📎 filename"} marker so
|
||||||
|
* the card text reads cleanly.</li>
|
||||||
|
* <li>An upload is enqueued for the cache-resolved bytes, with the
|
||||||
|
* right mediaType / fileName / mimeType from the cache entry.</li>
|
||||||
|
* <li>Cache misses degrade to a user-facing retry hint instead of
|
||||||
|
* leaving the dead link in the bubble.</li>
|
||||||
|
* <li>Replies with no generated-file URL pass through unchanged and
|
||||||
|
* trigger zero uploads.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
class FeishuScrubOnFinishTest {
|
||||||
|
|
||||||
|
private GeneratedFileCache cache;
|
||||||
|
private GeneratedFileScrubber scrubber;
|
||||||
|
private RecordingAdapter adapter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
cache = new GeneratedFileCache();
|
||||||
|
scrubber = new GeneratedFileScrubber(cache);
|
||||||
|
adapter = new RecordingAdapter(scrubber);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("agent reply with cached PDF URL → text rewritten + upload enqueued")
|
||||||
|
void cacheHitTriggersUploadAndRewrite() {
|
||||||
|
byte[] bytes = "%PDF-1.4 fake pdf body".getBytes();
|
||||||
|
String id = cache.put(bytes, "report.pdf", "application/pdf");
|
||||||
|
String agentReply = "✅ PDF 已生成\n[report.pdf](/api/v1/files/generated/" + id + ")\n"
|
||||||
|
+ "点击上方链接即可下载,链接 10 分钟内有效";
|
||||||
|
|
||||||
|
String rendered = adapter.scrubAndSendAttachments("ou_user_123", agentReply);
|
||||||
|
|
||||||
|
// Text bubble shows the marker, not the dead link.
|
||||||
|
assertTrue(rendered.contains("📎 report.pdf"),
|
||||||
|
"rewrittenText should embed the filename marker: " + rendered);
|
||||||
|
assertFalse(rendered.contains("/api/v1/files/generated/" + id),
|
||||||
|
"rewrittenText should NOT still carry the generated URL: " + rendered);
|
||||||
|
// The "(/api/...)" part of the markdown link disappears with the
|
||||||
|
// URL, leaving the bracketed display name + the marker. Both are
|
||||||
|
// user-friendly text — no live link rot.
|
||||||
|
|
||||||
|
// Exactly one upload enqueued, with the right metadata.
|
||||||
|
assertEquals(1, adapter.uploads.size(), "expected exactly one attachment send");
|
||||||
|
RecordedUpload upload = adapter.uploads.get(0);
|
||||||
|
assertEquals("ou_user_123", upload.targetId);
|
||||||
|
assertEquals("report.pdf", upload.fileName);
|
||||||
|
assertEquals("file", upload.mediaType, "PDF should classify as 'file', not 'image'");
|
||||||
|
assertEquals("application/pdf", upload.contentType);
|
||||||
|
assertTrue(upload.source instanceof MediaSource.Bytes,
|
||||||
|
"upload should be a Bytes source so the uploader skips a remote fetch");
|
||||||
|
assertEquals(bytes.length,
|
||||||
|
((MediaSource.Bytes) upload.source).data().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PNG cache hit classifies as 'image' so vision-aware bubble renders inline")
|
||||||
|
void imageMimeTriggersImageUpload() {
|
||||||
|
byte[] bytes = new byte[]{(byte) 0x89, 'P', 'N', 'G'};
|
||||||
|
String id = cache.put(bytes, "chart.png", "image/png");
|
||||||
|
String agentReply = "这是图表: /api/v1/files/generated/" + id;
|
||||||
|
|
||||||
|
String rendered = adapter.scrubAndSendAttachments("oc_chat_x", agentReply);
|
||||||
|
|
||||||
|
assertTrue(rendered.contains("📎 chart.png"));
|
||||||
|
assertEquals(1, adapter.uploads.size());
|
||||||
|
assertEquals("image", adapter.uploads.get(0).mediaType,
|
||||||
|
"image/png MIME must route to the image endpoint, not file");
|
||||||
|
assertEquals("image/png", adapter.uploads.get(0).contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cache miss → retry hint in bubble, no upload attempted")
|
||||||
|
void cacheMissDoesNotUpload() {
|
||||||
|
String agentReply = "你的文件: [doc.pdf](/api/v1/files/generated/never-existed-uuid)";
|
||||||
|
|
||||||
|
String rendered = adapter.scrubAndSendAttachments("ou_user_x", agentReply);
|
||||||
|
|
||||||
|
assertFalse(rendered.contains("/api/v1/files/generated/never-existed-uuid"),
|
||||||
|
"dead URL should be replaced with the missing-reference notice");
|
||||||
|
assertTrue(rendered.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE),
|
||||||
|
"user-visible retry hint expected: " + rendered);
|
||||||
|
assertTrue(adapter.uploads.isEmpty(),
|
||||||
|
"cache miss must NOT enqueue an upload — bytes don't exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("plain reply with no generated URL is forwarded unchanged, zero uploads")
|
||||||
|
void plainTextIsForwarded() {
|
||||||
|
String agentReply = "Sure, here's a quick summary: ...";
|
||||||
|
|
||||||
|
String rendered = adapter.scrubAndSendAttachments("ou_user_x", agentReply);
|
||||||
|
|
||||||
|
assertEquals(agentReply, rendered, "no scrubbable URL → exact pass-through");
|
||||||
|
assertTrue(adapter.uploads.isEmpty(),
|
||||||
|
"non-generated reply must not trigger any upload work");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multiple generated URLs in one reply enqueue one upload per cache hit")
|
||||||
|
void multipleUrlsEnqueueMultipleUploads() {
|
||||||
|
String aId = cache.put("AAAA".getBytes(), "a.pdf", "application/pdf");
|
||||||
|
String bId = cache.put("BBBB".getBytes(), "b.png", "image/png");
|
||||||
|
String agentReply = "两个产物:\n- /api/v1/files/generated/" + aId
|
||||||
|
+ "\n- /api/v1/files/generated/" + bId;
|
||||||
|
|
||||||
|
adapter.scrubAndSendAttachments("ou_user_x", agentReply);
|
||||||
|
|
||||||
|
assertEquals(2, adapter.uploads.size(),
|
||||||
|
"one upload per generated URL — got: "
|
||||||
|
+ adapter.uploads.stream().map(u -> u.fileName).toList());
|
||||||
|
assertEquals("a.pdf", adapter.uploads.get(0).fileName);
|
||||||
|
assertEquals("file", adapter.uploads.get(0).mediaType);
|
||||||
|
assertEquals("b.png", adapter.uploads.get(1).fileName);
|
||||||
|
assertEquals("image", adapter.uploads.get(1).mediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("scrubber missing → no-op pass-through, no NPE (legacy 3-arg ctor case)")
|
||||||
|
void scrubberAbsentIsNoop() {
|
||||||
|
// Build adapter without the scrubber — simulates the 3-arg ctor
|
||||||
|
// path / unit tests that don't wire the media beans.
|
||||||
|
RecordingAdapter legacyAdapter = new RecordingAdapter(null);
|
||||||
|
String text = "agent reply with /api/v1/files/generated/abc";
|
||||||
|
String rendered = legacyAdapter.scrubAndSendAttachments("ou_x", text);
|
||||||
|
assertEquals(text, rendered);
|
||||||
|
assertTrue(legacyAdapter.uploads.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Test fixture
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Captured upload arguments — what the test asserts on. */
|
||||||
|
private record RecordedUpload(String targetId, MediaSource source, String fileName,
|
||||||
|
String mediaType, String contentType) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subclass that records every {@link FeishuChannelAdapter#uploadAndSendAttachment}
|
||||||
|
* call instead of actually doing the HTTP send. Constructor wires a
|
||||||
|
* synthetic {@link ChannelEntity} with id=1 so the channelId check
|
||||||
|
* inside {@code scrubAndSendAttachments} passes; mediaUploader is
|
||||||
|
* mocked because the recording adapter overrides the call path that
|
||||||
|
* would use it anyway.
|
||||||
|
*/
|
||||||
|
private static class RecordingAdapter extends FeishuChannelAdapter {
|
||||||
|
final List<RecordedUpload> uploads = new ArrayList<>();
|
||||||
|
|
||||||
|
RecordingAdapter(GeneratedFileScrubber scrubber) {
|
||||||
|
super(testEntity(),
|
||||||
|
mock(ChannelMessageRouter.class),
|
||||||
|
new ObjectMapper(),
|
||||||
|
scrubber == null ? null : mock(FeishuMediaUploader.class),
|
||||||
|
scrubber);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void uploadAndSendAttachment(String targetId, Long channelId,
|
||||||
|
MediaSource source, String fileName,
|
||||||
|
String mediaType, String contentType,
|
||||||
|
Integer durationMillis) {
|
||||||
|
uploads.add(new RecordedUpload(targetId, source, fileName, mediaType, contentType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChannelEntity testEntity() {
|
||||||
|
ChannelEntity e = new ChannelEntity();
|
||||||
|
e.setId(1L);
|
||||||
|
e.setChannelType("feishu");
|
||||||
|
e.setConfigJson("{\"app_id\":\"x\",\"app_secret\":\"y\"}");
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user