Skip to content

Commit 6d7ffb0

Browse files
committed
fix(feishu-adapter): reuse one approval card per run
1 parent 9748252 commit 6d7ffb0

5 files changed

Lines changed: 194 additions & 77 deletions

File tree

internal/cli/feishu_adapter_command_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@ func (stubFeishuMessenger) SendText(context.Context, string, string) error { ret
245245
func (stubFeishuMessenger) SendPermissionCard(context.Context, string, feishuadapter.PermissionCardPayload) (string, error) {
246246
return "", nil
247247
}
248+
func (stubFeishuMessenger) UpdatePendingPermissionCard(context.Context, string, feishuadapter.PermissionCardPayload) error {
249+
return nil
250+
}
248251
func (stubFeishuMessenger) UpdatePermissionCard(context.Context, string, feishuadapter.ResolvedPermissionCardPayload) error {
249252
return nil
250253
}

internal/feishuadapter/adapter.go

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,13 @@ type Adapter struct {
6666

6767
nowFn func() time.Time
6868

69-
mu sync.RWMutex
70-
activeRuns map[string]sessionBinding
71-
sessionChats map[string]string
72-
requestRuns map[string]string
73-
lastProgressAt map[string]time.Time
74-
permissionCards map[string]string // requestID -> card message_id
69+
mu sync.RWMutex
70+
activeRuns map[string]sessionBinding
71+
sessionChats map[string]string
72+
requestRuns map[string]string
73+
lastProgressAt map[string]time.Time
74+
permissionCards map[string]string // requestID -> card message_id
75+
runPermissionCards map[string]string // runKey -> card message_id
7576
// resolvedPermissions 记录已完成审批的 request_id,避免重复事件将状态回滚为 pending。
7677
resolvedPermissions map[string]string // requestID -> decision
7778
userQuestionCards map[string]string // requestID -> card message_id
@@ -106,6 +107,7 @@ func New(cfg Config, gateway GatewayClient, messenger Messenger, logger *log.Log
106107
requestRuns: make(map[string]string),
107108
lastProgressAt: make(map[string]time.Time),
108109
permissionCards: make(map[string]string),
110+
runPermissionCards: make(map[string]string),
109111
resolvedPermissions: make(map[string]string),
110112
userQuestionCards: make(map[string]string),
111113
pendingQuestions: make(map[string]userQuestionEntry),
@@ -353,6 +355,7 @@ func (a *Adapter) untrackRun(sessionID string, runID string) {
353355
delete(a.pendingQuestions, requestID)
354356
}
355357
}
358+
delete(a.runPermissionCards, key)
356359
delete(a.lastProgressAt, key)
357360
_ = binding
358361
}
@@ -412,19 +415,19 @@ func (a *Adapter) handleGatewayEvent(ctx context.Context, raw json.RawMessage) {
412415
requestID, toolName, operation, target, reason := extractPermissionRequest(envelope)
413416
if requestID != "" {
414417
if a.markPermissionPending(sessionID, runID, requestID, toolName, reason) {
415-
cardID, err := a.messenger.SendPermissionCard(ctx, chatID, PermissionCardPayload{
416-
RequestID: requestID,
417-
ToolName: toolName,
418-
Operation: operation,
419-
Target: target,
420-
Message: reason,
421-
})
422-
if err == nil && strings.TrimSpace(cardID) != "" {
423-
a.mu.Lock()
424-
a.permissionCards[requestID] = cardID
425-
a.mu.Unlock()
426-
a.safeLog("permission card created request_id=%s card_id=%s runtime_type=%s", requestID, strings.TrimSpace(cardID), runtimeType)
427-
}
418+
a.upsertPermissionCard(
419+
ctx,
420+
sessionID,
421+
runID,
422+
chatID,
423+
PermissionCardPayload{
424+
RequestID: requestID,
425+
ToolName: toolName,
426+
Operation: operation,
427+
Target: target,
428+
Message: reason,
429+
},
430+
)
428431
}
429432
return
430433
}
@@ -675,7 +678,7 @@ func (a *Adapter) handleRunProgressCard(ctx context.Context, sessionID string, r
675678
}
676679
}
677680

678-
// markPermissionPending 将权限请求映射到 run 卡片,返回是否需要发送审批按钮卡片
681+
// markPermissionPending 将权限请求映射到 run 卡片,返回当前事件是否应继续刷新审批交互卡片
679682
func (a *Adapter) markPermissionPending(sessionID string, runID string, requestID string, toolName string, reason string) bool {
680683
normalizedRequestID := strings.TrimSpace(requestID)
681684
if normalizedRequestID == "" {
@@ -693,14 +696,15 @@ func (a *Adapter) markPermissionPending(sessionID string, runID string, requestI
693696
return false
694697
}
695698

696-
shouldSendCard := strings.TrimSpace(a.permissionCards[normalizedRequestID]) == ""
699+
alreadyPending := false
697700
binding.ApprovalStatus = "pending"
698701
found := false
699702
for i := range binding.ApprovalRecords {
700703
if binding.ApprovalRecords[i].RequestID != normalizedRequestID {
701704
continue
702705
}
703706
found = true
707+
alreadyPending = isApprovalPendingDecision(binding.ApprovalRecords[i].Decision)
704708
if strings.TrimSpace(binding.ApprovalRecords[i].ToolName) == "" && strings.TrimSpace(toolName) != "" {
705709
binding.ApprovalRecords[i].ToolName = strings.TrimSpace(toolName)
706710
}
@@ -720,14 +724,6 @@ func (a *Adapter) markPermissionPending(sessionID string, runID string, requestI
720724
if strings.TrimSpace(reason) != "" {
721725
binding.LastSummary = strings.TrimSpace(reason)
722726
}
723-
pendingCount := 0
724-
for _, entry := range binding.ApprovalRecords {
725-
if isApprovalPendingDecision(entry.Decision) {
726-
pendingCount++
727-
}
728-
}
729-
// 同一 run 仅展示一张待审批交互卡,后续审批排队等待前一条处理完成。
730-
shouldSendCard = shouldSendCard && pendingCount == 1
731727
a.activeRuns[key] = binding
732728
a.requestRuns[normalizedRequestID] = key
733729

@@ -741,7 +737,54 @@ func (a *Adapter) markPermissionPending(sessionID string, runID string, requestI
741737
a.safeLog("update pending approval card failed: %v", err)
742738
}
743739
}
744-
return shouldSendCard
740+
return !(found && alreadyPending)
741+
}
742+
743+
// upsertPermissionCard 在同一 run 内复用一张审批卡片,后续审批请求覆盖刷新该卡片。
744+
func (a *Adapter) upsertPermissionCard(
745+
ctx context.Context,
746+
sessionID string,
747+
runID string,
748+
chatID string,
749+
payload PermissionCardPayload,
750+
) {
751+
key := runBindingKey(sessionID, runID)
752+
normalizedRequestID := strings.TrimSpace(payload.RequestID)
753+
if key == "|" || normalizedRequestID == "" {
754+
return
755+
}
756+
757+
a.mu.RLock()
758+
existingCardID := strings.TrimSpace(a.runPermissionCards[key])
759+
a.mu.RUnlock()
760+
761+
if existingCardID == "" {
762+
cardID, err := a.messenger.SendPermissionCard(ctx, chatID, payload)
763+
if err != nil {
764+
a.safeLog("permission card create failed request_id=%s err=%v", normalizedRequestID, err)
765+
return
766+
}
767+
cardID = strings.TrimSpace(cardID)
768+
if cardID == "" {
769+
return
770+
}
771+
a.mu.Lock()
772+
a.runPermissionCards[key] = cardID
773+
a.permissionCards[normalizedRequestID] = cardID
774+
a.mu.Unlock()
775+
a.safeLog("permission card created request_id=%s card_id=%s run_key=%s", normalizedRequestID, cardID, key)
776+
return
777+
}
778+
779+
a.safeLog("permission card update start request_id=%s card_id=%s", normalizedRequestID, existingCardID)
780+
if err := a.messenger.UpdatePendingPermissionCard(ctx, existingCardID, payload); err != nil {
781+
a.safeLog("permission card update failed request_id=%s card_id=%s err=%v", normalizedRequestID, existingCardID, err)
782+
return
783+
}
784+
a.mu.Lock()
785+
a.permissionCards[normalizedRequestID] = existingCardID
786+
a.mu.Unlock()
787+
a.safeLog("permission card update done request_id=%s card_id=%s", normalizedRequestID, existingCardID)
745788
}
746789

747790
// markUserQuestionPending 记录 ask_user 待回答问题,并挂接到 run 状态卡上下文。
@@ -840,6 +883,11 @@ func (a *Adapter) updateApprovalStatus(requestID string, decision string) {
840883
return
841884
}
842885
a.mu.Lock()
886+
if alreadyDecision, resolved := a.resolvedPermissions[normalizedRequestID]; resolved &&
887+
normalizeApprovalDecision(alreadyDecision) == normalizedDecision {
888+
a.mu.Unlock()
889+
return
890+
}
843891
key := a.requestRuns[normalizedRequestID]
844892
binding, ok := a.activeRuns[key]
845893
var resolvedApproval *approvalEntry
@@ -885,6 +933,9 @@ func (a *Adapter) updateApprovalStatus(requestID string, decision string) {
885933
statusPayload = binding.statusCardPayload()
886934
}
887935
permCardID := strings.TrimSpace(a.permissionCards[normalizedRequestID])
936+
if permCardID == "" {
937+
permCardID = strings.TrimSpace(a.runPermissionCards[key])
938+
}
888939
a.resolvedPermissions[normalizedRequestID] = normalizedDecision
889940
a.mu.Unlock()
890941

internal/feishuadapter/adapter_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ type sentMessage struct {
111111
kind string
112112
text string
113113
card PermissionCardPayload
114+
updatedPendingCard *PermissionCardPayload
114115
userQuestionCard UserQuestionCardPayload
115116
runCard StatusCardPayload
116117
cardID string
@@ -151,6 +152,13 @@ func (m *fakeMessenger) UpdatePermissionCard(_ context.Context, cardID string, p
151152
return nil
152153
}
153154

155+
func (m *fakeMessenger) UpdatePendingPermissionCard(_ context.Context, cardID string, payload PermissionCardPayload) error {
156+
m.mu.Lock()
157+
defer m.mu.Unlock()
158+
m.messages = append(m.messages, sentMessage{chatID: cardID, kind: "update_pending_perm_card", updatedPendingCard: &payload})
159+
return nil
160+
}
161+
154162
func (m *fakeMessenger) DeleteMessage(_ context.Context, messageID string) error {
155163
m.mu.Lock()
156164
defer m.mu.Unlock()
@@ -807,6 +815,73 @@ func TestPermissionApprovalsDoNotAutoPushQueuedCardOnResolve(t *testing.T) {
807815
}
808816
}
809817

818+
func TestPermissionCardReusedAcrossSequentialRequests(t *testing.T) {
819+
adapter := newTestAdapter(t)
820+
if err := adapter.bindThenRun(context.Background(), "session-reuse", "run-reuse", "chat-reuse", "执行审批复用任务"); err != nil {
821+
t.Fatalf("bindThenRun: %v", err)
822+
}
823+
ctx, cancel := context.WithCancel(context.Background())
824+
defer cancel()
825+
go adapter.consumeGatewayEvents(ctx)
826+
827+
pushGatewayEvent(t, adapterTestGateway(adapter), "session-reuse", "run-reuse", "run_progress", map[string]any{
828+
"runtime_event_type": "permission_requested",
829+
"payload": map[string]any{
830+
"request_id": "perm-reuse-1",
831+
"reason": "审批一",
832+
},
833+
})
834+
time.Sleep(30 * time.Millisecond)
835+
836+
if err := adapter.HandleCardAction(context.Background(), FeishuCardActionEvent{
837+
RequestID: "perm-reuse-1",
838+
Decision: "allow_once",
839+
}); err != nil {
840+
t.Fatalf("handle first card action: %v", err)
841+
}
842+
time.Sleep(30 * time.Millisecond)
843+
844+
pushGatewayEvent(t, adapterTestGateway(adapter), "session-reuse", "run-reuse", "run_progress", map[string]any{
845+
"runtime_event_type": "permission_requested",
846+
"payload": map[string]any{
847+
"request_id": "perm-reuse-2",
848+
"reason": "审批二",
849+
},
850+
})
851+
time.Sleep(40 * time.Millisecond)
852+
853+
msgs := adapterTestMessenger(adapter).snapshot()
854+
cardSends := 0
855+
pendingUpdates := 0
856+
resolvedUpdates := 0
857+
lastPendingRequestID := ""
858+
for _, message := range msgs {
859+
switch message.kind {
860+
case "card":
861+
cardSends++
862+
case "update_pending_perm_card":
863+
pendingUpdates++
864+
if message.updatedPendingCard != nil {
865+
lastPendingRequestID = message.updatedPendingCard.RequestID
866+
}
867+
case "update_perm_card":
868+
resolvedUpdates++
869+
}
870+
}
871+
if cardSends != 1 {
872+
t.Fatalf("permission card sends = %d, want 1; msgs=%#v", cardSends, msgs)
873+
}
874+
if pendingUpdates < 1 {
875+
t.Fatalf("expected pending permission card update for second request, msgs=%#v", msgs)
876+
}
877+
if resolvedUpdates < 1 {
878+
t.Fatalf("expected resolved permission card update for first request, msgs=%#v", msgs)
879+
}
880+
if lastPendingRequestID != "perm-reuse-2" {
881+
t.Fatalf("last pending request id = %q, want perm-reuse-2; msgs=%#v", lastPendingRequestID, msgs)
882+
}
883+
}
884+
810885
func TestPermissionResolvedEventUpdatesPermissionCard(t *testing.T) {
811886
adapter := newTestAdapter(t)
812887
if err := adapter.bindThenRun(context.Background(), "session-resolve", "run-resolve", "chat-resolve", "执行审批事件任务"); err != nil {

internal/feishuadapter/messenger.go

Lines changed: 34 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -73,32 +73,14 @@ func (m *feishuMessenger) SendPermissionCard(ctx context.Context, chatID string,
7373
return m.sendMessage(ctx, chatID, "interactive", string(content))
7474
}
7575

76+
// UpdatePendingPermissionCard 根据 card_id 覆盖更新审批卡片为新的待审批请求。
77+
func (m *feishuMessenger) UpdatePendingPermissionCard(ctx context.Context, cardID string, payload PermissionCardPayload) error {
78+
return m.patchInteractiveCard(ctx, cardID, buildPermissionCard(payload))
79+
}
80+
7681
// UpdatePermissionCard 根据 card_id 覆盖更新审批卡片为已处理状态。
7782
func (m *feishuMessenger) UpdatePermissionCard(ctx context.Context, cardID string, payload ResolvedPermissionCardPayload) error {
78-
token, err := m.tenantAccessToken(ctx)
79-
if err != nil {
80-
return err
81-
}
82-
card := buildResolvedPermissionCard(payload)
83-
content, err := json.Marshal(card)
84-
if err != nil {
85-
return err
86-
}
87-
body := map[string]string{
88-
"content": string(content),
89-
}
90-
data, err := json.Marshal(body)
91-
if err != nil {
92-
return err
93-
}
94-
url := strings.TrimRight(m.baseURL, "/") + "/open-apis/im/v1/messages/" + cardID
95-
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(data))
96-
if err != nil {
97-
return err
98-
}
99-
req.Header.Set("Content-Type", "application/json")
100-
req.Header.Set("Authorization", "Bearer "+token)
101-
return m.doJSONRequest(req)
83+
return m.patchInteractiveCard(ctx, cardID, buildResolvedPermissionCard(payload))
10284
}
10385

10486
// DeleteMessage 根据 message_id 删除飞书消息,常用于审批卡片在完成后收起。
@@ -169,29 +151,7 @@ func (m *feishuMessenger) SendStatusCard(ctx context.Context, chatID string, pay
169151

170152
// UpdateCard 根据 card_id 覆盖更新当前 run 的状态卡片内容。
171153
func (m *feishuMessenger) UpdateCard(ctx context.Context, cardID string, payload StatusCardPayload) error {
172-
token, err := m.tenantAccessToken(ctx)
173-
if err != nil {
174-
return err
175-
}
176-
content, err := json.Marshal(buildStatusCard(payload))
177-
if err != nil {
178-
return err
179-
}
180-
body := map[string]string{
181-
"content": string(content),
182-
}
183-
data, err := json.Marshal(body)
184-
if err != nil {
185-
return err
186-
}
187-
url := strings.TrimRight(m.baseURL, "/") + "/open-apis/im/v1/messages/" + cardID
188-
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(data))
189-
if err != nil {
190-
return err
191-
}
192-
req.Header.Set("Content-Type", "application/json")
193-
req.Header.Set("Authorization", "Bearer "+token)
194-
return m.doJSONRequest(req)
154+
return m.patchInteractiveCard(ctx, cardID, buildStatusCard(payload))
195155
}
196156

197157
// sendMessage 统一封装飞书消息发送请求,复用鉴权与错误处理。
@@ -249,6 +209,33 @@ func (m *feishuMessenger) doJSONRequest(req *http.Request) error {
249209
return err
250210
}
251211

212+
// patchInteractiveCard 复用飞书消息 PATCH 接口更新交互卡片内容。
213+
func (m *feishuMessenger) patchInteractiveCard(ctx context.Context, cardID string, card map[string]any) error {
214+
token, err := m.tenantAccessToken(ctx)
215+
if err != nil {
216+
return err
217+
}
218+
content, err := json.Marshal(card)
219+
if err != nil {
220+
return err
221+
}
222+
body := map[string]string{
223+
"content": string(content),
224+
}
225+
data, err := json.Marshal(body)
226+
if err != nil {
227+
return err
228+
}
229+
url := strings.TrimRight(m.baseURL, "/") + "/open-apis/im/v1/messages/" + cardID
230+
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(data))
231+
if err != nil {
232+
return err
233+
}
234+
req.Header.Set("Content-Type", "application/json")
235+
req.Header.Set("Authorization", "Bearer "+token)
236+
return m.doJSONRequest(req)
237+
}
238+
252239
// tenantAccessToken 获取并缓存 tenant access token,避免每次发送都重复换取。
253240
func (m *feishuMessenger) tenantAccessToken(ctx context.Context) (string, error) {
254241
m.mu.Lock()

0 commit comments

Comments
 (0)