mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(feishu): inbound file/audio/video download — SDK path + cache push
This commit is contained in:
parent
71e08b015e
commit
6b397a10ed
@ -108,6 +108,16 @@ public class ChannelManager {
|
|||||||
*/
|
*/
|
||||||
private final vip.mate.channel.feishu.cards.FeishuCardDispatcher feishuCardDispatcher;
|
private final vip.mate.channel.feishu.cards.FeishuCardDispatcher feishuCardDispatcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-channelId SDK {@link com.lark.oapi.Client} cache. Injected into
|
||||||
|
* {@link vip.mate.channel.feishu.FeishuChannelAdapter} so inbound
|
||||||
|
* file/image/audio/video downloads go through
|
||||||
|
* {@code client.im().v1().messageResource().get(...)} instead of the
|
||||||
|
* legacy hand-rolled HTTP path — token refresh, retries, and domain
|
||||||
|
* switching are then handled by the SDK.
|
||||||
|
*/
|
||||||
|
private final vip.mate.channel.feishu.FeishuClientFactory feishuClientFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distributed leader election. Channels whose adapter reports
|
* Distributed leader election. Channels whose adapter reports
|
||||||
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
|
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
|
||||||
@ -1175,7 +1185,7 @@ public class ChannelManager {
|
|||||||
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
||||||
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
|
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
|
||||||
feishuCardDispatcher);
|
feishuCardDispatcher, feishuClientFactory, generatedFileCache);
|
||||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
|
|||||||
@ -137,10 +137,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
/** Interactive-card dispatcher (approval cards etc.). Nullable for legacy callers / tests. */
|
/** Interactive-card dispatcher (approval cards etc.). Nullable for legacy callers / tests. */
|
||||||
private final vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher;
|
private final vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-channel SDK {@code Client} factory — drives inbound resource
|
||||||
|
* downloads via {@code client.im().v1().messageResource().get(...)}
|
||||||
|
* instead of the legacy hand-rolled {@code HttpClient} path. Nullable
|
||||||
|
* for legacy callers / tests, in which case the adapter falls back
|
||||||
|
* to the raw HTTP path (still works, just bypasses retries / token
|
||||||
|
* refresh / domain switching that the SDK handles).
|
||||||
|
*/
|
||||||
|
private final FeishuClientFactory clientFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-local cache that exposes downloaded inbound media via
|
||||||
|
* {@code /api/v1/files/generated/{id}} so the conversation memory
|
||||||
|
* and admin UI can render the file without holding a tenant token.
|
||||||
|
* Nullable for legacy callers / tests.
|
||||||
|
*/
|
||||||
|
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this(channelEntity, messageRouter, objectMapper, null, null, null, null);
|
this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -148,7 +166,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
FeishuMediaUploader mediaUploader,
|
FeishuMediaUploader mediaUploader,
|
||||||
GeneratedFileScrubber generatedFileScrubber) {
|
GeneratedFileScrubber generatedFileScrubber) {
|
||||||
this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, null, null);
|
this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber,
|
||||||
|
null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -158,7 +177,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
GeneratedFileScrubber generatedFileScrubber,
|
GeneratedFileScrubber generatedFileScrubber,
|
||||||
FeishuStreamingCardManager streamingCardManager) {
|
FeishuStreamingCardManager streamingCardManager) {
|
||||||
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
||||||
generatedFileScrubber, streamingCardManager, null);
|
generatedFileScrubber, streamingCardManager, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -168,11 +187,26 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
GeneratedFileScrubber generatedFileScrubber,
|
GeneratedFileScrubber generatedFileScrubber,
|
||||||
FeishuStreamingCardManager streamingCardManager,
|
FeishuStreamingCardManager streamingCardManager,
|
||||||
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) {
|
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) {
|
||||||
|
this(channelEntity, messageRouter, objectMapper, mediaUploader,
|
||||||
|
generatedFileScrubber, streamingCardManager, cardDispatcher, null, 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) {
|
||||||
super(channelEntity, messageRouter, objectMapper);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
this.mediaUploader = mediaUploader;
|
this.mediaUploader = mediaUploader;
|
||||||
this.generatedFileScrubber = generatedFileScrubber;
|
this.generatedFileScrubber = generatedFileScrubber;
|
||||||
this.streamingCardManager = streamingCardManager;
|
this.streamingCardManager = streamingCardManager;
|
||||||
this.cardDispatcher = cardDispatcher;
|
this.cardDispatcher = cardDispatcher;
|
||||||
|
this.clientFactory = clientFactory;
|
||||||
|
this.generatedFileCache = generatedFileCache;
|
||||||
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
||||||
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
||||||
}
|
}
|
||||||
@ -1276,11 +1310,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
// image. Default-on for images (separate from the
|
// image. Default-on for images (separate from the
|
||||||
// file/audio/video gate) so vision works out of the
|
// file/audio/video gate) so vision works out of the
|
||||||
// box; admins can opt out with feishu_image_download_enabled=false.
|
// box; admins can opt out with feishu_image_download_enabled=false.
|
||||||
String localPath = maybeDownloadImage(messageId, imageKey);
|
DownloadedResource dl = maybeDownloadImage(messageId, imageKey);
|
||||||
MessageContentPart part = MessageContentPart.image(imageKey, null);
|
MessageContentPart part = MessageContentPart.image(imageKey, null);
|
||||||
if (localPath != null) {
|
applyDownload(part, dl);
|
||||||
part.setPath(localPath);
|
|
||||||
}
|
|
||||||
parts.add(part);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[图片]";
|
yield "[图片]";
|
||||||
@ -1289,9 +1321,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
String fileKey = (String) contentObj.get("file_key");
|
String fileKey = (String) contentObj.get("file_key");
|
||||||
String fileName = (String) contentObj.get("file_name");
|
String fileName = (String) contentObj.get("file_name");
|
||||||
if (fileKey != null) {
|
if (fileKey != null) {
|
||||||
String localPath = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
||||||
MessageContentPart part = MessageContentPart.file(fileKey, fileName, null);
|
MessageContentPart part = MessageContentPart.file(fileKey, fileName, null);
|
||||||
if (localPath != null) part.setPath(localPath);
|
applyDownload(part, dl);
|
||||||
parts.add(part);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[文件: " + (fileName != null ? fileName : "") + "]";
|
yield "[文件: " + (fileName != null ? fileName : "") + "]";
|
||||||
@ -1299,9 +1331,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
case "audio" -> {
|
case "audio" -> {
|
||||||
String fileKey = (String) contentObj.get("file_key");
|
String fileKey = (String) contentObj.get("file_key");
|
||||||
if (fileKey != null) {
|
if (fileKey != null) {
|
||||||
String localPath = maybeDownloadResource(messageId, fileKey, "file", null);
|
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", null);
|
||||||
MessageContentPart part = MessageContentPart.audio(fileKey, null);
|
MessageContentPart part = MessageContentPart.audio(fileKey, null);
|
||||||
if (localPath != null) part.setPath(localPath);
|
applyDownload(part, dl);
|
||||||
parts.add(part);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[音频]";
|
yield "[音频]";
|
||||||
@ -1310,9 +1342,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
String fileKey = (String) contentObj.get("file_key");
|
String fileKey = (String) contentObj.get("file_key");
|
||||||
String fileName = (String) contentObj.get("file_name");
|
String fileName = (String) contentObj.get("file_name");
|
||||||
if (fileKey != null) {
|
if (fileKey != null) {
|
||||||
String localPath = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName);
|
||||||
MessageContentPart part = MessageContentPart.video(fileKey, fileName);
|
MessageContentPart part = MessageContentPart.video(fileKey, fileName);
|
||||||
if (localPath != null) part.setPath(localPath);
|
applyDownload(part, dl);
|
||||||
parts.add(part);
|
parts.add(part);
|
||||||
}
|
}
|
||||||
yield "[视频]";
|
yield "[视频]";
|
||||||
@ -1388,7 +1420,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
(List<List<Map<String, Object>>>) localeBranch.get("content");
|
(List<List<Map<String, Object>>>) localeBranch.get("content");
|
||||||
if (paragraphs == null) return text.toString().trim();
|
if (paragraphs == null) return text.toString().trim();
|
||||||
|
|
||||||
boolean mediaDownload = getConfigBoolean("media_download_enabled", false);
|
// Same default-on behaviour as the standalone file/audio/video branch — see maybeDownloadResource.
|
||||||
|
boolean mediaDownload = getConfigBoolean("media_download_enabled", true);
|
||||||
|
|
||||||
for (int i = 0; i < paragraphs.size(); i++) {
|
for (int i = 0; i < paragraphs.size(); i++) {
|
||||||
List<Map<String, Object>> paragraph = paragraphs.get(i);
|
List<Map<String, Object>> paragraph = paragraphs.get(i);
|
||||||
@ -1432,9 +1465,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
if (imageKey != null) {
|
if (imageKey != null) {
|
||||||
// Same reasoning as the standalone image case: vision
|
// Same reasoning as the standalone image case: vision
|
||||||
// pipelines need bytes; image_key alone is opaque.
|
// pipelines need bytes; image_key alone is opaque.
|
||||||
String localPath = maybeDownloadImage(messageId, imageKey);
|
DownloadedResource dl = maybeDownloadImage(messageId, imageKey);
|
||||||
MessageContentPart imgPart = MessageContentPart.image(imageKey, null);
|
MessageContentPart imgPart = MessageContentPart.image(imageKey, null);
|
||||||
if (localPath != null) imgPart.setPath(localPath);
|
applyDownload(imgPart, dl);
|
||||||
parts.add(imgPart);
|
parts.add(imgPart);
|
||||||
text.append("[图片]");
|
text.append("[图片]");
|
||||||
}
|
}
|
||||||
@ -1442,9 +1475,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
case "media" -> {
|
case "media" -> {
|
||||||
String fileKey = (String) element.get("file_key");
|
String fileKey = (String) element.get("file_key");
|
||||||
if (fileKey != null) {
|
if (fileKey != null) {
|
||||||
String localPath = mediaDownload ? maybeDownloadResource(messageId, fileKey, "file", null) : null;
|
DownloadedResource dl = mediaDownload
|
||||||
|
? maybeDownloadResource(messageId, fileKey, "file", null)
|
||||||
|
: null;
|
||||||
MessageContentPart mediaPart = MessageContentPart.file(fileKey, null, null);
|
MessageContentPart mediaPart = MessageContentPart.file(fileKey, null, null);
|
||||||
if (localPath != null) mediaPart.setPath(localPath);
|
applyDownload(mediaPart, dl);
|
||||||
parts.add(mediaPart);
|
parts.add(mediaPart);
|
||||||
text.append("[媒体]");
|
text.append("[媒体]");
|
||||||
}
|
}
|
||||||
@ -1469,13 +1504,44 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
return result.isEmpty() ? null : result;
|
return result.isEmpty() ? null : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 媒体文件下载 ====================
|
// ==================== Inbound media download ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 如果 media_download_enabled 则下载资源,否则返回 null
|
* Result of a successful inbound-resource download.
|
||||||
|
*
|
||||||
|
* @param path absolute path on disk under
|
||||||
|
* {@code ~/.mateclaw/media/feishu/} — fed to
|
||||||
|
* {@link MessageContentPart#setPath(String)} so
|
||||||
|
* vision / file-reading tools can consume bytes
|
||||||
|
* @param fileUrl {@code /api/v1/files/generated/{id}} URL backed
|
||||||
|
* by {@link vip.mate.tool.document.GeneratedFileCache}
|
||||||
|
* (10-min TTL) so the admin UI and conversation
|
||||||
|
* memory can render the file without a tenant
|
||||||
|
* token; {@code null} when the cache wasn't wired
|
||||||
|
* in (e.g. legacy tests)
|
||||||
|
* @param fileName server-side filename (extension inferred from
|
||||||
|
* content-type, falls back to {@code fileNameHint}
|
||||||
|
* or the SDK-returned name)
|
||||||
|
* @param contentType MIME type from the download response headers
|
||||||
*/
|
*/
|
||||||
private String maybeDownloadResource(String messageId, String fileKey, String type, String fileNameHint) {
|
// Package-private for unit tests — pin the field copy contract.
|
||||||
if (!getConfigBoolean("media_download_enabled", false)) {
|
record DownloadedResource(String path, String fileUrl,
|
||||||
|
String fileName, String contentType) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a {@code file} / {@code audio} / {@code video} inbound
|
||||||
|
* resource, but only when the per-channel
|
||||||
|
* {@code media_download_enabled} flag is on. Default is now <b>true</b>
|
||||||
|
* so a user uploading a file to the bot lands as a real file the
|
||||||
|
* agent can read — set it to {@code false} on the channel config to
|
||||||
|
* opt out (e.g. tighter privacy, no disk usage).
|
||||||
|
*
|
||||||
|
* <p>Mirrors the WeCom / DingTalk pattern: text-only adapters dropping
|
||||||
|
* file bytes was the "did you receive my doc?" gap reported in
|
||||||
|
* production.
|
||||||
|
*/
|
||||||
|
private DownloadedResource maybeDownloadResource(String messageId, String fileKey, String type, String fileNameHint) {
|
||||||
|
if (!getConfigBoolean("media_download_enabled", true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return downloadResource(messageId, fileKey, type, fileNameHint);
|
return downloadResource(messageId, fileKey, type, fileNameHint);
|
||||||
@ -1490,7 +1556,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
* downloads (e.g. tighter privacy, no disk usage) can set
|
* downloads (e.g. tighter privacy, no disk usage) can set
|
||||||
* {@code feishu_image_download_enabled=false} on the channel config.
|
* {@code feishu_image_download_enabled=false} on the channel config.
|
||||||
*/
|
*/
|
||||||
private String maybeDownloadImage(String messageId, String imageKey) {
|
private DownloadedResource maybeDownloadImage(String messageId, String imageKey) {
|
||||||
if (!getConfigBoolean("feishu_image_download_enabled", true)) {
|
if (!getConfigBoolean("feishu_image_download_enabled", true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -1498,15 +1564,87 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载飞书消息资源(图片/文件)到本地
|
* Copy a {@link DownloadedResource}'s fields onto a
|
||||||
*
|
* {@link MessageContentPart}. {@code null} input is a no-op so the
|
||||||
* @param messageId 消息 ID
|
* caller can keep the part as a bare {@code file_key} placeholder
|
||||||
* @param fileKey 资源 key(image_key 或 file_key)
|
* when download was disabled / failed.
|
||||||
* @param type 资源类型:"image" 或 "file"
|
|
||||||
* @param fileNameHint 文件名提示(可选)
|
|
||||||
* @return 本地文件路径,失败返回 null
|
|
||||||
*/
|
*/
|
||||||
private String downloadResource(String messageId, String fileKey, String type, String fileNameHint) {
|
// Package-private for unit tests.
|
||||||
|
static void applyDownload(MessageContentPart part, DownloadedResource dl) {
|
||||||
|
if (dl == null || part == null) return;
|
||||||
|
if (dl.path() != null) part.setPath(dl.path());
|
||||||
|
if (dl.fileUrl() != null) part.setFileUrl(dl.fileUrl());
|
||||||
|
if (dl.fileName() != null && (part.getFileName() == null || part.getFileName().isBlank())) {
|
||||||
|
part.setFileName(dl.fileName());
|
||||||
|
}
|
||||||
|
if (dl.contentType() != null) part.setContentType(dl.contentType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull an inbound resource (image / file / audio / video) from
|
||||||
|
* Feishu and persist it both to disk and (best-effort) to the
|
||||||
|
* {@link vip.mate.tool.document.GeneratedFileCache}.
|
||||||
|
*
|
||||||
|
* <p>Implementation prefers the official {@code oapi-sdk}
|
||||||
|
* ({@code client.im().v1().messageResource().get(...)}) when a
|
||||||
|
* {@link FeishuClientFactory} was wired in — gets token refresh,
|
||||||
|
* domain switching (feishu / lark), retries, and the same
|
||||||
|
* {@code Authorization} contract the rest of the SDK uses for free.
|
||||||
|
* Falls back to the legacy hand-rolled {@code HttpClient} path
|
||||||
|
* when no factory is available (3-arg legacy ctor / unit tests).
|
||||||
|
*
|
||||||
|
* @param messageId Feishu message id ({@code om_…}) — required
|
||||||
|
* by the resource endpoint to scope the file
|
||||||
|
* @param fileKey resource key ({@code img_…} for images,
|
||||||
|
* {@code file_…} for everything else)
|
||||||
|
* @param type SDK type discriminator: {@code "image"} for
|
||||||
|
* images, {@code "file"} for file/audio/video
|
||||||
|
* @param fileNameHint optional original filename (used for
|
||||||
|
* extension inference when the response has no
|
||||||
|
* useful Content-Type)
|
||||||
|
* @return a {@link DownloadedResource} on success, {@code null} on
|
||||||
|
* any failure (logged at debug) — caller treats {@code null}
|
||||||
|
* as "skip, just pass the opaque key through"
|
||||||
|
*/
|
||||||
|
private DownloadedResource downloadResource(String messageId, String fileKey, String type, String fileNameHint) {
|
||||||
|
// ---- Prefer SDK path: token refresh, retries, domain handled by the SDK
|
||||||
|
if (clientFactory != null && channelEntity != null && channelEntity.getId() != null) {
|
||||||
|
try {
|
||||||
|
com.lark.oapi.Client client = clientFactory.client(channelEntity.getId());
|
||||||
|
com.lark.oapi.service.im.v1.model.GetMessageResourceReq req =
|
||||||
|
com.lark.oapi.service.im.v1.model.GetMessageResourceReq.newBuilder()
|
||||||
|
.messageId(messageId)
|
||||||
|
.fileKey(fileKey)
|
||||||
|
.type(type)
|
||||||
|
.build();
|
||||||
|
com.lark.oapi.service.im.v1.model.GetMessageResourceResp resp =
|
||||||
|
client.im().v1().messageResource().get(req);
|
||||||
|
if (!resp.success() || resp.getData() == null) {
|
||||||
|
log.debug("[feishu] SDK resource.get failed: code={}, msg={}",
|
||||||
|
resp.getCode(), resp.getMsg());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] bytes = resp.getData().toByteArray();
|
||||||
|
// SDK exposes Content-Disposition's filename via getFileName();
|
||||||
|
// prefer it over the hint when present.
|
||||||
|
String sdkFileName = resp.getFileName();
|
||||||
|
String preferredHint = (sdkFileName != null && !sdkFileName.isBlank())
|
||||||
|
? sdkFileName : fileNameHint;
|
||||||
|
// No Content-Type accessor on GetMessageResourceResp — infer
|
||||||
|
// from filename extension (good enough for cache MIME and
|
||||||
|
// for vision pipelines that key on extension).
|
||||||
|
String contentType = inferMimeFromName(preferredHint);
|
||||||
|
return persistAndCache(messageId, fileKey, bytes, preferredHint, contentType);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[feishu] SDK resource.get threw, falling back to raw HTTP: {}", e.getMessage());
|
||||||
|
// Fall through to raw HTTP — keeps the inbound path resilient
|
||||||
|
// when the SDK is temporarily unhappy (e.g. invalid req shape
|
||||||
|
// on a new event type the SDK doesn't yet model).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Legacy fallback: hand-rolled HTTP. Kept so the 3-arg ctor
|
||||||
|
// and unit tests that don't wire a factory still get bytes.
|
||||||
try {
|
try {
|
||||||
ensureTokenValid();
|
ensureTokenValid();
|
||||||
String apiBase = getApiBaseUrl();
|
String apiBase = getApiBaseUrl();
|
||||||
@ -1532,32 +1670,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
log.debug("[feishu] Download resource failed: status={}", response.statusCode());
|
log.debug("[feishu] Download resource failed: status={}", response.statusCode());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
String contentType = response.headers().firstValue("Content-Type")
|
||||||
// 构建目标目录
|
.orElse(inferMimeFromName(fileNameHint));
|
||||||
Path mediaDir = Path.of(System.getProperty("user.home"), ".mateclaw", "media", "feishu");
|
byte[] bytes = is.readAllBytes();
|
||||||
Files.createDirectories(mediaDir);
|
return persistAndCache(messageId, fileKey, bytes, fileNameHint, contentType);
|
||||||
|
|
||||||
// 安全文件名
|
|
||||||
String safeKey = fileKey.replaceAll("[^a-zA-Z0-9_]", "");
|
|
||||||
if (safeKey.isEmpty()) safeKey = "file";
|
|
||||||
|
|
||||||
// 推断扩展名
|
|
||||||
String ext = "bin";
|
|
||||||
String contentType = response.headers().firstValue("Content-Type").orElse("");
|
|
||||||
if (contentType.contains("jpeg") || contentType.contains("jpg")) ext = "jpg";
|
|
||||||
else if (contentType.contains("png")) ext = "png";
|
|
||||||
else if (contentType.contains("gif")) ext = "gif";
|
|
||||||
else if (contentType.contains("webp")) ext = "webp";
|
|
||||||
else if (contentType.contains("pdf")) ext = "pdf";
|
|
||||||
else if (fileNameHint != null && fileNameHint.contains(".")) {
|
|
||||||
ext = fileNameHint.substring(fileNameHint.lastIndexOf('.') + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path filePath = mediaDir.resolve(messageId + "_" + safeKey + "." + ext);
|
|
||||||
Files.copy(is, filePath, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
|
|
||||||
log.debug("[feishu] Downloaded resource to: {}", filePath);
|
|
||||||
return filePath.toAbsolutePath().toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -1566,6 +1682,98 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write {@code bytes} to {@code ~/.mateclaw/media/feishu/} and,
|
||||||
|
* if {@link #generatedFileCache} is wired, also push them into the
|
||||||
|
* cache so the UI gets a {@code /api/v1/files/generated/{id}} URL.
|
||||||
|
* Disk persist is always attempted; cache push is best-effort.
|
||||||
|
*/
|
||||||
|
private DownloadedResource persistAndCache(String messageId, String fileKey,
|
||||||
|
byte[] bytes, String fileNameHint,
|
||||||
|
String contentType) throws java.io.IOException {
|
||||||
|
Path mediaDir = Path.of(System.getProperty("user.home"), ".mateclaw", "media", "feishu");
|
||||||
|
Files.createDirectories(mediaDir);
|
||||||
|
|
||||||
|
String safeKey = fileKey.replaceAll("[^a-zA-Z0-9_]", "");
|
||||||
|
if (safeKey.isEmpty()) safeKey = "file";
|
||||||
|
|
||||||
|
String ext = extensionFor(contentType, fileNameHint);
|
||||||
|
String resolvedFileName = (fileNameHint != null && !fileNameHint.isBlank())
|
||||||
|
? fileNameHint
|
||||||
|
: (safeKey + "." + ext);
|
||||||
|
|
||||||
|
Path filePath = mediaDir.resolve(messageId + "_" + safeKey + "." + ext);
|
||||||
|
Files.write(filePath, bytes);
|
||||||
|
log.debug("[feishu] Downloaded resource to: {} ({} bytes, {})",
|
||||||
|
filePath, bytes.length, contentType);
|
||||||
|
|
||||||
|
// Best-effort cache push so the UI / agent can reference the file
|
||||||
|
// via a tokenless URL. Failure here doesn't kill the inbound path —
|
||||||
|
// the local path on its own is still useful for vision tools.
|
||||||
|
String fileUrl = null;
|
||||||
|
if (generatedFileCache != null) {
|
||||||
|
try {
|
||||||
|
String id = generatedFileCache.put(bytes, resolvedFileName, contentType);
|
||||||
|
fileUrl = "/api/v1/files/generated/" + id;
|
||||||
|
} catch (Exception cacheEx) {
|
||||||
|
log.debug("[feishu] cache put failed: {}", cacheEx.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DownloadedResource(filePath.toAbsolutePath().toString(),
|
||||||
|
fileUrl, resolvedFileName, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map content-type / file-name hint to a short extension for the on-disk filename. */
|
||||||
|
// Package-private for unit tests.
|
||||||
|
static String extensionFor(String contentType, String fileNameHint) {
|
||||||
|
if (contentType != null) {
|
||||||
|
String ct = contentType.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (ct.contains("jpeg") || ct.contains("jpg")) return "jpg";
|
||||||
|
if (ct.contains("png")) return "png";
|
||||||
|
if (ct.contains("gif")) return "gif";
|
||||||
|
if (ct.contains("webp")) return "webp";
|
||||||
|
if (ct.contains("pdf")) return "pdf";
|
||||||
|
if (ct.contains("opus")) return "opus";
|
||||||
|
if (ct.contains("mpeg") || ct.contains("mp3")) return "mp3";
|
||||||
|
if (ct.contains("mp4")) return "mp4";
|
||||||
|
if (ct.contains("wordprocessing") || ct.contains("msword")) return "docx";
|
||||||
|
if (ct.contains("spreadsheet") || ct.contains("excel")) return "xlsx";
|
||||||
|
if (ct.contains("presentation") || ct.contains("powerpoint")) return "pptx";
|
||||||
|
}
|
||||||
|
if (fileNameHint != null && fileNameHint.contains(".")) {
|
||||||
|
String hint = fileNameHint.substring(fileNameHint.lastIndexOf('.') + 1)
|
||||||
|
.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (!hint.isBlank()) return hint;
|
||||||
|
}
|
||||||
|
return "bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort MIME guess from a filename — the SDK download response
|
||||||
|
* doesn't expose Content-Type, so we fall back to the extension. Good
|
||||||
|
* enough for the cache (used for the {@code Content-Type} header the
|
||||||
|
* UI sets when re-serving) and for vision providers (most key on
|
||||||
|
* extension anyway).
|
||||||
|
*/
|
||||||
|
// Package-private for unit tests.
|
||||||
|
static String inferMimeFromName(String fileName) {
|
||||||
|
if (fileName == null) return "application/octet-stream";
|
||||||
|
String lower = fileName.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||||
|
if (lower.endsWith(".png")) return "image/png";
|
||||||
|
if (lower.endsWith(".gif")) return "image/gif";
|
||||||
|
if (lower.endsWith(".webp")) return "image/webp";
|
||||||
|
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||||
|
if (lower.endsWith(".opus")) return "audio/opus";
|
||||||
|
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
||||||
|
if (lower.endsWith(".mp4")) return "video/mp4";
|
||||||
|
if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
if (lower.endsWith(".txt") || lower.endsWith(".md")) return "text/plain";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 消息发送 ====================
|
// ==================== 消息发送 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -60,6 +60,7 @@ class ChannelManagerReconcileTest {
|
|||||||
mock(vip.mate.channel.media.GeneratedFileScrubber.class),
|
mock(vip.mate.channel.media.GeneratedFileScrubber.class),
|
||||||
mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class),
|
mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class),
|
||||||
mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class),
|
mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class),
|
||||||
|
mock(vip.mate.channel.feishu.FeishuClientFactory.class),
|
||||||
election);
|
election);
|
||||||
adapter = new TrackingAdapter();
|
adapter = new TrackingAdapter();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,170 @@
|
|||||||
|
package vip.mate.channel.feishu;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the inbound-resource download contract for Feishu:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link FeishuChannelAdapter#extensionFor} maps every supported
|
||||||
|
* inbound content-type to a sensible on-disk extension — important
|
||||||
|
* because vision providers and file-reading tools often key on the
|
||||||
|
* extension, not the MIME header.</li>
|
||||||
|
* <li>{@link FeishuChannelAdapter#inferMimeFromName} round-trips the
|
||||||
|
* same set so the {@link vip.mate.tool.document.GeneratedFileCache}
|
||||||
|
* entry (which only stores MIME, no headers) renders correctly in
|
||||||
|
* the admin UI.</li>
|
||||||
|
* <li>{@link FeishuChannelAdapter#applyDownload} copies every populated
|
||||||
|
* field from a {@link FeishuChannelAdapter.DownloadedResource} onto
|
||||||
|
* the outbound {@link MessageContentPart} — and tolerates {@code
|
||||||
|
* null} input so the legacy "download disabled, keep the bare
|
||||||
|
* file_key" path still produces a valid part.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>These are the contracts that broke production before this change:
|
||||||
|
* when a user uploaded a PDF to the bot, the adapter only emitted a
|
||||||
|
* {@code file_key} placeholder and the agent never saw the bytes.
|
||||||
|
*/
|
||||||
|
class FeishuInboundResourceTest {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// extensionFor — Content-Type → on-disk extension
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("extensionFor: image content-types map to standard image extensions")
|
||||||
|
void extensionForImages() {
|
||||||
|
assertEquals("jpg", FeishuChannelAdapter.extensionFor("image/jpeg", null));
|
||||||
|
assertEquals("jpg", FeishuChannelAdapter.extensionFor("image/jpg", null));
|
||||||
|
assertEquals("png", FeishuChannelAdapter.extensionFor("image/png", null));
|
||||||
|
assertEquals("gif", FeishuChannelAdapter.extensionFor("image/gif", null));
|
||||||
|
assertEquals("webp", FeishuChannelAdapter.extensionFor("image/webp", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("extensionFor: office and document MIMEs map correctly")
|
||||||
|
void extensionForOfficeDocs() {
|
||||||
|
assertEquals("pdf", FeishuChannelAdapter.extensionFor("application/pdf", null));
|
||||||
|
assertEquals("docx", FeishuChannelAdapter.extensionFor(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", null));
|
||||||
|
assertEquals("xlsx", FeishuChannelAdapter.extensionFor(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", null));
|
||||||
|
assertEquals("pptx", FeishuChannelAdapter.extensionFor(
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("extensionFor: audio + video MIMEs map correctly")
|
||||||
|
void extensionForMedia() {
|
||||||
|
assertEquals("opus", FeishuChannelAdapter.extensionFor("audio/opus", null));
|
||||||
|
assertEquals("mp3", FeishuChannelAdapter.extensionFor("audio/mpeg", null));
|
||||||
|
assertEquals("mp4", FeishuChannelAdapter.extensionFor("video/mp4", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("extensionFor: unknown content-type falls back to filename hint, then 'bin'")
|
||||||
|
void extensionForFallback() {
|
||||||
|
// Filename hint when content-type is unhelpful
|
||||||
|
assertEquals("zip", FeishuChannelAdapter.extensionFor("application/octet-stream", "report.zip"));
|
||||||
|
assertEquals("csv", FeishuChannelAdapter.extensionFor(null, "data.csv"));
|
||||||
|
// No hint and no recognisable content-type → bin sentinel
|
||||||
|
assertEquals("bin", FeishuChannelAdapter.extensionFor(null, null));
|
||||||
|
assertEquals("bin", FeishuChannelAdapter.extensionFor("application/x-weird", null));
|
||||||
|
// Filename hint with no dot is not a usable extension
|
||||||
|
assertEquals("bin", FeishuChannelAdapter.extensionFor(null, "noextension"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// inferMimeFromName — round-trip from filename to MIME
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("inferMimeFromName: common extensions resolve to the right MIME")
|
||||||
|
void inferMimeImages() {
|
||||||
|
assertEquals("image/jpeg", FeishuChannelAdapter.inferMimeFromName("photo.jpg"));
|
||||||
|
assertEquals("image/jpeg", FeishuChannelAdapter.inferMimeFromName("PHOTO.JPEG"));
|
||||||
|
assertEquals("image/png", FeishuChannelAdapter.inferMimeFromName("screenshot.png"));
|
||||||
|
assertEquals("image/gif", FeishuChannelAdapter.inferMimeFromName("anim.gif"));
|
||||||
|
assertEquals("image/webp", FeishuChannelAdapter.inferMimeFromName("avatar.webp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("inferMimeFromName: office docs + media")
|
||||||
|
void inferMimeDocsAndMedia() {
|
||||||
|
assertEquals("application/pdf", FeishuChannelAdapter.inferMimeFromName("contract.pdf"));
|
||||||
|
assertEquals("audio/opus", FeishuChannelAdapter.inferMimeFromName("voice.opus"));
|
||||||
|
assertEquals("audio/mpeg", FeishuChannelAdapter.inferMimeFromName("track.mp3"));
|
||||||
|
assertEquals("video/mp4", FeishuChannelAdapter.inferMimeFromName("clip.mp4"));
|
||||||
|
assertEquals("text/plain", FeishuChannelAdapter.inferMimeFromName("README.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("inferMimeFromName: null or unknown returns generic octet-stream")
|
||||||
|
void inferMimeUnknown() {
|
||||||
|
assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName(null));
|
||||||
|
assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName("mystery.xyz"));
|
||||||
|
assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName("noext"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// applyDownload — copy DownloadedResource onto MessageContentPart
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("applyDownload: copies path / fileUrl / fileName / contentType")
|
||||||
|
void applyDownloadFullCopy() {
|
||||||
|
MessageContentPart part = MessageContentPart.file("file_abc", null, null);
|
||||||
|
FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
|
||||||
|
"/tmp/mateclaw/feishu/om_x_file_abc.pdf",
|
||||||
|
"/api/v1/files/generated/uuid-1",
|
||||||
|
"contract.pdf",
|
||||||
|
"application/pdf");
|
||||||
|
FeishuChannelAdapter.applyDownload(part, dl);
|
||||||
|
assertEquals("/tmp/mateclaw/feishu/om_x_file_abc.pdf", part.getPath());
|
||||||
|
assertEquals("/api/v1/files/generated/uuid-1", part.getFileUrl());
|
||||||
|
assertEquals("contract.pdf", part.getFileName());
|
||||||
|
assertEquals("application/pdf", part.getContentType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("applyDownload: null download leaves the bare key part untouched (legacy fallback)")
|
||||||
|
void applyDownloadNullIsNoop() {
|
||||||
|
MessageContentPart part = MessageContentPart.file("file_abc", "original.pdf", "application/pdf");
|
||||||
|
FeishuChannelAdapter.applyDownload(part, null);
|
||||||
|
assertNull(part.getPath());
|
||||||
|
assertNull(part.getFileUrl());
|
||||||
|
// Pre-existing fields preserved — the "download disabled / failed" path
|
||||||
|
// still produces a valid part the outbound side can handle as an opaque key.
|
||||||
|
assertEquals("file_abc", part.getMediaId());
|
||||||
|
assertEquals("original.pdf", part.getFileName());
|
||||||
|
assertEquals("application/pdf", part.getContentType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("applyDownload: preserves pre-existing fileName when caller already had one")
|
||||||
|
void applyDownloadPreservesExistingName() {
|
||||||
|
MessageContentPart part = MessageContentPart.file("file_abc", "user-named.pdf", null);
|
||||||
|
FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource(
|
||||||
|
"/tmp/path.pdf", "/url", "server-derived.pdf", "application/pdf");
|
||||||
|
FeishuChannelAdapter.applyDownload(part, dl);
|
||||||
|
// User-provided file_name wins over server-derived name so the
|
||||||
|
// bubble matches what the sender typed.
|
||||||
|
assertEquals("user-named.pdf", part.getFileName());
|
||||||
|
assertEquals("/tmp/path.pdf", part.getPath());
|
||||||
|
assertEquals("/url", part.getFileUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("applyDownload: tolerates null part defensively (router never sees NPE)")
|
||||||
|
void applyDownloadNullPart() {
|
||||||
|
// Just assert no exception. This is the contract that lets the
|
||||||
|
// inbound parser keep going when an upstream event is malformed.
|
||||||
|
FeishuChannelAdapter.applyDownload(null, new FeishuChannelAdapter.DownloadedResource(
|
||||||
|
"/x", "/y", "z", "t"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user