diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java index e06e0350..a831c5bc 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelAdapter.java @@ -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 } / {@code /deny })? + * + *

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; + } + // ==================== 主动推送 ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index a0afe778..d3211337 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -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 " and anything else is an implicit + // change of mind. approvalService.resolve(pending.getPendingId(), message.getSenderId(), "denied"); conversationService.removeApprovalPlaceholders(conversationId); String cancelHint = "⛔ 审批已取消。将继续处理您的新消息。"; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 70330f57..36ed7972 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -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 } or + * {@code /deny } so the router runs its canonical + * text-approve path ({@code resolveAndConsume + replay}). + * + *

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); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java index dce6e872..d2beea8d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/FeishuCardHandler.java @@ -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). * + *

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. + * *

Implementations must: *

    - *
  1. Validate the click — decode the button value, look up the pending - * business object, identity-check the clicker against the original - * requester.
  2. - *
  3. 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).
  4. - *
  5. 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.
  6. + *
  7. Validate the click — decode the button value, look up the + * pending business object, identity-check the clicker against + * the original requester.
  8. + *
  9. Inject any agent-side follow-up (e.g. a synthetic + * {@code /approve } message via {@code adapter.injectSyntheticMessage(...)}) + * so the router runs its canonical resolve + replay logic.
  10. + *
  11. Build and return a {@link P2CardActionTriggerResponse} with + * the resolved-state card body. Must complete inside Feishu's + * response window (~3 seconds before timeout).
  12. *
+ * + *

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); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java index 430a8ea7..4d4e17a4 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java @@ -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. * - *

Step ordering — validate before render: a non-original- - * requester click must NOT briefly show "已批准 by ..." before the - * router drops the injected command. Order: + *

Schema-2.0 update protocol: 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. + * + *

Replay protocol: instead of calling + * {@code approvalWorkflowService.resolve(...)} directly, we inject a + * synthetic {@code /approve } 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. + * + *

Step ordering: *

    *
  1. Decode {@code action.value} → null check
  2. *
  3. Look up {@code PendingApproval} by id
  4. *
  5. Identity check: clicker (open_id) vs original requester
  6. - *
  7. Update the original card to a resolved state (success / - * unauthorized / expired)
  8. - *
  9. Resolve the approval via {@link ApprovalService#resolve}
  10. + *
  11. Inject synthetic {@code /approve} or {@code /deny} command — + * router does the resolve + replay
  12. + *
  13. Build {@link P2CardActionTriggerResponse} with the resolved + * card JSON + toast and return it
  14. *
* - *

Steps 1–4 must complete inside Feishu's response window; step 5 - * can be slower. + *

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 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 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 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 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. + * + *

conversationId matching is critical: the router routes + * the synthetic by {@code buildConversationId(message)} → + * {@code feishu:} 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. + * + *

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:" where + // 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; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java index fe27be46..757cc06e 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardKindFactory.java @@ -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); } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java index 01b8d55f..7ab29305 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRenderer.java @@ -60,15 +60,22 @@ public class ToolGuardCardRenderer implements FeishuCardRenderer { title.put("tag", "plain_text"); title.put("content", "🛡️ 工具审批"); Map header = new LinkedHashMap<>(); - header.put("title", title); header.put("template", severityToTemplate(severity)); + header.put("title", title); // Markdown summary Map 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 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 actionElement = new LinkedHashMap<>(); - actionElement.put("tag", "action"); - actionElement.put("actions", List.of(approveBtn, denyBtn)); + Map actionRow = new LinkedHashMap<>(); + actionRow.put("tag", "action"); + actionRow.put("actions", List.of(approveBtn, denyBtn)); - Map body = new LinkedHashMap<>(); - body.put("elements", List.of(markdown, actionElement)); + Map config = new LinkedHashMap<>(); + config.put("wide_screen_mode", true); + // Schema 1.0 layout — elements at root, no "schema" / "body" wrapper. Map 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 — Schema 1.0 + * 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 + *

Why Schema 1.0 here: 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. + * + *

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 buildResolvedCard(String title, String desc, String template) { Map titleObj = new LinkedHashMap<>(); titleObj.put("tag", "plain_text"); titleObj.put("content", title == null ? "" : title); Map header = new LinkedHashMap<>(); - header.put("title", titleObj); header.put("template", template == null ? "grey" : template); + header.put("title", titleObj); Map markdown = new LinkedHashMap<>(); markdown.put("tag", "markdown"); markdown.put("content", desc == null ? "" : desc); - Map body = new LinkedHashMap<>(); - body.put("elements", List.of(markdown)); + Map config = new LinkedHashMap<>(); + config.put("wide_screen_mode", true); + // Schema 1.0 layout — elements at root, no schema/body wrapper. Map 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; } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index 32d8444d..bc7f6f36 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -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) { diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java index f1c6691a..c9c18b20 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/FeishuCardDispatcherTest.java @@ -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)); } } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java index e927e35c..13f4a450 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardRendererTest.java @@ -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 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 config = (Map) card.get("config"); + assertEquals(Boolean.TRUE, config.get("wide_screen_mode")); + Map header = (Map) card.get("header"); assertNotNull(header); - Map body = (Map) card.get("body"); - List> elements = (List>) body.get("elements"); - assertEquals(2, elements.size(), "expect markdown + action elements"); + List> elements = (List>) card.get("elements"); + assertEquals(2, elements.size(), "expect markdown + action row"); Map 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 actions = elements.get(1); - assertEquals("action", actions.get("tag")); - List> buttons = (List>) actions.get("actions"); + // Schema 1.0 button row — {tag:"action", actions:[primary, danger]} + Map actionRow = elements.get(1); + assertEquals("action", actionRow.get("tag")); + List> buttons = (List>) 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 card = renderer.render(notice); - List> elements = (List>) ((Map) card.get("body")).get("elements"); + List> elements = (List>) card.get("elements"); List> buttons = (List>) elements.get(1).get("actions"); Map approveValue = (Map) 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 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 config = (Map) card.get("config"); + assertEquals(Boolean.TRUE, config.get("wide_screen_mode")); + Map header = (Map) card.get("header"); assertEquals("green", header.get("template")); Map title = (Map) header.get("title"); assertEquals("✅ 已批准", title.get("content")); - Map body = (Map) card.get("body"); - List> elements = (List>) body.get("elements"); + List> elements = (List>) 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"));