sync: Feishu approval card 5-chain hotfix — verified end-to-end in production

This commit is contained in:
matevip 2026-05-20 15:24:47 +08:00
parent 090bb64c6a
commit 71e08b015e
10 changed files with 378 additions and 137 deletions

View File

@ -142,6 +142,24 @@ public interface ChannelAdapter {
vip.mate.channel.notification.ApprovalNotificationService.staticBuildText(notice));
}
/**
* Does this adapter deliver approval decisions through an
* interactive card (button click card.action callback) rather
* than the text-command flow ({@code /approve <id>} / {@code /deny <id>})?
*
* <p>Controls {@code ChannelMessageRouter}'s "non-approval message →
* auto-cancel pending" heuristic. The heuristic was designed for
* the text flow where the user is expected to type
* {@code /approve} and anything else is an implicit "I changed my
* mind". With interactive cards the user clicks a button, and
* unrelated chat messages during the wait window must NOT
* auto-cancel the pending. Default false (text flow); WeCom +
* Feishu (when card dispatcher is wired) override to true.
*/
default boolean usesInteractiveApprovalCards() {
return false;
}
// ==================== 主动推送 ====================
/**

View File

@ -554,8 +554,23 @@ public class ChannelMessageRouter {
denyOutcome.messagesRewritten());
return;
} else if (adapter.usesInteractiveApprovalCards()) {
// Channel approves via button-clicks on an interactive
// card, NOT via /approve text. A casual follow-up
// message from the user during the wait window MUST
// NOT auto-cancel the pending the button click is
// the canonical decision path. Treat the new message
// as a fresh turn; the pending stays alive until the
// user clicks Approve / Deny, the GC TTL expires, or
// the workflow explicitly resolves it.
log.info("[{}] Non-approval message while pending exists; channel uses card buttons so NOT auto-cancelling pendingId={}",
adapter.getChannelType(), pending.getPendingId());
// Fall through to process the new message normally.
} else {
// Non-approval message while a pending exists treat as implicit deny.
// Text-command channels rely on this: the user is told
// "type /approve <id>" and anything else is an implicit
// change of mind.
approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied");
conversationService.removeApprovalPlaceholders(conversationId);
String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。";

View File

@ -326,18 +326,21 @@ 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
// Interactive card button clicks (Schema 2.0) routed through cardDispatcher.
// The returned P2CardActionTriggerResponse is how Schema-2.0
// cards update in-place; PATCH /im/v1/messages/{id} is a
// silent no-op for V2 cards and must not be used.
.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);
return handleCardActionTrigger(event);
} catch (Exception e) {
log.error("[feishu] Failed to handle card action: {}", e.getMessage(), e);
return null;
}
return null;
}
})
.build();
@ -891,6 +894,19 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
// ==================== Approval card ====================
/**
* Card-button approval is the canonical path for Feishu when the
* dispatcher is wired. Tells {@code ChannelMessageRouter} not to
* auto-cancel pending approvals on subsequent user messages
* users decide via clicking, not by typing {@code /approve}.
* Returns false only in legacy / test contexts where the
* dispatcher isn't injected (the inherited text-flow path applies).
*/
@Override
public boolean usesInteractiveApprovalCards() {
return cardDispatcher != null;
}
/**
* Render the approval notice as a Schema-2.0 interactive button card
* so the user can approve / deny in-channel without bouncing to the
@ -931,26 +947,43 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
/**
* 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.
* dispatcher and propagate the kind's response back to Feishu so
* the card can update in-place. Returns null when no dispatcher is
* wired or no kind matches the action prefix Feishu leaves the
* original card unchanged in that case.
*/
private void handleCardActionTrigger(
private com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse handleCardActionTrigger(
com.lark.oapi.event.cardcallback.model.P2CardActionTrigger event) {
if (cardDispatcher == null || event == null || event.getEvent() == null) {
return;
return null;
}
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;
return null;
}
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;
return null;
}
kindOpt.get().handler().handle(this, data);
return kindOpt.get().handler().handle(this, data);
}
/**
* Re-enter the router as if the given message arrived from this
* channel. Used by the tool-guard card handler to re-emit the
* button click as a synthetic {@code /approve <pendingId>} or
* {@code /deny <pendingId>} so the router runs its canonical
* text-approve path ({@code resolveAndConsume + replay}).
*
* <p>External callers must go through {@link #onMessage}; this is
* the in-package fast lane that skips the WS / webhook decode.
*/
public void injectSyntheticMessage(ChannelMessage message) {
messageRouter.enqueue(message, this, channelEntity);
}
/**

View File

@ -1,36 +1,48 @@
package vip.mate.channel.feishu.cards;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
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>Schema-2.0 cards update in-place via the {@code
* P2CardActionTriggerResponse} the handler returns Feishu uses
* {@code response.card} as the new card body and surfaces
* {@code response.toast} as a transient popup. The async
* {@code PATCH /im/v1/messages/{id}} path is a silent no-op for V2
* cards; do NOT use it.
*
* <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>
* <li>Validate the click decode the button value, look up the
* pending business object, identity-check the clicker against
* the original requester.</li>
* <li>Inject any agent-side follow-up (e.g. a synthetic
* {@code /approve <id>} message via {@code adapter.injectSyntheticMessage(...)})
* so the router runs its canonical resolve + replay logic.</li>
* <li>Build and return a {@link P2CardActionTriggerResponse} with
* the resolved-state card body. Must complete inside Feishu's
* response window (~3 seconds before timeout).</li>
* </ol>
*
* <p>Returning {@code null} is allowed Feishu leaves the original
* card untouched in that case (use sparingly, only when the event
* shouldn't acknowledge visibly).
*/
@FunctionalInterface
public interface FeishuCardHandler {
/**
* @param adapter the live Feishu adapter (provides {@code updateCard},
* {@code messageRouter}, SDK client, etc.)
* @param adapter the live Feishu adapter (provides
* {@code injectSyntheticMessage}, SDK client, etc.)
* @param data the parsed {@code P2CardActionTriggerData} payload
* contains the operator, action.value, the card token,
* and {@code context} (containing the
* contains the operator, action.value, the card
* token, and {@code context} (containing the
* {@code open_message_id} of the original card)
* @return the response Feishu should use to update the card, or
* null to leave it unchanged
*/
void handle(FeishuChannelAdapter adapter, P2CardActionTriggerData data);
P2CardActionTriggerResponse handle(FeishuChannelAdapter adapter, P2CardActionTriggerData data);
}

