chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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() : "未知错误");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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";
};
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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";
/** RedisgetUpdates 游标、消息幂等、分布式锁、配置缓存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
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}