fix(dingtalk): make inbound images visible to vision model and chat UI

Three knots untangled so an image sent from DingTalk lands in both the
LLM's multimodal prompt and the chat history bubble:

- Prefer MessageContent.downloadCode (universal, used by the new
  api.dingtalk.com messageFiles/download) over pictureDownloadCode
  (legacy oapi field). Sending the legacy code to the new API got
  HTTP 500 unknownError, which was the original 'image not recognized'.
- After fetching bytes, persist to ~/.mateclaw/media/dingtalk/ so vision
  can read via FileSystemResource, AND stuff the same bytes into
  GeneratedFileCache so the UI gets an /api/v1/files/generated/{id} URL
  to render. Without the URL the message bubble showed an empty card.
- Carry filename / contentType / size on the MessageContentPart so the
  chat history doesn't fall back to the 'unknown' caption.

Same treatment applied to the richText branch (inline images from the
PC client) and threaded through the Stream SDK path.

Bundles in the prerequisite ChannelManager wiring of GeneratedFileCache
into DingTalkChannelAdapter and the new DingTalkMediaUploader used by
the outbound attachment flow that this work depends on.

Known limit: GeneratedFileCache TTL is 10 min — fresh refreshes work,
but viewing the image after a JVM restart needs a stable on-disk
serving endpoint, which is intentionally out of scope here.
This commit is contained in:
matevip 2026-04-28 14:59:59 +08:00
parent c0c642380a
commit 62b94b522f
3 changed files with 621 additions and 33 deletions

View File

