package vip.mate.channel; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import reactor.core.publisher.Flux; import vip.mate.agent.AgentService; import vip.mate.agent.context.ChatOrigin; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.ResolveOutcome; import vip.mate.approval.PendingApproval; import vip.mate.channel.event.ChannelMessageReceivedEvent; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.notification.ApprovalNotificationService; import vip.mate.channel.service.ChannelService; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.memory.event.ConversationCompletionPublisher; import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; import vip.mate.workspace.conversation.model.MessageEntity; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantLock; /** * 渠道消息路由器 *
* 采用每渠道独立队列架构:
* - 每渠道一个 BlockingQueue,N 个消费线程从队列取消息处理
* - 会话级锁保证同一 conversationId 串行处理
* - 500ms 防抖:同一会话的连续消息合并为一条
* - Web 渠道不走队列(有自己的 SSE 流程)
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class ChannelMessageRouter {
private final AgentService agentService;
private final ConversationService conversationService;
private final ChannelService channelService;
private final ChannelSessionStore channelSessionStore;
private final ApprovalWorkflowService approvalService;
private final ApprovalNotificationService approvalNotificationService;
private final ConversationCompletionPublisher completionPublisher;
private final TtsService ttsService;
private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker;
private final ChannelChatOriginFactory chatOriginFactory;
private final ChannelErrorClassifier errorClassifier;
/** Field-injected (rather than constructor) to avoid a signature
* change that would ripple through every test that constructs the
* router directly. Spring's stock publisher is always available. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
/** 每个渠道类型的消息队列 */
private final ConcurrentHashMap
* The agent emits these via {@code GraphEventPublisher} and they ride on
* the {@code chatStructuredStream} Flux as {@code StreamDelta.event(...)}.
* Web direct chats already broadcast them via the ChatController
* accumulator. IM channels (DingTalk + the seven sync-path adapters)
* historically dropped them — DingTalk's {@code processStreamAsText}
* only consumes {@code delta.content()}, and the sync {@code chat()}
* collector explicitly filters {@code delta.isEvent()} out. The whitelist
* is applied in the IM stream path so PlanStepsPanel renders correctly
* when an operator monitors an IM conversation in the Web Console.
*
* Whitelist (not pass-through) so Web-side accumulator-internal events
* like {@code _usage_final} or future agent-internal markers don't leak
* to subscribers.
*/
private static final Set
* Webhook 调用此方法后立即返回,不阻塞。
*
* @param message 入站消息
* @param adapter 来源渠道适配器(用于回复)
* @param channelEntity 渠道配置(含关联 agentId)
*/
public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
// Fan out to the trigger pipeline FIRST — channel_message and
// content_match triggers fire on every received message regardless
// of whether the channel has an agent attached. If we returned
// early on a missing agent below without publishing, the workflow
// side would silently lose every channel-event that doesn't also
// route to a chat agent.
publishChannelEvent(message, adapter, channelEntity);
Long agentId = channelEntity.getAgentId();
if (agentId == null) {
log.warn("Channel {} has no associated agent, ignoring message from {}",
channelEntity.getName(), message.getSenderId());
return;
}
if (shutdown) {
log.warn("Router is shutting down, rejecting message from {}", message.getSenderId());
return;
}
String channelType = adapter.getChannelType();
String conversationId = buildConversationId(message);
log.info("[{}] Enqueuing message: sender={}, conversationId={}, agentId={}",
channelType, message.getSenderId(), conversationId, agentId);
// 防抖:同一会话 500ms 内的连续消息合并
synchronized (pendingMessages) {
PendingMessage existing = pendingMessages.get(conversationId);
if (existing != null) {
// 合并到已有的 pending 消息
if (existing.timer != null) {
existing.timer.cancel(false);
}
existing.appendContent(message.getContent());
existing.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS);
log.debug("[{}] Message merged with pending (debounce): conversationId={}",
channelType, conversationId);
return;
}
// 首条消息,创建 PendingMessage 并设定防抖定时器
PendingMessage pending = new PendingMessage(message, adapter, channelEntity);
pendingMessages.put(conversationId, pending);
pending.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), DEBOUNCE_MS, TimeUnit.MILLISECONDS);
}
}
/**
* Publish a {@link ChannelMessageReceivedEvent} so the trigger module's
* bridge can fan the message out to channel_message + content_match
* triggers. Best-effort — a publish failure must never block the
* primary chat-routing path. {@code messageId} is used as the dedup
* key downstream so repeated webhook deliveries can't double-fire
* the same trigger.
*/
private void publishChannelEvent(ChannelMessage message, ChannelAdapter adapter,
ChannelEntity channelEntity) {
if (events == null || message == null || adapter == null || channelEntity == null) return;
try {
long ws = channelEntity.getWorkspaceId() == null ? 0L : channelEntity.getWorkspaceId();
String channelType = adapter.getChannelType();
// messageId may be null for adapters that don't surface one;
// fall back to a sender+timestamp composite so the dedup key
// is at least deterministic-ish per webhook delivery.
String messageId = message.getMessageId();
if (messageId == null || messageId.isBlank()) {
messageId = channelType + ":" + message.getSenderId() + ":"
+ (message.getTimestamp() == null ? System.currentTimeMillis()
: message.getTimestamp());
}
events.publishEvent(new ChannelMessageReceivedEvent(
ws,
channelType,
messageId,
message.getSenderId(),
message.getSenderName(),
message.getChatId(),
message.getContent()));
} catch (Exception e) {
log.warn("[ChannelMessageRouter] event publish failed for sender {}: {}",
message.getSenderId(), e.getMessage());
}
}
/**
* 防抖到期:将合并后的消息真正放入渠道队列
*/
private void flushPending(String conversationId) {
PendingMessage pending;
synchronized (pendingMessages) {
pending = pendingMessages.remove(conversationId);
}
if (pending == null) return;
// 更新消息内容为合并后的文本
pending.firstMessage.setContent(pending.getMergedContent());
String channelType = pending.adapter.getChannelType();
LinkedBlockingQueue
* 当钉钉渠道启用 AI Card 时,走流式卡片路径。
*/
private void processMessage(ChannelMessage message, ChannelAdapter adapter,
ChannelEntity channelEntity, String conversationId) {
Long agentId = channelEntity.getAgentId();
log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}",
adapter.getChannelType(), message.getSenderId(), conversationId, agentId);
try {
// ======= 审批拦截层 =======
String userText = message.getContent() != null ? message.getContent().trim() : "";
PendingApproval pending = approvalService.findPendingByConversation(conversationId);
if (pending != null) {
String replyTarget = resolveReplyTarget(message);
if (isApproveCommand(userText)) {
// pendingId 校验:如果命令包含 shortId,验证是否匹配当前 pending
String shortId = extractShortId(userText);
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
return;
}
// 身份校验:只有原始请求者可以审批(群聊安全)
String originalRequester = pending.getUserId();
if (originalRequester != null && !"system".equals(originalRequester)
&& !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval rejected: sender={} != requester={}",
adapter.getChannelType(), message.getSenderId(), originalRequester);
return;
}
// Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically.
ResolveOutcome consumeOutcome = approvalService.resolveAndConsume(
pending.getPendingId(), message.getSenderId());
if (consumeOutcome.isAlreadyResolved()) {
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
return;
}
PendingApproval consumed = consumeOutcome.consumedSnapshot();
log.info("[{}] Approval APPROVED via IM command: pendingId={}, tool={}, msgRewritten={}",
adapter.getChannelType(), consumed.getPendingId(), consumed.getToolName(),
consumeOutcome.messagesRewritten());
replayApprovedToolCall(consumed, conversationId, adapter, message, channelEntity);
return;
} else if (isDenyCommand(userText)) {
// Deny via IM: workflow.resolve owns the full state-machine transition.
ResolveOutcome denyOutcome = approvalService.resolve(
pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId);
String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName();
persistAndBroadcastApprovalHint(conversationId, denyHint,
"denied", pending.getPendingId(), pending.getToolName());
adapter.sendMessage(replyTarget, denyHint);
log.info("[{}] Approval DENIED via IM command: pendingId={}, tool={}, msgRewritten={}",
adapter.getChannelType(), pending.getPendingId(), pending.getToolName(),
denyOutcome.messagesRewritten());
return;
} else {
// Non-approval message while a pending exists → treat as implicit deny.
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId);
String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。";
persistAndBroadcastApprovalHint(conversationId, cancelHint,
"cancelled", pending.getPendingId(), pending.getToolName());
adapter.sendMessage(replyTarget, cancelHint);
log.info("[{}] Approval auto-cancelled (non-approval message): pendingId={}",
adapter.getChannelType(), pending.getPendingId());
// Fall through to process the new message normally.
}
}
// ======= 审批拦截层结束 =======
// 确保会话存在(workspace 感知)
conversationService.getOrCreateSharedConversation(conversationId, agentId, channelEntity.getWorkspaceId());
// 更新渠道会话存储(用于主动推送)
String replyTarget = resolveReplyTarget(message);
if (replyTarget != null) {
channelSessionStore.saveOrUpdate(
conversationId,
adapter.getChannelType(),
replyTarget,
message.getSenderId(),
message.getSenderName(),
channelEntity.getId()
);
} else {
log.warn("[{}] No reply target resolved for sender={}, skipping session store update",
adapter.getChannelType(), message.getSenderId());
}
// 保存用户消息(带 contentParts)
List
* 事件流与渲染分离:
* - Router 负责产生 StreamDelta 流(调用 AgentService)
* - StreamingChannelAdapter 负责渲染(AI Card / 卡片更新 / 文本累积等)
* - Router 负责后续的审批检查、消息持久化、事件发布
*/
/**
* Forward whitelisted Plan-Execute SSE events to ChatStreamTracker so a
* Web Console viewer of an IM-routed conversation sees PlanStepsPanel.
*
* Bounded to {@link #MIRRORED_PLAN_EVENTS} — see the constant's javadoc
* for why this is a whitelist rather than a pass-through. Failures here
* are best-effort and never propagate, since dropping a UI update is
* preferable to derailing the channel reply.
*/
private void mirrorPlanEventToTracker(String conversationId,
AgentService.StreamDelta delta,
String channelTypeForLog) {
String eventType = delta.eventType();
if (eventType == null || !MIRRORED_PLAN_EVENTS.contains(eventType)) {
return;
}
try {
streamTracker.broadcastObject(conversationId, eventType, delta.eventData());
} catch (Exception ex) {
log.debug("[{}] Failed to mirror plan event {}: {}",
channelTypeForLog, eventType, ex.getMessage());
}
}
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
String conversationId, Long agentId, String promptText,
ChannelEntity channelEntity, ChatOrigin chatOrigin) {
String channelType = streamingAdapter.getChannelType();
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
try {
// Step 1: 产生事件流(RFC-063r §2.5: forward ChatOrigin so tools see channelId)
Flux
* 接收已消费的审批记录(由 resolveAndConsume 原子获取),通过 AgentService.chatWithReplay 重新执行工具。
* 重放前清理 DB 中的审批占位消息,防止 LLM 看到残留文本后重新发起工具调用(死循环根因)。
*/
private void replayApprovedToolCall(PendingApproval consumed, String conversationId,
ChannelAdapter adapter, ChannelMessage triggerMessage,
ChannelEntity channelEntity) {
String replyTarget = resolveReplyTarget(triggerMessage);
Long agentId = channelEntity.getAgentId();
// Notify the user that the approval went through. Persist + broadcast so a
// Web mirror of the same conversationId sees the resolution; otherwise this
// hint would only land in the IM channel and the Web admin console would
// show the replay reply with no preceding "approved" marker.
String approveHint = "✅ 已批准执行工具: " + consumed.getToolName();
persistAndBroadcastApprovalHint(conversationId, approveHint,
"approved", consumed.getPendingId(), consumed.getToolName());
adapter.sendMessage(replyTarget, approveHint);
// 清理 DB 中残留的审批占位消息
conversationService.removeApprovalPlaceholders(conversationId);
// 简化 replay prompt(不重复工具名,防止 LLM 误解)
String replayPrompt = "继续执行已批准的工具调用。";
try {
// RFC-063r §2.12: prefer the persisted Memento (covers
// cross-restart approval where the channel session changed) and
// only fall back to rebuilding from the current inbound message
// when no snapshot was captured (legacy rows from before this PR).
ChatOrigin replayOrigin = approvalService.restoreChatOrigin(consumed.getChatOrigin());
if (replayOrigin == ChatOrigin.EMPTY) {
replayOrigin = chatOriginFactory.from(
channelEntity, triggerMessage, conversationId, /* workspaceBasePath */ null);
}
String reply = agentService.chatWithReplay(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
// Persist the replay result. If the LLM 400'd during replay,
// the error reply must also get status='error' — otherwise the
// next turn's history would re-feed the error placeholder back
// into the prompt and re-trigger the same failure.
boolean isError = errorClassifier.isErrorReply(reply);
conversationService.saveMessage(conversationId, "assistant", reply, null,
isError ? "error" : "completed");
// 发送回复
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Replay completed: tool={}, replyLen={}",
adapter.getChannelType(), consumed.getToolName(), reply.length());
} catch (Exception e) {
log.error("[approval-replay] Replay failed: {}", e.getMessage(), e);
String errHint = "❌ 工具执行失败: " + e.getMessage();
persistAndBroadcastApprovalHint(conversationId, errHint, null, null, null);
adapter.sendMessage(replyTarget, errHint);
}
}
/**
* Persist an approval-related hint as an assistant message and best-effort
* broadcast it to any live SSE viewer of the conversation.
*
* Without this, IM-driven approve/deny only reaches the originating IM
* channel via {@code adapter.sendMessage(...)} — a Web mirror of the same
* conversationId has no record of the resolution because nothing lands in
* {@code mate_message} and no SSE event is emitted. The hint then "vanishes"
* from the Web admin console even though it shows up on the user's phone.
*
* Persistence is the load-bearing fix (Web reload picks it up). Broadcast
* is best-effort: if no SSE stream is currently registered for the
* conversation, the broadcast no-ops silently — that's the common case
* since IM-driven clicks rarely race with an active web subscriber.
*
* @param conversationId conversation owning the hint
* @param hint text to render as an assistant bubble
* @param decision "approved" / "denied" / "cancelled" / null (skips the
* structured resolved event when null, e.g. on replay error)
* @param pendingId pending approval id; null when not applicable
* @param toolName tool name for the structured event; null when not applicable
*/
private void persistAndBroadcastApprovalHint(String conversationId, String hint,
String decision, String pendingId,
String toolName) {
try {
conversationService.saveMessage(conversationId, "assistant", hint, null, "completed");
} catch (Exception e) {
log.warn("[approval-hint] saveMessage failed for conv={}: {}",
conversationId, e.getMessage());
}
try {
if (decision != null) {
streamTracker.broadcastObject(conversationId, "tool_approval_resolved", Map.of(
"pendingId", pendingId == null ? "" : pendingId,
"decision", decision,
"toolName", toolName == null ? "" : toolName,
"timestamp", System.currentTimeMillis()
));
}
streamTracker.broadcastObject(conversationId, "message_start",
Map.of("role", "assistant"));
streamTracker.broadcastObject(conversationId, "content_delta",
Map.of("delta", hint));
streamTracker.broadcastObject(conversationId, "message_complete",
Map.of("status", "completed"));
} catch (Exception e) {
// Broadcast is best-effort; a missing run state is the common case.
log.debug("[approval-hint] broadcast skipped/failed for conv={}: {}",
conversationId, e.getMessage());
}
}
/**
* 从 PendingApproval 元数据构建 IM 友好的审批通知(委托给 ApprovalNotificationService)
*/
private String buildApprovalNotice(PendingApproval pending) {
return approvalNotificationService.buildApprovalText(pending);
}
/**
* Publish the conversation-completed event (triggers async memory extraction).
* Delegates to {@link ConversationCompletionPublisher} so the try/catch and
* messageCount lookup no longer live here.
*/
private void publishConversationCompletedEvent(Long agentId, String conversationId,
String userMessage, String assistantReply) {
completionPublisher.publish(agentId, conversationId, userMessage, assistantReply, "channel");
}
// ==================== 流式处理(Web 渠道专用,不走队列) ====================
/**
* 路由消息并使用流式处理(用于支持流式的渠道,如 Web)
*/
public Flux
* 设计原则(借鉴 OpenClaw):
* - 文本先行,语音异步追加,不阻塞用户体验
* - 短回复(<10字)跳过 TTS(不值得合成)
* - TTS 失败静默降级,不影响已发出的文本回复
*/
private void maybeGenerateVoiceReply(ChannelMessage message, ChannelAdapter adapter,
String replyTarget, String conversationId,
String replyText, ChannelEntity channelEntity) {
if (!shouldGenerateVoiceReply(message, channelEntity, replyText)) {
return;
}
voiceReplyExecutor.submit(() -> {
try {
// 读取渠道级语音配置
Map