mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
sync: interactive approval card on Feishu via Schema-2.0 button + card.action callback
This commit is contained in:
parent
3554da8dbc
commit
a9fa8e7fb1
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<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 不影响主流程
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 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<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) + "…";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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": "<tool / risk / args summary>"},
|
||||
* {"tag": "action", "actions": [
|
||||
* {"tag": "button", "text": {...}, "type": "primary", "value": <approve payload>},
|
||||
* {"tag": "button", "text": {...}, "type": "danger", "value": <deny payload>}
|
||||
* ]}
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* </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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) -> {}));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user