feat(feishu): exec approval interactive cards with Handler pattern#60328
feat(feishu): exec approval interactive cards with Handler pattern#60328schumilin wants to merge 13 commits into
Conversation
ed25944 to
45ec88d
Compare
Greptile SummaryThis PR adds interactive Feishu approval cards for exec approval requests, bringing Feishu to parity with Discord and Telegram. The implementation follows the established Key changes:
Two style-level issues found:
Confidence Score: 4/5Safe to merge; no functional regressions found, only two style-level suggestions. The architecture is clean, follows established patterns, and is backed by 28 unit tests plus manual E2E verification. The two issues flagged (missing command/cwd in resolved card, misleading no-op normalizer) are UX/style gaps rather than correctness or security bugs. The core logic — target routing, approver gating, token deduplication, double-delivery prevention, and error handling — is all sound. extensions/feishu/src/exec-approvals-handler.ts (resolved card context) and extensions/feishu/src/exec-approvals.ts (normalizer correctness)
|
| private async finalizeResolved( | ||
| _request: ApprovalRequest, | ||
| resolved: ApprovalResolved, | ||
| messages: PendingMessage[], | ||
| ): Promise<void> { | ||
| await Promise.allSettled( | ||
| messages.map(async (message) => { | ||
| try { | ||
| const resolvedCard = createExecApprovalResolvedCard({ | ||
| approvalId: resolved.id, | ||
| decision: resolved.decision, | ||
| resolvedBy: resolved.resolvedBy ?? undefined, | ||
| }); | ||
| await updateCardFeishu({ | ||
| cfg: this.opts.cfg, | ||
| messageId: message.messageId, | ||
| card: resolvedCard, | ||
| accountId: this.opts.accountId, | ||
| }); | ||
| } catch (err) { | ||
| log.error( | ||
| `feishu exec approvals: failed to update card ${message.messageId}: ${String(err)}`, | ||
| ); | ||
| } | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Resolved card drops command/cwd from the pending card
The pending card shows the command and working directory (built in buildPendingContent), but finalizeResolved ignores _request and omits them from the resolved card. Because Feishu updates the card in-place (the pending card is replaced), the approver loses visibility into which command was approved/denied once the card flips state.
For exec approvals, the data is readily available in _request:
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
messages: PendingMessage[],
): Promise<void> {
const execRequest = resolved.id && (request as ExecApprovalRequest).request
? (request as ExecApprovalRequest)
: null;
const commandDisplay = execRequest
? resolveExecApprovalCommandDisplay(execRequest.request)
: null;
await Promise.allSettled(
messages.map(async (message) => {
try {
const resolvedCard = createExecApprovalResolvedCard({
approvalId: resolved.id,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy ?? undefined,
command: commandDisplay?.commandText,
cwd: execRequest?.request.cwd ?? undefined,
});
...Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/exec-approvals-handler.ts
Line: 138-163
Comment:
**Resolved card drops command/cwd from the pending card**
The pending card shows the command and working directory (built in `buildPendingContent`), but `finalizeResolved` ignores `_request` and omits them from the resolved card. Because Feishu updates the card in-place (the pending card is replaced), the approver loses visibility into which command was approved/denied once the card flips state.
For exec approvals, the data is readily available in `_request`:
```ts
private async finalizeResolved(
request: ApprovalRequest,
resolved: ApprovalResolved,
messages: PendingMessage[],
): Promise<void> {
const execRequest = resolved.id && (request as ExecApprovalRequest).request
? (request as ExecApprovalRequest)
: null;
const commandDisplay = execRequest
? resolveExecApprovalCommandDisplay(execRequest.request)
: null;
await Promise.allSettled(
messages.map(async (message) => {
try {
const resolvedCard = createExecApprovalResolvedCard({
approvalId: resolved.id,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy ?? undefined,
command: commandDisplay?.commandText,
cwd: execRequest?.request.cwd ?? undefined,
});
...
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in d61c190. finalizeResolved now extracts command/cwd from the request and passes them to createExecApprovalResolvedCard, so the resolved card retains full context about what was approved/denied.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ed2594462a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| approvalCapability: { | ||
| ...feishuApprovalCapability, | ||
| }, |
There was a problem hiding this comment.
Preserve legacy Feishu /approve authorization path
Adding approvalCapability here causes Feishu approval auth to be taken from feishuApprovalCapability (exec-approver list) instead of the existing auth: feishuApprovalAuth allowlist path, because channel capability auth takes precedence over legacy auth wiring. In setups that have existing Feishu approvers via channels.feishu.allowFrom but do not configure channels.feishu.execApprovals.approvers (the new feature is supposed to be opt-in), /approve now resolves to unauthorized for both exec and plugin approvals, so pending approvals can be sent but cannot be resolved.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d61c190. getFeishuExecApprovalApprovers now falls back to allowFrom (matching the Telegram pattern), so existing setups that rely on channels.feishu.allowFrom for approval authorization continue to work without requiring explicit execApprovals.approvers config.
|
Both findings addressed in d61c190:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d61c1906ce
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| accountId?: string | null; | ||
| payload: ReplyPayload; | ||
| }): boolean { | ||
| return feishuExecApprovalProfile.shouldSuppressLocalPrompt(params); |
There was a problem hiding this comment.
Gate local prompt suppression on approval request filters
shouldSuppressLocalFeishuExecApprovalPrompt suppresses any exec-approval payload whenever the Feishu client is enabled, but it does not check agentFilter/sessionFilter. In the same module, shouldHandleFeishuExecApprovalRequest does enforce those filters, so filtered requests can be skipped by the native handler while their fallback text prompt is still suppressed, leaving no Feishu approval prompt for those requests. Suppression should be aligned with the same request-level filter predicate used for handling.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This matches Telegram's behavior — shouldSuppressLocalTelegramExecApprovalPrompt also delegates to profile.shouldSuppressLocalPrompt without additional agentFilter/sessionFilter checks. If this needs to be tightened, it should be done at the createChannelExecApprovalProfile framework level to keep all channels consistent.
| if (!normalized.startsWith("ou_") && !normalized.startsWith("on_")) { | ||
| return undefined; |
There was a problem hiding this comment.
Reject unsupported on_ approver IDs or authorize them correctly
This normalizer now accepts on_ IDs as valid approvers, but approval authorization compares against Feishu open_id values (for example card actions pass event.operator.open_id), so on_... entries cannot actually pass approver checks at runtime. That makes config silently accept approvers that can never approve requests. Either restrict accepted IDs to the identifier type used by auth checks or add consistent union-id matching across authorization paths.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9a6891a. Approver normalizer now only accepts ou_ (open_id) prefix. on_ (union_id) is rejected since card action callbacks use event.operator.open_id which is always ou_-prefixed.
9a6891a to
3722fd1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3722fd119d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!normalized.startsWith("ou_")) { | ||
| return undefined; |
There was a problem hiding this comment.
Normalize Feishu approver IDs before prefix checks
getFeishuExecApprovalApprovers now uses normalizeFeishuDirectApproverId for both explicit approvers and allowFrom, but that normalizer only accepts raw lowercase ou_... strings. Existing valid Feishu allowlist forms such as user:ou_xxx, feishu:user:ou_xxx, or uppercase variants are filtered out, which can empty the approver set and make /approve unauthorized after approvalCapability takes precedence over legacy auth. This is a regression for configurations that previously relied on the broader Feishu ID normalization behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cde6083. normalizeFeishuDirectApproverId now uses parseFeishuDirectConversationId to handle prefixed forms (user:ou_xxx, feishu:user:ou_xxx, etc.) before validating the ou_ prefix.
| channel: | ||
| request.request.turnSourceChannel?.trim().toLowerCase() === "feishu" ? null : "feishu", | ||
| }); | ||
| return ( | ||
| !boundAccountId || | ||
| !accountId || | ||
| normalizeAccountId(boundAccountId) === normalizeAccountId(accountId) |
There was a problem hiding this comment.
Restrict ambiguous non-Feishu requests to one account
The account-matching predicate treats boundAccountId === null as a match for every Feishu account, and for non-Feishu turn sources this path is guaranteed because resolveApprovalRequestAccountId is called with channel: "feishu". In multi-account setups, a single non-Feishu approval request can therefore be handled by multiple Feishu handlers, causing duplicate prompts and leaking command details across accounts. The matcher should gate this ambiguous case (for example, only allow when there is exactly one eligible Feishu account), as done in other channel implementations.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cde6083. Added countFeishuExecApprovalEligibleAccounts and matchesFeishuRequestAccount — for non-Feishu turn sources with no bound account, the handler only proceeds when there is exactly one eligible Feishu account, matching Telegram's pattern.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cde60833a5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| approvalCapability: { | ||
| ...feishuApprovalCapability, | ||
| }, |
There was a problem hiding this comment.
Wire Feishu routing guards into approval capability
This capability only spreads feishuApprovalCapability, so the new forwarding safeguards in exec-approval-forwarding.ts are never applied. In practice, requests that bypass native suppression (for example, approvals originating from a non-Feishu turn source with channels.feishu.execApprovals.target: "dm") can still be forwarded by the generic exec forwarder to Feishu channel targets, which leaks command/cwd details into chats that the Feishu target mode was meant to exclude. The channel capability needs to register the Feishu-specific pending payload/suppression hooks instead of relying solely on the native adapter.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is already handled by the framework. createApproverRestrictedNativeApprovalCapability internally wires up shouldSuppressForwardingFallback (see src/plugin-sdk/approval-delivery-helpers.ts:112), so feishuApprovalCapability already includes forwarding suppression. Telegram doesn't register it separately either — it's built into the capability.
| // Strip "user:" prefix for DM targets | ||
| const normalized = to.startsWith("user:") ? to.slice(5) : to; | ||
| return normalized === normalizedSenderId; |
There was a problem hiding this comment.
Normalize Feishu target IDs before auth matching
Recipient authorization compares target.to by stripping only the user: prefix, so equivalent Feishu ID forms like dm:ou_xxx, open_id:ou_xxx, or provider-prefixed forms are treated as different users. If teams configure approvals.exec.targets with those accepted Feishu address forms, the intended recipient fails isTargetRecipient and /approve is rejected unless they are also in the approver list. This matcher should normalize with the same Feishu target parser used elsewhere.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7692637. isFeishuExecApprovalTargetRecipient now uses parseFeishuDirectConversationId to normalize all Feishu address forms (dm:ou_xxx, open_id:ou_xxx, feishu:user:ou_xxx, bare ou_xxx) consistently with the approver normalization path.
Add Feishu Interactive Card (V2) support for exec approval workflow: - Send pending approval cards with Allow Once / Allow Always / Deny buttons - Resolve approvals via direct gateway RPC (callGateway) instead of synthetic /approve commands, eliminating duplicate messages - Update cards in-place on resolution showing command, result, and operator - Display operator as Feishu user capsule (<at> mention) - Suppress default resolved text notification for Feishu channel - Support both DM and group chat contexts - Chinese localization for all card labels Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Revert commands-approve.ts to upstream, add only Feishu channel checks (preserves plugin routing, explicit authorization, and fallback logic) - Use static chunkTextForOutbound in feishu outbound (fixes chunker test) - Quote <at> tag id attribute in resolved card - Regenerate schema.base.generated.ts Co-Authored-By: Claude Opus 4.6 <[email protected]>
…ssion - Verify event.operator.open_id is a configured approver before resolving exec approval via card button RPC (prevents unauthorized button clicks) - Only suppress resolved text notification when resolved via Feishu card action (resolvedBy starts with "feishu:"); fall through to default text notification when resolved from other surfaces (terminal, web, etc.) - Support undefined return from buildResolvedPayload to signal "use default" Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Fix resolvedBy check to match "Feishu card approval" (the gateway clientDisplayName) instead of "feishu:" prefix - Preserve "sandbox" host value in Feishu approval payloads instead of collapsing non-"node" hosts to "gateway" Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Check isFeishuExecApprovalClientEnabled before processing card button clicks, so disabling the feature prevents stale cards from resolving - Log card patch failures and fall back to sending a new resolved card instead of silently swallowing the error Co-Authored-By: Claude Opus 4.6 <[email protected]>
…ayload Pass replyInThread to sendCardFeishu in the sendPayload path so card replies in threaded contexts are posted as thread replies instead of inline replies, matching the existing sendText behavior. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Refactor Feishu exec approval to use the same ExecApprovalChannelRuntime + createApproverRestrictedNativeApprovalCapability architecture as Discord and Telegram. - Add FeishuExecApprovalHandler with gateway WebSocket subscription, pending message tracking, multi-target card updates on resolve, and timeout/expiry management - Add approval-native.ts adapter for proper authorization, target routing, and forwarding suppression - Rewrite exec-approvals.ts to use createChannelExecApprovalProfile - Simplify card-action.ts button handler to RPC-only (card updates delegated to handler) - Support expired card state and smart resolvedBy display - Fix sendOutboundText replyInThread passthrough Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…zation - Fix empty approvalId in expired card (Greptile P2) - Extract host normalization to local variable in forwarding path (Greptile P2) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Complete card action token before async gateway RPC to avoid Feishu 200340 timeout error - Extract Feishu open_id from gateway clientDisplayName for proper <at> tag in resolved cards Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…r validation, allowFrom fallback - Include command/cwd in resolved card so approvers retain context after in-place update (Greptile P2) - Validate ou_/on_ prefix in normalizeFeishuDirectApproverId to reject misconfigured approver IDs (Greptile P2) - Fall back to allowFrom when resolving approvers, matching Telegram pattern to preserve legacy /approve authorization (Codex P1) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
on_ (union_id) would silently fail approver checks at runtime since card action callbacks use event.operator.open_id (ou_ prefix). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…atching - Parse prefixed forms (user:ou_xxx, feishu:user:ou_xxx) via parseFeishuDirectConversationId before ou_ validation (Codex P1) - Gate non-Feishu turn sources to single eligible account to prevent duplicate prompts in multi-account setups (Codex P2) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
cde6083 to
d42a6e5
Compare
Use the same Feishu ID parser for target recipient matching as for approver normalization, so all address forms (dm:ou_xxx, open_id:ou_xxx, feishu:user:ou_xxx) are handled consistently. (Codex P2) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7692637b4c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const parsed = parseFeishuDirectConversationId(value); | ||
| if (parsed) { | ||
| return parsed; |
There was a problem hiding this comment.
Validate parsed Feishu approver IDs before returning them
normalizeFeishuDirectApproverId returns any value produced by parseFeishuDirectConversationId without checking that it is an ou_ open_id, so entries like user:foo or user:OU_123 are treated as configured approvers. Those values increase approver count and enable the native exec-approval client, but they can never match card callback event.operator.open_id, which leaves approvals effectively unresolvable in production configs that use prefixed IDs. Fresh evidence is that parseFeishuDirectConversationId accepts any user: suffix, so this early return bypasses the intended ou_ validation.
Useful? React with 👍 / 👎.
|
Thanks for the context here. I swept through the related work, and this is now duplicate or superseded. Keep this PR open because current main still lacks the central Feishu native exec approval card feature and the branch contains meaningful source work, but the patch is not merge-ready due to approval-boundary defects, config/docs mismatch, and missing inspectable real Feishu proof. Canonical path: Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review. So I’m closing this here because the remaining work is already tracked in the canonical issue. Review detailsBest possible solution: Close this stale PR. The latest review rated it F, the branch still lacks merge-ready proof, and there has been no human follow-up after the durable review. Do we have a high-confidence way to reproduce the issue? Yes for the review blockers: source inspection shows the parsed-prefix path bypasses the intended Is this the best way to solve the issue? No in the current branch shape. The handler/card direction is plausible, but the mergeable solution should use the focused approval gateway seam, validate approver IDs after parsing, align the new config with sibling native clients, and document the new surface. Security review: Security review needs attention: The diff adds button-driven exec approval resolution and broad gateway SDK exports; approver identity validation and gateway scope need attention before merge.
AGENTS.md: found and applied where relevant. What I checked:
Likely related people:
Codex review notes: model internal, reasoning high; reviewed against 583b7195b4c2. |
|
ClawSweeper PR egg 🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat. Where did the egg go?
|
|
This pull request has been automatically marked as stale due to inactivity. |
|
Why hasn't it been merged all along? Feishu's interactive card approval has been missing, and the user experience is very poor |
|
ClawSweeper applied the proposed close for this PR.
|
Summary
/approve <id> <decision>to respond, while Discord and Telegram users get one-click interactive buttons.FeishuExecApprovalHandlerthat sends Feishu Interactive Cards (V2) with Allow Once / Allow Always / Deny buttons, tracks all delivered cards, and updates them in-place on resolution or expiry. Follows the sameExecApprovalChannelRuntime+createApproverRestrictedNativeApprovalCapabilityarchitecture used by Discord and Telegram.commands-approve.tsor the core approval authorization flow. No changes to Discord or Telegram exec approval behavior. The Feishu card UX approval flow (non-exec, existing feature) is untouched.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause / Regression History (if applicable)
N/A — new feature.
Regression Test Plan (if applicable)
N/A — new feature.
User-visible / Behavior Changes
channels.feishu.execApprovals.enabledistruewith configuredapprovers, exec approval requests are delivered as interactive Feishu cards instead of plain text.target:"dm"(default) |"channel"|"both"controls where approval cards are sent.Diagram (if applicable)
Cross-channel consistency:
DiscordExecApprovalHandlerTelegramExecApprovalHandlerFeishuExecApprovalHandlerExecApprovalChannelRuntimecreateChannelNativeApprovalRuntimecreateChannelNativeApprovalRuntimecreateApproverRestrictedNativeApprovalCapabilitySecurity Impact (required)
NoNoYes— handler opens a WebSocket to the local gateway (same as Discord/Telegram handlers). Button clicks callexec.approval.resolveviacallGatewayRPC.NoNooperator.approvalsscope with authenticated credentials. Button clicks verifyisFeishuExecApprovalClientEnabledandisFeishuExecApprovalApproverbefore calling the RPC.Repro + Verification
Environment
channels.feishu.execApprovals: { enabled: true, approvers: ["ou_xxx"], target: "dm" }Steps
tools.exec.security: "allowlist"andtools.exec.ask: "on-miss"to require approvalExpected
Actual
Evidence
Unit tests: 28 passed (exec-approvals: 18, card-ux-exec-approval: 10). Build clean. Format/lint clean.
Human Verification (required)
Review Conversations
Compatibility / Migration
Yes— new feature, opt-in via config.Yes— newchannels.feishu.execApprovalsconfig block.NoRisks and Mitigations
execApprovals.enabled: true.AI Disclosure
🤖 Generated with Claude Code