sync: interactive approval card on Feishu via Schema-2.0 button + card.action callback

This commit is contained in:
matevip 2026-05-20 12:05:47 +08:00
parent 3554da8dbc
commit a9fa8e7fb1
23 changed files with 1125 additions and 22 deletions

View File

@ -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,

View File

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

View File

@ -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: 2s4s8s16s30s, 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<String, Object> 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 不影响主流程

View File

@ -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.
*
* <p>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.
*
* <p>Card kinds <i>must</i> 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.
*
* <p>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<String, FeishuCardKind> byActionPrefix = new HashMap<>();
/** {@code name} → kind, for outbound lookup by callers that know the kind name. */
private final Map<String, FeishuCardKind> 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<FeishuCardKind> 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<FeishuCardKind> lookupByAction(String action) {
if (action == null || action.isBlank()) return Optional.empty();
for (Map.Entry<String, FeishuCardKind> e : byActionPrefix.entrySet()) {
if (action.startsWith(e.getKey())) {
return Optional.of(e.getValue());
}
}
return Optional.empty();
}
/** Visible for tests / logs. */
public List<String> registeredKindNames() {
return List.copyOf(byName.keySet());
}
}

View File

@ -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).
*
* <p>Implementations must:
* <ol>
* <li>Validate the click decode the button value, look up the pending
* business object, identity-check the clicker against the original
* requester.</li>
* <li>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).</li>
* <li>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.</li>
* </ol>
*/
@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);
}

View File

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

View File

@ -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.
*
* <p>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.
*
* <p>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<String, Object> render(ApprovalNotice notice) throws CardOversizedException;
}

View File

@ -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.
*
* <p>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.
*
* <p>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<String, Object> encode(Action action, String pendingId, String toolName, String severity) {
Map<String, Object> 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<String, Object> 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();
}
}

View File

@ -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.
*
* <p><b>Step ordering validate before render</b>: a non-original-
* requester click must NOT briefly show "已批准 by ..." before the
* router drops the injected command. Order:
* <ol>
* <li>Decode {@code action.value} null check</li>
* <li>Look up {@code PendingApproval} by id</li>
* <li>Identity check: clicker (open_id) vs original requester</li>
* <li>Update the original card to a resolved state (success /
* unauthorized / expired)</li>
* <li>Resolve the approval via {@link ApprovalService#resolve}</li>
* </ol>
*
* <p>Steps 14 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<PendingApproval> 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) + "";
}
}

View File

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

View File

@ -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}.
*
* <p>Card structure (Schema 2.0 interactive card):
* <pre>
* {
* "schema": "2.0",
* "header": {"title": {"tag": "plain_text", "content": "🛡️ 工具审批"}, "template": "orange"},
* "body": {
* "elements": [
* {"tag": "markdown", "content": "&lt;tool / risk / args summary&gt;"},
* {"tag": "action", "actions": [
* {"tag": "button", "text": {...}, "type": "primary", "value": &lt;approve payload&gt;},
* {"tag": "button", "text": {...}, "type": "danger", "value": &lt;deny payload&gt;}
* ]}
* ]
* }
* }
* </pre>
*
* <p>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<String, Object> 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<String, Object> approveValue = buttonValue.encode(
ToolGuardButtonValue.Action.APPROVE, pendingId, toolName, severity);
Map<String, Object> denyValue = buttonValue.encode(
ToolGuardButtonValue.Action.DENY, pendingId, toolName, severity);
// Header
Map<String, Object> title = new LinkedHashMap<>();
title.put("tag", "plain_text");
title.put("content", "🛡️ 工具审批");
Map<String, Object> header = new LinkedHashMap<>();
header.put("title", title);
header.put("template", severityToTemplate(severity));
// Markdown summary
Map<String, Object> markdown = new LinkedHashMap<>();
markdown.put("tag", "markdown");
markdown.put("content", buildSummaryMarkdown(notice, toolName, severity));
// Buttons
Map<String, Object> approveBtn = new LinkedHashMap<>();
approveBtn.put("tag", "button");
approveBtn.put("text", plainText("批准"));
approveBtn.put("type", "primary");
approveBtn.put("value", approveValue);
Map<String, Object> denyBtn = new LinkedHashMap<>();
denyBtn.put("tag", "button");
denyBtn.put("text", plainText("拒绝"));
denyBtn.put("type", "danger");
denyBtn.put("value", denyValue);
Map<String, Object> actionElement = new LinkedHashMap<>();
actionElement.put("tag", "action");
actionElement.put("actions", List.of(approveBtn, denyBtn));
Map<String, Object> body = new LinkedHashMap<>();
body.put("elements", List.of(markdown, actionElement));
Map<String, Object> 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<String, Object> buildResolvedCard(String title, String desc, String template) {
Map<String, Object> titleObj = new LinkedHashMap<>();
titleObj.put("tag", "plain_text");
titleObj.put("content", title == null ? "" : title);
Map<String, Object> header = new LinkedHashMap<>();
header.put("title", titleObj);
header.put("template", template == null ? "grey" : template);
Map<String, Object> markdown = new LinkedHashMap<>();
markdown.put("tag", "markdown");
markdown.put("content", desc == null ? "" : desc);
Map<String, Object> body = new LinkedHashMap<>();
body.put("elements", List.of(markdown));
Map<String, Object> 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<String, Object> plainText(String content) {
Map<String, Object> 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;
}
}

