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:
+ *
+ * - Validate the click — decode the button value, look up the pending
+ * business object, identity-check the clicker against the original
+ * requester.
+ * - 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).
+ * - 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.
+ *
+ */
+@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:
+ *
+ * - Decode {@code action.value} → null check
+ * - Look up {@code PendingApproval} by id
+ * - Identity check: clicker (open_id) vs original requester
+ * - Update the original card to a resolved state (success /
+ * unauthorized / expired)
+ * - Resolve the approval via {@link ApprovalService#resolve}
+ *
+ *
+ * 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