feat(channel): wecom approval card + keepalive + chunk dedup

This commit is contained in:
matevip 2026-05-10 08:28:07 +08:00
parent 20393ea679
commit 225d14026b
13 changed files with 1301 additions and 17 deletions

View File

@ -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);

View File

@ -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()) {

View File

@ -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 &gt; 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);
}
// ==================== 媒体上传协议 ====================
/**

View File

@ -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(); }
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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");
}
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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) + "";
}
}

View File

@ -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
);
}
}

View File

@ -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_&lt;pendingId&gt;",
* "main_title": {
* "title": "🛡️ 工具审批",
* "desc": "&lt;toolName&gt; | &lt;severityLabel&gt;"
* },
* "button_list": [
* { "text": "批准", "style": 1, "key": "&lt;encoded JSON&gt;" },
* { "text": "拒绝", "style": 2, "key": "&lt;encoded JSON&gt;" }
* ]
* }
* </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;
}
}