View File

@ -1,57 +1,77 @@
package vip.mate.channel.feishu.cards.tool_guard;
import com.lark.oapi.event.cardcallback.model.CallBackAction;
import com.lark.oapi.event.cardcallback.model.CallBackCard;
import com.lark.oapi.event.cardcallback.model.CallBackContext;
import com.lark.oapi.event.cardcallback.model.CallBackOperator;
import com.lark.oapi.event.cardcallback.model.CallBackToast;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
import lombok.extern.slf4j.Slf4j;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.PendingApproval;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.feishu.cards.FeishuCardHandler;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
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:
* <p><b>Schema-2.0 update protocol</b>: the card update must travel
* back to Feishu via the {@code P2CardActionTriggerResponse} we
* return async {@code PATCH /im/v1/messages/{id}} is silent no-op
* on V2 cards. We build a {@link CallBackCard} carrying the resolved
* card JSON and a {@link CallBackToast} for the transient "已批准" /
* "🚫 已拒绝" popup.
*
* <p><b>Replay protocol</b>: instead of calling
* {@code approvalWorkflowService.resolve(...)} directly, we inject a
* synthetic {@code /approve <pendingId>} message into the router. This
* reuses the canonical text-approve path that the router already
* tested: {@code resolveAndConsume + replayApprovedToolCall} so the
* approved tool actually re-runs through {@code ToolExecutionExecutor}
* and the agent picks the next step. The button click and the
* {@code /approve} text command thus take exactly the same code path,
* preventing the two from drifting.
*
* <p><b>Step ordering</b>:
* <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>
* <li>Inject synthetic {@code /approve} or {@code /deny} command
* router does the resolve + replay</li>
* <li>Build {@link P2CardActionTriggerResponse} with the resolved
* card JSON + toast and return it</li>
* </ol>
*
* <p>Steps 14 must complete inside Feishu's response window; step 5
* can be slower.
* <p>All steps must complete inside Feishu's response window. Steps
* 13 are O(ms) DB lookups; step 4 enqueues to the router but does
* not block on execution; step 5 just serialises a Map.
*/
@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) {
public P2CardActionTriggerResponse handle(FeishuChannelAdapter adapter, P2CardActionTriggerData data) {
if (data == null) {
log.warn("[feishu-toolguard] handle called with null data");
return;
return null;
}
CallBackAction action = data.getAction();
CallBackOperator operator = data.getOperator();
@ -66,7 +86,7 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
if (decoded == null) {
log.warn("[feishu-toolguard] Could not decode action.value (messageId={}, clicker={})",
abbrev(messageId), abbrev(clickerOpenId));
return;
return null;
}
String pendingId = decoded.pendingId();
ToolGuardButtonValue.Action act = decoded.action();
@ -76,8 +96,7 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
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;
return buildExpiredResponse(decoded.toolName());
}
PendingApproval pending = opt.get();
@ -89,79 +108,173 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
if (!authorized) {
log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
abbrev(clickerOpenId), abbrev(originalRequester), pendingId);
updateUnauthorized(adapter, messageId, decoded.toolName(), originalRequester);
return;
return buildUnauthorizedResponse(decoded.toolName(), originalRequester);
}
// ---- 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;
// ---- 4. Inject synthetic /approve | /deny router runs the
// canonical resolve + replay path. Mirror of WeCom's
// button-card handling so the two channels share one
// resolve code path.
String commandText = (act == ToolGuardButtonValue.Action.APPROVE ? "/approve " : "/deny ")
+ pendingId;
ChannelMessage synthetic = buildSynthetic(commandText, clickerOpenId, pending, data);
try {
approvalWorkflowService.resolve(pendingId, actor, decisionLabel);
log.info("[feishu-toolguard] Resolved pending={} decision={} actor={}",
pendingId, decisionLabel, abbrev(actor));
adapter.injectSyntheticMessage(synthetic);
log.info("[feishu-toolguard] Injected '{}' for pending={}, clicker={}",
act == ToolGuardButtonValue.Action.APPROVE ? "/approve" : "/deny",
pendingId, abbrev(clickerOpenId));
} 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);
// Returning the resolved-state card to Feishu without the
// router seeing the click leaves the agent stuck. Log and
// surface a failure toast the user will see "未生效"
// popup and the buttons stay clickable.
log.error("[feishu-toolguard] Failed to inject synthetic command for pending={}: {}",
pendingId, e.getMessage(), e);
return buildErrorResponse("⚠️ 审批未生效,请重试或联系运维");
}
// ---- 5. Build the resolved-state response
return buildResolvedResponse(decoded.toolName(), act, clickerOpenId);
}
// ------------------------------------------------------------------
// Card render helpers
// Response builders assemble P2CardActionTriggerResponse{toast,card}
// ------------------------------------------------------------------
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;
}
private static P2CardActionTriggerResponse buildResolvedResponse(
String toolName, ToolGuardButtonValue.Action act, String clickerOpenId) {
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());
}
+ "**操作者**: " + abbrev(clickerOpenId);
Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(title, desc, template);
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast(approve ? "info" : "warning", title));
resp.setCard(wrapCard(card));
return resp;
}
private static void updateExpired(FeishuChannelAdapter adapter, String messageId, String toolName) {
if (messageId == null || messageId.isBlank()) return;
private static P2CardActionTriggerResponse buildUnauthorizedResponse(
String toolName, String originalRequester) {
String requesterLabel = originalRequester == null ? "原请求者" : abbrev(originalRequester);
String desc = "**工具**: `" + (toolName == null ? "" : toolName) + "`\n"
+ "**原请求者**: " + requesterLabel + "\n*仅原请求者可批准 / 拒绝该操作*";
Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(
"❌ 仅原请求者可审批", desc, "grey");
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast("warning", "仅原请求者可审批"));
resp.setCard(wrapCard(card));
return resp;
}
private static P2CardActionTriggerResponse buildExpiredResponse(String toolName) {
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());
}
Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(
"⌛ 审批已失效", desc, "grey");
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast("warning", "审批已失效"));
resp.setCard(wrapCard(card));
return resp;
}
private static P2CardActionTriggerResponse buildErrorResponse(String message) {
P2CardActionTriggerResponse resp = new P2CardActionTriggerResponse();
resp.setToast(buildToast("error", message));
// Leave card null original card stays clickable so the user can retry.
return resp;
}
private static CallBackToast buildToast(String type, String content) {
CallBackToast toast = new CallBackToast();
toast.setType(type);
toast.setContent(content);
return toast;
}
private static CallBackCard wrapCard(Map<String, Object> cardJson) {
CallBackCard cb = new CallBackCard();
// Feishu callback-response validator only accepts Schema 1.0
// inline cards with type="raw" type="card_json" + Schema 2.0
// body returns 200672 "卡片内容格式错误" even though the same
// Schema 2.0 body works fine on cardkit/v1 card.create and on
// im/v1 message.create msg_type=interactive. Two different
// server-side validators, only one of which has been upgraded
// for Schema 2.0. QwenPaw's production Feishu adapter uses the
// same type="raw" approach for callback updates.
cb.setType("raw");
cb.setData(cardJson);
return cb;
}
// ------------------------------------------------------------------
// Synthetic message construction (mirror of WeCom pattern)
// ------------------------------------------------------------------
/**
* Build a {@link ChannelMessage} that looks like the clicker just
* typed "/approve &lt;pendingId&gt;" (or /deny) in the same chat.
* The router's existing approve / deny gate picks it up and runs
* the canonical resolveAndConsume + replay path.
*
* <p><b>conversationId matching is critical</b>: the router routes
* the synthetic by {@code buildConversationId(message)}
* {@code feishu:<chatId-or-senderId>} and looks up pending under
* that key. If the synthetic's conversationId doesn't match the
* pending's own {@code conversationId}, the router treats the
* message as a regular query and the LLM sees "/approve xxxxx"
* as user text.
*
* <p>Feishu's card-callback {@code context.openChatId} is populated
* even for 1:1 bot chats, but the original inbound-message handler
* stores 1:1 chats with {@code chatId=null} (so
* {@code buildConversationId} falls back to senderId). To stay
* consistent we ignore {@code openChatId} and derive the chatId
* from {@code pending.conversationId} the source of truth that
* was used to register the pending in the first place.
*/
private static ChannelMessage buildSynthetic(String commandText, String clickerOpenId,
PendingApproval pending,
P2CardActionTriggerData data) {
// pending.conversationId looks like "feishu:<scope>" where
// <scope> is either ou_xxx (1:1 chat derived from senderId)
// or oc_xxx (group chat derived from chatId). Reverse the
// scope back into the right chatId field so buildConversationId
// reproduces the exact same key.
String convId = pending.getConversationId();
String scope = (convId != null && convId.startsWith("feishu:"))
? convId.substring("feishu:".length())
: null;
boolean isGroup = scope != null && scope.startsWith("oc_");
String chatId = isGroup ? scope : null;
String replyToken = isGroup ? scope : clickerOpenId;
return ChannelMessage.builder()
.channelType("feishu")
.senderId(clickerOpenId)
.senderName(clickerOpenId)
.chatId(chatId)
.content(commandText)
.contentType("text")
.contentParts(List.of())
.inputMode("text")
.timestamp(LocalDateTime.now())
.replyToken(replyToken)
.rawPayload(Map.of(
"feishu_button_click", true,
"feishu_pending_id", pending.getPendingId()
))
.build();
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private static String abbrev(String s) {
if (s == null || s.isBlank()) return "";
if (s.length() <= 12) return s;

View File

@ -3,7 +3,6 @@ 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;
/**
@ -28,21 +27,21 @@ public class ToolGuardCardKindFactory {
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);
// Handler no longer needs ApprovalWorkflowService the canonical
// resolve + replay path runs via a synthetic /approve|/deny
// message injected back into the router.
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonValue);
return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler);
}
}

View File

@ -60,15 +60,22 @@ public class ToolGuardCardRenderer implements FeishuCardRenderer {
title.put("tag", "plain_text");
title.put("content", "🛡️ 工具审批");
Map<String, Object> header = new LinkedHashMap<>();
header.put("title", title);
header.put("template", severityToTemplate(severity));
header.put("title", title);
// Markdown summary
Map<String, Object> markdown = new LinkedHashMap<>();
markdown.put("tag", "markdown");
markdown.put("content", buildSummaryMarkdown(notice, toolName, severity));
// Buttons
// Schema 1.0 button row {tag:"action", actions:[buttons]}.
// We use Schema 1.0 throughout (not Schema 2.0) so the callback
// response can update this same card without a schema-version
// mismatch error. Schema 2.0 is supported by im/v1/message.create
// BUT the callback response validator only accepts Schema 1.0
// inline (type="raw") once we commit to Schema 1.0 here the
// resolved-state card update lands cleanly. QwenPaw's
// production Feishu integration uses the same Schema 1.0 path.
Map<String, Object> approveBtn = new LinkedHashMap<>();
approveBtn.put("tag", "button");
approveBtn.put("text", plainText("批准"));
@ -81,47 +88,64 @@ public class ToolGuardCardRenderer implements FeishuCardRenderer {
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> actionRow = new LinkedHashMap<>();
actionRow.put("tag", "action");
actionRow.put("actions", List.of(approveBtn, denyBtn));
Map<String, Object> body = new LinkedHashMap<>();
body.put("elements", List.of(markdown, actionElement));
Map<String, Object> config = new LinkedHashMap<>();
config.put("wide_screen_mode", true);
// Schema 1.0 layout elements at root, no "schema" / "body" wrapper.
Map<String, Object> card = new LinkedHashMap<>();
card.put("schema", "2.0");
card.put("config", config);
card.put("header", header);
card.put("body", body);
card.put("elements", List.of(markdown, actionRow));
return card;
}
/**
* Build the resolved-state card (same task_id semantics Feishu
* cards are updated in-place by message_id; no task_id needed).
* Build the resolved-state card for the {@code
* P2CardActionTriggerResponse.card} payload <b>Schema 1.0</b>
* inline format ({@code config / header / elements} all at root,
* no {@code "schema"} field, no {@code "body"} nesting).
*
* @param title headline like "✅ 已批准 by 张三"
* @param desc optional detail line
* @param template "green" / "red" / "grey" / "blue" header colour
* <p><b>Why Schema 1.0 here</b>: the Feishu callback-response
* validator is the legacy validator and rejects Schema 2.0 cards
* with error code 200672 "卡片内容格式错误". This is different from
* {@code im/v1/message.create msg_type=interactive} and
* {@code cardkit/v1 card.create}, both of which DO accept Schema
* 2.0. So we keep the original approval card (sent via message
* create) in Schema 2.0 for the column_set button layout, but the
* resolved-state update has to be Schema 1.0. QwenPaw's production
* Feishu integration uses the same split.
*
* <p>Caller passes the resulting Map to a {@code CallBackCard}
* with {@code type="raw"} (NOT {@code card_json}).
*
* @param title headline like "✅ 已批准 by 张三"
* @param desc optional detail line (markdown)
* @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);
header.put("title", titleObj);
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> config = new LinkedHashMap<>();
config.put("wide_screen_mode", true);
// Schema 1.0 layout elements at root, no schema/body wrapper.
Map<String, Object> card = new LinkedHashMap<>();
card.put("schema", "2.0");
card.put("config", config);
card.put("header", header);
card.put("body", body);
card.put("elements", List.of(markdown));
return card;
}

View File

@ -1279,6 +1279,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
* inbound frame's {@code req_id} that
* {@link #handleMessageCallback} stashed in {@link #replyContexts}.
*/
@Override
public boolean usesInteractiveApprovalCards() {
// Same gate as sendApprovalNotice below without the dispatcher
// wired we fall back to the inherited text path, and the router
// should treat unrelated follow-up messages as implicit deny
// (existing behaviour).
return cardDispatcher != null;
}
@Override
public void sendApprovalNotice(String targetId,
vip.mate.channel.notification.ApprovalNotice notice) {

View File

@ -4,7 +4,6 @@ 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;
@ -24,7 +23,6 @@ class FeishuCardDispatcherTest {
private FeishuCardDispatcher newDispatcher() {
ToolGuardCardKindFactory factory = new ToolGuardCardKindFactory(
mock(ApprovalService.class),
mock(ApprovalWorkflowService.class),
new ObjectMapper());
return new FeishuCardDispatcher(factory);
}
@ -70,12 +68,12 @@ class FeishuCardDispatcherTest {
@DisplayName("FeishuCardKind constructor rejects blank name / prefix")
void cardKindValidation() {
FeishuCardKind valid = new FeishuCardKind(
"ok", "ok.", (n) -> java.util.Map.of(), (adapter, data) -> {});
"ok", "ok.", (n) -> java.util.Map.of(), (adapter, data) -> null);
assertEquals("ok", valid.name());
assertThrows(IllegalArgumentException.class,
() -> new FeishuCardKind("", "x.", (n) -> java.util.Map.of(), (a, d) -> {}));
() -> new FeishuCardKind("", "x.", (n) -> java.util.Map.of(), (a, d) -> null));
assertThrows(IllegalArgumentException.class,
() -> new FeishuCardKind("ok", " ", (n) -> java.util.Map.of(), (a, d) -> {}));
() -> new FeishuCardKind("ok", " ", (n) -> java.util.Map.of(), (a, d) -> null));
}
}

View File

@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -24,7 +25,7 @@ class ToolGuardCardRendererTest {
@SuppressWarnings("unchecked")
@Test
@DisplayName("rendered card carries schema 2.0, header, summary markdown, and two buttons")
@DisplayName("rendered card is Schema 1.0 inline: config + header + elements at root, action row wraps two buttons")
void cardShape() {
ApprovalNotice notice = new ApprovalNotice(
"pend-1", "feishu_doc_create", "Create a new Doc",
@ -34,12 +35,20 @@ class ToolGuardCardRendererTest {
Map<String, Object> card = renderer.render(notice);
assertEquals("2.0", card.get("schema"));
// Schema 1.0: NO 'schema' field, NO 'body' nesting keeps the
// approval card and the callback-response resolved card on the
// same schema so Feishu's validator doesn't fault on update.
assertFalse(card.containsKey("schema"),
"Approval card must be Schema 1.0 for callback-response compatibility");
assertFalse(card.containsKey("body"));
Map<String, Object> config = (Map<String, Object>) card.get("config");
assertEquals(Boolean.TRUE, config.get("wide_screen_mode"));
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");
List<Map<String, Object>> elements = (List<Map<String, Object>>) card.get("elements");
assertEquals(2, elements.size(), "expect markdown + action row");
Map<String, Object> md = elements.get(0);
assertEquals("markdown", md.get("tag"));
@ -47,11 +56,14 @@ class ToolGuardCardRendererTest {
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");
// Schema 1.0 button row {tag:"action", actions:[primary, danger]}
Map<String, Object> actionRow = elements.get(1);
assertEquals("action", actionRow.get("tag"));
List<Map<String, Object>> buttons = (List<Map<String, Object>>) actionRow.get("actions");
assertEquals(2, buttons.size());
assertEquals("button", buttons.get(0).get("tag"));
assertEquals("primary", buttons.get(0).get("type"));
assertEquals("button", buttons.get(1).get("tag"));
assertEquals("danger", buttons.get(1).get("type"));
}
@ -64,7 +76,7 @@ class ToolGuardCardRendererTest {
"{}", "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>> elements = (List<Map<String, Object>>) card.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");
@ -77,20 +89,28 @@ class ToolGuardCardRendererTest {
}
@Test
@DisplayName("buildResolvedCard returns a no-action body with the given title + template")
@DisplayName("buildResolvedCard returns Schema 1.0 inline layout (no schema field, elements at root) — required by Feishu callback-response validator")
@SuppressWarnings("unchecked")
void buildResolvedCardShape() {
Map<String, Object> card = ToolGuardCardRenderer.buildResolvedCard(
"✅ 已批准", "tool foo approved by Alice", "green");
assertEquals("2.0", card.get("schema"));
// Schema 1.0 has NO "schema" field callback-response validator
// returns 200672 if it sees Schema 2.0.
assertFalse(card.containsKey("schema"),
"Resolved card must be Schema 1.0 (no 'schema' field) for callback-response compatibility");
assertFalse(card.containsKey("body"),
"Schema 1.0 puts elements at root, NOT under 'body'");
Map<String, Object> config = (Map<String, Object>) card.get("config");
assertEquals(Boolean.TRUE, config.get("wide_screen_mode"));
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");
List<Map<String, Object>> elements = (List<Map<String, Object>>) card.get("elements");
assertEquals(1, elements.size());
assertEquals("markdown", elements.get(0).get("tag"));
assertTrue(((String) elements.get(0).get("content")).contains("approved by Alice"));