mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
feat(approval): support approval event consumption (#1924)
Register approval.instance.status_changed_v4 and approval.task.status_changed_v4 with custom flattened schemas and user-auth pre-consume subscription setup. Handle approval subscription_type as optional multi-value pre-registration metadata: omitted values register both involved and managed relations, explicit values can be single, comma-separated, or JSON array, and consumers do not unsubscribe on exit. Report partial approval subscription registration failures with registered and failed relation context while preserving the underlying typed error classification. Document approval event output fields and subscription semantics, and refresh approval skill references from API metadata.
This commit is contained in:
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
|
||||
@@ -19,6 +19,29 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
type approvalSchemaJSONPayload struct {
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
AuthTypes []string `json:"auth_types"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Params []approvalSchemaJSONParam `json:"params"`
|
||||
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONParam struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
SubscriptionKey bool `json:"subscription_key"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONResolvedSchema struct {
|
||||
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONProperty struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
}{
|
||||
{"approval.instance.status_changed_v4", "approval:instance:read"},
|
||||
{"approval.task.status_changed_v4", "approval:task:read"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload approvalSchemaJSONPayload
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.JQRootPath != "." {
|
||||
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
|
||||
}
|
||||
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
|
||||
t.Errorf("auth_types = %#v, want user", got)
|
||||
}
|
||||
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
|
||||
t.Errorf("scopes = %#v, want %s", got, tc.scope)
|
||||
}
|
||||
if len(payload.Params) != 1 {
|
||||
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
|
||||
}
|
||||
param := payload.Params[0]
|
||||
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
|
||||
}
|
||||
props := payload.ResolvedOutputSchema.Properties
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("approval schema missing flat field %q: %+v", field, props)
|
||||
}
|
||||
}
|
||||
if _, ok := props["event"]; ok {
|
||||
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
|
||||
}
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
|
||||
155
events/approval/preconsume.go
Normal file
155
events/approval/preconsume.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type approvalEventType string
|
||||
type approvalSubscriptionPath string
|
||||
|
||||
type approvalSubscriptionConfig struct {
|
||||
eventType approvalEventType
|
||||
subscribePath approvalSubscriptionPath
|
||||
}
|
||||
|
||||
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
eventType := string(cfg.eventType)
|
||||
subscribePath := string(cfg.subscribePath)
|
||||
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registered := make([]string, 0, len(subscriptionTypes))
|
||||
for _, subscriptionType := range subscriptionTypes {
|
||||
body := map[string]string{"subscription_type": subscriptionType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
|
||||
}
|
||||
registered = append(registered, subscriptionType)
|
||||
}
|
||||
|
||||
// Approval subscriptions are durable user-auth relations. Consuming events
|
||||
// should not cancel that relation when this local process exits.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
|
||||
raw := strings.TrimSpace(params["subscription_type"])
|
||||
if raw == "" {
|
||||
return append([]string(nil), approvalAllSubscriptionTypes...), nil
|
||||
}
|
||||
|
||||
values, err := parseApprovalSubscriptionTypeValues(raw)
|
||||
if err != nil {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
|
||||
selected := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
|
||||
selected[value] = true
|
||||
default:
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(selected))
|
||||
for _, value := range approvalAllSubscriptionTypes {
|
||||
if selected[value] {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return strings.Split(raw, ","), nil
|
||||
}
|
||||
|
||||
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
|
||||
eventType,
|
||||
failed,
|
||||
)
|
||||
hint := fmt.Sprintf(
|
||||
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
|
||||
eventType,
|
||||
)
|
||||
if len(registered) > 0 {
|
||||
msg = fmt.Sprintf(
|
||||
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
hint = fmt.Sprintf(
|
||||
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
}
|
||||
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if upstream := strings.TrimSpace(p.Message); upstream != "" {
|
||||
p.Message = msg + ": " + upstream
|
||||
} else {
|
||||
p.Message = msg
|
||||
}
|
||||
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
|
||||
p.Hint = upstreamHint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
|
||||
WithHint("%s", hint).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid subscription_type for EventKey %s: %q", eventType, value).
|
||||
WithParam("--param").
|
||||
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
eventType)
|
||||
}
|
||||
179
events/approval/register.go
Normal file
179
events/approval/register.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package approval registers Approval-domain EventKeys.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
|
||||
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
|
||||
|
||||
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
|
||||
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
|
||||
|
||||
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
|
||||
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
|
||||
)
|
||||
|
||||
var approvalAllSubscriptionTypes = []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
}
|
||||
|
||||
// Keys returns all Approval-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
DisplayName: "Approval instance status changed",
|
||||
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalInstanceStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:instance:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
|
||||
},
|
||||
{
|
||||
Key: eventTypeApprovalTaskStatusChangedV4,
|
||||
DisplayName: "Approval task status changed",
|
||||
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalTaskStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:task:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionParams() []event.ParamDef {
|
||||
return []event.ParamDef{
|
||||
{
|
||||
Name: "subscription_type",
|
||||
Type: event.ParamMulti,
|
||||
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
|
||||
Values: []event.ParamValue{
|
||||
{
|
||||
Value: approvalSubscriptionTypeInvolved,
|
||||
Desc: "Receive events where the current user is the approval requester or approver.",
|
||||
},
|
||||
{
|
||||
Value: approvalSubscriptionTypeManaged,
|
||||
Desc: "Receive events under approval definitions managed by the current user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
StartUser *ApprovalUserID `json:"start_user"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalInstanceStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
StartUser: envelope.Event.StartUser,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
TaskExternalID string `json:"task_external_id"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &ApprovalTaskStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
TaskID: envelope.Event.TaskID,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
TaskExternalID: envelope.Event.TaskExternalID,
|
||||
AssignedUser: envelope.Event.AssignedUser,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
654
events/approval/register_test.go
Normal file
654
events/approval/register_test.go
Normal file
@@ -0,0 +1,654 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
type fakeAPIClient struct {
|
||||
calls []recordedCall
|
||||
err error
|
||||
errOnCall int
|
||||
}
|
||||
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
|
||||
return nil, f.err
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
func TestKeysApprovalMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
schemaType reflect.Type
|
||||
subscribe string
|
||||
}{
|
||||
{
|
||||
key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
scope: "approval:instance:read",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalInstancesSubscription,
|
||||
},
|
||||
{
|
||||
key: eventTypeApprovalTaskStatusChangedV4,
|
||||
scope: "approval:task:read",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalTasksSubscription,
|
||||
},
|
||||
}
|
||||
|
||||
byKey := make(map[string]event.KeyDefinition, len(keys))
|
||||
for _, def := range keys {
|
||||
byKey[def.Key] = def
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
def, ok := byKey[tc.key]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %s", tc.key)
|
||||
}
|
||||
if def.EventType != tc.key {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
|
||||
}
|
||||
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process must flatten raw V2 envelopes")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume must subscribe approval user-auth events")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
|
||||
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
|
||||
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
|
||||
}
|
||||
assertSubscriptionParam(t, def.Params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
|
||||
t.Helper()
|
||||
if len(params) != 1 {
|
||||
t.Fatalf("len(params) = %d, want 1", len(params))
|
||||
}
|
||||
p := params[0]
|
||||
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, v := range p.Values {
|
||||
got[v.Value] = v.Desc
|
||||
}
|
||||
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
|
||||
if got[want] == "" {
|
||||
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type reflectedApprovalSchema struct {
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type reflectedApprovalSchemaProperty struct {
|
||||
Format string `json:"format"`
|
||||
Enum []string `json:"enum"`
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
func TestApprovalSchemasAnnotations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schemaType reflect.Type
|
||||
eventType string
|
||||
statusValues []string
|
||||
userField string
|
||||
}{
|
||||
{
|
||||
name: "instance",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "start_user",
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "assigned_user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var schema reflectedApprovalSchema
|
||||
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
props := schema.Properties
|
||||
eventTypeEnum := props["type"].Enum
|
||||
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
|
||||
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
|
||||
}
|
||||
if got := props["timestamp"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("timestamp format = %v, want timestamp_ms", got)
|
||||
}
|
||||
assertEnumContains(t, props["status"].Enum, tc.statusValues)
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
|
||||
userProps := props[tc.userField].Properties
|
||||
if got := userProps["open_id"].Format; got != "open_id" {
|
||||
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["union_id"].Format; got != "union_id" {
|
||||
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["user_id"].Format; got != "user_id" {
|
||||
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnumContains(t *testing.T, raw []string, wants []string) {
|
||||
t.Helper()
|
||||
got := make(map[string]bool, len(raw))
|
||||
for _, v := range raw {
|
||||
got[v] = true
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !got[want] {
|
||||
t.Errorf("enum missing %q; enum=%v", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventType string
|
||||
subscribePath string
|
||||
params map[string]string
|
||||
wantTypes []string
|
||||
}{
|
||||
{
|
||||
name: "instance omitted subscription_type registers both",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "task explicit single managed",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
|
||||
wantTypes: []string{approvalSubscriptionTypeManaged},
|
||||
},
|
||||
{
|
||||
name: "task comma separated multi canonicalizes and deduplicates",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance json array multi",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: approvalEventType(tc.eventType),
|
||||
subscribePath: approvalSubscriptionPath(tc.subscribePath),
|
||||
})
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume returned error: %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(wantTypes) {
|
||||
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
|
||||
}
|
||||
for i, wantType := range wantTypes {
|
||||
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
|
||||
t.Helper()
|
||||
if got.method != wantMethod {
|
||||
t.Errorf("method = %q, want %q", got.method, wantMethod)
|
||||
}
|
||||
if got.path != wantPath {
|
||||
t.Errorf("path = %q, want %q", got.path, wantPath)
|
||||
}
|
||||
if !reflect.DeepEqual(got.body, wantBody) {
|
||||
t.Errorf("body = %#v, want %#v", got.body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
|
||||
t.Run("nil runtime", func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
|
||||
if err == nil {
|
||||
t.Fatal("expected nil runtime error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("err = %T/%v, want typed internal error", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
|
||||
t.Run("invalid subscription type "+raw, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid subscription_type error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("invalid subscription_type should carry a hint")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
|
||||
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
})
|
||||
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected partial registration error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on registration error")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"registered subscription_type(s) [INVOLVED_APPROVAL]",
|
||||
"failed subscription_type MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Errorf("partial error message missing %q: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"already registered",
|
||||
"--param subscription_type=MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
|
||||
t.Fatalf("nil cause returned error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
upstream,
|
||||
)
|
||||
if err != upstream {
|
||||
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
|
||||
t.Errorf("message missing failed relation: %q", p.Message)
|
||||
}
|
||||
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
|
||||
cause := errors.New("transport closed")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
cause,
|
||||
)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
|
||||
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
|
||||
t.Errorf("hint missing no-registration context: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
|
||||
out := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_001",
|
||||
"event_type": "approval.instance.status_changed_v4",
|
||||
"create_time": "1710000000000"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_001",
|
||||
"instance_code": "instance_code_001",
|
||||
"external_id": "external_001",
|
||||
"status": "PENDING",
|
||||
"operate_time": "1666079207003",
|
||||
"start_user": {
|
||||
"open_id": "ou_start",
|
||||
"union_id": "on_start",
|
||||
"user_id": "user_start"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
|
||||
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
|
||||
}
|
||||
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
|
||||
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
|
||||
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
|
||||
out := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_001",
|
||||
"event_type": "approval.task.status_changed_v4",
|
||||
"create_time": "1710000000001"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_002",
|
||||
"instance_code": "instance_code_002",
|
||||
"task_id": "task_001",
|
||||
"external_id": "external_002",
|
||||
"task_external_id": "task_external_001",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207004",
|
||||
"assigned_user": {
|
||||
"open_id": "ou_assignee",
|
||||
"union_id": "on_assignee",
|
||||
"user_id": "user_assignee"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
|
||||
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
|
||||
}
|
||||
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
|
||||
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
|
||||
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
|
||||
instance := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_fallback",
|
||||
"create_time": "1710000000002"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207005"
|
||||
}
|
||||
}`)
|
||||
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
|
||||
task := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_fallback",
|
||||
"create_time": "1710000000003"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"task_id": "task_fallback",
|
||||
"status": "DONE",
|
||||
"operate_time": "1666079207006"
|
||||
}
|
||||
}`)
|
||||
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
|
||||
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", processApprovalInstanceStatusChanged},
|
||||
{"task", processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.process(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process nil raw returned error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("Process nil raw output = %s, want nil", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalInstanceStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalTaskStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestApprovalKeysRegisterCleanly(t *testing.T) {
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
}
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
42
events/approval/types.go
Normal file
42
events/approval/types.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
// ApprovalUserID identifies a user in the three Lark ID formats included by
|
||||
// approval status-change events.
|
||||
type ApprovalUserID struct {
|
||||
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
|
||||
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
|
||||
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
|
||||
}
|
||||
|
||||
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
|
||||
// approval.instance.status_changed_v4.
|
||||
type ApprovalInstanceStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
|
||||
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
|
||||
}
|
||||
|
||||
// ApprovalTaskStatusChangedV4Output is the flattened shape for
|
||||
// approval.task.status_changed_v4.
|
||||
type ApprovalTaskStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
|
||||
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
|
||||
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
|
||||
@@ -69,19 +69,17 @@ lark-cli approval approvals get \
|
||||
|---|---|---|
|
||||
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
|
||||
| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 |
|
||||
| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 |
|
||||
| `form` | 否 | 表单值,**JSON 数组字符串**,不是普通对象;API 层非必填,但审批定义存在必填控件或用户需要提交表单值时必须传 |
|
||||
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
|
||||
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
|
||||
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
|
||||
| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
|
||||
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
### 4. 组装 `form`
|
||||
|
||||
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
`instances create --data.form` 是可选字段;传入时必须是一个 JSON 数组字符串。无表单或无需填写表单值的审批可省略 `form`,但只要审批定义包含需要提交的控件,就必须按控件结构组装后传入。组装原则:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
@@ -173,7 +171,6 @@ lark-cli approval instances create \
|
||||
}
|
||||
]
|
||||
}' \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
--as user \
|
||||
--yes
|
||||
```
|
||||
|
||||
@@ -14,6 +14,9 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user
|
||||
# 只看某个审批定义下我发起的实例
|
||||
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
|
||||
|
||||
# 按发起时间范围筛选(秒级时间戳)
|
||||
lark-cli approval instances initiated --params '{"start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>","page_size":20}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
|
||||
|
||||
@@ -30,6 +33,8 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user --dr
|
||||
|------|------|------|
|
||||
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 |
|
||||
| `start_timestamp` | 否 | 按发起时间筛选,时间范围开始值,秒级时间戳 |
|
||||
| `end_timestamp` | 否 | 按发起时间筛选,时间范围结束值,秒级时间戳 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
@@ -101,6 +106,7 @@ lark-cli approval instances initiated \
|
||||
|
||||
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。
|
||||
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
|
||||
- **按时间排查时使用 `start_timestamp` / `end_timestamp`**:这两个值都是秒级时间戳,用于按发起时间缩小结果范围。
|
||||
- **结果很多时优先 `--format table`**:适合人工快速浏览。
|
||||
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
|
||||
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。
|
||||
|
||||
@@ -14,6 +14,9 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
# 查询已办审批
|
||||
lark-cli approval tasks query --params '{"topic":"2"}' --as user
|
||||
|
||||
# 按任务时间范围筛选(秒级时间戳)
|
||||
lark-cli approval tasks query --params '{"topic":"1","start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>"}' --as user
|
||||
|
||||
# 使用 page_token 翻页
|
||||
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
|
||||
|
||||
@@ -28,6 +31,8 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
|
||||
| `topic` | 是 | 任务分组主题,见下方“topic 枚举” |
|
||||
| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 |
|
||||
| `start_timestamp` | 否 | 按任务时间筛选,时间范围开始值,秒级时间戳 |
|
||||
| `end_timestamp` | 否 | 按任务时间筛选,时间范围结束值,秒级时间戳 |
|
||||
| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` |
|
||||
| `page_size` | 否 | 分页大小 |
|
||||
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
|
||||
@@ -67,10 +72,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
|
||||
| `tasks[].summaries` | 表单摘要字段列表 |
|
||||
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
|
||||
| `tasks[].user_id` | 任务所属用户 ID |
|
||||
| `tasks[].instance_external_id` | 三方审批实例 ID,仅第三方审批实例存在 |
|
||||
| `tasks[].task_external_id` | 三方审批任务 ID,仅第三方审批任务存在 |
|
||||
| `tasks[].link` | 三方审批跳转链接 |
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。
|
||||
- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。
|
||||
- 按时间排查任务时使用 `start_timestamp` / `end_timestamp` 缩小范围;这两个值都是秒级时间戳。
|
||||
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。
|
||||
- 当结果量较大时,优先使用 `--format table` 提升可读性。
|
||||
|
||||
@@ -23,6 +23,12 @@ lark-cli approval tasks rollback \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 退回到发起节点(发起节点 ID 为 START)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["START"],"comment":"退回发起人补充材料"}' \
|
||||
--as user \
|
||||
--yes
|
||||
|
||||
# 传多个候选节点 ID(以实际审批定义支持情况为准)
|
||||
lark-cli approval tasks rollback \
|
||||
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
|
||||
@@ -43,7 +49,7 @@ lark-cli approval tasks rollback \
|
||||
| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 |
|
||||
| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 |
|
||||
| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `node_ids` | 是 | 退回目标节点 ID 数组;发起节点 ID 为 `START`;执行前应先确认这些节点确实可作为退回目标 |
|
||||
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` |
|
||||
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
|
||||
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
|
||||
@@ -75,7 +81,7 @@ lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' -
|
||||
## 使用建议
|
||||
|
||||
- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
|
||||
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组;退回发起节点时传 `START`。
|
||||
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
|
||||
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
|
||||
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-event
|
||||
version: 1.0.0
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Approval status changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -147,6 +147,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
||||
|
||||
| Topic | Reference | Coverage |
|
||||
|------------|------------------------------------------------------------------------------|---|
|
||||
| Approval | [`references/lark-event-approval.md`](references/lark-event-approval.md) | Catalog of 2 Approval EventKeys (`approval.instance.status_changed_v4`, `approval.task.status_changed_v4`) + optional/multi `subscription_type` pre-registration + user-auth subscription lifecycle + flat output field reference |
|
||||
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
|
||||
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
|
||||
170
skills/lark-event/references/lark-event-approval.md
Normal file
170
skills/lark-event/references/lark-event-approval.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Approval Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (2)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `approval.instance.status_changed_v4` | An approval instance status changed |
|
||||
| `approval.task.status_changed_v4` | An approval task status changed |
|
||||
|
||||
Both keys use a **Custom schema**. The raw Lark schema 2.0 envelope is flattened: event metadata is exposed as `type`, `event_id`, and `timestamp`, while approval business fields are exposed at the top level.
|
||||
|
||||
Both keys carry a **PreConsume hook** that subscribes the current authorized user through the Approval subscription APIs before listening. The consumer intentionally does **not** unsubscribe on exit; the server-side Approval subscription relation remains until it is canceled outside `event consume`. These keys require `--as user`.
|
||||
|
||||
## Listener and subscription selection
|
||||
|
||||
At the raw CLI level, each `event consume` process accepts exactly one EventKey. `approval.instance.status_changed_v4` and `approval.task.status_changed_v4` have different output shapes, so listening to both still means two processes.
|
||||
|
||||
For Approval only, `subscription_type` is an optional setup param used by PreConsume to register server-side Approval subscription relations before the local listener starts. It is **not** an output field, a local event filter, or a local subscription identity. The pushed event does not say which subscription relation caused delivery, and one business event can match both relations; deduplicate with `event_id` when needed.
|
||||
|
||||
`subscription_type` may be omitted, a single value, a comma-separated list, or a JSON string array:
|
||||
|
||||
```bash
|
||||
# Omitted: register both INVOLVED_APPROVAL and MANAGED_APPROVAL for this EventKey
|
||||
lark-cli event consume approval.instance.status_changed_v4 --as user
|
||||
|
||||
# Single relation
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Explicit multi-relation registration for one local consumer
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# JSON array form; quote it for the shell
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p 'subscription_type=["INVOLVED_APPROVAL","MANAGED_APPROVAL"]' \
|
||||
--as user
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `INVOLVED_APPROVAL` | Receive events where the current user is the approval requester or approver |
|
||||
| `MANAGED_APPROVAL` | Receive events under approval definitions managed by the current user |
|
||||
|
||||
User-intent inference:
|
||||
|
||||
| User intent | EventKey(s) | `subscription_type` |
|
||||
|---|---|---|
|
||||
| Mentions approval instances, approval forms, approval order/status, or "instance status" | `approval.instance.status_changed_v4` | infer from relation words below |
|
||||
| Mentions approval tasks, approval todo items, approver operations, or "task status" | `approval.task.status_changed_v4` | infer from relation words below |
|
||||
| Says "approval status changes/events" without saying task vs instance | both EventKeys | infer from relation words below |
|
||||
| Says "my approvals", "approvals involving me", "I requested/approved", "待我审批", "我发起/我参与" | requested EventKey(s) | `INVOLVED_APPROVAL` |
|
||||
| Says "approvals I manage", "managed definitions", "definitions managed by me", "我管理的审批定义" | requested EventKey(s) | `MANAGED_APPROVAL` |
|
||||
| Explicitly asks for both involved and managed, or says "all approval subscriptions" | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type`, or pass both values in one `-p` |
|
||||
| Relation is ambiguous and the user wants broad coverage | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type` so PreConsume registers both |
|
||||
|
||||
If the user's wording omits the relation and broad listening is acceptable, omit `subscription_type`. Ask only when registering both relations would be materially harmful.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `approval.instance.status_changed_v4` | `approval:instance:read` | user |
|
||||
| `approval.task.status_changed_v4` | `approval:task:read` | user |
|
||||
|
||||
## Subscription behavior
|
||||
|
||||
Startup calls the endpoint for the selected EventKey:
|
||||
|
||||
```text
|
||||
POST /open-apis/approval/v4/instances/subscription
|
||||
POST /open-apis/approval/v4/tasks/subscription
|
||||
```
|
||||
|
||||
For each resolved `subscription_type`, PreConsume sends one request body:
|
||||
|
||||
```json
|
||||
{"subscription_type":"INVOLVED_APPROVAL"}
|
||||
```
|
||||
|
||||
If `subscription_type` is omitted, PreConsume sends two registration requests for that EventKey: one with `INVOLVED_APPROVAL`, then one with `MANAGED_APPROVAL`. If listening to both instance and task events, run two consumers; each consumer may omit `subscription_type` to register both relations for its own EventKey.
|
||||
|
||||
Do not start two consumers for the same Approval EventKey merely to split `INVOLVED_APPROVAL` and `MANAGED_APPROVAL`. The server push and flattened output are keyed by EventKey and cannot be distinguished by subscription relation.
|
||||
|
||||
Shutdown behavior:
|
||||
|
||||
`event consume` does not call the Approval unsubscribe APIs when it exits. This applies to graceful exit, Ctrl+C / SIGTERM, stdin EOF, `--timeout`, and `--max-events`.
|
||||
|
||||
To stop future delivery for a user, cancel the Approval subscription relation outside this consumer. The unsubscribe APIs are separate operations and are not called by `event consume`.
|
||||
|
||||
## Output fields
|
||||
|
||||
Common fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type |
|
||||
| `event_id` | string | Globally unique event ID; use for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time in milliseconds, taken from `header.create_time` |
|
||||
|
||||
Instance event fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
||||
| `instance_code` | string | Approval instance code |
|
||||
| `external_id` | string | Third-party approval instance id, when present |
|
||||
| `status` | string enum | `PENDING`, `APPROVED`, `REJECTED`, `CANCELED`, `DELETED`, `REVERTED`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
||||
| `operate_time` | string (timestamp_ms) | Status change time |
|
||||
| `start_user` | object | Instance starter user IDs, omitted when unavailable |
|
||||
| `start_user.open_id` | string (open_id) | Instance starter open_id, when present |
|
||||
| `start_user.union_id` | string (union_id) | Instance starter union_id, when present |
|
||||
| `start_user.user_id` | string (user_id) | Instance starter tenant user_id, when present |
|
||||
|
||||
Task event fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `approval_code` | string | Approval definition code; not a subscription dimension |
|
||||
| `instance_code` | string | Approval instance code |
|
||||
| `task_id` | string | Approval task id |
|
||||
| `external_id` | string | Third-party approval external id, when present |
|
||||
| `task_external_id` | string | Third-party task external id, when emitted |
|
||||
| `assigned_user` | object | Task assignee or operator user IDs, omitted for automatic flows without an operator |
|
||||
| `assigned_user.open_id` | string (open_id) | Task assignee or operator open_id, when present |
|
||||
| `assigned_user.union_id` | string (union_id) | Task assignee or operator union_id, when present |
|
||||
| `assigned_user.user_id` | string (user_id) | Task assignee or operator tenant user_id, when present |
|
||||
| `status` | string enum | `REVERTED`, `PENDING`, `APPROVED`, `REJECTED`, `TRANSFERRED`, `ROLLBACK`, `DONE`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
|
||||
| `operate_time` | string (timestamp_ms) | Status change time |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Stream approval instance updates broadly; registers both involved and managed relations
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
--as user
|
||||
|
||||
# Stream approval instance updates only for approvals involving the current user
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Stream approval task updates for definitions managed by the current user
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=MANAGED_APPROVAL \
|
||||
--as user
|
||||
|
||||
# Broad approval status listening:
|
||||
# run both EventKeys as separate processes; omit subscription_type so each registers both relations.
|
||||
lark-cli event consume approval.instance.status_changed_v4 \
|
||||
--as user > approval-instance.ndjson &
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
--as user > approval-task.ndjson &
|
||||
wait
|
||||
|
||||
# Listen to both involved and managed task subscriptions with one local consumer.
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
|
||||
--as user > approval-task.ndjson
|
||||
|
||||
# Project a compact approval-task record
|
||||
lark-cli event consume approval.task.status_changed_v4 \
|
||||
-p subscription_type=INVOLVED_APPROVAL \
|
||||
--as user \
|
||||
--jq '{event_id, task_id, status, at: .operate_time}'
|
||||
```
|
||||
Reference in New Issue
Block a user