View File

@ -1303,7 +1303,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
try {
Map<String, Object> 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) {

View File

@ -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).
*
* <p>Catchable so that WeCom card renderers can fall back to the
* abstract-class text path on overflow rather than letting the entire
* approval flow drop. RFC-32 §2.1.1 calls this out explicitly.
*/
public class CardOversizedException extends RuntimeException {
public CardOversizedException(String message) {
super(message);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<MediaUploader> uploaderBeans;
@Autowired private List<MediaSizePolicy> 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

View File

@ -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<FeishuCardKind> 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<FeishuCardKind> approve = d.lookupByAction(ToolGuardButtonValue.ACTION_APPROVE);
assertTrue(approve.isPresent());
Optional<FeishuCardKind> 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) -> {}));
}
}

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> a = encoder.encode(
ToolGuardButtonValue.Action.APPROVE, "p-1", "tool-a", "HIGH");
Map<String, Object> b = encoder.encode(
ToolGuardButtonValue.Action.APPROVE, "p-1", "tool-a", "HIGH");
assertEquals(a.keySet().iterator().next(), b.keySet().iterator().next());
}
}

View File

@ -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<String, Object> card = renderer.render(notice);
assertEquals("2.0", card.get("schema"));
Map<String, Object> header = (Map<String, Object>) card.get("header");
assertNotNull(header);
Map<String, Object> body = (Map<String, Object>) card.get("body");
List<Map<String, Object>> elements = (List<Map<String, Object>>) body.get("elements");
assertEquals(2, elements.size(), "expect markdown + action elements");
Map<String, Object> 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<String, Object> actions = elements.get(1);
assertEquals("action", actions.get("tag"));
List<Map<String, Object>> buttons = (List<Map<String, Object>>) 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<String, Object> card = renderer.render(notice);
List<Map<String, Object>> elements = (List<Map<String, Object>>) ((Map<String, Object>) card.get("body")).get("elements");
List<Map<String, Object>> buttons = (List<Map<String, Object>>) elements.get(1).get("actions");
Map<String, Object> approveValue = (Map<String, Object>) buttons.get(0).get("value");
assertEquals(ToolGuardButtonValue.ACTION_APPROVE, approveValue.get("action"));
assertEquals("pend-42", approveValue.get("rid"));
Map<String, Object> denyValue = (Map<String, Object>) 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<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(
"✅ 已批准", "tool foo approved by Alice", "green");
assertEquals("2.0", card.get("schema"));
Map<String, Object> header = (Map<String, Object>) card.get("header");
assertEquals("green", header.get("template"));
Map<String, Object> title = (Map<String, Object>) header.get("title");
assertEquals("✅ 已批准", title.get("content"));
Map<String, Object> body = (Map<String, Object>) card.get("body");
List<Map<String, Object>> elements = (List<Map<String, Object>>) 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"));
}
}

View File

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