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 78476f4a..c6c85d4f 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 @@ -9,7 +9,9 @@ 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.approval.ResolveOutcome; import vip.mate.channel.ChannelMessage; import vip.mate.channel.feishu.FeishuChannelAdapter; import vip.mate.channel.feishu.cards.FeishuCardHandler; @@ -59,11 +61,23 @@ import java.util.Optional; public class ToolGuardCardHandler implements FeishuCardHandler { private final ApprovalService approvalService; + /** + * ISSUE #413 P2-B3: needed to resolve workflow-scoped approvals + * ({@code wf-} pendingIds) directly from the card click. Workflow + * approvals cannot go through the synthetic /approve injection + * (their conversationId is {@code workflow:run:{runId}}, which no + * IM conversation matches), so the handler resolves them inline — + * mirroring the Web / WebChat path. May be null in narrow test + * contexts (wf- approvals then fall back to the admin console). + */ + 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; } @@ -101,6 +115,19 @@ public class ToolGuardCardHandler implements FeishuCardHandler { PendingApproval pending = opt.get(); // ---- 3. Identity check (fail-closed) + // Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no + // human requester — the userId is null because the run is system- + // initiated. Their approval cards are only ever pushed to the + // channels declared in await_approval's approverChannels, so any + // member of that audience is a legitimate approver; we skip the + // requester==clicker guard and resolve inline (the synthetic /approve + // injection is a dead end for wf- ids: their conversationId is + // workflow:run:{runId}, which no IM conversation matches, so the + // router's findPendingByConversation would miss it). + if (pendingId.startsWith("wf-")) { + return handleWorkflowApproval(pendingId, decoded.toolName(), act, clickerOpenId); + } + // Agent/cron ("system") or unattributed (null) approvals have no human // requester to match the clicker against. A guarded-tool card landing in // a group chat would otherwise let ANY member click Approve and run the @@ -142,6 +169,54 @@ public class ToolGuardCardHandler implements FeishuCardHandler { return buildResolvedResponse(decoded.toolName(), act, clickerOpenId); } + // ------------------------------------------------------------------ + // Workflow-scoped approval (ISSUE #413 P2-B3) + // ------------------------------------------------------------------ + + /** + * Resolve a {@code wf-} workflow approval directly from the card click, + * bypassing the synthetic /approve injection. Workflow approvals live + * under a synthetic {@code workflow:run:{runId}} conversationId that no + * IM conversation matches, so the router path is a dead end. Instead we + * resolve inline (mirroring the Web / WebChat path); the + * {@link vip.mate.workflow.runtime.ApprovalResumeBridge} then picks up + * the {@code WorkflowApprovalResolvedEvent} published inside + * {@code ApprovalWorkflowService.resolve} and resumes the paused run. + * + *
No tool-call replay is needed — a workflow {@code await_approval} + * step is a declarative gate, not a tool invocation; resume simply + * advances to the next step. + * + *
Identity: any audience member may resolve. The card only reaches + * the channels declared in {@code await_approval.approverChannels} + * (pushed by {@code AwaitApprovalStepAdapter}'s notify step), so whoever + * can see it is a designated approver. + */ + private P2CardActionTriggerResponse handleWorkflowApproval(String pendingId, String toolName, + ToolGuardButtonValue.Action act, + String clickerOpenId) { + if (approvalWorkflowService == null) { + log.warn("[feishu-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} " + + "(use the admin console)", pendingId); + return buildErrorResponse("⚠️ 工作流审批需在管理端处理"); + } + String decision = act == ToolGuardButtonValue.Action.APPROVE ? "approved" : "denied"; + try { + ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerOpenId, decision); + if (!outcome.dbSynced()) { + // already resolved / superseded — not an error, but tell the clicker. + log.info("[feishu-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision()); + return buildExpiredResponse(toolName); + } + log.info("[feishu-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)", + pendingId, decision, abbrev(clickerOpenId)); + return buildResolvedResponse(toolName, act, clickerOpenId); + } catch (Exception e) { + log.error("[feishu-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e); + return buildErrorResponse("⚠️ 工作流审批未生效,请重试或在管理端处理"); + } + } + // ------------------------------------------------------------------ // Response builders — assemble P2CardActionTriggerResponse{toast,card} // ------------------------------------------------------------------ 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 757cc06e..435aab0c 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,6 +3,7 @@ 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; /** @@ -27,21 +28,27 @@ public class ToolGuardCardKindFactory { public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX; private final ApprovalService approvalService; + /** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */ + 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); - // 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); + // ISSUE #413 P2-B3: handler needs ApprovalWorkflowService to resolve + // wf- workflow approvals inline (the synthetic /approve injection is + // a dead end for wf- ids). Regular tool approvals still go through + // the synthetic /approve | /deny router path as before. + ToolGuardCardHandler handler = new ToolGuardCardHandler( + approvalService, approvalWorkflowService, buttonValue); return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler); } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java index c73230b3..ac28885d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java @@ -2,7 +2,9 @@ package vip.mate.channel.wecom.cards.tool_guard; import lombok.extern.slf4j.Slf4j; import vip.mate.approval.ApprovalService; +import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.PendingApproval; +import vip.mate.approval.ResolveOutcome; import vip.mate.channel.ChannelMessage; import vip.mate.channel.wecom.WeComChannelAdapter; import vip.mate.channel.wecom.cards.WeComCardHandler; @@ -37,10 +39,20 @@ import java.util.Optional; public class ToolGuardCardHandler implements WeComCardHandler { private final ApprovalService approvalService; + /** + * ISSUE #413 P2-B3: resolves workflow-scoped ({@code wf-}) approvals + * inline — the synthetic /approve injection is a dead end for wf- ids + * (their conversationId is {@code workflow:run:{runId}}, unmatched by + * any IM conversation). May be null in narrow test contexts. + */ + private final ApprovalWorkflowService approvalWorkflowService; private final ToolGuardButtonKey buttonKey; - public ToolGuardCardHandler(ApprovalService approvalService, ToolGuardButtonKey buttonKey) { + public ToolGuardCardHandler(ApprovalService approvalService, + ApprovalWorkflowService approvalWorkflowService, + ToolGuardButtonKey buttonKey) { this.approvalService = approvalService; + this.approvalWorkflowService = approvalWorkflowService; this.buttonKey = buttonKey; } @@ -76,6 +88,19 @@ public class ToolGuardCardHandler implements WeComCardHandler { PendingApproval pending = opt.get(); // ---- 3. Identity check (fail-closed) ---- + // Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no + // human requester (userId is null — the run is system-initiated). + // Their cards only reach the channels declared in await_approval's + // approverChannels, so any audience member is a legitimate approver. + // We resolve inline (no synthetic injection: the router path can't + // route a workflow:run:{runId} conversationId) and the + // ApprovalResumeBridge resumes the run off the resolved event. + if (pendingId.startsWith("wf-")) { + handleWorkflowApproval(adapter, eventReqId, taskId, pendingId, + decoded.toolName(), action, clickerUserId); + return; + } + // Agent/cron ("system") or unattributed (null) approvals have no human // requester to match the clicker against; a group card would let any // member resolve a guarded action. Reject here (mirrors the feishu card @@ -117,6 +142,48 @@ public class ToolGuardCardHandler implements WeComCardHandler { } } + // ------------------------------------------------------------------ + // Workflow-scoped approval (ISSUE #413 P2-B3) + // ------------------------------------------------------------------ + + /** + * Resolve a {@code wf-} workflow approval inline, then render the + * resolved card — both within the WeCom 5s callback window. The + * synthetic /approve injection is bypassed because the router cannot + * route a {@code workflow:run:{runId}} conversationId. The + * {@link vip.mate.workflow.runtime.ApprovalResumeBridge} picks up the + * {@code WorkflowApprovalResolvedEvent} published inside resolve and + * resumes the paused run asynchronously. + * + *
Identity: any audience member may resolve — the card only reaches
+ * the channels declared in {@code await_approval.approverChannels}.
+ */
+ private void handleWorkflowApproval(WeComChannelAdapter adapter, String eventReqId, String taskId,
+ String pendingId, String toolName,
+ ToolGuardButtonKey.Action action, String clickerUserId) {
+ if (approvalWorkflowService == null) {
+ log.warn("[wecom-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} "
+ + "(use the admin console)", pendingId);
+ renderExpired(adapter, eventReqId, taskId, toolName);
+ return;
+ }
+ String decision = action == ToolGuardButtonKey.Action.APPROVE ? "approved" : "denied";
+ try {
+ ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerUserId, decision);
+ if (!outcome.dbSynced()) {
+ log.info("[wecom-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision());
+ renderExpired(adapter, eventReqId, taskId, toolName);
+ return;
+ }
+ log.info("[wecom-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)",
+ pendingId, decision, abbrev(clickerUserId));
+ renderResolved(adapter, eventReqId, taskId, toolName, action, clickerUserId);
+ } catch (Exception e) {
+ log.error("[wecom-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e);
+ renderExpired(adapter, eventReqId, taskId, toolName);
+ }
+ }
+
// ------------------------------------------------------------------
// Card rendering helpers
// ------------------------------------------------------------------
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java
index e36e5422..d37e7a49 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardKindFactory.java
@@ -3,6 +3,7 @@ package vip.mate.channel.wecom.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.wecom.cards.WeComCardKind;
/**
@@ -34,17 +35,23 @@ public class ToolGuardCardKindFactory {
public static final String MESSAGE_TYPE = "tool_guard_approval";
private final ApprovalService approvalService;
+ /** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */
+ private final ApprovalWorkflowService approvalWorkflowService;
private final ObjectMapper objectMapper;
- public ToolGuardCardKindFactory(ApprovalService approvalService, ObjectMapper objectMapper) {
+ public ToolGuardCardKindFactory(ApprovalService approvalService,
+ ApprovalWorkflowService approvalWorkflowService,
+ ObjectMapper objectMapper) {
this.approvalService = approvalService;
+ this.approvalWorkflowService = approvalWorkflowService;
this.objectMapper = objectMapper;
}
public WeComCardKind create() {
ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(objectMapper);
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey);
- ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonKey);
+ ToolGuardCardHandler handler = new ToolGuardCardHandler(
+ approvalService, approvalWorkflowService, buttonKey);
return new WeComCardKind(
"tool_guard_approval",
MESSAGE_TYPE,
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 c9c18b20..0d48766a 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,6 +4,7 @@ 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;
@@ -23,6 +24,7 @@ class FeishuCardDispatcherTest {
private FeishuCardDispatcher newDispatcher() {
ToolGuardCardKindFactory factory = new ToolGuardCardKindFactory(
mock(ApprovalService.class),
+ mock(ApprovalWorkflowService.class),
new ObjectMapper());
return new FeishuCardDispatcher(factory);
}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java
index 77c8b6eb..c3c8c10f 100644
--- a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java
@@ -7,7 +7,9 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import vip.mate.approval.ApprovalService;
+import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.PendingApproval;
+import vip.mate.approval.ResolveOutcome;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.wecom.WeComChannelAdapter;
@@ -32,6 +34,7 @@ import static org.mockito.Mockito.*;
class ToolGuardCardHandlerTest {
private ApprovalService approvalService;
+ private ApprovalWorkflowService approvalWorkflowService;
private WeComChannelAdapter adapter;
private ToolGuardButtonKey buttonKey;
private ToolGuardCardHandler handler;
@@ -39,9 +42,10 @@ class ToolGuardCardHandlerTest {
@BeforeEach
void setUp() {
approvalService = Mockito.mock(ApprovalService.class);
+ approvalWorkflowService = Mockito.mock(ApprovalWorkflowService.class);
adapter = Mockito.mock(WeComChannelAdapter.class);
buttonKey = new ToolGuardButtonKey(new ObjectMapper());
- handler = new ToolGuardCardHandler(approvalService, buttonKey);
+ handler = new ToolGuardCardHandler(approvalService, approvalWorkflowService, buttonKey);
}
@Test
@@ -156,6 +160,68 @@ class ToolGuardCardHandlerTest {
verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class));
}
+ // ---- workflow-scoped (wf-) approval branch (ISSUE #413 P2-B3) ----
+
+ @Test
+ @DisplayName("wf- approval: card click resolves inline (no synthetic /approve injection)")
+ void workflowApprovalResolvesInline() {
+ // A workflow-scoped approval has userId=null (system-initiated) and a
+ // wf- pendingId. Before P2-B3 the identity check (requester==clicker)
+ // rejected every click — wf- approvals could only be resolved from the
+ // admin console. Now any audience member may resolve, and the handler
+ // calls resolve() directly (the synthetic injection is a dead end for
+ // wf- ids since their conversationId is workflow:run:{runId}).
+ PendingApproval wfPending = new PendingApproval(
+ "wf-abc123def456", "workflow:run:42", null,
+ "workflow:manager", "{}", "await manager approval");
+ when(approvalService.getPending("wf-abc123def456")).thenReturn(Optional.of(wfPending));
+ when(approvalWorkflowService.resolve("wf-abc123def456", "carol", "approved"))
+ .thenReturn(new ResolveOutcome(
+ "wf-abc123def456", "workflow:run:42", "workflow:manager",
+ "approved", null, true, 0));
+
+ Map