Skip to content

Commit 4dd44f9

Browse files
qinxiaoyunSunPeiYang996
authored andcommitted
feat(events): add whiteboard event domain with per-board subscription (#1265)
Wire the board.whiteboard.updated_v1 EventKey into the consume pipeline so that lark-cli event consume automatically calls the per-whiteboard subscribe / unsubscribe OAPIs instead of requiring callers to manage server-side subscriptions out-of-band. Change-Id: I94323807e8dc649d3296f6922311d2acaf92284e
1 parent 65d9b2a commit 4dd44f9

7 files changed

Lines changed: 393 additions & 6 deletions

File tree

events/register.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/larksuite/cli/events/im"
99
"github.com/larksuite/cli/events/minutes"
1010
"github.com/larksuite/cli/events/vc"
11+
"github.com/larksuite/cli/events/whiteboard"
1112
"github.com/larksuite/cli/internal/event"
1213
)
1314

@@ -17,6 +18,7 @@ func init() {
1718
im.Keys(),
1819
minutes.Keys(),
1920
vc.Keys(),
21+
whiteboard.Keys(),
2022
}
2123
for _, keys := range all {
2224
for _, k := range keys {

events/whiteboard/native.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package whiteboard
5+
6+
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
7+
type BoardWhiteboardUpdatedV1Data struct {
8+
// WhiteboardID is the id of the whiteboard whose content was updated.
9+
WhiteboardID string `json:"whiteboard_id"`
10+
// OperatorIDs lists the operators that produced this update batch.
11+
OperatorIDs []OperatorID `json:"operator_ids"`
12+
}
13+
14+
// OperatorID identifies an operator that produced the whiteboard update,
15+
// expressed in the three Lark identity formats.
16+
type OperatorID struct {
17+
// OpenID is the operator's open_id within the current app.
18+
OpenID string `json:"open_id"`
19+
// UnionID is the operator's union_id across apps under the same ISV.
20+
UnionID string `json:"union_id"`
21+
// UserID is the operator's user_id within the tenant.
22+
UserID string `json:"user_id"`
23+
}

events/whiteboard/preconsume.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package whiteboard
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"time"
10+
11+
"github.com/larksuite/cli/internal/event"
12+
"github.com/larksuite/cli/internal/validate"
13+
)
14+
15+
// cleanupTimeout bounds how long the unsubscribe call has to finish during
16+
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
17+
const cleanupTimeout = 5 * time.Second
18+
19+
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
20+
// and returns a cleanup that invokes the matching unsubscribe.
21+
//
22+
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
23+
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
24+
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
25+
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
26+
if rt == nil {
27+
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
28+
}
29+
whiteboardID := params["whiteboard_id"]
30+
if whiteboardID == "" {
31+
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
32+
}
33+
encoded := validate.EncodePathSegment(whiteboardID)
34+
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
35+
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
36+
37+
body := map[string]string{"event_type": eventType}
38+
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
39+
return nil, err
40+
}
41+
42+
return func() {
43+
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
44+
defer cancel()
45+
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
46+
}, nil
47+
}
48+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package whiteboard
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"errors"
10+
"strings"
11+
"sync"
12+
"testing"
13+
14+
"github.com/larksuite/cli/internal/event"
15+
)
16+
17+
// recordedCall captures a single APIClient invocation for assertion.
18+
type recordedCall struct {
19+
method string
20+
path string
21+
body interface{}
22+
}
23+
24+
// fakeAPIClient is a minimal event.APIClient stub that records calls and
25+
// can be configured to fail when the request path matches errOnPath.
26+
type fakeAPIClient struct {
27+
mu sync.Mutex
28+
calls []recordedCall
29+
errOnPath string
30+
}
31+
32+
// CallAPI records the invocation and optionally returns a simulated error
33+
// when the path contains the configured errOnPath substring.
34+
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
35+
f.mu.Lock()
36+
defer f.mu.Unlock()
37+
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
38+
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
39+
return nil, errors.New("simulated subscribe failure")
40+
}
41+
return json.RawMessage(`{}`), nil
42+
}
43+
44+
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
45+
// PreConsume hook fails fast with an actionable error when whiteboard_id
46+
// is absent from the params map.
47+
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
48+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
49+
50+
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
51+
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
52+
if err == nil {
53+
t.Fatalf("expected error when whiteboard_id missing")
54+
}
55+
if cleanup != nil {
56+
t.Fatalf("expected nil cleanup on error")
57+
}
58+
if !strings.Contains(err.Error(), "whiteboard_id") {
59+
t.Fatalf("error should mention whiteboard_id, got: %v", err)
60+
}
61+
}
62+
63+
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
64+
// returns an error when the runtime APIClient dependency is missing.
65+
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
66+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
67+
68+
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
69+
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
70+
if err == nil {
71+
t.Fatalf("expected error when runtime client is nil")
72+
}
73+
}
74+
75+
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
76+
// failed subscribe call surfaces the error and skips registering a cleanup,
77+
// so no spurious unsubscribe is invoked.
78+
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
79+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
80+
81+
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
82+
rt := &fakeAPIClient{errOnPath: "/subscribe"}
83+
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
84+
if err == nil {
85+
t.Fatalf("expected error from subscribe call")
86+
}
87+
if cleanup != nil {
88+
t.Fatalf("expected nil cleanup when subscribe fails")
89+
}
90+
// only the failed subscribe call should have been made; no unsubscribe.
91+
if len(rt.calls) != 1 {
92+
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
93+
}
94+
}
95+
96+
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
97+
// happy-path: subscribe is called once with the correct method/path/body,
98+
// and the returned cleanup invokes the matching unsubscribe.
99+
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
100+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
101+
102+
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
103+
rt := &fakeAPIClient{}
104+
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
105+
if err != nil {
106+
t.Fatalf("unexpected error: %v", err)
107+
}
108+
if cleanup == nil {
109+
t.Fatalf("expected non-nil cleanup")
110+
}
111+
112+
if len(rt.calls) != 1 {
113+
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
114+
}
115+
got := rt.calls[0]
116+
if got.method != "POST" {
117+
t.Errorf("subscribe method: got %q, want POST", got.method)
118+
}
119+
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
120+
if got.path != wantSubPath {
121+
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
122+
}
123+
body, _ := got.body.(map[string]string)
124+
if body["event_type"] != eventTypeWhiteboardUpdated {
125+
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
126+
}
127+
128+
cleanup()
129+
if len(rt.calls) != 2 {
130+
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
131+
}
132+
got2 := rt.calls[1]
133+
if got2.method != "POST" {
134+
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
135+
}
136+
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
137+
if got2.path != wantUnsubPath {
138+
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
139+
}
140+
body2, _ := got2.body.(map[string]string)
141+
if body2["event_type"] != eventTypeWhiteboardUpdated {
142+
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
143+
}
144+
}
145+
146+
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
147+
// whiteboard_id values containing reserved URL characters are properly
148+
// path-segment encoded so they cannot escape into adjacent path segments.
149+
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
150+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
151+
152+
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
153+
rt := &fakeAPIClient{}
154+
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
155+
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
156+
if err != nil {
157+
t.Fatalf("unexpected error: %v", err)
158+
}
159+
if len(rt.calls) != 1 {
160+
t.Fatalf("expected 1 call, got %d", len(rt.calls))
161+
}
162+
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
163+
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
164+
}
165+
}
166+
167+
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
168+
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
169+
// required whiteboard_id parameter.
170+
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
171+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
172+
173+
keys := Keys()
174+
for _, k := range keys {
175+
if k.Key == eventTypeWhiteboardUpdated {
176+
if k.PreConsume == nil {
177+
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
178+
}
179+
if len(k.Params) == 0 {
180+
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
181+
}
182+
var found bool
183+
for _, p := range k.Params {
184+
if p.Name == "whiteboard_id" && p.Required {
185+
found = true
186+
}
187+
}
188+
if !found {
189+
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
190+
}
191+
return
192+
}
193+
}
194+
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
195+
}
196+
197+
// 确保 event.APIClient 接口与本测试 mock 一致。
198+
var _ event.APIClient = (*fakeAPIClient)(nil)

