chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
<?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-application</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>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-im-domain</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-application</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-spec</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-agent-core-adapter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-agent-core-infra</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-im-wechat</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.github.kasukusakura</groupId>
|
||||
<artifactId>silk-codec</artifactId>
|
||||
<version>0.0.5</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.application.dto.StreamChunk;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉机器人智能体执行服务
|
||||
*/
|
||||
public interface DingtalkAgentApplicationService {
|
||||
|
||||
/**
|
||||
* 执行智能体并返回最终输出文本
|
||||
*
|
||||
* @param conversationType "1" 单聊,"2" 群聊
|
||||
* @param conversationId 会话 ID,群聊时为 openConversationId
|
||||
*/
|
||||
String executeAgent(String senderId, String message, String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId);
|
||||
|
||||
/**
|
||||
* 执行智能体并返回最终输出文本(支持附件)
|
||||
*/
|
||||
String executeAgent(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId);
|
||||
|
||||
/**
|
||||
* 执行智能体并返回结果和会话ID(用于内容后处理)
|
||||
*/
|
||||
AgentExecuteResultWithConv executeAgentWithConv(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName);
|
||||
|
||||
default AgentExecuteResultWithConv executeAgentWithConv(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgentWithConv(senderId, message, attachments, conversationType, conversationId, tenantId, userId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式执行智能体,返回增量输出的 Flux
|
||||
*
|
||||
* @param conversationType "1" 单聊,"2" 群聊
|
||||
* @param conversationId 会话 ID,群聊时为 openConversationId
|
||||
*/
|
||||
Flux<StreamChunk> executeAgentStream(String senderId, String message, String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId);
|
||||
|
||||
/**
|
||||
* 流式执行智能体(支持附件)
|
||||
*/
|
||||
Flux<StreamChunk> executeAgentStream(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId);
|
||||
|
||||
/**
|
||||
* 智能体执行结果(包含会话ID)
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
@lombok.NoArgsConstructor
|
||||
@lombok.AllArgsConstructor
|
||||
class AgentExecuteResultWithConv {
|
||||
private String text;
|
||||
private Long conversationId;
|
||||
private Long agentId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.application.dto.StreamChunk;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 飞书机器人智能体执行服务
|
||||
*/
|
||||
public interface FeishuAgentApplicationService {
|
||||
|
||||
/**
|
||||
* 执行智能体并返回最终输出文本(支持附件)
|
||||
*/
|
||||
String executeAgent(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId);
|
||||
|
||||
/**
|
||||
* 执行智能体并返回结果和会话ID(用于内容后处理)
|
||||
*/
|
||||
AgentExecuteResultWithConv executeAgentWithConv(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId, String sessionName);
|
||||
|
||||
default AgentExecuteResultWithConv executeAgentWithConv(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgentWithConv(sessionId, chatType, message, attachments, tenantId, userId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式执行智能体,返回增量输出的 Flux(支持附件)
|
||||
*/
|
||||
Flux<StreamChunk> executeAgentStream(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId, String sessionName);
|
||||
|
||||
default Flux<StreamChunk> executeAgentStream(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgentStream(sessionId, chatType, message, attachments, tenantId, userId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体执行结果(包含会话ID)
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
@lombok.NoArgsConstructor
|
||||
@lombok.AllArgsConstructor
|
||||
class AgentExecuteResultWithConv {
|
||||
private String text;
|
||||
private Long conversationId;
|
||||
private Long agentId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
/**
|
||||
* IM 渠道智能体输出统一后处理(与企微 {@code ImWeworkController#processAgentOutput} 同源逻辑,委托 {@code ImOutputProcessor})。
|
||||
*/
|
||||
public interface ImAgentOutputProcessService {
|
||||
|
||||
/**
|
||||
* @param text 智能体原始输出
|
||||
* @param conversationId 会话 ID(沙箱文件列表、会话链接等)
|
||||
* @param agentId 智能体 ID
|
||||
* @param tenantId 租户
|
||||
* @param userId 渠道归属用户(文件分享等)
|
||||
* @param imChannelCode 渠道编码(如 {@code wechat_ilink});为 {@code null} 时走通用 Markdown 输出
|
||||
*/
|
||||
String processAgentOutput(String text, Long conversationId, Long agentId, Long tenantId, Long userId, String imChannelCode);
|
||||
|
||||
default String processAgentOutput(String text, Long conversationId, Long agentId, Long tenantId, Long userId) {
|
||||
return processAgentOutput(text, conversationId, agentId, tenantId, userId, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigDto;
|
||||
import com.xspaceagi.im.application.dto.ImChannelStatisticsResponse;
|
||||
import com.xspaceagi.im.infra.dao.enitity.ImChannelConfig;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ImChannelConfigApplicationService {
|
||||
|
||||
ImChannelConfigDto getFeishuConfigByAppId(String appId);
|
||||
|
||||
ImChannelConfigDto getDingtalkConfigByRobotCode(String robotCode);
|
||||
|
||||
ImChannelConfigDto getWeworkBotConfigByToken(String token);
|
||||
|
||||
ImChannelConfigDto getWeworkAppConfigByToken(String token);
|
||||
|
||||
/**
|
||||
* 分页查询企业微信智能机器人配置
|
||||
*/
|
||||
List<ImChannelConfigDto> listWeworkBotConfigsByPage(int offset, int limit);
|
||||
|
||||
/**
|
||||
* 分页查询企业微信自建应用配置
|
||||
*/
|
||||
List<ImChannelConfigDto> listWeworkAppConfigsByPage(int offset, int limit);
|
||||
|
||||
/**
|
||||
* 分页查询已启用的微信 iLink 配置(跨租户,供长轮询 worker 使用)
|
||||
*/
|
||||
List<ImChannelConfigDto> listWechatIlinkEnabledByPage(int offset, int limit);
|
||||
|
||||
/**
|
||||
* 按 ilinkAccountId 查询微信 iLink 配置
|
||||
*/
|
||||
ImChannelConfigDto getWechatIlinkConfigByIlinkAccountId(String ilinkAccountId);
|
||||
|
||||
/**
|
||||
* 同租户、同空间下按 {@code configData.ilinkUserId} 查找一条微信 iLink 配置(重复扫码时用于原地更新 targetId/configData)。
|
||||
* 若存在多条历史重复,取 id 最大的一条。
|
||||
*/
|
||||
ImChannelConfig findWechatIlinkConfigEntityByIlinkUserId(Long tenantId, Long spaceId, String ilinkUserId);
|
||||
|
||||
/**
|
||||
* 某空间下已启用且含 botToken 的微信 iLink 配置(用于主动/定时出站选择渠道)。
|
||||
*/
|
||||
List<ImChannelConfigDto> listEnabledWechatIlinkInSpace(Long spaceId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 主动/定时任务须显式传 {@code im_channel_config.id};若未传则仅当空间内仅有一条启用配置时允许推断,否则抛出业务异常(对齐 1.0.3 delivery.accountId)。
|
||||
*/
|
||||
Long resolveExplicitOrUniqueWechatIlinkConfigId(Long spaceId, Long tenantId, Long explicitConfigId);
|
||||
|
||||
/**
|
||||
* 当前租户下按登录用户解析一条可推送的微信 iLink 配置主键
|
||||
* {@code botId} 对应 {@code im_channel_config.target_id}(微信侧 ilinkAccountId);未传则按用户下匹配记录取修改时间最近的一条启用配置。
|
||||
*/
|
||||
ImChannelConfigDto resolveWechatIlinkConfigIdForUserPush(Long tenantId, Long userId, String botId);
|
||||
|
||||
List<ImChannelConfig> list(ImChannelConfig query);
|
||||
|
||||
ImChannelConfig getById(Long id);
|
||||
|
||||
/**
|
||||
* 按主键加载并解析为 DTO(含 configData)
|
||||
*/
|
||||
ImChannelConfigDto getDtoById(Long id);
|
||||
|
||||
ImChannelConfig add(ImChannelConfig config);
|
||||
|
||||
ImChannelConfig update(ImChannelConfig config, ImChannelConfig exist);
|
||||
|
||||
boolean updateEnabled(ImChannelConfig config);
|
||||
|
||||
/**
|
||||
* 微信 iLink getUpdates 判定会话/授权已失效(如 errcode=-14 且游标已清空仍无法恢复)时,
|
||||
* 将对应 {@code im_channel_config} 记录 {@code enabled} 置为 false,表示需重新扫码连接。
|
||||
*/
|
||||
void disableWechatIlinkOnSessionExpired(Long configId);
|
||||
|
||||
/**
|
||||
* 删除配置(逻辑删除)
|
||||
*/
|
||||
boolean delete(Long id);
|
||||
|
||||
/**
|
||||
* 统计配置
|
||||
*/
|
||||
List<ImChannelStatisticsResponse> statistics(Long spaceId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
import com.xspaceagi.im.infra.dao.enitity.ImSession;
|
||||
|
||||
public interface ImSessionApplicationService {
|
||||
|
||||
Long getConversationId(ImSession imSession);
|
||||
|
||||
Long createNewConversationId(ImSession imSession);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 微信 iLink 渠道智能体执行
|
||||
*/
|
||||
public interface WechatIlinkAgentApplicationService {
|
||||
|
||||
/**
|
||||
* 非阻塞:在 {@link reactor.core.scheduler.Schedulers#boundedElastic()} 上执行会话与智能体链路,避免调用方占用线程池。
|
||||
*/
|
||||
Mono<WeworkAgentApplicationService.AgentExecuteResultWithConv> executeAgentWithConv(
|
||||
String fromUserId,
|
||||
String userMessage,
|
||||
List<AttachmentDto> attachments,
|
||||
Long tenantId,
|
||||
Long userId,
|
||||
Long agentId,
|
||||
String sessionName);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigDto;
|
||||
|
||||
public interface WechatIlinkPushApplicationService {
|
||||
|
||||
void pushText(Long configId, String message);
|
||||
|
||||
void pushText(ImChannelConfigDto dto, String message);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.xspaceagi.im.application;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.application.dto.StreamChunk;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 企业微信智能机器人智能体执行服务
|
||||
*/
|
||||
public interface WeworkAgentApplicationService {
|
||||
|
||||
/**
|
||||
* 执行智能体并返回最终输出文本
|
||||
*
|
||||
* @param chatType 会话类型:single 单聊,group 群聊
|
||||
* @param chatId 群聊时的 chatid,单聊时可为空
|
||||
* @param targetType 目标类型:bot 机器人,app 应用
|
||||
*/
|
||||
String executeAgent(String senderId, String message, String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId);
|
||||
|
||||
/**
|
||||
* 执行智能体并返回最终输出文本(支持附件)
|
||||
*/
|
||||
String executeAgent(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId);
|
||||
|
||||
/**
|
||||
* 执行智能体并返回结果和会话ID(用于内容后处理)
|
||||
*/
|
||||
AgentExecuteResultWithConv executeAgentWithConv(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName);
|
||||
|
||||
default AgentExecuteResultWithConv executeAgentWithConv(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgentWithConv(senderId, message, attachments, chatType, chatId, targetType, tenantId, userId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式执行智能体,返回增量输出的 Flux
|
||||
*/
|
||||
Flux<StreamChunk> executeAgentStream(String senderId, String message, String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName);
|
||||
|
||||
default Flux<StreamChunk> executeAgentStream(String senderId, String message, String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgentStream(senderId, message, chatType, chatId, targetType, tenantId, userId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式执行智能体(支持附件)
|
||||
*/
|
||||
Flux<StreamChunk> executeAgentStream(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName);
|
||||
|
||||
default Flux<StreamChunk> executeAgentStream(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgentStream(senderId, message, attachments, chatType, chatId, targetType, tenantId, userId, agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体执行结果(包含会话ID)
|
||||
*/
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
@lombok.NoArgsConstructor
|
||||
@lombok.AllArgsConstructor
|
||||
class AgentExecuteResultWithConv {
|
||||
private String text;
|
||||
private Long conversationId;
|
||||
private Long agentId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.xspaceagi.im.application.config;
|
||||
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkHttpClient;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* iLink HTTP 客户端 Bean(对齐 openclaw-weixin 网关调用)
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(WechatIlinkProperties.class)
|
||||
public class WechatIlinkClientConfiguration {
|
||||
|
||||
@Bean
|
||||
public IlinkHttpClient ilinkHttpClient(
|
||||
@Value("${wechat.ilink.sk-route-tag:}") String skRouteTag) {
|
||||
String tag = StringUtils.isBlank(skRouteTag) ? null : skRouteTag.trim();
|
||||
return new IlinkHttpClient(tag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.xspaceagi.im.application.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 微信 iLink 模块配置(对齐 openclaw-weixin 1.0.3 临时目录与可观测项)。
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "wechat.ilink")
|
||||
public class WechatIlinkProperties {
|
||||
|
||||
/**
|
||||
* 媒体/下载等使用的根目录;未配置时使用 {@code java.io.tmpdir} 下子目录。
|
||||
*/
|
||||
private String workDir;
|
||||
|
||||
/**
|
||||
* 解析并创建可写目录:{@code workDir/wechat-ilink} 或 {@code java.io.tmpdir/wechat-ilink}。
|
||||
*/
|
||||
public Path resolveWorkDirectory() throws IOException {
|
||||
String base = StringUtils.isNotBlank(workDir) ? workDir.trim() : System.getProperty("java.io.tmpdir");
|
||||
Path p = Paths.get(base, "wechat-ilink");
|
||||
Files.createDirectories(p);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.xspaceagi.im.application.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IM 渠道配置聚合 DTO
|
||||
*/
|
||||
@Data
|
||||
public class ImChannelConfigDto {
|
||||
|
||||
/** 配置主键(列表/长轮询等场景) */
|
||||
private Long id;
|
||||
|
||||
private Boolean enabled;
|
||||
|
||||
// 通用元信息
|
||||
private Long tenantId;
|
||||
private Long userId;
|
||||
private Long agentId;
|
||||
|
||||
// 输出方式:stream(流式输出)/once(一次性输出)
|
||||
private String outputMode;
|
||||
|
||||
// 当前记录所属平台、类型
|
||||
private String channel;
|
||||
private String targetType;
|
||||
|
||||
// 各渠道配置
|
||||
private FeishuConfig feishu;
|
||||
private DingtalkConfig dingtalk;
|
||||
private WeworkBotConfig weworkBot;
|
||||
private WeworkAppConfig weworkApp;
|
||||
private WechatIlinkConfig wechatIlink;
|
||||
|
||||
@Data
|
||||
public static class FeishuConfig {
|
||||
private String appId;
|
||||
private String appSecret;
|
||||
private String verificationToken;
|
||||
private String encryptKey;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class DingtalkConfig {
|
||||
private String clientId;
|
||||
private String clientSecret;
|
||||
private String robotCode;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class WeworkBotConfig {
|
||||
private String aibotId;
|
||||
private String corpId;
|
||||
private String corpSecret;
|
||||
private String token;
|
||||
private String encodingAesKey;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class WeworkAppConfig {
|
||||
private String agentId;
|
||||
private String corpId;
|
||||
private String corpSecret;
|
||||
private String token;
|
||||
private String encodingAesKey;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class WechatIlinkConfig {
|
||||
/** 网关基址,如 https://ilinkai.weixin.qq.com */
|
||||
private String baseUrl;
|
||||
private String botToken;
|
||||
/** 默认与 openclaw 插件一致为 "3" */
|
||||
private String botType;
|
||||
/** CDN 基址,默认可用 IlinkConstants.CDN_BASE_URL */
|
||||
private String cdnBaseUrl;
|
||||
/** 规范后的账号 id,作为 targetId */
|
||||
private String ilinkAccountId;
|
||||
/** 扫码绑定的微信用户 id(可选) */
|
||||
private String ilinkUserId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.xspaceagi.im.application.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* IM 渠道配置响应
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道配置响应")
|
||||
public class ImChannelConfigResponse {
|
||||
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "渠道类型:feishu/dingtalk/wework")
|
||||
private String channel;
|
||||
|
||||
@Schema(description = "目标类型:bot/app")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "目标唯一标识")
|
||||
private String targetId;
|
||||
|
||||
@Schema(description = "关联智能体ID")
|
||||
private Long agentId;
|
||||
|
||||
@Schema(description = "关联智能体名称")
|
||||
private String agentName;
|
||||
|
||||
@Schema(description = "关联智能体图标")
|
||||
private String agentIcon;
|
||||
|
||||
@Schema(description = "关联智能体描述")
|
||||
private String agentDescription;
|
||||
|
||||
@Schema(description = "是否启用")
|
||||
private Boolean enabled;
|
||||
|
||||
@Schema(description = "渠道专有配置(JSON 字符串)")
|
||||
private String configData;
|
||||
|
||||
@Schema(description = "输出方式:stream(流式输出)/once(一次性输出)")
|
||||
private String outputMode;
|
||||
|
||||
@Schema(description = "配置名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "空间ID")
|
||||
private Long spaceId;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date created;
|
||||
|
||||
@Schema(description = "创建者ID")
|
||||
private Long creatorId;
|
||||
|
||||
@Schema(description = "创建者名称")
|
||||
private String creatorName;
|
||||
|
||||
@Schema(description = "修改时间")
|
||||
private Date modified;
|
||||
|
||||
@Schema(description = "修改者ID")
|
||||
private Long modifiedId;
|
||||
|
||||
@Schema(description = "修改者名称")
|
||||
private String modifiedName;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.xspaceagi.im.application.dto;
|
||||
|
||||
import com.xspaceagi.system.spec.annotation.I18n;
|
||||
import com.xspaceagi.system.spec.annotation.I18nField;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@I18n(module = "ImChannel")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "IM 渠道统计响应")
|
||||
public class ImChannelStatisticsResponse {
|
||||
|
||||
@I18nField(keyPrefix = true)
|
||||
@Schema(description = "渠道类型:feishu/dingtalk/wework")
|
||||
private String channel;
|
||||
|
||||
@I18nField(field = "name")
|
||||
@Schema(description = "渠道名称")
|
||||
private String channelName;
|
||||
|
||||
@Schema(description = "配置条数")
|
||||
private Long count;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.xspaceagi.im.application.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 流式输出数据块
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class StreamChunk {
|
||||
|
||||
/**
|
||||
* 当前累积的文本内容
|
||||
*/
|
||||
private String text;
|
||||
|
||||
/**
|
||||
* 是否为最终结果
|
||||
*/
|
||||
private boolean isFinal;
|
||||
|
||||
/**
|
||||
* 会话 ID
|
||||
*/
|
||||
private Long conversationId;
|
||||
|
||||
public StreamChunk(String text, boolean isFinal) {
|
||||
this.text = text;
|
||||
this.isFinal = isFinal;
|
||||
this.conversationId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.xspaceagi.im.application.impl;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.ConversationApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AgentOutputDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.ChatMessageDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.TryReqDto;
|
||||
import com.xspaceagi.agent.core.infra.component.agent.dto.AgentExecuteResult;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.CallMessage;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.ComponentExecutingDto;
|
||||
import com.xspaceagi.im.application.DingtalkAgentApplicationService;
|
||||
import com.xspaceagi.im.application.ImSessionApplicationService;
|
||||
import com.xspaceagi.im.application.dto.StreamChunk;
|
||||
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.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import com.xspaceagi.system.application.service.UserApplicationService;
|
||||
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 reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉机器人智能体执行服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DingtalkAgentApplicationServiceImpl implements DingtalkAgentApplicationService {
|
||||
|
||||
@Resource
|
||||
private UserApplicationService userApplicationService;
|
||||
@Resource
|
||||
private ImSessionApplicationService imSessionApplicationService;
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
@Resource
|
||||
private ConversationApplicationService conversationApplicationService;
|
||||
|
||||
@Override
|
||||
public String executeAgent(String senderId, String message, String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgent(senderId, message, null, conversationType, conversationId, tenantId, userId, agentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String executeAgent(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
AgentExecuteResultWithConv result = executeAgentWithConv(senderId, message, attachments, conversationType, conversationId, tenantId, userId, agentId);
|
||||
return result.getText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentExecuteResultWithConv executeAgentWithConv(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName) {
|
||||
if (StringUtils.isBlank(senderId) || StringUtils.isBlank(message)) {
|
||||
return AgentExecuteResultWithConv.builder().text("消息内容不能为空").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
|
||||
if (agentId == null || agentId <= 0) {
|
||||
log.warn("DingTalk agent-id not configured");
|
||||
return AgentExecuteResultWithConv.builder().text("钉钉智能体未配置,请联系管理员").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(tenantId);
|
||||
requestContext.setTenantConfig(tenantConfigApplicationService.getTenantConfig(tenantId));
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
try {
|
||||
UserDto userDto = userApplicationService.queryById(userId);
|
||||
if (userDto == null) {
|
||||
return AgentExecuteResultWithConv.builder().text("系统用户不存在").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
requestContext.setUser(userDto);
|
||||
requestContext.setUserId(userId);
|
||||
|
||||
Long convId = getConversationId(senderId, conversationType, conversationId, userId, agentId, tenantId, sessionName);
|
||||
if (convId == null) {
|
||||
return AgentExecuteResultWithConv.builder().text("创建会话失败").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
|
||||
TryReqDto tryReqDto = new TryReqDto();
|
||||
tryReqDto.setConversationId(convId);
|
||||
tryReqDto.setMessage(message);
|
||||
tryReqDto.setAttachments(attachments != null ? attachments : new ArrayList<>());
|
||||
tryReqDto.setFrom(ImChannelEnum.DINGTALK.getCode());
|
||||
|
||||
Flux<AgentOutputDto> flux = conversationApplicationService.chat(tryReqDto, new HashMap<>(), false);
|
||||
|
||||
AgentExecuteResult finalResult = flux
|
||||
.filter(o -> o.getEventType() == AgentOutputDto.EventTypeEnum.FINAL_RESULT)
|
||||
.map(o -> (AgentExecuteResult) o.getData())
|
||||
.next()
|
||||
.block();
|
||||
|
||||
if (finalResult == null) {
|
||||
return AgentExecuteResultWithConv.builder().text("执行超时或未返回结果").conversationId(convId).agentId(agentId).build();
|
||||
}
|
||||
if (Boolean.FALSE.equals(finalResult.getSuccess())) {
|
||||
String err = StringUtils.isNotBlank(finalResult.getError()) ? finalResult.getError() : "模型执行失败";
|
||||
return AgentExecuteResultWithConv.builder().text(err).conversationId(convId).agentId(agentId).build();
|
||||
}
|
||||
String out = StringUtils.isNotBlank(finalResult.getOutputText()) ? finalResult.getOutputText() : "模型终止执行";
|
||||
return AgentExecuteResultWithConv.builder().text(out).conversationId(convId).agentId(agentId).build();
|
||||
} catch (Exception e) {
|
||||
log.error("DingTalk agent error: senderId={}", senderId, e);
|
||||
String err = e.getMessage() != null ? e.getMessage() : "模型执行异常";
|
||||
return AgentExecuteResultWithConv.builder().text(err).conversationId(null).agentId(agentId).build();
|
||||
} finally {
|
||||
RequestContext.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<StreamChunk> executeAgentStream(String senderId, String message, String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgentStream(senderId, message, null, conversationType, conversationId, tenantId, userId, agentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<StreamChunk> executeAgentStream(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String conversationType, String conversationId,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
if (StringUtils.isBlank(senderId) || StringUtils.isBlank(message)) {
|
||||
return Flux.just(new StreamChunk("消息内容不能为空", true));
|
||||
}
|
||||
if (agentId == null || agentId <= 0) {
|
||||
return Flux.just(new StreamChunk("钉钉智能体未配置,请联系管理员", true));
|
||||
}
|
||||
|
||||
return Flux.<StreamChunk>defer(() -> {
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(tenantId);
|
||||
requestContext.setTenantConfig(tenantConfigApplicationService.getTenantConfig(tenantId));
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
UserDto userDto = userApplicationService.queryById(userId);
|
||||
if (userDto == null) {
|
||||
RequestContext.remove();
|
||||
return Flux.just(new StreamChunk("系统用户不存在", true));
|
||||
}
|
||||
requestContext.setUser(userDto);
|
||||
requestContext.setUserId(userId);
|
||||
|
||||
Long convId = getConversationId(senderId, conversationType, conversationId, userId, agentId, tenantId, null);
|
||||
if (convId == null) {
|
||||
RequestContext.remove();
|
||||
return Flux.just(new StreamChunk("创建会话失败", true));
|
||||
}
|
||||
|
||||
TryReqDto tryReqDto = new TryReqDto();
|
||||
tryReqDto.setConversationId(convId);
|
||||
tryReqDto.setMessage(message);
|
||||
tryReqDto.setAttachments(attachments != null ? attachments : new ArrayList<>());
|
||||
tryReqDto.setFrom(ImChannelEnum.DINGTALK.getCode());
|
||||
|
||||
Flux<AgentOutputDto> chatFlux = conversationApplicationService.chat(tryReqDto, new HashMap<>(), false);
|
||||
StringBuilder accumulated = new StringBuilder();
|
||||
|
||||
return chatFlux
|
||||
.flatMap(event -> {
|
||||
StreamChunk chunk = null;
|
||||
if (event.getEventType() == AgentOutputDto.EventTypeEnum.MESSAGE && event.getData() instanceof ChatMessageDto) {
|
||||
String text = ((ChatMessageDto) event.getData()).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
chunk = new StreamChunk(accumulated.toString(), false, convId);
|
||||
}
|
||||
} else if (event.getEventType() == AgentOutputDto.EventTypeEnum.PROCESSING_MESSAGE && event.getData() instanceof ComponentExecutingDto) {
|
||||
Object exec = ((ComponentExecutingDto) event.getData()).getExecutingMessage();
|
||||
if (exec instanceof CallMessage) {
|
||||
String text = ((CallMessage) exec).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
chunk = new StreamChunk(accumulated.toString(), false, convId);
|
||||
}
|
||||
}
|
||||
} else if (event.getEventType() == AgentOutputDto.EventTypeEnum.FINAL_RESULT && event.getData() instanceof AgentExecuteResult) {
|
||||
AgentExecuteResult result = (AgentExecuteResult) event.getData();
|
||||
String finalText = result.getOutputText();
|
||||
if (Boolean.FALSE.equals(result.getSuccess()) && StringUtils.isNotBlank(result.getError())) {
|
||||
finalText = result.getError();
|
||||
}
|
||||
if (finalText == null) {
|
||||
finalText = accumulated.toString();
|
||||
}
|
||||
chunk = new StreamChunk(finalText != null ? finalText : "模型终止执行", true, convId);
|
||||
}
|
||||
return Mono.justOrEmpty(chunk);
|
||||
})
|
||||
.onErrorResume(e -> Flux.just(new StreamChunk("模型执行异常: " + (e.getMessage() != null ? e.getMessage() : "未知错误"), true, null)))
|
||||
.doFinally(s -> RequestContext.remove());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建会话。单聊用发送者 id 构建会话 key,群聊用群 id 构建会话 key。
|
||||
*
|
||||
* @param conversationType "1" 单聊,"2" 群聊
|
||||
* @param conversationId 群聊时的 openConversationId,单聊时可为空
|
||||
*/
|
||||
private Long getConversationId(String senderId, String conversationType, String conversationId,
|
||||
Long userId, Long agentId, Long tenantId, String sessionName) {
|
||||
String sessionKey;
|
||||
if ("2".equals(conversationType) && StringUtils.isNotBlank(conversationId)) {
|
||||
sessionKey = conversationId; // 群聊:用群 id
|
||||
} else {
|
||||
sessionKey = senderId; // 单聊:用发送用户 id
|
||||
}
|
||||
|
||||
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(userId)
|
||||
.agentId(agentId)
|
||||
.tenantId(tenantId)
|
||||
.build();
|
||||
return imSessionApplicationService.getConversationId(imSession);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.xspaceagi.im.application.impl;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.ConversationApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AgentOutputDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.ChatMessageDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.TryReqDto;
|
||||
import com.xspaceagi.agent.core.infra.component.agent.dto.AgentExecuteResult;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.CallMessage;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.ComponentExecutingDto;
|
||||
import com.xspaceagi.im.application.FeishuAgentApplicationService;
|
||||
import com.xspaceagi.im.application.ImSessionApplicationService;
|
||||
import com.xspaceagi.im.application.dto.StreamChunk;
|
||||
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.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import com.xspaceagi.system.application.service.UserApplicationService;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 飞书机器人智能体执行服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FeishuAgentApplicationServiceImpl implements FeishuAgentApplicationService {
|
||||
|
||||
@Resource
|
||||
private UserApplicationService userApplicationService;
|
||||
@Resource
|
||||
private ImSessionApplicationService imSessionApplicationService;
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
@Resource
|
||||
private ConversationApplicationService conversationApplicationService;
|
||||
|
||||
@Override
|
||||
public String executeAgent(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId) {
|
||||
AgentExecuteResultWithConv result = executeAgentWithConv(sessionId, chatType, message, attachments, tenantId, userId, agentId);
|
||||
return result.getText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentExecuteResultWithConv executeAgentWithConv(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId, String sessionName) {
|
||||
if (StringUtils.isBlank(sessionId) || StringUtils.isBlank(message)) {
|
||||
return AgentExecuteResultWithConv.builder().text("消息内容不能为空").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
|
||||
if (agentId == null || agentId <= 0) {
|
||||
log.warn("Feishu agent-id (feishu.agent-id) not configured");
|
||||
return AgentExecuteResultWithConv.builder().text("飞书智能体未配置,请联系管理员").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
if (tenantId == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "租户ID");
|
||||
}
|
||||
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(tenantId);
|
||||
requestContext.setTenantConfig(tenantConfigApplicationService.getTenantConfig(tenantId));
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
try {
|
||||
UserDto userDto = userApplicationService.queryById(userId);
|
||||
if (userDto == null) {
|
||||
return AgentExecuteResultWithConv.builder().text("系统用户不存在").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
requestContext.setUser(userDto);
|
||||
requestContext.setUserId(userId);
|
||||
// 防止外部组件/拦截器在中途清理 ThreadLocal,执行关键链路前再次写入
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
Long conversationId = getConversationId(sessionId, chatType, userId, agentId, tenantId, sessionName);
|
||||
if (conversationId == null) {
|
||||
return AgentExecuteResultWithConv.builder().text("创建会话失败").conversationId(null).agentId(agentId).build();
|
||||
}
|
||||
|
||||
TryReqDto tryReqDto = new TryReqDto();
|
||||
tryReqDto.setConversationId(conversationId);
|
||||
tryReqDto.setMessage(message);
|
||||
tryReqDto.setAttachments(attachments != null ? attachments : new ArrayList<>());
|
||||
tryReqDto.setFrom(ImChannelEnum.FEISHU.getCode());
|
||||
|
||||
Flux<AgentOutputDto> flux = conversationApplicationService.chat(tryReqDto, new HashMap<>(), false);
|
||||
|
||||
AgentExecuteResult finalResult = flux
|
||||
.filter(o -> o.getEventType() == AgentOutputDto.EventTypeEnum.FINAL_RESULT)
|
||||
.map(o -> (AgentExecuteResult) o.getData())
|
||||
.next()
|
||||
.block();
|
||||
|
||||
if (finalResult == null) {
|
||||
return AgentExecuteResultWithConv.builder().text("执行超时或未返回结果").conversationId(conversationId).agentId(agentId).build();
|
||||
}
|
||||
if (Boolean.FALSE.equals(finalResult.getSuccess())) {
|
||||
String err = StringUtils.isNotBlank(finalResult.getError()) ? finalResult.getError() : "模型执行失败";
|
||||
return AgentExecuteResultWithConv.builder().text(err).conversationId(conversationId).agentId(agentId).build();
|
||||
}
|
||||
String out = StringUtils.isNotBlank(finalResult.getOutputText()) ? finalResult.getOutputText() : "模型终止执行";
|
||||
return AgentExecuteResultWithConv.builder().text(out).conversationId(conversationId).agentId(agentId).build();
|
||||
} catch (Exception e) {
|
||||
log.error("Feishu agent error: sessionId={}", sessionId, e);
|
||||
String err = e.getMessage() != null ? e.getMessage() : "模型执行异常";
|
||||
return AgentExecuteResultWithConv.builder().text(err).conversationId(null).agentId(agentId).build();
|
||||
} finally {
|
||||
RequestContext.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<StreamChunk> executeAgentStream(String sessionId, String chatType, String message, List<AttachmentDto> attachments, Long tenantId, Long userId, Long agentId, String sessionName) {
|
||||
if (StringUtils.isBlank(sessionId) || StringUtils.isBlank(message)) {
|
||||
return Flux.just(new StreamChunk("消息内容不能为空", true));
|
||||
}
|
||||
if (agentId == null || agentId <= 0) {
|
||||
return Flux.just(new StreamChunk("飞书智能体未配置,请联系管理员", true));
|
||||
}
|
||||
|
||||
return Flux.<StreamChunk>defer(() -> {
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(tenantId);
|
||||
requestContext.setTenantConfig(tenantConfigApplicationService.getTenantConfig(tenantId));
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
UserDto userDto = userApplicationService.queryById(userId);
|
||||
if (userDto == null) {
|
||||
RequestContext.remove();
|
||||
return Flux.just(new StreamChunk("系统用户不存在", true));
|
||||
}
|
||||
requestContext.setUser(userDto);
|
||||
requestContext.setUserId(userId);
|
||||
// 防止外部组件/拦截器在中途清理 ThreadLocal,执行关键链路前再次写入
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
Long conversationId = getConversationId(sessionId, chatType, userId, agentId, tenantId, sessionName);
|
||||
if (conversationId == null) {
|
||||
RequestContext.remove();
|
||||
return Flux.just(new StreamChunk("创建会话失败", true));
|
||||
}
|
||||
|
||||
TryReqDto tryReqDto = new TryReqDto();
|
||||
tryReqDto.setConversationId(conversationId);
|
||||
tryReqDto.setMessage(message);
|
||||
tryReqDto.setAttachments(attachments != null ? attachments : new ArrayList<>());
|
||||
tryReqDto.setFrom(ImChannelEnum.FEISHU.getCode());
|
||||
|
||||
Flux<AgentOutputDto> chatFlux = conversationApplicationService.chat(tryReqDto, new HashMap<>(), false);
|
||||
StringBuilder accumulated = new StringBuilder();
|
||||
|
||||
return chatFlux
|
||||
.flatMap(event -> {
|
||||
StreamChunk chunk = null;
|
||||
if (event.getEventType() == AgentOutputDto.EventTypeEnum.MESSAGE && event.getData() instanceof ChatMessageDto) {
|
||||
String text = ((ChatMessageDto) event.getData()).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
chunk = new StreamChunk(accumulated.toString(), false, conversationId);
|
||||
}
|
||||
} else if (event.getEventType() == AgentOutputDto.EventTypeEnum.PROCESSING_MESSAGE && event.getData() instanceof ComponentExecutingDto) {
|
||||
Object exec = ((ComponentExecutingDto) event.getData()).getExecutingMessage();
|
||||
if (exec instanceof CallMessage) {
|
||||
String text = ((CallMessage) exec).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
chunk = new StreamChunk(accumulated.toString(), false, conversationId);
|
||||
}
|
||||
}
|
||||
} else if (event.getEventType() == AgentOutputDto.EventTypeEnum.FINAL_RESULT && event.getData() instanceof AgentExecuteResult) {
|
||||
AgentExecuteResult result = (AgentExecuteResult) event.getData();
|
||||
String finalText = result.getOutputText();
|
||||
if (Boolean.FALSE.equals(result.getSuccess()) && StringUtils.isNotBlank(result.getError())) {
|
||||
finalText = result.getError();
|
||||
}
|
||||
if (finalText == null) {
|
||||
finalText = accumulated.toString();
|
||||
}
|
||||
chunk = new StreamChunk(finalText != null ? finalText : "模型终止执行", true, conversationId);
|
||||
}
|
||||
return Mono.justOrEmpty(chunk);
|
||||
})
|
||||
.onErrorResume(e -> Flux.just(new StreamChunk("模型执行异常: " + (e.getMessage() != null ? e.getMessage() : "未知错误"), true)))
|
||||
.doFinally(s -> RequestContext.remove());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建会话 ID。单聊用 sessionKey 构建会话 key,群聊也用 sessionKey
|
||||
*
|
||||
* @param chatType "p2p" 单聊,"group" 群聊
|
||||
*/
|
||||
private Long getConversationId(String sessionKey, String chatType, Long userId, Long agentId, Long tenantId, String sessionName) {
|
||||
ImSession imSession = ImSession.builder()
|
||||
.channel(ImChannelEnum.FEISHU.getCode())
|
||||
.targetType(ImTargetTypeEnum.BOT.getCode())
|
||||
.sessionKey(sessionKey)
|
||||
.sessionName(sessionName)
|
||||
.chatType("p2p".equals(chatType) ? ImChatTypeEnum.PRIVATE.getCode() : ImChatTypeEnum.GROUP.getCode())
|
||||
.userId(userId)
|
||||
.agentId(agentId)
|
||||
.tenantId(tenantId)
|
||||
.build();
|
||||
return imSessionApplicationService.getConversationId(imSession);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package com.xspaceagi.im.application.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.xspaceagi.im.application.ImChannelConfigApplicationService;
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigDto;
|
||||
import com.xspaceagi.im.application.dto.ImChannelStatisticsResponse;
|
||||
import com.xspaceagi.im.application.wechat.WechatIlinkLongPollService;
|
||||
import com.xspaceagi.im.domain.service.ImChannelConfigDomainService;
|
||||
import com.xspaceagi.im.infra.dao.enitity.ImChannelConfig;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.infra.enums.ImOutputModeEnum;
|
||||
import com.xspaceagi.im.infra.enums.ImTargetTypeEnum;
|
||||
import com.xspaceagi.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
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.tenant.thread.TenantFunctions;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImChannelConfigApplicationServiceImpl implements ImChannelConfigApplicationService {
|
||||
|
||||
@Resource
|
||||
private ImChannelConfigDomainService imChannelConfigDomainService;
|
||||
@Lazy
|
||||
@Resource
|
||||
private WechatIlinkLongPollService wechatIlinkLongPollService;
|
||||
|
||||
@Override
|
||||
public ImChannelConfigDto getFeishuConfigByAppId(String appId) {
|
||||
return getConfig(ImChannelEnum.FEISHU.getCode(), ImTargetTypeEnum.BOT, appId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfigDto getDingtalkConfigByRobotCode(String robotCode) {
|
||||
return getConfig(ImChannelEnum.DINGTALK.getCode(), ImTargetTypeEnum.BOT, robotCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfigDto getWeworkBotConfigByToken(String token) {
|
||||
return getConfig(ImChannelEnum.WEWORK.getCode(), ImTargetTypeEnum.BOT, token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfigDto getWeworkAppConfigByToken(String token) {
|
||||
return getConfig(ImChannelEnum.WEWORK.getCode(), ImTargetTypeEnum.APP, token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ImChannelConfigDto> listWeworkBotConfigsByPage(int offset, int limit) {
|
||||
// 跨租户分页查询企业微信智能机器人配置(用于 webhook 回调场景)
|
||||
List<ImChannelConfig> configs = TenantFunctions.callWithIgnoreCheck(() -> {
|
||||
ImChannelConfig query = new ImChannelConfig();
|
||||
query.setChannel(ImChannelEnum.WEWORK.getCode());
|
||||
query.setTargetType(ImTargetTypeEnum.BOT.getCode());
|
||||
query.setEnabled(true);
|
||||
return imChannelConfigDomainService.listByPage(query, offset, limit);
|
||||
});
|
||||
if (configs == null || configs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return configs.stream()
|
||||
.map(this::toDto)
|
||||
.filter(cfg -> cfg != null && cfg.getWeworkBot() != null)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ImChannelConfigDto> listWechatIlinkEnabledByPage(int offset, int limit) {
|
||||
List<ImChannelConfig> configs = TenantFunctions.callWithIgnoreCheck(() -> {
|
||||
ImChannelConfig query = new ImChannelConfig();
|
||||
query.setChannel(ImChannelEnum.WECHAT_ILINK.getCode());
|
||||
query.setTargetType(ImTargetTypeEnum.BOT.getCode());
|
||||
query.setEnabled(true);
|
||||
return imChannelConfigDomainService.listByPage(query, offset, limit);
|
||||
});
|
||||
if (configs == null || configs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return configs.stream()
|
||||
.map(this::toDto)
|
||||
.filter(cfg -> cfg != null && cfg.getWechatIlink() != null)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfigDto getWechatIlinkConfigByIlinkAccountId(String ilinkAccountId) {
|
||||
return getConfig(ImChannelEnum.WECHAT_ILINK.getCode(), ImTargetTypeEnum.BOT, ilinkAccountId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfig findWechatIlinkConfigEntityByIlinkUserId(Long tenantId, Long spaceId, String ilinkUserId) {
|
||||
if (tenantId == null || spaceId == null || StringUtils.isBlank(ilinkUserId)) {
|
||||
return null;
|
||||
}
|
||||
ImChannelConfig query = new ImChannelConfig();
|
||||
query.setChannel(ImChannelEnum.WECHAT_ILINK.getCode());
|
||||
query.setTargetType(ImTargetTypeEnum.BOT.getCode());
|
||||
query.setSpaceId(spaceId);
|
||||
query.setTenantId(tenantId);
|
||||
List<ImChannelConfig> all = imChannelConfigDomainService.list(query);
|
||||
if (CollectionUtils.isEmpty(all)) {
|
||||
return null;
|
||||
}
|
||||
return all.stream()
|
||||
.filter(c -> ilinkUserId.equals(extractIlinkUserIdFromConfigData(c.getConfigData())))
|
||||
.max(Comparator.comparing(ImChannelConfig::getId))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ImChannelConfigDto> listEnabledWechatIlinkInSpace(Long spaceId, Long tenantId) {
|
||||
if (spaceId == null || tenantId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
ImChannelConfig query = new ImChannelConfig();
|
||||
query.setChannel(ImChannelEnum.WECHAT_ILINK.getCode());
|
||||
query.setTargetType(ImTargetTypeEnum.BOT.getCode());
|
||||
query.setSpaceId(spaceId);
|
||||
query.setTenantId(tenantId);
|
||||
query.setEnabled(true);
|
||||
List<ImChannelConfig> raw = imChannelConfigDomainService.list(query);
|
||||
if (CollectionUtils.isEmpty(raw)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return raw.stream()
|
||||
.map(this::toDto)
|
||||
.filter(cfg -> cfg != null
|
||||
&& cfg.getWechatIlink() != null
|
||||
&& StringUtils.isNotBlank(cfg.getWechatIlink().getBotToken()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long resolveExplicitOrUniqueWechatIlinkConfigId(Long spaceId, Long tenantId, Long explicitConfigId) {
|
||||
List<ImChannelConfigDto> list = listEnabledWechatIlinkInSpace(spaceId, tenantId);
|
||||
if (explicitConfigId != null) {
|
||||
boolean ok = list.stream().anyMatch(d -> explicitConfigId.equals(d.getId()));
|
||||
if (!ok) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imChannelConfigInvalidOrUnavailable);
|
||||
}
|
||||
return explicitConfigId;
|
||||
}
|
||||
if (list.size() == 1) {
|
||||
return list.get(0).getId();
|
||||
}
|
||||
if (list.isEmpty()) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imNoWechatIlinkInSpace);
|
||||
}
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imMultipleWechatIlinkNeedExplicitId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfigDto resolveWechatIlinkConfigIdForUserPush(Long tenantId, Long userId, String botId) {
|
||||
if (tenantId == null || userId == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imTenantOrUserMissing);
|
||||
}
|
||||
ImChannelConfig q = new ImChannelConfig();
|
||||
q.setChannel(ImChannelEnum.WECHAT_ILINK.getCode());
|
||||
q.setTargetType(ImTargetTypeEnum.BOT.getCode());
|
||||
q.setUserId(userId);
|
||||
q.setTenantId(tenantId);
|
||||
if (StringUtils.isNotBlank(botId)) {
|
||||
q.setTargetId(botId);
|
||||
}
|
||||
List<ImChannelConfig> list = imChannelConfigDomainService.list(q);
|
||||
if (CollectionUtils.isEmpty(list)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM,
|
||||
StringUtils.isNotBlank(botId) ? BizExceptionCodeEnum.imWechatChannelNotFoundForBot : BizExceptionCodeEnum.imWechatChannelNotFoundForUser);
|
||||
}
|
||||
// list 按 modified 降序;在仍为启用状态的记录中取第一条,若全部为未启用则报错
|
||||
ImChannelConfig cfg = list.stream()
|
||||
.filter(c -> Boolean.TRUE.equals(c.getEnabled()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatChannelDisabled));
|
||||
ImChannelConfigDto dto = toDto(cfg);
|
||||
if (dto == null || dto.getWechatIlink() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatIlinkParseFailed);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private static String extractIlinkUserIdFromConfigData(String configData) {
|
||||
if (StringUtils.isBlank(configData)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(configData);
|
||||
return json != null ? json.getString("ilinkUserId") : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ImChannelConfigDto> listWeworkAppConfigsByPage(int offset, int limit) {
|
||||
// 跨租户分页查询企业微信自建应用配置(用于 webhook 回调场景)
|
||||
List<ImChannelConfig> configs = TenantFunctions.callWithIgnoreCheck(() -> {
|
||||
ImChannelConfig query = new ImChannelConfig();
|
||||
query.setChannel(ImChannelEnum.WEWORK.getCode());
|
||||
query.setTargetType(ImTargetTypeEnum.APP.getCode());
|
||||
query.setEnabled(true);
|
||||
return imChannelConfigDomainService.listByPage(query, offset, limit);
|
||||
});
|
||||
if (configs == null || configs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return configs.stream()
|
||||
.map(this::toDto)
|
||||
.filter(cfg -> cfg != null && cfg.getWeworkApp() != null)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private ImChannelConfigDto getConfig(String channel, ImTargetTypeEnum targetType, String imTargetId) {
|
||||
if (StringUtils.isBlank(imTargetId)) {
|
||||
return null;
|
||||
}
|
||||
// 跨租户查询,需要忽略租户检查
|
||||
ImChannelConfig cfg = TenantFunctions.callWithIgnoreCheck(() ->
|
||||
imChannelConfigDomainService.findOne(channel, targetType.getCode(), imTargetId)
|
||||
);
|
||||
if (cfg == null || StringUtils.isBlank(cfg.getConfigData())) {
|
||||
return null;
|
||||
}
|
||||
return toDto(cfg);
|
||||
}
|
||||
|
||||
private ImChannelConfigDto toDto(ImChannelConfig cfg) {
|
||||
JSONObject json = JSON.parseObject(cfg.getConfigData());
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ImChannelConfigDto dto = new ImChannelConfigDto();
|
||||
dto.setId(cfg.getId());
|
||||
dto.setEnabled(cfg.getEnabled());
|
||||
dto.setTenantId(cfg.getTenantId());
|
||||
dto.setUserId(cfg.getUserId());
|
||||
dto.setAgentId(cfg.getAgentId());
|
||||
dto.setOutputMode(cfg.getOutputMode());
|
||||
dto.setChannel(cfg.getChannel());
|
||||
dto.setTargetType(cfg.getTargetType());
|
||||
|
||||
String channel = cfg.getChannel();
|
||||
ImTargetTypeEnum targetType = ImTargetTypeEnum.fromCode(cfg.getTargetType());
|
||||
|
||||
if (ImChannelEnum.FEISHU.getCode().equals(channel)) {
|
||||
ImChannelConfigDto.FeishuConfig feishu = new ImChannelConfigDto.FeishuConfig();
|
||||
feishu.setAppId(json.getString("appId"));
|
||||
feishu.setAppSecret(json.getString("appSecret"));
|
||||
feishu.setVerificationToken(json.getString("verificationToken"));
|
||||
feishu.setEncryptKey(json.getString("encryptKey"));
|
||||
dto.setFeishu(feishu);
|
||||
} else if (ImChannelEnum.DINGTALK.getCode().equals(channel)) {
|
||||
ImChannelConfigDto.DingtalkConfig ding = new ImChannelConfigDto.DingtalkConfig();
|
||||
ding.setClientId(json.getString("clientId"));
|
||||
ding.setClientSecret(json.getString("clientSecret"));
|
||||
ding.setRobotCode(json.getString("robotCode"));
|
||||
dto.setDingtalk(ding);
|
||||
} else if (ImChannelEnum.WEWORK.getCode().equals(channel)) {
|
||||
if (targetType == ImTargetTypeEnum.BOT) {
|
||||
ImChannelConfigDto.WeworkBotConfig bot = new ImChannelConfigDto.WeworkBotConfig();
|
||||
bot.setAibotId(json.getString("aibotId"));
|
||||
bot.setCorpId(json.getString("corpId"));
|
||||
bot.setCorpSecret(json.getString("corpSecret"));
|
||||
bot.setToken(json.getString("token"));
|
||||
bot.setEncodingAesKey(json.getString("encodingAesKey"));
|
||||
dto.setWeworkBot(bot);
|
||||
} else if (targetType == ImTargetTypeEnum.APP) {
|
||||
ImChannelConfigDto.WeworkAppConfig app = new ImChannelConfigDto.WeworkAppConfig();
|
||||
app.setAgentId(json.getString("agentId"));
|
||||
app.setCorpId(json.getString("corpId"));
|
||||
app.setCorpSecret(json.getString("corpSecret"));
|
||||
app.setToken(json.getString("token"));
|
||||
app.setEncodingAesKey(json.getString("encodingAesKey"));
|
||||
dto.setWeworkApp(app);
|
||||
}
|
||||
} else if (ImChannelEnum.WECHAT_ILINK.getCode().equals(channel) && targetType == ImTargetTypeEnum.BOT) {
|
||||
ImChannelConfigDto.WechatIlinkConfig w = new ImChannelConfigDto.WechatIlinkConfig();
|
||||
w.setBaseUrl(json.getString("baseUrl"));
|
||||
w.setBotToken(json.getString("botToken"));
|
||||
w.setBotType(json.getString("botType"));
|
||||
w.setCdnBaseUrl(json.getString("cdnBaseUrl"));
|
||||
w.setIlinkAccountId(json.getString("ilinkAccountId"));
|
||||
w.setIlinkUserId(json.getString("ilinkUserId"));
|
||||
dto.setWechatIlink(w);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ImChannelConfig> list(ImChannelConfig query) {
|
||||
RequestContext<?> requestContext = RequestContext.get();
|
||||
if (requestContext == null || requestContext.getTenantId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemGetTenantFailed);
|
||||
}
|
||||
if (query.getSpaceId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "spaceId");
|
||||
}
|
||||
|
||||
return imChannelConfigDomainService.list(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfig getById(Long id) {
|
||||
return imChannelConfigDomainService.getById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfigDto getDtoById(Long id) {
|
||||
// 微信 iLink 长轮询等后台线程无 HTTP RequestContext,需忽略租户插件对 RequestContext 的校验
|
||||
return TenantFunctions.callWithIgnoreCheck(() -> {
|
||||
ImChannelConfig cfg = imChannelConfigDomainService.getById(id);
|
||||
if (cfg == null || StringUtils.isBlank(cfg.getConfigData())) {
|
||||
return null;
|
||||
}
|
||||
return toDto(cfg);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfig add(ImChannelConfig config) {
|
||||
// 校验必要参数
|
||||
if (config == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemParamRequired);
|
||||
}
|
||||
if (config.getSpaceId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "spaceId");
|
||||
}
|
||||
if (StringUtils.isBlank(config.getChannel())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "渠道类型");
|
||||
}
|
||||
if (StringUtils.isBlank(config.getTargetType())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "目标类型");
|
||||
}
|
||||
if (config.getAgentId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "关联智能体ID");
|
||||
}
|
||||
if (StringUtils.isBlank(config.getConfigData())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "配置数据");
|
||||
}
|
||||
|
||||
ImChannelEnum imChannelEnum = ImChannelEnum.fromCode(config.getChannel());
|
||||
ImTargetTypeEnum imTargetTypeEnum = ImTargetTypeEnum.fromCode(config.getTargetType());
|
||||
|
||||
// 从 configData 中解析 targetId
|
||||
String targetId = extractTargetIdFromConfigData(config.getConfigData(), imChannelEnum, imTargetTypeEnum);
|
||||
if (StringUtils.isBlank(targetId)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imCannotResolveTargetId);
|
||||
}
|
||||
config.setTargetId(targetId);
|
||||
|
||||
if (!ImOutputModeEnum.isValid(config.getOutputMode())) {
|
||||
config.setOutputMode(getDefaultOutputMode(config).getCode());
|
||||
}
|
||||
|
||||
if (imChannelEnum == ImChannelEnum.WECHAT_ILINK && imTargetTypeEnum == ImTargetTypeEnum.BOT) {
|
||||
return addWechatIlinkBotConfig(config);
|
||||
}
|
||||
|
||||
RequestContext<?> requestContext = RequestContext.get();
|
||||
if (requestContext == null || requestContext.getTenantId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemGetTenantFailed);
|
||||
}
|
||||
var userDto = (UserDto) RequestContext.get().getUser();
|
||||
config.setUserId(userDto.getId());
|
||||
config.setTenantId(requestContext.getTenantId());
|
||||
config.setCreatorId(userDto.getId());
|
||||
config.setCreatorName(userDto.getUserName());
|
||||
config.setYn(1);
|
||||
|
||||
// 检查是否已存在相同配置
|
||||
ImChannelConfig existing = imChannelConfigDomainService.findOne(
|
||||
config.getChannel(),
|
||||
config.getTargetType(),
|
||||
config.getTargetId()
|
||||
);
|
||||
if (existing != null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imChannelConfigDuplicate);
|
||||
}
|
||||
|
||||
config = imChannelConfigDomainService.add(config);
|
||||
if (imChannelEnum == ImChannelEnum.WECHAT_ILINK) {
|
||||
notifyWechatIlinkPollWorkers(config.getId());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信 iLink:同一 ilinkUserId 或同一 ilinkAccountId(targetId) 时走更新,否则新增(与扫码流程对齐)。
|
||||
*/
|
||||
private ImChannelConfig addWechatIlinkBotConfig(ImChannelConfig config) {
|
||||
RequestContext<?> requestContext = RequestContext.get();
|
||||
if (requestContext == null || requestContext.getTenantId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemGetTenantFailed);
|
||||
}
|
||||
Long tenantId = requestContext.getTenantId();
|
||||
Long spaceId = config.getSpaceId();
|
||||
String ilinkUserId = extractIlinkUserIdFromConfigData(config.getConfigData());
|
||||
String normalized = config.getTargetId();
|
||||
|
||||
ImChannelConfig existingByIlinkUser = null;
|
||||
if (StringUtils.isNotBlank(ilinkUserId) && spaceId != null) {
|
||||
existingByIlinkUser = findWechatIlinkConfigEntityByIlinkUserId(tenantId, spaceId, ilinkUserId);
|
||||
}
|
||||
if (existingByIlinkUser != null) {
|
||||
ImChannelConfig patch = new ImChannelConfig();
|
||||
patch.setId(existingByIlinkUser.getId());
|
||||
patch.setChannel(ImChannelEnum.WECHAT_ILINK.getCode());
|
||||
patch.setTargetType(ImTargetTypeEnum.BOT.getCode());
|
||||
patch.setConfigData(config.getConfigData());
|
||||
patch.setEnabled(config.getEnabled() != null ? config.getEnabled() : true);
|
||||
patch.setOutputMode(config.getOutputMode());
|
||||
patch.setName(StringUtils.isNotBlank(config.getName()) ? config.getName() : existingByIlinkUser.getName());
|
||||
patch.setAgentId(config.getAgentId());
|
||||
return update(patch, existingByIlinkUser);
|
||||
}
|
||||
|
||||
ImChannelConfig existingByAccount = imChannelConfigDomainService.findOneIgnoreEnabled(
|
||||
ImChannelEnum.WECHAT_ILINK.getCode(), ImTargetTypeEnum.BOT.getCode(), normalized);
|
||||
if (existingByAccount != null) {
|
||||
ImChannelConfig patch = new ImChannelConfig();
|
||||
patch.setId(existingByAccount.getId());
|
||||
patch.setChannel(existingByAccount.getChannel());
|
||||
patch.setTargetType(existingByAccount.getTargetType());
|
||||
patch.setConfigData(config.getConfigData());
|
||||
patch.setEnabled(config.getEnabled() != null ? config.getEnabled() : true);
|
||||
patch.setOutputMode(config.getOutputMode());
|
||||
patch.setName(StringUtils.isNotBlank(config.getName()) ? config.getName() : existingByAccount.getName());
|
||||
patch.setAgentId(config.getAgentId());
|
||||
return update(patch, existingByAccount);
|
||||
}
|
||||
|
||||
ImChannelConfig dup = imChannelConfigDomainService.findOne(
|
||||
config.getChannel(), config.getTargetType(), config.getTargetId());
|
||||
if (dup != null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imChannelConfigDuplicate);
|
||||
}
|
||||
|
||||
var userDto = (UserDto) RequestContext.get().getUser();
|
||||
config.setUserId(userDto.getId());
|
||||
config.setTenantId(tenantId);
|
||||
config.setCreatorId(userDto.getId());
|
||||
config.setCreatorName(userDto.getUserName());
|
||||
config.setYn(1);
|
||||
if (StringUtils.isBlank(config.getName())) {
|
||||
config.setName("微信iLink");
|
||||
}
|
||||
|
||||
config = imChannelConfigDomainService.add(config);
|
||||
notifyWechatIlinkPollWorkers(config.getId());
|
||||
return config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImChannelConfig update(ImChannelConfig config, ImChannelConfig exist) {
|
||||
// 校验必要参数
|
||||
if (config == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemParamRequired);
|
||||
}
|
||||
if (config.getId() == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "ID");
|
||||
}
|
||||
|
||||
// 从 configData 中解析 targetId
|
||||
ImChannelEnum imChannelEnum = ImChannelEnum.fromCode(config.getChannel());
|
||||
ImTargetTypeEnum imTargetTypeEnum = ImTargetTypeEnum.fromCode(config.getTargetType());
|
||||
String targetId = extractTargetIdFromConfigData(config.getConfigData(), imChannelEnum, imTargetTypeEnum);
|
||||
if (StringUtils.isBlank(targetId)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imCannotResolveTargetId);
|
||||
}
|
||||
config.setTargetId(targetId);
|
||||
|
||||
if (StringUtils.isNotBlank(config.getOutputMode()) && !ImOutputModeEnum.isValid(config.getOutputMode())) {
|
||||
config.setOutputMode(null);
|
||||
}
|
||||
|
||||
// 设置修改者信息
|
||||
RequestContext<?> requestContext = RequestContext.get();
|
||||
if (requestContext != null) {
|
||||
config.setUserId(requestContext.getUserId());
|
||||
config.setModifiedId(requestContext.getUserId());
|
||||
}
|
||||
config.setTenantId(null); // 保持租户不变
|
||||
config.setSpaceId(null);// 保持空间不变
|
||||
|
||||
// 如果修改了关键字段,检查是否与其他配置冲突
|
||||
if (!exist.getTargetId().equals(config.getTargetId())
|
||||
|| !exist.getTargetType().equals(config.getTargetType())
|
||||
|| !exist.getChannel().equals(config.getChannel())) {
|
||||
ImChannelConfig duplicate = imChannelConfigDomainService.findOne(
|
||||
config.getChannel(),
|
||||
config.getTargetType(),
|
||||
config.getTargetId()
|
||||
);
|
||||
if (duplicate != null && !duplicate.getId().equals(config.getId())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imChannelConfigDuplicateDetail,
|
||||
ImChannelEnum.fromCode(config.getChannel()).getName(),
|
||||
ImTargetTypeEnum.fromCode(config.getTargetType()).getName(), config.getTargetId());
|
||||
}
|
||||
}
|
||||
|
||||
config = imChannelConfigDomainService.updateById(config);
|
||||
if (imChannelEnum == ImChannelEnum.WECHAT_ILINK) {
|
||||
notifyWechatIlinkPollWorkers(config.getId());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean updateEnabled(ImChannelConfig config) {
|
||||
boolean ok = imChannelConfigDomainService.updateEnabled(config);
|
||||
if (ok) {
|
||||
ImChannelConfig full = imChannelConfigDomainService.getById(config.getId());
|
||||
if (full != null && ImChannelEnum.WECHAT_ILINK.getCode().equals(full.getChannel())) {
|
||||
notifyWechatIlinkPollWorkers(config.getId());
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableWechatIlinkOnSessionExpired(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
TenantFunctions.callWithIgnoreCheck(() -> {
|
||||
ImChannelConfig c = imChannelConfigDomainService.getById(configId);
|
||||
if (c == null || !ImChannelEnum.WECHAT_ILINK.getCode().equals(c.getChannel())) {
|
||||
return null;
|
||||
}
|
||||
if (Boolean.FALSE.equals(c.getEnabled())) {
|
||||
return null;
|
||||
}
|
||||
c.setEnabled(false);
|
||||
boolean ok = updateEnabled(c);
|
||||
if (ok) {
|
||||
log.info("wechat ilink session expired or invalid, disabled channel config, configId={}", configId);
|
||||
} else {
|
||||
log.warn("wechat ilink disable on session expired failed, configId={}", configId);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private void notifyWechatIlinkPollWorkers(Long configId) {
|
||||
try {
|
||||
wechatIlinkLongPollService.forceReloadAndPollOnce(configId);
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink forceReloadAndPollOnce failed, configId={}", configId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(Long id) {
|
||||
ImChannelConfig exist = imChannelConfigDomainService.getById(id);
|
||||
boolean ok = imChannelConfigDomainService.delete(id);
|
||||
if (ok && exist != null && ImChannelEnum.WECHAT_ILINK.getCode().equals(exist.getChannel())) {
|
||||
try {
|
||||
wechatIlinkLongPollService.removePollForDeletedConfig(id);
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink removePollForDeletedConfig failed, configId={}", id, e);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ImChannelStatisticsResponse> statistics(Long spaceId) {
|
||||
if (spaceId == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "spaceId");
|
||||
}
|
||||
|
||||
ImChannelConfig query = new ImChannelConfig();
|
||||
query.setSpaceId(spaceId);
|
||||
List<ImChannelConfig> configs = list(query);
|
||||
|
||||
Map<String, Long> countMap = CollectionUtils.isEmpty(configs) ? Collections.emptyMap() : configs.stream()
|
||||
.filter(cfg -> StringUtils.isNotBlank(cfg.getChannel()))
|
||||
.collect(Collectors.groupingBy(ImChannelConfig::getChannel, Collectors.counting()));
|
||||
|
||||
// 结果必须包含所有 ImChannelEnum:数据库里不存在则 count=0
|
||||
return Arrays.stream(ImChannelEnum.values())
|
||||
.map(channelEnum -> {
|
||||
Long count = countMap.getOrDefault(channelEnum.getCode(), 0L);
|
||||
return ImChannelStatisticsResponse.builder()
|
||||
.channel(channelEnum.getCode())
|
||||
.channelName(channelEnum.getName())
|
||||
.count(count)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置数据中提取 targetId
|
||||
* 根据不同的渠道和目标类型,使用不同的字段作为 targetId:
|
||||
* - 飞书: appId
|
||||
* - 钉钉: robotCode
|
||||
* - 企业微信机器人: token(用于签名验证)
|
||||
* - 企业微信自建应用: token(用于签名验证)
|
||||
*/
|
||||
private String extractTargetIdFromConfigData(String configData, ImChannelEnum channel, ImTargetTypeEnum targetType) {
|
||||
if (StringUtils.isBlank(configData)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject json = JSON.parseObject(configData);
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String targetId = null;
|
||||
|
||||
if (channel == ImChannelEnum.FEISHU) {
|
||||
targetId = json.getString("appId");
|
||||
} else if (channel == ImChannelEnum.DINGTALK) {
|
||||
targetId = json.getString("robotCode");
|
||||
} else if (channel == ImChannelEnum.WEWORK) {
|
||||
// 企业微信使用 token 作为 targetId,因为签名是用 token 计算的
|
||||
// 可以通过签名验证找到对应的 token,然后直接查询配置
|
||||
targetId = json.getString("token");
|
||||
} else if (channel == ImChannelEnum.WECHAT_ILINK) {
|
||||
targetId = json.getString("ilinkAccountId");
|
||||
}
|
||||
|
||||
return targetId;
|
||||
}
|
||||
|
||||
private ImOutputModeEnum getDefaultOutputMode(ImChannelConfig config) {
|
||||
ImChannelEnum channel = ImChannelEnum.fromCode(config.getChannel());
|
||||
ImTargetTypeEnum targetType = ImTargetTypeEnum.fromCode(config.getTargetType());
|
||||
switch (channel) {
|
||||
case FEISHU:
|
||||
return ImOutputModeEnum.STREAM;
|
||||
case DINGTALK:
|
||||
return ImOutputModeEnum.STREAM;
|
||||
case WEWORK:
|
||||
if (targetType == ImTargetTypeEnum.BOT) {
|
||||
return ImOutputModeEnum.ONCE;
|
||||
} else if (targetType == ImTargetTypeEnum.APP) {
|
||||
return ImOutputModeEnum.ONCE;
|
||||
}
|
||||
case WECHAT_ILINK:
|
||||
return ImOutputModeEnum.ONCE;
|
||||
default:
|
||||
return ImOutputModeEnum.ONCE;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.xspaceagi.im.application.impl;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.ConversationApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.ConversationDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.ConversationUpdateDto;
|
||||
import com.xspaceagi.im.application.ImSessionApplicationService;
|
||||
import com.xspaceagi.im.domain.service.ImSessionDomainService;
|
||||
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.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.utils.I18nUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import static com.xspaceagi.im.infra.enums.ImChannelEnum.*;
|
||||
|
||||
/**
|
||||
* IM会话应用服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImSessionApplicationServiceImpl implements ImSessionApplicationService {
|
||||
|
||||
@Resource
|
||||
private ImSessionDomainService imSessionDomainService;
|
||||
@Resource
|
||||
private ConversationApplicationService conversationApplicationService;
|
||||
|
||||
/**
|
||||
* 获取或创建会话ID
|
||||
*/
|
||||
@Override
|
||||
public Long getConversationId(ImSession imSession) {
|
||||
// 先查询是否已存在
|
||||
ImSession existing = imSessionDomainService.findSession(imSession);
|
||||
if (existing != null) {
|
||||
// 验证会话是否仍然有效
|
||||
ConversationDto conversation = conversationApplicationService.getConversation(null, existing.getConversationId());
|
||||
if (conversation != null) {
|
||||
log.debug("Found existing IM session: platform={}, sessionKey={}, agentId={}, conversationId={}",
|
||||
imSession.getChannel(), imSession.getSessionKey(), imSession.getAgentId(), existing.getConversationId());
|
||||
return existing.getConversationId();
|
||||
} else {
|
||||
log.info("Underlying conversation missing, deleting IM session: platform={}, sessionKey={}, agentId={}, conversationId={}",
|
||||
imSession.getChannel(), imSession.getSessionKey(), imSession.getAgentId(), existing.getConversationId());
|
||||
imSessionDomainService.deleteSession(imSession);
|
||||
}
|
||||
}
|
||||
|
||||
return createAndSaveNewConversation(imSession);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long createNewConversationId(ImSession imSession) {
|
||||
// /new 场景:始终重建会话映射,后续消息默认走新会话
|
||||
imSessionDomainService.deleteSession(imSession);
|
||||
return createAndSaveNewConversation(imSession);
|
||||
}
|
||||
|
||||
private Long createAndSaveNewConversation(ImSession imSession) {
|
||||
// 创建新会话
|
||||
ConversationDto newConversation = conversationApplicationService.createConversation(imSession.getUserId(), imSession.getAgentId(), false, false);
|
||||
log.info("Created new session: platform={}, sessionKey={}, agentId={}, conversationId={}",
|
||||
imSession.getChannel(), imSession.getSessionKey(), imSession.getAgentId(), newConversation.getId());
|
||||
|
||||
ImChannelEnum imChannelEnum = ImChannelEnum.fromCode(imSession.getChannel());
|
||||
ImTargetTypeEnum imTargetTypeEnum = ImTargetTypeEnum.fromCode(imSession.getTargetType());
|
||||
|
||||
String lang = RequestContext.get().getLang();
|
||||
boolean isCN = "zh-CN".equalsIgnoreCase(lang);
|
||||
|
||||
String topic = "";
|
||||
switch (imChannelEnum) {
|
||||
case FEISHU:
|
||||
topic = isCN ? FEISHU.getName() : FEISHU.getCode();
|
||||
break;
|
||||
case DINGTALK:
|
||||
topic = isCN ? DINGTALK.getName() : DINGTALK.getCode();
|
||||
break;
|
||||
case WEWORK:
|
||||
if (ImTargetTypeEnum.APP.equals(imTargetTypeEnum)) {
|
||||
topic = isCN ? (WEWORK.getName() + ImTargetTypeEnum.APP.getName()) : (WEWORK.getCode() + ImTargetTypeEnum.APP.getCode());
|
||||
} else if (ImTargetTypeEnum.BOT.equals(imTargetTypeEnum)) {
|
||||
topic = isCN ? (WEWORK.getName() + ImTargetTypeEnum.BOT.getName()) : (WEWORK.getCode() + ImTargetTypeEnum.BOT.getCode());
|
||||
}
|
||||
break;
|
||||
case WECHAT_ILINK:
|
||||
topic = isCN ? WECHAT_ILINK.getName() : WECHAT_ILINK.getCode();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
String sessionDisplay = imSession.getSessionName();
|
||||
if (sessionDisplay == null || sessionDisplay.trim().isEmpty()) {
|
||||
sessionDisplay = imSession.getSessionKey();
|
||||
}
|
||||
topic = topic + " - "
|
||||
+ (isCN ? ImChatTypeEnum.fromCode(imSession.getChatType()).getName() : ImChatTypeEnum.fromCode(imSession.getChatType()).getCode())
|
||||
+ " - " + sessionDisplay;
|
||||
|
||||
ConversationUpdateDto conversationUpdateDto = new ConversationUpdateDto();
|
||||
conversationUpdateDto.setId(newConversation.getId());
|
||||
conversationUpdateDto.setTopic(topic);
|
||||
conversationApplicationService.updateConversationTopic(imSession.getUserId(), conversationUpdateDto);
|
||||
|
||||
// 保存IM会话
|
||||
imSession.setConversationId(newConversation.getId());
|
||||
imSessionDomainService.saveSession(imSession);
|
||||
|
||||
return newConversation.getId();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.xspaceagi.im.application.impl;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.ConversationApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AgentOutputDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.ChatMessageDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.TryReqDto;
|
||||
import com.xspaceagi.agent.core.infra.component.agent.dto.AgentExecuteResult;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.CallMessage;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.ComponentExecutingDto;
|
||||
import com.xspaceagi.im.application.ImSessionApplicationService;
|
||||
import com.xspaceagi.im.application.WechatIlinkAgentApplicationService;
|
||||
import com.xspaceagi.im.application.WeworkAgentApplicationService;
|
||||
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.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import com.xspaceagi.system.application.service.UserApplicationService;
|
||||
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 reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 微信 iLink 智能体执行(会话维度:单聊 peer = from_user_id)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WechatIlinkAgentApplicationServiceImpl implements WechatIlinkAgentApplicationService {
|
||||
|
||||
@Resource
|
||||
private UserApplicationService userApplicationService;
|
||||
@Resource
|
||||
private ImSessionApplicationService imSessionApplicationService;
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
@Resource
|
||||
private ConversationApplicationService conversationApplicationService;
|
||||
|
||||
@Override
|
||||
public Mono<WeworkAgentApplicationService.AgentExecuteResultWithConv> executeAgentWithConv(
|
||||
String fromUserId,
|
||||
String userMessage,
|
||||
List<AttachmentDto> attachments,
|
||||
Long tenantId,
|
||||
Long userId,
|
||||
Long agentId,
|
||||
String sessionName) {
|
||||
boolean noText = StringUtils.isBlank(userMessage);
|
||||
boolean noAttachments = attachments == null || attachments.isEmpty();
|
||||
if (StringUtils.isBlank(fromUserId) || (noText && noAttachments)) {
|
||||
return Mono.just(new WeworkAgentApplicationService.AgentExecuteResultWithConv("消息内容不能为空", null, agentId));
|
||||
}
|
||||
if (agentId == null || agentId <= 0) {
|
||||
return Mono.just(new WeworkAgentApplicationService.AgentExecuteResultWithConv("微信 iLink 智能体未配置,请联系管理员", null, agentId));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(tenantId);
|
||||
requestContext.setTenantConfig(tenantConfigApplicationService.getTenantConfig(tenantId));
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
UserDto userDto = userApplicationService.queryById(userId);
|
||||
if (userDto == null) {
|
||||
return Mono.just(new WeworkAgentApplicationService.AgentExecuteResultWithConv("系统用户不存在", null, agentId));
|
||||
}
|
||||
requestContext.setUser(userDto);
|
||||
requestContext.setUserId(userId);
|
||||
|
||||
Long convId = getConversationId(fromUserId, userId, agentId, tenantId, sessionName);
|
||||
if (convId == null) {
|
||||
return Mono.just(new WeworkAgentApplicationService.AgentExecuteResultWithConv("创建会话失败", null, agentId));
|
||||
}
|
||||
|
||||
TryReqDto tryReqDto = new TryReqDto();
|
||||
tryReqDto.setConversationId(convId);
|
||||
tryReqDto.setMessage(userMessage);
|
||||
tryReqDto.setAttachments(attachments != null ? attachments : new ArrayList<>());
|
||||
tryReqDto.setFrom(ImChannelEnum.WECHAT_ILINK.getCode());
|
||||
|
||||
Flux<AgentOutputDto> flux = conversationApplicationService.chat(tryReqDto, new HashMap<>(), false);
|
||||
|
||||
StringBuilder accumulated = new StringBuilder();
|
||||
AtomicReference<AgentExecuteResult> finalRef = new AtomicReference<>();
|
||||
final Long convIdFinal = convId;
|
||||
|
||||
return flux
|
||||
.doOnNext(o -> {
|
||||
if (o.getEventType() == AgentOutputDto.EventTypeEnum.MESSAGE && o.getData() instanceof ChatMessageDto) {
|
||||
String text = ((ChatMessageDto) o.getData()).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
}
|
||||
} else if (o.getEventType() == AgentOutputDto.EventTypeEnum.PROCESSING_MESSAGE && o.getData() instanceof ComponentExecutingDto) {
|
||||
Object exec = ((ComponentExecutingDto) o.getData()).getExecutingMessage();
|
||||
if (exec instanceof CallMessage) {
|
||||
String text = ((CallMessage) exec).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
}
|
||||
}
|
||||
} else if (o.getEventType() == AgentOutputDto.EventTypeEnum.FINAL_RESULT && o.getData() instanceof AgentExecuteResult) {
|
||||
finalRef.set((AgentExecuteResult) o.getData());
|
||||
}
|
||||
})
|
||||
.then(Mono.fromCallable(() -> mapToConvResult(accumulated, finalRef, convIdFinal, agentId)));
|
||||
})
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.doFinally(st -> RequestContext.remove())
|
||||
.onErrorResume(e -> {
|
||||
log.error("WeChat iLink agent error: fromUserId={}", fromUserId, e);
|
||||
String errorText = "执行异常: " + (e.getMessage() != null ? e.getMessage() : "未知错误");
|
||||
return Mono.just(new WeworkAgentApplicationService.AgentExecuteResultWithConv(errorText, null, agentId));
|
||||
});
|
||||
}
|
||||
|
||||
private static WeworkAgentApplicationService.AgentExecuteResultWithConv mapToConvResult(
|
||||
StringBuilder accumulated,
|
||||
AtomicReference<AgentExecuteResult> finalRef,
|
||||
Long convId,
|
||||
Long agentId) {
|
||||
AgentExecuteResult finalResult = finalRef.get();
|
||||
if (finalResult == null) {
|
||||
if (accumulated.length() > 0) {
|
||||
return new WeworkAgentApplicationService.AgentExecuteResultWithConv(accumulated.toString(), convId, agentId);
|
||||
}
|
||||
return new WeworkAgentApplicationService.AgentExecuteResultWithConv("模型执行超时或未返回结果", convId, agentId);
|
||||
}
|
||||
if (Boolean.FALSE.equals(finalResult.getSuccess())) {
|
||||
String errorText = StringUtils.isNotBlank(finalResult.getError()) ? finalResult.getError() : "模型执行失败";
|
||||
return new WeworkAgentApplicationService.AgentExecuteResultWithConv(errorText, convId, agentId);
|
||||
}
|
||||
String outputText = StringUtils.isNotBlank(finalResult.getOutputText()) ? finalResult.getOutputText() : null;
|
||||
if (outputText == null) {
|
||||
outputText = accumulated.toString();
|
||||
}
|
||||
if (StringUtils.isBlank(outputText)) {
|
||||
outputText = "模型终止执行";
|
||||
}
|
||||
return new WeworkAgentApplicationService.AgentExecuteResultWithConv(outputText, convId, agentId);
|
||||
}
|
||||
|
||||
private Long getConversationId(String fromUserId, Long userId, Long agentId, Long tenantId, String sessionName) {
|
||||
ImSession imSession = ImSession.builder()
|
||||
.channel(ImChannelEnum.WECHAT_ILINK.getCode())
|
||||
.targetType(ImTargetTypeEnum.BOT.getCode())
|
||||
.sessionKey(fromUserId)
|
||||
.sessionName(sessionName)
|
||||
.chatType(ImChatTypeEnum.PRIVATE.getCode())
|
||||
.userId(userId)
|
||||
.agentId(agentId)
|
||||
.tenantId(tenantId)
|
||||
.build();
|
||||
return imSessionApplicationService.getConversationId(imSession);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.xspaceagi.im.application.impl;
|
||||
|
||||
import com.xspaceagi.im.application.ImChannelConfigApplicationService;
|
||||
import com.xspaceagi.im.application.WechatIlinkPushApplicationService;
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigDto;
|
||||
import com.xspaceagi.im.infra.enums.ImChannelEnum;
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkConstants;
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkHttpClient;
|
||||
import com.xspaceagi.im.wechat.ilink.WechatIlinkMessageHelper;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WechatIlinkPushApplicationServiceImpl implements WechatIlinkPushApplicationService {
|
||||
|
||||
@Resource
|
||||
private ImChannelConfigApplicationService imChannelConfigApplicationService;
|
||||
@Resource
|
||||
private IlinkHttpClient ilinkHttpClient;
|
||||
|
||||
@Override
|
||||
public void pushText(Long configId, String message) {
|
||||
if (configId == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "configId");
|
||||
}
|
||||
if (StringUtils.isBlank(message)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "message");
|
||||
}
|
||||
|
||||
ImChannelConfigDto dto = imChannelConfigApplicationService.getDtoById(configId);
|
||||
pushText(dto, message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pushText(ImChannelConfigDto dto, String message) {
|
||||
if (StringUtils.isBlank(message)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.fieldRequiredButEmpty, "message");
|
||||
}
|
||||
if (dto == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.configNotFound);
|
||||
}
|
||||
if (!ImChannelEnum.WECHAT_ILINK.getCode().equals(dto.getChannel())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatIlinkChannelOnly);
|
||||
}
|
||||
if (Boolean.FALSE.equals(dto.getEnabled())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatIlinkConfigDisabled);
|
||||
}
|
||||
|
||||
ImChannelConfigDto.WechatIlinkConfig wechatIlink = dto.getWechatIlink();
|
||||
if (wechatIlink == null) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatIlinkConfigMissing);
|
||||
}
|
||||
if (StringUtils.isBlank(wechatIlink.getBotToken())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatIlinkBotTokenMissing);
|
||||
}
|
||||
if (StringUtils.isBlank(wechatIlink.getIlinkUserId())) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatIlinkUserIdMissing);
|
||||
}
|
||||
|
||||
String baseUrl = StringUtils.defaultIfBlank(wechatIlink.getBaseUrl(), IlinkConstants.DEFAULT_BASE_URL);
|
||||
try {
|
||||
ilinkHttpClient.sendMessage(
|
||||
baseUrl,
|
||||
wechatIlink.getBotToken(),
|
||||
WechatIlinkMessageHelper.buildTextReply(wechatIlink.getIlinkUserId(), null, message),
|
||||
15_000
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("wechat ilink push text failed, configId={}, ilinkUserId={}",
|
||||
dto.getId(), wechatIlink.getIlinkUserId(), e);
|
||||
throw BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.imWechatIlinkPushFailed,
|
||||
e.getMessage() != null ? e.getMessage() : "未知错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.xspaceagi.im.application.impl;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.ConversationApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AgentOutputDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.ChatMessageDto;
|
||||
import com.xspaceagi.agent.core.adapter.dto.TryReqDto;
|
||||
import com.xspaceagi.agent.core.infra.component.agent.dto.AgentExecuteResult;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.CallMessage;
|
||||
import com.xspaceagi.agent.core.infra.component.model.dto.ComponentExecutingDto;
|
||||
import com.xspaceagi.im.application.ImSessionApplicationService;
|
||||
import com.xspaceagi.im.application.WeworkAgentApplicationService;
|
||||
import com.xspaceagi.im.application.dto.StreamChunk;
|
||||
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.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import com.xspaceagi.system.application.service.UserApplicationService;
|
||||
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 reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 企业微信智能机器人智能体执行服务实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WeworkAgentApplicationServiceImpl implements WeworkAgentApplicationService {
|
||||
|
||||
@Resource
|
||||
private UserApplicationService userApplicationService;
|
||||
@Resource
|
||||
private ImSessionApplicationService imSessionApplicationService;
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
@Resource
|
||||
private ConversationApplicationService conversationApplicationService;
|
||||
|
||||
@Override
|
||||
public String executeAgent(String senderId, String message, String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
return executeAgent(senderId, message, null, chatType, chatId, targetType, tenantId, userId, agentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String executeAgent(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId) {
|
||||
AgentExecuteResultWithConv result = executeAgentWithConv(senderId, message, attachments, chatType, chatId, targetType, tenantId, userId, agentId);
|
||||
return result.getText();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentExecuteResultWithConv executeAgentWithConv(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName) {
|
||||
if (StringUtils.isBlank(senderId) || StringUtils.isBlank(message)) {
|
||||
return new AgentExecuteResultWithConv("消息内容不能为空", null, agentId);
|
||||
}
|
||||
|
||||
if (agentId == null || agentId <= 0) {
|
||||
log.warn("WeCom agent-id not configured");
|
||||
return new AgentExecuteResultWithConv("企业微信智能体未配置,请联系管理员", null, agentId);
|
||||
}
|
||||
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(tenantId);
|
||||
requestContext.setTenantConfig(tenantConfigApplicationService.getTenantConfig(tenantId));
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
try {
|
||||
UserDto userDto = userApplicationService.queryById(userId);
|
||||
if (userDto == null) {
|
||||
return new AgentExecuteResultWithConv("系统用户不存在", null, agentId);
|
||||
}
|
||||
requestContext.setUser(userDto);
|
||||
requestContext.setUserId(userId);
|
||||
|
||||
Long convId = getConversationId(senderId, chatType, chatId, targetType, userId, agentId, tenantId, sessionName);
|
||||
if (convId == null) {
|
||||
return new AgentExecuteResultWithConv("创建会话失败", null, agentId);
|
||||
}
|
||||
|
||||
TryReqDto tryReqDto = new TryReqDto();
|
||||
tryReqDto.setConversationId(convId);
|
||||
tryReqDto.setMessage(message);
|
||||
tryReqDto.setAttachments(attachments != null ? attachments : new ArrayList<>());
|
||||
tryReqDto.setFrom(ImChannelEnum.WEWORK.getCode());
|
||||
|
||||
Flux<AgentOutputDto> flux = conversationApplicationService.chat(tryReqDto, new HashMap<>(), false);
|
||||
|
||||
AgentExecuteResult finalResult = flux
|
||||
.filter(o -> o.getEventType() == AgentOutputDto.EventTypeEnum.FINAL_RESULT)
|
||||
.map(o -> (AgentExecuteResult) o.getData())
|
||||
.next()
|
||||
.block();
|
||||
|
||||
if (finalResult == null) {
|
||||
return new AgentExecuteResultWithConv("执行超时或未返回结果", convId, agentId);
|
||||
}
|
||||
if (Boolean.FALSE.equals(finalResult.getSuccess())) {
|
||||
String errorText = StringUtils.isNotBlank(finalResult.getError()) ? finalResult.getError() : "模型执行失败";
|
||||
return new AgentExecuteResultWithConv(errorText, convId, agentId);
|
||||
}
|
||||
String outputText = StringUtils.isNotBlank(finalResult.getOutputText()) ? finalResult.getOutputText() : "模型终止执行";
|
||||
return new AgentExecuteResultWithConv(outputText, convId, agentId);
|
||||
} catch (Exception e) {
|
||||
log.error("WeCom agent execution error: senderId={}", senderId, e);
|
||||
String errorText = e.getMessage() != null ? e.getMessage() : "模型执行异常";
|
||||
return new AgentExecuteResultWithConv(errorText, null, agentId);
|
||||
} finally {
|
||||
RequestContext.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<StreamChunk> executeAgentStream(String senderId, String message, String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName) {
|
||||
return executeAgentStream(senderId, message, null, chatType, chatId, targetType, tenantId, userId, agentId, sessionName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<StreamChunk> executeAgentStream(String senderId, String message, List<AttachmentDto> attachments,
|
||||
String chatType, String chatId, String targetType,
|
||||
Long tenantId, Long userId, Long agentId, String sessionName) {
|
||||
if (StringUtils.isBlank(senderId) || StringUtils.isBlank(message)) {
|
||||
return Flux.just(new StreamChunk("消息内容不能为空", true));
|
||||
}
|
||||
if (agentId == null || agentId <= 0) {
|
||||
return Flux.just(new StreamChunk("企业微信智能体未配置,请联系管理员", true));
|
||||
}
|
||||
|
||||
return Flux.<StreamChunk>defer(() -> {
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(tenantId);
|
||||
requestContext.setTenantConfig(tenantConfigApplicationService.getTenantConfig(tenantId));
|
||||
RequestContext.set(requestContext);
|
||||
|
||||
UserDto userDto = userApplicationService.queryById(userId);
|
||||
if (userDto == null) {
|
||||
RequestContext.remove();
|
||||
return Flux.just(new StreamChunk("系统用户不存在", true));
|
||||
}
|
||||
requestContext.setUser(userDto);
|
||||
requestContext.setUserId(userId);
|
||||
|
||||
Long convId = getConversationId(senderId, chatType, chatId, targetType, userId, agentId, tenantId, sessionName);
|
||||
if (convId == null) {
|
||||
RequestContext.remove();
|
||||
return Flux.just(new StreamChunk("创建会话失败", true));
|
||||
}
|
||||
|
||||
TryReqDto tryReqDto = new TryReqDto();
|
||||
tryReqDto.setConversationId(convId);
|
||||
tryReqDto.setMessage(message);
|
||||
tryReqDto.setAttachments(attachments != null ? attachments : new ArrayList<>());
|
||||
tryReqDto.setFrom(ImChannelEnum.WEWORK.getCode());
|
||||
|
||||
Flux<AgentOutputDto> chatFlux = conversationApplicationService.chat(tryReqDto, new HashMap<>(), false);
|
||||
StringBuilder accumulated = new StringBuilder();
|
||||
|
||||
return chatFlux
|
||||
.flatMap(event -> {
|
||||
StreamChunk chunk = null;
|
||||
if (event.getEventType() == AgentOutputDto.EventTypeEnum.MESSAGE && event.getData() instanceof ChatMessageDto) {
|
||||
String text = ((ChatMessageDto) event.getData()).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
chunk = new StreamChunk(accumulated.toString(), false, convId);
|
||||
}
|
||||
} else if (event.getEventType() == AgentOutputDto.EventTypeEnum.PROCESSING_MESSAGE && event.getData() instanceof ComponentExecutingDto) {
|
||||
Object exec = ((ComponentExecutingDto) event.getData()).getExecutingMessage();
|
||||
if (exec instanceof CallMessage) {
|
||||
String text = ((CallMessage) exec).getText();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
accumulated.append(text);
|
||||
chunk = new StreamChunk(accumulated.toString(), false, convId);
|
||||
}
|
||||
}
|
||||
} else if (event.getEventType() == AgentOutputDto.EventTypeEnum.FINAL_RESULT && event.getData() instanceof AgentExecuteResult) {
|
||||
AgentExecuteResult result = (AgentExecuteResult) event.getData();
|
||||
String finalText = result.getOutputText();
|
||||
if (Boolean.FALSE.equals(result.getSuccess()) && StringUtils.isNotBlank(result.getError())) {
|
||||
finalText = result.getError();
|
||||
}
|
||||
if (finalText == null) {
|
||||
finalText = accumulated.toString();
|
||||
}
|
||||
chunk = new StreamChunk(finalText != null ? finalText : "模型终止执行", true, convId);
|
||||
}
|
||||
return Mono.justOrEmpty(chunk);
|
||||
})
|
||||
.onErrorResume(e -> Flux.just(new StreamChunk("模型执行异常: " + (e.getMessage() != null ? e.getMessage() : "未知错误"), true, null)))
|
||||
.doFinally(s -> RequestContext.remove());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建会话。单聊用发送者 id 构建会话 key,群聊用群 id 构建会话 key。
|
||||
*
|
||||
* @param chatType single 单聊,group 群聊
|
||||
* @param chatId 群聊时的 chatid,单聊时可为空
|
||||
* @param targetType bot 机器人,app 应用
|
||||
*/
|
||||
private Long getConversationId(String senderId, String chatType, String chatId, String targetType,
|
||||
Long userId, Long agentId, Long tenantId, String sessionName) {
|
||||
ImChatTypeEnum chatTypeEnum = ImChatTypeEnum.fromCode(chatType);
|
||||
if (chatTypeEnum == null) {
|
||||
chatTypeEnum = ImChatTypeEnum.PRIVATE;
|
||||
}
|
||||
|
||||
String sessionKey;
|
||||
if (chatTypeEnum == ImChatTypeEnum.GROUP && StringUtils.isNotBlank(chatId)) {
|
||||
sessionKey = chatId; // 群聊:用群 id
|
||||
} else {
|
||||
sessionKey = senderId; // 单聊:用发送用户 id
|
||||
}
|
||||
|
||||
ImSession imSession = ImSession.builder()
|
||||
.channel(ImChannelEnum.WEWORK.getCode())
|
||||
.targetType(targetType)
|
||||
.sessionKey(sessionKey)
|
||||
.sessionName(sessionName)
|
||||
.chatType(chatTypeEnum.getCode())
|
||||
.userId(userId)
|
||||
.agentId(agentId)
|
||||
.tenantId(tenantId)
|
||||
.build();
|
||||
return imSessionApplicationService.getConversationId(imSession);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.xspaceagi.im.application.util;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* MIME 类型工具类
|
||||
* 提供文件扩展名与 MIME 类型的映射
|
||||
*/
|
||||
public final class MimeTypeUtils {
|
||||
|
||||
private MimeTypeUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名推断 MIME 类型
|
||||
*
|
||||
* @param fileName 文件名(可以是完整文件名或带点的扩展名,如 ".csv")
|
||||
* @return MIME 类型,如果无法推断返回 null
|
||||
*/
|
||||
public static String inferMimeTypeFromFileName(String fileName) {
|
||||
if (StringUtils.isBlank(fileName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已经是扩展名格式(如 ".csv"),直接使用
|
||||
String ext = fileName.startsWith(".") ? fileName.toLowerCase() : "";
|
||||
|
||||
// 否则从完整文件名提取扩展名
|
||||
if (ext.isEmpty()) {
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot < 0 || lastDot >= fileName.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
ext = fileName.substring(lastDot).toLowerCase();
|
||||
}
|
||||
|
||||
return switch (ext) {
|
||||
// 图片
|
||||
case ".jpg", ".jpeg" -> "image/jpeg";
|
||||
case ".png" -> "image/png";
|
||||
case ".gif" -> "image/gif";
|
||||
case ".webp" -> "image/webp";
|
||||
case ".bmp" -> "image/bmp";
|
||||
case ".svg" -> "image/svg+xml";
|
||||
|
||||
// 文档
|
||||
case ".pdf" -> "application/pdf";
|
||||
case ".doc" -> "application/msword";
|
||||
case ".docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
case ".xls" -> "application/vnd.ms-excel";
|
||||
case ".xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case ".ppt" -> "application/vnd.ms-powerpoint";
|
||||
case ".pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
case ".txt" -> "text/plain";
|
||||
case ".csv" -> "text/csv";
|
||||
case ".tsv" -> "text/tab-separated-values";
|
||||
case ".json" -> "application/json";
|
||||
case ".xml" -> "application/xml";
|
||||
case ".html", ".htm" -> "text/html";
|
||||
case ".md" -> "text/markdown";
|
||||
|
||||
// 压缩文件
|
||||
case ".zip" -> "application/zip";
|
||||
case ".tar" -> "application/x-tar";
|
||||
case ".gz" -> "application/gzip";
|
||||
case ".rar" -> "application/vnd.rar";
|
||||
case ".7z" -> "application/x-7z-compressed";
|
||||
|
||||
// 音频
|
||||
case ".mp3" -> "audio/mpeg";
|
||||
case ".ogg" -> "audio/ogg";
|
||||
case ".wav" -> "audio/wav";
|
||||
case ".flac" -> "audio/flac";
|
||||
case ".m4a" -> "audio/mp4";
|
||||
case ".aac" -> "audio/aac";
|
||||
|
||||
// 视频
|
||||
case ".mp4" -> "video/mp4";
|
||||
case ".mov" -> "video/quicktime";
|
||||
case ".webm" -> "video/webm";
|
||||
case ".mkv" -> "video/x-matroska";
|
||||
case ".avi" -> "video/x-msvideo";
|
||||
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MIME 类型获取文件扩展名
|
||||
*
|
||||
* @param mimeType MIME 类型
|
||||
* @return 文件扩展名(含点),如果无法推断返回 ".bin"
|
||||
*/
|
||||
public static String getExtensionFromMimeType(String mimeType) {
|
||||
if (StringUtils.isBlank(mimeType)) {
|
||||
return ".bin";
|
||||
}
|
||||
String mime = mimeType.toLowerCase();
|
||||
int semicolon = mime.indexOf(';');
|
||||
if (semicolon >= 0) {
|
||||
mime = mime.substring(0, semicolon).trim();
|
||||
}
|
||||
return switch (mime) {
|
||||
case "image/jpeg", "image/jpg" -> ".jpg";
|
||||
case "image/png" -> ".png";
|
||||
case "image/gif" -> ".gif";
|
||||
case "image/webp" -> ".webp";
|
||||
case "image/bmp" -> ".bmp";
|
||||
case "image/svg+xml" -> ".svg";
|
||||
|
||||
case "application/pdf" -> ".pdf";
|
||||
case "application/msword" -> ".doc";
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> ".docx";
|
||||
case "application/vnd.ms-excel" -> ".xls";
|
||||
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" -> ".xlsx";
|
||||
case "application/vnd.ms-powerpoint" -> ".ppt";
|
||||
case "application/vnd.openxmlformats-officedocument.presentationml.presentation" -> ".pptx";
|
||||
case "text/plain" -> ".txt";
|
||||
case "text/csv" -> ".csv";
|
||||
case "application/json" -> ".json";
|
||||
case "application/xml" -> ".xml";
|
||||
case "text/html" -> ".html";
|
||||
case "text/markdown" -> ".md";
|
||||
|
||||
case "application/zip" -> ".zip";
|
||||
case "application/x-tar" -> ".tar";
|
||||
case "application/gzip" -> ".gz";
|
||||
case "application/vnd.rar" -> ".rar";
|
||||
case "application/x-7z-compressed" -> ".7z";
|
||||
|
||||
case "audio/mpeg" -> ".mp3";
|
||||
case "audio/ogg" -> ".ogg";
|
||||
case "audio/wav" -> ".wav";
|
||||
case "audio/flac" -> ".flac";
|
||||
case "audio/mp4" -> ".m4a";
|
||||
|
||||
case "video/mp4" -> ".mp4";
|
||||
case "video/quicktime" -> ".mov";
|
||||
case "video/webm" -> ".webm";
|
||||
case "video/x-matroska" -> ".mkv";
|
||||
case "video/x-msvideo" -> ".avi";
|
||||
|
||||
default -> ".bin";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.xspaceagi.im.application.wechat;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
|
||||
/**
|
||||
* 将 iLink 入站媒体字节上传到业务存储并转为 {@link AttachmentDto},供智能体使用。
|
||||
* 由 web 模块提供实现(COS/本地等);无 Bean 时入站附件仅记录日志、不阻断文本消息。
|
||||
*/
|
||||
public interface WechatIlinkAttachmentService {
|
||||
|
||||
/**
|
||||
* 上传附件字节到存储
|
||||
*
|
||||
* @param bytes 文件字节数组
|
||||
* @param originalFilename 原始文件名
|
||||
* @param contentType 文件MIME类型
|
||||
* @param tenantId 租户ID(用于解析存储配置)
|
||||
* @param userId 上传用户ID
|
||||
* @return 上传失败时返回 null
|
||||
*/
|
||||
AttachmentDto upload(byte[] bytes, String originalFilename, String contentType, Long tenantId, Long userId);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.xspaceagi.im.application.wechat;
|
||||
|
||||
import com.xspaceagi.system.spec.utils.RedisUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 持久化「渠道 + 微信用户」对应最近收到的 {@code context_token}(对齐 openclaw-weixin 1.0.3 落盘语义),
|
||||
* 进程重启后仍可用于出站/错误回复兜底。
|
||||
*/
|
||||
@Component
|
||||
public class WechatIlinkContextTokenStore {
|
||||
|
||||
private static final String CTX_HASH_PREFIX = "wechat_ilink:ctx:";
|
||||
|
||||
@Resource
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Value("${wechat.ilink.context-token-ttl-seconds:1209600}")
|
||||
private long contextTokenTtlSeconds;
|
||||
|
||||
private static String hashKey(Long configId) {
|
||||
return CTX_HASH_PREFIX + configId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 入站消息带非空 token 时写入;覆盖同用户上一条。
|
||||
*/
|
||||
public void save(Long configId, String fromUserId, String contextToken) {
|
||||
if (configId == null || StringUtils.isBlank(fromUserId) || StringUtils.isBlank(contextToken)) {
|
||||
return;
|
||||
}
|
||||
String key = hashKey(configId);
|
||||
redisUtil.hashPut(key, fromUserId, contextToken);
|
||||
if (contextTokenTtlSeconds > 0) {
|
||||
redisUtil.expire(key, contextTokenTtlSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public String get(Long configId, String fromUserId) {
|
||||
if (configId == null || StringUtils.isBlank(fromUserId)) {
|
||||
return null;
|
||||
}
|
||||
Object v = redisUtil.hashGet(hashKey(configId), fromUserId);
|
||||
return v == null ? null : v.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道删除或禁用时清理该渠道下所有会话 token。
|
||||
*/
|
||||
public void clearForConfig(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
redisUtil.deleteKey(hashKey(configId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.xspaceagi.im.application.wechat;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.application.dto.ImChannelConfigDto;
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkConstants;
|
||||
import com.xspaceagi.im.wechat.ilink.WechatIlinkCdnDownloader;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.CdnMedia;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.FileItem;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.ImageItem;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.MessageItem;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.VideoItem;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.VoiceItem;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.WeixinMessage;
|
||||
import com.xspaceagi.im.wechat.ilink.WechatIlinkMessageHelper;
|
||||
import com.xspaceagi.im.application.util.MimeTypeUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 入站媒体:CDN 下载 + AES 解密后上传存储,填充 {@link AttachmentDto} 供智能体使用。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WechatIlinkInboundMediaService {
|
||||
|
||||
@Autowired(required = false)
|
||||
private WechatIlinkAttachmentService attachmentUploader;
|
||||
|
||||
public List<AttachmentDto> buildAttachmentsFromMessage(WeixinMessage msg, ImChannelConfigDto channelDto) {
|
||||
if (msg == null || msg.getItemList() == null || msg.getItemList().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (attachmentUploader == null) {
|
||||
log.debug("wechat ilink WechatIlinkAttachmentUploader absent, skip inbound attachments");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String cdnBase = IlinkConstants.CDN_BASE_URL;
|
||||
if (channelDto != null && channelDto.getWechatIlink() != null
|
||||
&& StringUtils.isNotBlank(channelDto.getWechatIlink().getCdnBaseUrl())) {
|
||||
cdnBase = channelDto.getWechatIlink().getCdnBaseUrl();
|
||||
}
|
||||
Long tenantId = channelDto != null ? channelDto.getTenantId() : null;
|
||||
Long userId = channelDto != null ? channelDto.getUserId() : null;
|
||||
|
||||
List<AttachmentDto> out = new ArrayList<>();
|
||||
for (MessageItem it : msg.getItemList()) {
|
||||
if (it == null || it.getType() == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
switch (it.getType()) {
|
||||
case WechatIlinkMessageHelper.ITEM_IMAGE -> {
|
||||
byte[] raw = downloadImage(it.getImageItem(), cdnBase);
|
||||
addOne(out, raw, null, null, tenantId, userId);
|
||||
}
|
||||
case WechatIlinkMessageHelper.ITEM_VOICE -> {
|
||||
byte[] voiceBytes = downloadVoice(it.getVoiceItem(), cdnBase);
|
||||
String voiceMime = inferVoiceMimeType(it.getVoiceItem());
|
||||
if (isSilkVoice(it.getVoiceItem())) {
|
||||
byte[] wavBytes = WechatIlinkSilkTranscoder.tryConvertSilkToWav(voiceBytes);
|
||||
if (wavBytes != null && wavBytes.length > 0) {
|
||||
voiceBytes = wavBytes;
|
||||
voiceMime = "audio/wav";
|
||||
}
|
||||
}
|
||||
addOne(out, voiceBytes, null, voiceMime, tenantId, userId);
|
||||
}
|
||||
case WechatIlinkMessageHelper.ITEM_FILE -> {
|
||||
byte[] fileBytes = downloadFile(it.getFileItem(), cdnBase);
|
||||
String fileName = it.getFileItem() != null ? it.getFileItem().getFileName() : null;
|
||||
String mime = MimeTypeUtils.inferMimeTypeFromFileName(fileName);
|
||||
addOne(out, fileBytes, fileName, mime, tenantId, userId);
|
||||
}
|
||||
case WechatIlinkMessageHelper.ITEM_VIDEO -> addOne(out, downloadVideo(it.getVideoItem(), cdnBase), null, "video/mp4", tenantId, userId);
|
||||
default -> {
|
||||
// ITEM_TEXT 或未知类型,不处理附件
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink inbound media item skipped, type={}, mid={}", it.getType(), msg.getMessageId(), e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void addOne(List<AttachmentDto> out, byte[] bytes, String filename, String mimeOverride, Long tenantId, Long userId) {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return;
|
||||
}
|
||||
AttachmentDto dto = attachmentUploader.upload(bytes, filename, mimeOverride, tenantId, userId);
|
||||
if (dto != null) {
|
||||
out.add(dto);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] downloadImage(ImageItem img, String cdnBase) throws Exception {
|
||||
if (img == null) {
|
||||
return null;
|
||||
}
|
||||
CdnMedia media = firstNonBlankMedia(img.getMedia(), img.getThumbMedia());
|
||||
return decryptMedia(media, cdnBase);
|
||||
}
|
||||
|
||||
private static byte[] downloadVoice(VoiceItem voice, String cdnBase) throws Exception {
|
||||
if (voice == null || voice.getMedia() == null) {
|
||||
return null;
|
||||
}
|
||||
return decryptMedia(voice.getMedia(), cdnBase);
|
||||
}
|
||||
|
||||
private static byte[] downloadFile(FileItem file, String cdnBase) throws Exception {
|
||||
if (file == null || file.getMedia() == null) {
|
||||
return null;
|
||||
}
|
||||
return decryptMedia(file.getMedia(), cdnBase);
|
||||
}
|
||||
|
||||
private static byte[] downloadVideo(VideoItem video, String cdnBase) throws Exception {
|
||||
if (video == null || video.getMedia() == null) {
|
||||
return null;
|
||||
}
|
||||
return decryptMedia(video.getMedia(), cdnBase);
|
||||
}
|
||||
|
||||
private static String inferVoiceMimeType(VoiceItem voice) {
|
||||
if (voice == null || voice.getEncodeType() == null) {
|
||||
return "audio/silk";
|
||||
}
|
||||
return switch (voice.getEncodeType()) {
|
||||
case 1 -> "audio/L16";
|
||||
case 4 -> "audio/speex";
|
||||
case 5 -> "audio/amr";
|
||||
case 6 -> "audio/silk";
|
||||
case 7 -> "audio/mpeg";
|
||||
case 8 -> "audio/ogg";
|
||||
default -> "audio/silk";
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isSilkVoice(VoiceItem voice) {
|
||||
return voice != null && voice.getEncodeType() != null && voice.getEncodeType() == 6;
|
||||
}
|
||||
|
||||
private static CdnMedia firstNonBlankMedia(CdnMedia primary, CdnMedia fallback) {
|
||||
if (primary != null && StringUtils.isNotBlank(primary.getEncryptQueryParam()) && StringUtils.isNotBlank(primary.getAesKey())) {
|
||||
return primary;
|
||||
}
|
||||
if (fallback != null && StringUtils.isNotBlank(fallback.getEncryptQueryParam()) && StringUtils.isNotBlank(fallback.getAesKey())) {
|
||||
return fallback;
|
||||
}
|
||||
return primary != null ? primary : fallback;
|
||||
}
|
||||
|
||||
private static byte[] decryptMedia(CdnMedia media, String cdnBase) throws Exception {
|
||||
if (media == null || StringUtils.isBlank(media.getEncryptQueryParam()) || StringUtils.isBlank(media.getAesKey())) {
|
||||
return null;
|
||||
}
|
||||
return WechatIlinkCdnDownloader.downloadAndDecrypt(media.getEncryptQueryParam(), media.getAesKey(), cdnBase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
package com.xspaceagi.im.application.wechat;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.xspaceagi.agent.core.adapter.dto.AttachmentDto;
|
||||
import com.xspaceagi.im.application.ImAgentOutputProcessService;
|
||||
import com.xspaceagi.im.application.ImChannelConfigApplicationService;
|
||||
import com.xspaceagi.im.application.ImSessionApplicationService;
|
||||
import com.xspaceagi.im.application.WechatIlinkAgentApplicationService;
|
||||
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.wechat.ilink.IlinkConstants;
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkHttpClient;
|
||||
import com.xspaceagi.im.wechat.ilink.WechatIlinkMessageHelper;
|
||||
import com.xspaceagi.im.wechat.ilink.WechatIlinkProtocolExtras;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.GetUpdatesResp;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.MessageItem;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.WeixinMessage;
|
||||
import com.xspaceagi.system.sdk.service.ScheduleTaskApiService;
|
||||
import com.xspaceagi.system.sdk.service.dto.ScheduleTaskDto;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.utils.RedisUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 微信 iLink getUpdates 长轮询。
|
||||
* 调度模型:每个应拉取的渠道在 {@code schedule_task} 中一条记录({@link #ILINK_POLL_TASK_ID_PREFIX}),
|
||||
* 由系统定时任务模块按 {@link #pollScheduleCron} 触发 {@link WechatIlinkPollScheduleTask},
|
||||
* 每次执行只做<strong>一轮</strong> {@link #pollOnce}(内含长轮询 HTTP)。并发度由任务条数与调度执行决定。
|
||||
* 注册/注销:{@link #reconcileSingleConfig(Long)} 应对单条配置变更;{@link #reconcileWorkers()} 用于启动全量对齐。
|
||||
* 多实例:{@link #LOCK_PREFIX} 分布式锁保证同一 {@code configId} 同一时刻仅一个实例执行 getUpdates。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WechatIlinkLongPollService {
|
||||
|
||||
/** 与 {@code schedule_task.task_id} 对应,格式:{@value #ILINK_POLL_TASK_ID_PREFIX}{configId} */
|
||||
public static final String ILINK_POLL_TASK_ID_PREFIX = "wechat_ilink_poll_";
|
||||
|
||||
/** 与 {@link WechatIlinkPollScheduleTask} 上 {@code @Component} 名称一致,供 {@link #ensureScheduleTask} 注册 */
|
||||
public static final String SCHEDULE_TASK_BEAN_ID = "wechatIlinkPollScheduleTask";
|
||||
|
||||
/** Redis:getUpdates 游标、消息幂等、分布式锁、配置缓存(field=configId) */
|
||||
private static final String BUF_PREFIX = "wechat_ilink:buf:";
|
||||
private static final String MSG_PREFIX = "wechat_ilink:msg:";
|
||||
private static final String LOCK_PREFIX = "wechat_ilink:poll_lock:";
|
||||
private static final String REDIS_POLL_CFG_CACHE_HASH_KEY = "wechat_ilink:poll_cfg";
|
||||
private static final long TYPING_KEEPALIVE_INTERVAL_SECONDS = 5L;
|
||||
|
||||
private static final int MSG_TTL_SECONDS = 300;
|
||||
private static final int LOCK_TTL_SECONDS = 55;
|
||||
/** getUpdates 游标 buf 在 Redis 中的过期时间(秒);正常轮询会不断续期 */
|
||||
private static final long BUF_TTL_SECONDS = 86400L;
|
||||
private static final String INSTANCE_ID = UUID.randomUUID().toString();
|
||||
private static final ScheduledExecutorService TYPING_KEEPALIVE_EXECUTOR = Executors.newScheduledThreadPool(
|
||||
2,
|
||||
r -> {
|
||||
Thread t = new Thread(r, "wechat-ilink-typing-keepalive");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
);
|
||||
|
||||
/** 本进程最近一次 reconcile 的 configId 列表,用于全量 reconcile 时对比并注销已下线任务 */
|
||||
private volatile List<Long> pollConfigSnapshot = List.of();
|
||||
private final Object pollSnapshotLock = new Object();
|
||||
|
||||
/** 空游标仍返回 -14 的连续次数;会话失效需重新扫码 */
|
||||
private final ConcurrentHashMap<Long, Integer> ilinkEmptyBufSessionTimeoutStreak = new ConcurrentHashMap<>();
|
||||
|
||||
/** 共用 Hash 上配置缓存 TTL(秒);≤0 表示不写/不读 Redis 缓存(每次打库) */
|
||||
private final int pollConfigCacheTtlSeconds = IlinkConstants.DEFAULT_POLL_CONFIG_CACHE_TTL_SECONDS;
|
||||
|
||||
/** 调度任务 cron,默认约每秒触发一次 */
|
||||
private final String pollScheduleCron = IlinkConstants.DEFAULT_POLL_SCHEDULE_CRON;
|
||||
/** 冷启动(buf 为空)时 getUpdates 超时,默认 35s */
|
||||
private final int getUpdatesColdTimeoutMs = IlinkConstants.DEFAULT_GET_UPDATES_POLL_TIMEOUT_MS;
|
||||
/** 热态(buf 非空)时 getUpdates 超时,默认 300ms */
|
||||
private final int getUpdatesHotTimeoutMs = IlinkConstants.DEFAULT_GET_UPDATES_HOT_TIMEOUT_MS;
|
||||
|
||||
@Resource
|
||||
private ScheduleTaskApiService scheduleTaskApiService;
|
||||
@Resource
|
||||
private RedisUtil redisUtil;
|
||||
@Resource
|
||||
private IlinkHttpClient ilinkHttpClient;
|
||||
@Resource
|
||||
private ImAgentOutputProcessService imAgentOutputProcessService;
|
||||
@Resource
|
||||
private ImChannelConfigApplicationService imChannelConfigApplicationService;
|
||||
@Resource
|
||||
private WechatIlinkAgentApplicationService wechatIlinkAgentApplicationService;
|
||||
@Resource
|
||||
private ImSessionApplicationService imSessionApplicationService;
|
||||
@Resource
|
||||
private WechatIlinkInboundMediaService wechatIlinkInboundMediaService;
|
||||
@Resource
|
||||
private WechatIlinkContextTokenStore wechatIlinkContextTokenStore;
|
||||
|
||||
// @EventListener(ApplicationReadyEvent.class)
|
||||
// public void onApplicationReady() {
|
||||
// reconcileWorkers();
|
||||
// }
|
||||
|
||||
/**
|
||||
* 配置中的 botToken / baseUrl 更新后调用:清空 getUpdates 游标与 -14 计数,避免旧游标与新 token 不匹配。
|
||||
*/
|
||||
public void invalidatePollCursor(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
redisUtil.set(BUF_PREFIX + configId, "", BUF_TTL_SECONDS);
|
||||
// Rebind/update should not be blocked by a stale in-flight lock from previous token/session.
|
||||
redisUtil.deleteKey(LOCK_PREFIX + configId);
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink clear poll lock failed, configId={}", configId, e);
|
||||
}
|
||||
ilinkEmptyBufSessionTimeoutStreak.remove(configId);
|
||||
try {
|
||||
redisUtil.hashDelete(REDIS_POLL_CFG_CACHE_HASH_KEY, String.valueOf(configId));
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink delete poll config cache failed, configId={}", configId, e);
|
||||
}
|
||||
try {
|
||||
// Rebind creates a new bot session; old context tokens may map to stale conversation context.
|
||||
wechatIlinkContextTokenStore.clearForConfig(configId);
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink clear context tokens on cursor invalidation failed, configId={}", configId, e);
|
||||
}
|
||||
log.debug("wechat ilink poll cursor invalidated, configId={}", configId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置已从库中删除(逻辑删除)后调用:取消调度任务、清理本渠道轮询相关的 Redis 与内存快照。
|
||||
* <p>
|
||||
* 说明:消息幂等键(前缀 {@code wechat_ilink:msg:})按 messageId 存储,无法按 configId 批量删除,依赖 TTL 自然过期。
|
||||
*/
|
||||
public void removePollForDeletedConfig(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
cancelScheduleTask(configId);
|
||||
try {
|
||||
redisUtil.deleteKey(BUF_PREFIX + configId);
|
||||
redisUtil.deleteKey(LOCK_PREFIX + configId);
|
||||
redisUtil.hashDelete(REDIS_POLL_CFG_CACHE_HASH_KEY, String.valueOf(configId));
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink remove poll redis keys failed, configId={}", configId, e);
|
||||
}
|
||||
try {
|
||||
wechatIlinkContextTokenStore.clearForConfig(configId);
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink clear context tokens failed, configId={}", configId, e);
|
||||
}
|
||||
ilinkEmptyBufSessionTimeoutStreak.remove(configId);
|
||||
synchronized (pollSnapshotLock) {
|
||||
List<Long> snap = new ArrayList<>(pollConfigSnapshot);
|
||||
snap.remove(configId);
|
||||
pollConfigSnapshot = Collections.unmodifiableList(snap);
|
||||
}
|
||||
log.info("wechat ilink poll removed for deleted configId={}", configId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个渠道变更时调用:只查该 config,注册或注销 {@code schedule_task},并更新内存快照。
|
||||
* 启动或全量对齐请用 {@link #reconcileWorkers()}。
|
||||
*/
|
||||
public void reconcileSingleConfig(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
ImChannelConfigDto dto = imChannelConfigApplicationService.getDtoById(configId);
|
||||
boolean eligible = isWechatIlinkPollEligible(dto);
|
||||
synchronized (pollSnapshotLock) {
|
||||
List<Long> snap = new ArrayList<>(pollConfigSnapshot);
|
||||
if (eligible) {
|
||||
ensureScheduleTask(configId);
|
||||
if (!snap.contains(configId)) {
|
||||
snap.add(configId);
|
||||
}
|
||||
} else {
|
||||
cancelScheduleTask(configId);
|
||||
snap.remove(configId);
|
||||
try {
|
||||
redisUtil.deleteKey(BUF_PREFIX + configId);
|
||||
redisUtil.deleteKey(LOCK_PREFIX + configId);
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink remove poll buf/lock on ineligible, configId={}", configId, e);
|
||||
}
|
||||
if (pollConfigCacheTtlSeconds > 0) {
|
||||
try {
|
||||
redisUtil.hashDelete(REDIS_POLL_CFG_CACHE_HASH_KEY, String.valueOf(configId));
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink remove poll cfg cache field, configId={}", configId, e);
|
||||
}
|
||||
}
|
||||
ilinkEmptyBufSessionTimeoutStreak.remove(configId);
|
||||
}
|
||||
pollConfigSnapshot = Collections.unmodifiableList(snap);
|
||||
}
|
||||
log.debug("wechat ilink reconcile single configId={}, eligible={}", configId, eligible);
|
||||
}
|
||||
|
||||
/** 是否应注册调度任务并执行拉取(渠道类型、启用、botToken) */
|
||||
static boolean isWechatIlinkPollEligible(ImChannelConfigDto dto) {
|
||||
if (dto == null || dto.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
if (!ImChannelEnum.WECHAT_ILINK.getCode().equals(dto.getChannel())) {
|
||||
return false;
|
||||
}
|
||||
if (Boolean.FALSE.equals(dto.getEnabled())) {
|
||||
return false;
|
||||
}
|
||||
if (dto.getWechatIlink() == null || StringUtils.isBlank(dto.getWechatIlink().getBotToken())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量刷新:分页查询所有启用的微信 iLink 配置,注册/注销 {@code schedule_task}。
|
||||
* 用于启动时或需要与 DB 完全一致时。
|
||||
*/
|
||||
public void reconcileWorkers() {
|
||||
List<Long> previous = new ArrayList<>(pollConfigSnapshot);
|
||||
List<Long> ids = new ArrayList<>();
|
||||
int offset = 0;
|
||||
while (true) {
|
||||
List<ImChannelConfigDto> list = imChannelConfigApplicationService.listWechatIlinkEnabledByPage(offset, 100);
|
||||
if (list == null || list.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (ImChannelConfigDto dto : list) {
|
||||
if (dto.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
if (dto.getWechatIlink() == null || StringUtils.isBlank(dto.getWechatIlink().getBotToken())) {
|
||||
continue;
|
||||
}
|
||||
ids.add(dto.getId());
|
||||
}
|
||||
offset += 100;
|
||||
if (list.size() < 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Set<Long> nextSet = new HashSet<>(ids);
|
||||
for (Long oldId : previous) {
|
||||
if (!nextSet.contains(oldId)) {
|
||||
cancelScheduleTask(oldId);
|
||||
}
|
||||
}
|
||||
for (Long configId : ids) {
|
||||
ensureScheduleTask(configId);
|
||||
}
|
||||
synchronized (pollSnapshotLock) {
|
||||
pollConfigSnapshot = Collections.unmodifiableList(ids);
|
||||
}
|
||||
prunePollConfigCacheFieldsNotIn(ids);
|
||||
log.info("wechat ilink poll reconciled, configCount={}, scheduleTasks synced", ids.size());
|
||||
}
|
||||
|
||||
private void ensureScheduleTask(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
scheduleTaskApiService.start(ScheduleTaskDto.builder()
|
||||
.taskId(ILINK_POLL_TASK_ID_PREFIX + configId)
|
||||
.beanId(SCHEDULE_TASK_BEAN_ID)
|
||||
.cron(pollScheduleCron)
|
||||
.maxExecTimes(Long.MAX_VALUE)
|
||||
.params(Map.of("configId", configId))
|
||||
.taskName("微信iLink消息拉取")
|
||||
.targetType("WechatIlink")
|
||||
.targetId(String.valueOf(configId))
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink register schedule task failed, configId={}", configId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelScheduleTask(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
scheduleTaskApiService.cancel(ILINK_POLL_TASK_ID_PREFIX + configId);
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink cancel schedule task failed, configId={}", configId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 供 {@link WechatIlinkPollScheduleTask} 调用:单次调度入口,内部只做一轮 {@link #pollOnce}。
|
||||
*/
|
||||
public void executePollOnce(Long configId) {
|
||||
pollOnce(configId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重绑/配置更新后强制收敛一次运行态:取消旧任务、清缓存、重建任务并立即拉取一轮。
|
||||
* 保持现有线程模型,不引入额外常驻轮询线程。
|
||||
*/
|
||||
public void forceReloadAndPollOnce(Long configId) {
|
||||
if (configId == null) {
|
||||
return;
|
||||
}
|
||||
cancelScheduleTask(configId);
|
||||
invalidatePollCursor(configId);
|
||||
reconcileSingleConfig(configId);
|
||||
executePollOnce(configId);
|
||||
log.debug("wechat ilink force reload and poll once done, configId={}", configId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询列表刷新后,删除共用 Hash 中已不在列表内的 field,避免残留 id 占用内存。
|
||||
*/
|
||||
private void prunePollConfigCacheFieldsNotIn(List<Long> activeIds) {
|
||||
if (pollConfigCacheTtlSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Set<Object> fields = redisUtil.hashKeys(REDIS_POLL_CFG_CACHE_HASH_KEY);
|
||||
if (fields == null || fields.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<Long> active = new HashSet<>(activeIds);
|
||||
for (Object f : fields) {
|
||||
if (f == null) {
|
||||
continue;
|
||||
}
|
||||
String fk = f.toString();
|
||||
if (StringUtils.isBlank(fk)) {
|
||||
continue;
|
||||
}
|
||||
long pid;
|
||||
try {
|
||||
pid = Long.parseLong(fk);
|
||||
} catch (NumberFormatException e) {
|
||||
continue;
|
||||
}
|
||||
if (!active.contains(pid)) {
|
||||
redisUtil.hashDelete(REDIS_POLL_CFG_CACHE_HASH_KEY, fk);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink prune poll config cache fields failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先读 Redis Hash 缓存,未命中再查库并回写;与 {@link #invalidatePollCursor(Long)} 配合保证 token 等变更可见。
|
||||
*/
|
||||
private ImChannelConfigDto loadDtoForPoll(Long configId) {
|
||||
if (pollConfigCacheTtlSeconds <= 0) {
|
||||
return imChannelConfigApplicationService.getDtoById(configId);
|
||||
}
|
||||
String field = String.valueOf(configId);
|
||||
try {
|
||||
Object cached = redisUtil.hashGet(REDIS_POLL_CFG_CACHE_HASH_KEY, field);
|
||||
if (cached instanceof String s && StringUtils.isNotBlank(s)) {
|
||||
ImChannelConfigDto dto = JSON.parseObject(s, ImChannelConfigDto.class);
|
||||
if (dto != null && dto.getId() != null) {
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink poll config cache read failed, configId={}", configId, e);
|
||||
}
|
||||
ImChannelConfigDto dto = imChannelConfigApplicationService.getDtoById(configId);
|
||||
if (dto != null) {
|
||||
putPollConfigCache(configId, dto);
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void putPollConfigCache(Long configId, ImChannelConfigDto dto) {
|
||||
if (pollConfigCacheTtlSeconds <= 0 || dto == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
redisUtil.hashPut(REDIS_POLL_CFG_CACHE_HASH_KEY, String.valueOf(configId), JSON.toJSONString(dto));
|
||||
redisUtil.expire(REDIS_POLL_CFG_CACHE_HASH_KEY, pollConfigCacheTtlSeconds);
|
||||
} catch (Exception e) {
|
||||
log.warn("wechat ilink put poll config cache failed, configId={}", configId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次调度内的一轮拉取:抢锁 → getUpdates(长轮询阻塞)→ 处理消息。
|
||||
* 资格与 {@link #reconcileSingleConfig(Long)} / 全量列表一致,统一用 {@link #isWechatIlinkPollEligible}。
|
||||
* <p>
|
||||
* 资格判断走 {@link #loadDtoForPoll(Long)}(优先 Redis Hash,减轻 DB;允许与库表短暂不一致)。
|
||||
* 若当前视图下不可拉取,仍调用 {@link #reconcileSingleConfig(Long)}:内部会再查库对齐,必要时 {@code cancel} 调度并清理 buf/锁/快照。
|
||||
* 重新启用后由配置变更路径 {@code reconcileSingleConfig} 会 {@link #ensureScheduleTask(Long)} 注册任务。
|
||||
*/
|
||||
private void pollOnce(Long configId) {
|
||||
try {
|
||||
ImChannelConfigDto dto = loadDtoForPoll(configId);
|
||||
if (!isWechatIlinkPollEligible(dto)) {
|
||||
log.debug("wechat ilink poll ineligible, reconcile to cancel schedule if needed, configId={}", configId);
|
||||
reconcileSingleConfig(configId);
|
||||
return;
|
||||
}
|
||||
|
||||
String lockKey = LOCK_PREFIX + configId;
|
||||
if (!redisUtil.setIfAbsent(lockKey, INSTANCE_ID, LOCK_TTL_SECONDS)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ImChannelConfigDto.WechatIlinkConfig w = dto.getWechatIlink();
|
||||
String baseUrl = StringUtils.defaultIfBlank(w.getBaseUrl(), IlinkConstants.DEFAULT_BASE_URL);
|
||||
String bufKey = BUF_PREFIX + configId;
|
||||
String buf = redisUtil.get(bufKey) instanceof String s ? s : "";
|
||||
int timeout = resolveGetUpdatesTimeoutMs(buf);
|
||||
String ilinkAccountId = w.getIlinkAccountId();
|
||||
GetUpdatesResp resp = ilinkHttpClient.getUpdatesLenient(baseUrl, w.getBotToken(), buf, timeout, ilinkAccountId);
|
||||
|
||||
Integer ret = resp.getRet();
|
||||
Integer ec = resp.getErrcode();
|
||||
Integer code = ec != null ? ec : ret;
|
||||
boolean hasApiError = (ret != null && ret != 0) || (ec != null && ec != 0);
|
||||
|
||||
if (hasApiError && code == IlinkConstants.GET_UPDATES_ERR_SESSION_TIMEOUT) {
|
||||
redisUtil.set(bufKey, "", BUF_TTL_SECONDS);
|
||||
if (StringUtils.isNotBlank(buf)) {
|
||||
ilinkEmptyBufSessionTimeoutStreak.remove(configId);
|
||||
log.debug("wechat ilink getUpdates -14, cleared non-empty cursor, configId={}", configId);
|
||||
return;
|
||||
}
|
||||
int streak = ilinkEmptyBufSessionTimeoutStreak.merge(configId, 1, Integer::sum);
|
||||
if (streak == 1) {
|
||||
log.warn("wechat ilink getUpdates session timeout (ret={}, errcode={}) with empty cursor; "
|
||||
+ "upstream session or botToken is invalid — re-scan QR to refresh bot token. configId={}", ret, ec, configId);
|
||||
imChannelConfigApplicationService.disableWechatIlinkOnSessionExpired(configId);
|
||||
} else if (streak % 20 == 0) {
|
||||
log.warn("wechat ilink getUpdates still session-timeout with empty cursor (ret={}, errcode={}, streak={}), configId={}",
|
||||
ret, ec, streak, configId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hasApiError) {
|
||||
log.warn("wechat ilink getUpdates error, configId={}, ret={}, errcode={}, errmsg={}",
|
||||
configId, ret, ec, resp.resolveErrmsg());
|
||||
return;
|
||||
}
|
||||
ilinkEmptyBufSessionTimeoutStreak.remove(configId);
|
||||
if (resp.getGetUpdatesBuf() != null) {
|
||||
redisUtil.set(bufKey, resp.getGetUpdatesBuf(), BUF_TTL_SECONDS);
|
||||
}
|
||||
if (resp.getMsgs() != null && !resp.getMsgs().isEmpty()) {
|
||||
handleInboundMessagesBatch(resp.getMsgs(), dto);
|
||||
}
|
||||
} finally {
|
||||
redisUtil.unlock(lockKey);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (e instanceof InterruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
log.warn("wechat ilink poll error configId={}", configId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** buf 为空视为重绑/冷启动建链阶段,使用长超时;非空走热态短超时。 */
|
||||
private int resolveGetUpdatesTimeoutMs(String buf) {
|
||||
int cold = Math.max(getUpdatesColdTimeoutMs, 1);
|
||||
int hot = Math.max(getUpdatesHotTimeoutMs, 1);
|
||||
return StringUtils.isBlank(buf) ? cold : hot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 getUpdates 若返回多条用户消息:按 {@code from_user_id} 分组(同一批内),
|
||||
* 同组内合并为一条文本 + 合并附件后只调用一次智能体;单条仍走 {@link #handleInboundMessage}。
|
||||
* <p>
|
||||
* 若按 {@code from + context_token} 分组,上游常对每条消息给不同 {@code context_token},会拆成多组并连续调智能体,
|
||||
* 在沙箱仍 busy 时第二条会失败(9010)。
|
||||
*/
|
||||
private void handleInboundMessagesBatch(List<WeixinMessage> msgs, ImChannelConfigDto channelDto) {
|
||||
if (msgs == null || msgs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<WeixinMessage> userMsgs = new ArrayList<>();
|
||||
for (WeixinMessage m : msgs) {
|
||||
if (m != null && WechatIlinkMessageHelper.isUserMessage(m)) {
|
||||
userMsgs.add(m);
|
||||
}
|
||||
}
|
||||
if (userMsgs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<WeixinMessage>> groups = new LinkedHashMap<>();
|
||||
for (WeixinMessage m : userMsgs) {
|
||||
String from = m.getFromUserId();
|
||||
if (StringUtils.isBlank(from)) {
|
||||
log.warn("wechat ilink skip message in batch: missing from mid={}", m.getMessageId());
|
||||
continue;
|
||||
}
|
||||
groups.computeIfAbsent(from, k -> new ArrayList<>()).add(m);
|
||||
}
|
||||
Comparator<WeixinMessage> byTime = Comparator
|
||||
.comparing((WeixinMessage x) -> Optional.ofNullable(x.getCreateTimeMs()).orElse(0L))
|
||||
.thenComparing(x -> Optional.ofNullable(x.getMessageId()).orElse(0L));
|
||||
for (List<WeixinMessage> group : groups.values()) {
|
||||
group.sort(byTime);
|
||||
}
|
||||
for (List<WeixinMessage> group : groups.values()) {
|
||||
if (group.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (group.size() == 1) {
|
||||
handleInboundMessage(group.get(0), channelDto);
|
||||
} else {
|
||||
handleMergedInboundMessages(group, channelDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一会话、同一批中的多条消息合并为一条入参;若任一条 messageId 已处理则整批跳过(防重复投递)。
|
||||
* 回复使用组内时间序最后一条非空 {@code context_token}(与上游逐条 token 对齐)。
|
||||
*/
|
||||
private void handleMergedInboundMessages(List<WeixinMessage> group, ImChannelConfigDto channelDto) {
|
||||
for (WeixinMessage m : group) {
|
||||
Long mid = m.getMessageId();
|
||||
if (mid != null && redisUtil.get(MSG_PREFIX + mid) != null) {
|
||||
log.debug("wechat ilink merged batch skip: duplicate messageId={}", mid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
WeixinMessage first = group.get(0);
|
||||
String from = first.getFromUserId();
|
||||
for (WeixinMessage m : group) {
|
||||
if (m != null && StringUtils.isNotBlank(m.getContextToken())) {
|
||||
wechatIlinkContextTokenStore.save(channelDto.getId(), from, m.getContextToken());
|
||||
}
|
||||
}
|
||||
String contextToken = resolveReplyContextToken(group);
|
||||
if (StringUtils.isBlank(contextToken)) {
|
||||
contextToken = wechatIlinkContextTokenStore.get(channelDto.getId(), from);
|
||||
}
|
||||
if (StringUtils.isBlank(contextToken)) {
|
||||
log.warn("wechat ilink merged batch: no context_token in message or Redis, still dispatch agent, messageIds={}",
|
||||
group.stream().map(WeixinMessage::getMessageId).toList());
|
||||
}
|
||||
for (WeixinMessage m : group) {
|
||||
Long mid = m.getMessageId();
|
||||
if (mid != null) {
|
||||
redisUtil.set(MSG_PREFIX + mid, "1", MSG_TTL_SECONDS);
|
||||
}
|
||||
}
|
||||
List<AttachmentDto> allAttachments = new ArrayList<>();
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (WeixinMessage m : group) {
|
||||
var att = wechatIlinkInboundMediaService.buildAttachmentsFromMessage(m, channelDto);
|
||||
if (att != null && !att.isEmpty()) {
|
||||
allAttachments.addAll(att);
|
||||
}
|
||||
String part = buildInboundUserText(m);
|
||||
if (StringUtils.isBlank(part)) {
|
||||
if (att != null && !att.isEmpty()) {
|
||||
part = "[用户发送了附件]";
|
||||
} else {
|
||||
part = "[用户发送了非文本内容]";
|
||||
}
|
||||
}
|
||||
parts.add(part);
|
||||
}
|
||||
String mergedText = String.join("\n\n", parts);
|
||||
if (StringUtils.isBlank(mergedText) && !allAttachments.isEmpty()) {
|
||||
mergedText = "[用户发送了附件]";
|
||||
}
|
||||
if (StringUtils.isBlank(mergedText) && allAttachments.isEmpty()) {
|
||||
mergedText = "[用户发送了非文本内容]";
|
||||
}
|
||||
log.info("wechat ilink dispatch agent (merged), configId={}, count={}, fromUserId={}, messageIds={}",
|
||||
channelDto.getId(), group.size(), from,
|
||||
group.stream().map(WeixinMessage::getMessageId).toList());
|
||||
runAgentAndReply(channelDto, from, contextToken, mergedText, allAttachments);
|
||||
}
|
||||
|
||||
/** 从后往前取第一条非空 context_token,供合并回复与最新一条用户消息对齐。 */
|
||||
private static String resolveReplyContextToken(List<WeixinMessage> group) {
|
||||
if (group == null || group.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (int i = group.size() - 1; i >= 0; i--) {
|
||||
String ct = group.get(i).getContextToken();
|
||||
if (StringUtils.isNotBlank(ct)) {
|
||||
return ct;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void handleInboundMessage(WeixinMessage msg, ImChannelConfigDto channelDto) {
|
||||
if (msg == null) {
|
||||
return;
|
||||
}
|
||||
if (!WechatIlinkMessageHelper.isUserMessage(msg)) {
|
||||
return;
|
||||
}
|
||||
Long mid = msg.getMessageId();
|
||||
if (mid != null) {
|
||||
String dedupKey = MSG_PREFIX + mid;
|
||||
if (redisUtil.get(dedupKey) != null) {
|
||||
return;
|
||||
}
|
||||
redisUtil.set(dedupKey, "1", MSG_TTL_SECONDS);
|
||||
}
|
||||
String from = msg.getFromUserId();
|
||||
String contextToken = msg.getContextToken();
|
||||
if (StringUtils.isBlank(from)) {
|
||||
log.warn("wechat ilink skip message: missing from mid={}", mid);
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isNotBlank(contextToken)) {
|
||||
wechatIlinkContextTokenStore.save(channelDto.getId(), from, contextToken);
|
||||
} else {
|
||||
contextToken = wechatIlinkContextTokenStore.get(channelDto.getId(), from);
|
||||
}
|
||||
if (StringUtils.isBlank(contextToken)) {
|
||||
log.warn("wechat ilink: no context_token in message or Redis, still dispatch agent, mid={}", mid);
|
||||
}
|
||||
|
||||
String userText = buildInboundUserText(msg);
|
||||
var attachments = wechatIlinkInboundMediaService.buildAttachmentsFromMessage(msg, channelDto);
|
||||
if (StringUtils.isBlank(userText)) {
|
||||
if (attachments != null && !attachments.isEmpty()) {
|
||||
userText = "[用户发送了附件]";
|
||||
} else {
|
||||
userText = "[用户发送了非文本内容]";
|
||||
}
|
||||
}
|
||||
|
||||
final String finalUserText = userText;
|
||||
final var finalAttachments = attachments;
|
||||
final String finalContextToken = contextToken;
|
||||
log.info("wechat ilink dispatch agent, configId={}, messageId={}, fromUserId={}", channelDto.getId(), mid, from);
|
||||
runAgentAndReply(channelDto, from, finalContextToken, finalUserText, finalAttachments);
|
||||
}
|
||||
|
||||
private String buildInboundUserText(WeixinMessage msg) {
|
||||
String plain = WechatIlinkMessageHelper.extractFirstText(msg);
|
||||
if (StringUtils.isNotBlank(plain)) {
|
||||
return plain;
|
||||
}
|
||||
if (msg.getItemList() == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (MessageItem it : msg.getItemList()) {
|
||||
if (it == null || it.getType() == null) {
|
||||
continue;
|
||||
}
|
||||
switch (it.getType()) {
|
||||
case 2 -> sb.append("[用户发送了图片]\n");
|
||||
case 3 -> sb.append("[用户发送了语音]\n");
|
||||
case 4 -> sb.append("[用户发送了文件]\n");
|
||||
case 5 -> sb.append("[用户发送了视频]\n");
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 非阻塞:智能体在 {@link reactor.core.scheduler.Schedulers#boundedElastic()} 上执行,本方法立即返回,回复在回调中发送。
|
||||
*/
|
||||
private void runAgentAndReply(ImChannelConfigDto channelDto,
|
||||
String fromUserId,
|
||||
String contextToken,
|
||||
String userMessage,
|
||||
List<AttachmentDto> attachments) {
|
||||
ImChannelConfigDto.WechatIlinkConfig w = channelDto.getWechatIlink();
|
||||
if (w == null) {
|
||||
return;
|
||||
}
|
||||
if (isNewCommand(userMessage) && (attachments == null || attachments.isEmpty())) {
|
||||
try {
|
||||
createNewConversationForWechatIlink(channelDto, fromUserId);
|
||||
String baseUrl = StringUtils.defaultIfBlank(w.getBaseUrl(), IlinkConstants.DEFAULT_BASE_URL);
|
||||
String replyToken = effectiveContextTokenForReply(channelDto.getId(), fromUserId, contextToken);
|
||||
WeixinMessage reply = WechatIlinkMessageHelper.buildTextReply(
|
||||
fromUserId, replyToken, "已为你创建新会话,后续消息默认走新会话");
|
||||
ilinkHttpClient.sendMessage(baseUrl, w.getBotToken(), reply, 15_000);
|
||||
} catch (Exception e) {
|
||||
log.error("wechat ilink create new conversation failed, configId={}, fromUserId={}",
|
||||
channelDto.getId(), fromUserId, e);
|
||||
sendWechatIlinkErrorReply(channelDto.getId(), w, fromUserId, contextToken, e.getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
log.info("wechat ilink agent execute start, configId={}, fromUserId={}", channelDto.getId(), fromUserId);
|
||||
String baseUrl = StringUtils.defaultIfBlank(w.getBaseUrl(), IlinkConstants.DEFAULT_BASE_URL);
|
||||
String typingToken = effectiveContextTokenForReply(channelDto.getId(), fromUserId, contextToken);
|
||||
AtomicBoolean typingStarted = new AtomicBoolean(sendTypingStartSafely(w, baseUrl, fromUserId, typingToken));
|
||||
ScheduledFuture<?> typingHeartbeat = startTypingKeepalive(w, baseUrl, fromUserId, typingToken, typingStarted);
|
||||
wechatIlinkAgentApplicationService.executeAgentWithConv(
|
||||
fromUserId,
|
||||
userMessage,
|
||||
attachments != null ? attachments : Collections.emptyList(),
|
||||
channelDto.getTenantId(),
|
||||
channelDto.getUserId(),
|
||||
channelDto.getAgentId(),
|
||||
fromUserId)
|
||||
.doFinally(st -> {
|
||||
stopTypingKeepalive(typingHeartbeat);
|
||||
if (typingStarted.get()) {
|
||||
sendTypingCancelSafely(w, baseUrl, fromUserId, typingToken);
|
||||
}
|
||||
})
|
||||
.subscribe(
|
||||
result -> {
|
||||
try {
|
||||
String processed = imAgentOutputProcessService.processAgentOutput(
|
||||
result.getText(), result.getConversationId(), result.getAgentId(),
|
||||
channelDto.getTenantId(), channelDto.getUserId(), ImChannelEnum.WECHAT_ILINK.getCode());
|
||||
if (StringUtils.isBlank(processed)) {
|
||||
processed = "模型终止执行";
|
||||
}
|
||||
String replyToken = effectiveContextTokenForReply(channelDto.getId(), fromUserId, contextToken);
|
||||
WeixinMessage reply = WechatIlinkMessageHelper.buildTextReply(fromUserId, replyToken, processed);
|
||||
ilinkHttpClient.sendMessage(baseUrl, w.getBotToken(), reply, 15_000);
|
||||
log.info("wechat ilink reply sent, configId={}, fromUserId={}, textLen={}", channelDto.getId(), fromUserId,
|
||||
processed.length());
|
||||
} catch (Exception e) {
|
||||
log.error("wechat ilink agent/reply failed fromUserId={}", fromUserId, e);
|
||||
sendWechatIlinkErrorReply(channelDto.getId(), w, fromUserId, contextToken, e.getMessage());
|
||||
}
|
||||
},
|
||||
err -> {
|
||||
log.error("wechat ilink agent mono error fromUserId={}", fromUserId, err);
|
||||
sendWechatIlinkErrorReply(channelDto.getId(), w, fromUserId, contextToken, err.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isNewCommand(String userMessage) {
|
||||
return "/new".equals(StringUtils.trimToEmpty(userMessage));
|
||||
}
|
||||
|
||||
private void createNewConversationForWechatIlink(ImChannelConfigDto channelDto, String fromUserId) {
|
||||
// /new 命令从长轮询后台线程进入,无 HTTP RequestContext,需补齐最小租户上下文
|
||||
boolean hasRequestContext = RequestContext.get() != null;
|
||||
if (!hasRequestContext) {
|
||||
RequestContext<Object> requestContext = new RequestContext<>();
|
||||
requestContext.setTenantId(channelDto.getTenantId());
|
||||
RequestContext.set(requestContext);
|
||||
}
|
||||
try {
|
||||
ImSession imSession = ImSession.builder()
|
||||
.channel(ImChannelEnum.WECHAT_ILINK.getCode())
|
||||
.targetType(ImTargetTypeEnum.BOT.getCode())
|
||||
.sessionKey(fromUserId)
|
||||
.sessionName(fromUserId)
|
||||
.chatType(ImChatTypeEnum.PRIVATE.getCode())
|
||||
.userId(channelDto.getUserId())
|
||||
.agentId(channelDto.getAgentId())
|
||||
.tenantId(channelDto.getTenantId())
|
||||
.build();
|
||||
imSessionApplicationService.createNewConversationId(imSession);
|
||||
} finally {
|
||||
if (!hasRequestContext) {
|
||||
RequestContext.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean sendTypingStartSafely(ImChannelConfigDto.WechatIlinkConfig w,
|
||||
String baseUrl,
|
||||
String fromUserId,
|
||||
String contextToken) {
|
||||
if (w == null || StringUtils.isBlank(w.getBotToken()) || StringUtils.isBlank(fromUserId) || StringUtils.isBlank(contextToken)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
WechatIlinkProtocolExtras.sendTypingIndicator(ilinkHttpClient, baseUrl, w.getBotToken(), fromUserId, contextToken);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink typing start failed, fromUserId={}", fromUserId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void sendTypingCancelSafely(ImChannelConfigDto.WechatIlinkConfig w,
|
||||
String baseUrl,
|
||||
String fromUserId,
|
||||
String contextToken) {
|
||||
if (w == null || StringUtils.isBlank(w.getBotToken()) || StringUtils.isBlank(fromUserId) || StringUtils.isBlank(contextToken)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
WechatIlinkProtocolExtras.cancelTypingIndicator(ilinkHttpClient, baseUrl, w.getBotToken(), fromUserId, contextToken);
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink typing cancel failed, fromUserId={}", fromUserId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private ScheduledFuture<?> startTypingKeepalive(ImChannelConfigDto.WechatIlinkConfig w,
|
||||
String baseUrl,
|
||||
String fromUserId,
|
||||
String contextToken,
|
||||
AtomicBoolean typingStarted) {
|
||||
if (w == null || StringUtils.isBlank(w.getBotToken()) || StringUtils.isBlank(fromUserId) || StringUtils.isBlank(contextToken)) {
|
||||
return null;
|
||||
}
|
||||
return TYPING_KEEPALIVE_EXECUTOR.scheduleAtFixedRate(() -> {
|
||||
boolean ok = sendTypingStartSafely(w, baseUrl, fromUserId, contextToken);
|
||||
if (ok) {
|
||||
typingStarted.set(true);
|
||||
}
|
||||
}, TYPING_KEEPALIVE_INTERVAL_SECONDS, TYPING_KEEPALIVE_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void stopTypingKeepalive(ScheduledFuture<?> future) {
|
||||
if (future == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
future.cancel(false);
|
||||
} catch (Exception e) {
|
||||
log.debug("wechat ilink typing keepalive cancel failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先使用当次入站 token,否则 Redis 最近 token;仍无则返回 null(对齐 openclaw-weixin 1.0.3 允许无 context 发送)。
|
||||
*/
|
||||
private String effectiveContextTokenForReply(Long configId, String fromUserId, String messageToken) {
|
||||
if (StringUtils.isNotBlank(messageToken)) {
|
||||
return messageToken;
|
||||
}
|
||||
String cached = wechatIlinkContextTokenStore.get(configId, fromUserId);
|
||||
if (StringUtils.isNotBlank(cached)) {
|
||||
log.debug("wechat ilink: context_token fallback from Redis configId={}, fromUserId={}", configId, fromUserId);
|
||||
return cached;
|
||||
}
|
||||
log.warn("wechat ilink: no context_token for configId={}, fromUserId={}, sending without context (align 1.0.3)", configId, fromUserId);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void sendWechatIlinkErrorReply(Long configId,
|
||||
ImChannelConfigDto.WechatIlinkConfig w,
|
||||
String fromUserId,
|
||||
String contextToken,
|
||||
String errMsg) {
|
||||
try {
|
||||
String baseUrl = StringUtils.defaultIfBlank(w.getBaseUrl(), IlinkConstants.DEFAULT_BASE_URL);
|
||||
String replyToken = effectiveContextTokenForReply(configId, fromUserId, contextToken);
|
||||
WeixinMessage errReply = WechatIlinkMessageHelper.buildTextReply(fromUserId, replyToken,
|
||||
"执行异常: " + (errMsg != null ? errMsg : "未知错误"));
|
||||
ilinkHttpClient.sendMessage(baseUrl, w.getBotToken(), errReply, 15_000);
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.xspaceagi.im.application.wechat;
|
||||
|
||||
import com.xspaceagi.system.sdk.service.AbstractTaskExecuteService;
|
||||
import com.xspaceagi.system.sdk.service.dto.ScheduleTaskDto;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 系统定时任务入口:每个微信 iLink 渠道一条task
|
||||
*/
|
||||
@Slf4j
|
||||
@Component(WechatIlinkLongPollService.SCHEDULE_TASK_BEAN_ID)
|
||||
public class WechatIlinkPollScheduleTask extends AbstractTaskExecuteService {
|
||||
|
||||
@Resource
|
||||
private WechatIlinkLongPollService wechatIlinkLongPollService;
|
||||
|
||||
@Override
|
||||
protected boolean execute(ScheduleTaskDto scheduleTaskDto) {
|
||||
Long configId = resolveConfigId(scheduleTaskDto);
|
||||
if (configId == null) {
|
||||
log.warn("wechat ilink poll schedule task missing configId, taskId={}", scheduleTaskDto.getTaskId());
|
||||
return false;
|
||||
}
|
||||
wechatIlinkLongPollService.executePollOnce(configId);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Long resolveConfigId(ScheduleTaskDto dto) {
|
||||
Object params = dto.getParams();
|
||||
if (params instanceof Map<?, ?> m) {
|
||||
Object c = m.get("configId");
|
||||
if (c instanceof Number n) {
|
||||
return n.longValue();
|
||||
}
|
||||
if (c != null) {
|
||||
try {
|
||||
return Long.parseLong(c.toString());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
String tid = dto.getTaskId();
|
||||
String p = WechatIlinkLongPollService.ILINK_POLL_TASK_ID_PREFIX;
|
||||
if (tid != null && tid.startsWith(p)) {
|
||||
try {
|
||||
return Long.parseLong(tid.substring(p.length()));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.xspaceagi.im.application.wechat;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.xspaceagi.im.infra.dao.enitity.ImChannelConfig;
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkAccountIdNormalize;
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkConstants;
|
||||
import com.xspaceagi.im.wechat.ilink.IlinkHttpClient;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.QrCodeStartResponse;
|
||||
import com.xspaceagi.im.wechat.ilink.dto.QrCodeStatusResponse;
|
||||
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 jakarta.annotation.Resource;
|
||||
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.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 微信 iLink 扫码:仅负责获取二维码与上游会话 {@link #pollStatus(String)}(确认后不落库)。
|
||||
* 落库请使用 {@link com.xspaceagi.im.application.ImChannelConfigApplicationService#add(ImChannelConfig)}(channel=wechat_ilink, targetType=bot),并传入扫码得到的 configData。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WechatIlinkQrService {
|
||||
|
||||
private static final String SESSION_PREFIX = "wechat_ilink:qr_session:";
|
||||
private static final int SESSION_TTL_SECONDS = 900;
|
||||
|
||||
@Resource
|
||||
private RedisUtil redisUtil;
|
||||
@Resource
|
||||
private IlinkHttpClient ilinkHttpClient;
|
||||
|
||||
public QrStartResult startSession() {
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
String apiBase = IlinkConstants.DEFAULT_BASE_URL;
|
||||
String bt = IlinkConstants.DEFAULT_BOT_TYPE;
|
||||
try {
|
||||
QrCodeStartResponse qr = ilinkHttpClient.getBotQrCode(apiBase, bt);
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("baseUrl", apiBase);
|
||||
o.put("botType", bt);
|
||||
o.put("qrcode", qr.getQrcode());
|
||||
o.put("qrcodeImgContent", qr.getQrcodeImgContent());
|
||||
redisUtil.set(SESSION_PREFIX + sessionId, o.toJSONString(), SESSION_TTL_SECONDS);
|
||||
return QrStartResult.builder()
|
||||
.sessionId(sessionId)
|
||||
.qrcode(qr.getQrcode())
|
||||
.qrcodeImgContent(qr.getQrcodeImgContent())
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.error("get_bot_qrcode failed", e);
|
||||
throw BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.imWechatQrFetchFailed, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 长轮询上游扫码状态(单次调用可能阻塞数十秒)。上游返回 confirmed 后<strong>不落库</strong>,仅将 token 等写入会话 Redis;
|
||||
* 前端将 configData 与 spaceId、agentId 一并提交 {@code POST /api/im-config/channel/add} 保存。
|
||||
*/
|
||||
public QrPollResult pollStatus(String sessionId) {
|
||||
String raw = redisUtil.get(SESSION_PREFIX + sessionId) instanceof String s ? s : null;
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatQrSessionExpired);
|
||||
}
|
||||
JSONObject o = JSON.parseObject(raw);
|
||||
if ("confirmed".equals(o.getString("lastStatus"))) {
|
||||
return QrPollResult.builder()
|
||||
.status("confirmed")
|
||||
.configData(configDataJsonFromConfirmedSession(o))
|
||||
.build();
|
||||
}
|
||||
String qrcode = o.getString("qrcode");
|
||||
String apiBase = o.getString("baseUrl");
|
||||
if (StringUtils.isBlank(qrcode)) {
|
||||
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.imWechatQrSessionDataInvalid);
|
||||
}
|
||||
try {
|
||||
QrCodeStatusResponse st = ilinkHttpClient.getQrCodeStatus(apiBase, qrcode,
|
||||
(int) IlinkConstants.DEFAULT_LONG_POLL_TIMEOUT_MS);
|
||||
String stStr = st.getStatus();
|
||||
o.put("lastStatus", stStr);
|
||||
if ("confirmed".equals(stStr)) {
|
||||
o.put("botToken", st.getBotToken());
|
||||
o.put("loginBaseUrl", st.getBaseurl());
|
||||
o.put("ilinkBotId", st.getIlinkBotId());
|
||||
o.put("ilinkUserId", st.getIlinkUserId());
|
||||
}
|
||||
redisUtil.set(SESSION_PREFIX + sessionId, o.toJSONString(), SESSION_TTL_SECONDS);
|
||||
if ("confirmed".equals(stStr)) {
|
||||
return QrPollResult.builder()
|
||||
.status("confirmed")
|
||||
.configData(configDataJsonFromConfirmedSession(o))
|
||||
.build();
|
||||
}
|
||||
return QrPollResult.builder()
|
||||
.status(stStr)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.warn("get_qrcode_status: {}", e.getMessage());
|
||||
throw BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.imWechatQrQueryStatusFailed, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String configDataJsonFromConfirmedSession(JSONObject o) {
|
||||
JSONObject cfgJson = buildWechatIlinkConfigDataFromSession(o);
|
||||
return cfgJson != null ? cfgJson.toJSONString() : null;
|
||||
}
|
||||
|
||||
private JSONObject buildWechatIlinkConfigDataFromSession(JSONObject o) {
|
||||
if (o == null || !"confirmed".equals(o.getString("lastStatus"))) {
|
||||
return null;
|
||||
}
|
||||
String botToken = o.getString("botToken");
|
||||
String ilinkBotId = o.getString("ilinkBotId");
|
||||
if (StringUtils.isBlank(botToken) || StringUtils.isBlank(ilinkBotId)) {
|
||||
return null;
|
||||
}
|
||||
String normalized = IlinkAccountIdNormalize.normalizeForStorage(ilinkBotId);
|
||||
String loginBase = o.getString("loginBaseUrl");
|
||||
if (StringUtils.isBlank(loginBase)) {
|
||||
loginBase = o.getString("baseUrl");
|
||||
}
|
||||
JSONObject cfgJson = new JSONObject();
|
||||
cfgJson.put("baseUrl", loginBase);
|
||||
cfgJson.put("botToken", botToken);
|
||||
cfgJson.put("botType", o.getString("botType"));
|
||||
cfgJson.put("cdnBaseUrl", IlinkConstants.CDN_BASE_URL);
|
||||
cfgJson.put("ilinkAccountId", normalized);
|
||||
cfgJson.put("ilinkUserId", o.getString("ilinkUserId"));
|
||||
return cfgJson;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class QrStartResult {
|
||||
private String sessionId;
|
||||
private String qrcode;
|
||||
private String qrcodeImgContent;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class QrPollResult {
|
||||
/**
|
||||
* 上游:wait / scaned / confirmed / expired。
|
||||
*/
|
||||
private String status;
|
||||
/**
|
||||
* 与 {@link ImChannelConfig#getConfigData()} 同结构的 JSON 字符串;
|
||||
* {@code confirmed} 时来自会话预览。
|
||||
*/
|
||||
private String configData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.xspaceagi.im.application.wechat;
|
||||
|
||||
import io.github.kasukusakura.silkcodec.SilkCoder;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 将微信入站 SILK 语音尽量转为 WAV,便于后续平台统一消费。
|
||||
*/
|
||||
public final class WechatIlinkSilkTranscoder {
|
||||
|
||||
private static final int DEFAULT_SAMPLE_RATE = 24_000;
|
||||
|
||||
private WechatIlinkSilkTranscoder() {
|
||||
}
|
||||
|
||||
public static byte[] tryConvertSilkToWav(byte[] silkBytes) {
|
||||
if (silkBytes == null || silkBytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
try (ByteArrayInputStream silkIn = new ByteArrayInputStream(silkBytes);
|
||||
ByteArrayOutputStream pcmOut = new ByteArrayOutputStream()) {
|
||||
SilkCoder.decode(silkIn, pcmOut);
|
||||
byte[] pcmBytes = pcmOut.toByteArray();
|
||||
if (pcmBytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return pcmToWav(pcmBytes, DEFAULT_SAMPLE_RATE);
|
||||
} catch (Throwable ignore) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pcmToWav(byte[] pcmBytes, int sampleRate) throws IOException {
|
||||
int totalSize = 44 + pcmBytes.length;
|
||||
try (ByteArrayOutputStream wavOut = new ByteArrayOutputStream(totalSize)) {
|
||||
writeAscii(wavOut, "RIFF");
|
||||
writeIntLE(wavOut, totalSize - 8);
|
||||
writeAscii(wavOut, "WAVE");
|
||||
writeAscii(wavOut, "fmt ");
|
||||
writeIntLE(wavOut, 16);
|
||||
writeShortLE(wavOut, 1);
|
||||
writeShortLE(wavOut, 1);
|
||||
writeIntLE(wavOut, sampleRate);
|
||||
writeIntLE(wavOut, sampleRate * 2);
|
||||
writeShortLE(wavOut, 2);
|
||||
writeShortLE(wavOut, 16);
|
||||
writeAscii(wavOut, "data");
|
||||
writeIntLE(wavOut, pcmBytes.length);
|
||||
wavOut.write(pcmBytes);
|
||||
return wavOut.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeAscii(ByteArrayOutputStream out, String value) throws IOException {
|
||||
out.write(value.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
private static void writeShortLE(ByteArrayOutputStream out, int value) {
|
||||
out.write(value & 0xff);
|
||||
out.write((value >>> 8) & 0xff);
|
||||
}
|
||||
|
||||
private static void writeIntLE(ByteArrayOutputStream out, int value) {
|
||||
out.write(value & 0xff);
|
||||
out.write((value >>> 8) & 0xff);
|
||||
out.write((value >>> 16) & 0xff);
|
||||
out.write((value >>> 24) & 0xff);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user