mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
fix(wecom): harden streaming reply management
This commit is contained in:
parent
e756219bdc
commit
be836b6f0a
@ -12,11 +12,13 @@ import vip.mate.workspace.core.service.ChatUploadLocationResolver;
|
|||||||
import vip.mate.channel.ExponentialBackoff;
|
import vip.mate.channel.ExponentialBackoff;
|
||||||
import vip.mate.channel.media.InboundMediaDownloader;
|
import vip.mate.channel.media.InboundMediaDownloader;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
|
import vip.mate.channel.wecom.cards.tool_guard.ToolGuardCardRenderer;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
|
||||||
import javax.crypto.Cipher;
|
import javax.crypto.Cipher;
|
||||||
import javax.crypto.spec.IvParameterSpec;
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
@ -24,6 +26,7 @@ import java.net.http.HttpRequest;
|
|||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.net.http.WebSocket;
|
import java.net.http.WebSocket;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
@ -196,6 +199,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
/** WebSocket 消息碎片缓冲区 */
|
/** WebSocket 消息碎片缓冲区 */
|
||||||
private final StringBuilder wsBuffer = new StringBuilder();
|
private final StringBuilder wsBuffer = new StringBuilder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw-byte accumulator for fragmented binary WS frames. Bytes are only
|
||||||
|
* decoded (as UTF-8) once the final fragment arrives — decoding each
|
||||||
|
* fragment separately would corrupt any multi-byte character split
|
||||||
|
* across a fragment boundary. Accessed only from the JDK WebSocket
|
||||||
|
* listener callbacks, which are delivered serially per socket.
|
||||||
|
*/
|
||||||
|
private final ByteArrayOutputStream wsBinaryBuffer = new ByteArrayOutputStream();
|
||||||
|
|
||||||
/** 请求 ID 计数器 */
|
/** 请求 ID 计数器 */
|
||||||
private final AtomicInteger reqIdCounter = new AtomicInteger(0);
|
private final AtomicInteger reqIdCounter = new AtomicInteger(0);
|
||||||
|
|
||||||
@ -498,6 +510,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
pendingFrames.clear();
|
pendingFrames.clear();
|
||||||
replyContexts.clear();
|
replyContexts.clear();
|
||||||
streamLastContent.clear();
|
streamLastContent.clear();
|
||||||
|
// 断线时可能残留半截帧碎片,清空以免污染下一个连接的首帧
|
||||||
|
wsBuffer.setLength(0);
|
||||||
|
wsBinaryBuffer.reset();
|
||||||
if (keepaliveScheduler != null) {
|
if (keepaliveScheduler != null) {
|
||||||
keepaliveScheduler.shutdownAll();
|
keepaliveScheduler.shutdownAll();
|
||||||
}
|
}
|
||||||
@ -761,8 +776,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
}
|
}
|
||||||
byte[] bytes = new byte[data.remaining()];
|
byte[] bytes = new byte[data.remaining()];
|
||||||
data.get(bytes);
|
data.get(bytes);
|
||||||
wsBuffer.append(new String(bytes));
|
wsBinaryBuffer.write(bytes, 0, bytes.length);
|
||||||
if (last) {
|
if (last) {
|
||||||
|
wsBuffer.append(new String(wsBinaryBuffer.toByteArray(), StandardCharsets.UTF_8));
|
||||||
|
wsBinaryBuffer.reset();
|
||||||
String fullMessage = wsBuffer.toString();
|
String fullMessage = wsBuffer.toString();
|
||||||
wsBuffer.setLength(0);
|
wsBuffer.setLength(0);
|
||||||
handleWebSocketFrame(fullMessage);
|
handleWebSocketFrame(fullMessage);
|
||||||
@ -1455,6 +1472,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
try {
|
try {
|
||||||
replyStream(ctx.frameReqId(), ctx.processingStreamId(), progress.snapshot(), false);
|
replyStream(ctx.frameReqId(), ctx.processingStreamId(), progress.snapshot(), false);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
// Reset the throttle window so the next delta retries the
|
||||||
|
// overwrite immediately instead of waiting out the min
|
||||||
|
// interval — a failed push means the bubble is stale.
|
||||||
|
lastFlushAt[0] = 0L;
|
||||||
log.debug("[wecom] progress overwrite failed: {}", e.getMessage());
|
log.debug("[wecom] progress overwrite failed: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}).blockLast(Duration.ofMinutes(10));
|
}).blockLast(Duration.ofMinutes(10));
|
||||||
@ -1516,10 +1537,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有 pending frame(用于 reply_stream 覆盖"思考中...")
|
// 无 frame 上下文的通用发送入口(cron/异步通知等主动推送场景)。
|
||||||
// sendMessage 被 renderAndSend 调用时,尝试用 reply_stream 覆盖
|
// 带 WeComReplyContext 的回复路径(renderAndSend / sendContentParts)
|
||||||
// 但由于 rawPayload 信息在 ChannelMessageRouter 层已丢失,
|
// 已直接绑定入站 frame 发送,不再落到这里;此处保留
|
||||||
// 这里走 send_message 主动推送路径
|
// send_message 主动推送 + 群聊缓存 reqId 兜底。
|
||||||
sendMessageToChat(targetId, content);
|
sendMessageToChat(targetId, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1655,6 +1676,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
&& !ctx.processingStreamId().isBlank()) {
|
&& !ctx.processingStreamId().isBlank()) {
|
||||||
replyStream(ctx.frameReqId(), ctx.processingStreamId(), segment, true);
|
replyStream(ctx.frameReqId(), ctx.processingStreamId(), segment, true);
|
||||||
first = false;
|
first = false;
|
||||||
|
} else if (ctx != null && ctx.frameReqId() != null && !ctx.frameReqId().isBlank()) {
|
||||||
|
// 后续分段绑定同一入站 frame 回复——群聊拒收主动推送,
|
||||||
|
// 走 frame 回复在群聊/单聊都可达且保持顺序
|
||||||
|
replyMarkdown(ctx.frameReqId(), segment);
|
||||||
} else {
|
} else {
|
||||||
sendMessage(targetId, segment);
|
sendMessage(targetId, segment);
|
||||||
}
|
}
|
||||||
@ -1773,6 +1798,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
&& !ctx.processingStreamId().isBlank()) {
|
&& !ctx.processingStreamId().isBlank()) {
|
||||||
replyStream(ctx.frameReqId(), ctx.processingStreamId(), rewritten, true);
|
replyStream(ctx.frameReqId(), ctx.processingStreamId(), rewritten, true);
|
||||||
firstText = false;
|
firstText = false;
|
||||||
|
} else if (ctx != null && ctx.frameReqId() != null
|
||||||
|
&& !ctx.frameReqId().isBlank()) {
|
||||||
|
// 后续文本绑定同一入站 frame 回复(群聊拒收主动推送)
|
||||||
|
replyMarkdown(ctx.frameReqId(), rewritten);
|
||||||
} else {
|
} else {
|
||||||
sendMessage(targetId, rewritten);
|
sendMessage(targetId, rewritten);
|
||||||
}
|
}
|
||||||
@ -2075,6 +2104,42 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
*/
|
*/
|
||||||
private final ConcurrentHashMap<String, String> streamLastContent = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, String> streamLastContent = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a markdown bubble bound to an inbound frame via
|
||||||
|
* {@code aibot_respond_msg}.
|
||||||
|
*
|
||||||
|
* <p>Used for reply segments after the first when a live
|
||||||
|
* {@link WeComReplyContext} exists: the platform rejects
|
||||||
|
* {@code aibot_send_msg} in group chats, so segments pushed actively
|
||||||
|
* would silently vanish there. Riding the inbound frame's reply slot
|
||||||
|
* works in both group and single chats, and keeps segment ordering
|
||||||
|
* behind the stream bubble (same per-reqId serial worker queue).
|
||||||
|
*
|
||||||
|
* <p>Failures are logged, not propagated — one bad segment must not
|
||||||
|
* abort the remaining segments of a long reply.
|
||||||
|
*/
|
||||||
|
private void replyMarkdown(String frameReqId, String content) {
|
||||||
|
if (frameReqId == null || frameReqId.isBlank()
|
||||||
|
|| content == null || content.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = Map.of(
|
||||||
|
"msgtype", "markdown",
|
||||||
|
"markdown", Map.of("content", content)
|
||||||
|
);
|
||||||
|
Map<String, Object> frame = Map.of(
|
||||||
|
"cmd", CMD_RESPONSE,
|
||||||
|
"headers", Map.of("req_id", frameReqId),
|
||||||
|
"body", body
|
||||||
|
);
|
||||||
|
sendFrameWithAck(frameReqId, frame);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[wecom] Failed to send reply segment via frame {}: {}",
|
||||||
|
frameReqId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 上传大小预校验(WeCom 平台限制) ====================
|
// ==================== 上传大小预校验(WeCom 平台限制) ====================
|
||||||
|
|
||||||
/** WeCom hard limits — verified empirically; sources differ slightly. */
|
/** WeCom hard limits — verified empirically; sources differ slightly. */
|
||||||
@ -2252,6 +2317,16 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
sendFrameWithAck(eventReqId, frame);
|
sendFrameWithAck(eventReqId, frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL for the resolved approval card's mandatory {@code card_action}
|
||||||
|
* link (WeCom rejects {@code text_notice} cards without a type-1/2
|
||||||
|
* action). Reads channel config {@code card_action_url}; public so the
|
||||||
|
* card handler (different package) can pass it to the renderer.
|
||||||
|
*/
|
||||||
|
public String resolvedCardActionUrl() {
|
||||||
|
return getConfigString("card_action_url", ToolGuardCardRenderer.DEFAULT_CARD_ACTION_URL);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keepalive refresh tick (called by {@link WeComKeepaliveScheduler}
|
* Keepalive refresh tick (called by {@link WeComKeepaliveScheduler}
|
||||||
* every 20s). Sends {@code finish=false} on the existing stream so
|
* every 20s). Sends {@code finish=false} on the existing stream so
|
||||||
@ -2262,6 +2337,13 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea
|
|||||||
* not be triggering refresh ticks.
|
* not be triggering refresh ticks.
|
||||||
*/
|
*/
|
||||||
public void replyStreamRefreshForKeepalive(String reqId, String streamId, String text) {
|
public void replyStreamRefreshForKeepalive(String reqId, String streamId, String text) {
|
||||||
|
// Bypass chunk dedup: a refresh whose text equals the previous chunk
|
||||||
|
// (e.g. the static placeholder when no progress supplier is attached)
|
||||||
|
// would otherwise be swallowed by replyStream's dedup guard — no
|
||||||
|
// network frame goes out, the server-side TTL is NOT reset, and the
|
||||||
|
// slot dies exactly the way this keepalive exists to prevent. Clearing
|
||||||
|
// the dedup slot first guarantees every tick produces a real frame.
|
||||||
|
streamLastContent.remove(streamId);
|
||||||
replyStream(reqId, streamId, text, false);
|
replyStream(reqId, streamId, text, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,9 +48,17 @@ public class WeComKeepaliveScheduler {
|
|||||||
/** Hard ceiling — after this many seconds, force-finish the stream. */
|
/** Hard ceiling — after this many seconds, force-finish the stream. */
|
||||||
static final long MAX_DURATION_SECONDS = 180;
|
static final long MAX_DURATION_SECONDS = 180;
|
||||||
|
|
||||||
/** Placeholder text written on every refresh tick + on force-finish. */
|
/** Placeholder text written on every refresh tick (when no live progress supplier is attached). */
|
||||||
static final String PROCESSING_TEXT = "🤔 思考中...";
|
static final String PROCESSING_TEXT = "🤔 思考中...";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text written when the 180s ceiling force-finishes the stream. The real
|
||||||
|
* answer will arrive later as a separate pushed bubble (the reply context
|
||||||
|
* is invalidated below), so the sealed bubble must tell the user the
|
||||||
|
* reply is still coming — freezing it on "思考中..." reads as a hang.
|
||||||
|
*/
|
||||||
|
static final String FORCE_FINISH_TEXT = "⏳ 任务耗时较长,仍在处理中,结果稍后送达";
|
||||||
|
|
||||||
/** One-shot state per active stream. Held by reference inside the scheduled task. */
|
/** One-shot state per active stream. Held by reference inside the scheduled task. */
|
||||||
private static final class StreamState {
|
private static final class StreamState {
|
||||||
final WeComChannelAdapter adapter;
|
final WeComChannelAdapter adapter;
|
||||||
@ -159,7 +167,7 @@ public class WeComKeepaliveScheduler {
|
|||||||
// replyContext entry so the eventual real reply takes the
|
// replyContext entry so the eventual real reply takes the
|
||||||
// fresh-stream path.
|
// fresh-stream path.
|
||||||
try {
|
try {
|
||||||
st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT);
|
st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, FORCE_FINISH_TEXT);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("[wecom-keepalive] force-finish replyStream failed for {}: {}",
|
log.debug("[wecom-keepalive] force-finish replyStream failed for {}: {}",
|
||||||
st.streamId, e.getMessage());
|
st.streamId, e.getMessage());
|
||||||
|
|||||||
@ -199,7 +199,8 @@ public class ToolGuardCardHandler implements WeComCardHandler {
|
|||||||
: "Tool " + toolName + " 已拒绝";
|
: "Tool " + toolName + " 已拒绝";
|
||||||
try {
|
try {
|
||||||
adapter.updateTemplateCard(eventReqId,
|
adapter.updateTemplateCard(eventReqId,
|
||||||
ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc));
|
ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc,
|
||||||
|
adapter.resolvedCardActionUrl()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[wecom-toolguard] update_template_card (resolved) failed: {}", e.getMessage());
|
log.warn("[wecom-toolguard] update_template_card (resolved) failed: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
@ -212,7 +213,8 @@ public class ToolGuardCardHandler implements WeComCardHandler {
|
|||||||
adapter.updateTemplateCard(eventReqId,
|
adapter.updateTemplateCard(eventReqId,
|
||||||
ToolGuardCardRenderer.buildResolvedCard(taskId,
|
ToolGuardCardRenderer.buildResolvedCard(taskId,
|
||||||
"❌ 仅原请求者可审批",
|
"❌ 仅原请求者可审批",
|
||||||
"请由 " + requesterLabel + " 操作"));
|
"请由 " + requesterLabel + " 操作",
|
||||||
|
adapter.resolvedCardActionUrl()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[wecom-toolguard] update_template_card (unauthorised) failed: {}", e.getMessage());
|
log.warn("[wecom-toolguard] update_template_card (unauthorised) failed: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
@ -224,7 +226,8 @@ public class ToolGuardCardHandler implements WeComCardHandler {
|
|||||||
adapter.updateTemplateCard(eventReqId,
|
adapter.updateTemplateCard(eventReqId,
|
||||||
ToolGuardCardRenderer.buildResolvedCard(taskId,
|
ToolGuardCardRenderer.buildResolvedCard(taskId,
|
||||||
"⌛ 审批已过期",
|
"⌛ 审批已过期",
|
||||||
"Tool " + toolName + " 的审批已过期或被处理"));
|
"Tool " + toolName + " 的审批已过期或被处理",
|
||||||
|
adapter.resolvedCardActionUrl()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[wecom-toolguard] update_template_card (expired) failed: {}", e.getMessage());
|
log.warn("[wecom-toolguard] update_template_card (expired) failed: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -99,13 +99,29 @@ public class ToolGuardCardRenderer implements WeComCardRenderer {
|
|||||||
* to stay inside WeCom's main_title.desc limit
|
* to stay inside WeCom's main_title.desc limit
|
||||||
*/
|
*/
|
||||||
public static Map<String, Object> buildResolvedCard(String taskId, String title, String desc) {
|
public static Map<String, Object> buildResolvedCard(String taskId, String title, String desc) {
|
||||||
|
return buildResolvedCard(taskId, title, desc, DEFAULT_CARD_ACTION_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for the mandatory {@code card_action} link when the channel
|
||||||
|
* config doesn't provide one ({@code card_action_url}).
|
||||||
|
*/
|
||||||
|
public static final String DEFAULT_CARD_ACTION_URL = "https://mateclaw.vip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #buildResolvedCard(String, String, String)} but with an
|
||||||
|
* explicit {@code card_action} URL (per-channel configurable).
|
||||||
|
*/
|
||||||
|
public static Map<String, Object> buildResolvedCard(String taskId, String title, String desc,
|
||||||
|
String actionUrl) {
|
||||||
Map<String, Object> mainTitle = new LinkedHashMap<>();
|
Map<String, Object> mainTitle = new LinkedHashMap<>();
|
||||||
mainTitle.put("title", title == null ? "" : title);
|
mainTitle.put("title", title == null ? "" : title);
|
||||||
mainTitle.put("desc", truncate(desc == null ? "" : desc, 30));
|
mainTitle.put("desc", truncate(desc == null ? "" : desc, 30));
|
||||||
|
|
||||||
Map<String, Object> cardAction = new LinkedHashMap<>();
|
Map<String, Object> cardAction = new LinkedHashMap<>();
|
||||||
cardAction.put("type", 1);
|
cardAction.put("type", 1);
|
||||||
cardAction.put("url", "https://mateclaw.vip");
|
cardAction.put("url", (actionUrl == null || actionUrl.isBlank())
|
||||||
|
? DEFAULT_CARD_ACTION_URL : actionUrl);
|
||||||
|
|
||||||
Map<String, Object> card = new LinkedHashMap<>();
|
Map<String, Object> card = new LinkedHashMap<>();
|
||||||
card.put("card_type", "text_notice");
|
card.put("card_type", "text_notice");
|
||||||
|
|||||||
@ -136,6 +136,26 @@ class ReplyStreamDedupTest {
|
|||||||
"dedup memory must be per-streamId — same content on a different stream still dispatches");
|
"dedup memory must be per-streamId — same content on a different stream still dispatches");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("keepalive refresh bypasses dedup — identical placeholder text still dispatches")
|
||||||
|
void keepaliveRefreshBypassesDedup() throws Exception {
|
||||||
|
Method m = WeComChannelAdapter.class.getDeclaredMethod(
|
||||||
|
"replyStream", String.class, String.class, String.class, boolean.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
// Initial placeholder chunk primes the dedup slot with this exact text.
|
||||||
|
m.invoke(adapter, "rid", "stream-1", "🤔 思考中...", false);
|
||||||
|
// Keepalive ticks re-send the SAME static placeholder. If they were
|
||||||
|
// deduplicated, no frame would reach the server and the stream slot's
|
||||||
|
// TTL would never reset — the exact failure keepalive exists to prevent.
|
||||||
|
adapter.replyStreamRefreshForKeepalive("rid", "stream-1", "🤔 思考中...");
|
||||||
|
adapter.replyStreamRefreshForKeepalive("rid", "stream-1", "🤔 思考中...");
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
assertNotNull(sentFrames.poll(500, TimeUnit.MILLISECONDS),
|
||||||
|
"expected frame #" + (i + 1) + " — keepalive refreshes must not be deduplicated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("after finish=true, the dedup slot is cleared so the next stream with same content goes")
|
@DisplayName("after finish=true, the dedup slot is cleared so the next stream with same content goes")
|
||||||
void finishClearsDedupSlot() throws Exception {
|
void finishClearsDedupSlot() throws Exception {
|
||||||
|
|||||||
@ -16,8 +16,8 @@ import static org.mockito.Mockito.*;
|
|||||||
/**
|
/**
|
||||||
* Verify the WeComKeepaliveScheduler bookkeeping + force-finish path.
|
* Verify the WeComKeepaliveScheduler bookkeeping + force-finish path.
|
||||||
*
|
*
|
||||||
* <p>The 20s/180s timing constants come from QwenPaw and are already
|
* <p>The 20s/180s timing constants were chosen against the empirically
|
||||||
* validated empirically in production; we don't re-test the exact
|
* observed stream-slot TTL and are validated in production; we don't re-test the exact
|
||||||
* scheduling intervals here (would require either real wall-clock waits
|
* scheduling intervals here (would require either real wall-clock waits
|
||||||
* or invasive ScheduledExecutor mocking). Instead we cover:
|
* or invasive ScheduledExecutor mocking). Instead we cover:
|
||||||
* <ul>
|
* <ul>
|
||||||
@ -114,7 +114,7 @@ class WeComKeepaliveSchedulerTest {
|
|||||||
tick.invoke(scheduler, state);
|
tick.invoke(scheduler, state);
|
||||||
|
|
||||||
verify(adapter, times(1)).replyStreamFinishForKeepalive(
|
verify(adapter, times(1)).replyStreamFinishForKeepalive(
|
||||||
eq("req-x"), eq("stream-x"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT));
|
eq("req-x"), eq("stream-x"), eq(WeComKeepaliveScheduler.FORCE_FINISH_TEXT));
|
||||||
verify(adapter, times(1)).invalidateReplyContext(eq("user-alice"), eq("stream-x"));
|
verify(adapter, times(1)).invalidateReplyContext(eq("user-alice"), eq("stream-x"));
|
||||||
verify(adapter, never()).replyStreamRefreshForKeepalive(any(), any(), any());
|
verify(adapter, never()).replyStreamRefreshForKeepalive(any(), any(), any());
|
||||||
// After force-finish, the stream is removed from the tracker
|
// After force-finish, the stream is removed from the tracker
|
||||||
@ -142,11 +142,15 @@ class WeComKeepaliveSchedulerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("constants match the QwenPaw-verified values (20s refresh / 180s ceiling)")
|
@DisplayName("constants match the empirically-verified values (20s refresh / 180s ceiling)")
|
||||||
void constantsMatch() {
|
void constantsMatch() {
|
||||||
assertEquals(20L, WeComKeepaliveScheduler.REFRESH_INTERVAL_SECONDS);
|
assertEquals(20L, WeComKeepaliveScheduler.REFRESH_INTERVAL_SECONDS);
|
||||||
assertEquals(180L, WeComKeepaliveScheduler.MAX_DURATION_SECONDS);
|
assertEquals(180L, WeComKeepaliveScheduler.MAX_DURATION_SECONDS);
|
||||||
assertEquals("🤔 思考中...", WeComKeepaliveScheduler.PROCESSING_TEXT);
|
assertEquals("🤔 思考中...", WeComKeepaliveScheduler.PROCESSING_TEXT);
|
||||||
|
// Force-finish must NOT reuse the processing placeholder — the sealed
|
||||||
|
// bubble is the last thing the user sees until the pushed reply lands.
|
||||||
|
assertNotEquals(WeComKeepaliveScheduler.PROCESSING_TEXT,
|
||||||
|
WeComKeepaliveScheduler.FORCE_FINISH_TEXT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull a tracked StreamState by streamId via reflection. The states map
|
// Pull a tracked StreamState by streamId via reflection. The states map
|
||||||
|
|||||||
@ -141,6 +141,44 @@ class WeComProcessStreamTest {
|
|||||||
"default config must not leave standalone tool messages");
|
"default config must not leave standalone tool messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multi-segment reply: segments after the first ride the inbound frame, not proactive push")
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void multiSegmentRidesInboundFrame() throws Exception {
|
||||||
|
TestableAdapter adapter = newAdapter("{}");
|
||||||
|
seedReplyContext(adapter, "alice", "req-1", "stream-1");
|
||||||
|
|
||||||
|
// Long enough to exceed the 2048-char platform limit → at least 2 segments.
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < 80; i++) {
|
||||||
|
sb.append("第").append(i)
|
||||||
|
.append("行:这是一段足够长的中文内容,用来撑破企业微信单条消息的长度上限,验证分段发送路径。\n");
|
||||||
|
}
|
||||||
|
adapter.renderAndSend("alice", sb.toString());
|
||||||
|
|
||||||
|
List<Map<String, Object>> frames = adapter.drainFrames();
|
||||||
|
assertTrue(frames.size() >= 2, "expected >= 2 outbound frames, got " + frames.size());
|
||||||
|
|
||||||
|
// Segment 1 overwrites the stream bubble with finish=true.
|
||||||
|
Map<String, Object> firstBody = (Map<String, Object>) frames.get(0).get("body");
|
||||||
|
assertEquals("stream", firstBody.get("msgtype"), "first segment must close the stream bubble");
|
||||||
|
|
||||||
|
// Segments 2+ must be markdown replies bound to the SAME inbound frame —
|
||||||
|
// aibot_send_msg is rejected in group chats, so any proactive push here
|
||||||
|
// would silently lose the segment for group users.
|
||||||
|
for (int i = 1; i < frames.size(); i++) {
|
||||||
|
Map<String, Object> frame = frames.get(i);
|
||||||
|
assertEquals("aibot_respond_msg", frame.get("cmd"),
|
||||||
|
"segment #" + (i + 1) + " must ride the inbound frame reply slot");
|
||||||
|
Map<String, Object> headers = (Map<String, Object>) frame.get("headers");
|
||||||
|
assertEquals("req-1", headers.get("req_id"));
|
||||||
|
Map<String, Object> body = (Map<String, Object>) frame.get("body");
|
||||||
|
assertEquals("markdown", body.get("msgtype"));
|
||||||
|
}
|
||||||
|
assertTrue(frames.stream().noneMatch(f -> "aibot_send_msg".equals(f.get("cmd"))),
|
||||||
|
"no segment may fall back to proactive push while a reply context exists");
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== helpers ====================
|
// ==================== helpers ====================
|
||||||
|
|
||||||
private static ChannelMessage inbound(String sender) {
|
private static ChannelMessage inbound(String sender) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user