mateclaw/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java

1593 lines
82 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.agent.model.AgentEntity;
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.exception.MateClawException;
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.core.service.ChatUploadLocationResolver;
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;
/**
* 渠道消息路由器
* <p>
* 采用每渠道独立队列架构:
* - 每渠道一个 BlockingQueueN 个消费线程从队列取消息处理
* - 会话级锁保证同一 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;
/** Field-injected for the same reason as {@link #events}: the chat-upload
* resolver resolves the workspace-aware TTS output directory on the
* voice-reply path. Optional so tests that build the router directly
* still work; falls back to the legacy default dir when unset. */
@Autowired(required = false)
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
/** 每个渠道类型的消息队列 */
private final ConcurrentHashMap<String, LinkedBlockingQueue<QueueEntry>> channelQueues = new ConcurrentHashMap<>();
/** 每个渠道类型的消费线程池 */
private final ConcurrentHashMap<String, ExecutorService> channelExecutors = new ConcurrentHashMap<>();
/** 会话级别的锁:保证同一 conversationId 串行处理 */
private final ConcurrentHashMap<String, ReentrantLock> sessionLocks = new ConcurrentHashMap<>();
/** 防抖调度器 */
private final ScheduledExecutorService debounceScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "channel-debounce-scheduler");
t.setDaemon(true);
return t;
});
/** 防抖缓冲区conversationId -> 待合并消息 */
private final ConcurrentHashMap<String, PendingMessage> pendingMessages = new ConcurrentHashMap<>();
/** 每个渠道的消费线程数 */
private static final int CONSUMERS_PER_CHANNEL = 4;
/** 每个渠道的队列容量 */
private static final int QUEUE_CAPACITY = 1000;
/** 防抖等待时间毫秒。Package-private for unit-test access. */
static final long DEBOUNCE_MS = 500;
/**
* Extended debounce window for suspected paste-split scenarios. WeCom
* (and other IM clients) silently split a single pasted long prompt
* into 2-4 separate messages when it exceeds the per-frame limit
* (~2000 chars). The fragments arrive 0.5-2s apart, which means the
* default {@link #DEBOUNCE_MS} flushes the first fragment before the
* second one arrives — the agent then sees a torn context, calls the
* LLM on a partial prompt, and gets re-triggered when the next
* fragment lands. When merged content exceeds
* {@link #LONG_TEXT_THRESHOLD} we extend the window so the merger has
* time to absorb the rest.
* <p>
* Package-private for unit-test access.
*/
static final long LONG_DEBOUNCE_MS = 2500;
/**
* Content length (chars) above which we treat the message as a likely
* paste-split fragment. 1500 sits below the typical ~2000-char IM
* client split point while staying well above any normally-typed
* message, so the long-debounce path doesn't penalize ordinary
* chatting. A short typed "hello" still flushes in 500ms.
* <p>
* Package-private for unit-test access.
*/
static final int LONG_TEXT_THRESHOLD = 1500;
/**
* Pick the debounce window: extend to {@link #LONG_DEBOUNCE_MS} when
* either the new arrival or the accumulated merged buffer looks like
* a paste-split fragment, otherwise stay at {@link #DEBOUNCE_MS}.
* <p>
* Package-private + static so tests can pin the threshold without
* spinning up the whole router (which has 12+ injected dependencies).
*/
static long pickDebounceMs(int currentMergedLength) {
return currentMergedLength > LONG_TEXT_THRESHOLD ? LONG_DEBOUNCE_MS : DEBOUNCE_MS;
}
/**
* Plan-Execute SSE events that the Web Console mirror needs to see when
* a conversation runs through an IM channel.
* <p>
* 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.
* <p>
* 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<String> MIRRORED_PLAN_EVENTS = Set.of(
"plan_created",
"plan_step_started",
"plan_step_completed"
);
/** 是否已关闭 */
private volatile boolean shutdown = false;
public ChannelMessageRouter(AgentService agentService,
ConversationService conversationService,
ChannelService channelService,
ChannelSessionStore channelSessionStore,
ApprovalWorkflowService approvalService,
ApprovalNotificationService approvalNotificationService,
ConversationCompletionPublisher completionPublisher,
TtsService ttsService,
ObjectMapper objectMapper,
ChatStreamTracker streamTracker,
ChannelChatOriginFactory chatOriginFactory,
ChannelErrorClassifier errorClassifier) {
this.agentService = agentService;
this.conversationService = conversationService;
this.channelService = channelService;
this.channelSessionStore = channelSessionStore;
this.approvalService = approvalService;
this.approvalNotificationService = approvalNotificationService;
this.completionPublisher = completionPublisher;
this.ttsService = ttsService;
this.objectMapper = objectMapper;
this.streamTracker = streamTracker;
this.chatOriginFactory = chatOriginFactory;
this.errorClassifier = errorClassifier;
}
// ==================== 防抖辅助类 ====================
/**
* 防抖待合并消息
*/
private static class PendingMessage {
final ChannelAdapter adapter;
final ChannelEntity channelEntity;
final ChannelMessage firstMessage;
final StringBuilder mergedContent;
volatile ScheduledFuture<?> timer;
PendingMessage(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
this.firstMessage = message;
this.adapter = adapter;
this.channelEntity = channelEntity;
this.mergedContent = new StringBuilder(message.getContent() != null ? message.getContent() : "");
}
synchronized void appendContent(String content) {
if (content != null && !content.isBlank()) {
if (!mergedContent.isEmpty()) {
mergedContent.append('\n');
}
mergedContent.append(content);
}
}
synchronized String getMergedContent() {
return mergedContent.toString();
}
}
// ==================== 入队(替代原 route 方法) ====================
/**
* 将渠道消息入队到对应渠道的处理队列(防抖后入队)。
* <p>
* Webhook 调用此方法后立即返回,不阻塞。
*
* @param message 入站消息
* @param adapter 来源渠道适配器(用于回复)
* @param channelEntity 渠道配置(含关联 agentId
*/
public void enqueue(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {
// The adapter caches the ChannelEntity it was constructed with, so a
// long-lived adapter (e.g. Feishu WS) keeps handing us a snapshot
// that may be stale by the time the message arrives. Refresh from
// the DB so a freshly-rebound agent (or any other routing-metadata
// change applied without a restart) is honoured immediately.
ChannelEntity fresh = freshChannelEntity(channelEntity);
if (fresh == null) {
// Channel deleted between adapter start and message arrival.
// Skip everything — even the trigger publish, since the channel
// no longer exists for downstream consumers to reference.
return;
}
// Only drop on an EXPLICIT enabled=false. A null enabled (which the
// production DB never returns but tests / hand-constructed entities
// do) means "not declared", and treating it as disabled would
// collapse every downstream behaviour into a silent drop — which is
// exactly how the previous !Boolean.TRUE.equals(...) form regressed
// mock-driven tests that don't bother seeding the flag.
if (Boolean.FALSE.equals(fresh.getEnabled())) {
log.warn("[{}] Channel {} (id={}) is disabled; dropping message from {}",
adapter.getChannelType(), fresh.getName(), fresh.getId(), message.getSenderId());
return;
}
channelEntity = fresh;
// 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);
// Debounce + adaptive merge: same conversation messages within the
// (500ms / 2.5s) window get concatenated into one. Adaptive: when
// the merged buffer crosses the LONG_TEXT_THRESHOLD we extend to
// LONG_DEBOUNCE_MS so paste-split fragments arrive together
// instead of triggering one agent call per piece.
synchronized (pendingMessages) {
PendingMessage existing = pendingMessages.get(conversationId);
if (existing != null) {
// Sender boundary in groups: when a different user sends to the
// same group within the debounce window, merging would attribute
// both fragments to whoever sent first — the LLM then loses the
// ability to tell who asked what. Flush the existing buffer
// immediately so each user's text rides its own pending window.
// Reentrant on `pendingMessages`, so the inner flushPending's
// synchronized block re-acquires safely on the same thread.
String existingSender = existing.firstMessage.getSenderId();
String incomingSender = message.getSenderId();
boolean sameSender = isSameSender(existingSender, incomingSender);
if (!sameSender) {
log.info("[{}] Sender boundary in conversation {}: flushing pending from sender={}, accepting new sender={}",
channelType, conversationId, existingSender, incomingSender);
if (existing.timer != null) {
existing.timer.cancel(false);
}
flushPending(conversationId);
// Fall through to create a fresh pending for the new sender.
} else {
// Same sender — original paste-split / rapid-follow merge path.
if (existing.timer != null) {
existing.timer.cancel(false);
}
existing.appendContent(message.getContent());
int mergedLen = existing.getMergedContent().length();
long debounceMs = pickDebounceMs(mergedLen);
existing.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), debounceMs, TimeUnit.MILLISECONDS);
if (debounceMs > DEBOUNCE_MS) {
log.info("[{}] Long-text merger active: conversationId={}, mergedLen={}, debounce={}ms (paste-split suspected)",
channelType, conversationId, mergedLen, debounceMs);
} else {
log.debug("[{}] Message merged with pending (debounce {}ms): conversationId={}",
channelType, debounceMs, conversationId);
}
return;
}
}
// 首条消息(或 sender boundary 之后的新 sender创建 PendingMessage 并设定防抖定时器
PendingMessage pending = new PendingMessage(message, adapter, channelEntity);
pendingMessages.put(conversationId, pending);
int firstLen = message.getContent() != null ? message.getContent().length() : 0;
long debounceMs = pickDebounceMs(firstLen);
pending.timer = debounceScheduler.schedule(
() -> flushPending(conversationId), debounceMs, TimeUnit.MILLISECONDS);
if (debounceMs > DEBOUNCE_MS) {
log.info("[{}] Long-text merger armed on first message: conversationId={}, len={}, debounce={}ms",
channelType, conversationId, firstLen, debounceMs);
}
}
}
/**
* 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<QueueEntry> queue = channelQueues.computeIfAbsent(channelType, this::createChannelQueue);
boolean offered = queue.offer(new QueueEntry(pending.firstMessage, pending.adapter, pending.channelEntity));
if (!offered) {
log.error("[{}] Message queue full (capacity={}), dropping message from {}",
channelType, QUEUE_CAPACITY, pending.firstMessage.getSenderId());
try {
String replyTarget = resolveReplyTarget(pending.firstMessage);
pending.adapter.sendMessage(replyTarget, "系统繁忙,请稍后再试");
} catch (Exception e) {
log.error("[{}] Failed to send busy message: {}", channelType, e.getMessage());
}
} else {
log.debug("[{}] Message flushed to queue: conversationId={}, queueSize={}",
channelType, conversationId, queue.size());
}
}
// ==================== 消费线程 ====================
/**
* 为渠道类型创建队列并启动消费线程
*/
private LinkedBlockingQueue<QueueEntry> createChannelQueue(String channelType) {
LinkedBlockingQueue<QueueEntry> queue = new LinkedBlockingQueue<>(QUEUE_CAPACITY);
ExecutorService executor = Executors.newFixedThreadPool(CONSUMERS_PER_CHANNEL, new ThreadFactory() {
private int counter = 0;
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "channel-consumer-" + channelType + "-" + (counter++));
t.setDaemon(true);
return t;
}
});
for (int i = 0; i < CONSUMERS_PER_CHANNEL; i++) {
executor.execute(() -> consumeLoop(channelType, queue));
}
channelExecutors.put(channelType, executor);
log.info("[{}] Created message queue (capacity={}) with {} consumer threads",
channelType, QUEUE_CAPACITY, CONSUMERS_PER_CHANNEL);
return queue;
}
/**
* 消费线程循环:从队列取消息,加会话锁后串行处理
*/
private void consumeLoop(String channelType, LinkedBlockingQueue<QueueEntry> queue) {
log.info("[{}] Consumer thread started: {}", channelType, Thread.currentThread().getName());
while (!shutdown) {
try {
QueueEntry entry = queue.poll(1, TimeUnit.SECONDS);
if (entry == null) {
continue; // 超时,重新检查 shutdown 标志
}
String conversationId = buildConversationId(entry.message());
ReentrantLock lock = sessionLocks.computeIfAbsent(conversationId, k -> new ReentrantLock());
lock.lock();
try {
processMessage(entry.message(), entry.adapter(), entry.channelEntity(), conversationId);
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("[{}] Unexpected error in consumer loop: {}", channelType, e.getMessage(), e);
}
}
log.info("[{}] Consumer thread stopped: {}", channelType, Thread.currentThread().getName());
}
// ==================== 审批命令识别 ====================
private static final java.util.Set<String> APPROVE_COMMANDS = java.util.Set.of(
"approve", "/approve", "批准", "/批准");
private static final java.util.Set<String> DENY_COMMANDS = java.util.Set.of(
"deny", "/deny", "拒绝", "/拒绝");
/** 带 pendingId 的审批命令格式:/approve a1b2c3 */
private static final java.util.regex.Pattern APPROVE_WITH_ID =
java.util.regex.Pattern.compile("^/?(approve|批准)\\s+([a-f0-9]{6,16})$",
java.util.regex.Pattern.CASE_INSENSITIVE);
private static final java.util.regex.Pattern DENY_WITH_ID =
java.util.regex.Pattern.compile("^/?(deny|拒绝)\\s+([a-f0-9]{6,16})$",
java.util.regex.Pattern.CASE_INSENSITIVE);
private boolean isApproveCommand(String text) {
String t = text.toLowerCase().strip();
return APPROVE_COMMANDS.contains(t) || APPROVE_WITH_ID.matcher(t).matches();
}
private boolean isDenyCommand(String text) {
String t = text.toLowerCase().strip();
return DENY_COMMANDS.contains(t) || DENY_WITH_ID.matcher(t).matches();
}
/**
* 从审批命令中提取 shortId如 "/approve a1b2c3" → "a1b2c3"),无 id 则返回 null
*/
private String extractShortId(String text) {
java.util.regex.Matcher m = APPROVE_WITH_ID.matcher(text.strip());
if (m.matches()) return m.group(2);
m = DENY_WITH_ID.matcher(text.strip());
if (m.matches()) return m.group(2);
return null;
}
/**
* Identity gate shared by /approve and /deny (group-chat safety): only the
* original human requester may resolve a pending. Agent/cron ("system") and
* unattributed (null) approvals are fail-closed in IM — any group member
* could otherwise approve OR deny/cancel a guarded action — and must be
* handled from the admin console. Sends the rejection notice + logs and
* returns {@code false} when the caller is not authorized.
*/
private boolean approvalResolveAuthorized(PendingApproval pending, ChannelMessage message,
ChannelAdapter adapter, String replyTarget) {
String originalRequester = pending.getUserId();
boolean systemOriginated = originalRequester == null || "system".equals(originalRequester);
if (systemOriginated || !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, systemOriginated
? "⚠️ 该审批由系统/定时任务发起,请在管理端处理。"
: "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval resolve rejected: sender={} != requester={} (systemOriginated={})",
adapter.getChannelType(), message.getSenderId(), originalRequester, systemOriginated);
return false;
}
return true;
}
/**
* When an /approve or /deny command carries an explicit short pendingId
* (e.g. "/deny a1b2c3"), verify it matches the conversation's current
* pending before resolving — otherwise a stale or copy-pasted id would
* silently act on the wrong pending. Sends the mismatch notice and returns
* {@code true} (caller must abort) when the ids don't line up.
*/
private boolean pendingIdMismatch(String userText, PendingApproval pending,
ChannelAdapter adapter, String replyTarget) {
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 true;
}
return false;
}
// ==================== 消息处理(原 route 逻辑 + 审批拦截层) ====================
/**
* 处理单条消息:保存 -> 调用 Agent -> 保存回复 -> 发送回复
* <p>
* 当钉钉渠道启用 AI Card 时,走流式卡片路径。
*/
private void processMessage(ChannelMessage message, ChannelAdapter adapter,
ChannelEntity channelEntity, String conversationId) {
// The snapshot captured at enqueue time can be stale: an admin may
// have rebound, deleted, or disabled the channel between debounce-
// queue and flush. Re-read here so the rest of this method sees the
// current state, and fail closed on deletion / disable so we don't
// process traffic for a channel the admin has shut down.
ChannelEntity fresh = freshChannelEntity(channelEntity);
if (fresh == null) {
log.warn("[{}] Channel id={} not found at processing time; dropping message from {}",
adapter.getChannelType(),
channelEntity != null ? channelEntity.getId() : null,
message.getSenderId());
return;
}
if (Boolean.FALSE.equals(fresh.getEnabled())) {
log.warn("[{}] Channel {} (id={}) is disabled at processing time; dropping message from {}",
adapter.getChannelType(), fresh.getName(), fresh.getId(), message.getSenderId());
return;
}
channelEntity = fresh;
Long agentId = channelEntity.getAgentId();
if (agentId == null) {
log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}",
adapter.getChannelType(), channelEntity.getName(), message.getSenderId());
return;
}
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 校验approve / deny 共用——命令带 shortId 时必须匹配当前 pending。
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
return;
}
// 身份校验approve / deny 共用同一道门禁(群聊安全 + system/null fail-closed
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
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)) {
// pendingId 校验:与 approve 一致——命令带 shortId 时必须匹配当前 pending
// 否则 /deny <其它ID> 会错误地拒绝当前 conversation 的 pending。
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
return;
}
// 身份校验deny 与 approve 共用门禁。否则群里任意成员可拒绝/取消他人的
// pendingsystem/null 发起的审批也会被任意人 deny取消审批、清 placeholder、
// 写入 denied 状态);这类审批改到管理端处理。
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
return;
}
// Deny via IM: workflow.resolve owns the full state-machine transition.
ResolveOutcome denyOutcome = approvalService.resolve(
pending.getPendingId(), message.getSenderId(), "denied");
if (denyOutcome.isAlreadyResolved()) {
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
return;
}
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 if (adapter.usesInteractiveApprovalCards()) {
// Channel approves via button-clicks on an interactive
// card, NOT via /approve text. A casual follow-up
// message from the user during the wait window MUST
// NOT auto-cancel the pending — the button click is
// the canonical decision path. Treat the new message
// as a fresh turn; the pending stays alive until the
// user clicks Approve / Deny, the GC TTL expires, or
// the workflow explicitly resolves it.
log.info("[{}] Non-approval message while pending exists; channel uses card buttons so NOT auto-cancelling pendingId={}",
adapter.getChannelType(), pending.getPendingId());
// Fall through to process the new message normally.
} else {
// Non-approval message while a pending exists → treat as implicit deny.
// Text-command channels rely on this: the user is told
// "type /approve <id>" and anything else is an implicit
// change of mind.
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.
}
}
// ======= 审批拦截层结束 =======
// Ensure the conversation exists, seeded with the agent's
// currently-configured default model so per-conversation model
// selection works for IM channels too (issue #183).
//
// Two-part behaviour, both inside getOrCreateSharedConversation:
// 1. Brand-new conversation → write defaultModelName so the
// very first turn picks the right model; user can later
// switch via the admin UI (updateConversationModel) and the
// override sticks.
// 2. Pre-existing conversation with model still null (legacy
// rows created before #183 fix) → backfill once, then leave
// alone. Already-pinned conversations are never overwritten.
//
// We pass provider=null because AgentEntity doesn't carry a
// provider field — the downstream ProviderChatModelFactory
// resolves provider from the model name. The seed logic in
// ConversationService treats (null, name) as no-seed (both
// fields must be non-blank to take effect), which is the
// correct defensive behaviour: we only pin when we have a
// complete (provider, model) pair from the admin UI.
String agentDefaultModel = null;
try {
AgentEntity agentEntity = agentService.getAgent(agentId);
agentDefaultModel = agentEntity.getModelName();
} catch (Exception e) {
// Agent deleted / disabled mid-flight — don't block message
// intake. Downstream agentService.chatStructuredStream will
// surface the real error to the user.
log.debug("[{}] Could not load agent {} for model-seed lookup: {}",
adapter.getChannelType(), agentId, e.getMessage());
}
conversationService.getOrCreateSharedConversation(
conversationId, agentId, channelEntity.getWorkspaceId(),
null, agentDefaultModel);
// 更新渠道会话存储(用于主动推送)
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
// Group sender attribution: tag the persisted content + the
// prompt with [@sender] in groups so the LLM can disambiguate
// multiple users sharing one conversation. Single chats pass
// through unchanged (chatId is null).
List<MessageContentPart> parts = message.getContentParts();
String attributedContent = applyGroupTag(message, message.getContent());
conversationService.saveMessage(conversationId, "user", attributedContent, parts);
// 构建 prompt语音输入时注入场景提示词
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
// Re-apply the tag in case the prompt was assembled from
// non-text parts (image/file) where buildPromptFromParts
// ignored `content`. Idempotent: skips when already prefixed.
promptText = applyGroupTag(message, promptText);
// 注册到 ChatStreamTracker让 graph 节点广播的事件phase / content_delta / tool_call_* 等)
// 能被 ChatConsole observer 订阅到。不注册 → broadcast() 会因 state==null 短路丢弃。
// 同步 DB stream_status 让侧栏列表可以识别"该渠道对话正在运行",从而触发 selectConversation 的 reconnect 分支。
streamTracker.register(conversationId);
streamTracker.incrementFlux(conversationId);
conversationService.updateStreamStatus(conversationId, "running");
// 捕获已保存 assistant 的 DB id供 finally 的 done 事件带出来 —— 前端 useChat.done 处理器
// 依赖 data.assistantMessageId 把本地流式 placeholder 的 client-uuid 升级为 DB id这样
// 紧接着 refreshCurrentConversationMessages 的 reconcile 能按 id 干净匹配,不会走"认领"兜底
// 导致气泡丢失。
Long savedAssistantId = null;
try {
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
// RFC-063r §2.5: build the ChatOrigin once per channel-message
// so cron jobs created during this conversation inherit the
// channel binding (Issue #25 root path).
ChatOrigin chatOrigin = chatOriginFactory.from(
channelEntity, message, conversationId, /* workspaceBasePath */ null);
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
} else {
// Sync path for non-streaming IM adapters (feishu / wecom / weixin /
// slack / discord / qq / telegram). We can't use agentService.chat()
// because its collector filters out `delta.isEvent()` deltas — that
// would silently drop plan_created / plan_step_* events that the Web
// Console mirror needs to render PlanStepsPanel. Instead we consume
// chatStructuredStream directly: content gets accumulated for the IM
// reply, and whitelisted plan events are mirrored to ChatStreamTracker
// for any Web SSE viewer of the same conversationId.
StringBuilder replyAccumulator = new StringBuilder();
final String channelType = adapter.getChannelType();
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin)
.doOnNext(delta -> {
if (delta.isEvent()) {
if ("_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
}
mirrorPlanEventToTracker(conversationId, delta, channelType);
} else if (delta.content() != null) {
// Match the legacy agentService.chat() behavior: include
// persistOnly deltas too. DirectAnswerNode-routed answers
// arrive as persistOnly when CONTENT_STREAMED=true and IM
// channels still need the text for the outgoing reply.
replyAccumulator.append(delta.content());
}
})
.blockLast(Duration.ofMinutes(10));
String reply = replyAccumulator.toString();
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
// Channel-specific approval rendering: WeCom overrides
// sendApprovalNotice to post a button_interaction card;
// every other adapter falls back to the markdown-text path
// on AbstractChannelAdapter (preserves PR-0 behavior for
// non-WeCom channels).
var notice = approvalNotificationService.buildNotice(newPending);
adapter.sendApprovalNotice(replyTarget, notice);
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
adapter.getChannelType(), newPending.getToolName());
} else {
// Tag error replies (matched by ChannelErrorClassifier — the
// "[错误]" content prefix / Bad request: / LLM error templates)
// with status='error' so BaseAgent.sanitizeForLlm drops them
// from the next turn's LLM history, breaking the self-replicating
// 400 loop. Only successful replies fire the ConversationCompletedEvent —
// error turns must not pollute memory extraction.
boolean isError = errorClassifier.isErrorReply(reply);
String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", reply, null, status,
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
savedAssistantId = saved != null ? saved.getId() : null;
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
}
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Reply sent to {}: {}chars",
adapter.getChannelType(), replyTarget, reply.length());
// 给 observer 推送完整的 assistant 消息以便前端一次性渲染为气泡
// (在 content_delta 事件可能没开的同步路径下作为兜底)
Map<String, Object> msgCompletePayload = new HashMap<>();
msgCompletePayload.put("conversationId", conversationId);
msgCompletePayload.put("content", reply);
if (savedAssistantId != null) {
msgCompletePayload.put("assistantMessageId", savedAssistantId);
}
msgCompletePayload.put("timestamp", System.currentTimeMillis());
streamTracker.broadcastObject(conversationId, "message_complete", msgCompletePayload);
// 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞)
maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity);
// Per-channel completion ack (e.g. Feishu ✅ reaction).
// No-op for adapters that haven't overridden the hook.
try {
adapter.onAgentCompleted(message);
} catch (Exception hookErr) {
log.debug("[{}] onAgentCompleted hook failed (non-fatal): {}",
adapter.getChannelType(), hookErr.getMessage());
}
}
}
} finally {
// 广播 done 事件让 observer 前端收尾HashMap 允许 null 值缺失Map.of 不允许)
Map<String, Object> donePayload = new HashMap<>();
donePayload.put("conversationId", conversationId);
donePayload.put("status", "completed");
if (savedAssistantId != null) {
donePayload.put("assistantMessageId", savedAssistantId);
}
donePayload.put("timestamp", System.currentTimeMillis());
streamTracker.broadcastObject(conversationId, "done", donePayload);
streamTracker.completeAndConsumeIfLast(conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("Failed to reset stream_status for {}: {}", conversationId, e.getMessage());
}
}
} catch (Exception e) {
log.error("[{}] Failed to process message from {}: {}",
adapter.getChannelType(), message.getSenderId(), e.getMessage(), e);
// 尝试发送错误提示
try {
String errorTarget = resolveReplyTarget(message);
adapter.sendMessage(errorTarget, "抱歉,处理消息时出现错误:" + e.getMessage());
} catch (Exception sendErr) {
log.error("[{}] Failed to send error message: {}",
adapter.getChannelType(), sendErr.getMessage());
}
}
}
/**
* 流式处理路径(渠道无关)
* <p>
* 事件流与渲染分离:
* - 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.
* <p>
* 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<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
// Mirror plan-execute SSE events to ChatStreamTracker before the
// adapter consumes the Flux. DingTalkChannelAdapter.processStreamAsText
// only reads `delta.content()` and would otherwise eat plan_created /
// plan_step_* events, leaving the Web Console mirror with no
// PlanStepsPanel for IM-routed conversations.
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
}
mirrorPlanEventToTracker(conversationId, delta, channelType);
});
// Step 2: 委托渠道渲染(渠道内部消费 Flux 并处理 UI 更新)
String finalContent = streamingAdapter.processStream(mirroredStream, message, conversationId);
// Step 3: 审批检查 + 持久化(渠道无关逻辑,由 Router 统一处理)
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
String replyTarget = resolveReplyTarget(message);
// Same polymorphic dispatch as the non-streaming path — WeCom
// renders a card, others render text. See the buildNotice +
// sendApprovalNotice pair at the non-streaming call site above.
var notice = approvalNotificationService.buildNotice(newPending);
streamingAdapter.sendApprovalNotice(replyTarget, notice);
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
channelType, newPending.getToolName());
} else if (finalContent != null && !finalContent.isBlank()) {
boolean isError = errorClassifier.isErrorReply(finalContent);
String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", finalContent, null, status,
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
}
log.info("[{}] Streaming completed: contentLen={}, isError={}",
channelType, finalContent.length(), isError);
// 流式回复完成后也触发语音回复
String replyTarget = resolveReplyTarget(message);
if (replyTarget != null) {
maybeGenerateVoiceReply(message, streamingAdapter, replyTarget,
conversationId, finalContent, channelEntity);
}
try {
streamingAdapter.onAgentCompleted(message);
} catch (Exception hookErr) {
log.debug("[{}] onAgentCompleted hook failed (non-fatal): {}",
channelType, hookErr.getMessage());
}
return saved != null ? saved.getId() : null;
}
} catch (Exception e) {
log.error("[{}] Streaming processing failed: {}", channelType, e.getMessage(), e);
// Persist an error placeholder (status='error') so that the next
// turn's history does not show a user → user sequence (which some
// providers reject with 400). sanitizeForLlm filters this row out
// before the LLM sees it, so it costs nothing at the prompt layer.
try {
conversationService.saveMessage(conversationId, "assistant",
"[错误] " + e.getMessage(), null, "error");
} catch (Exception persistErr) {
log.warn("[{}] Failed to persist error placeholder: {}",
channelType, persistErr.getMessage());
}
try {
String errorTarget = resolveReplyTarget(message);
streamingAdapter.sendMessage(errorTarget, "抱歉,流式处理失败:" + e.getMessage());
} catch (Exception sendErr) {
log.error("[{}] Failed to send streaming error message: {}", channelType, sendErr.getMessage());
}
}
return null;
}
// ==================== 审批重放 ====================
/**
* 重放被审批阻塞的工具调用
* <p>
* 接收已消费的审批记录(由 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);
}
AgentService.ChatResult replayResult = agentService.chatWithReplayWithUsage(
agentId, replayPrompt, conversationId, consumed.getToolCallPayload(), replayOrigin);
String reply = replayResult.content();
// 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",
replayResult.promptTokens(), replayResult.completionTokens(),
replayResult.runtimeModel(), replayResult.runtimeProvider());
// 发送回复
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.
* <p>
* 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.
* <p>
* 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,
ChatOrigin origin) {
// Attribute the memory write to the same external sender the read path
// recalled for, so per-sender IM memory is both written and recalled
// under the same owner key.
completionPublisher.publishForOrigin(agentId, conversationId, userMessage, assistantReply,
"channel", origin);
}
// ==================== 流式处理Web 渠道专用,不走队列) ====================
/**
* 路由消息并使用流式处理(用于支持流式的渠道,如 Web
*/
public Flux<String> routeStream(ChannelMessage message, ChannelEntity channelEntity) {
ChannelEntity fresh = freshChannelEntity(channelEntity);
if (fresh == null) {
return Flux.error(new IllegalStateException("Channel no longer exists"));
}
if (Boolean.FALSE.equals(fresh.getEnabled())) {
return Flux.error(new IllegalStateException("Channel is disabled"));
}
channelEntity = fresh;
Long agentId = channelEntity.getAgentId();
if (agentId == null) {
return Flux.error(new IllegalStateException("Channel has no associated agent"));
}
String conversationId = buildConversationId(message);
String username = message.getSenderName() != null ? message.getSenderName() : message.getSenderId();
conversationService.getOrCreateConversation(conversationId, agentId, username, channelEntity.getWorkspaceId());
List<MessageContentPart> parts = message.getContentParts();
// Mirror processMessage's group attribution for the streaming path
// (Web channel today; future streaming IM channels inherit it).
String attributedContent = applyGroupTag(message, message.getContent());
conversationService.saveMessage(conversationId, "user", attributedContent, parts);
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
promptText = applyGroupTag(message, promptText);
// RFC-063r §2.5: forward ChatOrigin so tools created during this
// streaming conversation inherit channel binding.
ChatOrigin origin = chatOriginFactory.from(
channelEntity, message, conversationId, /* workspaceBasePath */ null);
return agentService.chatStream(agentId, promptText, conversationId, origin);
}
// ==================== 优雅关闭 ====================
/**
* 优雅关闭:停止防抖调度器和所有消费线程
*/
public void shutdown() {
log.info("Shutting down ChannelMessageRouter...");
shutdown = true;
// 1. 关闭防抖调度器
debounceScheduler.shutdownNow();
// 2. 清理残留的 pending 消息
synchronized (pendingMessages) {
pendingMessages.forEach((convId, pending) -> {
if (pending.timer != null) {
pending.timer.cancel(false);
}
log.warn("Dropping pending debounced message for conversation: {}", convId);
});
pendingMessages.clear();
}
// 3. 关闭每个渠道的消费线程池shutdown -> 等待 5 秒 -> shutdownNow
channelExecutors.forEach((channelType, executor) -> {
log.info("[{}] Shutting down consumer threads...", channelType);
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
log.warn("[{}] Consumer threads did not terminate in 5s, forcing shutdown", channelType);
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
});
channelExecutors.clear();
channelQueues.clear();
sessionLocks.clear();
// 4. 关闭语音回复线程池
voiceReplyExecutor.shutdownNow();
log.info("ChannelMessageRouter shutdown complete");
}
// ==================== 工具方法 ====================
/**
* Re-read the channel row from the database so the rest of the message
* pipeline sees current routing metadata (agentId, workspaceId, identityJson)
* rather than the snapshot captured when the adapter was constructed.
*
* <p>Failure semantics:
* <ul>
* <li><b>Channel deleted</b> — {@link ChannelService#getChannel} throws
* a {@link MateClawException} with {@code msgKey="err.channel.not_found"}.
* We return {@code null} so the caller drops the message: the channel
* no longer exists, routing the message would land it against a row
* that's been removed.</li>
* <li><b>Transient lookup failure</b> — any other exception (DB blip,
* NPE in mapper, …). We fall back to the snapshot so an isolated
* infrastructure hiccup doesn't black-hole live traffic.</li>
* </ul>
*
* <p>{@code enabled=false} is NOT handled here — that's an admin decision
* the callers check separately, with channel-type-specific logging.
*/
private ChannelEntity freshChannelEntity(ChannelEntity snapshot) {
if (snapshot == null || snapshot.getId() == null) {
return snapshot;
}
try {
ChannelEntity latest = channelService.getChannel(snapshot.getId());
return latest != null ? latest : snapshot;
} catch (MateClawException biz) {
if ("err.channel.not_found".equals(biz.getMsgKey())) {
log.warn("Channel id={} no longer exists; dropping incoming message",
snapshot.getId());
return null;
}
log.debug("Transient channel lookup failure id={}, using snapshot: {}",
snapshot.getId(), biz.getMessage());
return snapshot;
} catch (Exception e) {
log.debug("Failed to refresh ChannelEntity id={}, using snapshot: {}",
snapshot.getId(), e.getMessage());
return snapshot;
}
}
/**
* 构建会话 ID
* 格式:{channelType}:{chatId 或 senderId}
* 格式采用 {channelType}:{identifier} 命名规则
*/
private String buildConversationId(ChannelMessage message) {
String identifier = message.getChatId() != null ? message.getChatId() : message.getSenderId();
return message.getChannelType() + ":" + identifier;
}
/**
* Read-only existence check for a conversation by its logical id. Used by
* adapters that need to alias a legacy conversationId scheme to a new one
* without rewriting stored rows (e.g. Feishu group session-id migration).
*/
public boolean conversationExists(String conversationId) {
return conversationService.findByConversationId(conversationId) != null;
}
/**
* Build a sender-attribution tag for group messages. Returns
* {@code [@senderName]} when the message is from a multi-user channel
* context (chatId is set), else {@code null} for 1:1 chats.
*
* <p>Without this tag, three users asking three different questions in
* the same group conversation collapse into an unattributed wall of
* "user:" turns and the LLM can no longer tell who is asking what —
* it answers based on the most-recent text and ignores the rest.
* Single chats are unaffected because chatId is null there.
*
* <p>Prefer {@code senderName} when populated; otherwise fall back to
* {@code senderId}. WeCom currently sets both to the same opaque
* openid which is still useful for disambiguation; future channels
* (DingTalk, Slack) carry friendlier display names that flow through
* unchanged.
*
* @return sender tag like {@code [@Alice]}, or {@code null} if the
* message is not from a group context.
*/
static String buildGroupTag(ChannelMessage message) {
if (message == null) return null;
String chatId = message.getChatId();
if (chatId == null || chatId.isBlank()) return null;
String name = (message.getSenderName() != null && !message.getSenderName().isBlank())
? message.getSenderName() : message.getSenderId();
if (name == null || name.isBlank()) return null;
return "[@" + name + "]";
}
/**
* Apply {@link #buildGroupTag} to {@code content}. Idempotent: if
* {@code content} already starts with the tag (e.g. an upstream
* adapter has pre-attributed it), returns it unchanged so we don't
* double-stamp. No-op for single chats.
*/
static String applyGroupTag(ChannelMessage message, String content) {
String tag = buildGroupTag(message);
if (tag == null) return content;
// Empty content: leave empty rather than persist or prompt with a
// bare "[@Alice]" — the message had no payload to attribute.
if (content == null || content.isEmpty()) return content;
if (content.startsWith(tag)) return content;
return tag + " " + content;
}
/**
* Decision helper for the debounce merger: should an incoming message
* from {@code incomingSender} merge into a pending buffer started by
* {@code existingSender}? True only when the senders match — different
* senders in the same conversation (a group context) must NOT merge,
* else the second user's text gets attributed to the first.
*
* <p>Null-handling: a null {@code existingSender} means "no buffer to
* merge into" so the answer is always false; a null
* {@code incomingSender} (rare, but seen in test fixtures) is also
* not allowed to silently merge — returning false routes to the
* "create new pending" branch which is safe.
*/
static boolean isSameSender(String existingSender, String incomingSender) {
if (existingSender == null || incomingSender == null) return false;
return existingSender.equals(incomingSender);
}
/**
* 确定回复目标
* 优先使用 replyToken渠道特有的回复标识其次 chatId最后 senderId
*/
private String resolveReplyTarget(ChannelMessage message) {
if (message.getReplyToken() != null) {
return message.getReplyToken();
}
return message.getChatId() != null ? message.getChatId() : message.getSenderId();
}
/**
* 从 contentParts 构建完整 prompt 文本。
* 文本直接拼接;媒体类型生成描述性占位符,让 Agent 知道用户发送了什么。
* 语音输入时注入场景提示词,引导 Agent 用简短口语化方式回复。
*/
private String buildPromptFromParts(String fallbackContent, List<MessageContentPart> parts, String inputMode) {
if (parts == null || parts.isEmpty()) {
return fallbackContent != null ? fallbackContent : "";
}
StringBuilder sb = new StringBuilder();
for (MessageContentPart part : parts) {
if (part == null || part.getType() == null) continue;
switch (part.getType()) {
case "text" -> appendLine(sb, part.getText());
case "image" -> appendLine(sb, "[用户发送了图片" + descMedia(part) + "]");
case "file" -> appendLine(sb, "[用户发送了文件: " + safe(part.getFileName()) + "]");
case "audio" -> appendLine(sb, "[用户发送了音频" + descMedia(part) + "]");
case "video" -> appendLine(sb, "[用户发送了视频" + descMedia(part) + "]");
default -> appendLine(sb, part.getText());
}
}
String result = sb.toString().trim();
if (result.isEmpty()) {
return fallbackContent != null ? fallbackContent : "";
}
// 语音场景:注入提示词让 Agent 回复更简短口语化
if ("voice".equals(inputMode)) {
result = "[用户通过语音输入,请用简短口语化的方式回复]\n" + result;
}
return result;
}
private void appendLine(StringBuilder sb, String text) {
if (text == null || text.isBlank()) return;
if (!sb.isEmpty()) sb.append('\n');
sb.append(text);
}
private String descMedia(MessageContentPart part) {
if (part.getFileName() != null && !part.getFileName().isBlank()) {
return ": " + part.getFileName();
}
return "";
}
private String safe(String s) {
return s == null ? "" : s;
}
// ==================== 语音回复TTS====================
/** TTS 异步工作线程池 */
private final ExecutorService voiceReplyExecutor = Executors.newFixedThreadPool(2, r -> {
Thread t = new Thread(r, "voice-reply-worker");
t.setDaemon(true);
return t;
});
/**
* 根据消息上下文和渠道配置,判断是否需要生成语音回复。
* 若需要,异步合成 TTS 并通过渠道发送音频文件。
* <p>
* 设计原则(借鉴 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<String, Object> channelConfig = parseChannelConfig(channelEntity.getConfigJson());
String voiceName = channelConfig.getOrDefault("voice_name", "").toString();
double voiceSpeed = 1.0;
Object speedObj = channelConfig.get("voice_speed");
if (speedObj instanceof Number n) voiceSpeed = n.doubleValue();
// 调用 TtsService 合成
Map<String, Object> result = ttsService.synthesize(
conversationId, replyText,
voiceName.isBlank() ? null : voiceName,
voiceSpeed, "mp3");
if (!Boolean.TRUE.equals(result.get("success"))) {
log.debug("[voice-reply] TTS synthesis failed: {}", result.get("error"));
return;
}
// 构建音频 MessageContentPart
String audioUrl = (String) result.get("audioUrl");
String fileName = Paths.get(audioUrl).getFileName().toString();
// TTS output may live under a workspace-scoped dir or the legacy
// default dir — probe each candidate root to find the file.
Path audioPath = resolveVoiceReplyAudio(conversationId, fileName);
if (audioPath == null) {
log.warn("[voice-reply] TTS output file not found for conversation {} ({})", conversationId, fileName);
return;
}
MessageContentPart audioPart = new MessageContentPart();
audioPart.setType("audio");
audioPart.setFileName(fileName);
audioPart.setPath(audioPath.toString());
audioPart.setContentType("audio/mpeg");
adapter.sendContentParts(replyTarget, List.of(audioPart));
log.info("[voice-reply] Sent to {} via {}: {} ({}KB)",
replyTarget, adapter.getChannelType(), fileName,
Files.size(audioPath) / 1024);
} catch (Exception e) {
// 静默降级TTS 失败不影响已发出的文本回复
log.warn("[voice-reply] Failed for conversation {}: {}", conversationId, e.getMessage());
}
});
}
/**
* Resolve the TTS audio file across every candidate upload root. Returns the
* first existing match, or {@code null} when the file is absent under every
* root. Used by the voice-reply path so workspace-scoped and legacy default
* outputs are both found.
*/
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
if (chatUploadLocationResolver != null) {
for (Path dir : chatUploadLocationResolver.resolveCandidateConversationDirs(conversationId)) {
Path candidate = dir.resolve(fileName);
if (Files.exists(candidate)) {
return candidate;
}
}
}
// Fallback to the legacy default dir when the resolver is absent
// (e.g. direct-construction unit tests). Sanitize the id for the path
// segment so it matches the write side.
Path legacy = Paths.get("data", "chat-uploads",
ChatUploadLocationResolver.sanitizeSegment(conversationId), fileName);
return Files.exists(legacy) ? legacy : null;
}
/**
* 判断是否需要为此消息生成语音回复
*/
private boolean shouldGenerateVoiceReply(ChannelMessage message, ChannelEntity channelEntity,
String replyText) {
// 1. TTS 全局开关
if (!ttsService.isTtsEnabled()) return false;
// 2. 回复内容过短或为空,跳过 TTS借鉴 OpenClaw: <10字不合成
if (replyText == null || replyText.trim().length() < 10) return false;
// 3. 读取渠道级语音回复模式
Map<String, Object> channelConfig = parseChannelConfig(channelEntity.getConfigJson());
String voiceMode = channelConfig.getOrDefault("voice_reply_mode", "off").toString();
if ("off".equals(voiceMode)) return false;
if ("always".equals(voiceMode)) return true;
// 4. auto 模式:仅当用户通过语音输入时回语音
return "auto".equals(voiceMode) && "voice".equals(message.getInputMode());
}
/**
* 解析 Channel 的 configJson 为 Map
*/
private Map<String, Object> parseChannelConfig(String configJson) {
if (configJson == null || configJson.isBlank()) return Map.of();
try {
return objectMapper.readValue(configJson, new TypeReference<>() {});
} catch (Exception e) {
log.debug("[voice-reply] Failed to parse channel config: {}", e.getMessage());
return Map.of();
}
}
}