-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(events): subscribe board.whiteboard.updated_v1 #1265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package whiteboard | ||
|
|
||
| // BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload. | ||
| type BoardWhiteboardUpdatedV1Data struct { | ||
| // WhiteboardID is the id of the whiteboard whose content was updated. | ||
| WhiteboardID string `json:"whiteboard_id"` | ||
| // OperatorIDs lists the operators that produced this update batch. | ||
| OperatorIDs []OperatorID `json:"operator_ids"` | ||
| } | ||
|
|
||
| // OperatorID identifies an operator that produced the whiteboard update, | ||
| // expressed in the three Lark identity formats. | ||
| type OperatorID struct { | ||
| // OpenID is the operator's open_id within the current app. | ||
| OpenID string `json:"open_id"` | ||
| // UnionID is the operator's union_id across apps under the same ISV. | ||
| UnionID string `json:"union_id"` | ||
| // UserID is the operator's user_id within the tenant. | ||
| UserID string `json:"user_id"` | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package whiteboard | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "time" | ||
|
|
||
| "github.com/larksuite/cli/internal/event" | ||
| "github.com/larksuite/cli/internal/validate" | ||
| ) | ||
|
|
||
| // cleanupTimeout bounds how long the unsubscribe call has to finish during | ||
| // PreConsume cleanup so a stuck OAPI cannot block process shutdown. | ||
| const cleanupTimeout = 5 * time.Second | ||
|
|
||
| // whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI | ||
| // and returns a cleanup that invokes the matching unsubscribe. | ||
| // | ||
| // board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id), | ||
| // so the path contains a :whiteboard_id placeholder that must be supplied via params. | ||
| func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) { | ||
| return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) { | ||
| if rt == nil { | ||
| return nil, fmt.Errorf("runtime API client is required for pre-consume subscription") | ||
| } | ||
| whiteboardID := params["whiteboard_id"] | ||
| if whiteboardID == "" { | ||
| return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType) | ||
| } | ||
| encoded := validate.EncodePathSegment(whiteboardID) | ||
| subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded) | ||
| unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded) | ||
|
|
||
| body := map[string]string{"event_type": eventType} | ||
| if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return func() { | ||
| cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) | ||
| defer cancel() | ||
| _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) | ||
| }, nil | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package whiteboard | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "strings" | ||
| "sync" | ||
| "testing" | ||
|
|
||
| "github.com/larksuite/cli/internal/event" | ||
| ) | ||
|
|
||
| // recordedCall captures a single APIClient invocation for assertion. | ||
| type recordedCall struct { | ||
| method string | ||
| path string | ||
| body interface{} | ||
| } | ||
|
|
||
| // fakeAPIClient is a minimal event.APIClient stub that records calls and | ||
| // can be configured to fail when the request path matches errOnPath. | ||
| type fakeAPIClient struct { | ||
| mu sync.Mutex | ||
| calls []recordedCall | ||
| errOnPath string | ||
| } | ||
|
|
||
| // CallAPI records the invocation and optionally returns a simulated error | ||
| // when the path contains the configured errOnPath substring. | ||
| func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) { | ||
| f.mu.Lock() | ||
| defer f.mu.Unlock() | ||
| f.calls = append(f.calls, recordedCall{method: method, path: path, body: body}) | ||
| if f.errOnPath != "" && strings.Contains(path, f.errOnPath) { | ||
| return nil, errors.New("simulated subscribe failure") | ||
| } | ||
| return json.RawMessage(`{}`), nil | ||
| } | ||
|
|
||
| // TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the | ||
| // PreConsume hook fails fast with an actionable error when whiteboard_id | ||
| // is absent from the params map. | ||
| func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) { | ||
| t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) | ||
|
|
||
| pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated) | ||
| cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{}) | ||
| if err == nil { | ||
| t.Fatalf("expected error when whiteboard_id missing") | ||
| } | ||
| if cleanup != nil { | ||
| t.Fatalf("expected nil cleanup on error") | ||
| } | ||
| if !strings.Contains(err.Error(), "whiteboard_id") { | ||
| t.Fatalf("error should mention whiteboard_id, got: %v", err) | ||
| } | ||
| } | ||
|
|
||
| // TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume | ||
| // returns an error when the runtime APIClient dependency is missing. | ||
| func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) { | ||
| t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) | ||
|
|
||
| pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated) | ||
| _, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"}) | ||
| if err == nil { | ||
| t.Fatalf("expected error when runtime client is nil") | ||
| } | ||
| } | ||
|
|
||
| // TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a | ||
| // failed subscribe call surfaces the error and skips registering a cleanup, | ||
| // so no spurious unsubscribe is invoked. | ||
| func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) { | ||
| t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) | ||
|
|
||
| pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated) | ||
| rt := &fakeAPIClient{errOnPath: "/subscribe"} | ||
| cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"}) | ||
| if err == nil { | ||
| t.Fatalf("expected error from subscribe call") | ||
| } | ||
| if cleanup != nil { | ||
| t.Fatalf("expected nil cleanup when subscribe fails") | ||
| } | ||
| // only the failed subscribe call should have been made; no unsubscribe. | ||
| if len(rt.calls) != 1 { | ||
| t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls)) | ||
| } | ||
| } | ||
|
|
||
| // TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full | ||
| // happy-path: subscribe is called once with the correct method/path/body, | ||
| // and the returned cleanup invokes the matching unsubscribe. | ||
| func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) { | ||
| t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) | ||
|
|
||
| pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated) | ||
| rt := &fakeAPIClient{} | ||
| cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if cleanup == nil { | ||
| t.Fatalf("expected non-nil cleanup") | ||
| } | ||
|
|
||
| if len(rt.calls) != 1 { | ||
| t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls)) | ||
| } | ||
| got := rt.calls[0] | ||
| if got.method != "POST" { | ||
| t.Errorf("subscribe method: got %q, want POST", got.method) | ||
| } | ||
| wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe" | ||
| if got.path != wantSubPath { | ||
| t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath) | ||
| } | ||
| body, _ := got.body.(map[string]string) | ||
| if body["event_type"] != eventTypeWhiteboardUpdated { | ||
| t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated) | ||
| } | ||
|
|
||
| cleanup() | ||
| if len(rt.calls) != 2 { | ||
| t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls)) | ||
| } | ||
| got2 := rt.calls[1] | ||
| if got2.method != "POST" { | ||
| t.Errorf("unsubscribe method: got %q, want POST", got2.method) | ||
| } | ||
| wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe" | ||
| if got2.path != wantUnsubPath { | ||
| t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath) | ||
| } | ||
| body2, _ := got2.body.(map[string]string) | ||
| if body2["event_type"] != eventTypeWhiteboardUpdated { | ||
| t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated) | ||
| } | ||
| } | ||
|
|
||
| // TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that | ||
| // whiteboard_id values containing reserved URL characters are properly | ||
| // path-segment encoded so they cannot escape into adjacent path segments. | ||
| func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) { | ||
| t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) | ||
|
|
||
| pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated) | ||
| rt := &fakeAPIClient{} | ||
| // 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。 | ||
| _, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"}) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if len(rt.calls) != 1 { | ||
| t.Fatalf("expected 1 call, got %d", len(rt.calls)) | ||
| } | ||
| if strings.Contains(rt.calls[0].path, "wb/1?evil") { | ||
| t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path) | ||
| } | ||
| } | ||
|
|
||
| // TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for | ||
| // board.whiteboard.updated_v1 wires the PreConsume hook and declares the | ||
| // required whiteboard_id parameter. | ||
| func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) { | ||
| t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) | ||
|
|
||
| keys := Keys() | ||
| for _, k := range keys { | ||
| if k.Key == eventTypeWhiteboardUpdated { | ||
| if k.PreConsume == nil { | ||
| t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated) | ||
| } | ||
| if len(k.Params) == 0 { | ||
| t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated) | ||
| } | ||
| var found bool | ||
| for _, p := range k.Params { | ||
| if p.Name == "whiteboard_id" && p.Required { | ||
| found = true | ||
| } | ||
| } | ||
| if !found { | ||
| t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated) | ||
| } | ||
| return | ||
| } | ||
| } | ||
| t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated) | ||
| } | ||
|
|
||
| // 确保 event.APIClient 接口与本测试 mock 一致。 | ||
| var _ event.APIClient = (*fakeAPIClient)(nil) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| // Package whiteboard registers Board-domain EventKeys. | ||
| package whiteboard | ||
|
|
||
| import ( | ||
| "reflect" | ||
|
|
||
| "github.com/larksuite/cli/internal/event" | ||
| "github.com/larksuite/cli/internal/event/schemas" | ||
| ) | ||
|
|
||
| // eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates. | ||
| const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1" | ||
|
|
||
| // Keys returns all Board-domain EventKey definitions. | ||
| func Keys() []event.KeyDefinition { | ||
| return []event.KeyDefinition{ | ||
| { | ||
| Key: eventTypeWhiteboardUpdated, | ||
| DisplayName: "Whiteboard updated", | ||
| Description: "Pushed when the whiteboard content is updated.", | ||
| EventType: eventTypeWhiteboardUpdated, | ||
| Params: []event.ParamDef{ | ||
| { | ||
| Name: "whiteboard_id", | ||
| Type: event.ParamString, | ||
| Required: true, | ||
| Description: "Whiteboard id to subscribe; subscription is per-whiteboard.", | ||
| }, | ||
| }, | ||
| Schema: event.SchemaDef{ | ||
| Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})}, | ||
| FieldOverrides: map[string]schemas.FieldMeta{ | ||
| "/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"}, | ||
| "/event/operator_ids/*/open_id": {Kind: "open_id"}, | ||
| "/event/operator_ids/*/union_id": {Kind: "union_id"}, | ||
| "/event/operator_ids/*/user_id": {Kind: "user_id"}, | ||
| }, | ||
| }, | ||
| PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated), | ||
| Scopes: []string{"board:whiteboard:node:read"}, | ||
| AuthTypes: []string{"user", "bot"}, | ||
| RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated}, | ||
| }, | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.