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.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
* 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.
*
* 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}.
*
* 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.
*
* 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) {
// 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
* 当钉钉渠道启用 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 共用门禁。否则群里任意成员可拒绝/取消他人的
// pending,system/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
* 事件流与渲染分离:
* - 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);
}
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.
*
* 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,
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 Failure semantics:
* {@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;
}
/**
* 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.
*
* 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.
*
* 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.
*
* 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
* 设计原则(借鉴 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
*
*
*