diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index 3440dc0d..a4fea314 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -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 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); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index f2b9c98f..802db239 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -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()) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index c97e796e..96338fdc 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -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. - * - *

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). + * + *

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. + * + *

5-second window: 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 frame, + Map body, + Map event) { + if (cardDispatcher == null) { + log.debug("[wecom] template_card_event ignored: dispatcher not wired"); + return; + } + Map tce = event.get("template_card_event") instanceof Map m + ? (Map) m + : (Map) 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 fromBlock = body.get("from") instanceof Map fm + ? (Map) 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. + * + *

Falls back to {@code super.sendApprovalNotice} (markdown text) + * in three failure modes: + *

    + *
  1. No card dispatcher available (DI did not wire it — usually + * a test / hot-swap context)
  2. + *
  3. 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
  4. + *
  5. {@link CardOversizedException} thrown by the renderer + * (button.key payload > 1024 bytes)
  6. + *
+ * + *

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 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 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 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 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). + * + *

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}. + * + *

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 templateCard) { + Map body = Map.of( + "msgtype", "template_card", + "template_card", templateCard + ); + Map 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. + * + *

5-second window: 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. + * + *

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 templateCard) { + Map body = Map.of( + "response_type", "update_template_card", + "template_card", templateCard + ); + Map 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. + * + *

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. + * + *

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}. + * + *

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. + * + *

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. + * + *

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); + } + // ==================== 媒体上传协议 ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java new file mode 100644 index 00000000..53a0b74e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComKeepaliveScheduler.java @@ -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. + * + *

Why this exists: 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). + * + *

Constants (verified against QwenPaw {@code channel.py:66-67,986-1031}): + *

+ * + *

Force-finish invariant: 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 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(); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/CardOversizedException.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/CardOversizedException.java new file mode 100644 index 00000000..1ed8cf03 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/CardOversizedException.java @@ -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). + * + *

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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardDispatcher.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardDispatcher.java new file mode 100644 index 00000000..0b7bdcb4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardDispatcher.java @@ -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. + * + *

Maintains two indexes keyed by disjoint identifiers: + *

+ * + *

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 byMessageType = new HashMap<>(); + private final Map 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 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 lookupByTaskId(String taskId) { + if (taskId == null || taskId.isBlank()) return Optional.empty(); + for (Map.Entry e : byTaskIdPrefix.entrySet()) { + if (taskId.startsWith(e.getKey())) { + return Optional.of(e.getValue()); + } + } + return Optional.empty(); + } + + /** Visible for tests / logs. */ + public List registeredKindNames() { + return byMessageType.values().stream().map(WeComCardKind::name).toList(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardHandler.java new file mode 100644 index 00000000..bf1216d0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardHandler.java @@ -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. + * + *

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 }). + * 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 frame, + Map tce, + Map fromBlock); +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardKind.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardKind.java new file mode 100644 index 00000000..64bc71fa --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardKind.java @@ -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. + * + *

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"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardRenderer.java new file mode 100644 index 00000000..9248f25d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardRenderer.java @@ -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. + * + *

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. + * + *

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 render(ApprovalNotice notice) throws CardOversizedException; +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKey.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKey.java new file mode 100644 index 00000000..2e2dcb8f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKey.java @@ -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. + * + *

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. + * + *

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 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 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(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java new file mode 100644 index 00000000..ea1b934f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java @@ -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. + * + *

Step ordering — validate before render (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: + *

    + *
  1. Decode {@code event_key} → null check
  2. + *
  3. Look up {@code PendingApproval} by id
  4. + *
  5. Identity check: pending.userId vs clicker
  6. + *
  7. Render the appropriate resolved-state card (success / unauthorized / expired)
  8. + *
  9. Inject {@code /approve} or {@code /deny} command into the router + * — only for authorised clicks
  10. + *
+ * + *

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 frame, + Map tce, + Map 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 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 " (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 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 body = (Map) 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 frame) { + Map headers = (Map) 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) + "…"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java new file mode 100644 index 00000000..e36e5422 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java @@ -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}. + * + *

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 + ); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRenderer.java new file mode 100644 index 00000000..6d94b52b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRenderer.java @@ -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}. + * + *

Card structure (verified against QwenPaw {@code tool_guard.py:107-140} + * + WeCom official protocol): + *

+ * {
+ *   "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>" }
+ *   ]
+ * }
+ * 
+ * + *

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 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 mainTitle = new LinkedHashMap<>(); + mainTitle.put("title", "🛡️ 工具审批"); + mainTitle.put("desc", buildSubtitle(toolName, severity)); + + Map approveBtn = new LinkedHashMap<>(); + approveBtn.put("text", "批准"); + approveBtn.put("style", 1); + approveBtn.put("key", approveKey); + + Map denyBtn = new LinkedHashMap<>(); + denyBtn.put("text", "拒绝"); + denyBtn.put("style", 2); + denyBtn.put("key", denyKey); + + Map 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 buildResolvedCard(String taskId, String title, String desc) { + Map mainTitle = new LinkedHashMap<>(); + mainTitle.put("title", title == null ? "" : title); + mainTitle.put("desc", truncate(desc == null ? "" : desc, 30)); + + Map cardAction = new LinkedHashMap<>(); + cardAction.put("type", 1); + cardAction.put("url", "https://mateclaw.vip"); + + Map 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: + // " | ". 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; + } +}