mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(channel): wecom approval card + keepalive + chunk dedup
This commit is contained in:
parent
20393ea679
commit
225d14026b
@ -48,7 +48,7 @@ public class ChannelManager {
|
||||
|
||||
/**
|
||||
* Approval notification renderer — used by WeCom adapter (PR-0
|
||||
* threading; PR-1 will switch the WeCom override to render a
|
||||
* threading; PR-1 wired the WeCom override to render a
|
||||
* {@code button_interaction} card via this service's card builder).
|
||||
* Other adapters keep using the text path on
|
||||
* {@link AbstractChannelAdapter}, which calls
|
||||
@ -57,6 +57,20 @@ public class ChannelManager {
|
||||
*/
|
||||
private final vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService;
|
||||
|
||||
/**
|
||||
* WeCom interactive card dispatcher (PR-1). Drives the
|
||||
* {@code button_interaction} approval card render + the inbound
|
||||
* {@code template_card_event} routing.
|
||||
*/
|
||||
private final vip.mate.channel.wecom.cards.WeComCardDispatcher weComCardDispatcher;
|
||||
|
||||
/**
|
||||
* WeCom keepalive scheduler (PR-1). Refreshes the "🤔 思考中..."
|
||||
* placeholder every 20s and force-finishes after 180s so long-
|
||||
* running agent tasks don't lose their stream slot.
|
||||
*/
|
||||
private final vip.mate.channel.wecom.WeComKeepaliveScheduler weComKeepaliveScheduler;
|
||||
|
||||
/** 运行中的渠道适配器:channelId -> adapter */
|
||||
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
|
||||
|
||||
@ -467,7 +481,7 @@ public class ChannelManager {
|
||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
||||
approvalNotificationService);
|
||||
approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler);
|
||||
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
|
||||
|
||||
@ -564,9 +564,13 @@ public class ChannelMessageRouter {
|
||||
// 检查 chat 过程中是否产生了审批 pending
|
||||
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
||||
if (newPending != null) {
|
||||
// 有审批需求:不保存 LLM 的审批占位回复到 DB,直接从 pending 元数据构建通知
|
||||
String approvalNotice = buildApprovalNotice(newPending);
|
||||
adapter.renderAndSend(replyTarget, approvalNotice);
|
||||
// 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 {
|
||||
@ -694,7 +698,11 @@ public class ChannelMessageRouter {
|
||||
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
||||
if (newPending != null) {
|
||||
String replyTarget = resolveReplyTarget(message);
|
||||
streamingAdapter.sendMessage(replyTarget, buildApprovalNotice(newPending));
|
||||
// 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()) {
|
||||
|
||||
@ -83,6 +83,13 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
private static final String CMD_HEARTBEAT = "ping";
|
||||
private static final String CMD_RESPONSE = "aibot_respond_msg";
|
||||
private static final String CMD_RESPONSE_WELCOME = "aibot_respond_welcome_msg";
|
||||
/**
|
||||
* Update an interactive template card. Source-verified against the
|
||||
* aibot SDK at {@code aibot/types.py:81} (RESPONSE_UPDATE constant)
|
||||
* — used by {@link #updateTemplateCard} to replace a posted card
|
||||
* within the 5-second window WeCom enforces after a button click.
|
||||
*/
|
||||
private static final String CMD_RESPONSE_UPDATE = "aibot_respond_update_msg";
|
||||
private static final String CMD_SEND_MSG = "aibot_send_msg";
|
||||
private static final String CMD_CALLBACK = "aibot_msg_callback";
|
||||
private static final String CMD_EVENT_CALLBACK = "aibot_event_callback";
|
||||
@ -203,25 +210,46 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
private final AtomicBoolean disconnectInflight = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* Optional approval-notification renderer.
|
||||
*
|
||||
* <p>Wired in PR-0 for forward compatibility. PR-1 will use this to
|
||||
* build {@code button_interaction} card payloads for tool-guard
|
||||
* approvals; PR-0 keeps the field but does not consume it (the
|
||||
* default text path on {@link AbstractChannelAdapter#sendApprovalNotice}
|
||||
* still handles approvals). {@code null}-tolerant: if Spring DI
|
||||
* cannot find the bean (unit-test contexts, hot-swap edge cases,
|
||||
* etc.) the adapter still functions in PR-0 fashion.
|
||||
* 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 in PR-1
|
||||
@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).
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
public WeComChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper,
|
||||
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService) {
|
||||
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
|
||||
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
|
||||
WeComKeepaliveScheduler keepaliveScheduler) {
|
||||
super(channelEntity, messageRouter, objectMapper);
|
||||
this.approvalNotificationService = approvalNotificationService;
|
||||
this.cardDispatcher = cardDispatcher;
|
||||
this.keepaliveScheduler = keepaliveScheduler;
|
||||
// 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).
|
||||
@ -383,6 +411,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
// ---- 其他 per-connection 状态 ----
|
||||
pendingFrames.clear();
|
||||
replyContexts.clear();
|
||||
streamLastContent.clear();
|
||||
if (keepaliveScheduler != null) {
|
||||
keepaliveScheduler.shutdownAll();
|
||||
}
|
||||
missedPongCount.set(0);
|
||||
|
||||
this.httpClient = null;
|
||||
@ -995,6 +1027,19 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
String replyToken = isGroup ? chatId : senderId;
|
||||
replyContexts.put(replyToken, new WeComReplyContext(frameReqId, processingStreamId));
|
||||
|
||||
// PR-1: launch keepalive for the processing stream so long-running agent
|
||||
// tasks (>60s) keep their stream slot alive — without this, WeCom's
|
||||
// server-side TTL drops the slot and the eventual real reply gets
|
||||
// silently rejected. RFC-32 §2.1.2 / R-7 / B-5.
|
||||
if (keepaliveScheduler != null
|
||||
&& processingStreamId != null && !processingStreamId.isBlank()) {
|
||||
try {
|
||||
keepaliveScheduler.start(this, frameReqId, processingStreamId, replyToken);
|
||||
} catch (Exception e) {
|
||||
log.debug("[wecom] keepalive start failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[wecom] Received message: sender={}, chatType={}, msgType={}, textLen={}",
|
||||
senderId.length() > 20 ? senderId.substring(0, 20) : senderId,
|
||||
chatType, msgType,
|
||||
@ -1032,14 +1077,122 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("template_card_event".equals(eventType)) {
|
||||
handleTemplateCardEvent(frame, body, event);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("[wecom] Ignoring event type: {}", eventType);
|
||||
} catch (Exception e) {
|
||||
log.error("[wecom] Failed to handle event callback: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an inbound {@code template_card_event} (a button click on a
|
||||
* card we previously sent) to the correct
|
||||
* {@link vip.mate.channel.wecom.cards.WeComCardKind} based on the
|
||||
* task_id prefix. Each card kind owns its own validation +
|
||||
* resolved-state render + command-injection logic.
|
||||
*
|
||||
* <p><b>5-second window</b>: WeCom requires the
|
||||
* {@code aibot_respond_update_msg} for this event to be sent inside
|
||||
* 5s. Handlers therefore run synchronously here; the heavy work
|
||||
* (e.g. agent re-execution) is deferred to the router's normal
|
||||
* processMessage path via {@link #injectSyntheticMessage}.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleTemplateCardEvent(Map<String, Object> frame,
|
||||
Map<String, Object> body,
|
||||
Map<String, Object> event) {
|
||||
if (cardDispatcher == null) {
|
||||
log.debug("[wecom] template_card_event ignored: dispatcher not wired");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> tce = event.get("template_card_event") instanceof Map<?, ?> m
|
||||
? (Map<String, Object>) m
|
||||
: (Map<String, Object>) event; // some firmware nests directly under event
|
||||
String taskId = (String) tce.getOrDefault("task_id", "");
|
||||
if (taskId.isBlank()) {
|
||||
log.debug("[wecom] template_card_event missing task_id, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
var kindOpt = cardDispatcher.lookupByTaskId(taskId);
|
||||
if (kindOpt.isEmpty()) {
|
||||
log.warn("[wecom] No registered card kind matches task_id={}, ignoring", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> fromBlock = body.get("from") instanceof Map<?, ?> fm
|
||||
? (Map<String, Object>) fm
|
||||
: Map.of();
|
||||
try {
|
||||
kindOpt.get().handler().handle(this, frame, tce, fromBlock);
|
||||
} catch (Exception e) {
|
||||
log.error("[wecom] template_card_event handler ({}) threw: {}",
|
||||
kindOpt.get().name(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 消息发送 ====================
|
||||
|
||||
/**
|
||||
* Render an approval notice as a WeCom {@code button_interaction}
|
||||
* card and post it via the active reply context, instead of the
|
||||
* abstract-class default text path.
|
||||
*
|
||||
* <p>Falls back to {@code super.sendApprovalNotice} (markdown text)
|
||||
* in three failure modes:
|
||||
* <ol>
|
||||
* <li>No card dispatcher available (DI did not wire it — usually
|
||||
* a test / hot-swap context)</li>
|
||||
* <li>No active {@link WeComReplyContext} for {@code targetId} —
|
||||
* proactive paths (cron without a recent inbound message)
|
||||
* cannot post a card because WeCom AI Bots reject
|
||||
* {@code aibot_send_msg + template_card}; fall back to text
|
||||
* so the user still sees the approval</li>
|
||||
* <li>{@link CardOversizedException} thrown by the renderer
|
||||
* (button.key payload > 1024 bytes)</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The card is sent via {@link #replyTemplateCard} bound to the
|
||||
* inbound frame's {@code req_id} that
|
||||
* {@link #handleMessageCallback} stashed in {@link #replyContexts}.
|
||||
*/
|
||||
@Override
|
||||
public void sendApprovalNotice(String targetId,
|
||||
vip.mate.channel.notification.ApprovalNotice notice) {
|
||||
if (cardDispatcher == null) {
|
||||
super.sendApprovalNotice(targetId, notice);
|
||||
return;
|
||||
}
|
||||
WeComReplyContext ctx = replyContexts.get(targetId);
|
||||
if (ctx == null || ctx.frameReqId() == null || ctx.frameReqId().isBlank()) {
|
||||
// No bound reply context — fall back to text. Most common in
|
||||
// proactive paths (cron-triggered approvals) which WeCom AI
|
||||
// Bot rejects for cards anyway.
|
||||
super.sendApprovalNotice(targetId, notice);
|
||||
return;
|
||||
}
|
||||
var kindOpt = cardDispatcher.lookupByMessageType(
|
||||
vip.mate.channel.wecom.cards.tool_guard.ToolGuardCardKindFactory.MESSAGE_TYPE);
|
||||
if (kindOpt.isEmpty()) {
|
||||
super.sendApprovalNotice(targetId, notice);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> card = kindOpt.get().renderer().render(notice);
|
||||
replyTemplateCard(ctx.frameReqId(), card);
|
||||
} catch (vip.mate.channel.wecom.cards.CardOversizedException oversized) {
|
||||
log.warn("[wecom] approval card oversized, falling back to text: {}", oversized.getMessage());
|
||||
super.sendApprovalNotice(targetId, notice);
|
||||
} catch (Exception e) {
|
||||
log.warn("[wecom] approval card render/send failed, falling back to text: {}", e.getMessage());
|
||||
super.sendApprovalNotice(targetId, notice);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessage(String targetId, String content) {
|
||||
if (webSocket == null) {
|
||||
@ -1083,6 +1236,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
public void renderAndSend(String targetId, String content) {
|
||||
// 消费回复上下文(如果有的话)
|
||||
WeComReplyContext ctx = replyContexts.remove(targetId);
|
||||
// Stop keepalive before we send the real reply: avoids racing the next
|
||||
// refresh tick against this finish=true chunk on the same stream.
|
||||
// No-op if force-finish already evicted the entry.
|
||||
if (keepaliveScheduler != null && ctx != null && ctx.processingStreamId() != null) {
|
||||
keepaliveScheduler.stop(ctx.processingStreamId());
|
||||
}
|
||||
|
||||
// 先进行正常的内容渲染(过滤 thinking、分割长文本)
|
||||
boolean filterThinking = getConfigBoolean("filter_thinking", true);
|
||||
@ -1111,6 +1270,9 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
@Override
|
||||
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
|
||||
WeComReplyContext ctx = replyContexts.remove(targetId);
|
||||
if (keepaliveScheduler != null && ctx != null && ctx.processingStreamId() != null) {
|
||||
keepaliveScheduler.stop(ctx.processingStreamId());
|
||||
}
|
||||
boolean sentText = false;
|
||||
boolean firstText = true;
|
||||
|
||||
@ -1324,6 +1486,24 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
*/
|
||||
private void replyStream(String originalReqId, String streamId, String content,
|
||||
boolean finish, String feedbackId) {
|
||||
// PR-1 chunk dedup: skip the network round-trip when a non-final chunk
|
||||
// has the exact same content as the previous one for the same streamId.
|
||||
// Tool-call argument streaming in particular emits many redundant chunks
|
||||
// (each token re-flushes the partial JSON args) that would otherwise
|
||||
// flicker the IM client. The final chunk (finish=true) ALWAYS goes
|
||||
// through so WeCom closes the slot cleanly. RFC-32 §2.1.3.
|
||||
if (!finish) {
|
||||
String content_safe = content == null ? "" : content;
|
||||
String prev = streamLastContent.get(streamId);
|
||||
if (content_safe.equals(prev)) {
|
||||
return;
|
||||
}
|
||||
streamLastContent.put(streamId, content_safe);
|
||||
} else {
|
||||
// Final chunk consumes the dedup slot.
|
||||
streamLastContent.remove(streamId);
|
||||
}
|
||||
|
||||
Map<String, Object> streamBody = new LinkedHashMap<>();
|
||||
streamBody.put("id", streamId);
|
||||
streamBody.put("finish", finish);
|
||||
@ -1346,6 +1526,13 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
sendFrameWithAck(originalReqId, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-streamId last-content cache for chunk dedup. Bounded only by
|
||||
* the number of in-flight streams (a small handful in practice);
|
||||
* cleared on each finish=true chunk and on connection release.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, String> streamLastContent = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 发送欢迎消息
|
||||
*/
|
||||
@ -1362,6 +1549,139 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
sendFrameWithAck(reqId, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an interactive template card (e.g. button_interaction approval card).
|
||||
*
|
||||
* <p>Wraps the card payload in {@code msgtype=template_card} and routes via
|
||||
* the existing reply channel ({@code aibot_respond_msg}, bound to the inbound
|
||||
* frame's req_id). Source-verified against aibot SDK
|
||||
* {@code client.py:188-207 reply_template_card}.
|
||||
*
|
||||
* <p>Caller must have an active reply context for {@code reqId} — i.e. the
|
||||
* card is sent in response to a previously received message frame, not as a
|
||||
* proactive group push (which WeCom rejects for AI Bots, see RFC-32 G-12).
|
||||
*
|
||||
* @param reqId the original inbound frame's {@code headers.req_id}
|
||||
* @param templateCard the WeCom template_card payload (card_type / task_id /
|
||||
* main_title / button_list / etc.)
|
||||
*/
|
||||
public void replyTemplateCard(String reqId, Map<String, Object> templateCard) {
|
||||
Map<String, Object> body = Map.of(
|
||||
"msgtype", "template_card",
|
||||
"template_card", templateCard
|
||||
);
|
||||
Map<String, Object> frame = Map.of(
|
||||
"cmd", CMD_RESPONSE,
|
||||
"headers", Map.of("req_id", reqId),
|
||||
"body", body
|
||||
);
|
||||
sendFrameWithAck(reqId, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a previously-posted template card. Used by inbound
|
||||
* {@code template_card_event} handlers (e.g. tool-guard approval) to swap
|
||||
* the {@code button_interaction} card for a {@code text_notice} resolved
|
||||
* state once the user clicks a button.
|
||||
*
|
||||
* <p><b>5-second window</b>: per the aibot protocol, the response must be
|
||||
* sent within 5s of receiving the {@code template_card_event} frame —
|
||||
* otherwise the update is silently dropped. The handler path therefore
|
||||
* has to validate identity + render the new card synchronously (fast
|
||||
* DB lookup + map construction, well under 1ms) and only enqueue the
|
||||
* inject-command on the agent thread afterwards.
|
||||
*
|
||||
* <p>Source-verified against aibot SDK {@code client.py:260-284 update_template_card}.
|
||||
*
|
||||
* @param eventReqId the inbound {@code template_card_event} frame's req_id
|
||||
* (DIFFERENT from the original card-posting req_id)
|
||||
* @param templateCard the replacement card payload (same task_id as the
|
||||
* original card)
|
||||
*/
|
||||
public void updateTemplateCard(String eventReqId, Map<String, Object> templateCard) {
|
||||
Map<String, Object> body = Map.of(
|
||||
"response_type", "update_template_card",
|
||||
"template_card", templateCard
|
||||
);
|
||||
Map<String, Object> frame = Map.of(
|
||||
"cmd", CMD_RESPONSE_UPDATE,
|
||||
"headers", Map.of("req_id", eventReqId),
|
||||
"body", body
|
||||
);
|
||||
sendFrameWithAck(eventReqId, frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keepalive refresh tick (called by {@link WeComKeepaliveScheduler}
|
||||
* every 20s). Sends {@code finish=false} on the existing stream so
|
||||
* WeCom's server-side TTL counter resets.
|
||||
*
|
||||
* <p>Public so the scheduler in this same package can invoke it; the
|
||||
* scheduler is itself a singleton bean and outside callers should
|
||||
* not be triggering refresh ticks.
|
||||
*/
|
||||
public void replyStreamRefreshForKeepalive(String reqId, String streamId, String text) {
|
||||
replyStream(reqId, streamId, text, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-finish the keepalive stream (180s ceiling reached). Sends
|
||||
* {@code finish=true} so WeCom closes the slot cleanly. The
|
||||
* scheduler immediately follows this with
|
||||
* {@link #invalidateReplyContext} so the eventual real reply takes
|
||||
* the fresh-stream path.
|
||||
*/
|
||||
public void replyStreamFinishForKeepalive(String reqId, String streamId, String text) {
|
||||
replyStream(reqId, streamId, text, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the {@link WeComReplyContext} entry for a {@code targetId}
|
||||
* if (and only if) its current {@code processingStreamId} matches
|
||||
* the supplied {@code streamId}. Idempotent and safe to call from
|
||||
* any thread.
|
||||
*
|
||||
* <p>Used by {@link WeComKeepaliveScheduler} after force-finishing
|
||||
* a stuck stream — RFC-32 §2.1.2 invariant: the next
|
||||
* {@link #renderAndSend} call must NOT reuse a finished
|
||||
* {@code processingStreamId}.
|
||||
*
|
||||
* <p>The match-and-remove uses {@link
|
||||
* java.util.concurrent.ConcurrentHashMap#computeIfPresent} so a
|
||||
* concurrent {@code renderAndSend} that already swapped the
|
||||
* context for a fresh stream is left untouched.
|
||||
*/
|
||||
public void invalidateReplyContext(String targetId, String streamId) {
|
||||
if (targetId == null || streamId == null) return;
|
||||
replyContexts.computeIfPresent(targetId, (k, ctx) -> {
|
||||
if (streamId.equals(ctx.processingStreamId())) {
|
||||
log.debug("[wecom] invalidateReplyContext: cleared {} (stream={})", targetId, streamId);
|
||||
return null; // remove entry
|
||||
}
|
||||
return ctx;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a synthetic message into the standard
|
||||
* {@link ChannelMessageRouter} pipeline as if the user had typed it.
|
||||
*
|
||||
* <p>Bypasses {@link AbstractChannelAdapter#onMessage} so the
|
||||
* pre-flight bot-prefix filter and access-control check are SKIPPED
|
||||
* — appropriate for events that already represent an explicit user
|
||||
* intent (e.g. a button click on an approval card). The router still
|
||||
* runs its own approval validation in
|
||||
* {@link ChannelMessageRouter#processMessage}, so the identity check
|
||||
* for "only original requester can approve" still fires.
|
||||
*
|
||||
* <p>Currently used by tool-guard card handler. Package-private (no
|
||||
* modifier) so only sibling classes in the wecom package can inject;
|
||||
* external code must go through {@link ChannelAdapter#onMessage}.
|
||||
*/
|
||||
public void injectSyntheticMessage(ChannelMessage message) {
|
||||
messageRouter.enqueue(message, this, channelEntity);
|
||||
}
|
||||
|
||||
// ==================== 媒体上传协议 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Periodically refreshes a WeCom AI Bot {@code stream} reply with the
|
||||
* "🤔 思考中..." placeholder text so WeCom's server-side does not drop
|
||||
* the stream slot while a long-running agent task is still computing.
|
||||
*
|
||||
* <p><b>Why this exists</b>: WeCom's stream slot has an undocumented TTL
|
||||
* (empirically observed at ~60-120s of silence). When the slot drops,
|
||||
* the eventual {@code finish=true} chunk is silently rejected — the
|
||||
* user sees "🤔 思考中..." stuck forever. RFC-32 §2.1.2 (R-7 / B-5).
|
||||
*
|
||||
* <p><b>Constants</b> (verified against QwenPaw {@code channel.py:66-67,986-1031}):
|
||||
* <ul>
|
||||
* <li>20s refresh interval — well under the observed 60s minimum drop</li>
|
||||
* <li>180s force-finish ceiling — bound the worst-case "stuck" UX even
|
||||
* if the agent task hangs forever; lets the next user reply use a
|
||||
* fresh stream slot rather than reuse a closed one</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Force-finish invariant</b>: after the 180s ceiling fires, the
|
||||
* scheduler calls {@link WeComChannelAdapter#invalidateReplyContext}
|
||||
* to evict the {@code (frameReqId, processingStreamId)} pair from
|
||||
* {@code replyContexts} — so when the eventual real reply arrives,
|
||||
* {@code renderAndSend} sees no context and falls through to a fresh
|
||||
* {@code sendMessage} path instead of reusing the dead stream id.
|
||||
* RFC-32 §2.1.2 / R-7 closes this race; the adapter's
|
||||
* {@code invalidateReplyContext} is the contract.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WeComKeepaliveScheduler {
|
||||
|
||||
/** Refresh interval (seconds). */
|
||||
static final long REFRESH_INTERVAL_SECONDS = 20;
|
||||
|
||||
/** Hard ceiling — after this many seconds, force-finish the stream. */
|
||||
static final long MAX_DURATION_SECONDS = 180;
|
||||
|
||||
/** Placeholder text written on every refresh tick + on force-finish. */
|
||||
static final String PROCESSING_TEXT = "🤔 思考中...";
|
||||
|
||||
/** One-shot state per active stream. Held by reference inside the scheduled task. */
|
||||
private static final class StreamState {
|
||||
final WeComChannelAdapter adapter;
|
||||
final String reqId;
|
||||
final String streamId;
|
||||
final String replyToken;
|
||||
final long startedAt;
|
||||
volatile ScheduledFuture<?> future;
|
||||
StreamState(WeComChannelAdapter a, String r, String s, String t) {
|
||||
this.adapter = a; this.reqId = r; this.streamId = s; this.replyToken = t;
|
||||
this.startedAt = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, StreamState> states = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2, r -> {
|
||||
Thread t = new Thread(r, "wecom-keepalive");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
/**
|
||||
* Begin keepalive for a freshly-issued processing stream. No-op if
|
||||
* called twice for the same {@code streamId} (just logs and keeps
|
||||
* the existing schedule alive).
|
||||
*
|
||||
* @param adapter the live WeCom adapter (carries replyStream + invalidateReplyContext)
|
||||
* @param reqId the inbound message frame's req_id (binds the
|
||||
* outbound reply_stream chunks)
|
||||
* @param streamId the same stream_id used in the initial "🤔 思考中..." chunk
|
||||
* @param replyToken target id used to look up reply context on
|
||||
* invalidation (typically chatId for groups,
|
||||
* senderId for direct messages — same value passed
|
||||
* to {@code replyContexts.put})
|
||||
*/
|
||||
public void start(WeComChannelAdapter adapter, String reqId, String streamId, String replyToken) {
|
||||
if (adapter == null || reqId == null || streamId == null
|
||||
|| reqId.isBlank() || streamId.isBlank()) {
|
||||
log.debug("[wecom-keepalive] start ignored — null/blank arg(s)");
|
||||
return;
|
||||
}
|
||||
if (states.containsKey(streamId)) {
|
||||
log.debug("[wecom-keepalive] start ignored — stream {} already tracked", streamId);
|
||||
return;
|
||||
}
|
||||
StreamState st = new StreamState(adapter, reqId, streamId, replyToken);
|
||||
st.future = scheduler.scheduleAtFixedRate(
|
||||
() -> tick(st),
|
||||
REFRESH_INTERVAL_SECONDS,
|
||||
REFRESH_INTERVAL_SECONDS,
|
||||
TimeUnit.SECONDS);
|
||||
states.put(streamId, st);
|
||||
log.debug("[wecom-keepalive] started for stream={} reqId={}", streamId, reqId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop keepalive for a stream — call this immediately before sending
|
||||
* the real reply so the next refresh tick doesn't race the
|
||||
* {@code finish=true} chunk on the same stream.
|
||||
*/
|
||||
public void stop(String streamId) {
|
||||
if (streamId == null || streamId.isBlank()) return;
|
||||
StreamState st = states.remove(streamId);
|
||||
if (st != null && st.future != null) {
|
||||
st.future.cancel(false);
|
||||
log.debug("[wecom-keepalive] stopped for stream={}", streamId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every tracked stream and cancel its schedule. Called from
|
||||
* {@code releaseConnectionResources} so reconnects start clean.
|
||||
*/
|
||||
public void shutdownAll() {
|
||||
for (StreamState st : states.values()) {
|
||||
if (st.future != null) st.future.cancel(false);
|
||||
}
|
||||
states.clear();
|
||||
}
|
||||
|
||||
private void tick(StreamState st) {
|
||||
long elapsedSec = (System.currentTimeMillis() - st.startedAt) / 1000;
|
||||
if (elapsedSec >= MAX_DURATION_SECONDS) {
|
||||
// Force-finish: send finish=true on the same stream so the
|
||||
// server-side closes the slot cleanly, then evict the
|
||||
// replyContext entry so the eventual real reply takes the
|
||||
// fresh-stream path.
|
||||
try {
|
||||
st.adapter.replyStreamFinishForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT);
|
||||
} catch (Exception e) {
|
||||
log.debug("[wecom-keepalive] force-finish replyStream failed for {}: {}",
|
||||
st.streamId, e.getMessage());
|
||||
}
|
||||
try {
|
||||
st.adapter.invalidateReplyContext(st.replyToken, st.streamId);
|
||||
} catch (Exception e) {
|
||||
log.debug("[wecom-keepalive] invalidateReplyContext failed for {}: {}",
|
||||
st.streamId, e.getMessage());
|
||||
}
|
||||
stop(st.streamId);
|
||||
log.info("[wecom-keepalive] force-finished stream {} after {}s ceiling",
|
||||
st.streamId, MAX_DURATION_SECONDS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
st.adapter.replyStreamRefreshForKeepalive(st.reqId, st.streamId, PROCESSING_TEXT);
|
||||
} catch (Exception e) {
|
||||
log.debug("[wecom-keepalive] refresh failed for {}: {}", st.streamId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Test hooks ----
|
||||
|
||||
int activeStreamCount() { return states.size(); }
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package vip.mate.channel.wecom.cards;
|
||||
|
||||
/**
|
||||
* Thrown when a card payload would exceed a WeCom-imposed size limit
|
||||
* (most commonly: button.key serialised JSON > 1024 bytes).
|
||||
*
|
||||
* <p>Catchable so that WeCom card renderers can fall back to the
|
||||
* abstract-class text path on overflow rather than letting the entire
|
||||
* approval flow drop. RFC-32 §2.1.1 calls this out explicitly.
|
||||
*/
|
||||
public class CardOversizedException extends RuntimeException {
|
||||
public CardOversizedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package vip.mate.channel.wecom.cards;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.channel.wecom.cards.tool_guard.ToolGuardCardKindFactory;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Routing-only dispatcher for WeCom interactive template cards.
|
||||
*
|
||||
* <p>Maintains two indexes keyed by disjoint identifiers:
|
||||
* <ul>
|
||||
* <li><b>Outbound</b>: {@code metadata.message_type} from the agent
|
||||
* runtime → {@link WeComCardKind#renderer()}. Currently the only
|
||||
* direct outbound caller is {@link
|
||||
* vip.mate.channel.wecom.WeComChannelAdapter#sendApprovalNotice}
|
||||
* which doesn't yet read message_type — but having the index lets
|
||||
* us add future card kinds without touching the adapter.</li>
|
||||
* <li><b>Inbound</b>: prefix of the
|
||||
* {@code template_card_event.task_id} → {@link WeComCardKind#handler()}.
|
||||
* Card kinds <i>must</i> use disjoint prefixes; collision throws
|
||||
* at registration time.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The {@code @Component} is autowired; current contributors are
|
||||
* collected via {@link #registerKinds()} which calls factory beans for
|
||||
* each kind. Adding a new card kind: implement {@code WeComCardKind} +
|
||||
* a factory bean returning it + add a line to {@link #registerKinds()}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WeComCardDispatcher {
|
||||
|
||||
private final Map<String, WeComCardKind> byMessageType = new HashMap<>();
|
||||
private final Map<String, WeComCardKind> byTaskIdPrefix = new HashMap<>();
|
||||
|
||||
private final ToolGuardCardKindFactory toolGuardFactory;
|
||||
|
||||
public WeComCardDispatcher(ToolGuardCardKindFactory toolGuardFactory) {
|
||||
this.toolGuardFactory = toolGuardFactory;
|
||||
registerKinds();
|
||||
}
|
||||
|
||||
private void registerKinds() {
|
||||
// Currently single kind. Add lines here as new card kinds land
|
||||
// (poll cards / info-request cards / etc.). Order doesn't matter:
|
||||
// the disjoint-prefix invariant prevents ambiguity at lookup.
|
||||
register(toolGuardFactory.create());
|
||||
}
|
||||
|
||||
private void register(WeComCardKind kind) {
|
||||
if (byMessageType.containsKey(kind.messageType())) {
|
||||
throw new IllegalStateException(
|
||||
"duplicate card kind for messageType '" + kind.messageType()
|
||||
+ "': existing=" + byMessageType.get(kind.messageType()).name()
|
||||
+ ", new=" + kind.name());
|
||||
}
|
||||
if (byTaskIdPrefix.containsKey(kind.taskIdPrefix())) {
|
||||
throw new IllegalStateException(
|
||||
"duplicate card kind for taskIdPrefix '" + kind.taskIdPrefix()
|
||||
+ "': existing=" + byTaskIdPrefix.get(kind.taskIdPrefix()).name()
|
||||
+ ", new=" + kind.name());
|
||||
}
|
||||
byMessageType.put(kind.messageType(), kind);
|
||||
byTaskIdPrefix.put(kind.taskIdPrefix(), kind);
|
||||
log.info("[wecom-cards] Registered card kind: name={} messageType={} taskIdPrefix={}",
|
||||
kind.name(), kind.messageType(), kind.taskIdPrefix());
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a card kind by outbound {@code metadata.message_type}.
|
||||
*/
|
||||
public Optional<WeComCardKind> lookupByMessageType(String messageType) {
|
||||
if (messageType == null || messageType.isBlank()) return Optional.empty();
|
||||
return Optional.ofNullable(byMessageType.get(messageType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a card kind by inbound {@code template_card_event.task_id}'s
|
||||
* prefix. O(N) over registered kinds (N is small — currently 1).
|
||||
*/
|
||||
public Optional<WeComCardKind> lookupByTaskId(String taskId) {
|
||||
if (taskId == null || taskId.isBlank()) return Optional.empty();
|
||||
for (Map.Entry<String, WeComCardKind> e : byTaskIdPrefix.entrySet()) {
|
||||
if (taskId.startsWith(e.getKey())) {
|
||||
return Optional.of(e.getValue());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Visible for tests / logs. */
|
||||
public List<String> registeredKindNames() {
|
||||
return byMessageType.values().stream().map(WeComCardKind::name).toList();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package vip.mate.channel.wecom.cards;
|
||||
|
||||
import vip.mate.channel.wecom.WeComChannelAdapter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Processes an inbound WeCom {@code template_card_event} frame for one
|
||||
* kind of card.
|
||||
*
|
||||
* <p>Implementations must respect the 5-second WeCom window: render and
|
||||
* dispatch the resolved-state card update (via
|
||||
* {@link WeComChannelAdapter#updateTemplateCard}) inside that window,
|
||||
* THEN enqueue any agent-side command (e.g. {@code /approve <id>}).
|
||||
* The window starts the moment the event frame is received, so any
|
||||
* pre-update validation must be cheap (DB lookup + identity check is
|
||||
* fine; LLM round-trip is not).
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface WeComCardHandler {
|
||||
/**
|
||||
* @param adapter the live WeCom adapter (provides
|
||||
* {@code updateTemplateCard}, {@code messageRouter}
|
||||
* for command injection, etc.)
|
||||
* @param frame the raw inbound frame including {@code headers.req_id}
|
||||
* needed by {@code updateTemplateCard}
|
||||
* @param tce the parsed {@code event.template_card_event} sub-object
|
||||
* (already extracted by the dispatcher; contains
|
||||
* {@code task_id} / {@code event_key})
|
||||
* @param fromBlock the {@code body.from} sub-object (carries
|
||||
* {@code userid} of the clicker — needed for
|
||||
* identity validation against original requester)
|
||||
*/
|
||||
void handle(WeComChannelAdapter adapter,
|
||||
Map<String, Object> frame,
|
||||
Map<String, Object> tce,
|
||||
Map<String, Object> fromBlock);
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package vip.mate.channel.wecom.cards;
|
||||
|
||||
/**
|
||||
* Description of one kind of interactive WeCom template card the
|
||||
* dispatcher knows how to route, along with the two functional callbacks
|
||||
* that handle the outbound render and the inbound click event.
|
||||
*
|
||||
* <p>Kept as a simple record so adding a new card type (e.g. a poll
|
||||
* card, an info-request card) is just: implement renderer/handler,
|
||||
* register a new {@code WeComCardKind} in
|
||||
* {@link WeComCardDispatcher#registerKinds()}.
|
||||
*
|
||||
* @param name short human-readable name for logs
|
||||
* @param messageType matches {@code metadata.message_type} on the
|
||||
* outbound event coming from the agent runtime
|
||||
* (drives the {@code render} dispatch)
|
||||
* @param taskIdPrefix matches the prefix of the inbound
|
||||
* {@code template_card_event.task_id} (drives the
|
||||
* {@code handle} dispatch). Card kinds must use
|
||||
* disjoint prefixes; the dispatcher rejects
|
||||
* registration of a colliding prefix.
|
||||
* @param renderer converts a pending business object (e.g.
|
||||
* {@code ApprovalNotice}) into a WeCom template_card
|
||||
* payload Map. Throws {@link CardOversizedException}
|
||||
* to signal "this kind cannot render now, fall back
|
||||
* to text".
|
||||
* @param handler processes an inbound {@code template_card_event}
|
||||
* frame: validate identity → render resolved card →
|
||||
* enqueue any follow-up command. Implementations
|
||||
* MUST complete the render-resolved-card step inside
|
||||
* the 5s WeCom protocol window; the agent enqueue
|
||||
* step can be slower.
|
||||
*/
|
||||
public record WeComCardKind(
|
||||
String name,
|
||||
String messageType,
|
||||
String taskIdPrefix,
|
||||
WeComCardRenderer renderer,
|
||||
WeComCardHandler handler
|
||||
) {
|
||||
public WeComCardKind {
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("WeComCardKind.name must not be blank");
|
||||
}
|
||||
if (messageType == null || messageType.isBlank()) {
|
||||
throw new IllegalArgumentException("WeComCardKind.messageType must not be blank");
|
||||
}
|
||||
if (taskIdPrefix == null || taskIdPrefix.isBlank()) {
|
||||
throw new IllegalArgumentException("WeComCardKind.taskIdPrefix must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package vip.mate.channel.wecom.cards;
|
||||
|
||||
import vip.mate.channel.notification.ApprovalNotice;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Builds a WeCom template_card payload Map from a business object.
|
||||
*
|
||||
* <p>Implementations may throw {@link CardOversizedException} to signal
|
||||
* the caller to fall back to a non-card path (e.g. text approval
|
||||
* notice). All other throw paths surface as bugs.
|
||||
*
|
||||
* <p>Currently parameterised on {@link ApprovalNotice} since tool-guard
|
||||
* is the only card kind in PR-1; future kinds will likely accept a
|
||||
* different input or take {@code Object} and self-cast.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface WeComCardRenderer {
|
||||
/**
|
||||
* Build the template_card body Map ready to drop into
|
||||
* {@code aibot_respond_msg.body.template_card}.
|
||||
*/
|
||||
Map<String, Object> render(ApprovalNotice notice) throws CardOversizedException;
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import vip.mate.channel.wecom.cards.CardOversizedException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JSON encode/decode helper for the {@code key} field on each
|
||||
* tool-guard approval card button.
|
||||
*
|
||||
* <p>WeCom enforces a hard 1024-byte ceiling on each
|
||||
* {@code button.key} — the field is what the server echoes back as
|
||||
* {@code event_key} when the user clicks. We pack the action plus the
|
||||
* minimal context we need to recover the pending approval (the
|
||||
* {@code pendingId} alone is enough — mateclaw's
|
||||
* {@code ApprovalService.findById} resolves the rest, including the
|
||||
* original requester). QwenPaw's approach also stuffs sender/chat
|
||||
* context for offline-render reasons; mateclaw doesn't need that
|
||||
* because the inbound handler runs in-process and can do a synchronous
|
||||
* DB lookup, leaving headroom in the 1024-byte budget for long
|
||||
* tool names / Chinese characters.
|
||||
*
|
||||
* <p>Encoding is stable (LinkedHashMap → consistent key order so byte-
|
||||
* length is predictable). Decoding tolerates extra fields — useful if
|
||||
* a future change adds optional context.
|
||||
*/
|
||||
public final class ToolGuardButtonKey {
|
||||
|
||||
/** Hard byte limit for the {@code button.key} field; verified against WeCom protocol. */
|
||||
public static final int MAX_KEY_BYTES = 1024;
|
||||
|
||||
public enum Action {
|
||||
APPROVE("approve"),
|
||||
DENY("deny");
|
||||
|
||||
public final String wireValue;
|
||||
Action(String v) { this.wireValue = v; }
|
||||
|
||||
public static Action fromWire(String v) {
|
||||
if ("approve".equalsIgnoreCase(v)) return APPROVE;
|
||||
if ("deny".equalsIgnoreCase(v)) return DENY;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decoded button-key payload. {@code null} if parse fails or action invalid. */
|
||||
public record Decoded(Action action, String pendingId, String toolName, String severity) {}
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ToolGuardButtonKey(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an approval-button {@code key}.
|
||||
*
|
||||
* @throws CardOversizedException if the resulting JSON exceeds 1024
|
||||
* bytes (caller must fall back to text approval path)
|
||||
*/
|
||||
public String encode(Action action, String pendingId, String toolName, String severity) {
|
||||
// LinkedHashMap so the key order on the wire is stable across calls;
|
||||
// makes byte-length predictable and snapshot-testable.
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("a", action.wireValue); // action
|
||||
payload.put("rid", pendingId); // request/pending id
|
||||
payload.put("tool", toolName); // for log readability when WeCom replays event_key
|
||||
payload.put("sev", severity == null ? "" : severity);
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(payload);
|
||||
int bytes = json.getBytes(StandardCharsets.UTF_8).length;
|
||||
if (bytes > MAX_KEY_BYTES) {
|
||||
throw new CardOversizedException(
|
||||
"tool_guard button.key payload " + bytes + " bytes > limit " + MAX_KEY_BYTES);
|
||||
}
|
||||
return json;
|
||||
} catch (CardOversizedException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new CardOversizedException("failed to serialise button.key: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the {@code event_key} echoed back by WeCom on button click.
|
||||
* Returns {@code null} if the payload is malformed or the action
|
||||
* unrecognised. Callers should treat null as "ignore this event".
|
||||
*/
|
||||
public Decoded decode(String eventKey) {
|
||||
if (eventKey == null || eventKey.isBlank()) return null;
|
||||
try {
|
||||
Map<String, Object> raw = objectMapper.readValue(eventKey, new TypeReference<>() {});
|
||||
Action action = Action.fromWire(asString(raw.get("a")));
|
||||
if (action == null) return null;
|
||||
String pendingId = asString(raw.get("rid"));
|
||||
if (pendingId == null || pendingId.isBlank()) return null;
|
||||
return new Decoded(
|
||||
action,
|
||||
pendingId,
|
||||
asString(raw.getOrDefault("tool", "")),
|
||||
asString(raw.getOrDefault("sev", "")));
|
||||
} catch (Exception e) {
|
||||
// Malformed payload — treat as "ignore". The caller's
|
||||
// log.debug at handler entry covers visibility.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String asString(Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,225 @@
|
||||
package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import vip.mate.approval.ApprovalService;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.wecom.WeComChannelAdapter;
|
||||
import vip.mate.channel.wecom.cards.WeComCardHandler;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Process an inbound {@code template_card_event} frame for a tool-guard
|
||||
* approval card.
|
||||
*
|
||||
* <p><b>Step ordering — validate before render</b> (RFC-32 v2.1 / R-5):
|
||||
* v2.0's draft step order was "render resolved card → inject command →
|
||||
* router validates identity". That meant a non-original-requester click
|
||||
* would briefly show "✅ 已批准 by 李四" on the card before the router
|
||||
* silently dropped the injected command. v2.1 reorders to:
|
||||
* <ol>
|
||||
* <li>Decode {@code event_key} → null check</li>
|
||||
* <li>Look up {@code PendingApproval} by id</li>
|
||||
* <li>Identity check: pending.userId vs clicker</li>
|
||||
* <li>Render the appropriate resolved-state card (success / unauthorized / expired)</li>
|
||||
* <li>Inject {@code /approve} or {@code /deny} command into the router
|
||||
* — only for authorised clicks</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Steps 1-4 must complete inside the WeCom 5-second window for the
|
||||
* card update; step 5 can be slower.
|
||||
*/
|
||||
@Slf4j
|
||||
public class ToolGuardCardHandler implements WeComCardHandler {
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
private final ToolGuardButtonKey buttonKey;
|
||||
|
||||
public ToolGuardCardHandler(ApprovalService approvalService, ToolGuardButtonKey buttonKey) {
|
||||
this.approvalService = approvalService;
|
||||
this.buttonKey = buttonKey;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public void handle(WeComChannelAdapter adapter,
|
||||
Map<String, Object> frame,
|
||||
Map<String, Object> tce,
|
||||
Map<String, Object> fromBlock) {
|
||||
String eventReqId = extractEventReqId(frame);
|
||||
String taskId = (String) tce.getOrDefault("task_id", "");
|
||||
String eventKey = (String) tce.getOrDefault("event_key", "");
|
||||
|
||||
// ---- 1. Decode event_key ----
|
||||
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(eventKey);
|
||||
if (decoded == null) {
|
||||
log.warn("[wecom-toolguard] Could not decode event_key, ignoring task_id={}", taskId);
|
||||
return;
|
||||
}
|
||||
String pendingId = decoded.pendingId();
|
||||
ToolGuardButtonKey.Action action = decoded.action();
|
||||
String clickerUserId = stringOrEmpty(fromBlock.get("userid"));
|
||||
|
||||
// ---- 2. Look up pending approval ----
|
||||
Optional<PendingApproval> opt = approvalService.getPending(pendingId);
|
||||
if (opt.isEmpty() || !"pending".equals(opt.get().getStatus())) {
|
||||
// Either: GC'd it / approved/denied via another path / never existed
|
||||
log.info("[wecom-toolguard] Pending {} not found or already resolved (action={}, clicker={})",
|
||||
pendingId, action, abbrev(clickerUserId));
|
||||
renderExpired(adapter, eventReqId, taskId, decoded.toolName());
|
||||
return;
|
||||
}
|
||||
PendingApproval pending = opt.get();
|
||||
|
||||
// ---- 3. Identity check ----
|
||||
String originalRequester = pending.getUserId();
|
||||
boolean isAuthorized = originalRequester == null
|
||||
|| "system".equals(originalRequester)
|
||||
|| originalRequester.equals(clickerUserId);
|
||||
if (!isAuthorized) {
|
||||
log.warn("[wecom-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
|
||||
abbrev(clickerUserId), abbrev(originalRequester), pendingId);
|
||||
renderUnauthorised(adapter, eventReqId, taskId, decoded.toolName(), originalRequester);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 4. Render resolved card (must finish inside the 5s WeCom window) ----
|
||||
renderResolved(adapter, eventReqId, taskId, decoded.toolName(), action, clickerUserId);
|
||||
|
||||
// ---- 5. Inject the /approve or /deny command into the message router ----
|
||||
// Re-routing the click as a synthetic user message means we reuse the
|
||||
// existing approval validation + state-machine path
|
||||
// (ChannelMessageRouter.processMessage), so any future change to the
|
||||
// approval flow keeps working without a parallel button-click code path.
|
||||
String commandText = (action == ToolGuardButtonKey.Action.APPROVE ? "/approve " : "/deny ")
|
||||
+ pendingId;
|
||||
ChannelMessage synthetic = buildSynthetic(commandText, clickerUserId, pending, frame);
|
||||
try {
|
||||
adapter.injectSyntheticMessage(synthetic);
|
||||
log.info("[wecom-toolguard] Injected '{}' for pending={}, clicker={}",
|
||||
action == ToolGuardButtonKey.Action.APPROVE ? "/approve" : "/deny",
|
||||
pendingId, abbrev(clickerUserId));
|
||||
} catch (Exception e) {
|
||||
// Never let an enqueue failure leave the card looking applied.
|
||||
// The card already shows resolved-state, but the agent won't see
|
||||
// the approve/deny — operator log is the safety net.
|
||||
log.error("[wecom-toolguard] Failed to inject command for pending={}: {}",
|
||||
pendingId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Card rendering helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void renderResolved(WeComChannelAdapter adapter, String eventReqId,
|
||||
String taskId, String toolName,
|
||||
ToolGuardButtonKey.Action action, String clicker) {
|
||||
String title = action == ToolGuardButtonKey.Action.APPROVE
|
||||
? "✅ 已批准"
|
||||
: "🚫 已拒绝";
|
||||
String desc = action == ToolGuardButtonKey.Action.APPROVE
|
||||
? "Tool " + toolName + " 已批准"
|
||||
: "Tool " + toolName + " 已拒绝";
|
||||
try {
|
||||
adapter.updateTemplateCard(eventReqId,
|
||||
ToolGuardCardRenderer.buildResolvedCard(taskId, title, desc));
|
||||
} catch (Exception e) {
|
||||
log.warn("[wecom-toolguard] update_template_card (resolved) failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void renderUnauthorised(WeComChannelAdapter adapter, String eventReqId,
|
||||
String taskId, String toolName, String originalRequester) {
|
||||
String requesterLabel = originalRequester == null ? "原请求者" : abbrev(originalRequester);
|
||||
try {
|
||||
adapter.updateTemplateCard(eventReqId,
|
||||
ToolGuardCardRenderer.buildResolvedCard(taskId,
|
||||
"❌ 仅原请求者可审批",
|
||||
"请由 " + requesterLabel + " 操作"));
|
||||
} catch (Exception e) {
|
||||
log.warn("[wecom-toolguard] update_template_card (unauthorised) failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void renderExpired(WeComChannelAdapter adapter, String eventReqId,
|
||||
String taskId, String toolName) {
|
||||
try {
|
||||
adapter.updateTemplateCard(eventReqId,
|
||||
ToolGuardCardRenderer.buildResolvedCard(taskId,
|
||||
"⌛ 审批已过期",
|
||||
"Tool " + toolName + " 的审批已过期或被处理"));
|
||||
} catch (Exception e) {
|
||||
log.warn("[wecom-toolguard] update_template_card (expired) failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Synthetic message construction
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a {@link ChannelMessage} that looks like the clicker just sent
|
||||
* "/approve <id>" (or /deny). The router's existing approval gate
|
||||
* picks it up via {@code processMessage} and runs the same identity-
|
||||
* check + state-machine that text commands hit.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ChannelMessage buildSynthetic(String commandText, String clickerUserId,
|
||||
PendingApproval pending,
|
||||
Map<String, Object> eventFrame) {
|
||||
// The conversation behind the original card is whichever WeCom chat
|
||||
// the {@code template_card_event} arrived from. body.chattype +
|
||||
// body.chatid let us reconstruct the same conversationId the
|
||||
// original message used.
|
||||
Map<String, Object> body = (Map<String, Object>) eventFrame.getOrDefault("body", Map.of());
|
||||
String chatType = stringOrEmpty(body.get("chattype"));
|
||||
String chatId = stringOrEmpty(body.get("chatid"));
|
||||
boolean isGroup = "group".equals(chatType);
|
||||
String effectiveChatId = isGroup && !chatId.isBlank() ? chatId : null;
|
||||
String replyToken = isGroup && !chatId.isBlank() ? chatId : clickerUserId;
|
||||
|
||||
return ChannelMessage.builder()
|
||||
.channelType("wecom")
|
||||
.senderId(clickerUserId)
|
||||
.senderName(clickerUserId)
|
||||
.chatId(effectiveChatId)
|
||||
.content(commandText)
|
||||
.contentType("text")
|
||||
.contentParts(List.of())
|
||||
.inputMode("text")
|
||||
.timestamp(LocalDateTime.now())
|
||||
.replyToken(replyToken)
|
||||
// Tag rawPayload so any downstream code that wants to
|
||||
// distinguish real messages from button-click injections
|
||||
// can read this flag instead of inspecting senderId.
|
||||
.rawPayload(Map.of(
|
||||
"wecom_button_click", true,
|
||||
"wecom_pending_id", pending.getPendingId()
|
||||
))
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String extractEventReqId(Map<String, Object> frame) {
|
||||
Map<String, Object> headers = (Map<String, Object>) frame.getOrDefault("headers", Map.of());
|
||||
return stringOrEmpty(headers.get("req_id"));
|
||||
}
|
||||
|
||||
private static String stringOrEmpty(Object v) {
|
||||
return v == null ? "" : v.toString();
|
||||
}
|
||||
|
||||
private static String abbrev(String s) {
|
||||
if (s == null || s.length() <= 8) return s == null ? "" : s;
|
||||
return s.substring(0, 8) + "…";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.approval.ApprovalService;
|
||||
import vip.mate.channel.wecom.cards.WeComCardKind;
|
||||
|
||||
/**
|
||||
* Spring-managed factory that produces the tool-guard card kind for
|
||||
* {@link vip.mate.channel.wecom.cards.WeComCardDispatcher}.
|
||||
*
|
||||
* <p>Plain {@code @Component} so the dispatcher can constructor-inject
|
||||
* it. Each call to {@link #create()} returns a freshly constructed
|
||||
* {@link WeComCardKind}; the dispatcher keeps the result and queries it
|
||||
* for life of the JVM.
|
||||
*/
|
||||
@Component
|
||||
public class ToolGuardCardKindFactory {
|
||||
|
||||
/**
|
||||
* Same value as {@link ToolGuardCardRenderer#TASK_ID_PREFIX}. Kept
|
||||
* here too so the {@link WeComCardKind#taskIdPrefix()} index can be
|
||||
* declared from the factory without reaching into the renderer.
|
||||
*/
|
||||
public static final String TASK_ID_PREFIX = ToolGuardCardRenderer.TASK_ID_PREFIX;
|
||||
|
||||
/**
|
||||
* Outbound metadata.message_type matched by the dispatcher when the
|
||||
* agent runtime emits an approval-pending event. Currently the WeCom
|
||||
* adapter's sendApprovalNotice override doesn't read message_type
|
||||
* (it always renders tool-guard), but having the index lets future
|
||||
* card kinds plug in cleanly.
|
||||
*/
|
||||
public static final String MESSAGE_TYPE = "tool_guard_approval";
|
||||
|
||||
private final ApprovalService approvalService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ToolGuardCardKindFactory(ApprovalService approvalService, ObjectMapper objectMapper) {
|
||||
this.approvalService = approvalService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public WeComCardKind create() {
|
||||
ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(objectMapper);
|
||||
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey);
|
||||
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonKey);
|
||||
return new WeComCardKind(
|
||||
"tool_guard_approval",
|
||||
MESSAGE_TYPE,
|
||||
TASK_ID_PREFIX,
|
||||
renderer,
|
||||
handler
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,148 @@
|
||||
package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import vip.mate.channel.notification.ApprovalNotice;
|
||||
import vip.mate.channel.wecom.cards.CardOversizedException;
|
||||
import vip.mate.channel.wecom.cards.WeComCardRenderer;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Build the WeCom {@code button_interaction} approval card payload from
|
||||
* an {@link ApprovalNotice}.
|
||||
*
|
||||
* <p>Card structure (verified against QwenPaw {@code tool_guard.py:107-140}
|
||||
* + WeCom official protocol):
|
||||
* <pre>
|
||||
* {
|
||||
* "card_type": "button_interaction",
|
||||
* "task_id": "tg_approval_<pendingId>",
|
||||
* "main_title": {
|
||||
* "title": "🛡️ 工具审批",
|
||||
* "desc": "<toolName> | <severityLabel>"
|
||||
* },
|
||||
* "button_list": [
|
||||
* { "text": "批准", "style": 1, "key": "<encoded JSON>" },
|
||||
* { "text": "拒绝", "style": 2, "key": "<encoded JSON>" }
|
||||
* ]
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>If either button.key would exceed the 1024-byte WeCom limit, the
|
||||
* encoder throws {@link CardOversizedException} and the calling adapter
|
||||
* falls back to the abstract-class text-approval path.
|
||||
*/
|
||||
public class ToolGuardCardRenderer implements WeComCardRenderer {
|
||||
|
||||
/**
|
||||
* Prefix on the card's {@code task_id}; the inbound dispatcher matches
|
||||
* this to find the right handler. Same value as
|
||||
* {@link ToolGuardCardKindFactory#TASK_ID_PREFIX}.
|
||||
*/
|
||||
public static final String TASK_ID_PREFIX = "tg_approval_";
|
||||
|
||||
private final ToolGuardButtonKey buttonKey;
|
||||
|
||||
public ToolGuardCardRenderer(ToolGuardButtonKey buttonKey) {
|
||||
this.buttonKey = buttonKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> render(ApprovalNotice notice) throws CardOversizedException {
|
||||
String pendingId = notice.pendingId();
|
||||
String toolName = nullSafe(notice.toolName(), "tool");
|
||||
String severity = nullSafe(notice.maxSeverity(), "MEDIUM");
|
||||
|
||||
// Encode buttons first so a 1024-byte overflow throws BEFORE we
|
||||
// build any of the cosmetic card structure. Same payload shape on
|
||||
// both buttons differs only in the action wire value.
|
||||
String approveKey = buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE, pendingId, toolName, severity);
|
||||
String denyKey = buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.DENY, pendingId, toolName, severity);
|
||||
|
||||
// Use LinkedHashMap so the JSON serialisation order is stable —
|
||||
// helps when log-grepping outbound frames against snapshots.
|
||||
Map<String, Object> mainTitle = new LinkedHashMap<>();
|
||||
mainTitle.put("title", "🛡️ 工具审批");
|
||||
mainTitle.put("desc", buildSubtitle(toolName, severity));
|
||||
|
||||
Map<String, Object> approveBtn = new LinkedHashMap<>();
|
||||
approveBtn.put("text", "批准");
|
||||
approveBtn.put("style", 1);
|
||||
approveBtn.put("key", approveKey);
|
||||
|
||||
Map<String, Object> denyBtn = new LinkedHashMap<>();
|
||||
denyBtn.put("text", "拒绝");
|
||||
denyBtn.put("style", 2);
|
||||
denyBtn.put("key", denyKey);
|
||||
|
||||
Map<String, Object> card = new LinkedHashMap<>();
|
||||
card.put("card_type", "button_interaction");
|
||||
card.put("task_id", TASK_ID_PREFIX + pendingId);
|
||||
card.put("main_title", mainTitle);
|
||||
card.put("button_list", List.of(approveBtn, denyBtn));
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@code text_notice} resolved-state card to update the
|
||||
* original button card after a click. WeCom's card protocol requires
|
||||
* {@code text_notice} cards to carry a {@code card_action} of type 1
|
||||
* or 2 (type 0 is rejected by the bot endpoint), so we provide a
|
||||
* harmless project URL.
|
||||
*
|
||||
* @param taskId same task_id as the original card so the
|
||||
* update targets the right message
|
||||
* @param title one-line headline (e.g. "✅ 已批准 by 张三")
|
||||
* @param desc optional detail line; truncated to 30 chars
|
||||
* to stay inside WeCom's main_title.desc limit
|
||||
*/
|
||||
public static Map<String, Object> buildResolvedCard(String taskId, String title, String desc) {
|
||||
Map<String, Object> mainTitle = new LinkedHashMap<>();
|
||||
mainTitle.put("title", title == null ? "" : title);
|
||||
mainTitle.put("desc", truncate(desc == null ? "" : desc, 30));
|
||||
|
||||
Map<String, Object> cardAction = new LinkedHashMap<>();
|
||||
cardAction.put("type", 1);
|
||||
cardAction.put("url", "https://mateclaw.vip");
|
||||
|
||||
Map<String, Object> card = new LinkedHashMap<>();
|
||||
card.put("card_type", "text_notice");
|
||||
card.put("task_id", taskId);
|
||||
card.put("main_title", mainTitle);
|
||||
card.put("card_action", cardAction);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static String buildSubtitle(String toolName, String severity) {
|
||||
// Keep the subtitle short — WeCom truncates aggressively. Format:
|
||||
// "<tool> | <severity>". Translate severity to a single-word Chinese
|
||||
// label so it reads naturally in the card.
|
||||
return toolName + " | " + severityShortLabel(severity);
|
||||
}
|
||||
|
||||
private static String severityShortLabel(String severity) {
|
||||
if (severity == null) return "MEDIUM";
|
||||
return switch (severity.toUpperCase()) {
|
||||
case "CRITICAL" -> "🔴 极高";
|
||||
case "HIGH" -> "🟠 高";
|
||||
case "MEDIUM" -> "🟡 中";
|
||||
case "LOW" -> "🔵 低";
|
||||
case "INFO" -> "⚪ 提示";
|
||||
default -> severity;
|
||||
};
|
||||
}
|
||||
|
||||
private static String truncate(String s, int max) {
|
||||
if (s == null) return "";
|
||||
if (s.length() <= max) return s;
|
||||
if (max <= 1) return s.substring(0, max);
|
||||
return s.substring(0, max - 1) + "…";
|
||||
}
|
||||
|
||||
private static String nullSafe(String v, String fallback) {
|
||||
return (v == null || v.isBlank()) ? fallback : v;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user