mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
sync: Feishu approval card 5-chain hotfix — verified end-to-end in production
This commit is contained in:
parent
090bb64c6a
commit
71e08b015e
@ -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;
|
||||
}
|
||||
|
||||
// ==================== 主动推送 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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 = "⛔ 审批已取消。将继续处理您的新消息。";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 1–4 must complete inside Feishu's response window; step 5
|
||||
* can be slower.
|
||||
* <p>All steps must complete inside Feishu's response window. Steps
|
||||
* 1–3 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 <pendingId>" (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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user