chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-im</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>app-platform-im-web</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-im-application</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-spec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-application</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-application</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 飞书 -->
|
||||
<dependency>
|
||||
<groupId>com.larksuite.oapi</groupId>
|
||||
<artifactId>oapi-sdk</artifactId>
|
||||
<version>2.5.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 企业微信 -->
|
||||
<dependency>
|
||||
<groupId>com.github.binarywang</groupId>
|
||||
<artifactId>weixin-java-cp</artifactId>
|
||||
<version>4.8.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,783 @@
|
||||
package com.xspaceagi.im.web.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.xspaceagi.agent.core.adapter.application.IComputerFileApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.application.DingtalkAgentApplicationService;
|
||||
import com.xspaceagi.im.application.ImChannelConfigApplicationService;
|
||||
import com.xspaceagi.im.application.ImSessionApplicationService;
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigDto;
|
||||
import com.xspaceagi.im.infra.dao.enitity.ImSession;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.infra.enums.ImChatTypeEnum;
|
||||
import com.xspaceagi.im.infra.enums.ImTargetTypeEnum;
|
||||
import com.xspaceagi.im.infra.enums.ImOutputModeEnum;
|
||||
import com.xspaceagi.im.web.dto.DingtalkAttachmentCodeDto;
|
||||
import com.xspaceagi.im.web.service.DingtalkAttachmentService;
|
||||
import com.xspaceagi.im.web.service.DingtalkOpenApiClient;
|
||||
import com.xspaceagi.im.web.service.ImFileShareService;
|
||||
import com.xspaceagi.im.web.util.ImOutputProcessor;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import com.xspaceagi.system.spec.utils.RedisUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.tuple.ImmutablePair;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/im/dingtalk")
|
||||
@Slf4j
|
||||
@Tag(name = "钉钉 IM 集成")
|
||||
public class IMDingtalkController {
|
||||
|
||||
@Resource
|
||||
private RedisUtil redisUtil;
|
||||
@Resource
|
||||
private DingtalkAgentApplicationService dingtalkAgentApplicationService;
|
||||
@Resource
|
||||
private DingtalkAttachmentService dingtalkAttachmentService;
|
||||
@Resource
|
||||
private ImSessionApplicationService imSessionApplicationService;
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
@Resource
|
||||
private ImChannelConfigApplicationService imChannelConfigApplicationService;
|
||||
@Resource
|
||||
private ImFileShareService imFileShareService;
|
||||
@Resource
|
||||
private IComputerFileApplicationService computerFileApplicationService;
|
||||
|
||||
/**
|
||||
* 钉钉 Webhook 时间戳容错范围(毫秒)
|
||||
* 用于验证请求中的 timestamp,防止重放攻击
|
||||
* 允许客户端时间与服务器时间偏差在 ±1 小时内
|
||||
*/
|
||||
private static final long TIMESTAMP_TOLERANCE_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 钉钉消息幂等 Redis Key 前缀
|
||||
* 用于在 Redis 中存储已处理的消息标识,防止重复处理
|
||||
*/
|
||||
private static final String DINGTALK_MSG_PREFIX = "dingtalk:msg:";
|
||||
|
||||
/**
|
||||
* 钉钉消息幂等 Redis Key 过期时间(秒)
|
||||
* 用于控制幂等 Key 的有效期,5 分钟后自动删除
|
||||
* 超过此时间的消息不再认为是重复消息
|
||||
*/
|
||||
private static final int DINGTALK_MSG_TTL_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* 流式消息补丁间隔(毫秒)
|
||||
* 流式输出时,每隔 200ms 发送一次内容
|
||||
*/
|
||||
private static final long STREAM_PATCH_INTERVAL_MS = 200;
|
||||
|
||||
/**
|
||||
* 流式消息补丁最小字符数
|
||||
* 单词边界,避免单词被截断
|
||||
*/
|
||||
private static final int STREAM_PATCH_MIN_CHARS = 60;
|
||||
|
||||
@RequestMapping(value = "/webhook", method = {RequestMethod.GET, RequestMethod.POST})
|
||||
@Operation(summary = "钉钉机器人消息接收 Webhook", hidden = true)
|
||||
public void webhook(HttpServletRequest request, HttpServletResponse response) throws Throwable {
|
||||
byte[] bodyBytes = StreamUtils.copyToByteArray(request.getInputStream());
|
||||
String bodyStr = new String(bodyBytes, StandardCharsets.UTF_8);
|
||||
String timestamp = request.getHeader("timestamp");
|
||||
String sign = request.getHeader("sign");
|
||||
|
||||
log.info("DingTalk Webhook request: method={}, contentLength={}, timestamp={}, sign={}, body={}",
|
||||
request.getMethod(), bodyBytes.length,
|
||||
timestamp != null ? "***" : null, sign != null ? "***" : null,
|
||||
bodyBytes.length > 500 ? bodyStr.substring(0, 500) + "..." : bodyStr);
|
||||
|
||||
// 空 body 且无签名:可能是 URL 验证或测试请求,直接返回 200
|
||||
if (bodyBytes.length == 0 && (StringUtils.isBlank(timestamp) || StringUtils.isBlank(sign))) {
|
||||
log.info("DingTalk Webhook empty request (no timestamp/sign), likely URL verify, returning 200");
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getOutputStream().write(JSON.toJSONString(Map.of(
|
||||
"msg", "钉钉 Webhook 已就绪,请从钉钉客户端发送消息测试",
|
||||
"url", "配置正确,需 POST 请求且 Header 含 timestamp、sign,Body 为 JSON")).getBytes(StandardCharsets.UTF_8));
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject body = JSON.parseObject(bodyStr);
|
||||
DingtalkBotConfig config = resolveBotConfig(timestamp, sign, body);
|
||||
if (config == null) {
|
||||
log.warn("DingTalk Webhook signature failed: timestamp={}, sign={}", timestamp, sign);
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getOutputStream().write(JSON.toJSONString(Map.of("error", "非法请求")).getBytes(StandardCharsets.UTF_8));
|
||||
return;
|
||||
}
|
||||
|
||||
if (body == null) {
|
||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
|
||||
String msgtype = body.getString("msgtype");
|
||||
if (!"text".equals(msgtype) && !"richText".equals(msgtype) && !"picture".equals(msgtype) && !"file".equals(msgtype)) {
|
||||
log.info("DingTalk Webhook unsupported msgtype, replying: msgtype={}", msgtype);
|
||||
String sessionWebhook = body.getString("sessionWebhook");
|
||||
if (StringUtils.isNotBlank(sessionWebhook)) {
|
||||
replyBySessionWebhook(sessionWebhook, "抱歉,暂不支持此消息格式的处理", null,
|
||||
body.getString("senderNick"), body.getString("senderStaffId"), body.getString("conversationType"));
|
||||
}
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
return;
|
||||
}
|
||||
|
||||
// 幂等:钉钉可能重复推送同一条消息,用 msgId 去重
|
||||
String msgId = body.getString("msgId");
|
||||
String dedupKey = StringUtils.isNotBlank(msgId) ? DINGTALK_MSG_PREFIX + msgId
|
||||
: DINGTALK_MSG_PREFIX + body.getString("conversationId") + ":" + body.getLong("createAt");
|
||||
if (redisUtil != null && redisUtil.get(dedupKey) != null) {
|
||||
log.info("DingTalk Webhook duplicate, skip: msgId={}", msgId);
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
return;
|
||||
}
|
||||
if (redisUtil != null) {
|
||||
redisUtil.set(dedupKey, "1", DINGTALK_MSG_TTL_SECONDS);
|
||||
}
|
||||
|
||||
String userMessage;
|
||||
List<DingtalkAttachmentCodeDto> attachmentCodes = new ArrayList<>();
|
||||
if ("text".equals(msgtype)) {
|
||||
userMessage = body.getJSONObject("text") != null
|
||||
? body.getJSONObject("text").getString("content")
|
||||
: "";
|
||||
} else if ("picture".equals(msgtype) || "file".equals(msgtype)) {
|
||||
userMessage = "";
|
||||
String code = parseAttachmentDownloadCode(body, msgtype);
|
||||
String fileName = parseAttachmentFileName(body, msgtype);
|
||||
if (StringUtils.isNotBlank(code)) {
|
||||
attachmentCodes.add(new DingtalkAttachmentCodeDto(code, "picture".equals(msgtype), fileName));
|
||||
} else {
|
||||
log.warn("DingTalk {} message missing downloadCode, bodyKeys={}, picture={}, content={}",
|
||||
msgtype, body != null ? body.keySet() : null,
|
||||
body != null ? body.getJSONObject("picture") : null,
|
||||
body != null ? body.getJSONObject("content") : null);
|
||||
}
|
||||
} else {
|
||||
// richText
|
||||
var parsed = parseRichTextContent(body.getJSONObject("content"));
|
||||
userMessage = parsed.getLeft();
|
||||
attachmentCodes = parsed.getRight();
|
||||
}
|
||||
String senderNick = body.getString("senderNick");
|
||||
|
||||
// 下载附件并上传到项目存储
|
||||
List<AttachmentDto> attachments = new ArrayList<>();
|
||||
if (!attachmentCodes.isEmpty()) {
|
||||
DingtalkOpenApiClient apiClient = getApiClient(config);
|
||||
// robotCode 需与接收该消息的机器人一致。picture 消息体可能不含 robotCode,richText 通常包含
|
||||
String robotCode = body.getString("robotCode");
|
||||
if (StringUtils.isBlank(robotCode)) {
|
||||
robotCode = StringUtils.isNotBlank(config.getRobotCode()) ? config.getRobotCode() : config.getClientId();
|
||||
}
|
||||
var tenantConfig = tenantConfigApplicationService.getTenantConfig(config.getTenantId());
|
||||
var attachmentResult = dingtalkAttachmentService.downloadAndUpload(
|
||||
apiClient, attachmentCodes, robotCode, null, tenantConfig, config.getUserId());
|
||||
attachments = attachmentResult.getAttachments();
|
||||
// 仅当全部附件下载失败时提示;有任一成功则只显示 [附件]
|
||||
if (!attachmentResult.getUnsupportedKeys().isEmpty() && attachmentResult.getAttachments().isEmpty()) {
|
||||
String errHint = "附件下载失败,可能原因:1) 群聊不支持文件下载,请在单聊中发送;2) 在钉钉开放平台robotCode 并配置。";
|
||||
if (StringUtils.isNotBlank(userMessage)) {
|
||||
userMessage = userMessage + "\n\n[系统提示:" + errHint + "]";
|
||||
} else {
|
||||
// 仅附件且全部下载失败:直接回复用户,不调用智能体
|
||||
replyBySessionWebhook(body.getString("sessionWebhook"), errHint, null, senderNick,
|
||||
body.getString("senderStaffId"), body.getString("conversationType"));
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
return;
|
||||
}
|
||||
} else if (!attachmentResult.getAttachments().isEmpty() && StringUtils.isBlank(userMessage)) {
|
||||
userMessage = buildAttachmentDisplay(attachments.size());
|
||||
}
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(userMessage) && attachments.isEmpty()) {
|
||||
replyBySessionWebhook(body.getString("sessionWebhook"), "请输入文本内容或发送附件", null, senderNick, body.getString("senderStaffId"), body.getString("conversationType"));
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(userMessage)) {
|
||||
userMessage = "[用户发送了附件]";
|
||||
}
|
||||
|
||||
// 引用块显示:有文本有附件时显示「文本 [附件]xN」,仅附件时显示「[附件]xN」(避免 userMessage 已是 [附件] 时重复拼接)
|
||||
String attachmentDisplay = buildAttachmentDisplay(attachments.size());
|
||||
String quoteDisplayMessage = attachments.isEmpty() ? userMessage
|
||||
: (StringUtils.isNotBlank(userMessage) && !userMessage.equals(attachmentDisplay)
|
||||
? userMessage + " " + attachmentDisplay
|
||||
: attachmentDisplay);
|
||||
|
||||
String senderId = body.getString("senderId");
|
||||
if (StringUtils.isBlank(senderId)) {
|
||||
senderId = body.getString("conversationId");
|
||||
}
|
||||
|
||||
String sessionWebhook = body.getString("sessionWebhook");
|
||||
Long sessionWebhookExpiredTime = body.getLong("sessionWebhookExpiredTime");
|
||||
if (sessionWebhookExpiredTime != null && sessionWebhookExpiredTime < System.currentTimeMillis()) {
|
||||
log.warn("DingTalk sessionWebhook expired: expiredTime={}", sessionWebhookExpiredTime);
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Handle DingTalk message: senderId={}, conversationId={}, content={}, robotCode={}",
|
||||
senderId, body.getString("conversationId"), userMessage, body.getString("robotCode"));
|
||||
|
||||
String conversationType = body.getString("conversationType");
|
||||
String conversationId = body.getString("conversationId");
|
||||
String senderStaffId = body.getString("senderStaffId");
|
||||
String sessionName = StringUtils.isNotBlank(senderNick) ? senderNick : null;
|
||||
// 群聊:查群名,失败则回退 senderNick / conversationId(不抛异常)
|
||||
if ("2".equals(conversationType) && StringUtils.isNotBlank(conversationId)) {
|
||||
try {
|
||||
DingtalkOpenApiClient apiClient = getApiClient(config);
|
||||
String groupName = apiClient.queryGroupName(conversationId);
|
||||
if (StringUtils.isNotBlank(groupName)) {
|
||||
sessionName = groupName;
|
||||
} else if (StringUtils.isBlank(sessionName)) {
|
||||
sessionName = conversationId;
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
if (StringUtils.isBlank(sessionName)) {
|
||||
sessionName = conversationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNewCommand(userMessage)) {
|
||||
createNewConversationForDingtalk(senderId, conversationType, conversationId, sessionName, config);
|
||||
replyBySessionWebhook(sessionWebhook, "已为你创建新会话,后续消息默认走新会话", null, senderNick, senderStaffId, conversationType);
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ImOutputModeEnum.ONCE == ImOutputModeEnum.fromCode(config.getOutputMode())) {
|
||||
// 一次性输出:非流式,使用 sessionWebhook 发送 Markdown(非互动卡片)
|
||||
DingtalkAgentApplicationService.AgentExecuteResultWithConv result = dingtalkAgentApplicationService.executeAgentWithConv(
|
||||
senderId, userMessage, attachments, conversationType, conversationId,
|
||||
config.getTenantId(), config.getUserId(), config.getAgentId(), sessionName);
|
||||
String fileUrlDomain = getPlatformBaseUrl(config.getTenantId());
|
||||
String processed = ImOutputProcessor.processOutput(result.getText(), result.getConversationId(),
|
||||
config.getAgentId(), fileUrlDomain, config.getUserId(), config.getTenantId(),
|
||||
imFileShareService, computerFileApplicationService);
|
||||
replyBySessionWebhook(sessionWebhook, processed, quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先使用互动卡片流式更新,失败时回退到 sessionWebhook 同步回复
|
||||
// 需要权限:qyapi_chat_manage
|
||||
String outTrackId = "card_" + UUID.randomUUID();
|
||||
// 优先使用 webhook 回调中的 robotCode(钉钉推送的机器人标识),否则用配置的 robotCode/clientId
|
||||
String robotCode = body.getString("robotCode");
|
||||
if (StringUtils.isBlank(robotCode)) {
|
||||
robotCode = StringUtils.isNotBlank(config.getRobotCode()) ? config.getRobotCode() : config.getClientId();
|
||||
}
|
||||
DingtalkOpenApiClient apiClient = getApiClient(config);
|
||||
String initialCardContent = buildReplyContent("正在思考...", quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
boolean cardSent = apiClient.sendInteractiveCard(
|
||||
conversationType, conversationId, senderStaffId,
|
||||
outTrackId, DingtalkOpenApiClient.buildCardData(initialCardContent), robotCode);
|
||||
|
||||
if (cardSent) {
|
||||
streamAndPatchCard(apiClient, outTrackId, senderId, userMessage, quoteDisplayMessage, attachments, senderNick, senderStaffId,
|
||||
conversationType, conversationId, config);
|
||||
} else {
|
||||
String lastErr = apiClient.getLastError();
|
||||
boolean isCredentialError = lastErr != null && (lastErr.contains("AccessToken 为空")
|
||||
|| lastErr.contains("invalidClientIdOrSecret") || lastErr.contains("无效的clientId"));
|
||||
boolean isPermissionError = lastErr != null && (lastErr.contains("qyapi_chat_manage")
|
||||
|| lastErr.contains("AccessTokenPermissionDenied") || lastErr.contains("应用尚未开通所需的权限"));
|
||||
boolean isRobotNotFound = lastErr != null && (lastErr.contains("chatbot.notFound") || lastErr.contains("机器人不存在"));
|
||||
if (isCredentialError) {
|
||||
// 凭证错误:不执行智能体,仅提示用户
|
||||
log.warn("DingTalk credential misconfigured, skip: senderId={}, reason={}", senderId, lastErr);
|
||||
replyBySessionWebhook(sessionWebhook, "凭证配置错误,请联系管理员检查钉钉应用 ClientId/ClientSecret", quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
} else if (isPermissionError) {
|
||||
// 权限错误:提示开通会话管理权限
|
||||
log.warn("DingTalk app permission not enabled, skip: senderId={}, reason={}", senderId, lastErr);
|
||||
replyBySessionWebhook(sessionWebhook, "钉钉应用权限未开通,请联系管理员在钉钉开放平台开通「会话管理」权限(qyapi_chat_manage)", quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
} else if (isRobotNotFound) {
|
||||
// 机器人不存在:提示检查机器人是否已添加到群聊
|
||||
log.warn("DingTalk bot not found, skip reply: senderId={}, reason={}", senderId, lastErr);
|
||||
replyBySessionWebhook(sessionWebhook, "机器人未找到,请确认:1) 机器人已添加到当前群聊;2) 机器人在钉钉开放平台已发布;3) 若 robotCode 与 AppKey 不同,请在配置中填写 robotCode(在【消息推送】获取)", quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
} else {
|
||||
// 其他失败(如网络等),回退到 sessionWebhook 流式回复
|
||||
log.warn("DingTalk interactive card failed, fallback sessionWebhook: senderId={}, conversationId={}, reason={}", senderId, conversationId, lastErr);
|
||||
streamBySessionWebhook(sessionWebhook, senderId, userMessage, quoteDisplayMessage, attachments, senderNick, senderStaffId,
|
||||
conversationType, conversationId, config);
|
||||
}
|
||||
}
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证钉钉签名
|
||||
* sign = Base64(HmacSHA256(timestamp + "\n" + clientSecret, clientSecret))
|
||||
*/
|
||||
private boolean verifySign(String timestamp, String sign, String clientSecret) {
|
||||
if (StringUtils.isBlank(timestamp) || StringUtils.isBlank(sign) || StringUtils.isBlank(clientSecret)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
long ts = Long.parseLong(timestamp);
|
||||
if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_TOLERANCE_MS) {
|
||||
log.warn("DingTalk timestamp out of range: ts={}", ts);
|
||||
return false;
|
||||
}
|
||||
String stringToSign = timestamp + "\n" + clientSecret;
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(clientSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
|
||||
String expectedSign = Base64.getEncoder().encodeToString(signData);
|
||||
return sign.equals(expectedSign);
|
||||
} catch (Exception e) {
|
||||
log.warn("DingTalk signature verify error: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据签名验证解析当前请求对应的机器人配置。
|
||||
* 从 body 的 robotCode 查找(企业机器人),否则遍历所有 config 用签名匹配。
|
||||
*/
|
||||
private DingtalkBotConfig resolveBotConfig(String timestamp, String sign, JSONObject body) {
|
||||
if (body != null) {
|
||||
String robotCode = body.getString("robotCode");
|
||||
if (StringUtils.isNotBlank(robotCode)) {
|
||||
DingtalkBotConfig config = getConfigByRobotCode(robotCode);
|
||||
if (config != null && verifySign(timestamp, sign, config.getClientSecret())) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private DingtalkBotConfig getConfigByRobotCode(String robotCode) {
|
||||
if (StringUtils.isBlank(robotCode)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imDingtalkWebhookMissingRobotCode);
|
||||
}
|
||||
ImChannelConfigDto cfg = imChannelConfigApplicationService.getDingtalkConfigByRobotCode(robotCode);
|
||||
ImChannelConfigDto.DingtalkConfig ding = cfg != null ? cfg.getDingtalk() : null;
|
||||
if (ding == null || StringUtils.isBlank(ding.getRobotCode())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imDingtalkBotNotBound);
|
||||
}
|
||||
return DingtalkBotConfig.builder()
|
||||
.clientId(ding.getClientId())
|
||||
.clientSecret(ding.getClientSecret())
|
||||
.robotCode(ding.getRobotCode())
|
||||
.tenantId(cfg.getTenantId())
|
||||
.userId(cfg.getUserId())
|
||||
.agentId(cfg.getAgentId())
|
||||
.outputMode(cfg.getOutputMode())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String getPlatformBaseUrl(Long tenantId) {
|
||||
TenantConfigDto tenantConfig = tenantConfigApplicationService.getTenantConfig(tenantId);
|
||||
if (tenantConfig == null || StringUtils.isBlank(tenantConfig.getSiteUrl())) {
|
||||
return null;
|
||||
}
|
||||
String domain = tenantConfig.getSiteUrl().trim();
|
||||
if (!domain.startsWith("http://") && !domain.startsWith("https://")) {
|
||||
domain = "https://" + domain;
|
||||
}
|
||||
if (domain.endsWith("/")) {
|
||||
domain = domain.substring(0, domain.length() - 1);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
private DingtalkOpenApiClient getApiClient(DingtalkBotConfig config) {
|
||||
return new DingtalkOpenApiClient(config.getClientId(), config.getClientSecret(), config.getRobotCode());
|
||||
}
|
||||
|
||||
/** 根据附件数量生成显示文本,如 3 个附件显示 [附件][附件][附件] */
|
||||
private static String buildAttachmentDisplay(int count) {
|
||||
if (count <= 0) return "";
|
||||
return "[附件]".repeat(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析钉钉 picture/file 消息的下载码。picture 消息 content 同时含 pictureDownloadCode 和 downloadCode,
|
||||
* 下载接口需用 downloadCode(mIofN... 格式),pictureDownloadCode(vcOp... 格式)会返回 500。
|
||||
*/
|
||||
private String parseAttachmentDownloadCode(JSONObject body, String msgtype) {
|
||||
if (body == null) return null;
|
||||
JSONObject content = body.getJSONObject("content");
|
||||
if (content != null) {
|
||||
// 优先 downloadCode:钉钉下载接口要求此字段,picture 消息 content 中会同时提供
|
||||
String code = content.getString("downloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
code = content.getString("pictureDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
code = content.getString("fileDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
}
|
||||
String objKey = "picture".equals(msgtype) ? "picture" : "file";
|
||||
JSONObject obj = body.getJSONObject(objKey);
|
||||
if (obj != null) {
|
||||
String code = obj.getString("downloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
code = obj.getString("pictureDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
code = obj.getString("fileDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
}
|
||||
String code = body.getString("downloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
code = body.getString("pictureDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
code = body.getString("fileDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) return code;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析钉钉 picture/file 消息的原始文件名。可能在 content、picture、file 对象下。
|
||||
*/
|
||||
private String parseAttachmentFileName(JSONObject body, String msgtype) {
|
||||
if (body == null) return null;
|
||||
JSONObject content = body.getJSONObject("content");
|
||||
if (content != null) {
|
||||
String name = content.getString("fileName");
|
||||
if (StringUtils.isNotBlank(name)) return sanitizeFileName(name);
|
||||
}
|
||||
String objKey = "picture".equals(msgtype) ? "picture" : "file";
|
||||
JSONObject obj = body.getJSONObject(objKey);
|
||||
if (obj != null) {
|
||||
String name = obj.getString("fileName");
|
||||
if (StringUtils.isNotBlank(name)) return sanitizeFileName(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String sanitizeFileName(String name) {
|
||||
if (name == null) return null;
|
||||
return name.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
|
||||
private static boolean isNewCommand(String userMessage) {
|
||||
String normalized = StringUtils.trimToEmpty(userMessage)
|
||||
.replace('\u00A0', ' ')
|
||||
.replaceAll("^(?:@[^\\s]+\\s*)+", "")
|
||||
.trim();
|
||||
return "/new".equals(normalized);
|
||||
}
|
||||
|
||||
private void createNewConversationForDingtalk(String senderId, String conversationType, String conversationId,
|
||||
String sessionName, DingtalkBotConfig config) {
|
||||
String sessionKey = "2".equals(conversationType) && StringUtils.isNotBlank(conversationId)
|
||||
? conversationId : senderId;
|
||||
ImSession imSession = ImSession.builder()
|
||||
.channel(ImChannelEnum.DINGTALK.getCode())
|
||||
.targetType(ImTargetTypeEnum.BOT.getCode())
|
||||
.sessionKey(sessionKey)
|
||||
.sessionName(sessionName)
|
||||
.chatType("2".equals(conversationType) ? ImChatTypeEnum.GROUP.getCode() : ImChatTypeEnum.PRIVATE.getCode())
|
||||
.userId(config.getUserId())
|
||||
.agentId(config.getAgentId())
|
||||
.tenantId(config.getTenantId())
|
||||
.build();
|
||||
imSessionApplicationService.createNewConversationId(imSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析钉钉 richText 富文本消息。
|
||||
* content.richText 为数组,每项可能包含:text、pictureDownloadCode、fileDownloadCode、downloadCode、fileName。
|
||||
*
|
||||
* @return Pair(userMessage 拼接文本, List of DingtalkAttachmentCodeDto)
|
||||
*/
|
||||
private Pair<String, List<DingtalkAttachmentCodeDto>> parseRichTextContent(JSONObject content) {
|
||||
if (content == null) {
|
||||
return ImmutablePair.of("", new ArrayList<>());
|
||||
}
|
||||
JSONArray richText = content.getJSONArray("richText");
|
||||
if (richText == null || richText.isEmpty()) {
|
||||
return ImmutablePair.of("", new ArrayList<>());
|
||||
}
|
||||
StringBuilder textSb = new StringBuilder();
|
||||
List<DingtalkAttachmentCodeDto> attachmentCodes = new ArrayList<>();
|
||||
for (int i = 0; i < richText.size(); i++) {
|
||||
JSONObject item = richText.getJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String text = item.getString("text");
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
if (textSb.length() > 0) textSb.append("\n");
|
||||
textSb.append(text);
|
||||
}
|
||||
String code = item.getString("downloadCode");
|
||||
boolean isPicture = false;
|
||||
if (StringUtils.isNotBlank(code)) {
|
||||
isPicture = false;
|
||||
} else {
|
||||
code = item.getString("pictureDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) isPicture = true;
|
||||
else {
|
||||
code = item.getString("fileDownloadCode");
|
||||
if (StringUtils.isNotBlank(code)) isPicture = false;
|
||||
}
|
||||
}
|
||||
String fileName = item.getString("fileName");
|
||||
if (StringUtils.isNotBlank(fileName)) fileName = sanitizeFileName(fileName);
|
||||
if (StringUtils.isNotBlank(code)) {
|
||||
attachmentCodes.add(new DingtalkAttachmentCodeDto(code, isPicture, fileName));
|
||||
}
|
||||
}
|
||||
return ImmutablePair.of(textSb.toString(), attachmentCodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式执行智能体并更新互动卡片(打字机效果)
|
||||
*/
|
||||
private void streamAndPatchCard(DingtalkOpenApiClient apiClient, String outTrackId, String senderId,
|
||||
String userMessage, String quoteDisplayMessage,
|
||||
List<AttachmentDto> attachments,
|
||||
String senderNick, String senderStaffId,
|
||||
String conversationType, String conversationId, DingtalkBotConfig config) {
|
||||
AtomicLong lastPatchTime = new AtomicLong(0);
|
||||
AtomicInteger lastPatchLength = new AtomicInteger(0);
|
||||
|
||||
dingtalkAgentApplicationService.executeAgentStream(
|
||||
senderId, userMessage, attachments, conversationType, conversationId,
|
||||
config.getTenantId(), config.getUserId(), config.getAgentId())
|
||||
.filter(chunk -> chunk != null && chunk.getText() != null)
|
||||
.subscribe(
|
||||
chunk -> {
|
||||
long now = System.currentTimeMillis();
|
||||
int currentLen = chunk.getText().length();
|
||||
int newChars = currentLen - lastPatchLength.get();
|
||||
boolean isFirstChunk = lastPatchLength.get() == 0 && currentLen > 0;
|
||||
boolean shouldPatch = chunk.isFinal()
|
||||
|| isFirstChunk
|
||||
|| (newChars >= STREAM_PATCH_MIN_CHARS && (now - lastPatchTime.get()) >= STREAM_PATCH_INTERVAL_MS);
|
||||
if (shouldPatch) {
|
||||
String fileUrlDomain = getPlatformBaseUrl(config.getTenantId());
|
||||
String content = ImOutputProcessor.processOutput(chunk.getText(),
|
||||
chunk.getConversationId(), config.getAgentId(), fileUrlDomain,
|
||||
config.getUserId(), config.getTenantId(), imFileShareService, computerFileApplicationService);
|
||||
String displayContent = buildReplyContent(content, quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
boolean ok = apiClient.updateInteractiveCard(outTrackId,
|
||||
DingtalkOpenApiClient.buildCardData(displayContent));
|
||||
if (ok) {
|
||||
lastPatchTime.set(now);
|
||||
lastPatchLength.set(currentLen);
|
||||
}
|
||||
}
|
||||
},
|
||||
e -> {
|
||||
log.error("DingTalk stream error: senderId={}", senderId, e);
|
||||
String errMsg = "执行异常: " + (e.getMessage() != null ? e.getMessage() : "未知错误");
|
||||
// 仅更新卡片,避免同时调用 sessionWebhook 导致重复回复
|
||||
apiClient.updateInteractiveCard(outTrackId,
|
||||
DingtalkOpenApiClient.buildCardData(buildReplyContent(errMsg, quoteDisplayMessage, senderNick, senderStaffId, conversationType)));
|
||||
},
|
||||
() -> log.debug("DingTalk stream done: senderId={}", senderId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式失败回退时,通过 sessionWebhook 一次性发送最终结果。
|
||||
* 注意:sessionWebhook 每次 POST 会生成新消息,无法更新同一条;为避免多次发卡片,仅在全量完成后发送一次。
|
||||
*/
|
||||
private void streamBySessionWebhook(String sessionWebhook, String senderId, String userMessage,
|
||||
String quoteDisplayMessage,
|
||||
List<AttachmentDto> attachments,
|
||||
String senderNick, String senderStaffId,
|
||||
String conversationType, String conversationId, DingtalkBotConfig config) {
|
||||
if (StringUtils.isBlank(sessionWebhook)) {
|
||||
log.warn("DingTalk sessionWebhook empty, cannot reply");
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicReference<String> finalContent = new AtomicReference<>("");
|
||||
AtomicReference<Long> finalConversationId = new AtomicReference<>();
|
||||
|
||||
dingtalkAgentApplicationService.executeAgentStream(
|
||||
senderId, userMessage, attachments, conversationType, conversationId,
|
||||
config.getTenantId(), config.getUserId(), config.getAgentId())
|
||||
.filter(chunk -> chunk != null && chunk.getText() != null)
|
||||
.subscribe(
|
||||
chunk -> {
|
||||
finalContent.set(chunk.getText());
|
||||
if (chunk.getConversationId() != null) {
|
||||
finalConversationId.set(chunk.getConversationId());
|
||||
}
|
||||
},
|
||||
e -> {
|
||||
log.error("DingTalk sessionWebhook stream error: senderId={}", senderId, e);
|
||||
String errMsg = "执行异常: " + (e.getMessage() != null ? e.getMessage() : "未知错误");
|
||||
replyBySessionWebhook(sessionWebhook, errMsg, quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
},
|
||||
() -> {
|
||||
String content = finalContent.get();
|
||||
if (StringUtils.isNotBlank(content)) {
|
||||
String fileUrlDomain = getPlatformBaseUrl(config.getTenantId());
|
||||
content = ImOutputProcessor.processOutput(content, finalConversationId.get(),
|
||||
config.getAgentId(), fileUrlDomain, config.getUserId(), config.getTenantId(),
|
||||
imFileShareService, computerFileApplicationService);
|
||||
replyBySessionWebhook(sessionWebhook, content, quoteDisplayMessage, senderNick, senderStaffId, conversationType);
|
||||
}
|
||||
log.debug("DingTalk sessionWebhook stream done: senderId={}", senderId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 sessionWebhook 发送 Markdown 消息。
|
||||
* 私聊:引用块仅显示原消息内容,不@用户;群聊:引用块含用户名,@用户。
|
||||
* 参考:<a href="https://open.dingtalk.com/document/development/enterprise-internal-robots-send-markdown-messages">企业内部机器人发送Markdown消息</a>
|
||||
*/
|
||||
private boolean replyBySessionWebhook(String sessionWebhook, String content, String userMessage,
|
||||
String senderNick, String senderStaffId, String conversationType) {
|
||||
if (StringUtils.isBlank(sessionWebhook) || content == null) {
|
||||
return false;
|
||||
}
|
||||
String finalContent = buildReplyContent(content, userMessage, senderNick, senderStaffId, conversationType);
|
||||
try {
|
||||
String title = extractMarkdownTitle(finalContent);
|
||||
Map<String, Object> markdown = Map.of("title", title, "text", finalContent);
|
||||
Map<String, Object> payload = new HashMap<>(Map.of("msgtype", "markdown", "markdown", markdown));
|
||||
// 群聊才 @ 用户,私聊不 @
|
||||
if ("2".equals(conversationType) && StringUtils.isNotBlank(senderStaffId)) {
|
||||
payload.put("at", Map.of("atUserIds", Collections.singletonList(senderStaffId), "isAtAll", false));
|
||||
}
|
||||
String json = JSON.toJSONString(payload);
|
||||
byte[] body = json.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(sessionWebhook).openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(10000);
|
||||
conn.getOutputStream().write(body);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code >= 200 && code < 300) {
|
||||
return true;
|
||||
}
|
||||
InputStream errStream = conn.getErrorStream();
|
||||
String resp = errStream != null
|
||||
? new String(StreamUtils.copyToByteArray(errStream), StandardCharsets.UTF_8)
|
||||
: "";
|
||||
log.warn("DingTalk sessionWebhook reply failed: code={}, resp={}", code, resp);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.error("DingTalk sessionWebhook reply error: url={}", sessionWebhook, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建回复内容,仿 AI 小钉效果。
|
||||
* 私聊:引用块仅显示原消息内容,不@用户。
|
||||
* 群聊:引用块含发送者昵称,@用户。
|
||||
*
|
||||
* @param conversationType "1" 单聊,"2" 群聊
|
||||
*/
|
||||
private String buildReplyContent(String content, String quoteDisplayMessage, String senderNick, String senderStaffId, String conversationType) {
|
||||
if (StringUtils.isBlank(quoteDisplayMessage)) return content;
|
||||
String quoted = quoteDisplayMessage.length() > 100 ? quoteDisplayMessage.substring(0, 97) + "..." : quoteDisplayMessage;
|
||||
quoted = quoted.replace("\r", "");
|
||||
String quotedEscaped = escapeHtml(quoted);
|
||||
String styleOpen = "<span style=\"font-size:12px;color:#999999\">";
|
||||
String styleClose = "</span>";
|
||||
String quotedLines = quotedEscaped.replace("\n", styleClose + "\n> " + styleOpen) + styleClose;
|
||||
|
||||
String ref;
|
||||
if ("1".equals(conversationType)) {
|
||||
// 私聊:引用块仅显示原消息内容,不显示用户名
|
||||
ref = "> " + styleOpen + quotedLines;
|
||||
} else {
|
||||
// 群聊:引用块含发送者昵称 + 原消息,@用户
|
||||
String displayName = StringUtils.isNotBlank(senderNick) ? senderNick : "用户";
|
||||
String line1 = "> " + styleOpen + escapeHtml(displayName) + styleClose;
|
||||
String line2 = "> " + styleOpen + quotedEscaped.replace("\n", styleClose + "\n> " + styleOpen) + styleClose;
|
||||
ref = line1 + "\n" + line2;
|
||||
}
|
||||
|
||||
if ("1".equals(conversationType)) {
|
||||
return ref + "\n\n" + content;
|
||||
}
|
||||
String atUser = StringUtils.isNotBlank(senderStaffId) ? senderStaffId : senderNick;
|
||||
if (StringUtils.isNotBlank(atUser)) {
|
||||
return ref + "\n\n@" + atUser + " " + content;
|
||||
}
|
||||
return ref + "\n\n" + content;
|
||||
}
|
||||
|
||||
private static String escapeHtml(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从内容提取 Markdown 标题(首屏展示,最长 128 字节)
|
||||
*/
|
||||
private String extractMarkdownTitle(String content) {
|
||||
if (StringUtils.isBlank(content)) return "AI 回复";
|
||||
String firstLine = content.split("[\r\n]+", 2)[0].trim();
|
||||
firstLine = firstLine.replaceAll("^#+\\s*", ""); // 去掉 markdown 标题符
|
||||
if (firstLine.isEmpty()) return "AI 回复";
|
||||
byte[] bytes = firstLine.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length <= 128) return firstLine;
|
||||
for (int len = Math.min(125, firstLine.length()); len > 0; len--) {
|
||||
String s = firstLine.substring(0, len);
|
||||
if (s.getBytes(StandardCharsets.UTF_8).length <= 125) return s + "...";
|
||||
}
|
||||
return "AI 回复";
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class DingtalkBotConfig {
|
||||
private String clientId;
|
||||
private String clientSecret;
|
||||
/** 机器人编码,在钉钉开放平台【消息推送】获取,与 AppKey 可能不同。不填则用 clientId。 */
|
||||
private String robotCode;
|
||||
private Long tenantId;
|
||||
private Long userId;
|
||||
private Long agentId;
|
||||
private String outputMode;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
package com.xspaceagi.im.web.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.xspaceagi.agent.core.adapter.application.AgentApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.config.AgentConfigDto;
|
||||
import com.xspaceagi.im.application.ImChannelConfigApplicationService;
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigResponse;
|
||||
import com.xspaceagi.im.application.dto.ImChannelStatisticsResponse;
|
||||
import com.xspaceagi.im.infra.dao.enitity.ImChannelConfig;
|
||||
import com.xspaceagi.im.web.dto.*;
|
||||
import com.xspaceagi.im.web.service.ImChannelTestService;
|
||||
import com.xspaceagi.im.web.util.ImDtoConvertor;
|
||||
import com.xspaceagi.system.sdk.permission.SpacePermissionService;
|
||||
import com.xspaceagi.system.spec.annotation.RequireResource;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import com.xspaceagi.system.spec.utils.I18nUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.xspaceagi.system.spec.enums.ResourceEnum.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/im-config/channel")
|
||||
@Slf4j
|
||||
@Tag(name = "IM 渠道配置")
|
||||
public class ImChannelController {
|
||||
|
||||
@Resource
|
||||
private SpacePermissionService spacePermissionService;
|
||||
@Resource
|
||||
private AgentApplicationService agentApplicationService;
|
||||
@Resource
|
||||
private ImChannelConfigApplicationService imChannelConfigApplicationService;
|
||||
@Resource
|
||||
private ImChannelTestService imChannelTestService;
|
||||
|
||||
@RequireResource(IM_CONFIG_QUERY_LIST)
|
||||
@PostMapping("/statistics")
|
||||
@Operation(summary = "统计IM渠道配置")
|
||||
public ReqResult<List<ImChannelStatisticsResponse>> statistics(@RequestBody ImChannelStatisticsRequest request) {
|
||||
if (request.getSpaceId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "空间ID");
|
||||
}
|
||||
spacePermissionService.checkSpaceUserPermission(request.getSpaceId());
|
||||
|
||||
List<ImChannelStatisticsResponse> response = imChannelConfigApplicationService.statistics(request.getSpaceId());
|
||||
|
||||
I18nUtil.replaceSystemMessage(response);
|
||||
return ReqResult.success(response);
|
||||
}
|
||||
|
||||
@RequireResource(IM_CONFIG_QUERY_LIST)
|
||||
@PostMapping("/list")
|
||||
@Operation(summary = "查询IM渠道配置列表")
|
||||
public ReqResult<List<ImChannelConfigResponse>> list(@RequestBody ImChannelConfigQueryRequest request) {
|
||||
if (request.getSpaceId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "空间ID");
|
||||
}
|
||||
spacePermissionService.checkSpaceUserPermission(request.getSpaceId());
|
||||
|
||||
// 将请求对象转换为查询条件
|
||||
ImChannelConfig query = new ImChannelConfig();
|
||||
query.setChannel(request.getChannel());
|
||||
query.setTargetType(request.getTargetType());
|
||||
query.setAgentId(request.getAgentId());
|
||||
query.setEnabled(request.getEnabled());
|
||||
query.setTargetId(request.getTargetId());
|
||||
query.setSpaceId(request.getSpaceId());
|
||||
|
||||
List<ImChannelConfig> configs = imChannelConfigApplicationService.list(query);
|
||||
if (CollectionUtils.isEmpty(configs)) {
|
||||
return ReqResult.success(null);
|
||||
}
|
||||
|
||||
// 收集所有智能体ID
|
||||
List<Long> agentIds = configs.stream()
|
||||
.map(ImChannelConfig::getAgentId)
|
||||
.filter(id -> id != null)
|
||||
.toList();
|
||||
|
||||
// 批量查询智能体信息
|
||||
Map<Long, AgentConfigDto> agentMap = Map.of();
|
||||
if (!agentIds.isEmpty()) {
|
||||
try {
|
||||
List<AgentConfigDto> agents = agentApplicationService.queryListByIds(agentIds);
|
||||
if (agents != null) {
|
||||
agentMap = agents.stream().collect(Collectors.toMap(AgentConfigDto::getId, Function.identity()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Batch query agents failed: agentIds={}", agentIds, e);
|
||||
}
|
||||
}
|
||||
final Map<Long, AgentConfigDto> finalAgentMap = agentMap;
|
||||
// 构建响应并填充智能体名称
|
||||
List<ImChannelConfigResponse> list = configs.stream()
|
||||
.map(config -> ImDtoConvertor.toResponse(config, finalAgentMap.get(config.getAgentId()))).toList();
|
||||
return ReqResult.success(list);
|
||||
}
|
||||
|
||||
@RequireResource(IM_CONFIG_QUERY_DETAIL)
|
||||
@GetMapping("/detail/{id}")
|
||||
@Operation(summary = "根据ID查询IM渠道配置")
|
||||
public ReqResult<ImChannelConfigResponse> getById(@PathVariable Long id) {
|
||||
ImChannelConfig config = imChannelConfigApplicationService.getById(id);
|
||||
if (config == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
|
||||
}
|
||||
spacePermissionService.checkSpaceUserPermission(config.getSpaceId());
|
||||
|
||||
AgentConfigDto agentConfigDto = agentApplicationService.queryById(config.getAgentId());
|
||||
ImChannelConfigResponse response = ImDtoConvertor.toResponse(config, agentConfigDto);
|
||||
return ReqResult.success(response);
|
||||
}
|
||||
|
||||
@RequireResource(IM_CONFIG_ADD)
|
||||
@PostMapping("/add")
|
||||
@Operation(summary = "添加IM渠道配置")
|
||||
public ReqResult<Void> add(@Valid @RequestBody ImChannelConfigSaveRequest request) {
|
||||
// 校验空间权限
|
||||
if (request.getSpaceId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "空间ID");
|
||||
}
|
||||
spacePermissionService.checkSpaceUserPermission(request.getSpaceId());
|
||||
|
||||
// 将请求对象转换为实体
|
||||
ImChannelConfig config = ImDtoConvertor.toEntity(request);
|
||||
|
||||
imChannelConfigApplicationService.add(config);
|
||||
return ReqResult.success(null);
|
||||
}
|
||||
|
||||
@RequireResource(IM_CONFIG_MODIFY)
|
||||
@PostMapping("/update")
|
||||
@Operation(summary = "修改IM渠道配置")
|
||||
public ReqResult<Void> update(@Valid @RequestBody ImChannelConfigSaveRequest request) {
|
||||
if (request.getId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "配置ID");
|
||||
}
|
||||
ImChannelConfig exist = imChannelConfigApplicationService.getById(request.getId());
|
||||
if (exist == null) {
|
||||
throw new IllegalArgumentException("Configuration does not exist");
|
||||
}
|
||||
spacePermissionService.checkSpaceUserPermission(exist.getSpaceId());
|
||||
|
||||
// 将请求对象转换为实体
|
||||
ImChannelConfig newConfig = ImDtoConvertor.toEntity(request);
|
||||
|
||||
imChannelConfigApplicationService.update(newConfig, exist);
|
||||
return ReqResult.success(null);
|
||||
}
|
||||
|
||||
// @RequireResource(IM_CONFIG_ENABLE)
|
||||
// @PostMapping("/updateEnabled")
|
||||
// @Operation(summary = "启用/禁用IM渠道配置")
|
||||
// public ReqResult<Void> updateEnabled(@Valid @RequestBody ImChannelConfigEnabledRequest request) {
|
||||
// if (request.getId() == null) {
|
||||
// throw new BizException("配置ID不能为空");
|
||||
// }
|
||||
// if (request.getEnabled() == null) {
|
||||
// throw new BizException("启用状态不能为空");
|
||||
// }
|
||||
// ImChannelConfig exist = imChannelConfigApplicationService.getById(request.getId());
|
||||
// if (exist == null) {
|
||||
// throw new IllegalArgumentException("Configuration does not exist");
|
||||
// }
|
||||
// spacePermissionService.checkSpaceUserPermission(exist.getSpaceId());
|
||||
//
|
||||
// exist.setEnabled(request.getEnabled());
|
||||
//
|
||||
// boolean success = imChannelConfigApplicationService.updateEnabled(exist);
|
||||
// if (!success) {
|
||||
// return ReqResult.error("操作失败");
|
||||
// }
|
||||
// return ReqResult.success(null);
|
||||
// }
|
||||
|
||||
@RequireResource(IM_CONFIG_DELETE)
|
||||
@PostMapping("/delete/{id}")
|
||||
@Operation(summary = "删除IM渠道配置")
|
||||
public ReqResult<Void> delete(@PathVariable Long id) {
|
||||
ImChannelConfig exist = imChannelConfigApplicationService.getById(id);
|
||||
if (exist == null) {
|
||||
throw new IllegalArgumentException("Configuration does not exist");
|
||||
}
|
||||
spacePermissionService.checkSpaceUserPermission(exist.getSpaceId());
|
||||
|
||||
boolean success = imChannelConfigApplicationService.delete(id);
|
||||
if (!success) {
|
||||
return ReqResult.error("删除失败");
|
||||
}
|
||||
return ReqResult.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/testConnection")
|
||||
@Operation(summary = "测试IM渠道配置连通性")
|
||||
public ReqResult<ImChannelConfigTestResponse> testConnection(@Valid @RequestBody ImChannelConfigTestRequest request) {
|
||||
// 执行连通性测试
|
||||
ImChannelConfigTestResponse response = imChannelTestService.testConnection(
|
||||
request.getChannel(),
|
||||
request.getTargetType(),
|
||||
request.getConfigData()
|
||||
);
|
||||
log.info("IM channel connectivity result: {}", JSON.toJSONString(response));
|
||||
if (response.getSuccess()) {
|
||||
return ReqResult.success();
|
||||
}
|
||||
return ReqResult.error(response.getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.xspaceagi.im.web.controller;
|
||||
|
||||
import com.xspaceagi.im.application.ImChannelConfigApplicationService;
|
||||
import com.xspaceagi.im.application.WechatIlinkPushApplicationService;
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigDto;
|
||||
import com.xspaceagi.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/im/wechat")
|
||||
@Slf4j
|
||||
@Tag(name = "微信")
|
||||
public class ImWechatController {
|
||||
|
||||
@Resource
|
||||
private WechatIlinkPushApplicationService wechatIlinkPushApplicationService;
|
||||
@Resource
|
||||
private ImChannelConfigApplicationService imChannelConfigApplicationService;
|
||||
|
||||
@PostMapping("/push-message")
|
||||
@Operation(summary = "向当前用户绑定的微信 iLink 会话推送文本消息")
|
||||
public ReqResult<Void> pushMessage(
|
||||
@RequestParam(required = false) String botId,
|
||||
@RequestParam String message) {
|
||||
if (StringUtils.isBlank(message)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "message");
|
||||
}
|
||||
UserDto userDto = (UserDto) RequestContext.get().getUser();
|
||||
if (userDto == null) {
|
||||
throw BizException.of(ErrorCodeEnum.UNAUTHORIZED, BizExceptionCodeEnum.userNotLoggedIn);
|
||||
}
|
||||
Long userId = userDto.getId();
|
||||
Long tenantId = userDto.getTenantId();
|
||||
ImChannelConfigDto configDto = imChannelConfigApplicationService.resolveWechatIlinkConfigIdForUserPush(tenantId, userId, botId);
|
||||
wechatIlinkPushApplicationService.pushText(configDto, message);
|
||||
return ReqResult.success();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.xspaceagi.im.web.controller;
|
||||
|
||||
import com.xspaceagi.im.application.wechat.WechatIlinkQrService;
|
||||
import com.xspaceagi.im.web.dto.ImWechatIlinkQrPollResponse;
|
||||
import com.xspaceagi.im.web.dto.ImWechatIlinkQrStartResponse;
|
||||
import com.xspaceagi.system.spec.annotation.RequireResource;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import static com.xspaceagi.system.spec.enums.ResourceEnum.IM_CONFIG_ADD;
|
||||
|
||||
/**
|
||||
* 微信 iLink:扫码获取二维码、查询上游状态(不落库)。保存请走 {@link com.xspaceagi.im.web.controller.ImChannelController#add}。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/im-config/wechat-ilink")
|
||||
@Slf4j
|
||||
@Tag(name = "微信 iLink")
|
||||
public class ImWechatIlinkController {
|
||||
|
||||
@Resource
|
||||
private WechatIlinkQrService wechatIlinkQrService;
|
||||
|
||||
@RequireResource(IM_CONFIG_ADD)
|
||||
@PostMapping("/qr/start")
|
||||
@Operation(summary = "创建扫码会话并返回二维码", description = "")
|
||||
public ReqResult<ImWechatIlinkQrStartResponse> qrStart() {
|
||||
WechatIlinkQrService.QrStartResult r = wechatIlinkQrService.startSession();
|
||||
return ReqResult.success(ImWechatIlinkQrStartResponse.builder()
|
||||
.sessionId(r.getSessionId())
|
||||
.qrcode(r.getQrcode())
|
||||
.qrcodeImgContent(r.getQrcodeImgContent())
|
||||
.build());
|
||||
}
|
||||
|
||||
@RequireResource(IM_CONFIG_ADD)
|
||||
@GetMapping("/qr/status")
|
||||
@Operation(summary = "查询扫码状态(可能长阻塞)", description = "返回 status、configData;落库请使用 POST /api/im-config/channel/add。")
|
||||
public ReqResult<ImWechatIlinkQrPollResponse> qrStatus(@RequestParam String sessionId) {
|
||||
if (StringUtils.isBlank(sessionId)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "sessionId");
|
||||
}
|
||||
WechatIlinkQrService.QrPollResult r = wechatIlinkQrService.pollStatus(sessionId);
|
||||
return ReqResult.success(ImWechatIlinkQrPollResponse.builder()
|
||||
.status(r.getStatus())
|
||||
.configData(r.getConfigData())
|
||||
.build());
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 钉钉附件下载码信息,含原始文件名(可选)用于保留扩展名。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DingtalkAttachmentCodeDto {
|
||||
|
||||
private String downloadCode;
|
||||
|
||||
private boolean picture;
|
||||
|
||||
/** 原始文件名,如 report.pdf,有则优先用其扩展名 */
|
||||
private String originalFileName;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 飞书附件下载结果:成功上传的附件 + 不支持的附件 key 列表。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FeishuAttachmentResultDto {
|
||||
|
||||
private List<AttachmentDto> attachments = new ArrayList<>();
|
||||
|
||||
private List<String> unsupportedKeys = new ArrayList<>();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 飞书群组信息
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FeishuChatDto {
|
||||
|
||||
@Schema(description = "群组 ID,用于发送消息")
|
||||
private String chatId;
|
||||
|
||||
@Schema(description = "群名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "群头像 URL")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "群描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "群主 ID")
|
||||
private String ownerId;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 飞书群组列表响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FeishuChatListRespDto {
|
||||
|
||||
@Schema(description = "群组列表")
|
||||
private List<FeishuChatDto> items;
|
||||
|
||||
@Schema(description = "分页标记,用于获取下一页")
|
||||
private String pageToken;
|
||||
|
||||
@Schema(description = "是否还有更多数据")
|
||||
private Boolean hasMore;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.xspaceagi.agent.core.adapter.application.IComputerFileApplicationService;
|
||||
import com.xspaceagi.im.web.service.ImFileShareService;
|
||||
import com.xspaceagi.im.web.util.ImOutputProcessor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
|
||||
/**
|
||||
* 飞书回复消息内容。
|
||||
* 支持消息类型:text(文本)、post(富文本)、image(图片)、interactive(互动卡片)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class FeishuReplyContent {
|
||||
|
||||
/**
|
||||
* 消息类型:text、post、image、interactive
|
||||
*/
|
||||
private String msgType;
|
||||
|
||||
/**
|
||||
* 消息内容 JSON 字符串,格式依 msg_type 不同而不同
|
||||
*/
|
||||
private String contentJson;
|
||||
|
||||
/**
|
||||
* 创建纯文本回复
|
||||
*/
|
||||
public static FeishuReplyContent text(String text) {
|
||||
if (text == null) {
|
||||
text = "";
|
||||
}
|
||||
String content = JSON.toJSONString(JSONObject.of("text", text));
|
||||
return FeishuReplyContent.builder()
|
||||
.msgType("text")
|
||||
.contentJson(content)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建富文本回复
|
||||
*/
|
||||
public static FeishuReplyContent post(String title, String body) {
|
||||
if (body == null) {
|
||||
body = "";
|
||||
}
|
||||
if (title == null) {
|
||||
title = "";
|
||||
}
|
||||
Object[] paragraph = new Object[]{JSONObject.of("tag", "text", "text", body)};
|
||||
Object[] content = new Object[]{paragraph};
|
||||
JSONObject zhCn = new JSONObject();
|
||||
zhCn.put("title", title);
|
||||
zhCn.put("content", content);
|
||||
JSONObject post = new JSONObject();
|
||||
post.put("zh_cn", zhCn);
|
||||
String contentJson = JSON.toJSONString(JSONObject.of("post", post));
|
||||
return FeishuReplyContent.builder()
|
||||
.msgType("post")
|
||||
.contentJson(contentJson)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Markdown 富文本回复(非互动卡片)。
|
||||
* <p>
|
||||
* 注意:这里使用 post 的 md 组件,避免 interactive 卡片。
|
||||
*/
|
||||
public static FeishuReplyContent postMarkdown(String title, String markdown) {
|
||||
if (markdown == null) {
|
||||
markdown = "";
|
||||
}
|
||||
if (title == null) {
|
||||
title = "";
|
||||
}
|
||||
Object[] paragraph = new Object[]{JSONObject.of("tag", "md", "text", markdown)};
|
||||
Object[] content = new Object[]{paragraph};
|
||||
JSONObject zhCn = new JSONObject();
|
||||
zhCn.put("title", title);
|
||||
zhCn.put("content", content);
|
||||
JSONObject post = new JSONObject();
|
||||
post.put("zh_cn", zhCn);
|
||||
String contentJson = JSON.toJSONString(JSONObject.of("post", post));
|
||||
return FeishuReplyContent.builder()
|
||||
.msgType("post")
|
||||
.contentJson(contentJson)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 卡片 header 主题色 */
|
||||
private static final String CARD_HEADER_TEMPLATE = "indigo";
|
||||
|
||||
/**
|
||||
* 创建简单文本卡片回复,支持流式 patch 更新。
|
||||
* 当 conversationId 不为空时,将 <file>filename</file> 替换为文件 URL,
|
||||
* 若有 file 标签则追加:点击此链接查看会话:域名/home/chat/{conversationId}/{agentId}
|
||||
*/
|
||||
public static FeishuReplyContent card(String text, boolean updateMulti, Long conversationId) {
|
||||
return card(text, updateMulti, conversationId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建简单文本卡片回复,支持流式 patch 更新。
|
||||
* 当 conversationId 不为空时,将 <file>filename</file> 替换为:源文件名:链接,
|
||||
* 若有 file 标签且 agentId 不为空,则追加:点击此链接查看会话:域名/home/chat/{conversationId}/{agentId}
|
||||
*/
|
||||
public static FeishuReplyContent card(String text, boolean updateMulti, Long conversationId, Long agentId) {
|
||||
return card(text, updateMulti, conversationId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建简单文本卡片回复,支持流式 patch 更新。
|
||||
* fileUrlDomain 为文件 URL 域名,为空时不进行 file 标签替换
|
||||
*/
|
||||
public static FeishuReplyContent card(String text, boolean updateMulti, Long conversationId, Long agentId, String fileUrlDomain) {
|
||||
return card(text, updateMulti, conversationId, agentId, fileUrlDomain, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建简单文本卡片回复,支持流式 patch 更新。
|
||||
* fileUrlDomain 为文件 URL 域名,为空时不进行 file 标签替换
|
||||
* 支持传入 userId、tenantId 和 fileShareService 以使用分享链接
|
||||
*/
|
||||
public static FeishuReplyContent card(String text, boolean updateMulti, Long conversationId, Long agentId, String fileUrlDomain,
|
||||
Long userId, Long tenantId, ImFileShareService fileShareService,
|
||||
IComputerFileApplicationService computerFileApplicationService) {
|
||||
// 使用统一的输出处理工具
|
||||
text = ImOutputProcessor.processOutput(text, conversationId, agentId, fileUrlDomain, userId, tenantId, fileShareService, computerFileApplicationService);
|
||||
|
||||
Object[] elements = new Object[]{buildMarkdownElement(text)};
|
||||
JSONObject config = new JSONObject();
|
||||
config.put("wide_screen_mode", true);
|
||||
if (updateMulti) {
|
||||
config.put("update_multi", true);
|
||||
}
|
||||
JSONObject card = new JSONObject();
|
||||
card.put("config", config);
|
||||
JSONObject header = new JSONObject();
|
||||
header.put("template", CARD_HEADER_TEMPLATE);
|
||||
header.put("title", JSONObject.of("tag", "plain_text", "content", ""));
|
||||
card.put("header", header);
|
||||
card.put("elements", elements);
|
||||
return FeishuReplyContent.builder()
|
||||
.msgType("interactive")
|
||||
.contentJson(JSON.toJSONString(card))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 markdown 组件。使用 tag=markdown 以支持完整 Markdown(含表格等),lark_md 仅支持部分语法。
|
||||
*/
|
||||
private static JSONObject buildMarkdownElement(String content) {
|
||||
JSONObject el = new JSONObject();
|
||||
el.put("tag", "markdown");
|
||||
el.put("content", content);
|
||||
return el;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IM 渠道配置启用/禁用请求
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道配置启用/禁用请求")
|
||||
public class ImChannelConfigEnabledRequest {
|
||||
|
||||
@NotNull(message = "Configuration ID is required")
|
||||
@Schema(description = "配置ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long id;
|
||||
|
||||
@NotNull(message = "Enabled flag is required")
|
||||
@Schema(description = "是否启用", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean enabled;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IM 渠道配置查询请求
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道配置查询请求")
|
||||
public class ImChannelConfigQueryRequest {
|
||||
|
||||
@Schema(description = "渠道类型:feishu/dingtalk/wework", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String channel;
|
||||
|
||||
@Schema(description = "目标类型:bot/app")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "目标唯一标识")
|
||||
private String targetId;
|
||||
|
||||
@Schema(description = "关联智能体ID")
|
||||
private Long agentId;
|
||||
|
||||
@Schema(description = "是否启用")
|
||||
private Boolean enabled;
|
||||
|
||||
@Schema(description = "空间ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long spaceId;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Builder.Default;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IM 渠道配置保存请求
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道配置保存请求")
|
||||
public class ImChannelConfigSaveRequest {
|
||||
|
||||
@Schema(description = "主键ID(修改时必填)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "空间主键ID(新增时必填)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long spaceId;
|
||||
|
||||
@NotBlank(message = "Channel type is required")
|
||||
@Schema(description = "渠道类型:feishu/dingtalk/wework/wechat_ilink")
|
||||
private String channel;
|
||||
|
||||
@NotBlank(message = "Target type is required")
|
||||
@Schema(description = "目标类型:bot/app")
|
||||
private String targetType;
|
||||
|
||||
@NotNull(message = "Bound agent ID is required")
|
||||
@Schema(description = "关联智能体ID")
|
||||
private Long agentId;
|
||||
|
||||
@Schema(description = "是否启用")
|
||||
@Default
|
||||
private Boolean enabled = true;
|
||||
|
||||
@Schema(description = "渠道专有配置(JSON 字符串)")
|
||||
private String configData;
|
||||
|
||||
@Schema(description = "输出方式:stream(流式输出)/once(一次性输出)")
|
||||
@Default
|
||||
private String outputMode = "once";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IM 渠道配置连通性测试请求
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道配置连通性测试请求")
|
||||
public class ImChannelConfigTestRequest {
|
||||
|
||||
@NotBlank(message = "Channel type is required")
|
||||
@Schema(description = "渠道类型:feishu/dingtalk/wework")
|
||||
private String channel;
|
||||
|
||||
@NotBlank(message = "Target type is required")
|
||||
@Schema(description = "目标类型:bot/app")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "渠道专有配置(JSON 字符串)")
|
||||
private String configData;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IM 渠道配置连通性测试响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道配置连通性测试响应")
|
||||
public class ImChannelConfigTestResponse {
|
||||
|
||||
@Schema(description = "是否连通")
|
||||
private Boolean success;
|
||||
|
||||
@Schema(description = "测试消息")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "返回的详细信息(如机器人名称等)")
|
||||
private String detail;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道配置统计请求")
|
||||
public class ImChannelStatisticsRequest {
|
||||
|
||||
@NotNull(message = "Space ID is required")
|
||||
@Schema(description = "空间ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long spaceId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
@Schema(description = "文件上传结果")
|
||||
@Data
|
||||
public class ImUploadResultDto implements Serializable {
|
||||
|
||||
@Schema(description = "文件完整的网络地址")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "文件唯一标识")
|
||||
private String key;
|
||||
|
||||
@Schema(description = "文件名称")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "文件类型")
|
||||
private String mimeType;
|
||||
|
||||
@Schema(description = "文件大小")
|
||||
private int size;
|
||||
|
||||
@Schema(description = "图片宽度")
|
||||
private int width;
|
||||
|
||||
@Schema(description = "图片高度")
|
||||
private int height;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "微信 iLink 扫码状态")
|
||||
public class ImWechatIlinkQrPollResponse {
|
||||
@Schema(description = "wait / scaned / confirmed / expired;落库请使用 POST /api/im-config/channel/add")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "仅 status=confirmed 时可能返回;与落库后 config_data 同结构的 JSON 预览(会话内 botToken/ilinkBotId 等齐全时才有值;含敏感字段,请妥善保管)")
|
||||
private String configData;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "微信 iLink 扫码开始响应")
|
||||
public class ImWechatIlinkQrStartResponse {
|
||||
private String sessionId;
|
||||
@Schema(description = "二维码链接;终端无法展示时请用浏览器打开此链接扫码(对齐 openclaw-weixin 1.0.3)")
|
||||
private String qrcode;
|
||||
@Schema(description = "二维码图片内容 URL;可与 qrcode 二选一在浏览器中打开扫码")
|
||||
private String qrcodeImgContent;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.xspaceagi.im.web.dto;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 企业微信附件下载结果:成功上传的附件 + 不支持的附件 key 列表。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WeworkAttachmentResultDto {
|
||||
|
||||
private List<AttachmentDto> attachments = new ArrayList<>();
|
||||
|
||||
private List<String> unsupportedKeys = new ArrayList<>();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.web.dto.DingtalkAttachmentCodeDto;
|
||||
import com.xspaceagi.im.web.dto.FeishuAttachmentResultDto;
|
||||
import com.xspaceagi.im.web.dto.ImUploadResultDto;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉附件服务:从钉钉消息中下载附件(通过 downloadCode),上传到项目存储,返回可访问的 URL。
|
||||
* 参考:<a href="https://open.dingtalk.com/document/orgapp/download-files-received-by-robot">下载机器人接收消息的文件内容</a>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DingtalkAttachmentService {
|
||||
|
||||
@Resource
|
||||
private ImFileUploadHelper fileUploadHelperService;
|
||||
|
||||
/**
|
||||
* 从钉钉消息中下载附件并上传到项目存储。
|
||||
*
|
||||
* @param apiClient 钉钉 API 客户端
|
||||
* @param attachmentCodes 附件信息列表(含 downloadCode、是否图片、原始文件名)
|
||||
* @param robotCode 机器人编码
|
||||
* @param robotCodeFallback 备用 robotCode,首次失败时重试,可为 null
|
||||
* @param tenantConfig 租户配置(可为 null)
|
||||
* @return 上传成功的附件列表 + 不支持的 key 列表
|
||||
*/
|
||||
public FeishuAttachmentResultDto downloadAndUpload(DingtalkOpenApiClient apiClient,
|
||||
List<DingtalkAttachmentCodeDto> attachmentCodes,
|
||||
String robotCode, String robotCodeFallback,
|
||||
TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
FeishuAttachmentResultDto result = new FeishuAttachmentResultDto();
|
||||
if (attachmentCodes == null || attachmentCodes.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (int i = 0; i < attachmentCodes.size(); i++) {
|
||||
DingtalkAttachmentCodeDto codeInfo = attachmentCodes.get(i);
|
||||
if (codeInfo == null || StringUtils.isBlank(codeInfo.getDownloadCode())) continue;
|
||||
|
||||
String code = codeInfo.getDownloadCode();
|
||||
String originalFileName = codeInfo.getOriginalFileName();
|
||||
|
||||
if (StringUtils.isBlank(code)) continue;
|
||||
|
||||
try {
|
||||
// 1. 从钉钉 API 下载文件
|
||||
byte[] bytes = apiClient.downloadMessageFile(code, robotCode);
|
||||
if (bytes == null && StringUtils.isNotBlank(robotCodeFallback)) {
|
||||
log.info("DingTalk attachment first download failed, trying robotCodeFallback={}", robotCodeFallback);
|
||||
bytes = apiClient.downloadMessageFile(code, robotCodeFallback);
|
||||
}
|
||||
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
log.warn("DingTalk attachment download failed: downloadCode={}", code);
|
||||
result.getUnsupportedKeys().add(code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 确定文件名和检测文件类型
|
||||
ImFileUploadHelper.UploadResult uploadResult;
|
||||
|
||||
if (StringUtils.isNotBlank(originalFileName)) {
|
||||
// 如果有原始文件名,使用它
|
||||
uploadResult = fileUploadHelperService.detectAndUploadByFileName(
|
||||
bytes, originalFileName, null, ImChannelEnum.DINGTALK, tenantConfig, uploadUserId);
|
||||
} else {
|
||||
// 如果没有原始文件名,使用完整检测(基于内容)
|
||||
uploadResult = fileUploadHelperService.detectAndUpload(
|
||||
bytes,
|
||||
null, // 无 HTTP 响应头文件名
|
||||
null, // 无 HTTP 响应头 Content-Type
|
||||
null, // 无 URL
|
||||
"file", // 原始类型(钉钉附件都是 file 类型)
|
||||
code, // 默认文件名(使用 downloadCode)
|
||||
ImChannelEnum.DINGTALK, // IM 渠道类型
|
||||
tenantConfig,
|
||||
uploadUserId
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadResult.isSuccess()) {
|
||||
ImUploadResultDto imUploadResult = uploadResult.getUploadResult();
|
||||
AttachmentDto attachment = fileUploadHelperService.createAttachmentDto(
|
||||
code,
|
||||
imUploadResult.getUrl(),
|
||||
imUploadResult.getFileName(),
|
||||
imUploadResult.getMimeType()
|
||||
);
|
||||
result.getAttachments().add(attachment);
|
||||
log.info("DingTalk attachment OK: downloadCode={}, url={}", code, attachment.getFileUrl());
|
||||
} else {
|
||||
log.warn("DingTalk attachment failed: downloadCode={}, error={}", code, uploadResult.getErrorMessage());
|
||||
result.getUnsupportedKeys().add(code);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("DingTalk attachment download/upload error: downloadCode={}", code, e);
|
||||
result.getUnsupportedKeys().add(code);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 钉钉 OpenAPI 客户端,用于获取 AccessToken、发送/更新互动卡片。
|
||||
* 统一使用高级版接口:发送 POST /v1.0/im/interactiveCards/send,更新 PUT /v1.0/im/interactiveCards。
|
||||
*/
|
||||
@Slf4j
|
||||
public class DingtalkOpenApiClient {
|
||||
|
||||
private static final String API_BASE = "https://api.dingtalk.com";
|
||||
private static final String OAPI_BASE = "https://oapi.dingtalk.com";
|
||||
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
/** 机器人编码,在钉钉开放平台【消息推送】获取。不填则用 clientId。 */
|
||||
private final String robotCode;
|
||||
private String cachedAccessToken;
|
||||
private long tokenExpireAt;
|
||||
/** 最后一次失败原因,便于 Controller 层记录 */
|
||||
private volatile String lastError;
|
||||
|
||||
public DingtalkOpenApiClient(String clientId, String clientSecret) {
|
||||
this(clientId, clientSecret, null);
|
||||
}
|
||||
|
||||
public DingtalkOpenApiClient(String clientId, String clientSecret, String robotCode) {
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.robotCode = StringUtils.isNotBlank(robotCode) ? robotCode : clientId;
|
||||
}
|
||||
|
||||
private synchronized String getAccessToken() {
|
||||
if (StringUtils.isNotBlank(cachedAccessToken) && System.currentTimeMillis() < tokenExpireAt) {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
try {
|
||||
String body = JSON.toJSONString(Map.of("appKey", clientId, "appSecret", clientSecret));
|
||||
PostResult result = postWithStatus(API_BASE + "/v1.0/oauth2/accessToken", body, null);
|
||||
JSONObject resp = result.body;
|
||||
if (resp != null) {
|
||||
String token = resp.getString("accessToken");
|
||||
if (token == null) token = resp.getString("access_token");
|
||||
if (StringUtils.isNotBlank(token)) {
|
||||
cachedAccessToken = token;
|
||||
int expiresIn = resp.getIntValue("expire");
|
||||
if (expiresIn <= 0) expiresIn = resp.getIntValue("expires_in");
|
||||
if (expiresIn <= 0) expiresIn = 7200;
|
||||
tokenExpireAt = System.currentTimeMillis() + (expiresIn - 300) * 1000L;
|
||||
return cachedAccessToken;
|
||||
}
|
||||
// 获取 token 失败,记录钉钉返回的错误
|
||||
String errCode = resp.getString("code");
|
||||
String errMsg = resp.getString("message");
|
||||
if (errMsg == null) errMsg = resp.getString("msg");
|
||||
log.error("DingTalk AccessToken failed: httpCode={}, code={}, message={}, body={}", result.httpCode, errCode, errMsg, resp);
|
||||
} else {
|
||||
log.error("DingTalk AccessToken failed: empty body, httpCode={}", result.httpCode);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("DingTalk AccessToken error: clientId={}", clientId, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送互动卡片(高级版 /v1.0/im/interactiveCards/send)
|
||||
*
|
||||
* @param conversationType "1" 单聊,"2" 群聊
|
||||
* @param conversationId 会话 ID,群聊时作为 openConversationId
|
||||
* @param senderStaffId 单聊时的接收者 userId
|
||||
* @param outTrackId 卡片唯一标识,用于后续更新时定位同一张卡片
|
||||
* @param cardData 卡片数据 JSON 字符串(StandardCard 格式)
|
||||
* @param robotCodeOverride 群聊时使用的 robotCode,可为空则用配置的。优先用 webhook 回调中的 robotCode。
|
||||
*/
|
||||
public boolean sendInteractiveCard(String conversationType, String conversationId,
|
||||
String senderStaffId, String outTrackId, String cardData, String robotCodeOverride) {
|
||||
String token = getAccessToken();
|
||||
if (StringUtils.isBlank(token)) {
|
||||
lastError = "AccessToken 为空";
|
||||
log.error("DingTalk interactive card failed: AccessToken empty; check clientId/clientSecret/app");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 高级版发送:/v1.0/im/interactiveCards/send
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("cardTemplateId", "StandardCard");
|
||||
body.put("outTrackId", outTrackId);
|
||||
// 高级版 cardData:cardParamMap.sys_full_json_obj 包裹完整 StandardCard JSON
|
||||
Map<String, Object> cardDataObj = new HashMap<>();
|
||||
Map<String, String> cardParamMap = new HashMap<>();
|
||||
cardParamMap.put("sys_full_json_obj", StringUtils.isNotBlank(cardData) ? cardData : "{}");
|
||||
cardDataObj.put("cardParamMap", cardParamMap);
|
||||
body.put("cardData", cardDataObj);
|
||||
// Webhook: "1"=单聊 "2"=群聊;API: 0=单聊 1=群聊
|
||||
int apiConvType = "1".equals(conversationType) ? 0 : 1;
|
||||
body.put("conversationType", apiConvType);
|
||||
// 单聊不填 robotCode;群聊需填 robotCode(优先用 webhook 回调中的,其次配置的)
|
||||
if (apiConvType == 1) {
|
||||
String rc = StringUtils.isNotBlank(robotCodeOverride) ? robotCodeOverride : robotCode;
|
||||
body.put("robotCode", rc);
|
||||
}
|
||||
|
||||
if (apiConvType == 1 && StringUtils.isNotBlank(conversationId)) {
|
||||
body.put("openConversationId", conversationId);
|
||||
} else {
|
||||
// 单聊:receiverUserIdList 填写接收者 userId 列表
|
||||
String userId = StringUtils.isNotBlank(senderStaffId) ? senderStaffId : conversationId;
|
||||
if (StringUtils.isNotBlank(userId)) {
|
||||
body.put("receiverUserIdList", Collections.singletonList(userId));
|
||||
}
|
||||
}
|
||||
|
||||
String jsonBody = JSON.toJSONString(body);
|
||||
PostResult postResult = postWithStatus(API_BASE + "/v1.0/im/interactiveCards/send", jsonBody, token);
|
||||
JSONObject resp = postResult.body;
|
||||
int httpCode = postResult.httpCode;
|
||||
|
||||
// 成功:HTTP 2xx 且响应包含 processQueryKey(可能在 result 下)
|
||||
boolean hasSuccess = false;
|
||||
if (resp != null) {
|
||||
hasSuccess = resp.containsKey("processQueryKey");
|
||||
if (!hasSuccess) {
|
||||
JSONObject result = resp.getJSONObject("result");
|
||||
hasSuccess = result != null && result.containsKey("processQueryKey");
|
||||
}
|
||||
}
|
||||
if (httpCode >= 200 && httpCode < 300 && hasSuccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String errCode = resp != null ? resp.getString("errorCode") : null;
|
||||
if (errCode == null && resp != null) errCode = resp.getString("code");
|
||||
if (errCode == null && resp != null && resp.get("errcode") != null) errCode = String.valueOf(resp.get("errcode"));
|
||||
String errMsg = resp != null ? resp.getString("errorMessage") : null;
|
||||
if (errMsg == null && resp != null) errMsg = resp.getString("message");
|
||||
if (errMsg == null && resp != null) errMsg = resp.getString("errmsg");
|
||||
lastError = String.format("httpCode=%d, errorCode=%s, errorMessage=%s, resp=%s", httpCode, errCode, errMsg, resp);
|
||||
log.error("DingTalk interactive card failed: {}", lastError);
|
||||
} catch (Exception e) {
|
||||
lastError = "exception: " + e.getMessage();
|
||||
log.error("DingTalk interactive card error: conversationType={}, conversationId={}", conversationType, conversationId, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 获取最后一次失败原因,用于 Controller 层记录 */
|
||||
public String getLastError() {
|
||||
return lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新互动卡片(高级版 PUT /v1.0/im/interactiveCards,实现打字机/流式效果)
|
||||
*
|
||||
* @param outTrackId 与发送时一致,用于标识要更新的卡片
|
||||
* @param cardData 卡片数据 JSON 字符串(StandardCard 格式)
|
||||
*/
|
||||
public boolean updateInteractiveCard(String outTrackId, String cardData) {
|
||||
String token = getAccessToken();
|
||||
if (StringUtils.isBlank(token)) return false;
|
||||
|
||||
try {
|
||||
// 高级版更新:outTrackId + cardData.cardParamMap.sys_full_json_obj
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("outTrackId", outTrackId);
|
||||
Map<String, Object> cardDataObj = new HashMap<>();
|
||||
Map<String, String> cardParamMap = new HashMap<>();
|
||||
cardParamMap.put("sys_full_json_obj", StringUtils.isNotBlank(cardData) ? cardData : "{}");
|
||||
cardDataObj.put("cardParamMap", cardParamMap);
|
||||
body.put("cardData", cardDataObj);
|
||||
|
||||
String jsonBody = JSON.toJSONString(body);
|
||||
PutResult putResult = putWithStatus(API_BASE + "/v1.0/im/interactiveCards", jsonBody, token);
|
||||
int code = putResult.httpCode;
|
||||
if (code >= 200 && code < 300) {
|
||||
return true;
|
||||
}
|
||||
String errCode = putResult.body != null ? putResult.body.getString("code") : null;
|
||||
if (errCode == null && putResult.body != null) errCode = putResult.body.getString("errorCode");
|
||||
String errMsg = putResult.body != null ? putResult.body.getString("message") : null;
|
||||
if (errMsg == null && putResult.body != null) errMsg = putResult.body.getString("errorMessage");
|
||||
log.error("DingTalk update card failed: httpCode={}, errorCode={}, errorMessage={}, resp={}",
|
||||
code, errCode, errMsg, putResult.body);
|
||||
} catch (Exception e) {
|
||||
log.error("DingTalk update card error: outTrackId={}", outTrackId, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询群名称
|
||||
* <p>
|
||||
* 当前优先尝试 v1.0 场景群接口(如不可用/无权限会返回错误,调用方需回退)。
|
||||
* 所需的权限:[qyapi_chat_read],点击链接开通:https://open-dev.dingtalk.com/appscope/apply?content=dinguopbejtkgmzi1egt%23qyapi_chat_read
|
||||
*/
|
||||
public String queryGroupName(String openConversationId) {
|
||||
if (StringUtils.isBlank(openConversationId)) {
|
||||
return null;
|
||||
}
|
||||
String token = getAccessToken();
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 使用旧版 topapi 查询群信息(文档体系稳定,且与你们现有 accessToken 获取方式一致)
|
||||
// 注意:旧版接口使用 access_token query 参数
|
||||
String url = OAPI_BASE + "/topapi/im/chat/scenegroup/get?access_token=" + token;
|
||||
String body = JSON.toJSONString(Map.of("open_conversation_id", openConversationId));
|
||||
PostResult topapi = postWithStatus(url, body, null);
|
||||
JSONObject resp = topapi != null ? topapi.body : null;
|
||||
if (resp == null) {
|
||||
return null;
|
||||
}
|
||||
// 旧版通常返回:{ "errcode":0, "errmsg":"ok", "result": { "title": "..."} }
|
||||
if (resp.getIntValue("errcode") != 0) {
|
||||
return null;
|
||||
}
|
||||
JSONObject result = resp.getJSONObject("result");
|
||||
String name = extractGroupName(result);
|
||||
return StringUtils.isNotBlank(name) ? name : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractGroupName(JSONObject obj) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
String name = obj.getString("name");
|
||||
if (StringUtils.isBlank(name)) {
|
||||
name = obj.getString("title");
|
||||
}
|
||||
if (StringUtils.isBlank(name)) {
|
||||
JSONObject r = obj.getJSONObject("result");
|
||||
if (r != null) {
|
||||
name = r.getString("name");
|
||||
if (StringUtils.isBlank(name)) {
|
||||
name = r.getString("title");
|
||||
}
|
||||
}
|
||||
}
|
||||
return StringUtils.isNotBlank(name) ? name : null;
|
||||
}
|
||||
|
||||
private static class PostResult {
|
||||
final int httpCode;
|
||||
final JSONObject body;
|
||||
|
||||
PostResult(int httpCode, JSONObject body) {
|
||||
this.httpCode = httpCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
private PostResult postWithStatus(String url, String body, String accessToken) throws Exception {
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
|
||||
if (StringUtils.isNotBlank(accessToken)) {
|
||||
conn.setRequestProperty("x-acs-dingtalk-access-token", accessToken);
|
||||
}
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(15000);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
String respStr = readResponse(conn, code);
|
||||
return new PostResult(code, JSON.parseObject(respStr != null ? respStr : "{}"));
|
||||
}
|
||||
|
||||
private static class PutResult {
|
||||
final int httpCode;
|
||||
final JSONObject body;
|
||||
|
||||
PutResult(int httpCode, JSONObject body) {
|
||||
this.httpCode = httpCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
private PutResult putWithStatus(String url, String body, String accessToken) throws Exception {
|
||||
HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||
conn.setRequestMethod("PUT");
|
||||
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
|
||||
conn.setRequestProperty("x-acs-dingtalk-access-token", accessToken);
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(15000);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
String respStr = readResponse(conn, code);
|
||||
return new PutResult(code, JSON.parseObject(respStr != null ? respStr : "{}"));
|
||||
}
|
||||
|
||||
private String readResponse(HttpURLConnection conn, int code) throws Exception {
|
||||
var is = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
|
||||
return is != null ? new String(StreamUtils.copyToByteArray(is), StandardCharsets.UTF_8) : "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载机器人接收消息中的文件
|
||||
*
|
||||
* @param downloadCode 消息中的 downloadCode / pictureDownloadCode / fileDownloadCode
|
||||
* @param robotCodeOverride 机器人编码,为空则用配置的
|
||||
* @return 文件字节数组,失败返回 null
|
||||
*/
|
||||
public byte[] downloadMessageFile(String downloadCode, String robotCodeOverride) {
|
||||
String token = getAccessToken();
|
||||
if (StringUtils.isBlank(token) || StringUtils.isBlank(downloadCode)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String rc = StringUtils.isNotBlank(robotCodeOverride) ? robotCodeOverride : robotCode;
|
||||
String body = JSON.toJSONString(Map.of("downloadCode", downloadCode, "robotCode", rc));
|
||||
PostResult result = postWithStatus(API_BASE + "/v1.0/robot/messageFiles/download", body, token);
|
||||
if (result.httpCode < 200 || result.httpCode >= 300 || result.body == null) {
|
||||
log.warn("DingTalk file download URL failed: httpCode={}, robotCode={}, downloadCodePrefix={}, body={}. " +
|
||||
"排查建议: 1) 在钉钉开放平台【消息推送】获取 robotCode 并配置; 2) 群聊不支持文件下载,请在单聊中测试",
|
||||
result.httpCode, rc, downloadCode != null && downloadCode.length() > 8 ? downloadCode.substring(0, 8) + "..." : downloadCode, result.body);
|
||||
return null;
|
||||
}
|
||||
String downloadUrl = result.body.getString("downloadUrl");
|
||||
if (StringUtils.isBlank(downloadUrl)) {
|
||||
log.warn("DingTalk downloadUrl empty: body={}", result.body);
|
||||
return null;
|
||||
}
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(downloadUrl).openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(60000);
|
||||
int code = conn.getResponseCode();
|
||||
if (code >= 200 && code < 300) {
|
||||
try (InputStream is = conn.getInputStream()) {
|
||||
return StreamUtils.copyToByteArray(is);
|
||||
}
|
||||
}
|
||||
log.warn("DingTalk download file failed: url={}, httpCode={}", downloadUrl, code);
|
||||
} catch (Exception e) {
|
||||
log.warn("DingTalk download file error: downloadCode={}", downloadCode, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 StandardCard 格式的 cardData JSON,用于 sys_full_json_obj。
|
||||
* 无头部,仅展示内容。支持流式更新:通过 updateInteractiveCard 全量替换 contents 实现打字机效果。
|
||||
*/
|
||||
public static String buildCardData(String content) {
|
||||
if (content == null) content = "";
|
||||
return "{\"config\":{\"autoLayout\":true,\"enableForward\":true},"
|
||||
+ "\"contents\":[{\"type\":\"markdown\",\"text\":\"" + escapeJson(content) + "\",\"id\":\"streaming_content\"}]}";
|
||||
}
|
||||
|
||||
private static String escapeJson(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.google.gson.JsonIOException;
|
||||
import com.lark.oapi.Client;
|
||||
import com.lark.oapi.service.im.v1.model.GetMessageResourceReq;
|
||||
import com.lark.oapi.service.im.v1.model.GetMessageResourceResp;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.web.dto.FeishuAttachmentResultDto;
|
||||
import com.xspaceagi.im.web.dto.ImUploadResultDto;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 飞书附件服务:从飞书消息中下载附件,通过 FileUploadService 上传到项目存储,返回可访问的 URL。
|
||||
* 参考飞书文档:https://open.feishu.cn/document/server-docs/im-v1/message/get-2
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FeishuAttachmentService {
|
||||
|
||||
@Resource
|
||||
private ImFileUploadHelper fileUploadHelperService;
|
||||
|
||||
/**
|
||||
* 从飞书消息中下载附件并上传到项目存储。
|
||||
*
|
||||
* @param appId 飞书 appId
|
||||
* @param appSecret 飞书 appSecret
|
||||
* @param messageId 消息 ID
|
||||
* @param fileKeys 附件 key 列表(image_key 或 file_key)
|
||||
* @param types 对应资源类型:"image" 或 "file"
|
||||
* @param tenantConfig 租户配置(可为 null,用于 file 存储时获取 siteUrl)
|
||||
* @return 上传成功的附件列表 + 不支持的附件 key 列表(如文件夹)
|
||||
*/
|
||||
public FeishuAttachmentResultDto downloadAndUpload(String appId, String appSecret, String messageId,
|
||||
List<String> fileKeys, List<String> types,
|
||||
TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
FeishuAttachmentResultDto result = new FeishuAttachmentResultDto();
|
||||
if (fileKeys == null || fileKeys.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Client client = Client.newBuilder(appId, appSecret).build();
|
||||
|
||||
for (int i = 0; i < fileKeys.size(); i++) {
|
||||
String fileKey = fileKeys.get(i);
|
||||
String type = (types != null && i < types.size()) ? types.get(i) : "file";
|
||||
|
||||
if (StringUtils.isBlank(fileKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 从飞书 SDK 下载文件
|
||||
GetMessageResourceReq req = GetMessageResourceReq.newBuilder()
|
||||
.messageId(messageId)
|
||||
.fileKey(fileKey)
|
||||
.type(type)
|
||||
.build();
|
||||
GetMessageResourceResp resp = client.im().v1().messageResource().get(req);
|
||||
|
||||
if (resp.getData() == null) {
|
||||
log.warn("Feishu attachment download failed: messageId={}, fileKey={}, type={}", messageId, fileKey, type);
|
||||
result.getUnsupportedKeys().add(fileKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream data = resp.getData();
|
||||
byte[] bytes = data.toByteArray();
|
||||
|
||||
// 2. 确定文件名和检测文件类型
|
||||
String fileName = resp.getFileName();
|
||||
ImFileUploadHelper.UploadResult uploadResult;
|
||||
|
||||
if (StringUtils.isNotBlank(fileName)) {
|
||||
// 如果飞书返回了文件名,使用它
|
||||
uploadResult = fileUploadHelperService.detectAndUploadByFileName(
|
||||
bytes, fileName, null, ImChannelEnum.FEISHU, tenantConfig, uploadUserId);
|
||||
} else {
|
||||
// 如果没有文件名,使用完整检测(不依赖文件名)
|
||||
uploadResult = fileUploadHelperService.detectAndUpload(
|
||||
bytes,
|
||||
null, // 无 HTTP 响应头文件名
|
||||
null, // 无 HTTP 响应头 Content-Type
|
||||
null, // 无 URL
|
||||
type, // 原始类型(image/file)
|
||||
fileKey, // 默认文件名
|
||||
ImChannelEnum.FEISHU, // IM 渠道类型
|
||||
tenantConfig,
|
||||
uploadUserId
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadResult.isSuccess()) {
|
||||
ImUploadResultDto imUploadResult = uploadResult.getUploadResult();
|
||||
AttachmentDto dto = fileUploadHelperService.createAttachmentDto(
|
||||
fileKey,
|
||||
imUploadResult.getUrl(),
|
||||
imUploadResult.getFileName(),
|
||||
imUploadResult.getMimeType()
|
||||
);
|
||||
result.getAttachments().add(dto);
|
||||
log.info("Feishu attachment OK: fileKey={}, url={}", fileKey, dto.getFileUrl());
|
||||
} else {
|
||||
log.warn("Feishu attachment failed: fileKey={}, error={}", fileKey, uploadResult.getErrorMessage());
|
||||
result.getUnsupportedKeys().add(fileKey);
|
||||
}
|
||||
|
||||
} catch (JsonIOException e) {
|
||||
// 飞书 SDK 在解析错误响应时可能抛出(如文件夹等不支持的类型)
|
||||
log.warn("Feishu attachment skipped (folder or unsupported): messageId={}, fileKey={}, type={}", messageId, fileKey, type);
|
||||
result.getUnsupportedKeys().add(fileKey);
|
||||
} catch (Exception e) {
|
||||
log.warn("Feishu attachment download/upload error: messageId={}, fileKey={}", messageId, fileKey, e);
|
||||
result.getUnsupportedKeys().add(fileKey);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.IComputerFileApplicationService;
|
||||
import com.xspaceagi.im.application.ImAgentOutputProcessService;
|
||||
import com.xspaceagi.im.web.util.ImOutputProcessor;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 与企微 {@code ImWeworkController} 中站点域名解析 + {@link ImOutputProcessor} 处理链一致。
|
||||
*/
|
||||
@Service
|
||||
public class ImAgentOutputProcessServiceImpl implements ImAgentOutputProcessService {
|
||||
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
@Resource
|
||||
private ImFileShareService imFileShareService;
|
||||
@Resource
|
||||
private IComputerFileApplicationService computerFileApplicationService;
|
||||
|
||||
@Override
|
||||
public String processAgentOutput(String text, Long conversationId, Long agentId, Long tenantId, Long userId, String imChannelCode) {
|
||||
TenantConfigDto tenantConfig = tenantId != null ? tenantConfigApplicationService.getTenantConfig(tenantId) : null;
|
||||
String domain = tenantConfig != null ? tenantConfig.getSiteUrl() : null;
|
||||
if (domain != null) {
|
||||
domain = domain.trim();
|
||||
if (!domain.startsWith("http://") && !domain.startsWith("https://")) {
|
||||
domain = "https://" + domain;
|
||||
}
|
||||
if (domain.endsWith("/")) {
|
||||
domain = domain.substring(0, domain.length() - 1);
|
||||
}
|
||||
}
|
||||
return ImOutputProcessor.processOutput(text, conversationId, agentId, domain, userId, tenantId,
|
||||
imFileShareService, computerFileApplicationService, imChannelCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.infra.enums.ImTargetTypeEnum;
|
||||
import com.xspaceagi.im.web.dto.ImChannelConfigTestResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* IM 渠道配置连通性测试服务
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ImChannelTestService {
|
||||
|
||||
/**
|
||||
* 测试 IM 渠道配置的连通性
|
||||
*
|
||||
* @param channel 渠道类型
|
||||
* @param targetType 目标类型
|
||||
* @param configData 配置数据(JSON字符串)
|
||||
* @return 测试结果
|
||||
*/
|
||||
public ImChannelConfigTestResponse testConnection(String channel, String targetType, String configData) {
|
||||
try {
|
||||
ImChannelEnum channelEnum = ImChannelEnum.fromCode(channel);
|
||||
ImTargetTypeEnum targetTypeEnum = ImTargetTypeEnum.fromCode(targetType);
|
||||
|
||||
if (channelEnum == ImChannelEnum.FEISHU) {
|
||||
return testFeishuConnection(configData);
|
||||
} else if (channelEnum == ImChannelEnum.DINGTALK) {
|
||||
return testDingtalkConnection(configData);
|
||||
} else if (channelEnum == ImChannelEnum.WEWORK) {
|
||||
return testWeworkConnection(targetTypeEnum, configData);
|
||||
} else {
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(false)
|
||||
.message("不支持的渠道类型")
|
||||
.build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("IM channel connectivity test failed: channel={}, targetType={}, error={}", channel, targetType, e.getMessage(), e);
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(false)
|
||||
.message(e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试飞书配置连通性
|
||||
* 通过调用获取 tenant_access_token 接口来验证 appId 和 appSecret 是否有效
|
||||
* verificationToken 和 encryptKey 如果配置了则验证格式,这两个参数用于接收 webhook 事件时的验证
|
||||
*/
|
||||
private ImChannelConfigTestResponse testFeishuConnection(String configData) {
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(configData);
|
||||
if (json == null) {
|
||||
return errorResponse("配置数据格式错误");
|
||||
}
|
||||
|
||||
String appId = json.getString("appId");
|
||||
String appSecret = json.getString("appSecret");
|
||||
String verificationToken = json.getString("verificationToken");
|
||||
String encryptKey = json.getString("encryptKey");
|
||||
|
||||
// 验证必要字段
|
||||
if (appId == null || appId.isEmpty()) {
|
||||
return errorResponse("appId 不能为空");
|
||||
}
|
||||
|
||||
if (appSecret == null || appSecret.isEmpty()) {
|
||||
return errorResponse("appSecret 不能为空");
|
||||
}
|
||||
|
||||
// 验证 webhook 安全参数格式(如果配置了的话)
|
||||
if ((StringUtils.isNotBlank(encryptKey) && StringUtils.isBlank(verificationToken))
|
||||
|| (StringUtils.isBlank(encryptKey) && StringUtils.isNotBlank(verificationToken))) {
|
||||
return errorResponse("encryptKey 和 verificationToken需要同时为空或同时不为空");
|
||||
}
|
||||
|
||||
// if (encryptKey != null && !encryptKey.isEmpty()) {
|
||||
// // 验证 encryptKey 格式(应该是43个字符的base64编码字符串,解码后32字节)
|
||||
// try {
|
||||
// // 检查字符串长度
|
||||
// if (encryptKey.length() != 43) {
|
||||
// return errorResponse("encryptKey 格式错误:应为43个字符的base64编码字符串");
|
||||
// }
|
||||
// // 验证是否为有效的 base64 编码
|
||||
// byte[] decoded = java.util.Base64.getDecoder().decode(encryptKey);
|
||||
// if (decoded.length != 32) {
|
||||
// return errorResponse("encryptKey 格式错误:解码后应为32字节(AES-256密钥)");
|
||||
// }
|
||||
// } catch (IllegalArgumentException e) {
|
||||
// return errorResponse("encryptKey 格式错误:不是有效的base64编码");
|
||||
// }
|
||||
// }
|
||||
|
||||
// 直接调用飞书 API 获取 tenant_access_token
|
||||
try {
|
||||
String url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
|
||||
String requestBody = String.format("{\"app_id\":\"%s\",\"app_secret\":\"%s\"}", appId, appSecret);
|
||||
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
|
||||
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
|
||||
|
||||
org.springframework.http.HttpEntity<String> entity = new org.springframework.http.HttpEntity<>(requestBody, headers);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
JSONObject resp = JSON.parseObject(response.getBody());
|
||||
if (resp != null && resp.getInteger("code") == 0) {
|
||||
String tenantAccessToken = resp.getString("tenant_access_token");
|
||||
if (StringUtils.isNotBlank(tenantAccessToken)) {
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(true)
|
||||
.message("飞书连通性测试成功")
|
||||
.detail("appId: " + appId)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
// 返回了非成功状态
|
||||
Integer code = resp != null ? resp.getInteger("code") : null;
|
||||
String msg = resp != null ? resp.getString("msg") : null;
|
||||
return errorResponse("获取 tenant_access_token 失败: code=" + code + ", msg=" + msg);
|
||||
}
|
||||
|
||||
return errorResponse("连接飞书服务器失败,HTTP状态码: " + response.getStatusCode());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Feishu API error", e);
|
||||
return errorResponse("连接飞书服务器异常: " + e.getMessage());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Feishu connectivity test failed", e);
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(false)
|
||||
.message(e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试钉钉配置连通性
|
||||
* 通过调用获取AccessToken接口来验证 clientId 和 clientSecret 是否有效
|
||||
* robotCode 是必填参数,但由于钉钉 API 限制,无法通过主动调用验证其正确性,需在实际发送消息时验证
|
||||
*/
|
||||
private ImChannelConfigTestResponse testDingtalkConnection(String configData) {
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(configData);
|
||||
if (json == null) {
|
||||
return errorResponse("配置数据格式错误");
|
||||
}
|
||||
|
||||
String clientId = json.getString("clientId");
|
||||
String clientSecret = json.getString("clientSecret");
|
||||
String robotCode = json.getString("robotCode");
|
||||
|
||||
// 验证必要字段
|
||||
if (clientId == null || clientId.isEmpty()) {
|
||||
return errorResponse("clientId 不能为空");
|
||||
}
|
||||
|
||||
if (clientSecret == null || clientSecret.isEmpty()) {
|
||||
return errorResponse("clientSecret 不能为空");
|
||||
}
|
||||
|
||||
// robotCode 是必填参数
|
||||
if (robotCode == null || robotCode.isEmpty()) {
|
||||
return errorResponse("robotCode 不能为空,请在钉钉开放平台【消息推送】获取");
|
||||
}
|
||||
|
||||
// 创建客户端并测试获取 AccessToken
|
||||
DingtalkOpenApiClient client = new DingtalkOpenApiClient(clientId, clientSecret, robotCode);
|
||||
|
||||
// 通过反射调用 getAccessToken() 方法进行连通性测试
|
||||
// 如果能够成功获取 AccessToken,说明凭证有效且网络连通
|
||||
Method method = DingtalkOpenApiClient.class.getDeclaredMethod("getAccessToken");
|
||||
method.setAccessible(true);
|
||||
String accessToken = (String) method.invoke(client);
|
||||
|
||||
if (accessToken != null && !accessToken.isEmpty()) {
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(true)
|
||||
.message("钉钉连通性测试成功")
|
||||
.detail("clientId: " + clientId + ", robotCode: " + robotCode + " | 注:robotCode 需在实际发送消息时验证")
|
||||
.build();
|
||||
} else {
|
||||
return errorResponse("无法获取 AccessToken,请检查 clientId 和 clientSecret 是否正确");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DingTalk connectivity test failed", e);
|
||||
String errorMsg = e.getMessage();
|
||||
// 提取更有用的错误信息
|
||||
if (errorMsg != null) {
|
||||
if (errorMsg.contains("invalid_client") || errorMsg.contains("40025")) {
|
||||
errorMsg = "clientId 或 clientSecret 不正确";
|
||||
} else if (errorMsg.contains("timeout") || errorMsg.contains("connect")) {
|
||||
errorMsg = "连接钉钉服务器超时,请检查网络";
|
||||
}
|
||||
}
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(false)
|
||||
.message(errorMsg != null ? errorMsg : e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试企业微信配置连通性
|
||||
* BOT类型:验证格式完整性(无API可调用)
|
||||
* APP类型:验证 corpId/corpSecret/agentId,token 和 encodingAesKey 需在实际接收 webhook 事件时验证
|
||||
* 验证 corpId 和 corpSecret(通过获取 access_token API)
|
||||
* 验证 agentId(通过调用 /cgi-bin/agent/get API,返回应用名称)
|
||||
*/
|
||||
private ImChannelConfigTestResponse testWeworkConnection(ImTargetTypeEnum targetType, String configData) {
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(configData);
|
||||
if (json == null) {
|
||||
return errorResponse("配置数据格式错误");
|
||||
}
|
||||
|
||||
if (targetType == ImTargetTypeEnum.BOT) {
|
||||
// 企业微信智能机器人 - 只验证格式,没有API可以直接测试
|
||||
String aibotId = json.getString("aibotId");
|
||||
String corpId = json.getString("corpId");
|
||||
String token = json.getString("token");
|
||||
String encodingAesKey = json.getString("encodingAesKey");
|
||||
|
||||
if (aibotId == null || corpId == null || token == null || encodingAesKey == null) {
|
||||
return errorResponse("企业微信机器人配置不完整,缺少必要字段");
|
||||
}
|
||||
|
||||
// 企业微信机器人配置验证:只验证格式完整性
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(true)
|
||||
.message("企业微信机器人配置格式验证成功")
|
||||
.detail("aibotId: " + aibotId + ", corpId: " + corpId)
|
||||
.build();
|
||||
|
||||
} else if (targetType == ImTargetTypeEnum.APP) {
|
||||
// 企业微信自建应用 - 验证 corpId/corpSecret/agentId
|
||||
String agentId = json.getString("agentId");
|
||||
String corpId = json.getString("corpId");
|
||||
String corpSecret = json.getString("corpSecret");
|
||||
String token = json.getString("token");
|
||||
String encodingAesKey = json.getString("encodingAesKey");
|
||||
|
||||
if (agentId == null || corpId == null || corpSecret == null || token == null || encodingAesKey == null) {
|
||||
return errorResponse("企业微信自建应用配置不完整,缺少必要字段");
|
||||
}
|
||||
|
||||
// 验证 encodingAesKey 格式(应该是43个字符的base64编码字符串,解码后32字节)
|
||||
try {
|
||||
// 检查字符串长度
|
||||
if (encodingAesKey.length() != 43) {
|
||||
return errorResponse("encodingAesKey 格式错误:应为43个字符的base64编码字符串");
|
||||
}
|
||||
// 验证是否为有效的 base64 编码
|
||||
byte[] decoded = Base64.getDecoder().decode(encodingAesKey);
|
||||
if (decoded.length != 32) {
|
||||
return errorResponse("encodingAesKey 格式错误:解码后应为32字节(AES-256密钥)");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return errorResponse("encodingAesKey 格式错误:不是有效的base64编码");
|
||||
}
|
||||
|
||||
// 测试获取 access_token
|
||||
try {
|
||||
String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + corpSecret;
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
String response = restTemplate.getForObject(url, String.class);
|
||||
|
||||
if (StringUtils.isNotBlank(response)) {
|
||||
JSONObject resp = JSON.parseObject(response);
|
||||
if (resp != null && resp.getIntValue("errcode") == 0) {
|
||||
String accessToken = resp.getString("access_token");
|
||||
if (StringUtils.isNotBlank(accessToken)) {
|
||||
// access_token 获取成功,继续验证 agentId
|
||||
try {
|
||||
String agentUrl = "https://qyapi.weixin.qq.com/cgi-bin/agent/get?access_token=" + accessToken + "&agentid=" + agentId;
|
||||
String agentResp = restTemplate.getForObject(agentUrl, String.class);
|
||||
|
||||
if (StringUtils.isNotBlank(agentResp)) {
|
||||
JSONObject agentJson = JSON.parseObject(agentResp);
|
||||
if (agentJson != null && agentJson.getIntValue("errcode") == 0) {
|
||||
// agentId 验证成功
|
||||
String appName = agentJson.getString("name");
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(true)
|
||||
.message("企业微信连通性测试成功")
|
||||
.detail("agentId: " + agentId + " (" + appName + "), corpId: " + corpId + " | 注:token 和 encodingAesKey 需在实际接收 webhook 事件时验证")
|
||||
.build();
|
||||
}
|
||||
|
||||
// agentId 验证失败
|
||||
Integer errcode = agentJson != null ? agentJson.getInteger("errcode") : null;
|
||||
String errmsg = agentJson != null ? agentJson.getString("errmsg") : null;
|
||||
if (errcode != null && errcode == 301053) {
|
||||
return errorResponse("agentId 不存在或无权限访问");
|
||||
}
|
||||
return errorResponse("验证 agentId 失败: errcode=" + errcode + ", errmsg=" + errmsg);
|
||||
}
|
||||
|
||||
return errorResponse("获取应用信息失败,响应为空");
|
||||
} catch (Exception e) {
|
||||
log.error("agentId validation error", e);
|
||||
// agentId 验证失败,但不影响整体测试结果(可能只是权限问题)
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(true)
|
||||
.message("企业微信连通性测试成功(部分验证)")
|
||||
.detail("agentId: " + agentId + ", corpId: " + corpId + " | 注:agentId 需要有相应权限,token 和 encodingAesKey 需在实际接收 webhook 事件时验证")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回了错误
|
||||
Integer errcode = resp != null ? resp.getInteger("errcode") : null;
|
||||
String errmsg = resp != null ? resp.getString("errmsg") : null;
|
||||
return errorResponse("获取 access_token 失败: errcode=" + errcode + ", errmsg=" + errmsg);
|
||||
}
|
||||
|
||||
return errorResponse("连接企业微信服务器失败,响应为空");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("WeCom API error", e);
|
||||
return errorResponse("连接企业微信服务器异常: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
return errorResponse("不支持的企业微信目标类型");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("WeCom connectivity test failed", e);
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(false)
|
||||
.message(e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private ImChannelConfigTestResponse errorResponse(String message) {
|
||||
return ImChannelConfigTestResponse.builder()
|
||||
.success(false)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.xspaceagi.agent.core.infra.rpc.UserShareRpcService;
|
||||
import com.xspaceagi.system.sdk.service.dto.UserShareDto;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* IM 文件分享服务
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ImFileShareService {
|
||||
|
||||
@Resource
|
||||
private UserShareRpcService userShareRpcService;
|
||||
|
||||
private static final Integer expireSeconds = 60 * 60;
|
||||
|
||||
/**
|
||||
* 创建文件分享
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @param tenantId 租户 ID
|
||||
* @param conversationId 会话 ID
|
||||
* @param filePath 文件路径
|
||||
* @return shareKey,创建失败返回 null
|
||||
*/
|
||||
public String createFileShare(Long userId, Long conversationId, String filePath, Long tenantId) {
|
||||
if (userId == null || tenantId == null || StringUtils.isBlank(filePath)) {
|
||||
log.warn("Create file share failed: userId, tenantId or filePath empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (conversationId == null) {
|
||||
log.warn("Create file share failed: conversationId empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 设置 RequestContext 的租户 ID
|
||||
RequestContext.setThreadTenantId(tenantId);
|
||||
|
||||
try {
|
||||
UserShareDto userShareDto = new UserShareDto();
|
||||
userShareDto.setType(UserShareDto.UserShareType.CONVERSATION);
|
||||
userShareDto.setTargetId(conversationId.toString());
|
||||
userShareDto.setUserId(userId);
|
||||
userShareDto.setContent(filePath);
|
||||
userShareDto.setExpire(Date.from(Instant.now().plusSeconds(expireSeconds)));
|
||||
|
||||
UserShareDto result = userShareRpcService.addOrUpdateUserShare(userShareDto);
|
||||
if (result != null && StringUtils.isNotBlank(result.getShareKey())) {
|
||||
return result.getShareKey();
|
||||
} else {
|
||||
log.warn("File share creation failed: empty result or shareKey");
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Create file share error: userId={}, conversationId={}, filePath={}", userId, conversationId, filePath, e);
|
||||
return null;
|
||||
} finally {
|
||||
// 清理 RequestContext
|
||||
RequestContext.remove();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.xspaceagi.im.application.util.MimeTypeUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.tika.Tika;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/**
|
||||
* 文件类型检测服务:统一的文件类型识别和MIME类型推断
|
||||
* 整合了企业微信、钉钉、飞书的文件检测逻辑
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImFileTypeDetector {
|
||||
|
||||
private final Tika tika = new Tika();
|
||||
|
||||
/**
|
||||
* 文件检测结果
|
||||
*/
|
||||
public static class FileDetectionResult {
|
||||
private String extension; // 文件扩展名(不含点),如 "csv"
|
||||
private String mimeType; // MIME类型,如 "text/csv"
|
||||
private String fileName; // 推荐的文件名(可为null)
|
||||
private String detectionSource; // 检测来源,用于日志
|
||||
|
||||
public FileDetectionResult(String extension, String mimeType, String detectionSource) {
|
||||
this.extension = extension;
|
||||
this.mimeType = mimeType;
|
||||
this.detectionSource = detectionSource;
|
||||
}
|
||||
|
||||
public FileDetectionResult(String extension, String mimeType, String fileName, String detectionSource) {
|
||||
this.extension = extension;
|
||||
this.mimeType = mimeType;
|
||||
this.fileName = fileName;
|
||||
this.detectionSource = detectionSource;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public String getExtension() { return extension; }
|
||||
public String getMimeType() { return mimeType; }
|
||||
public String getFileName() { return fileName; }
|
||||
public String getDetectionSource() { return detectionSource; }
|
||||
|
||||
public void setFileName(String fileName) { this.fileName = fileName; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测文件类型(综合多种检测方式)
|
||||
*
|
||||
* @param fileBytes 文件字节数组
|
||||
* @param headerFileName HTTP响应头的文件名(Content-Disposition)
|
||||
* @param headerContentType HTTP响应头的Content-Type
|
||||
* @param url 文件URL
|
||||
* @param originalType 原始类型提示(如 "image", "file")
|
||||
* @return 文件检测结果
|
||||
*/
|
||||
public FileDetectionResult detectFileType(byte[] fileBytes,
|
||||
String headerFileName,
|
||||
String headerContentType,
|
||||
String url,
|
||||
String originalType) {
|
||||
// 优先级1: HTTP响应头信息
|
||||
FileDetectionResult result = detectFromHttpHeaders(headerFileName, headerContentType);
|
||||
if (result != null) {
|
||||
log.info("File type from HTTP headers: ext={}, mime={}", result.getExtension(), result.getMimeType());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 优先级2: Magic Bytes检测
|
||||
result = detectFromMagicBytes(fileBytes);
|
||||
if (result != null) {
|
||||
log.info("File type from magic bytes: ext={}, mime={}", result.getExtension(), result.getMimeType());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 优先级3: 从URL提取扩展名
|
||||
result = detectFromUrl(url);
|
||||
if (result != null) {
|
||||
log.info("File type from URL: ext={}", result.getExtension());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 所有检测方式都失败,返回 null
|
||||
log.warn("All file type detections failed: originalType={}", originalType);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名推断MIME类型(供其他服务使用)
|
||||
* 注意:如果无法推断,返回 null,而不是默认值
|
||||
*/
|
||||
public String inferMimeTypeFromFileName(String fileName) {
|
||||
return MimeTypeUtils.inferMimeTypeFromFileName(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MIME 类型推断文件扩展名(含点)。
|
||||
*/
|
||||
public String getExtensionFromMimeType(String mimeType) {
|
||||
return MimeTypeUtils.getExtensionFromMimeType(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名提取扩展名
|
||||
*/
|
||||
public String extractExtensionFromFileName(String fileName) {
|
||||
if (StringUtils.isBlank(fileName)) {
|
||||
return null;
|
||||
}
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex <= 0 || dotIndex >= fileName.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
return fileName.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
* 从HTTP响应头检测文件类型
|
||||
*/
|
||||
private FileDetectionResult detectFromHttpHeaders(String headerFileName, String headerContentType) {
|
||||
// 优先从Content-Disposition的文件名中获取扩展名
|
||||
if (StringUtils.isNotBlank(headerFileName)) {
|
||||
String ext = extractExtensionFromFileName(headerFileName);
|
||||
if (StringUtils.isNotBlank(ext)) {
|
||||
String mimeType = MimeTypeUtils.inferMimeTypeFromFileName("." + ext);
|
||||
return new FileDetectionResult(ext, mimeType, headerFileName, "Content-Disposition");
|
||||
}
|
||||
}
|
||||
|
||||
// 从Content-Type映射扩展名
|
||||
if (StringUtils.isNotBlank(headerContentType)) {
|
||||
String ext = MimeTypeUtils.getExtensionFromMimeType(headerContentType);
|
||||
if (StringUtils.isNotBlank(ext) && !".bin".equals(ext)) {
|
||||
String mimeType = MimeTypeUtils.inferMimeTypeFromFileName(ext);
|
||||
return new FileDetectionResult(ext.substring(1), mimeType, "Content-Type");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Magic Bytes检测文件类型(使用 Tika)
|
||||
* Tika 内置了大量的 Magic Bytes 检测,比手动维护更可靠
|
||||
*/
|
||||
private FileDetectionResult detectFromMagicBytes(byte[] fileBytes) {
|
||||
if (fileBytes == null || fileBytes.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 Tika 检测文件类型
|
||||
// Tika 会检查文件头 Magic Bytes,支持 1000+ 种文件类型
|
||||
byte[] sample = fileBytes;
|
||||
if (fileBytes.length > 64 * 1024) {
|
||||
// 只检测前64KB,避免处理大文件
|
||||
sample = new byte[64 * 1024];
|
||||
System.arraycopy(fileBytes, 0, sample, 0, 64 * 1024);
|
||||
}
|
||||
|
||||
String mimeType = tika.detect(sample);
|
||||
String extension = MimeTypeUtils.getExtensionFromMimeType(mimeType);
|
||||
|
||||
if (StringUtils.isNotBlank(extension) && !".bin".equals(extension) && !"application/octet-stream".equals(mimeType)) {
|
||||
log.info("Tika detected type: ext={}, mimeType={}", extension, mimeType);
|
||||
return new FileDetectionResult(extension.substring(1), mimeType, "Tika");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Tika detection failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL检测文件类型
|
||||
*/
|
||||
private FileDetectionResult detectFromUrl(String url) {
|
||||
if (StringUtils.isBlank(url)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
URI uri = URI.create(url);
|
||||
String path = uri.getPath();
|
||||
if (StringUtils.isBlank(path)) {
|
||||
return null;
|
||||
}
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
String lastPart = lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
|
||||
int dotIndex = lastPart.lastIndexOf('.');
|
||||
if (dotIndex > 0 && dotIndex < lastPart.length() - 1) {
|
||||
String ext = lastPart.substring(dotIndex + 1).toLowerCase();
|
||||
String mimeType = MimeTypeUtils.inferMimeTypeFromFileName("." + ext);
|
||||
return new FileDetectionResult(ext, mimeType, "URL");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 忽略异常
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.file.application.service.FileManagementService;
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.web.dto.ImUploadResultDto;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.file.InMemoryMultipartFile;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 文件上传辅助服务:统一的文件类型检测和上传流程
|
||||
* 供企业微信、钉钉、飞书等平台服务使用
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImFileUploadHelper {
|
||||
|
||||
@Resource
|
||||
private ImFileTypeDetector imFileTypeDetector;
|
||||
|
||||
@Resource
|
||||
private FileManagementService fileManagementService;
|
||||
|
||||
/**
|
||||
* 检测文件类型并上传
|
||||
*
|
||||
* @param fileBytes 文件字节数组
|
||||
* @param headerFileName HTTP响应头的文件名(Content-Disposition)
|
||||
* @param headerContentType HTTP响应头的Content-Type
|
||||
* @param url 文件URL
|
||||
* @param originalType 原始类型提示(如 "image", "file")
|
||||
* @param defaultFileName 默认文件名(当无法从其他来源获取时使用)
|
||||
* @param imType IM 渠道类型
|
||||
* @param tenantConfig 租户配置
|
||||
* @return 上传结果
|
||||
*/
|
||||
public UploadResult detectAndUpload(byte[] fileBytes,
|
||||
String headerFileName,
|
||||
String headerContentType,
|
||||
String url,
|
||||
String originalType,
|
||||
String defaultFileName,
|
||||
ImChannelEnum imType,
|
||||
TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
if (fileBytes == null || fileBytes.length == 0) {
|
||||
log.warn("File bytes empty; cannot upload");
|
||||
return UploadResult.failed("文件字节数组为空");
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 检测文件类型
|
||||
ImFileTypeDetector.FileDetectionResult detection = imFileTypeDetector.detectFileType(fileBytes, headerFileName, headerContentType, url, originalType);
|
||||
|
||||
// 如果所有检测方式都失败,但有默认文件名,就使用默认文件名
|
||||
if (detection == null) {
|
||||
if (StringUtils.isNotBlank(defaultFileName)) {
|
||||
// 使用默认文件名,Content-Type 使用通用类型
|
||||
String fileName = defaultFileName;
|
||||
String contentType = "application/octet-stream";
|
||||
log.info("File type detection failed, using default fileName: fileName={}", fileName);
|
||||
|
||||
ImUploadResultDto uploadResult = uploadBytesWithUnifiedService(
|
||||
fileBytes, fileName, contentType, imType, tenantConfig, uploadUserId);
|
||||
|
||||
if (uploadResult != null && StringUtils.isNotBlank(uploadResult.getUrl())) {
|
||||
// 创建一个空的 detection 对象(不包含类型信息)
|
||||
return UploadResult.success(uploadResult, null);
|
||||
} else {
|
||||
return UploadResult.failed("文件上传失败");
|
||||
}
|
||||
} else {
|
||||
log.warn("File type detection failed and no default name; cannot upload: originalType={}", originalType);
|
||||
return UploadResult.failed("文件类型检测失败,且无默认文件名");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 确定文件名
|
||||
String fileName = determineFileName(detection, defaultFileName, originalType);
|
||||
|
||||
// 3. 确定Content-Type
|
||||
String contentType = detection.getMimeType();
|
||||
if (StringUtils.isBlank(contentType)) {
|
||||
contentType = "application/octet-stream"; // 最后的兜底
|
||||
}
|
||||
|
||||
log.info("Uploading file: fileName={}, contentType={}, source={}", fileName, contentType, detection.getDetectionSource());
|
||||
|
||||
// 4. 上传文件
|
||||
ImUploadResultDto uploadResult = uploadBytesWithUnifiedService(
|
||||
fileBytes, fileName, contentType, imType, tenantConfig, uploadUserId);
|
||||
|
||||
if (uploadResult != null && StringUtils.isNotBlank(uploadResult.getUrl())) {
|
||||
log.info("File upload OK: url={}, uploadFileName={}", uploadResult.getUrl(), uploadResult.getFileName());
|
||||
return UploadResult.success(uploadResult, detection);
|
||||
} else {
|
||||
log.warn("File upload failed");
|
||||
return UploadResult.failed("文件上传失败");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("File detect or upload error", e);
|
||||
return UploadResult.failed("文件检测或上传异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文件名上传,并允许调用方显式覆盖 MIME 类型。
|
||||
* 适用于已知文件名,且上游协议能够提供更准确 MIME 的场景。
|
||||
*
|
||||
* @param fileBytes 文件字节数组
|
||||
* @param originalFileName 原始文件名
|
||||
* @param mimeOverride MIME 类型覆盖(可为 null)
|
||||
* @param imType IM 渠道类型
|
||||
* @param tenantConfig 租户配置
|
||||
* @return 上传结果
|
||||
*/
|
||||
public UploadResult detectAndUploadByFileName(byte[] fileBytes,
|
||||
String originalFileName,
|
||||
String mimeOverride,
|
||||
ImChannelEnum imType,
|
||||
TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
if (fileBytes == null || fileBytes.length == 0) {
|
||||
return UploadResult.failed("文件字节数组为空");
|
||||
}
|
||||
|
||||
try {
|
||||
// 从文件名推断MIME类型
|
||||
String contentType = StringUtils.isNotBlank(mimeOverride)
|
||||
? mimeOverride
|
||||
: imFileTypeDetector.inferMimeTypeFromFileName(originalFileName);
|
||||
|
||||
// 如果无法推断,使用默认值
|
||||
if (StringUtils.isBlank(contentType)) {
|
||||
contentType = "application/octet-stream";
|
||||
}
|
||||
|
||||
log.info("MIME inferred from file name: fileName={}, contentType={}", originalFileName, contentType);
|
||||
|
||||
// 上传文件
|
||||
ImUploadResultDto uploadResult = uploadBytesWithUnifiedService(
|
||||
fileBytes, originalFileName, contentType, imType, tenantConfig, uploadUserId);
|
||||
|
||||
if (uploadResult != null && StringUtils.isNotBlank(uploadResult.getUrl())) {
|
||||
return UploadResult.success(uploadResult, null);
|
||||
} else {
|
||||
return UploadResult.failed("文件上传失败");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("File upload error: fileName={}", originalFileName, e);
|
||||
return UploadResult.failed("文件上传异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 MIME 类型生成默认文件名并上传。
|
||||
* 适用于没有原始文件名,但已知 MIME 类型的场景。
|
||||
*
|
||||
* @param fileBytes 文件字节数组
|
||||
* @param mimeOverride MIME 类型
|
||||
* @param originalType 原始类型提示(如 "image", "file")
|
||||
* @param imType IM 渠道类型
|
||||
* @param tenantConfig 租户配置
|
||||
* @return 上传结果
|
||||
*/
|
||||
public UploadResult uploadWithMimeOverride(byte[] fileBytes,
|
||||
String mimeOverride,
|
||||
String originalType,
|
||||
ImChannelEnum imType,
|
||||
TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
if (fileBytes == null || fileBytes.length == 0) {
|
||||
return UploadResult.failed("文件字节数组为空");
|
||||
}
|
||||
|
||||
String contentType = StringUtils.defaultIfBlank(mimeOverride, "application/octet-stream");
|
||||
String extension = imFileTypeDetector.getExtensionFromMimeType(contentType);
|
||||
String typePrefix = StringUtils.defaultIfBlank(originalType, "file");
|
||||
String fileName = typePrefix + "_" + System.currentTimeMillis() + extension;
|
||||
|
||||
try {
|
||||
log.info("Overwrite upload with MIME: fileName={}, contentType={}", fileName, contentType);
|
||||
ImUploadResultDto uploadResult = uploadBytesWithUnifiedService(
|
||||
fileBytes, fileName, contentType, imType, tenantConfig, uploadUserId);
|
||||
|
||||
if (uploadResult != null && StringUtils.isNotBlank(uploadResult.getUrl())) {
|
||||
return UploadResult.success(uploadResult, null);
|
||||
} else {
|
||||
return UploadResult.failed("文件上传失败");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("MIME overwrite upload error: fileName={}", fileName, e);
|
||||
return UploadResult.failed("文件上传异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建AttachmentDto
|
||||
*/
|
||||
public AttachmentDto createAttachmentDto(String fileKey, String fileUrl, String fileName, String mimeType) {
|
||||
AttachmentDto dto = new AttachmentDto();
|
||||
dto.setFileKey(fileKey);
|
||||
dto.setFileUrl(fileUrl);
|
||||
dto.setFileName(fileName);
|
||||
dto.setMimeType(mimeType);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确定文件名
|
||||
* 优先级:检测结果文件名 > 默认文件名 > 生成唯一文件名
|
||||
*/
|
||||
private String determineFileName(ImFileTypeDetector.FileDetectionResult detection,
|
||||
String defaultFileName,
|
||||
String originalType) {
|
||||
// 优先使用检测结果中的文件名(来自 HTTP 响应头的 Content-Disposition)
|
||||
if (StringUtils.isNotBlank(detection.getFileName())) {
|
||||
return detection.getFileName();
|
||||
}
|
||||
|
||||
// 使用默认文件名(调用者提供的文件名)
|
||||
if (StringUtils.isNotBlank(defaultFileName)) {
|
||||
return defaultFileName;
|
||||
}
|
||||
|
||||
// 兜底:生成唯一的文件名(基于时间戳和类型)
|
||||
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||
String typePrefix = StringUtils.isNotBlank(originalType) ? originalType : "file";
|
||||
return typePrefix + "_" + timestamp + "." + detection.getExtension();
|
||||
}
|
||||
|
||||
private ImUploadResultDto uploadBytesWithUnifiedService(byte[] bytes,
|
||||
String originalFilename,
|
||||
String contentType,
|
||||
ImChannelEnum imType,
|
||||
TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
String safeFileName = StringUtils.defaultIfBlank(originalFilename, "im_" + System.currentTimeMillis() + ".bin");
|
||||
String safeContentType = StringUtils.defaultIfBlank(contentType, "application/octet-stream");
|
||||
String targetType = imType != null ? "im/" + imType.getCode() : "im/default";
|
||||
|
||||
Long tenantId = tenantConfig != null ? tenantConfig.getTenantId() : null;
|
||||
RequestContext<?> requestContext = RequestContext.get();
|
||||
if (tenantId == null && requestContext != null) {
|
||||
tenantId = requestContext.getTenantId();
|
||||
}
|
||||
Long userId = uploadUserId != null ? uploadUserId : (requestContext != null ? requestContext.getUserId() : null);
|
||||
|
||||
InMemoryMultipartFile multipartFile = new InMemoryMultipartFile("file", safeFileName, safeContentType, bytes);
|
||||
FileRecordDomain fileRecord = fileManagementService.uploadFile(
|
||||
multipartFile, tenantId, userId, targetType, -1L, null, false);
|
||||
if (fileRecord == null || StringUtils.isBlank(fileRecord.getFileKey())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ImUploadResultDto dto = new ImUploadResultDto();
|
||||
dto.setFileName(safeFileName);
|
||||
dto.setKey(fileRecord.getFileKey());
|
||||
dto.setUrl(fileRecord.getFileUrl());
|
||||
dto.setMimeType(StringUtils.defaultIfBlank(fileRecord.getFileType(), safeContentType));
|
||||
dto.setSize(bytes.length);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传结果封装类
|
||||
*/
|
||||
public static class UploadResult {
|
||||
private boolean success;
|
||||
private String errorMessage;
|
||||
private ImUploadResultDto uploadResult;
|
||||
private ImFileTypeDetector.FileDetectionResult detection;
|
||||
|
||||
private UploadResult(boolean success, String errorMessage, ImUploadResultDto uploadResult, ImFileTypeDetector.FileDetectionResult detection) {
|
||||
this.success = success;
|
||||
this.errorMessage = errorMessage;
|
||||
this.uploadResult = uploadResult;
|
||||
this.detection = detection;
|
||||
}
|
||||
|
||||
public static UploadResult success(ImUploadResultDto uploadResult, ImFileTypeDetector.FileDetectionResult detection) {
|
||||
return new UploadResult(true, null, uploadResult, detection);
|
||||
}
|
||||
|
||||
public static UploadResult failed(String errorMessage) {
|
||||
return new UploadResult(false, errorMessage, null, null);
|
||||
}
|
||||
|
||||
// Getters
|
||||
public boolean isSuccess() { return success; }
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public ImUploadResultDto getUploadResult() { return uploadResult; }
|
||||
public ImFileTypeDetector.FileDetectionResult getDetection() { return detection; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.application.wechat.WechatIlinkAttachmentService;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 微信 iLink 入站附件服务:上传到 IM 统一存储,返回智能体可用的 URL。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WechatIlinkAttachmentServiceImpl implements WechatIlinkAttachmentService {
|
||||
|
||||
@Resource
|
||||
private ImFileUploadHelper fileUploadHelperService;
|
||||
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
|
||||
/**
|
||||
* 上传附件字节到存储
|
||||
*
|
||||
* @param bytes 文件字节数组
|
||||
* @param originalFilename 原始文件名
|
||||
* @param contentType 文件MIME类型(当前未使用,保留以兼容接口)
|
||||
* @param tenantId 租户ID
|
||||
* @return 上传失败时返回 null
|
||||
*/
|
||||
public AttachmentDto upload(byte[] bytes, String originalFilename, String contentType, Long tenantId, Long userId) {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
TenantConfigDto tenantConfig = tenantId != null ? tenantConfigApplicationService.getTenantConfig(tenantId) : null;
|
||||
|
||||
ImFileUploadHelper.UploadResult result;
|
||||
if (StringUtils.isNotBlank(originalFilename)) {
|
||||
// 如果有文件名,优先保留文件名,同时允许上游显式覆盖 MIME。
|
||||
result = fileUploadHelperService.detectAndUploadByFileName(
|
||||
bytes, originalFilename, contentType, ImChannelEnum.WECHAT_ILINK, tenantConfig, userId);
|
||||
} else if (StringUtils.isNotBlank(contentType)) {
|
||||
// 对于语音、视频等没有文件名的场景,使用 MIME 生成扩展名并上传。
|
||||
result = fileUploadHelperService.uploadWithMimeOverride(
|
||||
bytes, contentType, inferTypePrefix(contentType), ImChannelEnum.WECHAT_ILINK, tenantConfig, userId);
|
||||
} else {
|
||||
// 否则使用完整检测(基于内容)
|
||||
result = fileUploadHelperService.detectAndUpload(
|
||||
bytes, null, null, null, null, null, ImChannelEnum.WECHAT_ILINK, tenantConfig, userId);
|
||||
}
|
||||
|
||||
if (result.isSuccess()) {
|
||||
return fileUploadHelperService.createAttachmentDto(
|
||||
result.getUploadResult().getKey(),
|
||||
result.getUploadResult().getUrl(),
|
||||
result.getUploadResult().getFileName(),
|
||||
result.getUploadResult().getMimeType()
|
||||
);
|
||||
} else {
|
||||
log.warn("wechat ilink inbound upload failed, filename={}, error={}", originalFilename, result.getErrorMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String inferTypePrefix(String contentType) {
|
||||
if (StringUtils.isBlank(contentType)) {
|
||||
return "wechat_ilink";
|
||||
}
|
||||
if (contentType.startsWith("audio/")) {
|
||||
return "voice";
|
||||
}
|
||||
if (contentType.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (contentType.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
return "wechat_ilink";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package com.xspaceagi.im.web.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.web.dto.ImUploadResultDto;
|
||||
import com.xspaceagi.im.web.dto.WeworkAttachmentResultDto;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 企业微信附件服务:从企业微信消息中下载附件,通过 FileUploadService 上传到项目存储,返回可访问的 URL。
|
||||
* 参考企业微信文档:https://developer.work.weixin.qq.com/document/path/90237
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WeworkAttachmentService {
|
||||
|
||||
@Resource
|
||||
private ImFileUploadHelper fileUploadHelperService;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public WeworkAttachmentService() {
|
||||
this.restTemplate = new RestTemplate();
|
||||
this.restTemplate.getMessageConverters().add(new ByteArrayHttpMessageConverter());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从企业微信消息中下载附件并上传到项目存储
|
||||
*
|
||||
* @param corpId 企业微信企业ID
|
||||
* @param corpSecret 企业微信应用密钥
|
||||
* @param mediaId 附件 media_id(图片、文件等)
|
||||
* @param type 附件类型:"image" 或 "file"
|
||||
* @param tenantConfig 租户配置
|
||||
* @return 上传成功的附件列表 + 不支持的附件列表
|
||||
*/
|
||||
public WeworkAttachmentResultDto downloadAndUpload(String corpId, String corpSecret, String mediaId,
|
||||
String type, TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
WeworkAttachmentResultDto result = new WeworkAttachmentResultDto();
|
||||
|
||||
if (StringUtils.isBlank(mediaId)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 获取 access_token
|
||||
String accessToken = getAccessToken(corpId, corpSecret);
|
||||
if (StringUtils.isBlank(accessToken)) {
|
||||
log.warn("WeCom access_token failed: corpId={}", corpId);
|
||||
result.getUnsupportedKeys().add(mediaId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 下载附件
|
||||
byte[] fileBytes = downloadMedia(accessToken, mediaId);
|
||||
if (fileBytes == null || fileBytes.length == 0) {
|
||||
log.warn("WeCom attachment download failed: mediaId={}, type={}", mediaId, type);
|
||||
result.getUnsupportedKeys().add(mediaId);
|
||||
return result;
|
||||
}
|
||||
|
||||
log.info("WeCom attachment download OK: mediaId={}, type={}, size={}", mediaId, type, fileBytes.length);
|
||||
|
||||
// 3. 使用统一服务检测文件类型并上传
|
||||
ImFileUploadHelper.UploadResult uploadResult = fileUploadHelperService.detectAndUpload(
|
||||
fileBytes,
|
||||
null, // 无HTTP响应头文件名
|
||||
null, // 无HTTP响应头Content-Type
|
||||
null, // 无URL
|
||||
type, // 原始类型
|
||||
mediaId, // 默认文件名
|
||||
ImChannelEnum.WEWORK, // IM 渠道类型
|
||||
tenantConfig,
|
||||
uploadUserId
|
||||
);
|
||||
|
||||
if (uploadResult.isSuccess()) {
|
||||
ImUploadResultDto imUploadResult = uploadResult.getUploadResult();
|
||||
AttachmentDto dto = fileUploadHelperService.createAttachmentDto(
|
||||
mediaId,
|
||||
imUploadResult.getUrl(),
|
||||
imUploadResult.getFileName(),
|
||||
imUploadResult.getMimeType()
|
||||
);
|
||||
result.getAttachments().add(dto);
|
||||
log.info("WeCom attachment OK: mediaId={}, url={}, detectionSource={}",
|
||||
mediaId, dto.getFileUrl(), uploadResult.getDetection().getDetectionSource());
|
||||
} else {
|
||||
log.warn("WeCom attachment failed: mediaId={}, error={}", mediaId, uploadResult.getErrorMessage());
|
||||
result.getUnsupportedKeys().add(mediaId);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("WeCom attachment error: mediaId={}", mediaId, e);
|
||||
result.getUnsupportedKeys().add(mediaId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 下载附件并上传到项目存储
|
||||
* 企业微信智能机器人直接提供附件 URL(临时签名 URL)
|
||||
*
|
||||
* @param url 附件 URL
|
||||
* @param type 附件类型:"image" 或 "file"
|
||||
* @param aesKey 解密密钥(Base64 编码的 AES Key)
|
||||
* @param tenantConfig 租户配置
|
||||
* @return 上传成功的附件列表 + 不支持的附件列表
|
||||
*/
|
||||
public WeworkAttachmentResultDto downloadAndUploadFromUrl(String url, String type, String aesKey, TenantConfigDto tenantConfig,
|
||||
Long uploadUserId) {
|
||||
WeworkAttachmentResultDto result = new WeworkAttachmentResultDto();
|
||||
|
||||
if (StringUtils.isBlank(url)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 从 URL 下载附件(加密数据)
|
||||
DownloadResult downloadResult = downloadFromUrl(url);
|
||||
byte[] encryptedBytes = downloadResult != null ? downloadResult.bytes : null;
|
||||
if (encryptedBytes == null || encryptedBytes.length == 0) {
|
||||
log.warn("WeCom attachment download failed: url={}, type={}", url, type);
|
||||
result.getUnsupportedKeys().add(url);
|
||||
return result;
|
||||
}
|
||||
|
||||
String headerContentType = downloadResult != null ? downloadResult.contentType : null;
|
||||
String headerFileName = downloadResult != null ? downloadResult.fileName : null;
|
||||
|
||||
log.info("WeCom encrypted attachment download OK: url={}, type={}, size={}", url, type, encryptedBytes.length);
|
||||
log.info("First 16 bytes of ciphertext: {}", bytesToHex(encryptedBytes, Math.min(16, encryptedBytes.length)));
|
||||
|
||||
// 2. 尝试解密附件数据(如果可能)
|
||||
byte[] fileBytes = null;
|
||||
if (StringUtils.isBlank(aesKey)) {
|
||||
// 自建应用回调的 PicUrl 通常是明文可下载图片,无需 AES 解密
|
||||
fileBytes = encryptedBytes;
|
||||
log.info("WeCom attachment uses plain bytes (no aesKey): url={}, type={}, size={}", url, type, fileBytes.length);
|
||||
} else {
|
||||
try {
|
||||
// 企业微信智能机器人的图片可能是加密的,但也可能只是特殊格式
|
||||
// 先尝试解密
|
||||
fileBytes = decryptWeworkData(encryptedBytes, aesKey);
|
||||
log.info("WeCom attachment decrypt OK: rawSize={}, decryptedSize={}", encryptedBytes.length, fileBytes.length);
|
||||
log.info("First 16 bytes after decrypt: {}", bytesToHex(fileBytes, Math.min(16, fileBytes.length)));
|
||||
} catch (Exception e) {
|
||||
log.warn("WeCom attachment decrypt failed: url={}, error: {}", url, e.getMessage());
|
||||
// 有些文件(比如普通附件)可能本身并不需要解密;
|
||||
// 解密失败时,尝试把加密字节当成明文继续识别与上传(仅 type=file 才这样做)。
|
||||
if ("file".equals(type)) {
|
||||
fileBytes = encryptedBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果解密失败或数据为空,直接返回失败
|
||||
if (fileBytes == null || fileBytes.length == 0) {
|
||||
log.warn("WeCom attachment decrypted empty: url={}", url);
|
||||
result.getUnsupportedKeys().add(url);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. 使用统一服务检测文件类型并上传
|
||||
ImFileUploadHelper.UploadResult uploadResult = fileUploadHelperService.detectAndUpload(
|
||||
fileBytes,
|
||||
headerFileName, // Content-Disposition中的文件名
|
||||
headerContentType, // Content-Type
|
||||
url, // 文件URL
|
||||
type, // 原始类型
|
||||
"wework_attachment", // 默认文件名
|
||||
ImChannelEnum.WEWORK, // IM 渠道类型
|
||||
tenantConfig,
|
||||
uploadUserId
|
||||
);
|
||||
|
||||
if (uploadResult.isSuccess()) {
|
||||
ImUploadResultDto imUploadResult = uploadResult.getUploadResult();
|
||||
AttachmentDto dto = fileUploadHelperService.createAttachmentDto(
|
||||
url,
|
||||
imUploadResult.getUrl(),
|
||||
imUploadResult.getFileName(),
|
||||
imUploadResult.getMimeType()
|
||||
);
|
||||
result.getAttachments().add(dto);
|
||||
log.info("WeCom attachment OK: url={}, finalUrl={}, detectionSource={}",
|
||||
url, dto.getFileUrl(), uploadResult.getDetection().getDetectionSource());
|
||||
} else {
|
||||
log.warn("WeCom attachment failed: url={}, error={}", url, uploadResult.getErrorMessage());
|
||||
result.getUnsupportedKeys().add(url);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("WeCom attachment handling error: url={}", url, e);
|
||||
result.getUnsupportedKeys().add(url);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 下载文件内容
|
||||
*/
|
||||
private static class DownloadResult {
|
||||
private byte[] bytes;
|
||||
private String contentType;
|
||||
private String fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 下载文件内容,并尽量返回 HTTP 头信息作为辅助识别。
|
||||
*/
|
||||
private DownloadResult downloadFromUrl(String url) {
|
||||
try {
|
||||
|
||||
// 使用 URI 避免重复编码(企业微信URL已经包含编码的签名参数)
|
||||
URI uri = URI.create(url);
|
||||
|
||||
// 设置请求头,避免被COS服务器拒绝
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
headers.set("Accept", "*/*");
|
||||
|
||||
HttpEntity<?> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<byte[]> response = restTemplate.exchange(
|
||||
uri,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
byte[].class
|
||||
);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||
DownloadResult result = new DownloadResult();
|
||||
result.bytes = response.getBody();
|
||||
result.contentType = response.getHeaders() != null ? (response.getHeaders().getContentType() != null
|
||||
? response.getHeaders().getContentType().toString()
|
||||
: response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)) : null;
|
||||
|
||||
String contentDisposition = response.getHeaders() != null ? response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION) : null;
|
||||
result.fileName = extractFileNameFromContentDisposition(contentDisposition);
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("WeCom attachment download error: url={}", url, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractFileNameFromContentDisposition(String contentDisposition) {
|
||||
if (StringUtils.isBlank(contentDisposition)) {
|
||||
return null;
|
||||
}
|
||||
String cd = contentDisposition.trim();
|
||||
String lower = cd.toLowerCase();
|
||||
|
||||
// filename*=UTF-8''xxx
|
||||
int idxStar = lower.indexOf("filename*=");
|
||||
String filePart = null;
|
||||
if (idxStar >= 0) {
|
||||
filePart = cd.substring(idxStar + "filename*=".length()).trim();
|
||||
} else {
|
||||
// filename=xxx
|
||||
int idx = lower.indexOf("filename=");
|
||||
if (idx < 0) {
|
||||
return null;
|
||||
}
|
||||
filePart = cd.substring(idx + "filename=".length()).trim();
|
||||
}
|
||||
|
||||
// 截断掉后续参数
|
||||
int semicolon = filePart.indexOf(';');
|
||||
if (semicolon >= 0) {
|
||||
filePart = filePart.substring(0, semicolon).trim();
|
||||
}
|
||||
|
||||
// 去掉引号
|
||||
if (filePart.startsWith("\"") && filePart.endsWith("\"") && filePart.length() >= 2) {
|
||||
filePart = filePart.substring(1, filePart.length() - 1);
|
||||
}
|
||||
|
||||
// 去掉 RFC 5987 前缀:UTF-8''xxx
|
||||
int dd = filePart.indexOf("''");
|
||||
if (dd >= 0 && dd + 2 < filePart.length()) {
|
||||
filePart = filePart.substring(dd + 2);
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解码 percent-encoding
|
||||
return java.net.URLDecoder.decode(filePart, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
return filePart;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企业微信 access_token
|
||||
*/
|
||||
private String getAccessToken(String corpId, String corpSecret) {
|
||||
try {
|
||||
String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + corpSecret;
|
||||
String response = restTemplate.getForObject(url, String.class);
|
||||
|
||||
if (StringUtils.isNotBlank(response)) {
|
||||
// 解析 JSON 响应:{"errcode":0,"errmsg":"ok","access_token":"xxx","expires_in":7200}
|
||||
JSONObject json = parseJson(response);
|
||||
if (json != null && json.getIntValue("errcode") == 0) {
|
||||
return json.getString("access_token");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Exception fetching WeCom access_token", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载企业微信临时素材
|
||||
*/
|
||||
private byte[] downloadMedia(String accessToken, String mediaId) {
|
||||
try {
|
||||
String url = "https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=" + accessToken + "&media_id=" + mediaId;
|
||||
|
||||
ResponseEntity<byte[]> response = restTemplate.exchange(
|
||||
url,
|
||||
HttpMethod.GET,
|
||||
null,
|
||||
byte[].class
|
||||
);
|
||||
|
||||
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
|
||||
return response.getBody();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("WeCom attachment download error: mediaId={}", mediaId, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 JSON 解析(避免引入额外依赖)
|
||||
*/
|
||||
private JSONObject parseJson(String json) {
|
||||
try {
|
||||
return JSON.parseObject(json);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密企业微信附件数据
|
||||
* 使用 AES-256-CBC 模式
|
||||
* 根据官方文档:IV 取 AESKey 的前16字节,填充方式为 PKCS#7
|
||||
*/
|
||||
private byte[] decryptWeworkData(byte[] encryptedData, String aesKey) throws Exception {
|
||||
// 1. Base64 解码 AESKey(企业微信的 AESKey 长度为 43 字符,需要添加 = 填充)
|
||||
String paddedAesKey = aesKey;
|
||||
if (paddedAesKey.length() % 4 != 0) {
|
||||
paddedAesKey = paddedAesKey + "=".repeat(4 - (paddedAesKey.length() % 4));
|
||||
}
|
||||
byte[] keyBytes = Base64.getDecoder().decode(paddedAesKey);
|
||||
log.info("AES key decode OK, rawLen: {}, decodedLen: {} bytes", aesKey.length(), keyBytes.length);
|
||||
log.info("AES Key (Hex): {}", bytesToHex(keyBytes, keyBytes.length));
|
||||
|
||||
// 2. 提取 IV:**取 AESKey 的前16字节**(不是加密数据的前16字节!)
|
||||
byte[] iv = new byte[16];
|
||||
System.arraycopy(keyBytes, 0, iv, 0, 16);
|
||||
log.info("IV (first 16 bytes of AESKey): {}", bytesToHex(iv, 16));
|
||||
|
||||
// 3. 所有加密数据都是密文(不需要跳过前16字节)
|
||||
byte[] cipherText = encryptedData;
|
||||
log.info("Ciphertext length: {} bytes", cipherText.length);
|
||||
|
||||
// 4. 先尝试 PKCS5Padding 解密
|
||||
try {
|
||||
byte[] result = decryptWithPadding(keyBytes, iv, cipherText, "AES/CBC/PKCS5Padding");
|
||||
log.info("PKCS5Padding decrypt OK, plaintext length: {} bytes", result.length);
|
||||
log.info("First 16 bytes after decrypt: {}", bytesToHex(result, Math.min(16, result.length)));
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.warn("PKCS5Padding decrypt failed: {}, trying NoPadding", e.getMessage());
|
||||
// 如果 PKCS5Padding 失败,尝试 NoPadding
|
||||
try {
|
||||
byte[] result = decryptWithNoPadding(keyBytes, iv, cipherText);
|
||||
log.info("NoPadding decrypt OK, plaintext length: {} bytes", result.length);
|
||||
log.info("First 16 bytes after decrypt: {}", bytesToHex(result, Math.min(16, result.length)));
|
||||
return result;
|
||||
} catch (Exception e2) {
|
||||
log.error("NoPadding decrypt also failed: {}", e2.getMessage());
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定填充方式解密
|
||||
*/
|
||||
private byte[] decryptWithPadding(byte[] keyBytes, byte[] iv, byte[] cipherText, String transformation) throws Exception {
|
||||
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
IvParameterSpec ivSpec = new IvParameterSpec(iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
|
||||
|
||||
return cipher.doFinal(cipherText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 备用解密方法:使用 NoPadding,避免填充错误
|
||||
* 解密后需要手动去除 PKCS#7 填充
|
||||
*/
|
||||
private byte[] decryptWithNoPadding(byte[] keyBytes, byte[] iv, byte[] cipherText) throws Exception {
|
||||
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
IvParameterSpec ivSpec = new IvParameterSpec(iv);
|
||||
|
||||
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
|
||||
|
||||
byte[] decrypted = cipher.doFinal(cipherText);
|
||||
|
||||
// 手动去除 PKCS#7 填充
|
||||
// PKCS#7 填充:每个填充字节的值等于填充字节数
|
||||
if (decrypted.length > 0) {
|
||||
int padLength = decrypted[decrypted.length - 1] & 0xFF;
|
||||
if (padLength > 0 && padLength <= 16 && decrypted.length >= padLength) {
|
||||
// 验证填充是否正确
|
||||
boolean validPadding = true;
|
||||
for (int i = decrypted.length - padLength; i < decrypted.length; i++) {
|
||||
if ((decrypted[i] & 0xFF) != padLength) {
|
||||
validPadding = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (validPadding) {
|
||||
byte[] unpadded = new byte[decrypted.length - padLength];
|
||||
System.arraycopy(decrypted, 0, unpadded, 0, unpadded.length);
|
||||
log.info("Stripped PKCS#7 padding: {} bytes", padLength);
|
||||
return unpadded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数组转十六进制字符串(用于调试)
|
||||
*/
|
||||
private String bytesToHex(byte[] bytes, int length) {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return "";
|
||||
}
|
||||
int len = Math.min(length, bytes.length);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < len; i++) {
|
||||
sb.append(String.format("%02X ", bytes[i]));
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.xspaceagi.im.web.util;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.config.AgentConfigDto;
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigResponse;
|
||||
import com.xspaceagi.im.infra.dao.enitity.ImChannelConfig;
|
||||
import com.xspaceagi.im.web.dto.ImChannelConfigSaveRequest;
|
||||
import com.xspaceagi.system.application.util.DefaultIconUrlUtil;
|
||||
|
||||
public class ImDtoConvertor {
|
||||
|
||||
public static ImChannelConfigResponse toResponse(ImChannelConfig config, AgentConfigDto agentConfig) {
|
||||
if (config == null) {
|
||||
return null;
|
||||
}
|
||||
ImChannelConfigResponse response = new ImChannelConfigResponse();
|
||||
response.setId(config.getId());
|
||||
response.setChannel(config.getChannel());
|
||||
response.setTargetType(config.getTargetType());
|
||||
response.setTargetId(config.getTargetId());
|
||||
response.setAgentId(config.getAgentId());
|
||||
response.setEnabled(config.getEnabled());
|
||||
response.setConfigData(config.getConfigData());
|
||||
response.setOutputMode(config.getOutputMode());
|
||||
response.setName(config.getName());
|
||||
response.setSpaceId(config.getSpaceId());
|
||||
response.setCreated(config.getCreated());
|
||||
response.setCreatorId(config.getCreatorId());
|
||||
response.setCreatorName(config.getCreatorName());
|
||||
response.setModified(config.getModified());
|
||||
response.setModifiedId(config.getModifiedId());
|
||||
response.setModifiedName(config.getModifiedName());
|
||||
if (agentConfig != null) {
|
||||
response.setAgentName(agentConfig.getName());
|
||||
response.setAgentIcon(DefaultIconUrlUtil.setDefaultIconUrl(agentConfig.getIcon(), agentConfig.getName()));
|
||||
response.setAgentDescription(agentConfig.getDescription());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public static ImChannelConfig toEntity(ImChannelConfigSaveRequest request) {
|
||||
ImChannelConfig config = new ImChannelConfig();
|
||||
config.setId(request.getId());
|
||||
config.setChannel(request.getChannel());
|
||||
config.setTargetType(request.getTargetType());
|
||||
config.setAgentId(request.getAgentId());
|
||||
config.setEnabled(request.getEnabled());
|
||||
config.setConfigData(request.getConfigData());
|
||||
config.setOutputMode(request.getOutputMode());
|
||||
config.setSpaceId(request.getSpaceId());
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
package com.xspaceagi.im.web.util;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.IComputerFileApplicationService;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.web.service.ImFileShareService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* IM 智能机器人输出内容统一处理工具
|
||||
* <p>
|
||||
* 用于处理飞书、钉钉、企业微信等平台的智能体输出内容,确保:
|
||||
* 1. 移除内部标签和分隔符
|
||||
* 2. 还原工具输出中的转义字符
|
||||
* 3. 替换文件标签为 URL
|
||||
* 4. 规范化 Markdown 格式
|
||||
* <p>
|
||||
* 统一处理逻辑,避免各平台重复实现
|
||||
*/
|
||||
@Slf4j
|
||||
public class ImOutputProcessor {
|
||||
|
||||
/**
|
||||
* 内部消息分隔符(用于在 AI 消息流中分隔不同片段)
|
||||
*/
|
||||
private static final String INTERNAL_DIV_PATTERN = "<div class=\"ai-message-divider\"></div>";
|
||||
|
||||
/**
|
||||
* 内部标签正则(匹配任意 HTML 标签)
|
||||
*/
|
||||
private static final String INTERNAL_TAG_PATTERN = "<[^>]+>";
|
||||
|
||||
/**
|
||||
* 文件标签正则(匹配 <file>filename</file>)
|
||||
*/
|
||||
private static final Pattern FILE_TAG_PATTERN = Pattern.compile("<file>([^<]+)</file>");
|
||||
|
||||
/**
|
||||
* 通用文件路径正则(匹配多种操作系统的文件路径格式)
|
||||
* <p>
|
||||
* 支持的格式:
|
||||
* - Linux/MacOS: /home/user/file.txt, /Users/username/project/file.py
|
||||
* - Windows: C:\\Users\\file.txt, D:/project/file.md
|
||||
* - 云端沙箱: /home/user/12345/file.txt
|
||||
* <p>
|
||||
* 限制条件:
|
||||
* - 必须包含文件扩展名(.txt, .md, .py等)
|
||||
* - 避免匹配HTML标签和目录路径
|
||||
*/
|
||||
private static final Pattern FILE_PATH_PATTERN = Pattern.compile(
|
||||
// 匹配以盘符开头的Windows路径(如 C:\path\file.txt)
|
||||
// 前置负向约束,避免误匹配 file:///D:/... 里的 e:/... 片段
|
||||
"(?<![A-Za-z0-9_])([A-Za-z]:[\\\\/][^\\s\"'`)\\]]+\\.[a-zA-Z0-9]+)|" +
|
||||
// 匹配以 / 开头的Unix/Linux/MacOS路径(支持中文等 Unicode 文件名)
|
||||
"(/[^\\s\"'`)\\]]+\\.[a-zA-Z0-9]+)" +
|
||||
// 确保路径以文件扩展名结束
|
||||
"(?<!\\.markdown-custom-process)(?<!\\.div)"
|
||||
);
|
||||
|
||||
/**
|
||||
* Windows 盘符路径(如:C:\path\file.txt)
|
||||
* <p>
|
||||
* 用于保护路径中的 "\n" / "\t" 等片段,避免在工具转义还原阶段被误替换成换行/制表符。
|
||||
*/
|
||||
private static final Pattern WINDOWS_DRIVE_PATH_PATTERN = Pattern.compile("(?i)\\b[A-Z]:\\\\[^\\s\"'`\\]]+");
|
||||
|
||||
/**
|
||||
* 文件名前缀识别(如:文件名:xxx.md)
|
||||
*/
|
||||
private static final Pattern FILE_NAME_PREFIX_PATTERN = Pattern.compile(
|
||||
"(?i)(文件名\\s*[::]\\s*|file name\\s*[::]\\s*)([^\\s<>'\"`\\[\\]]+\\.[a-zA-Z0-9]+)"
|
||||
);
|
||||
|
||||
/**
|
||||
* 反引号包裹的文件名/相对路径
|
||||
*/
|
||||
private static final Pattern BACKTICK_FILE_PATTERN = Pattern.compile("`([^`\\n]+\\.[a-zA-Z0-9]+)`");
|
||||
private static final Pattern PURE_MARKDOWN_LINK_LINE_PATTERN = Pattern.compile("^\\s*\\[([^\\]]+)\\]\\([^)]+\\)\\s*$");
|
||||
private static final Pattern MARKDOWN_LINK_CAPTURE_PATTERN = Pattern.compile("\\[([^\\]]+)\\]\\(([^)]+)\\)");
|
||||
// 兼容 ```...``` / ```\n...\n``` / ```markdown\n...\n``` 等多种围栏格式
|
||||
private static final Pattern CODE_FENCE_BLOCK_PATTERN = Pattern.compile("```+[^\\n`]*\\n?([\\s\\S]*?)\\n?```+");
|
||||
private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[[^\\]]+\\]\\([^)]+\\)");
|
||||
|
||||
/** 纯文本中的 http(s) 链接,用于微信渠道:先占位再处理文件名,避免误改 URL 路径里的 .html 等 */
|
||||
private static final Pattern PLAIN_HTTP_URL_PATTERN = Pattern.compile("https?://[^\\s]+");
|
||||
/**
|
||||
* 微信会把独立出现的「文件名.扩展名」标成可点击链接(常无法打开)。在点前插入零宽空格(U+200B)打断识别。
|
||||
* 不覆盖 URL 子串(见 {@link #injectZeroWidthBeforeStandaloneFileExtensionsForWechat})。
|
||||
*/
|
||||
private static final Pattern WECHAT_STANDALONE_FILENAME_EXT = Pattern.compile(
|
||||
"(?i)(?<![.\u200B])([a-zA-Z0-9_\u4e00-\u9fff\\-]{1,240})"
|
||||
+ "\\.(html|htm|jpg|jpeg|png|gif|webp|bmp|svg|md|pdf|txt|py|js|ts|tsx|jsx|json|xml|yaml|yml|csv|wasm|xlsx|xls|doc|docx|pptx|ppt|zip|rar|7z|tar|gz)"
|
||||
+ "(?=\\s|$|[\\u4e00-\\u9fff]|[,。;:、)】!?]|-|\\))");
|
||||
|
||||
/**
|
||||
* 工具调用标签正则(匹配 <div><markdown-custom-process type="ToolCall" name="..."></markdown-custom-process></div>)
|
||||
*/
|
||||
private static final Pattern TOOL_CALL_PATTERN = Pattern.compile("<div>\\s*<markdown-custom-process[^>]*type\\s*=\\s*\"ToolCall\"[^>]*name\\s*=\\s*\"([^\"]+)\"[^>]*/?>\\s*</markdown-custom-process>\\s*</div>", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static String stripWrapping(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String v = value.trim();
|
||||
if ((v.startsWith("\"") && v.endsWith("\"")) ||
|
||||
(v.startsWith("'") && v.endsWith("'")) ||
|
||||
(v.startsWith("`") && v.endsWith("`"))) {
|
||||
if (v.length() >= 2) {
|
||||
v = v.substring(1, v.length() - 1).trim();
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String normalizeRelativePathForCompare(String path) {
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
// 保持相对路径语义:去掉开头的 /,并统一分隔符
|
||||
String normalized = path.trim().replace('\\', '/');
|
||||
while (normalized.startsWith("/")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断匹配片段是否处于 Markdown 链接 label 中:
|
||||
* 例如在 [xxx](url) 的 xxx 区域内,避免重复替换导致 [[...](...)](...)
|
||||
*/
|
||||
private static boolean isInsideMarkdownLinkLabel(String text, int start, int end) {
|
||||
if (StringUtils.isBlank(text) || start < 0 || end <= start || end > text.length()) {
|
||||
return false;
|
||||
}
|
||||
int leftBracket = text.lastIndexOf('[', start);
|
||||
if (leftBracket < 0 || leftBracket >= start) {
|
||||
return false;
|
||||
}
|
||||
int rightBracket = text.indexOf(']', start);
|
||||
if (rightBracket < 0 || rightBracket < end) {
|
||||
return false;
|
||||
}
|
||||
// rightBracket 后必须紧跟 '(' 才是 markdown 链接
|
||||
if (rightBracket + 1 >= text.length() || text.charAt(rightBracket + 1) != '(') {
|
||||
return false;
|
||||
}
|
||||
int rightParen = text.indexOf(')', rightBracket + 2);
|
||||
return rightParen > rightBracket + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断匹配位置是否位于“工具调用”行,避免把工具调用说明里的路径替换成链接。
|
||||
*/
|
||||
private static boolean isInToolCallLine(String text, int matchStart) {
|
||||
if (StringUtils.isBlank(text) || matchStart < 0 || matchStart > text.length()) {
|
||||
return false;
|
||||
}
|
||||
int lineStart = text.lastIndexOf('\n', Math.max(0, matchStart - 1));
|
||||
lineStart = lineStart < 0 ? 0 : lineStart + 1;
|
||||
int lineEnd = text.indexOf('\n', matchStart);
|
||||
lineEnd = lineEnd < 0 ? text.length() : lineEnd;
|
||||
String line = text.substring(lineStart, lineEnd).trim();
|
||||
return line.startsWith("> 🔧 工具调用:");
|
||||
}
|
||||
|
||||
private static class FileEntry {
|
||||
private final String name; // 归一化后的相对路径(无开头/,分隔符为/)
|
||||
private final String basename; // name 的最后一段
|
||||
private final String fileProxyUrl;
|
||||
|
||||
private FileEntry(String name, String basename, String fileProxyUrl) {
|
||||
this.name = name;
|
||||
this.basename = basename;
|
||||
this.fileProxyUrl = fileProxyUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<FileEntry> fetchFileEntries(Long userId, Long conversationId,
|
||||
IComputerFileApplicationService computerFileApplicationService) {
|
||||
List<FileEntry> entries = new ArrayList<>();
|
||||
if (conversationId == null || userId == null || computerFileApplicationService == null) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
try {
|
||||
String proxyPath = String.format("/api/computer/static/%s", conversationId);
|
||||
Map<String, Object> result = computerFileApplicationService.getFileList(userId, conversationId, proxyPath, null);
|
||||
if (result == null) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
Object filesObj = result.get("files");
|
||||
// 兼容另一种返回结构: { data: { files: [...] } }
|
||||
if (!(filesObj instanceof List)) {
|
||||
Object dataObj = result.get("data");
|
||||
if (dataObj instanceof Map) {
|
||||
filesObj = ((Map<?, ?>) dataObj).get("files");
|
||||
}
|
||||
}
|
||||
if (!(filesObj instanceof List)) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
List<?> files = (List<?>) filesObj;
|
||||
for (Object f : files) {
|
||||
if (!(f instanceof Map)) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> fm = (Map<?, ?>) f;
|
||||
Object nameObj = fm.get("name");
|
||||
Object proxyObj = fm.get("fileProxyUrl");
|
||||
if (nameObj == null || proxyObj == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = String.valueOf(nameObj);
|
||||
String proxyUrl = String.valueOf(proxyObj);
|
||||
if (StringUtils.isBlank(name) || StringUtils.isBlank(proxyUrl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String normalizedName = normalizeRelativePathForCompare(name);
|
||||
String basename = extractFilename(normalizedName);
|
||||
if (StringUtils.isBlank(normalizedName) || StringUtils.isBlank(basename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.add(new FileEntry(normalizedName, basename, proxyUrl));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[fetchFileEntries] Failed to list files: userId={}, conversationId={}, error={}",
|
||||
userId, conversationId, e.getMessage());
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static String toMarkdownLinkIfMatched(String originalToken, List<FileEntry> entries,
|
||||
Map<String, String> proxyByExactName,
|
||||
Map<String, String> proxyByBasename,
|
||||
String domain,
|
||||
ImFileShareService fileShareService,
|
||||
Long tenantId,
|
||||
Long userId,
|
||||
Long conversationId) {
|
||||
if (StringUtils.isBlank(originalToken)) {
|
||||
return originalToken;
|
||||
}
|
||||
|
||||
String tokenForLabel = stripWrapping(originalToken);
|
||||
if (StringUtils.isBlank(tokenForLabel)) {
|
||||
return originalToken;
|
||||
}
|
||||
|
||||
String tokenNorm = normalizeRelativePathForCompare(tokenForLabel);
|
||||
if (StringUtils.isBlank(tokenNorm)) {
|
||||
return originalToken;
|
||||
}
|
||||
|
||||
String tokenBasename = extractFilename(tokenNorm);
|
||||
if (StringUtils.isBlank(tokenBasename)) {
|
||||
return originalToken;
|
||||
}
|
||||
|
||||
String proxyUrl = proxyByExactName.get(tokenNorm);
|
||||
if (proxyUrl == null) {
|
||||
proxyUrl = proxyByBasename.get(tokenBasename);
|
||||
}
|
||||
|
||||
if (proxyUrl == null && entries != null) {
|
||||
// 兜底:支持 token 是“完整路径”但我们只有相对路径 name 的情况
|
||||
for (FileEntry entry : entries) {
|
||||
if (entry == null || StringUtils.isBlank(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (tokenNorm.equals(entry.name) || tokenNorm.endsWith("/" + entry.name)) {
|
||||
proxyUrl = entry.fileProxyUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyUrl == null) {
|
||||
return originalToken;
|
||||
}
|
||||
|
||||
String finalUrl = proxyUrl;
|
||||
if (StringUtils.isNotBlank(domain)) {
|
||||
// 优先使用分享链接(带 sk),若无法生成则回退为完整代理链接
|
||||
if (fileShareService != null && tenantId != null && userId != null && conversationId != null) {
|
||||
String shareUrl = getShareUrl(domain, fileShareService, tenantId, userId, conversationId, proxyUrl);
|
||||
if (StringUtils.isNotBlank(shareUrl)) {
|
||||
finalUrl = shareUrl;
|
||||
} else if (proxyUrl.startsWith("/")) {
|
||||
finalUrl = domain + proxyUrl;
|
||||
}
|
||||
} else if (proxyUrl.startsWith("/")) {
|
||||
finalUrl = domain + proxyUrl;
|
||||
}
|
||||
}
|
||||
return "[" + tokenForLabel + "](" + finalUrl + ")";
|
||||
}
|
||||
|
||||
private static String resolveMatchedUrl(String token, List<FileEntry> entries,
|
||||
Map<String, String> proxyByExactName,
|
||||
Map<String, String> proxyByBasename,
|
||||
String domain,
|
||||
ImFileShareService fileShareService,
|
||||
Long tenantId,
|
||||
Long userId,
|
||||
Long conversationId) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
String tokenForLookup = stripWrapping(token);
|
||||
if (StringUtils.isBlank(tokenForLookup)) {
|
||||
return null;
|
||||
}
|
||||
String tokenNorm = normalizeRelativePathForCompare(tokenForLookup);
|
||||
if (StringUtils.isBlank(tokenNorm)) {
|
||||
return null;
|
||||
}
|
||||
String tokenBasename = extractFilename(tokenNorm);
|
||||
if (StringUtils.isBlank(tokenBasename)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String proxyUrl = proxyByExactName.get(tokenNorm);
|
||||
if (proxyUrl == null) {
|
||||
proxyUrl = proxyByBasename.get(tokenBasename);
|
||||
}
|
||||
if (proxyUrl == null && entries != null) {
|
||||
for (FileEntry entry : entries) {
|
||||
if (entry == null || StringUtils.isBlank(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (tokenNorm.equals(entry.name) || tokenNorm.endsWith("/" + entry.name)) {
|
||||
proxyUrl = entry.fileProxyUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (proxyUrl == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String finalUrl = proxyUrl;
|
||||
if (StringUtils.isNotBlank(domain)) {
|
||||
if (fileShareService != null && tenantId != null && userId != null && conversationId != null) {
|
||||
String shareUrl = getShareUrl(domain, fileShareService, tenantId, userId, conversationId, proxyUrl);
|
||||
if (StringUtils.isNotBlank(shareUrl)) {
|
||||
finalUrl = shareUrl;
|
||||
} else if (proxyUrl.startsWith("/")) {
|
||||
finalUrl = domain + proxyUrl;
|
||||
}
|
||||
} else if (proxyUrl.startsWith("/")) {
|
||||
finalUrl = domain + proxyUrl;
|
||||
}
|
||||
}
|
||||
return finalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径中提取文件名
|
||||
*
|
||||
* @param filePath 文件路径(可能包含多层目录)
|
||||
* @return 提取的文件名
|
||||
*/
|
||||
private static String extractFilename(String filePath) {
|
||||
if (filePath == null || filePath.isEmpty()) {
|
||||
return filePath;
|
||||
}
|
||||
// 处理 Unix 路径分隔符 /
|
||||
int lastUnixSlash = filePath.lastIndexOf('/');
|
||||
// 处理 Windows 路径分隔符 \
|
||||
int lastWindowsSlash = filePath.lastIndexOf('\\');
|
||||
// 取最后一个分隔符之后的内容
|
||||
int lastSeparator = Math.max(lastUnixSlash, lastWindowsSlash);
|
||||
if (lastSeparator >= 0) {
|
||||
return filePath.substring(lastSeparator + 1);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集每个工具调用之后的候选内容,按出现顺序返回。
|
||||
* 如果没有工具调用标签,则仅返回原文。
|
||||
*/
|
||||
private static List<String> collectContentAfterToolCalls(String text) {
|
||||
List<String> candidates = new ArrayList<>();
|
||||
if (text == null || text.isEmpty()) {
|
||||
candidates.add(text);
|
||||
return candidates;
|
||||
}
|
||||
Matcher matcher = TOOL_CALL_PATTERN.matcher(text);
|
||||
while (matcher.find()) {
|
||||
candidates.add(matcher.end() >= text.length() ? "" : text.substring(matcher.end()));
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
candidates.add(text);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除内部标签和分隔符
|
||||
* <p>
|
||||
* 移除 AI 消息流中使用的内部标签,如 <div class="ai-message-divider"></div> 等
|
||||
*
|
||||
* @param text 原始文本
|
||||
* @return 移除内部标签后的文本
|
||||
*/
|
||||
private static String removeInternalTags(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
// 替换内部分隔符为换行
|
||||
text = text.replaceAll(INTERNAL_DIV_PATTERN, "\n");
|
||||
// 移除所有内部标签
|
||||
text = text.replaceAll(INTERNAL_TAG_PATTERN, "");
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将连续空行压缩为最多 1 个空行。
|
||||
* 例如:三连换行或多连换行 -> 保留为双换行(中间 1 行空行)。
|
||||
*/
|
||||
private static String collapseConsecutiveBlankLines(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return text;
|
||||
}
|
||||
// (?m) 多行模式;允许空行中存在空格/Tab
|
||||
// 将 “至少 3 个连续换行(中间可带空白)” 替换为 “2 个连续换行(中间仅 1 个空行)”
|
||||
return text.replaceAll("(\\r?\\n)[ \\t]*(?:\\r?\\n[ \\t]*){2,}", "$1$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉开头/结尾的“纯空行”(允许空格/Tab)。
|
||||
* 注意:只移除空行,不影响中间非空内容及其换行结构。
|
||||
*/
|
||||
private static String stripLeadingTrailingBlankLines(String text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
if (text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
String[] lines = text.split("\\r?\\n", -1);
|
||||
|
||||
int start = 0;
|
||||
while (start < lines.length && lines[start].trim().isEmpty()) {
|
||||
start++;
|
||||
}
|
||||
|
||||
int end = lines.length - 1;
|
||||
while (end >= start && lines[end].trim().isEmpty()) {
|
||||
end--;
|
||||
}
|
||||
|
||||
if (start == 0 && end == lines.length - 1) {
|
||||
return text;
|
||||
}
|
||||
if (end < start) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = start; i <= end; i++) {
|
||||
if (i > start) {
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append(lines[i]);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Markdown链接周围的内联代码标记(反引号)
|
||||
* <p>
|
||||
* 智能体可能输出:文件:`[filename.md](url)`,这会导致链接无法渲染
|
||||
* 该方法会移除链接周围的反引号,使其正常显示
|
||||
*
|
||||
* @param text 包含Markdown链接的文本
|
||||
* @return 移除链接周围反引号后的文本
|
||||
*/
|
||||
private static String removeInlineCodeAroundLinks(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 移除内联代码标记包裹的Markdown链接
|
||||
// 匹配:`[text](url)` 或 `[text](url)` 这种格式
|
||||
// 保持链接本身不变,只移除外层的反引号
|
||||
|
||||
// 处理单反引号包裹的情况:`[link](url)` -> [link](url)
|
||||
text = text.replaceAll("`+\\[([^\\]]+)\\]\\(([^)]+)\\)`+", "[$1]($2)");
|
||||
|
||||
// 处理更复杂的情况:链接前有反引号但没有闭合的情况
|
||||
// `[link](url) -> [link](url)
|
||||
text = text.replaceAll("`+\\[", "[");
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重“纯 Markdown 链接行”。
|
||||
* 典型场景:同一文件路径同时出现在 <description> 和 <file> 中,替换后出现两行重复链接。
|
||||
*/
|
||||
private static String deduplicatePureLinkLines(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return text;
|
||||
}
|
||||
String[] lines = text.split("\\r?\\n", -1);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String previousPureLinkLabel = null;
|
||||
boolean changed = false;
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
String trimmed = line == null ? "" : line.trim();
|
||||
Matcher m = PURE_MARKDOWN_LINK_LINE_PATTERN.matcher(trimmed);
|
||||
boolean isPureLink = m.matches();
|
||||
if (isPureLink) {
|
||||
String label = StringUtils.defaultString(m.group(1)).trim();
|
||||
// 仅去重“连续相同链接文案”的纯链接行(description + file 场景)
|
||||
if (StringUtils.equals(previousPureLinkLabel, label)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
previousPureLinkLabel = label;
|
||||
} else {
|
||||
previousPureLinkLabel = null;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append(line);
|
||||
}
|
||||
return changed ? sb.toString() : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除包裹Markdown链接的代码块标记
|
||||
* <p>
|
||||
* 智能体可能输出:```[link](url)```,导致链接无法渲染
|
||||
* 该方法会移除链接周围的代码块标记,使其正常显示
|
||||
*
|
||||
* @param text 包含Markdown链接的文本
|
||||
* @return 移除代码块标记后的文本
|
||||
*/
|
||||
private static String removeCodeBlocksAroundLinks(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
Matcher matcher = CODE_FENCE_BLOCK_PATTERN.matcher(text);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
String blockContent = matcher.group(1);
|
||||
// 只要代码块内容中存在 markdown 链接,就去掉外层代码围栏
|
||||
if (MARKDOWN_LINK_PATTERN.matcher(blockContent).find()) {
|
||||
String normalized = blockContent;
|
||||
// 单行围栏场景可能把两端空白一起包进来,这里裁掉仅保留内容
|
||||
if (normalized != null) {
|
||||
normalized = normalized.trim();
|
||||
}
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(normalized));
|
||||
} else {
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(matcher.group(0)));
|
||||
}
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原工具输出中的转义字符
|
||||
* <p>
|
||||
* 工具输出中的换行符、引号等被转义为 \\n、\\",需要还原为原始字符
|
||||
* 否则 Markdown 无法正确解析换行和引号
|
||||
*
|
||||
* @param text 包含转义字符的文本
|
||||
* @return 还原转义字符后的文本
|
||||
*/
|
||||
private static String unescapeToolOutput(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 1) 先保护 Windows 盘符路径,避免把路径里的 "\n"/"\t" 当成工具转义
|
||||
Matcher winPathMatcher = WINDOWS_DRIVE_PATH_PATTERN.matcher(text);
|
||||
List<String> winPathHolds = new ArrayList<>();
|
||||
StringBuffer masked = new StringBuffer();
|
||||
int holdIdx = 0;
|
||||
while (winPathMatcher.find()) {
|
||||
String path = winPathMatcher.group();
|
||||
winPathHolds.add(path);
|
||||
winPathMatcher.appendReplacement(masked, Matcher.quoteReplacement("__WINPATH_HOLD_" + holdIdx++ + "__"));
|
||||
}
|
||||
winPathMatcher.appendTail(masked);
|
||||
|
||||
// 2) 还原工具输出中的转义字符
|
||||
String unescaped = masked.toString()
|
||||
.replace("\\n", "\n") // 还原换行符
|
||||
.replace("\\\"", "\"") // 还原引号
|
||||
.replace("\\t", "\t") // 还原制表符
|
||||
.replace("\\\\", "\\"); // 还原反斜杠
|
||||
|
||||
// 3) 恢复被保护的 Windows 盘符路径
|
||||
for (int i = 0; i < winPathHolds.size(); i++) {
|
||||
unescaped = unescaped.replace("__WINPATH_HOLD_" + i + "__", winPathHolds.get(i));
|
||||
}
|
||||
return unescaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 Markdown 内容,确保解析器能正确识别语法
|
||||
* <p>
|
||||
* 飞书、钉钉、企业微信等平台的 Markdown 解析器要求:
|
||||
* - ** 加粗标记与文本之间不能有换行,但可以有空格
|
||||
* - 当 ** 与前后文本粘连时,自动添加空格避免语法错误
|
||||
* <p>
|
||||
* 例如:
|
||||
* - "text**bold**" → "text **bold**"(前面加空格)
|
||||
* - "**bold**more" → "**bold** more"(后面加空格)
|
||||
*
|
||||
* @param text 原始 Markdown 文本
|
||||
* @return 规范化后的 Markdown 文本
|
||||
*/
|
||||
private static String normalizeMarkdownContent(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// ** 前:若与前置文本粘连,则加空格(text** -> text **)
|
||||
text = text.replaceAll("([^\\n\\s])\\*\\*", "$1 **");
|
||||
|
||||
// **...** 后:若与后置文本粘连,则加空格(**bold**more -> **bold** more)
|
||||
text = text.replaceAll("(\\*\\*[^*]*\\*\\*)([^\\n\\s*])", "$1 $2");
|
||||
|
||||
// 兼容:冒号后紧跟链接时可能无法渲染,补一个空格
|
||||
// 例如 file:[a](b) -> file: [a](b)
|
||||
text = text.replaceAll("([::])\\[", "$1 [");
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static String getShareUrl(String domain, ImFileShareService fileShareService, Long tenantId, Long userId, Long conversationId, String proxyUrl) {
|
||||
if (fileShareService == null || tenantId == null || userId == null || conversationId == null || StringUtils.isBlank(proxyUrl)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String shareKey = fileShareService.createFileShare(userId, conversationId, proxyUrl, tenantId);
|
||||
if (StringUtils.isBlank(shareKey)) {
|
||||
return null;
|
||||
}
|
||||
String separator = proxyUrl.contains("?") ? "&" : "?";
|
||||
return domain + "/static/file-preview.html" + separator + "sk=" + shareKey + "&dl=1";
|
||||
} catch (Exception e) {
|
||||
log.warn("[getShareUrl] Failed to build share URL, conversationId={}, proxyUrl={}, error={}", conversationId, proxyUrl, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本中的文件信息替换为 Markdown 链接 [文件](fileProxyUrl),用于在钉钉/飞书/企业微信中可点击。
|
||||
* <p>
|
||||
* 处理来源:
|
||||
* - `<file>filename</file>` 标签
|
||||
* - 文本中的绝对路径/相对路径(如 `/home/...`、`/xxx/...`、`/D/...`、`D:\\...` 等,且包含扩展名)
|
||||
* - `文件名:xxx.md`(或 `file name:xxx.md`)
|
||||
* <p>
|
||||
* 匹配策略:
|
||||
* - 先调用 `file-list` 获取空间下所有文件的 `name` 与 `fileProxyUrl`
|
||||
* - 将识别到的“路径/文件名”与文件列表的 `name`(以及其 basename 后缀)做匹配
|
||||
* - 匹配不到时保持原样(不会生成 Markdown 链接)
|
||||
* <p>
|
||||
*/
|
||||
private static String replaceFileTagsWithUrl(String text, Long conversationId, Long agentId, String fileUrlDomain, Long userId, Long tenantId,
|
||||
ImFileShareService fileShareService, IComputerFileApplicationService computerFileApplicationService) {
|
||||
if (text == null || text.isEmpty() || conversationId == null || StringUtils.isBlank(fileUrlDomain)) {
|
||||
return text;
|
||||
}
|
||||
String domain = fileUrlDomain.endsWith("/") ? fileUrlDomain.substring(0, fileUrlDomain.length() - 1) : fileUrlDomain;
|
||||
|
||||
// 只有匹配到“文件候选”时才拉取文件列表,避免无谓的 HTTP 调用
|
||||
boolean hasFileCandidate = FILE_TAG_PATTERN.matcher(text).find()
|
||||
|| FILE_PATH_PATTERN.matcher(text).find()
|
||||
|| FILE_NAME_PREFIX_PATTERN.matcher(text).find()
|
||||
|| BACKTICK_FILE_PATTERN.matcher(text).find()
|
||||
|| MARKDOWN_LINK_CAPTURE_PATTERN.matcher(text).find();
|
||||
if (!hasFileCandidate) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 预先拉取空间下所有文件名 -> 代理地址,用于后续匹配替换
|
||||
// 注意:tenantId/userId/fileShareService 在新逻辑中不再参与“文件链接生成”,保留参数用于兼容老方法签名
|
||||
log.debug("[replaceFileTagsWithUrl] file-list match mode. tenantId={}, userId={}", tenantId, userId);
|
||||
List<FileEntry> fileEntries = fetchFileEntries(userId, conversationId, computerFileApplicationService);
|
||||
|
||||
Map<String, String> proxyByExactName = new HashMap<>();
|
||||
Map<String, String> proxyByBasename = new HashMap<>();
|
||||
for (FileEntry entry : fileEntries) {
|
||||
if (entry == null || StringUtils.isBlank(entry.name) || StringUtils.isBlank(entry.fileProxyUrl)) {
|
||||
continue;
|
||||
}
|
||||
// 同名取第一个,避免覆盖导致不确定行为
|
||||
proxyByExactName.putIfAbsent(entry.name, entry.fileProxyUrl);
|
||||
proxyByBasename.putIfAbsent(entry.basename, entry.fileProxyUrl);
|
||||
}
|
||||
|
||||
String processed = text;
|
||||
|
||||
// 1) 处理 <file> 标签:<file>xxx</file> -> [xxx](fileProxyUrl)(仅匹配到时)
|
||||
Matcher matcher = FILE_TAG_PATTERN.matcher(processed);
|
||||
StringBuilder sbWithTag = new StringBuilder();
|
||||
int fileTagCount = 0;
|
||||
while (matcher.find()) {
|
||||
fileTagCount++;
|
||||
String rawToken = matcher.group(1);
|
||||
String replacement = toMarkdownLinkIfMatched(rawToken, fileEntries, proxyByExactName, proxyByBasename,
|
||||
domain, fileShareService, tenantId, userId, conversationId);
|
||||
matcher.appendReplacement(sbWithTag, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(sbWithTag);
|
||||
processed = sbWithTag.toString();
|
||||
|
||||
// 2) 处理文本中的文件路径:/home/... 或 D:\\... 等
|
||||
Matcher filePathMatcher = FILE_PATH_PATTERN.matcher(processed);
|
||||
StringBuilder sbWithPath = new StringBuilder();
|
||||
int filePathCount = 0;
|
||||
while (filePathMatcher.find()) {
|
||||
filePathCount++;
|
||||
String rawToken = filePathMatcher.group();
|
||||
if (isInsideMarkdownLinkLabel(processed, filePathMatcher.start(), filePathMatcher.end())) {
|
||||
// 已在 markdown 链接 label 内,跳过,防止重复套娃
|
||||
filePathMatcher.appendReplacement(sbWithPath, Matcher.quoteReplacement(rawToken));
|
||||
continue;
|
||||
}
|
||||
if (isInToolCallLine(processed, filePathMatcher.start())) {
|
||||
// 工具调用说明行不做文件链接替换
|
||||
filePathMatcher.appendReplacement(sbWithPath, Matcher.quoteReplacement(rawToken));
|
||||
continue;
|
||||
}
|
||||
String replacement = toMarkdownLinkIfMatched(rawToken, fileEntries, proxyByExactName, proxyByBasename,
|
||||
domain, fileShareService, tenantId, userId, conversationId);
|
||||
filePathMatcher.appendReplacement(sbWithPath, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
filePathMatcher.appendTail(sbWithPath);
|
||||
processed = sbWithPath.toString();
|
||||
|
||||
// 3) 处理“文件名:xxx”
|
||||
Matcher fileNameMatcher = FILE_NAME_PREFIX_PATTERN.matcher(processed);
|
||||
StringBuilder sbWithName = new StringBuilder();
|
||||
int fileNameCount = 0;
|
||||
while (fileNameMatcher.find()) {
|
||||
fileNameCount++;
|
||||
String prefix = fileNameMatcher.group(1);
|
||||
String filename = fileNameMatcher.group(2);
|
||||
String replacementFilename = toMarkdownLinkIfMatched(filename, fileEntries, proxyByExactName, proxyByBasename,
|
||||
domain, fileShareService, tenantId, userId, conversationId);
|
||||
fileNameMatcher.appendReplacement(sbWithName, Matcher.quoteReplacement(prefix + replacementFilename));
|
||||
}
|
||||
fileNameMatcher.appendTail(sbWithName);
|
||||
processed = sbWithName.toString();
|
||||
|
||||
// 4) 处理反引号包裹的文件名/相对路径:`xxx.md`
|
||||
Matcher backtickMatcher = BACKTICK_FILE_PATTERN.matcher(processed);
|
||||
StringBuilder sbWithBacktick = new StringBuilder();
|
||||
int backtickCount = 0;
|
||||
while (backtickMatcher.find()) {
|
||||
backtickCount++;
|
||||
String token = backtickMatcher.group(1);
|
||||
String replacement = toMarkdownLinkIfMatched(token, fileEntries, proxyByExactName, proxyByBasename,
|
||||
domain, fileShareService, tenantId, userId, conversationId);
|
||||
backtickMatcher.appendReplacement(sbWithBacktick, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
backtickMatcher.appendTail(sbWithBacktick);
|
||||
processed = sbWithBacktick.toString();
|
||||
|
||||
// 5) 处理“已是 markdown 链接,但 href 是相对文件名/相对路径”的场景:
|
||||
// [arbor_day.html](arbor_day.html) -> [arbor_day.html](https://.../static/file-preview.html?sk=...)
|
||||
Matcher markdownLinkMatcher = MARKDOWN_LINK_CAPTURE_PATTERN.matcher(processed);
|
||||
StringBuilder sbWithMarkdownLink = new StringBuilder();
|
||||
int markdownLinkCount = 0;
|
||||
while (markdownLinkMatcher.find()) {
|
||||
String label = markdownLinkMatcher.group(1);
|
||||
String href = markdownLinkMatcher.group(2);
|
||||
String hrefTrim = href == null ? "" : href.trim();
|
||||
|
||||
// 仅处理相对 href,外部链接/锚点链接保持原样
|
||||
if (hrefTrim.startsWith("http://") || hrefTrim.startsWith("https://")
|
||||
|| hrefTrim.startsWith("mailto:") || hrefTrim.startsWith("#")) {
|
||||
markdownLinkMatcher.appendReplacement(sbWithMarkdownLink, Matcher.quoteReplacement(markdownLinkMatcher.group(0)));
|
||||
continue;
|
||||
}
|
||||
|
||||
String resolvedUrl = resolveMatchedUrl(hrefTrim, fileEntries, proxyByExactName, proxyByBasename,
|
||||
domain, fileShareService, tenantId, userId, conversationId);
|
||||
if (StringUtils.isBlank(resolvedUrl)) {
|
||||
markdownLinkMatcher.appendReplacement(sbWithMarkdownLink, Matcher.quoteReplacement(markdownLinkMatcher.group(0)));
|
||||
continue;
|
||||
}
|
||||
markdownLinkCount++;
|
||||
String replacement = "[" + label + "](" + resolvedUrl + ")";
|
||||
markdownLinkMatcher.appendReplacement(sbWithMarkdownLink, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
markdownLinkMatcher.appendTail(sbWithMarkdownLink);
|
||||
processed = sbWithMarkdownLink.toString();
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信 iLink 等不支持 Markdown 的场景:将 {@code [label](url)} 转为纯文本。
|
||||
* <p>
|
||||
* 不使用「label: https://」同一行:微信常把「xxx.jpg:」等误判为可点击链接且无法打开。
|
||||
* 改为「标签换行 + URL 独占一行」,仅对 https 行做正常识别。
|
||||
*/
|
||||
private static String flattenMarkdownLinksToPlainText(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return text;
|
||||
}
|
||||
Matcher m = MARKDOWN_LINK_CAPTURE_PATTERN.matcher(text);
|
||||
return m.replaceAll(mr -> {
|
||||
String label = mr.group(1) == null ? "" : mr.group(1).trim();
|
||||
String href = mr.group(2) == null ? "" : mr.group(2).trim();
|
||||
if (StringUtils.isBlank(href)) {
|
||||
return label;
|
||||
}
|
||||
if (StringUtils.isBlank(label)) {
|
||||
return href;
|
||||
}
|
||||
return label + "\n" + href;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信对「xxx.html」「yyy.jpg」等会加下划线链接;在扩展名前插入 U+200B,且先保护 http(s) 整段,避免破坏 URL。
|
||||
*/
|
||||
private static String injectZeroWidthBeforeStandaloneFileExtensionsForWechat(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return text;
|
||||
}
|
||||
List<String> urlHolds = new ArrayList<>();
|
||||
Matcher urlMatcher = PLAIN_HTTP_URL_PATTERN.matcher(text);
|
||||
StringBuffer masked = new StringBuffer();
|
||||
int holdIdx = 0;
|
||||
while (urlMatcher.find()) {
|
||||
urlHolds.add(urlMatcher.group());
|
||||
urlMatcher.appendReplacement(masked, Matcher.quoteReplacement("__WECHAT_URL_HOLD_" + holdIdx++ + "__"));
|
||||
}
|
||||
urlMatcher.appendTail(masked);
|
||||
String maskedStr = masked.toString();
|
||||
|
||||
Matcher extMatcher = WECHAT_STANDALONE_FILENAME_EXT.matcher(maskedStr);
|
||||
StringBuffer out = new StringBuffer();
|
||||
while (extMatcher.find()) {
|
||||
extMatcher.appendReplacement(out, Matcher.quoteReplacement(
|
||||
extMatcher.group(1) + "\u200B." + extMatcher.group(2)));
|
||||
}
|
||||
extMatcher.appendTail(out);
|
||||
String result = out.toString();
|
||||
for (int i = 0; i < urlHolds.size(); i++) {
|
||||
result = result.replace("__WECHAT_URL_HOLD_" + i + "__", urlHolds.get(i));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输出内容,应用标准化处理流程(通用渠道,保留 Markdown 链接形式)。
|
||||
*/
|
||||
public static String processOutput(String text, Long conversationId, Long agentId, String fileUrlDomain, Long userId, Long tenantId,
|
||||
ImFileShareService fileShareService, IComputerFileApplicationService computerFileApplicationService) {
|
||||
return processOutput(text, conversationId, agentId, fileUrlDomain, userId, tenantId, fileShareService,
|
||||
computerFileApplicationService, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理输出内容,应用标准化处理流程
|
||||
*
|
||||
* @param imChannelCode 为 {@link ImChannelEnum#WECHAT_ILINK} 时:展平 {@code [label](url)},并对独立文件名插入零宽空格以减轻微信误加「假链接」
|
||||
*/
|
||||
public static String processOutput(String text, Long conversationId, Long agentId, String fileUrlDomain, Long userId, Long tenantId,
|
||||
ImFileShareService fileShareService, IComputerFileApplicationService computerFileApplicationService,
|
||||
String imChannelCode) {
|
||||
log.info("---------------------- ImOutputProcessor start ----------------------");
|
||||
log.info("Before: {}", text);
|
||||
if (text == null) {
|
||||
log.debug("After: null");
|
||||
log.debug("==================== ImOutputProcessor end ====================");
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> candidates = collectContentAfterToolCalls(text);
|
||||
String processed = "";
|
||||
for (int i = candidates.size() - 1; i >= 0; i--) {
|
||||
processed = processOutputCandidate(candidates.get(i), conversationId, agentId, fileUrlDomain, userId, tenantId,
|
||||
fileShareService, computerFileApplicationService, imChannelCode);
|
||||
if (StringUtils.isNotBlank(processed)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
text = processed;
|
||||
log.info("After: {}", text);
|
||||
log.info("==================== ImOutputProcessor end ====================");
|
||||
return text;
|
||||
}
|
||||
|
||||
private static String processOutputCandidate(String text, Long conversationId, Long agentId, String fileUrlDomain, Long userId, Long tenantId,
|
||||
ImFileShareService fileShareService, IComputerFileApplicationService computerFileApplicationService,
|
||||
String imChannelCode) {
|
||||
// 1. 替换文件标签为 URL(必须在移除内部标签之前)
|
||||
if (conversationId != null && StringUtils.isNotBlank(fileUrlDomain)) {
|
||||
text = replaceFileTagsWithUrl(text, conversationId, agentId, fileUrlDomain.trim(), userId, tenantId, fileShareService, computerFileApplicationService);
|
||||
} else {
|
||||
// 如果没有 URL 参数,至少移除 <file> 标签,保留文件名
|
||||
text = FILE_TAG_PATTERN.matcher(text).replaceAll("$1");
|
||||
}
|
||||
|
||||
// 2. 移除内部标签和分隔符(如 <description>、<div> 等)
|
||||
text = removeInternalTags(text);
|
||||
|
||||
// 3. 去重纯链接行(同一路径在多个内部标签中重复时保留一条)
|
||||
text = deduplicatePureLinkLines(text);
|
||||
|
||||
// 4. 移除可能包裹文件名的内联代码标记(反引号)
|
||||
text = removeInlineCodeAroundLinks(text);
|
||||
|
||||
// 5. 移除包裹Markdown链接的代码块标记
|
||||
text = removeCodeBlocksAroundLinks(text);
|
||||
|
||||
// 6. 还原工具输出中的转义字符
|
||||
text = unescapeToolOutput(text);
|
||||
|
||||
// 7. 再次移除包裹链接的代码块(处理转义字符还原后才出现的 ``` 场景)
|
||||
text = removeCodeBlocksAroundLinks(text);
|
||||
|
||||
// 8. 规范化 Markdown 内容
|
||||
text = normalizeMarkdownContent(text);
|
||||
// 9. 合并连续多行空行:最多保留 1 个空行
|
||||
text = collapseConsecutiveBlankLines(text);
|
||||
// 10. 去掉开头/结尾的纯空行
|
||||
text = stripLeadingTrailingBlankLines(text);
|
||||
|
||||
if (ImChannelEnum.WECHAT_ILINK.getCode().equals(imChannelCode)) {
|
||||
text = flattenMarkdownLinksToPlainText(text);
|
||||
text = injectZeroWidthBeforeStandaloneFileExtensionsForWechat(text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user