events/whiteboard/register.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
// Package whiteboard registers Board-domain EventKeys.
5+
package whiteboard
6+
7+
import (
8+
"reflect"
9+
10+
"github.com/larksuite/cli/internal/event"
11+
"github.com/larksuite/cli/internal/event/schemas"
12+
)
13+
14+
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
15+
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
16+
17+
// Keys returns all Board-domain EventKey definitions.
18+
func Keys() []event.KeyDefinition {
19+
return []event.KeyDefinition{
20+
{
21+
Key: eventTypeWhiteboardUpdated,
22+
DisplayName: "Whiteboard updated",
23+
Description: "Pushed when the whiteboard content is updated.",
24+
EventType: eventTypeWhiteboardUpdated,
25+
Params: []event.ParamDef{
26+
{
27+
Name: "whiteboard_id",
28+
Type: event.ParamString,
29+
Required: true,
30+
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
31+
},
32+
},
33+
Schema: event.SchemaDef{
34+
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
35+
FieldOverrides: map[string]schemas.FieldMeta{
36+
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
37+
"/event/operator_ids/*/open_id": {Kind: "open_id"},
38+
"/event/operator_ids/*/union_id": {Kind: "union_id"},
39+
"/event/operator_ids/*/user_id": {Kind: "user_id"},
40+
},
41+
},
42+
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
43+
Scopes: []string{"board:whiteboard:node:read"},
44+
AuthTypes: []string{"user", "bot"},
45+
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
46+
},
47+
}
48+
}

skills/lark-event/SKILL.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: lark-event
33
version: 1.0.0
4-
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, VC meeting ended, Minutes generated, 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."
4+
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, VC meeting 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."
55
metadata:
66
requires:
77
bins: ["lark-cli"]
@@ -140,8 +140,9 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
140140

141141
## Topic index
142142

143-
| Topic | Reference | Coverage |
144-
|---|---|---|
145-
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 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) |
146-
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
147-
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
143+
| Topic | Reference | Coverage |
144+
|------------|------------------------------------------------------------------------------|---|
145+
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 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) |
146+
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
147+
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
148+
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |

0 commit comments

Comments
 (0)