replyContexts = new ConcurrentHashMap<>();
/**
* Group-chat reply-slot fallback cache: {@code chatId → most recent
* inbound frameReqId from that group}.
*
* The WeCom AI Bot platform blocks {@code aibot_send_msg} in
* group chats — proactive pushes (cron summaries,
* image-generation completions, TTS audio, async-task forwards) must
* ride {@code aibot_respond_msg} bound to some prior frame
* id. Without this cache every group push silently failed:
* {@code sendMessageToChat} fell through to {@code aibot_send_msg},
* the platform rejected it, the user saw nothing.
*
* Populated for group inbound frames only — single-chat
* {@code aibot_send_msg} still works, so we don't need a cached
* reqId there. {@link #pickGroupReplyReqId(String)} returns the
* cached id (or null when there's never been a group inbound), and
* the proactive send paths fall through to {@code aibot_send_msg}
* when null.
*
* Bounded LRU at {@link #LAST_CHAT_REQ_IDS_MAX_SIZE} via insertion-
* order eviction — long-lived bots in many groups don't unbounded-grow.
*/
private final ConcurrentHashMap lastChatReqIds = new ConcurrentHashMap<>();
/** Max chat-id entries to keep in {@link #lastChatReqIds} before evicting. */
private static final int LAST_CHAT_REQ_IDS_MAX_SIZE = 1000;
private record WeComReplyContext(String frameReqId, String processingStreamId) {}
/**
* Single-flight guard for failure signals. JDK WebSocket can fire onClose
* AND onError for the same outage, plus connect-exception and heartbeat
* timeout, all routing to the disconnect path. The first one wins; the
* rest are deduped. Cleared at the start of each new connect attempt and
* after auth_succeed in markReady().
*/
private final AtomicBoolean disconnectInflight = new AtomicBoolean(false);
/**
* Approval-notification renderer. Held for symmetry with other channel
* adapters; the WeCom override of {@link #sendApprovalNotice} delegates
* card rendering to {@link #cardDispatcher} but still uses this service
* to build the {@link vip.mate.channel.notification.ApprovalNotice}
* data carrier. Null-tolerant: if Spring DI fails (test contexts), the
* default text-approval fallback still works.
*/
@SuppressWarnings("unused") // consumed via card dispatcher's tool_guard kind in PR-1
private final vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService;
/**
* WeCom interactive-card dispatcher (PR-1).
*
* Routes outbound approval notices to a {@code button_interaction}
* card via tool_guard renderer, and inbound {@code template_card_event}
* frames to the matching handler by task_id prefix. Null-tolerant for
* test contexts (the {@link #sendApprovalNotice} override falls back
* to the abstract-class text path when the dispatcher is missing).
*/
private final vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher;
/**
* Refreshes the "🤔 思考中..." processing-stream chunk every 20s and
* force-finishes after 180s, so WeCom's server-side stream slot
* doesn't drop while a long-running agent task is still computing
* (RFC-32 §2.1.2 / R-7 / B-5). Null-tolerant: if missing (test DI
* gap), placeholder still appears once but is not refreshed.
*/
private final WeComKeepaliveScheduler keepaliveScheduler;
/**
* In-memory cache of bytes generated by tools like
* {@code DocxRenderTool} / {@code PptxRenderTool}. The agent emits a
* {@code /api/v1/files/generated/{id}} URL referencing this cache; the
* channel layer resolves that URL back to bytes and uploads them as a
* native WeCom file message so the user actually receives a tappable
* document instead of an unopenable link. Null-tolerant: if missing
* (older constructor / test DI gap), URL stays inline as plain markdown
* which renders as a non-interactive link in the bubble.
*/
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/**
* Workspace/agent-aware upload-root resolver, set by the production factory.
* Null in unit tests (the legacy {@code data/chat-uploads} default applies).
*/
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
WeComKeepaliveScheduler keepaliveScheduler) {
this(channelEntity, messageRouter, objectMapper, approvalNotificationService,
cardDispatcher, keepaliveScheduler, null);
}
public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
WeComKeepaliveScheduler keepaliveScheduler,
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
this(channelEntity, messageRouter, objectMapper, approvalNotificationService,
cardDispatcher, keepaliveScheduler, generatedFileCache, null);
}
/**
* Full constructor used by the production factory (ChannelManager). The
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
* behaviour.
*/
public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
WeComKeepaliveScheduler keepaliveScheduler,
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
super(channelEntity, messageRouter, objectMapper);
this.approvalNotificationService = approvalNotificationService;
this.cardDispatcher = cardDispatcher;
this.keepaliveScheduler = keepaliveScheduler;
this.generatedFileCache = generatedFileCache;
this.chatUploadLocationResolver = chatUploadLocationResolver;
// Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential)
// so the UI eventually settles in ERROR instead of getting stuck in
// RECONNECTING forever. User config still overrides (-1 = infinite).
int maxAttempts = 8;
Object val = config.get("max_reconnect_attempts");
if (val instanceof Number n) {
maxAttempts = n.intValue();
} else if (val instanceof String s) {
try { maxAttempts = Integer.parseInt(s); } catch (NumberFormatException ignored) {}
}
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, maxAttempts);
}
// ==================== 生命周期 ====================
@Override
protected void doStart() {
String botId = getConfigString("bot_id");
String secret = getConfigString("secret");
if (botId == null || botId.isBlank() || secret == null || secret.isBlank()) {
throw new IllegalStateException("WeCom bot channel requires bot_id and secret in configJson");
}
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
// Build the reply-queue worker pool BEFORE the WS handshake kicks off, so
// any inbound auth_succeed → markReady → openReplyQueue path finds a live
// executor to schedule against. The gate stays closed until markReady runs.
ensureReplyExecutor();
connectWebSocket(botId, secret);
log.info("[wecom] WeCom bot channel initialized: botId={}, maxReconnectAttempts={}",
botId.length() > 12 ? botId.substring(0, 12) + "..." : botId, backoff.getMaxAttempts());
}
@Override
protected void doStop() {
releaseConnectionResources("stopped");
// doStop also clears history that survives reconnects (processedMessageIds)
processedMessageIds.clear();
log.info("[wecom] WeCom bot channel stopped");
}
@Override
protected void doReconnect() {
log.info("[wecom] Reconnecting WebSocket...");
releaseConnectionResources("reconnecting");
// releaseConnectionResources nulls httpClient — rebuild a fresh one.
// This is the core fix: each reconnect starts from a clean SSL/I-O
// surface, mirroring what manual stop+start has been doing in the field.
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
// Re-arm the reply-queue worker pool BEFORE attempting the new
// handshake. accepting flag stays false until the new connection's
// auth_succeed fires markReady → openReplyQueue.
ensureReplyExecutor();
String botId = getConfigString("bot_id");
String secret = getConfigString("secret");
connectWebSocket(botId, secret);
}
/**
* Release every per-connection resource so a fresh HttpClient + WebSocket
* are always built next. Shared by doStop (channel teardown) and
* doReconnect (auto-recovery cycle).
*
*
Why drop {@code httpClient} too: the JDK HttpClient caches SSL
* sessions and keeps an async selector loop. After certain WS error paths
* the SSL session can be poisoned, causing every subsequent handshake to
* be RST'd by the server ("Remote host terminated the handshake"). Dropping
* the client forces a clean rebuild — this is exactly what manual
* "Disable + Enable" did to recover.
*/
private void releaseConnectionResources(String reason) {
// ============================================================================
// RFC-32 §2.4.1 a-3 / R-6 + R-8 修正:必须按 step 0~4 顺序,不是尾部追加。
// step 0 (replyQueueAccepting=false) 必须在 ws.close()/wsThread.join() 之前;
// 否则在 ws teardown 期间还会有 keepalive / 最终回复 / proactiveSend 漏进 enqueue。
// ============================================================================
// ---- Step 0:先关 lifecycle gate,让任何后续 sendFrameWithAck 立刻 fast-fail ----
replyQueueAccepting.set(false);
// ---- 现有的 ws/heartbeat teardown(功能未变;插在 step 0 之后、step 1 之前) ----
if (heartbeatFuture != null) {
heartbeatFuture.cancel(false);
heartbeatFuture = null;
}
if (webSocket != null) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, reason)
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> null)
.join();
} catch (Exception e) {
log.debug("[wecom] release: ws close: {}", e.getMessage());
}
webSocket = null;
}
if (wsThread != null) {
wsThread.interrupt();
try {
wsThread.join(3000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
wsThread = null;
}
// ---- Step 1:第一次 drain replyQueues ----
// forEach 是 weakly-consistent 迭代器,可能错过 step 0 之前刚提交但还没出 compute
// 的 enqueue —— step 3 会再 drain 一次兜底。
replyQueues.forEach((rid, state) -> {
state.closed().set(true);
ReplyTask t;
while ((t = state.queue().poll()) != null) {
if (!t.future().isDone()) {
t.future().completeExceptionally(new IllegalStateException("Channel " + reason));
}
}
});
// ---- Step 2:shutdownNow 中断 worker 阻塞中的 poll(60s) + 拒绝后续 submit ----
ExecutorService oldExecutor = this.replyExecutor;
if (oldExecutor != null) {
oldExecutor.shutdownNow();
this.replyExecutor = null;
}
// ---- Step 3:second drain,捕获 step 1 与 step 2 之间的窗口期残留 ----
// 此刻 shutdownNow 已经把任何新 fresh state 的 worker 拒掉,drain 是它们唯一退路。
replyQueues.forEach((rid, state) -> {
state.closed().set(true);
ReplyTask t;
while ((t = state.queue().poll()) != null) {
if (!t.future().isDone()) {
t.future().completeExceptionally(new IllegalStateException("Channel " + reason));
}
}
});
replyQueues.clear();
// ---- Step 4:pendingAcks 残留 ----
pendingAcks.forEach((k, f) -> {
if (!f.isDone()) {
f.completeExceptionally(new IllegalStateException("Channel " + reason));
}
});
pendingAcks.clear();
// ---- 其他 per-connection 状态 ----
pendingFrames.clear();
replyContexts.clear();
streamLastContent.clear();
// 断线时可能残留半截帧碎片,清空以免污染下一个连接的首帧
wsBuffer.setLength(0);
wsBinaryBuffer.reset();
if (keepaliveScheduler != null) {
keepaliveScheduler.shutdownAll();
}
missedPongCount.set(0);
this.httpClient = null;
}
// ====================================================================
// RFC-32 §2.0.5 / §2.4.1 a-1: lifecycle gate plumbing
// ====================================================================
/**
* (Re)build the worker pool. Called from {@link #doStart()} and
* {@link #doReconnect()}. Does not touch the {@link #replyQueueAccepting}
* gate — that flag is controlled by the transport-ready signal
* ({@link #markReady()}). See §2.4.1 a-1 / R-7.
*/
private void ensureReplyExecutor() {
if (replyExecutor == null || replyExecutor.isShutdown()) {
replyExecutor = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "wecom-reply");
t.setDaemon(true);
return t;
});
}
}
/**
* Open the {@link #replyQueueAccepting} lifecycle gate. Only
* called from {@link #markReady()} after auth_succeed. Until this
* runs, every {@link #sendFrameWithAck} call fast-fails the caller's
* future with {@link IllegalStateException}.
*/
private void openReplyQueue() {
replyQueueAccepting.set(true);
}
/**
* Per-reqId serial worker. Started lazily by
* {@link #sendFrameWithAck} when a fresh {@link ReplyQueueState} is
* created. Exits when:
*
* - queue is idle for 60s and atomically closes via compute
* (so any concurrent late-offer is observed and we stay alive)
* - {@code running} flips to false
* - worker thread is interrupted (e.g. by
* {@link ExecutorService#shutdownNow()})
*
*
* The compute-based idle-close fixes the TOCTOU race called out
* in RFC-32 §2.4.1 a-2 / R-5: enqueue's {@code compute} and
* worker's idle-close {@code compute} share the same bin lock,
* so offer and remove never interleave on the same key.
*/
private void reqIdWorker(String reqId, ReplyQueueState state) {
while (running.get() && !Thread.currentThread().isInterrupted()) {
ReplyTask task;
try {
task = state.queue().poll(workerIdleTimeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break; // fall through to drainStateExceptionally + return
}
if (task == null) {
// Atomic close — serialized against sendFrameWithAck.compute on
// the same reqId by ConcurrentHashMap's bin lock.
ReplyQueueState afterClose = replyQueues.compute(reqId, (k, current) -> {
if (current != state) return current; // (c) replaced — defensive exit
if (!current.queue().isEmpty()) return current; // (b) late offer — stay alive
current.closed().set(true); // (a) truly idle — close
return null; // (a) remove entry
});
if (afterClose != state) return; // (a) or (c) — exit
continue; // (b) — keep going
}
try {
pendingAcks.put(reqId, task.future());
// orTimeout 5s 兜底,whenComplete 在完成时清 pendingAcks。
// 用 (key, value) 双参 remove 避免误删后续 task 的注册。
task.future().orTimeout(REPLY_ACK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.whenComplete((r, ex) -> pendingAcks.remove(reqId, task.future()));
sendFrame(task.frame());
task.future().join(); // serialize: don't dequeue next until this is done
} catch (CompletionException ce) {
// join() 抛的是 orTimeout 注入的异常(典型:TimeoutException)——
// task.future 已经 complete,无需手动 fail
log.debug("[wecom] reply task ACK failed for reqId={}: {}", reqId, ce.getCause());
} catch (Exception e) {
// sendFrame 同步抛 → ACK 永远不会到 → 必须显式 fail,否则 caller future 永久 pending
if (!task.future().isDone()) {
task.future().completeExceptionally(e);
}
pendingAcks.remove(reqId, task.future());
log.debug("[wecom] reply task send failed for reqId={}: {}", reqId, e.getMessage());
}
}
// running=false / interrupted: mark closed + drain leftover
state.closed().set(true);
drainStateExceptionally(reqId, state, "channel stopped");
}
/**
* Drain remaining tasks in a {@link ReplyQueueState} and best-effort
* remove the entry from {@link #replyQueues}. Used by worker exit
* paths (running=false / interrupt). For {@link #releaseConnectionResources}
* the drain is inlined (step 1 / step 3) to keep the ordering proof local.
*/
private void drainStateExceptionally(String reqId, ReplyQueueState state, String reason) {
ReplyTask t;
while ((t = state.queue().poll()) != null) {
if (!t.future().isDone()) {
t.future().completeExceptionally(new IllegalStateException(reason));
}
}
replyQueues.remove(reqId, state);
}
// ==================== WebSocket 连接 ====================
/**
* 在守护线程中建立 WebSocket 连接
*/
private void connectWebSocket(String botId, String secret) {
// Fresh attempt — allow new failure signals to register again.
disconnectInflight.set(false);
wsThread = new Thread(() -> {
try {
log.info("[wecom] WebSocket connecting to {}...", DEFAULT_WS_URL);
CompletableFuture wsFuture = httpClient.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(15))
.buildAsync(URI.create(DEFAULT_WS_URL), new WeComWebSocketListener());
webSocket = wsFuture.get(20, TimeUnit.SECONDS);
log.info("[wecom] WebSocket connected, sending auth...");
// 发送认证帧
sendAuth(botId, secret);
} catch (Exception e) {
log.error("[wecom] WebSocket connection failed: {}", e.getMessage(), e);
handleFailure("WebSocket connection failed: " + e.getMessage());
}
}, "wecom-ws-" + channelEntity.getId());
wsThread.setDaemon(true);
wsThread.start();
}
/**
* Single-flight failure handler. JDK WebSocket can fire onClose AND
* onError for the same outage, plus connect-exception and heartbeat
* timeout, all wanting to trigger reconnect. Without dedup this fans out
* into 2-4 concurrent reconnect attempts, which collide on shared state
* (httpClient, wsThread) and amplify failure into a storm.
*
* Only the first signal of a given outage gets through; later signals
* are logged at debug level and dropped. The flag is cleared at the start
* of each new connect attempt and on auth success (markReady).
*/
private void handleFailure(String reason) {
if (!disconnectInflight.compareAndSet(false, true)) {
log.debug("[wecom] handleFailure dedup: {}", reason);
return;
}
if (!running.get()) {
return;
}
onDisconnected(reason);
}
/**
* Suppressed at the framework's call site. {@link AbstractChannelAdapter}
* invokes this immediately after {@code doReconnect()} returns, but our
* doReconnect only fire-and-forget schedules an async WS connect — the
* connection isn't actually ready yet. Letting the framework reset
* {@code backoff} here causes attempts to stall at #1 forever.
*
*
Real reset happens in {@link #markReady()} when the WeCom
* {@code aibot_subscribe} auth response confirms the session is up.
*/
@Override
protected void onReconnectSuccess() {
// intentionally empty
}
/**
* Called when WeCom auth_succeed frame is received — the only point at
* which the connection is genuinely usable. Resets backoff and clears
* the failure dedup flag so the next outage (if any) can register.
*
*
RFC-080 follow-up: also cancels {@code reconnectFuture}. A stale
* failure signal (e.g. the previous socket's async onError fired AFTER
* connectWebSocket reset the dedup flag) can schedule a ghost reconnect
* while this attempt is still in flight. Without canceling, that ghost
* fires 2s later and tears down the freshly-authenticated connection,
* producing the self-sustaining loop. Per-listener identity dedup catches
* most cases; this is the belt-and-suspenders for any signal that still
* gets through.
*/
private void markReady() {
super.onReconnectSuccess();
if (reconnectFuture != null) {
reconnectFuture.cancel(false);
reconnectFuture = null;
}
disconnectInflight.set(false);
// RFC-32 §2.4.1 a-1 / R-7: only NOW does sendFrameWithAck start
// accepting tasks — auth_succeed has just been observed and the
// WS is the canonical "transport ready" anchor.
openReplyQueue();
}
/**
* WebSocket 监听器:接收消息帧并分发处理。
*
*
RFC-080 follow-up: dedup by socket identity. The {@code disconnectInflight}
* flag is per-channel and gets reset by {@link #connectWebSocket} as soon as a
* new attempt starts. But the previous socket's onClose/onError can fire
* asynchronously on a JDK HttpClient worker tens of milliseconds AFTER we have
* already called sendClose+rebuilt and reset the flag. Without per-socket
* dedup that stale signal slips through, schedules a ghost reconnect, and
* tears down the freshly-established healthy connection 2s later — producing
* the self-sustaining 2-second loop observed in the field.
*
*
Each listener instance compares the {@code WebSocket} argument against
* {@link #webSocket} and ignores callbacks for sockets that have already
* been replaced or released.
*/
private class WeComWebSocketListener implements WebSocket.Listener {
@Override
public void onOpen(WebSocket webSocket) {
log.debug("[wecom] WebSocket onOpen");
webSocket.request(1);
}
@Override
public CompletionStage> onText(WebSocket webSocket, CharSequence data, boolean last) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
return null;
}
wsBuffer.append(data);
if (last) {
String fullMessage = wsBuffer.toString();
wsBuffer.setLength(0);
handleWebSocketFrame(fullMessage);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
return null;
}
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
wsBinaryBuffer.write(bytes, 0, bytes.length);
if (last) {
wsBuffer.append(new String(wsBinaryBuffer.toByteArray(), StandardCharsets.UTF_8));
wsBinaryBuffer.reset();
String fullMessage = wsBuffer.toString();
wsBuffer.setLength(0);
handleWebSocketFrame(fullMessage);
}
webSocket.request(1);
return null;
}
@Override
public CompletionStage> onClose(WebSocket webSocket, int statusCode, String reason) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
log.debug("[wecom] stale onClose ignored: code={}, reason={}", statusCode, reason);
return null;
}
log.warn("[wecom] WebSocket closed: code={}, reason={}", statusCode, reason);
handleFailure("WebSocket closed: code=" + statusCode + ", reason=" + reason);
return null;
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (webSocket != WeComChannelAdapter.this.webSocket) {
log.debug("[wecom] stale onError ignored: {}", error.getMessage());
return;
}
log.error("[wecom] WebSocket error: {}", error.getMessage());
handleFailure("WebSocket error: " + error.getMessage());
}
}
// ==================== 帧处理 ====================
/**
* 处理收到的 WebSocket JSON 帧
*/
@SuppressWarnings("unchecked")
private void handleWebSocketFrame(String jsonStr) {
try {
Map frame = objectMapper.readValue(jsonStr, Map.class);
String cmd = (String) frame.get("cmd");
// 消息推送
if (CMD_CALLBACK.equals(cmd)) {
handleMessageCallback(frame);
return;
}
// 事件推送
if (CMD_EVENT_CALLBACK.equals(cmd)) {
handleEventCallback(frame);
return;
}
// 无 cmd 的帧:认证响应、心跳响应或回复 ACK
Map headers = (Map) frame.getOrDefault("headers", Map.of());
String reqId = (String) headers.getOrDefault("req_id", "");
// 检查是否是回复消息的 ACK
CompletableFuture