From a9fa8e7fb135bfe3d9d2b3de40f759dd604131f3 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 12:05:47 +0800 Subject: [PATCH] sync: interactive approval card on Feishu via Schema-2.0 button + card.action callback --- .../java/vip/mate/channel/ChannelManager.java | 11 +- .../channel/cards/CardOversizedException.java | 17 ++ .../channel/feishu/FeishuChannelAdapter.java | 97 +++++++++- .../feishu/cards/FeishuCardDispatcher.java | 97 ++++++++++ .../feishu/cards/FeishuCardHandler.java | 36 ++++ .../channel/feishu/cards/FeishuCardKind.java | 45 +++++ .../feishu/cards/FeishuCardRenderer.java | 28 +++ .../tool_guard/ToolGuardButtonValue.java | 115 ++++++++++++ .../tool_guard/ToolGuardCardHandler.java | 170 +++++++++++++++++ .../tool_guard/ToolGuardCardKindFactory.java | 48 +++++ .../tool_guard/ToolGuardCardRenderer.java | 173 ++++++++++++++++++ .../channel/wecom/WeComChannelAdapter.java | 2 +- .../wecom/cards/CardOversizedException.java | 15 -- .../channel/wecom/cards/WeComCardKind.java | 2 + .../wecom/cards/WeComCardRenderer.java | 2 + .../cards/tool_guard/ToolGuardButtonKey.java | 2 +- .../tool_guard/ToolGuardCardRenderer.java | 2 +- .../channel/ChannelManagerReconcileTest.java | 1 + .../channel/feishu/FeishuMediaWiringIT.java | 4 + .../cards/FeishuCardDispatcherTest.java | 81 ++++++++ .../tool_guard/ToolGuardButtonValueTest.java | 99 ++++++++++ .../tool_guard/ToolGuardCardRendererTest.java | 98 ++++++++++ .../tool_guard/ToolGuardButtonKeyTest.java | 2 +- 23 files changed, 1125 insertions(+), 22 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/cards/CardOversizedException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardDispatcher.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardKind.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardRenderer.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValue.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java delete mode 100644 mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/CardOversizedException.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValueTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java 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 b140e069..8eeb5f4c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -100,6 +100,14 @@ public class ChannelManager { */ private final vip.mate.channel.feishu.FeishuStreamingCardManager feishuStreamingCardManager; + /** + * Feishu interactive-card dispatcher. Drives + * {@code FeishuChannelAdapter.sendApprovalNotice} (button-card render) + * and routes inbound {@code P2CardActionTrigger} events to the right + * card kind's handler (e.g. tool-guard approve / deny). + */ + private final vip.mate.channel.feishu.cards.FeishuCardDispatcher feishuCardDispatcher; + /** * Distributed leader election. Channels whose adapter reports * {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so @@ -1166,7 +1174,8 @@ public class ChannelManager { case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper); case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper, - feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager); + feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager, + feishuCardDispatcher); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, diff --git a/mateclaw-server/src/main/java/vip/mate/channel/cards/CardOversizedException.java b/mateclaw-server/src/main/java/vip/mate/channel/cards/CardOversizedException.java new file mode 100644 index 00000000..147de1b6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/cards/CardOversizedException.java @@ -0,0 +1,17 @@ +package vip.mate.channel.cards; + +/** + * Thrown when a channel-specific card payload would exceed a platform- + * imposed size limit (e.g. WeCom's 1024-byte {@code button.key}, + * Feishu's 30 KB interactive content cap). + * + *

Caught by adapters so they can fall back to the + * {@code AbstractChannelAdapter} text-approval path instead of letting + * the whole approval flow drop. Lives in the generic {@code channel/cards} + * package so every channel implementation shares one type. + */ +public class CardOversizedException extends RuntimeException { + public CardOversizedException(String message) { + super(message); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 54f85226..70330f57 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -134,10 +134,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** CardKit streaming-card manager. Nullable for legacy callers / tests. */ private final FeishuStreamingCardManager streamingCardManager; + /** Interactive-card dispatcher (approval cards etc.). Nullable for legacy callers / tests. */ + private final vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher; + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { - this(channelEntity, messageRouter, objectMapper, null, null, null); + this(channelEntity, messageRouter, objectMapper, null, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -145,7 +148,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre ObjectMapper objectMapper, FeishuMediaUploader mediaUploader, GeneratedFileScrubber generatedFileScrubber) { - this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, null); + this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -154,10 +157,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre FeishuMediaUploader mediaUploader, GeneratedFileScrubber generatedFileScrubber, FeishuStreamingCardManager streamingCardManager) { + this(channelEntity, messageRouter, objectMapper, mediaUploader, + generatedFileScrubber, streamingCardManager, null); + } + + public FeishuChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + FeishuMediaUploader mediaUploader, + GeneratedFileScrubber generatedFileScrubber, + FeishuStreamingCardManager streamingCardManager, + vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) { super(channelEntity, messageRouter, objectMapper); this.mediaUploader = mediaUploader; this.generatedFileScrubber = generatedFileScrubber; this.streamingCardManager = streamingCardManager; + this.cardDispatcher = cardDispatcher; // Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1); } @@ -311,6 +326,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre @Override public void handle(com.lark.oapi.service.im.v1.model.P2ChatMemberBotAddedV1 event) {} }) + // Interactive card button clicks (Schema 2.0) — routed through cardDispatcher + .onP2CardActionTrigger(new com.lark.oapi.event.cardcallback.P2CardActionTriggerHandler() { + @Override + public com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse handle( + com.lark.oapi.event.cardcallback.model.P2CardActionTrigger event) { + if (!running.get()) return null; + try { + handleCardActionTrigger(event); + } catch (Exception e) { + log.error("[feishu] Failed to handle card action: {}", e.getMessage(), e); + } + return null; + } + }) .build(); return new com.lark.oapi.ws.Client.Builder(appId, appSecret) @@ -860,6 +889,70 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre addReactionAsync(messageId, "DONE"); } + // ==================== Approval card ==================== + + /** + * Render the approval notice as a Schema-2.0 interactive button card + * so the user can approve / deny in-channel without bouncing to the + * web UI. Falls back to the inherited markdown-text path when the + * dispatcher isn't wired (legacy constructors / test rigs), the + * card oversizes, or the platform refuses to deliver. + */ + @Override + public void sendApprovalNotice(String targetId, + vip.mate.channel.notification.ApprovalNotice notice) { + if (cardDispatcher == null || notice == null || targetId == null) { + super.sendApprovalNotice(targetId, notice); + return; + } + var kindOpt = cardDispatcher.lookupByName( + vip.mate.channel.feishu.cards.tool_guard.ToolGuardCardKindFactory.KIND_NAME); + if (kindOpt.isEmpty()) { + super.sendApprovalNotice(targetId, notice); + return; + } + try { + Map cardJson = kindOpt.get().renderer().render(notice); + boolean sent = sendCard(targetId, cardJson); + if (!sent) { + log.warn("[feishu-toolguard] sendCard returned false; falling back to text"); + super.sendApprovalNotice(targetId, notice); + } + } catch (vip.mate.channel.cards.CardOversizedException e) { + log.warn("[feishu-toolguard] approval card oversized ({}); falling back to text", e.getMessage()); + super.sendApprovalNotice(targetId, notice); + } catch (Exception e) { + log.error("[feishu-toolguard] render/send approval card failed; falling back to text: {}", + e.getMessage(), e); + super.sendApprovalNotice(targetId, notice); + } + } + + /** + * Dispatch a {@code P2CardActionTrigger} (button click on an + * interactive card) to the matching {@code FeishuCardKind} via the + * dispatcher. No-op if no dispatcher wired or no kind matches. + */ + private void handleCardActionTrigger( + com.lark.oapi.event.cardcallback.model.P2CardActionTrigger event) { + if (cardDispatcher == null || event == null || event.getEvent() == null) { + return; + } + com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData data = event.getEvent(); + com.lark.oapi.event.cardcallback.model.CallBackAction action = data.getAction(); + if (action == null || action.getValue() == null) { + return; + } + Object actionField = action.getValue().get("action"); + String actionStr = actionField != null ? actionField.toString() : null; + var kindOpt = cardDispatcher.lookupByAction(actionStr); + if (kindOpt.isEmpty()) { + log.debug("[feishu] No card kind registered for action={}", actionStr); + return; + } + kindOpt.get().handler().handle(this, data); + } + /** * 非阻塞地给消息添加表情反应 * 在新线程中执行,失败只 log.debug 不影响主流程 diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardDispatcher.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardDispatcher.java new file mode 100644 index 00000000..f54880e1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardDispatcher.java @@ -0,0 +1,97 @@ +package vip.mate.channel.feishu.cards; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.channel.feishu.cards.tool_guard.ToolGuardCardKindFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Routing-only dispatcher for Feishu interactive cards. + * + *

Maintains a single index keyed by {@link FeishuCardKind#actionPrefix} — + * the inbound {@code P2CardActionTrigger} payload carries + * {@code action.value.action} (a string we put there during render), + * and the dispatcher picks the handler whose prefix matches. + * + *

Card kinds must use disjoint prefixes; collision throws at + * registration time. Mirror image of {@code WeComCardDispatcher} — + * same shape, same disjoint-prefix invariant — but parameterised on + * Feishu's {@code action.value} discriminator rather than WeCom's + * {@code template_card_event.task_id} prefix. + * + *

Outbound rendering today has a single direct caller + * ({@code FeishuChannelAdapter.sendApprovalNotice}) which always wants + * the tool-guard kind, so no outbound discriminator is needed yet. + * Adding more outbound kinds: introduce a second index keyed by + * {@code metadata.message_type} the way WeCom does. + */ +@Slf4j +@Component +public class FeishuCardDispatcher { + + /** {@code action.value.action} prefix → kind. */ + private final Map byActionPrefix = new HashMap<>(); + + /** {@code name} → kind, for outbound lookup by callers that know the kind name. */ + private final Map byName = new HashMap<>(); + + private final ToolGuardCardKindFactory toolGuardFactory; + + public FeishuCardDispatcher(ToolGuardCardKindFactory toolGuardFactory) { + this.toolGuardFactory = toolGuardFactory; + registerKinds(); + } + + private void registerKinds() { + // Currently single kind. Add lines here as new card kinds land. + // Order doesn't matter — disjoint-prefix invariant prevents ambiguity. + register(toolGuardFactory.create()); + } + + private void register(FeishuCardKind kind) { + if (byActionPrefix.containsKey(kind.actionPrefix())) { + throw new IllegalStateException( + "duplicate card kind for actionPrefix '" + kind.actionPrefix() + + "': existing=" + byActionPrefix.get(kind.actionPrefix()).name() + + ", new=" + kind.name()); + } + if (byName.containsKey(kind.name())) { + throw new IllegalStateException( + "duplicate card kind name '" + kind.name() + "'"); + } + byActionPrefix.put(kind.actionPrefix(), kind); + byName.put(kind.name(), kind); + log.info("[feishu-cards] Registered card kind: name={} actionPrefix={}", + kind.name(), kind.actionPrefix()); + } + + /** Look up a card kind by its registered name (outbound). */ + public Optional lookupByName(String name) { + if (name == null || name.isBlank()) return Optional.empty(); + return Optional.ofNullable(byName.get(name)); + } + + /** + * Look up a card kind by the inbound {@code action.value.action} + * string's prefix. O(N) over registered kinds (N is small — + * currently 1). + */ + public Optional lookupByAction(String action) { + if (action == null || action.isBlank()) return Optional.empty(); + for (Map.Entry e : byActionPrefix.entrySet()) { + if (action.startsWith(e.getKey())) { + return Optional.of(e.getValue()); + } + } + return Optional.empty(); + } + + /** Visible for tests / logs. */ + public List registeredKindNames() { + return List.copyOf(byName.keySet()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java new file mode 100644 index 00000000..dce6e872 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java @@ -0,0 +1,36 @@ +package vip.mate.channel.feishu.cards; + +import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData; +import vip.mate.channel.feishu.FeishuChannelAdapter; + +/** + * Process an inbound {@code P2CardActionTrigger} event for one kind of + * interactive card (e.g. a tool-guard approval card). + * + *

Implementations must: + *

    + *
  1. Validate the click — decode the button value, look up the pending + * business object, identity-check the clicker against the original + * requester.
  2. + *
  3. Update the original card to a resolved state via + * {@link FeishuChannelAdapter#updateCard} so the user sees an + * immediate "✅ 已批准" / "🚫 已拒绝" / unauthorized / expired + * confirmation. Must complete inside Feishu's response window + * (the SDK gives ~3 seconds before timing out the callback).
  4. + *
  5. Trigger any agent-side follow-up (e.g. resolve the approval, + * inject a synthetic command) — this step may be slower than the + * card-update step.
  6. + *
+ */ +@FunctionalInterface +public interface FeishuCardHandler { + /** + * @param adapter the live Feishu adapter (provides {@code updateCard}, + * {@code messageRouter}, SDK client, etc.) + * @param data the parsed {@code P2CardActionTriggerData} payload — + * contains the operator, action.value, the card token, + * and {@code context} (containing the + * {@code open_message_id} of the original card) + */ + void handle(FeishuChannelAdapter adapter, P2CardActionTriggerData data); +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardKind.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardKind.java new file mode 100644 index 00000000..4a882fd9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardKind.java @@ -0,0 +1,45 @@ +package vip.mate.channel.feishu.cards; + +import vip.mate.channel.cards.CardOversizedException; + +/** + * Description of one kind of interactive Feishu card the dispatcher + * knows how to route — its outbound render path plus its inbound click + * handler. + * + *

Disjoint-prefix invariant on {@link #actionPrefix} — the inbound + * dispatcher picks a handler by matching the prefix of the button + * {@code value.action} string, so two card kinds MUST NOT share a + * prefix (the dispatcher rejects collisions at registration time). + * Kept as a simple record so adding a new kind is just: implement + * renderer/handler, register a new {@code FeishuCardKind} in + * {@link FeishuCardDispatcher#registerKinds()}. + * + * @param name short human-readable label for logs + * @param actionPrefix matches the prefix of inbound + * {@code action.value.action} (drives the inbound + * {@code handle} dispatch). E.g. + * {@code "tg_approval."} for tool-guard buttons. + * @param renderer converts a pending business object (e.g. + * {@link vip.mate.channel.notification.ApprovalNotice}) + * into a Feishu interactive-card payload Map. + * Throws {@link CardOversizedException} to signal + * "this kind can't render now, fall back to text". + * @param handler processes an inbound {@code P2CardActionTrigger} + * event for this kind. + */ +public record FeishuCardKind( + String name, + String actionPrefix, + FeishuCardRenderer renderer, + FeishuCardHandler handler +) { + public FeishuCardKind { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("FeishuCardKind.name must not be blank"); + } + if (actionPrefix == null || actionPrefix.isBlank()) { + throw new IllegalArgumentException("FeishuCardKind.actionPrefix must not be blank"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardRenderer.java new file mode 100644 index 00000000..7e342e42 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardRenderer.java @@ -0,0 +1,28 @@ +package vip.mate.channel.feishu.cards; + +import vip.mate.channel.cards.CardOversizedException; +import vip.mate.channel.notification.ApprovalNotice; + +import java.util.Map; + +/** + * Build a Feishu interactive-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 on the {@code AbstractChannelAdapter} default). Anything + * else surfaces as a bug. + * + *

Currently parameterised on {@link ApprovalNotice} since tool-guard + * is the only card kind in this PR; future kinds (poll cards, info- + * request cards, etc.) will likely take a different input or accept + * {@code Object} and self-cast. + */ +@FunctionalInterface +public interface FeishuCardRenderer { + /** + * Build the Schema-2.0 interactive-card body Map ready to drop into + * {@code im/v1/messages.create} with {@code msg_type=interactive}. + */ + Map render(ApprovalNotice notice) throws CardOversizedException; +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValue.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValue.java new file mode 100644 index 00000000..7eef4eb8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValue.java @@ -0,0 +1,115 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import vip.mate.channel.cards.CardOversizedException; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Encode / decode the button {@code value} field on a tool-guard + * approval card. + * + *

Feishu Schema-2.0 buttons carry a free-form JSON object as + * {@code value}. The server echoes it back inside the inbound + * {@code P2CardActionTrigger}'s {@code action.value}. We pack just + * enough to recover the pending approval (the {@code pendingId} + * alone is enough — mateclaw's {@code ApprovalService.getPending} + * resolves the rest, including the original requester). Sender / chat + * context is intentionally not packed — the inbound handler runs in- + * process and can look it up synchronously. + * + *

Feishu does not publish an explicit byte ceiling on the value + * field but interactive-content as a whole is capped at ~30 KB. + * {@link #MAX_VALUE_BYTES} keeps our share well below that so the rest + * of the card body fits even when the tool name is verbose. + */ +public final class ToolGuardButtonValue { + + /** Discriminator action prefix shared with {@link ToolGuardCardKindFactory}. */ + public static final String ACTION_PREFIX = "tg_approval."; + + public static final String ACTION_APPROVE = ACTION_PREFIX + "approve"; + public static final String ACTION_DENY = ACTION_PREFIX + "deny"; + + /** Soft cap on the serialised value payload (well below Feishu's overall ~30 KB cap). */ + public static final int MAX_VALUE_BYTES = 2048; + + public enum Action { + APPROVE(ACTION_APPROVE), + DENY(ACTION_DENY); + + public final String wireValue; + Action(String v) { this.wireValue = v; } + + public static Action fromWire(String v) { + if (v == null) return null; + if (ACTION_APPROVE.equals(v)) return APPROVE; + if (ACTION_DENY.equals(v)) return DENY; + return null; + } + } + + public record Decoded(Action action, String pendingId, String toolName, String severity) {} + + private final ObjectMapper objectMapper; + + public ToolGuardButtonValue(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Build the Map that goes into the Schema-2.0 button's {@code value} + * field. LinkedHashMap so the JSON serialisation order is stable — + * makes byte-length predictable and snapshot-testable. + * + * @throws CardOversizedException when the resulting JSON would + * exceed {@link #MAX_VALUE_BYTES}; caller falls back to text + */ + public Map encode(Action action, String pendingId, String toolName, String severity) { + Map payload = new LinkedHashMap<>(); + payload.put("action", action.wireValue); + payload.put("rid", pendingId); + payload.put("tool", toolName == null ? "" : toolName); + payload.put("sev", severity == null ? "" : severity); + // Size-check via a one-off JSON serialisation so we surface the + // overflow at render time rather than letting Feishu reject the + // whole interactive message at send time. + try { + String json = objectMapper.writeValueAsString(payload); + int bytes = json.getBytes(StandardCharsets.UTF_8).length; + if (bytes > MAX_VALUE_BYTES) { + throw new CardOversizedException( + "tool_guard button.value payload " + bytes + " bytes > limit " + MAX_VALUE_BYTES); + } + } catch (CardOversizedException e) { + throw e; + } catch (Exception e) { + throw new CardOversizedException("failed to serialise button.value: " + e.getMessage()); + } + return payload; + } + + /** + * Decode the {@code action.value} echoed back by Feishu on click. + * Returns null if the payload is malformed or the action + * unrecognised. Callers should treat null as "ignore this event". + */ + public Decoded decode(Map value) { + if (value == null || value.isEmpty()) return null; + Action action = Action.fromWire(asString(value.get("action"))); + if (action == null) return null; + String pendingId = asString(value.get("rid")); + if (pendingId == null || pendingId.isBlank()) return null; + return new Decoded( + action, + pendingId, + asString(value.getOrDefault("tool", "")), + asString(value.getOrDefault("sev", ""))); + } + + private static String asString(Object o) { + return o == null ? null : o.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java new file mode 100644 index 00000000..430a8ea7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java @@ -0,0 +1,170 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import com.lark.oapi.event.cardcallback.model.CallBackAction; +import com.lark.oapi.event.cardcallback.model.CallBackContext; +import com.lark.oapi.event.cardcallback.model.CallBackOperator; +import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData; +import lombok.extern.slf4j.Slf4j; +import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.approval.PendingApproval; +import vip.mate.channel.feishu.FeishuChannelAdapter; +import vip.mate.channel.feishu.cards.FeishuCardHandler; + +import java.util.Optional; + +/** + * Process an inbound {@code P2CardActionTrigger} for the tool-guard + * approval card. + * + *

Step ordering — validate before render: a non-original- + * requester click must NOT briefly show "已批准 by ..." before the + * router drops the injected command. Order: + *

    + *
  1. Decode {@code action.value} → null check
  2. + *
  3. Look up {@code PendingApproval} by id
  4. + *
  5. Identity check: clicker (open_id) vs original requester
  6. + *
  7. Update the original card to a resolved state (success / + * unauthorized / expired)
  8. + *
  9. Resolve the approval via {@link ApprovalService#resolve}
  10. + *
+ * + *

Steps 1–4 must complete inside Feishu's response window; step 5 + * can be slower. + */ +@Slf4j +public class ToolGuardCardHandler implements FeishuCardHandler { + + private final ApprovalService approvalService; + private final ApprovalWorkflowService approvalWorkflowService; + private final ToolGuardButtonValue buttonValue; + + public ToolGuardCardHandler(ApprovalService approvalService, + ApprovalWorkflowService approvalWorkflowService, + ToolGuardButtonValue buttonValue) { + this.approvalService = approvalService; + this.approvalWorkflowService = approvalWorkflowService; + this.buttonValue = buttonValue; + } + + @Override + public void handle(FeishuChannelAdapter adapter, P2CardActionTriggerData data) { + if (data == null) { + log.warn("[feishu-toolguard] handle called with null data"); + return; + } + CallBackAction action = data.getAction(); + CallBackOperator operator = data.getOperator(); + CallBackContext context = data.getContext(); + String messageId = context != null ? context.getOpenMessageId() : null; + String clickerOpenId = operator != null ? operator.getOpenId() : null; + + // ---- 1. Decode button value + ToolGuardButtonValue.Decoded decoded = action != null + ? buttonValue.decode(action.getValue()) + : null; + if (decoded == null) { + log.warn("[feishu-toolguard] Could not decode action.value (messageId={}, clicker={})", + abbrev(messageId), abbrev(clickerOpenId)); + return; + } + String pendingId = decoded.pendingId(); + ToolGuardButtonValue.Action act = decoded.action(); + + // ---- 2. Look up pending approval + Optional opt = approvalService.getPending(pendingId); + if (opt.isEmpty() || !"pending".equals(opt.get().getStatus())) { + log.info("[feishu-toolguard] Pending {} not found / already resolved (action={}, clicker={})", + pendingId, act, abbrev(clickerOpenId)); + updateExpired(adapter, messageId, decoded.toolName()); + return; + } + PendingApproval pending = opt.get(); + + // ---- 3. Identity check + String originalRequester = pending.getUserId(); + boolean authorized = originalRequester == null + || "system".equals(originalRequester) + || originalRequester.equals(clickerOpenId); + if (!authorized) { + log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}", + abbrev(clickerOpenId), abbrev(originalRequester), pendingId); + updateUnauthorized(adapter, messageId, decoded.toolName(), originalRequester); + return; + } + + // ---- 4. Render resolved card (must finish inside Feishu's response window) + updateResolved(adapter, messageId, decoded.toolName(), act, clickerOpenId); + + // ---- 5. Resolve the approval via the canonical workflow service + String decisionLabel = act == ToolGuardButtonValue.Action.APPROVE ? "approved" : "denied"; + String actor = clickerOpenId == null ? "feishu-card" : clickerOpenId; + try { + approvalWorkflowService.resolve(pendingId, actor, decisionLabel); + log.info("[feishu-toolguard] Resolved pending={} decision={} actor={}", + pendingId, decisionLabel, abbrev(actor)); + } catch (Exception e) { + // Never let a resolve failure leave the card looking applied. + // Card already shows resolved-state, but the agent won't see + // the decision — operator log is the safety net. + log.error("[feishu-toolguard] resolve {} failed for pending={}: {}", + decisionLabel, pendingId, e.getMessage(), e); + } + } + + // ------------------------------------------------------------------ + // Card render helpers + // ------------------------------------------------------------------ + + private static void updateResolved(FeishuChannelAdapter adapter, String messageId, + String toolName, ToolGuardButtonValue.Action act, String clicker) { + if (messageId == null || messageId.isBlank()) { + log.debug("[feishu-toolguard] No messageId on inbound — cannot update card (will rely on resolve event)"); + return; + } + boolean approve = act == ToolGuardButtonValue.Action.APPROVE; + String title = approve ? "✅ 已批准" : "🚫 已拒绝"; + String template = approve ? "green" : "red"; + StringBuilder desc = new StringBuilder(); + desc.append("**工具**: `").append(toolName == null ? "" : toolName).append("`\n"); + desc.append("**操作者**: ").append(abbrev(clicker)); + try { + adapter.updateCard(messageId, + ToolGuardCardRenderer.buildResolvedCard(title, desc.toString(), template)); + } catch (Exception e) { + log.warn("[feishu-toolguard] update_card (resolved) failed: {}", e.getMessage()); + } + } + + private static void updateUnauthorized(FeishuChannelAdapter adapter, String messageId, + String toolName, String originalRequester) { + if (messageId == null || messageId.isBlank()) return; + String desc = "**工具**: `" + (toolName == null ? "" : toolName) + "`\n" + + "**原请求者**: " + abbrev(originalRequester) + "\n" + + "*仅原请求者可批准 / 拒绝该操作*"; + try { + adapter.updateCard(messageId, + ToolGuardCardRenderer.buildResolvedCard("❌ 仅原请求者可审批", desc, "grey")); + } catch (Exception e) { + log.warn("[feishu-toolguard] update_card (unauthorized) failed: {}", e.getMessage()); + } + } + + private static void updateExpired(FeishuChannelAdapter adapter, String messageId, String toolName) { + if (messageId == null || messageId.isBlank()) return; + String desc = "**工具**: `" + (toolName == null ? "" : toolName) + "`\n" + + "*该审批已过期或已被处理*"; + try { + adapter.updateCard(messageId, + ToolGuardCardRenderer.buildResolvedCard("⌛ 审批已失效", desc, "grey")); + } catch (Exception e) { + log.warn("[feishu-toolguard] update_card (expired) failed: {}", e.getMessage()); + } + } + + private static String abbrev(String s) { + if (s == null || s.isBlank()) return ""; + if (s.length() <= 12) return s; + return s.substring(0, 12) + "…"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java new file mode 100644 index 00000000..fe27be46 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java @@ -0,0 +1,48 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.channel.feishu.cards.FeishuCardKind; + +/** + * Spring-managed factory that produces the tool-guard card kind for + * {@link vip.mate.channel.feishu.cards.FeishuCardDispatcher}. + * + *

Plain {@code @Component} so the dispatcher can constructor-inject + * it. Each call to {@link #create()} returns a freshly constructed + * {@link FeishuCardKind}; the dispatcher caches the result and queries + * it for life of the JVM. + */ +@Component("feishuToolGuardCardKindFactory") +public class ToolGuardCardKindFactory { + + /** + * Kind name — used by callers that look up the kind by name for + * outbound render (today only {@code FeishuChannelAdapter.sendApprovalNotice}). + */ + public static final String KIND_NAME = "tool_guard_approval"; + + /** Discriminator prefix on inbound {@code action.value.action}. */ + public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX; + + private final ApprovalService approvalService; + private final ApprovalWorkflowService approvalWorkflowService; + private final ObjectMapper objectMapper; + + public ToolGuardCardKindFactory(ApprovalService approvalService, + ApprovalWorkflowService approvalWorkflowService, + ObjectMapper objectMapper) { + this.approvalService = approvalService; + this.approvalWorkflowService = approvalWorkflowService; + this.objectMapper = objectMapper; + } + + public FeishuCardKind create() { + ToolGuardButtonValue buttonValue = new ToolGuardButtonValue(objectMapper); + ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonValue); + ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, approvalWorkflowService, buttonValue); + return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java new file mode 100644 index 00000000..01b8d55f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java @@ -0,0 +1,173 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import vip.mate.channel.cards.CardOversizedException; +import vip.mate.channel.feishu.cards.FeishuCardRenderer; +import vip.mate.channel.notification.ApprovalNotice; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Build the Feishu Schema-2.0 button-card payload from an + * {@link ApprovalNotice}. + * + *

Card structure (Schema 2.0 interactive card): + *

+ * {
+ *   "schema": "2.0",
+ *   "header": {"title": {"tag": "plain_text", "content": "🛡️ 工具审批"}, "template": "orange"},
+ *   "body": {
+ *     "elements": [
+ *       {"tag": "markdown", "content": "<tool / risk / args summary>"},
+ *       {"tag": "action", "actions": [
+ *         {"tag": "button", "text": {...}, "type": "primary", "value": <approve payload>},
+ *         {"tag": "button", "text": {...}, "type": "danger",  "value": <deny payload>}
+ *       ]}
+ *     ]
+ *   }
+ * }
+ * 
+ * + *

If either button.value would exceed the size ceiling, the encoder + * throws {@link CardOversizedException} and the calling adapter falls + * back to the {@link vip.mate.channel.AbstractChannelAdapter} text- + * approval path. + */ +public class ToolGuardCardRenderer implements FeishuCardRenderer { + + private final ToolGuardButtonValue buttonValue; + + public ToolGuardCardRenderer(ToolGuardButtonValue buttonValue) { + this.buttonValue = buttonValue; + } + + @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 button values FIRST so a size overflow throws before + // we build any cosmetic body. + Map approveValue = buttonValue.encode( + ToolGuardButtonValue.Action.APPROVE, pendingId, toolName, severity); + Map denyValue = buttonValue.encode( + ToolGuardButtonValue.Action.DENY, pendingId, toolName, severity); + + // Header + Map title = new LinkedHashMap<>(); + title.put("tag", "plain_text"); + title.put("content", "🛡️ 工具审批"); + Map header = new LinkedHashMap<>(); + header.put("title", title); + header.put("template", severityToTemplate(severity)); + + // Markdown summary + Map markdown = new LinkedHashMap<>(); + markdown.put("tag", "markdown"); + markdown.put("content", buildSummaryMarkdown(notice, toolName, severity)); + + // Buttons + Map approveBtn = new LinkedHashMap<>(); + approveBtn.put("tag", "button"); + approveBtn.put("text", plainText("批准")); + approveBtn.put("type", "primary"); + approveBtn.put("value", approveValue); + + Map denyBtn = new LinkedHashMap<>(); + denyBtn.put("tag", "button"); + denyBtn.put("text", plainText("拒绝")); + denyBtn.put("type", "danger"); + denyBtn.put("value", denyValue); + + Map actionElement = new LinkedHashMap<>(); + actionElement.put("tag", "action"); + actionElement.put("actions", List.of(approveBtn, denyBtn)); + + Map body = new LinkedHashMap<>(); + body.put("elements", List.of(markdown, actionElement)); + + Map card = new LinkedHashMap<>(); + card.put("schema", "2.0"); + card.put("header", header); + card.put("body", body); + return card; + } + + /** + * Build the resolved-state card (same task_id semantics — Feishu + * cards are updated in-place by message_id; no task_id needed). + * + * @param title headline like "✅ 已批准 by 张三" + * @param desc optional detail line + * @param template "green" / "red" / "grey" / "blue" — header colour + */ + public static Map buildResolvedCard(String title, String desc, String template) { + Map titleObj = new LinkedHashMap<>(); + titleObj.put("tag", "plain_text"); + titleObj.put("content", title == null ? "" : title); + Map header = new LinkedHashMap<>(); + header.put("title", titleObj); + header.put("template", template == null ? "grey" : template); + + Map markdown = new LinkedHashMap<>(); + markdown.put("tag", "markdown"); + markdown.put("content", desc == null ? "" : desc); + + Map body = new LinkedHashMap<>(); + body.put("elements", List.of(markdown)); + + Map card = new LinkedHashMap<>(); + card.put("schema", "2.0"); + card.put("header", header); + card.put("body", body); + return card; + } + + private static String buildSummaryMarkdown(ApprovalNotice notice, String toolName, String severity) { + StringBuilder sb = new StringBuilder(); + sb.append("**工具**: `").append(toolName).append("`\n"); + sb.append("**风险等级**: ").append(severityLabel(severity)).append("\n"); + if (notice.summary() != null && !notice.summary().isBlank()) { + sb.append("**摘要**: ").append(notice.summary()).append("\n"); + } + if (notice.argumentsPreview() != null && !notice.argumentsPreview().isBlank()) { + String args = notice.argumentsPreview(); + if (args.length() > 200) args = args.substring(0, 200) + "…"; + sb.append("**参数**: `").append(args).append("`"); + } + return sb.toString(); + } + + private static String severityLabel(String severity) { + return switch (severity.toUpperCase()) { + case "CRITICAL" -> "🔴 CRITICAL"; + case "HIGH" -> "🟠 HIGH"; + case "MEDIUM" -> "🟡 MEDIUM"; + case "LOW" -> "🔵 LOW"; + case "INFO" -> "⚪ INFO"; + default -> severity; + }; + } + + private static String severityToTemplate(String severity) { + return switch (severity.toUpperCase()) { + case "CRITICAL", "HIGH" -> "orange"; + case "MEDIUM" -> "yellow"; + case "LOW", "INFO" -> "blue"; + default -> "orange"; + }; + } + + private static Map plainText(String content) { + Map m = new LinkedHashMap<>(); + m.put("tag", "plain_text"); + m.put("content", content); + return m; + } + + private static String nullSafe(String v, String fallback) { + return (v == null || v.isBlank()) ? fallback : v; + } +} 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 aa27f7f8..32d8444d 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 @@ -1303,7 +1303,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { try { Map card = kindOpt.get().renderer().render(notice); replyTemplateCard(ctx.frameReqId(), card); - } catch (vip.mate.channel.wecom.cards.CardOversizedException oversized) { + } catch (vip.mate.channel.cards.CardOversizedException oversized) { log.warn("[wecom] approval card oversized, falling back to text: {}", oversized.getMessage()); super.sendApprovalNotice(targetId, notice); } catch (Exception e) { 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 deleted file mode 100644 index 1ed8cf03..00000000 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/CardOversizedException.java +++ /dev/null @@ -1,15 +0,0 @@ -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/WeComCardKind.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/WeComCardKind.java index 64bc71fa..d2c6a87a 100644 --- 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 @@ -1,5 +1,7 @@ package vip.mate.channel.wecom.cards; +import vip.mate.channel.cards.CardOversizedException; + /** * Description of one kind of interactive WeCom template card the * dispatcher knows how to route, along with the two functional callbacks 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 index 9248f25d..2062948a 100644 --- 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 @@ -1,5 +1,7 @@ package vip.mate.channel.wecom.cards; +import vip.mate.channel.cards.CardOversizedException; + import vip.mate.channel.notification.ApprovalNotice; import java.util.Map; 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 index 43687cb8..2e182830 100644 --- 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 @@ -2,7 +2,7 @@ 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 vip.mate.channel.cards.CardOversizedException; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; 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 index 0addd2a5..6498c3e4 100644 --- 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 @@ -1,7 +1,7 @@ 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.cards.CardOversizedException; import vip.mate.channel.wecom.cards.WeComCardRenderer; import java.util.LinkedHashMap; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java index 244369c4..4db97a05 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -59,6 +59,7 @@ class ChannelManagerReconcileTest { mock(vip.mate.channel.feishu.FeishuMediaUploader.class), mock(vip.mate.channel.media.GeneratedFileScrubber.class), mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class), + mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class), election); adapter = new TrackingAdapter(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java index 8aa9e8ca..d1c5a287 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java @@ -34,6 +34,7 @@ class FeishuMediaWiringIT { @Autowired private FeishuSizePolicy sizePolicy; @Autowired private GeneratedFileScrubber scrubber; @Autowired private FeishuStreamingCardManager streamingCardManager; + @Autowired private vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher; @Autowired private List uploaderBeans; @Autowired private List policyBeans; @@ -45,6 +46,9 @@ class FeishuMediaWiringIT { assertNotNull(sizePolicy); assertNotNull(scrubber); assertNotNull(streamingCardManager); + assertNotNull(cardDispatcher); + assertTrue(cardDispatcher.registeredKindNames().contains("tool_guard_approval"), + "tool_guard kind should be auto-registered on dispatcher construction"); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java new file mode 100644 index 00000000..f1c6691a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java @@ -0,0 +1,81 @@ +package vip.mate.channel.feishu.cards; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.channel.feishu.cards.tool_guard.ToolGuardButtonValue; +import vip.mate.channel.feishu.cards.tool_guard.ToolGuardCardKindFactory; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Pin the dispatcher's registration + lookup invariants. + */ +class FeishuCardDispatcherTest { + + private FeishuCardDispatcher newDispatcher() { + ToolGuardCardKindFactory factory = new ToolGuardCardKindFactory( + mock(ApprovalService.class), + mock(ApprovalWorkflowService.class), + new ObjectMapper()); + return new FeishuCardDispatcher(factory); + } + + @Test + @DisplayName("tool_guard kind is registered after construction") + void toolGuardRegistered() { + FeishuCardDispatcher d = newDispatcher(); + assertTrue(d.registeredKindNames().contains(ToolGuardCardKindFactory.KIND_NAME)); + assertEquals(1, d.registeredKindNames().size()); + } + + @Test + @DisplayName("lookupByName returns the registered kind") + void lookupByName() { + FeishuCardDispatcher d = newDispatcher(); + Optional opt = d.lookupByName(ToolGuardCardKindFactory.KIND_NAME); + assertTrue(opt.isPresent()); + assertEquals(ToolGuardCardKindFactory.KIND_NAME, opt.get().name()); + + assertFalse(d.lookupByName("nonexistent").isPresent()); + assertFalse(d.lookupByName(null).isPresent()); + assertFalse(d.lookupByName("").isPresent()); + } + + @Test + @DisplayName("lookupByAction matches the prefix and ignores unknown actions") + void lookupByAction() { + FeishuCardDispatcher d = newDispatcher(); + Optional approve = d.lookupByAction(ToolGuardButtonValue.ACTION_APPROVE); + assertTrue(approve.isPresent()); + Optional deny = d.lookupByAction(ToolGuardButtonValue.ACTION_DENY); + assertTrue(deny.isPresent()); + // Any string starting with the prefix matches + assertTrue(d.lookupByAction("tg_approval.future_subaction").isPresent()); + + assertFalse(d.lookupByAction("unknown.action").isPresent()); + assertFalse(d.lookupByAction(null).isPresent()); + assertFalse(d.lookupByAction("").isPresent()); + } + + @Test + @DisplayName("FeishuCardKind constructor rejects blank name / prefix") + void cardKindValidation() { + FeishuCardKind valid = new FeishuCardKind( + "ok", "ok.", (n) -> java.util.Map.of(), (adapter, data) -> {}); + assertEquals("ok", valid.name()); + + assertThrows(IllegalArgumentException.class, + () -> new FeishuCardKind("", "x.", (n) -> java.util.Map.of(), (a, d) -> {})); + assertThrows(IllegalArgumentException.class, + () -> new FeishuCardKind("ok", " ", (n) -> java.util.Map.of(), (a, d) -> {})); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValueTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValueTest.java new file mode 100644 index 00000000..0206b5b0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardButtonValueTest.java @@ -0,0 +1,99 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.cards.CardOversizedException; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Pin the encode / decode contract for the tool-guard approval card + * button {@code value} field. Round-trips matter because Feishu + * deserialises the value to a Map and we need to identify which + * pending approval and which decision the click came from. + */ +class ToolGuardButtonValueTest { + + private final ToolGuardButtonValue encoder = new ToolGuardButtonValue(new ObjectMapper()); + + @Test + @DisplayName("approve round-trip preserves action / pendingId / tool / severity") + void approveRoundTrip() { + Map value = encoder.encode( + ToolGuardButtonValue.Action.APPROVE, "pend-1", "feishu_doc_create", "HIGH"); + + ToolGuardButtonValue.Decoded decoded = encoder.decode(value); + assertNotNull(decoded); + assertEquals(ToolGuardButtonValue.Action.APPROVE, decoded.action()); + assertEquals("pend-1", decoded.pendingId()); + assertEquals("feishu_doc_create", decoded.toolName()); + assertEquals("HIGH", decoded.severity()); + } + + @Test + @DisplayName("deny round-trip preserves the deny action") + void denyRoundTrip() { + Map value = encoder.encode( + ToolGuardButtonValue.Action.DENY, "pend-2", "feishu_calendar_create_event", "MEDIUM"); + ToolGuardButtonValue.Decoded decoded = encoder.decode(value); + assertEquals(ToolGuardButtonValue.Action.DENY, decoded.action()); + assertEquals("pend-2", decoded.pendingId()); + } + + @Test + @DisplayName("decode rejects unknown / missing action with null") + void decodeRejectsUnknownAction() { + Map bad = new HashMap<>(); + bad.put("action", "unknown.thing"); + bad.put("rid", "pend-1"); + assertNull(encoder.decode(bad)); + + bad.remove("action"); + assertNull(encoder.decode(bad)); + } + + @Test + @DisplayName("decode rejects missing pendingId with null") + void decodeRejectsMissingPendingId() { + Map bad = new HashMap<>(); + bad.put("action", ToolGuardButtonValue.ACTION_APPROVE); + assertNull(encoder.decode(bad)); + + bad.put("rid", ""); + assertNull(encoder.decode(bad)); + } + + @Test + @DisplayName("decode is tolerant of null / empty input") + void decodeTolerantOfNullEmpty() { + assertNull(encoder.decode(null)); + assertNull(encoder.decode(Map.of())); + } + + @Test + @DisplayName("encode rejects oversize payload with CardOversizedException") + void encodeRejectsOversize() { + // Build a tool name that will push the JSON well past MAX_VALUE_BYTES (2048) + String huge = "x".repeat(3000); + CardOversizedException ex = assertThrows(CardOversizedException.class, + () -> encoder.encode(ToolGuardButtonValue.Action.APPROVE, "pend-1", huge, "HIGH")); + assertEquals(true, ex.getMessage().contains("> limit")); + } + + @Test + @DisplayName("encoded JSON key order is stable (LinkedHashMap → predictable byte length)") + void encodedFieldOrderStable() { + Map a = encoder.encode( + ToolGuardButtonValue.Action.APPROVE, "p-1", "tool-a", "HIGH"); + Map b = encoder.encode( + ToolGuardButtonValue.Action.APPROVE, "p-1", "tool-a", "HIGH"); + assertEquals(a.keySet().iterator().next(), b.keySet().iterator().next()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java new file mode 100644 index 00000000..e927e35c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java @@ -0,0 +1,98 @@ +package vip.mate.channel.feishu.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.notification.ApprovalNotice; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the shape of the rendered Schema-2.0 button card so a Feishu + * spec tweak (button format change, action element rename) breaks + * loudly here rather than in production. + */ +class ToolGuardCardRendererTest { + + private final ToolGuardCardRenderer renderer = new ToolGuardCardRenderer( + new ToolGuardButtonValue(new ObjectMapper())); + + @SuppressWarnings("unchecked") + @Test + @DisplayName("rendered card carries schema 2.0, header, summary markdown, and two buttons") + void cardShape() { + ApprovalNotice notice = new ApprovalNotice( + "pend-1", "feishu_doc_create", "Create a new Doc", + "{\"title\":\"meeting notes\"}", "HIGH", + List.of(Map.of("severity", "HIGH", "title", "Mutating Feishu doc")), + "/approve pend-1", "/deny pend-1"); + + Map card = renderer.render(notice); + + assertEquals("2.0", card.get("schema")); + Map header = (Map) card.get("header"); + assertNotNull(header); + Map body = (Map) card.get("body"); + List> elements = (List>) body.get("elements"); + assertEquals(2, elements.size(), "expect markdown + action elements"); + + Map md = elements.get(0); + assertEquals("markdown", md.get("tag")); + String content = (String) md.get("content"); + assertTrue(content.contains("feishu_doc_create"), "tool name in summary"); + assertTrue(content.contains("HIGH"), "severity in summary"); + + Map actions = elements.get(1); + assertEquals("action", actions.get("tag")); + List> buttons = (List>) actions.get("actions"); + assertEquals(2, buttons.size()); + assertEquals("primary", buttons.get(0).get("type")); + assertEquals("danger", buttons.get(1).get("type")); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("approve / deny buttons carry round-trippable action values") + void buttonsCarryRoundTrippableValues() { + ApprovalNotice notice = new ApprovalNotice( + "pend-42", "feishu_calendar_create_event", "schedule meeting", + "{}", "MEDIUM", List.of(), "/approve pend-42", "/deny pend-42"); + + Map card = renderer.render(notice); + List> elements = (List>) ((Map) card.get("body")).get("elements"); + List> buttons = (List>) elements.get(1).get("actions"); + + Map approveValue = (Map) buttons.get(0).get("value"); + assertEquals(ToolGuardButtonValue.ACTION_APPROVE, approveValue.get("action")); + assertEquals("pend-42", approveValue.get("rid")); + + Map denyValue = (Map) buttons.get(1).get("value"); + assertEquals(ToolGuardButtonValue.ACTION_DENY, denyValue.get("action")); + assertEquals("pend-42", denyValue.get("rid")); + } + + @Test + @DisplayName("buildResolvedCard returns a no-action body with the given title + template") + @SuppressWarnings("unchecked") + void buildResolvedCardShape() { + Map card = ToolGuardCardRenderer.buildResolvedCard( + "✅ 已批准", "tool foo approved by Alice", "green"); + + assertEquals("2.0", card.get("schema")); + Map header = (Map) card.get("header"); + assertEquals("green", header.get("template")); + Map title = (Map) header.get("title"); + assertEquals("✅ 已批准", title.get("content")); + + Map body = (Map) card.get("body"); + List> elements = (List>) body.get("elements"); + assertEquals(1, elements.size()); + assertEquals("markdown", elements.get(0).get("tag")); + assertTrue(((String) elements.get(0).get("content")).contains("approved by Alice")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java index a725c407..2a176ce3 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import vip.mate.channel.wecom.cards.CardOversizedException; +import vip.mate.channel.cards.CardOversizedException; import java.nio.charset.StandardCharsets;