@ -44,6 +44,7 @@ public class ChannelManager {
private final ChannelMessageRouter messageRouter;
private final ChannelSessionStore channelSessionStore;
private final ObjectMapper objectMapper;
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/** 运行中的渠道适配器channelId -> adapter */
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
@ -440,7 +441,7 @@ public class ChannelManager {
String type = channel.getChannelType();
return switch (type) {
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper);
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);

View File

@ -62,10 +62,18 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
/** AI Card 管理器message_type=card 时初始化) */
private DingTalkAICardManager aiCardManager;
/** 钉钉媒体上传器doStart 时初始化) */
private DingTalkMediaUploader mediaUploader;
/** 工具产生的可下载字节缓存DocxRenderTool 等用,注入避免回调到自己的 HTTP API */
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
public DingTalkChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
super(channelEntity, messageRouter, objectMapper);
this.generatedFileCache = generatedFileCache;
// 钉钉 Stream 重连2s4s8s16s30s无限重试
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
}
@ -97,6 +105,9 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
.connectTimeout(Duration.ofSeconds(10))
.build();
// 媒体上传器sampleFile / sampleImageMsg 都靠它先把字节传到钉钉拿 mediaId
this.mediaUploader = new DingTalkMediaUploader(httpClient, objectMapper);
// 初始化 AI Card 管理器message_type=card 且配置了模板 ID
String cardTemplateId = getConfigString("card_template_id");
String messageType = getConfigString("message_type", "markdown");
@ -168,13 +179,25 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
// 消息内容
// 钉钉服务端已经把语音转写好放在 MessageContent.recognition
// 企业微信 voice.content 一个模式不需要 STT优先读 recognition
// 否则读 text.content其他复杂类型picture / richText暂由
// handleWebhook 内部处理 stream 模式下我们目前没把那些类型
// 的字段塞进 payload是个遗留待修项picture / richText 同样会掉消息
String recognition = msg.getContent() != null ? msg.getContent().getRecognition() : null;
// picture msgtype downloadCode / pictureDownloadCode 透传给 webhook 处理
// 否则读 text.content
// richText 还没接上 stream 模式收到 richText 会重新落到 webhook 默认 else
// 分支静默丢消息单独修
com.dingtalk.open.app.api.models.bot.MessageContent body = msg.getContent();
String recognition = body != null ? body.getRecognition() : null;
String pictureDownloadCode = body != null ? body.getPictureDownloadCode() : null;
String downloadCode = body != null ? body.getDownloadCode() : null;
if (recognition != null && !recognition.isBlank()) {
payload.put("msgtype", "audio");
payload.put("audio", Map.of("recognition", recognition));
} else if ("picture".equals(msg.getMsgtype())
&& (pictureDownloadCode != null || downloadCode != null)) {
payload.put("msgtype", "picture");
Map<String, Object> picture = new java.util.HashMap<>();
if (pictureDownloadCode != null) picture.put("pictureDownloadCode", pictureDownloadCode);
if (downloadCode != null) picture.put("downloadCode", downloadCode);
payload.put("picture", picture);
} else if (msg.getText() != null) {
payload.put("msgtype", "text");
payload.put("text", Map.of("content", msg.getText().getContent() != null ? msg.getText().getContent() : ""));
@ -234,10 +257,16 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
}
/**
* 获取机器人编码
* 获取机器人编码
* <p>
* 钉钉自建应用机器人的 robotCode 大多数情况等于 AppKey client_id
* 所以 robot_code 显式没配时直接 fallback client_id 99% 的用户零配置就能用
* 第三方应用 / 单独申请的机器人才需要手动填 robot_code
*/
public String getRobotCode() {
return getConfigString("robot_code");
String configured = getConfigString("robot_code");
if (configured != null && !configured.isBlank()) return configured;
return getConfigString("client_id");
}
// ==================== StreamingChannelAdapter ====================
@ -387,7 +416,21 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
String downloadCode = (String) item.get("downloadCode");
String pictureUrl = (String) item.get("pictureUrl");
if (downloadCode != null || pictureUrl != null) {
contentParts.add(MessageContentPart.image(downloadCode, pictureUrl));
MessageContentPart imgPart = MessageContentPart.image(downloadCode, pictureUrl);
// Same as the standalone picture branch: vision needs bytes, not just an opaque
// downloadCode. Best-effort fetch; falls back to image-with-id if download fails.
if (downloadCode != null && !downloadCode.isBlank()) {
DownloadedMedia media = downloadDingTalkMedia(downloadCode);
if (media != null) {
if (media.path() != null) imgPart.setPath(media.path());
// Only override richText pictureUrl if we have a local-served URL.
if (media.url() != null) imgPart.setFileUrl(media.url());
if (media.fileName() != null) imgPart.setFileName(media.fileName());
if (media.contentType() != null) imgPart.setContentType(media.contentType());
if (media.size() > 0) imgPart.setFileSize(media.size());
}
}
contentParts.add(imgPart);
}
}
textContent = textBuilder.toString().trim();
@ -402,6 +445,35 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
textContent = recognition.trim();
contentParts.add(MessageContentPart.text(textContent));
}
} else if ("picture".equals(msgtype)) {
// 单图消息钉钉只给 downloadCode不透明 IDvision 模型直接读不了
// /v1.0/robot/messageFiles/download 拿临时 URL下载字节存本地
// path 塞进 MessageContentPart 让多模态 LLM 能从磁盘读图下载失败仍保留
// image part downloadCode作为占位 至少消息不丢
Map<String, Object> pictureBody = (Map<String, Object>) payload.get("picture");
String picDownloadCode = pictureBody != null ? (String) pictureBody.get("pictureDownloadCode") : null;
String dlCode = pictureBody != null ? (String) pictureBody.get("downloadCode") : null;
// Prefer the universal `downloadCode` that's what the new
// api.dingtalk.com/v1.0/robot/messageFiles/download API expects.
// `pictureDownloadCode` is the legacy field for the old
// oapi.dingtalk.com endpoint; passing it to the new API gets
// HTTP 500 "unknownError".
String code = dlCode != null ? dlCode : picDownloadCode;
if (code != null && !code.isBlank()) {
DownloadedMedia media = downloadDingTalkMedia(code);
// mediaId stays as downloadCode for traceability; fileUrl is the
// browser-renderable URL (or null on download failure).
MessageContentPart imgPart = MessageContentPart.image(
code, media != null ? media.url() : null);
if (media != null) {
if (media.path() != null) imgPart.setPath(media.path());
if (media.fileName() != null) imgPart.setFileName(media.fileName());
if (media.contentType() != null) imgPart.setContentType(media.contentType());
if (media.size() > 0) imgPart.setFileSize(media.size());
}
contentParts.add(imgPart);
textContent = "[图片]";
}
} else {
// 默认 text 消息
Map<String, Object> msgBody = (Map<String, Object>) payload.get("text");
@ -446,7 +518,12 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
.rawPayload(payload)
.build();
message.setReplyToken(sessionWebhook);
// Reply token 编码上下文sessionWebhook Markdown 文本无附件
// userId / conversationId Robot API能传 sampleFile / sampleImageMsg 真附件
// 出站时 sendContentParts 解析这个 token 决定路径格式是不透明字符串sendMessage
// 也能处理 如果只是发 markdown 就直接走 webhook不需要 access_token
String dtReplyToken = encodeReplyToken(sessionWebhook, senderId, conversationId, conversationType);
message.setReplyToken(dtReplyToken);
onMessage(message);
} catch (Exception e) {
@ -460,19 +537,22 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
log.warn("[dingtalk] Channel not started, cannot send message");
return;
}
// Delegate to sendContentParts so the URL sniff for /api/v1/files/generated/ runs on
// streaming-response markdown too processStreamAsText's renderAndSend sendMessage
// path used to bypass it. content==null/blank short-circuits to no-op.
if (content == null || content.isEmpty()) return;
sendContentParts(targetId, List.of(MessageContentPart.text(content)));
}
/** Existing sessionWebhook reply path, factored out so sendMessage and sendContentParts share it. */
private void sendMarkdownViaWebhook(String webhookUrl, String content) {
String messageType = getConfigString("message_type", "markdown");
try {
String jsonBody;
// card 模式的文本回退也使用 markdown 格式
if ("markdown".equals(messageType) || "card".equals(messageType)) {
jsonBody = objectMapper.writeValueAsString(Map.of(
"msgtype", "markdown",
"markdown", Map.of(
"title", "MateClaw",
"text", content
)
"markdown", Map.of("title", "MateClaw", "text", content)
));
} else {
jsonBody = objectMapper.writeValueAsString(Map.of(
@ -480,20 +560,17 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
"text", Map.of("content", content)
));
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(targetId))
.uri(URI.create(webhookUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("[dingtalk] Send message failed: status={}, body={}", response.statusCode(), response.body());
} else {
log.debug("[dingtalk] Message sent successfully via sessionWebhook");
}
} catch (Exception e) {
log.error("[dingtalk] Failed to send message: {}", e.getMessage(), e);
}
@ -506,26 +583,388 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
return;
}
// 钉钉 sessionWebhook 只支持 text/markdown/link/actionCard 等类型
// 图片需要通过上传 media 后发送这里暂时将媒体内容以 Markdown 图片语法发出
ReplyContext ctx = decodeReplyToken(targetId);
// Phase 1 walk parts. Text concatenates into the markdown reply; image/file
// parts (with resolvable bytes) become upload jobs. Text segments are also
// scanned for /api/v1/files/generated/{id} URLs (DocxRenderTool output etc.):
// hits are pulled out of the cache and queued as file uploads, with the URL
// replaced inline by a "📎 filename" badge so the user doesn't see a stale link.
List<UploadJob> uploadJobs = new ArrayList<>();
StringBuilder markdown = new StringBuilder();
for (MessageContentPart part : parts) {
if (part == null) continue;
switch (part.getType()) {
case "text" -> { if (part.getText() != null) markdown.append(part.getText()); }
case "text" -> {
String text = part.getText();
if (text == null || text.isEmpty()) break;
markdown.append(sniffGeneratedFiles(text, uploadJobs));
}
case "image" -> {
if (part.getFileUrl() != null) {
byte[] bytes = resolveBytes(part);
String fileName = part.getFileName() != null ? part.getFileName() : "image.png";
if (bytes != null) {
uploadJobs.add(new UploadJob(bytes, fileName, "image"));
} else if (part.getFileUrl() != null) {
markdown.append("\n![图片](").append(part.getFileUrl()).append(")\n");
} else {
markdown.append("\n[图片]\n");
}
}
case "file" -> markdown.append("\n[文件: ").append(part.getFileName() != null ? part.getFileName() : "").append("]\n");
case "file" -> {
byte[] bytes = resolveBytes(part);
String fileName = part.getFileName() != null ? part.getFileName() : "file.bin";
if (bytes != null) {
uploadJobs.add(new UploadJob(bytes, fileName, "file"));
} else {
markdown.append("\n[文件: ").append(fileName).append("]\n");
}
}
default -> { if (part.getText() != null) markdown.append(part.getText()); }
}
}
sendMessage(targetId, markdown.toString().trim());
// Phase 2 send the markdown body (if any) via sessionWebhook.
String md = markdown.toString().trim();
if (!md.isEmpty()) {
if (ctx.webhook != null && ctx.webhook.startsWith("http")) {
sendMarkdownViaWebhook(ctx.webhook, md);
} else {
// No sessionWebhook fall back to Robot API text send. proactiveSend()
// re-decodes the same token and routes by userId/conv. Don't call back into
// sendMessage() here sendMessage() now delegates to sendContentParts(),
// which would recurse infinitely.
proactiveSend(ctx.userId != null ? ctx.userId : targetId, md);
}
}
// Phase 3 upload + send media jobs via Robot API. Skips silently and falls back
// to a markdown placeholder if Robot API isn't usable (no robot_code, or
// no userId / conversationId in the reply context).
if (!uploadJobs.isEmpty()) {
sendUploadJobs(ctx, uploadJobs);
}
}
/** Scan a text fragment for `/api/v1/files/generated/{id}` URLs; return the rewritten text
* with each hit replaced by "📎 filename" and the corresponding bytes pushed onto jobs. */
private String sniffGeneratedFiles(String text, List<UploadJob> jobs) {
if (generatedFileCache == null) return text;
java.util.regex.Matcher m = GENERATED_URL_PATTERN.matcher(text);
StringBuilder out = new StringBuilder();
while (m.find()) {
String id = m.group(1);
var entry = generatedFileCache.get(id).orElse(null);
if (entry != null) {
String type = isImageMime(entry.mimeType()) ? "image" : "file";
jobs.add(new UploadJob(entry.bytes(), entry.filename(), type));
m.appendReplacement(out, java.util.regex.Matcher.quoteReplacement("📎 " + entry.filename()));
} else {
// Cache miss / expired leave the URL alone so user can still try clicking.
m.appendReplacement(out, java.util.regex.Matcher.quoteReplacement(m.group(0)));
}
}
m.appendTail(out);
return out.toString();
}
private static final java.util.regex.Pattern GENERATED_URL_PATTERN =
java.util.regex.Pattern.compile("/api/v1/files/generated/([a-zA-Z0-9-]+)");
private static boolean isImageMime(String mimeType) {
return mimeType != null && mimeType.toLowerCase().startsWith("image/");
}
/** Pull bytes from disk (part.path) or from the in-memory generated-file cache (part.fileUrl). */
private byte[] resolveBytes(MessageContentPart part) {
if (part == null) return null;
// Disk path: trust the agent's filesystem write, but guard against blowup on big files.
String path = part.getPath();
if (path != null && !path.isBlank()) {
try {
java.nio.file.Path p = java.nio.file.Paths.get(path);
if (java.nio.file.Files.exists(p) && java.nio.file.Files.size(p) <= DingTalkMediaUploader.MAX_FILE_BYTES) {
return java.nio.file.Files.readAllBytes(p);
}
} catch (Exception e) {
log.debug("[dingtalk] resolveBytes from path failed: {}", e.getMessage());
}
}
// GeneratedFileCache URL: /api/v1/files/generated/{id}
String url = part.getFileUrl();
if (url != null && generatedFileCache != null) {
java.util.regex.Matcher m = GENERATED_URL_PATTERN.matcher(url);
if (m.find()) {
var entry = generatedFileCache.get(m.group(1)).orElse(null);
if (entry != null) return entry.bytes();
}
}
return null;
}
/** Records to keep sendContentParts readable. */
private record UploadJob(byte[] bytes, String fileName, String type) {}
/** Decoded reply context, populated from {@link #encodeReplyToken}. */
private static class ReplyContext {
String webhook;
String userId;
String convId;
/** "1" = one-to-one, "2" = group; empty/null = unknown. */
String chatType;
}
/**
* Reply token is JSON now: {wh, user, conv, ct}. Old bare-URL tokens (e.g. from
* an in-flight conversation that started before this change) are still accepted
* the leading-{ check tells JSON apart from a raw HTTP URL.
*/
private String encodeReplyToken(String webhook, String userId, String conversationId, String chatType) {
java.util.LinkedHashMap<String, String> ctx = new java.util.LinkedHashMap<>();
if (webhook != null) ctx.put("wh", webhook);
if (userId != null) ctx.put("user", userId);
if (conversationId != null) ctx.put("conv", conversationId);
if (chatType != null) ctx.put("ct", chatType);
try {
return objectMapper.writeValueAsString(ctx);
} catch (Exception e) {
return webhook != null ? webhook : "";
}
}
@SuppressWarnings("unchecked")
private ReplyContext decodeReplyToken(String token) {
ReplyContext c = new ReplyContext();
if (token == null || token.isBlank()) return c;
String trimmed = token.trim();
if (!trimmed.startsWith("{")) {
// Legacy bare sessionWebhook URL or userId treat http-prefixed as webhook,
// otherwise as userId for proactive send.
if (trimmed.startsWith("http")) c.webhook = trimmed;
else c.userId = trimmed;
return c;
}
try {
Map<String, Object> m = objectMapper.readValue(trimmed, Map.class);
c.webhook = (String) m.get("wh");
c.userId = (String) m.get("user");
c.convId = (String) m.get("conv");
c.chatType = (String) m.get("ct");
} catch (Exception e) {
c.webhook = trimmed;
}
return c;
}
/**
* Upload each job to DingTalk's media endpoint, then send a sampleFile / sampleImageMsg
* via the Robot API. Falls back gracefully (one log line per skipped job) if the channel
* isn't configured for proactive send (no robot_code, or no usable target id).
*/
private void sendUploadJobs(ReplyContext ctx, List<UploadJob> jobs) {
String robotCode = getRobotCode();
if (robotCode == null || robotCode.isBlank()) {
log.warn("[dingtalk] {} attachment(s) skipped: robot_code not configured (Robot API needed for sampleFile / sampleImageMsg)", jobs.size());
return;
}
boolean isGroup = "2".equals(ctx.chatType) && ctx.convId != null && !ctx.convId.isBlank();
boolean isOneToOne = ctx.userId != null && !ctx.userId.isBlank();
if (!isGroup && !isOneToOne) {
log.warn("[dingtalk] {} attachment(s) skipped: no usable userId / conversationId in reply context", jobs.size());
return;
}
String accessToken = getDingTalkAccessToken();
if (accessToken == null) {
log.warn("[dingtalk] {} attachment(s) skipped: failed to get access_token", jobs.size());
return;
}
for (UploadJob job : jobs) {
try {
String mediaId = "image".equals(job.type)
? mediaUploader.uploadImage(job.bytes, job.fileName, accessToken)
: mediaUploader.uploadFile(job.bytes, job.fileName, accessToken);
if (mediaId == null) {
log.warn("[dingtalk] upload failed for {}, attachment skipped", job.fileName);
continue;
}
String msgKey;
String msgParam;
if ("image".equals(job.type)) {
msgKey = "sampleImageMsg";
msgParam = objectMapper.writeValueAsString(Map.of("photoURL", mediaId));
} else {
msgKey = "sampleFile";
msgParam = objectMapper.writeValueAsString(Map.of(
"mediaId", mediaId,
"fileName", job.fileName,
"fileType", inferFileType(job.fileName)
));
}
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("robotCode", robotCode);
body.put("msgKey", msgKey);
body.put("msgParam", msgParam);
String endpoint;
if (isGroup) {
body.put("openConversationId", ctx.convId);
endpoint = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
} else {
body.put("userIds", List.of(ctx.userId));
endpoint = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.header("Content-Type", "application/json")
.header("x-acs-dingtalk-access-token", accessToken)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("[dingtalk] sampleFile send HTTP {}: {}", response.statusCode(), response.body());
} else {
log.info("[dingtalk] attachment sent: {} ({})", job.fileName, job.type);
}
} catch (Exception e) {
log.warn("[dingtalk] sendUploadJobs error for {}: {}", job.fileName, e.getMessage());
}
}
}
/** sampleFile expects fileType as a short extension string (docx / pdf / xlsx / ...). */
private static String inferFileType(String fileName) {
if (fileName == null) return "";
int dot = fileName.lastIndexOf('.');
if (dot < 0 || dot == fileName.length() - 1) return "";
return fileName.substring(dot + 1).toLowerCase();
}
// ==================== 入站媒体下载 ====================
/**
* Result of a media download: local path (for vision pipeline) + HTTP URL
* (for browser rendering) + metadata to populate MessageContentPart so the
* UI doesn't fall back to "unknown" filenames.
*/
private record DownloadedMedia(String path, String url, String fileName,
String contentType, long size) {
}
/**
* 把入站消息里的 downloadCode 解析成本地文件路径 + 浏览器可访问的 URL
* <p>
* 钉钉的 inbound 图片 / 文件不直接给字节只给 downloadCode不透明的内部 ID
* 要让 vision 模型读图或落盘存档给用户回看必须先调
* {@code /v1.0/robot/messageFiles/download} 拿一个短期 downloadUrl GET 拉字节
* <p>
* 字节落两份磁盘 (~/.mateclaw/media/dingtalk/) vision path
* GeneratedFileCache UI 通过 /api/v1/files/generated/{id} 渲染10 min TTL
* <p>
* 失败 / 没配 robot_code / token 拿不到 返回 null调用方继续把 downloadCode 当占位用
*/
private DownloadedMedia downloadDingTalkMedia(String downloadCode) {
if (downloadCode == null || downloadCode.isBlank()) return null;
String robotCode = getRobotCode();
if (robotCode == null || robotCode.isBlank()) {
log.debug("[dingtalk] media download skipped: no robot_code");
return null;
}
String accessToken = getDingTalkAccessToken();
if (accessToken == null) {
log.debug("[dingtalk] media download skipped: no access_token");
return null;
}
try {
// Step 1: ask DingTalk for a short-TTL download URL
String body = objectMapper.writeValueAsString(Map.of(
"downloadCode", downloadCode,
"robotCode", robotCode
));
HttpRequest infoReq = HttpRequest.newBuilder()
.uri(URI.create("https://api.dingtalk.com/v1.0/robot/messageFiles/download"))
.header("Content-Type", "application/json")
.header("x-acs-dingtalk-access-token", accessToken)
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> infoResp = httpClient.send(infoReq, HttpResponse.BodyHandlers.ofString());
if (infoResp.statusCode() != 200) {
// Mask the codes so the log doesn't carry full credentials but is still
// useful for diagnosing whether we sent the right shape.
String codeTail = downloadCode.length() > 8
? "..." + downloadCode.substring(downloadCode.length() - 8) : downloadCode;
String robotTail = robotCode.length() > 6
? "..." + robotCode.substring(robotCode.length() - 6) : robotCode;
log.warn("[dingtalk] media download info HTTP {} (downloadCode={}, robotCode={}): {}",
infoResp.statusCode(), codeTail, robotTail, infoResp.body());
return null;
}
Map<?, ?> infoData = objectMapper.readValue(infoResp.body(), Map.class);
String downloadUrl = (String) infoData.get("downloadUrl");
if (downloadUrl == null || downloadUrl.isBlank()) {
log.warn("[dingtalk] media download info missing downloadUrl: {}", infoResp.body());
return null;
}
// Step 2: pull bytes from the signed URL
HttpRequest dlReq = HttpRequest.newBuilder()
.uri(URI.create(downloadUrl))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<byte[]> dlResp = httpClient.send(dlReq, HttpResponse.BodyHandlers.ofByteArray());
if (dlResp.statusCode() != 200) {
log.warn("[dingtalk] media download fetch HTTP {}", dlResp.statusCode());
return null;
}
byte[] bytes = dlResp.body();
// Step 3: persist to ~/.mateclaw/media/dingtalk/. Filename derived from
// downloadCode (sanitised) + extension inferred from Content-Type so vision
// pipelines that key on extension still work.
String contentType = dlResp.headers().firstValue("Content-Type").orElse("image/jpeg");
String ext = "jpg";
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";
java.nio.file.Path mediaDir = java.nio.file.Paths.get(
System.getProperty("user.home"), ".mateclaw", "media", "dingtalk");
java.nio.file.Files.createDirectories(mediaDir);
String safeCode = downloadCode.replaceAll("[^a-zA-Z0-9_-]", "_");
if (safeCode.length() > 32) safeCode = safeCode.substring(safeCode.length() - 32);
java.nio.file.Path filePath = mediaDir.resolve(safeCode + "." + ext);
java.nio.file.Files.write(filePath, bytes);
// Step 4: also stuff into GeneratedFileCache so the UI can render the image
// via /api/v1/files/generated/{id}. Best-effort failure here doesn't kill
// the vision pipeline (path is still useful).
String url = null;
if (generatedFileCache != null) {
try {
String fileName = safeCode + "." + ext;
String id = generatedFileCache.put(bytes, fileName, contentType);
url = "/api/v1/files/generated/" + id;
} catch (Exception cacheEx) {
log.debug("[dingtalk] cache put failed: {}", cacheEx.getMessage());
}
}
String fileName = safeCode + "." + ext;
log.info("[dingtalk] media downloaded: {} ({} bytes, {}) url={}",
fileName, bytes.length, contentType, url);
return new DownloadedMedia(
filePath.toAbsolutePath().toString(), url, fileName, contentType, bytes.length);
} catch (Exception e) {
log.warn("[dingtalk] media download failed: {}", e.getMessage());
return null;
}
}
// ==================== 主动推送 ====================
@ -549,19 +988,21 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
return;
}
if (targetId.startsWith("http")) {
// sessionWebhook 直接发送
sendMessage(targetId, content);
// targetId may be: sessionWebhook URL, raw userId, or our JSON-encoded reply token
// (after the replyToken refactor). Decode unifies all three.
ReplyContext ctx = decodeReplyToken(targetId);
if (ctx.webhook != null && ctx.webhook.startsWith("http")) {
sendMarkdownViaWebhook(ctx.webhook, content);
return;
}
// 通过 Robot API 发送获取 access_token 后调用 /v1.0/robot/oToMessages/batchSend
String robotCode = getConfigString("robot_code");
String robotCode = getRobotCode();
if (robotCode == null || robotCode.isBlank()) {
log.warn("[dingtalk] robot_code not configured, falling back to sendMessage");
sendMessage(targetId, content);
log.warn("[dingtalk] robot_code not configured, no usable webhook either — proactive send skipped");
return;
}
String resolvedUserId = ctx.userId != null ? ctx.userId : targetId;
try {
String accessToken = getDingTalkAccessToken();
@ -583,7 +1024,7 @@ public class DingTalkChannelAdapter extends AbstractChannelAdapter implements St
String jsonBody = objectMapper.writeValueAsString(Map.of(
"robotCode", robotCode,
"userIds", List.of(targetId),
"userIds", List.of(resolvedUserId),
"msgKey", msgKey,
"msgParam", objectMapper.writeValueAsString(msgParam)
));

View File

@ -0,0 +1,146 @@
package vip.mate.channel.dingtalk;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 钉钉媒体文件上传 helper
* <p>
* 钉钉 SampleFile / SampleImageMsg 消息要求 mediaId必须先把字节传到钉钉的 media
* 存储拿到 mediaId3 天有效
*
* <pre>
* POST https://oapi.dingtalk.com/media/upload?access_token=XXX&type=file|image|voice|video
* Content-Type: multipart/form-data
*
* form field: media (file content)
*
* { "errcode": 0, "media_id": "@xxx", "type": "file", "created_at": ... }
* </pre>
* <p>
* 单文件上限 20 MB钉钉服务端限制客户端上传时 timeout 30s 已经够大文件用
*
* @author MateClaw Team
*/
@Slf4j
@RequiredArgsConstructor
public class DingTalkMediaUploader {
private static final String UPLOAD_URL = "https://oapi.dingtalk.com/media/upload";
/** 钉钉单文件大小上限 20 MB —— 不是我们的政策,是钉钉服务端拒绝上传更大的,提前拦下省一次失败往返 */
public static final int MAX_FILE_BYTES = 20 * 1024 * 1024;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
public String uploadFile(byte[] bytes, String fileName, String accessToken) {
return upload(bytes, fileName, "file", accessToken);
}
public String uploadImage(byte[] bytes, String fileName, String accessToken) {
return upload(bytes, fileName, "image", accessToken);
}
/**
* @param type "file" / "image" / "voice" / "video"
* @return mediaId `@` 前缀任何失败返回 null调用方自己决定怎么回退
*/
private String upload(byte[] bytes, String fileName, String type, String accessToken) {
if (bytes == null || bytes.length == 0) {
log.warn("[dingtalk-upload] empty bytes for {} ({})", type, fileName);
return null;
}
if (bytes.length > MAX_FILE_BYTES) {
log.warn("[dingtalk-upload] file too large: {} bytes (limit {} bytes)",
bytes.length, MAX_FILE_BYTES);
return null;
}
if (accessToken == null || accessToken.isBlank()) {
log.warn("[dingtalk-upload] missing access_token");
return null;
}
try {
String boundary = "----DingTalkBoundary" + UUID.randomUUID().toString().replace("-", "");
byte[] body = buildMultipartBody(boundary, fileName, bytes);
String url = UPLOAD_URL + "?access_token=" + accessToken + "&type=" + type;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("[dingtalk-upload] HTTP {}: {}", response.statusCode(), response.body());
return null;
}
Map<?, ?> result = objectMapper.readValue(response.body(), Map.class);
Integer errcode = result.get("errcode") instanceof Number n ? n.intValue() : null;
if (errcode != null && errcode != 0) {
log.warn("[dingtalk-upload] errcode={}, errmsg={}", errcode, result.get("errmsg"));
return null;
}
String mediaId = (String) result.get("media_id");
if (mediaId == null || mediaId.isBlank()) {
log.warn("[dingtalk-upload] empty media_id in response: {}", response.body());
return null;
}
log.info("[dingtalk-upload] uploaded {} ({} bytes) → mediaId suffix=...{}",
type, bytes.length,
mediaId.length() > 8 ? mediaId.substring(mediaId.length() - 8) : mediaId);
return mediaId;
} catch (Exception e) {
log.warn("[dingtalk-upload] failed: {}", e.getMessage());
return null;
}
}
/**
* 手写最小 multipart 避免引入第三方 multipart
* 字段名 "media"content-type application/octet-stream钉钉只看字节不嫌弃 mime
*/
private byte[] buildMultipartBody(String boundary, String fileName, byte[] fileBytes) {
String safeFileName = fileName != null && !fileName.isBlank() ? fileName : "upload.bin";
// Use a simple ASCII-only fallback if the filename has non-ASCII chars to avoid header
// encoding issues; the actual bytes are unaffected.
String headerSafe = safeFileName.replaceAll("[\\r\\n\"]", "_");
String prefix = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"media\"; filename=\"" + headerSafe + "\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n";
String suffix = "\r\n--" + boundary + "--\r\n";
byte[] prefixBytes = prefix.getBytes(StandardCharsets.UTF_8);
byte[] suffixBytes = suffix.getBytes(StandardCharsets.UTF_8);
List<byte[]> chunks = new ArrayList<>(3);
chunks.add(prefixBytes);
chunks.add(fileBytes);
chunks.add(suffixBytes);
int total = prefixBytes.length + fileBytes.length + suffixBytes.length;
byte[] body = new byte[total];
int pos = 0;
for (byte[] c : chunks) {
System.arraycopy(c, 0, body, pos, c.length);
pos += c.length;
}
return body;
}
}